Compare commits
282 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 448854b3cd | |||
| 80370b51cd | |||
| 2e8304e851 | |||
| 7bfbb17479 | |||
| 397179d550 | |||
| 926b31de78 | |||
| ea6b63de53 | |||
| 879ba5c37f | |||
| f633612207 | |||
| 8ba76a60e7 | |||
| b6b8145901 | |||
| cdc9f927b5 | |||
| 4d57e4e6ed | |||
| 1b8d6e50e2 | |||
| e3e13563d5 | |||
| ee3da3893c | |||
| 91145c38fb | |||
| 1dd0270f4f | |||
| 77256462c5 | |||
| ae071fefaa | |||
| 152d35f92a | |||
| 8dd278f47c | |||
| 045d6983dc | |||
| 2f8861be62 | |||
| 6dbe9c0ebb | |||
| 45df132dc6 | |||
| c42a285f0b | |||
| 1e3211ae74 | |||
| ec507b05d6 | |||
| 339bb1afac | |||
| c441012e02 | |||
| 0d61278c56 | |||
| ffd06ab561 | |||
| eb8dd330b6 | |||
| 6267e52bdf | |||
| ab21008f34 | |||
| 0998639d59 | |||
| eccde07d06 | |||
| 770cdc5f13 | |||
| 6bafb62414 | |||
| 6ce0fbbbe6 | |||
| 8fe42e6f22 | |||
| 47a6209730 | |||
| 24d3f867f8 | |||
| 9db60374e4 | |||
| 8ef4b21dab | |||
| 8f56812dd1 | |||
| 3833cb093d | |||
| 94db65b85e | |||
| 6f731e48d2 | |||
| 99fe0e543c | |||
| c6b0799b2a | |||
| 861f2286db | |||
| 9af3e3b2e9 | |||
| 341c1b45b2 | |||
| 89f5d8cdf5 | |||
| ca3270437d | |||
| bbbc6f7363 | |||
| 8a0abacf6f | |||
| 976ccdabd4 | |||
| 99b26680b6 | |||
| c5be477855 | |||
| 32c1501e9c | |||
| 463837e7d4 | |||
| d74f142cdd | |||
| 53954aae89 | |||
| 24aa62a503 | |||
| 2618bb9c63 | |||
| 32a31045ef | |||
| 56edad77a8 | |||
| fdb604e350 | |||
| 3c66dfd83c | |||
| 81633b0a1e | |||
| 4a967de184 | |||
| 59961cbdb5 | |||
| 95d9d8bf23 | |||
| 2fd9741a2b | |||
| fe9c325580 | |||
| 61e93d4071 | |||
| 1e4a4e43dc | |||
| e1a7b051bd | |||
| 7a7af58f5c | |||
| 016ae86d50 | |||
| 2bff060a5e | |||
| 68231504d0 | |||
| 0658a8ee44 | |||
| 43fb3d35e6 | |||
| 4cc1cc95ca | |||
| 964ef441ec | |||
| 796f37d320 | |||
| b46fd94578 | |||
| bdc8e75640 | |||
| ef08821796 | |||
| 9f386f6968 | |||
| ec0b6a99e2 | |||
| f6d9e52c6e | |||
| 90f86b833d | |||
| 29bb33c26c | |||
| c740bd21d4 | |||
| 1d92709c76 | |||
| a42e1df1a7 | |||
| e33beee17d | |||
| b10ea04cb3 | |||
| e8c94177ca | |||
| f1f2083c88 | |||
| f42889c3c2 | |||
| a75e1f96eb | |||
| 85c5293082 | |||
| 37efa6a62c | |||
| 1d5f91fb6c | |||
| ef18655776 | |||
| b786e858d9 | |||
| f4ebc4e99e | |||
| 65ca8a7fd8 | |||
| 7f1e98dcb2 | |||
| 4c19ee823b | |||
| 8e2dd8b278 | |||
| 8d35b3aad2 | |||
| 613cad31c0 | |||
| 3779a90f26 | |||
| 7470f28f31 | |||
| 17fb4e780b | |||
| 30c2a6ef79 | |||
| 0547e9513f | |||
| 70e5172f1b | |||
| 61c568a112 | |||
| ae2ba6f44d | |||
| f84006fbe4 | |||
| fed34a2747 | |||
| 80df16f97b | |||
| 18cb245599 | |||
| fd6cc84be6 | |||
| 9311cab3b2 | |||
| fceccf47be | |||
| fe20fbfd28 | |||
| 4f3a2a1660 | |||
| 1c8457a4bf | |||
| 8710043a02 | |||
| dc46b42cb6 | |||
| 2f1972e70a | |||
| c5fcf12165 | |||
| 61ed632579 | |||
| 86f4b75c52 | |||
| b26ab916d5 | |||
| c882198206 | |||
| 4aef27ffd5 | |||
| cf4e3f5fc6 | |||
| 57eb919c83 | |||
| 85cfaf2bc9 | |||
| 25a69a8191 | |||
| 6feeb23b1f | |||
| 4b92ffe3c5 | |||
| 823a9c3271 | |||
| fe89df2aa3 | |||
| 97ff8ff802 | |||
| a10a9e7043 | |||
| 4f42abc2ff | |||
| fe042c88b8 | |||
| 55e3a31b61 | |||
| 5760be4313 | |||
| 2fd7556a52 | |||
| e8ed9cd379 | |||
| eeeb3c96d2 | |||
| 2da5dee6bd | |||
| a66193ff45 | |||
| 55131ba7ce | |||
| df6282d2ba | |||
| 6ebe792ce5 | |||
| 6c9bdb2ccd | |||
| bc94c705f3 | |||
| 2b9b4da2cc | |||
| 090070d1f9 | |||
| 16a73f27c9 | |||
| 82245d895c | |||
| 610b8dd171 | |||
| f5b1e91378 | |||
| 1de6d7a874 | |||
| b716f3f792 | |||
| 75053bbbb1 | |||
| f9c7ed4936 | |||
| 1f5be54cb1 | |||
| 0761cdd28f | |||
| 7e2a0e9d5f | |||
| 7ae887561d | |||
| baa1d49b3a | |||
| 58a6be911a | |||
| 368f0b048b | |||
| 10894e17a5 | |||
| ec8a7a40e2 | |||
| ce30820108 | |||
| 147c756cc1 | |||
| c7fb404404 | |||
| 2546146ca8 | |||
| ffa776fd42 | |||
| a59ffb8758 | |||
| 9e74c94871 | |||
| 77294e7f1c | |||
| 57f2f4a619 | |||
| 1df2284ea3 | |||
| 189af077e7 | |||
| 10e4d83bce | |||
| 5d6661f964 | |||
| e6e11bb8f2 | |||
| 0e65e834da | |||
| 19f532c12e | |||
| bfc997ba37 | |||
| 99966a976e | |||
| cd54bc2880 | |||
| ffdd689331 | |||
| af41d81981 | |||
| 10d28ed364 | |||
| b02f4bd53a | |||
| 7ce8e3dbe6 | |||
| 2446d5cdb8 | |||
| d015018a16 | |||
| 6231c75e34 | |||
| 2f3bc6cc6f | |||
| 16c6015919 | |||
| e6b291cc68 | |||
| ae523c1ca6 | |||
| 7c86c1477f | |||
| 71f162f20d | |||
| eeacaca725 | |||
| af52ee25eb | |||
| eef32ca11e | |||
| 1ae821bff8 | |||
| 65483a6ef0 | |||
| 606a9343d9 | |||
| 7dfa6538be | |||
| 476d010ebe | |||
| 96d2efebc8 | |||
| f60f5af424 | |||
| 3da0334083 | |||
| c970038943 | |||
| 4000477bdb | |||
| ba11d53922 | |||
| beef606024 | |||
| 2adf64da55 | |||
| fd3fb8573c | |||
| e0d94d9794 | |||
| 7d049150a0 | |||
| 527ef59adc | |||
| b39775daef | |||
| 4bdb21560a | |||
| 797a9c32aa | |||
| bc864b29f8 | |||
| 482121db5c | |||
| 0fa26c8d0a | |||
| f5c768d6a7 | |||
| c43544734a | |||
| 86d99916f7 | |||
| 135dbc8789 | |||
| fc14de9b0f | |||
| c77197d959 | |||
| 56dddbdd86 | |||
| cbafcf6939 | |||
| 4b156ee699 | |||
| a4e883b09a | |||
| b114a724e2 | |||
| 621c0d839c | |||
| 021c1fc7c4 | |||
| bda91080ab | |||
| a9828be25c | |||
| dde9dbfbfe | |||
| ca7d126a3c | |||
| 7f6450375b | |||
| c9954db3fe | |||
| 3d268f1f9d | |||
| 66a7a2a7af | |||
| 7823e1d803 | |||
| d5e91ce874 | |||
| 6f32c1932f | |||
| cb06c4e954 | |||
| 9188c0a8bc | |||
| 30653fe344 | |||
| 5bb55c453f | |||
| 3024e08ca5 | |||
| aaf1f25167 | |||
| aabbb758a4 | |||
| d824f928b5 | |||
| 445ed27eb8 | |||
| 21f3970ca8 |
@@ -1,5 +1,6 @@
|
||||
--ignore-dir=.svelte-kit
|
||||
--ignore-dir=android
|
||||
--ignore-dir=target
|
||||
--ignore-dir=build
|
||||
--ignore-dir=ios/DerivedData
|
||||
--ignore-dir=ios/App/App/public
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
---
|
||||
name: welshman-app
|
||||
description: "Use this skill when working with @welshman/app: high-level Svelte stores for nostr apps, session management, WoT (web of trust), making requests, publishing events, user data, or relay selection at the app layer."
|
||||
---
|
||||
|
||||
# welshman/app — Application Layer Stores
|
||||
|
||||
`@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 install @welshman/app
|
||||
# or
|
||||
pnpm add @welshman/app
|
||||
yarn add @welshman/app
|
||||
```
|
||||
|
||||
## Key Exports
|
||||
|
||||
### Core Singletons
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `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 |
|
||||
|
||||
### Session Management
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `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` |
|
||||
|
||||
**Login functions** (all call `addSession` internally):
|
||||
|
||||
```typescript
|
||||
loginWithNip01(secret: string): void
|
||||
loginWithNip07(pubkey: string): void
|
||||
loginWithNip46(pubkey, clientSecret, signerPubkey, relays): void
|
||||
loginWithNip55(pubkey: string, signerPackageName: string): void
|
||||
loginWithPomade(pubkey, email, clientOptions): void
|
||||
loginWithPubkey(pubkey: string): void // read-only
|
||||
```
|
||||
|
||||
**Session utilities**:
|
||||
|
||||
```typescript
|
||||
addSession(session: Session): void // add and activate
|
||||
dropSession(pubkey: string): void // remove and clean up signer
|
||||
getSession(pubkey: string): Session | undefined
|
||||
updateSession(pubkey, fn): void
|
||||
clearSessions(): void
|
||||
nip46Perms: string // default NIP-46 permission string
|
||||
```
|
||||
|
||||
**Gift wrap (NIP-59)**:
|
||||
|
||||
```typescript
|
||||
shouldUnwrap: Writable<boolean> // must be true to process incoming wraps
|
||||
wrapManager: WrapManager // tracks wrap↔rumor mappings
|
||||
unwrapAndStore(wrap: SignedEvent): Promise<void>
|
||||
```
|
||||
|
||||
### Publishing — 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` |
|
||||
|
||||
**Thunk status helpers**:
|
||||
|
||||
```typescript
|
||||
thunkIsComplete(thunk): boolean
|
||||
getThunkError(thunk): string | undefined
|
||||
getThunkUrlsWithStatus(statuses, thunk): string[]
|
||||
getCompleteThunkUrls(thunk): string[]
|
||||
getFailedThunkUrls(thunk): string[]
|
||||
waitForThunkError(thunk): Promise<string>
|
||||
waitForThunkCompletion(thunk): Promise<void>
|
||||
```
|
||||
|
||||
`ThunkOptions`:
|
||||
```typescript
|
||||
type ThunkOptions = {
|
||||
event: EventTemplate // unsigned — will be signed lazily
|
||||
relays: string[]
|
||||
recipient?: string // if set, event is NIP-59 gift-wrapped
|
||||
delay?: number // ms to wait before sending (abort window)
|
||||
pow?: number // proof-of-work difficulty target
|
||||
timeout?: number // ms per relay before marking as timed out
|
||||
// PublishOptions callbacks: onSuccess, onFailure, onPending, onTimeout, onAborted, onComplete
|
||||
}
|
||||
```
|
||||
|
||||
### Commands (Higher-level Thunk Factories)
|
||||
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
### Follow / Mute / Pin Lists
|
||||
|
||||
Each list type follows the same pattern (`follow` shown, `mute` and `pin` are identical):
|
||||
|
||||
```typescript
|
||||
followListsByPubkey: Readable<Map<string, List>>
|
||||
followLists: Readable<List[]>
|
||||
getFollowList(pubkey): List | undefined
|
||||
loadFollowList(pubkey, relayHints?): Promise<void>
|
||||
forceLoadFollowList(pubkey, relayHints?): Promise<void>
|
||||
deriveFollowList(pubkey): Readable<List | undefined>
|
||||
```
|
||||
|
||||
### Relay Lists
|
||||
|
||||
```typescript
|
||||
// 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]
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```tsx
|
||||
import {userProfile, pubkey} from '@welshman/app'
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: welshman-content
|
||||
description: "Use this skill when working with @welshman/content: parsing nostr note content, extracting mentions/links/media/topics, or rendering parsed content to HTML or custom formats."
|
||||
---
|
||||
|
||||
# welshman/content — Note Content Parsing
|
||||
|
||||
`@welshman/content` parses raw nostr event content strings into structured typed elements (links, profiles, events, topics, media, etc.) and renders them back to text or HTML. It is a standalone package with no welshman sibling dependencies — it sits at the bottom of the stack and can be used independently or alongside `@welshman/app`, `@welshman/net`, etc.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/content
|
||||
# or
|
||||
pnpm add @welshman/content
|
||||
yarn add @welshman/content
|
||||
```
|
||||
|
||||
## Key Exports
|
||||
|
||||
### Parsing
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `parse({ content?, tags? })` | Main entry point. Parses a content string (and optional event tags) into a `Parsed[]` array. Falls back to the `alt` tag if content is empty. |
|
||||
| `truncate(content, opts?)` | Truncates a `Parsed[]` array, appending an `Ellipsis` element. Leaves content unchanged if it fits within `maxLength`. |
|
||||
| `reduceLinks(content)` | Collapses consecutive block-level links (each on its own line) into a single `LinkGrid` element for gallery rendering. |
|
||||
| `urlIsMedia(url)` | Returns `true` if the URL has a media file extension (jpg, png, gif, webp, mp4, etc.). |
|
||||
|
||||
### Parsed Types
|
||||
|
||||
`ParsedType` enum values and their corresponding type shapes:
|
||||
|
||||
| `ParsedType` | `value` type | Notes |
|
||||
|---|---|---|
|
||||
| `Text` | `string` | Plain text |
|
||||
| `Newline` | `string` | One or more `\n` characters |
|
||||
| `Topic` | `string` | Hashtag text without the `#`; numeric-only tags are skipped |
|
||||
| `Link` | `{ url: URL, meta: Record<string, string> }` | URLs with any scheme (http, https, ftp, ws, wss, etc.) and bare domains without a protocol; `meta` is populated from `imeta` tags or URL hash params |
|
||||
| `LinkGrid` | `{ links: ParsedLinkValue[] }` | Produced by `reduceLinks`; a collection of adjacent block links |
|
||||
| `Profile` | `ProfilePointer` (`{ pubkey, relays? }`) | nostr:npub / nostr:nprofile / @nostr:npub / @nostr:nprofile references (the `nostr:` prefix is required) |
|
||||
| `Event` | `EventPointer` (`{ id, relays?, author?, kind? }`) | note / nevent references |
|
||||
| `Address` | `AddressPointer` (`{ identifier, pubkey, kind, relays? }`) | naddr references |
|
||||
| `Emoji` | `{ name: string, url?: string }` | `:shortcode:` — `url` resolved from `emoji` tags |
|
||||
| `Code` | `string` | Backtick inline code or triple-backtick blocks |
|
||||
| `Cashu` | `string` | cashu: token strings |
|
||||
| `Invoice` | `string` | Bare lightning invoice string (without `lightning:` prefix); the `lightning:` prefix is in `raw` |
|
||||
| `Email` | `string` | Email addresses (with or without `mailto:`) |
|
||||
| `Ellipsis` | `string` | Appended by `truncate` to indicate truncated content |
|
||||
|
||||
Every `Parsed` element also has a `raw: string` field holding the original matched text (empty string for synthetic elements like `LinkGrid` and `Ellipsis`).
|
||||
|
||||
### Type Guards
|
||||
|
||||
All guards narrow the union type:
|
||||
|
||||
```
|
||||
isAddress isCashu isCode isEllipsis isEmail
|
||||
isEmoji isEvent isImage isInvoice isLink
|
||||
isLinkGrid isNewline isProfile isText isTopic
|
||||
```
|
||||
|
||||
`isImage(parsed)` — special guard: true only for `ParsedLink` elements whose URL ends in `.jpg/.jpeg/.png/.gif/.webp`.
|
||||
|
||||
### Rendering
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `renderAsText(parsed, options?)` | Renders `Parsed \| Parsed[]` to a `Renderer`; call `.toString()` to get a string. Text rendering shows links as full raw URLs and entities as their full bech32 string (the `renderEntity` truncation is discarded in text mode since `renderLink` returns the href). |
|
||||
| `renderAsHtml(parsed, options?)` | Same, but produces sanitized HTML with `<a>` tags. Default `entityBase` is `https://njump.me/`. |
|
||||
| `render(parsed, renderer)` | Low-level: renders into an existing `Renderer` instance. |
|
||||
| `makeTextRenderer(options?)` | Creates a `Renderer` pre-configured for text output. |
|
||||
| `makeHtmlRenderer(options?)` | Creates a `Renderer` pre-configured for HTML output. |
|
||||
| `Renderer` | Class with `addText`, `addLink`, `addEntityLink`, `addNewlines`, `toString`. |
|
||||
|
||||
`RenderOptions` fields (all optional when using the convenience functions):
|
||||
|
||||
| Field | Default (HTML) | Description |
|
||||
|---|---|---|
|
||||
| `newline` | `"\n"` | String emitted for each newline character |
|
||||
| `entityBase` | `"https://njump.me/"` | Base URL prepended to bech32 entity strings |
|
||||
| `renderLink(href, display)` | `<a href=... target=_blank>display</a>` | Custom link HTML/text |
|
||||
| `renderEntity(entity)` | `entity.slice(0, 16) + "…"` | Display text for entity links |
|
||||
| `createElement(tag)` | `document.createElement(tag)` | DOM element factory; override for SSR/non-browser |
|
||||
|
||||
Individual per-type render helpers are also exported (`renderText`, `renderLink`, `renderProfile`, `renderEvent`, `renderAddress`, `renderTopic`, `renderEmoji`, `renderCode`, `renderCashu`, `renderInvoice`, `renderEmail`, `renderNewline`, `renderEllipsis`, `renderOne`, `renderMany`).
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Parse and render a note to HTML
|
||||
|
||||
```typescript
|
||||
import { parse, renderAsHtml } from '@welshman/content'
|
||||
|
||||
const event = {
|
||||
content: "Hello #nostr! Check out nostr:npub1jlrs53pkdfjnts29kveljul2sm0actt6n8dxrrzqcersttvcuv3qdjynqn",
|
||||
tags: []
|
||||
}
|
||||
|
||||
const parsed = parse({ content: event.content, tags: event.tags })
|
||||
const html = renderAsHtml(parsed, {
|
||||
entityBase: 'https://njump.me/',
|
||||
renderEntity: (entity) => entity.slice(0, 12) + '…',
|
||||
}).toString()
|
||||
// → 'Hello nostr! Check out <a href="https://njump.me/nprofile1..." target="_blank">nprofile1qqsj…</a>'
|
||||
```
|
||||
|
||||
### Truncate long notes for a feed preview
|
||||
|
||||
```typescript
|
||||
import { parse, truncate, reduceLinks, renderAsHtml } from '@welshman/content'
|
||||
|
||||
const parsed = parse({ content: event.content, tags: event.tags })
|
||||
const withGrids = reduceLinks(parsed)
|
||||
const preview = truncate(withGrids, { minLength: 300, maxLength: 500 })
|
||||
const html = renderAsHtml(preview).toString()
|
||||
```
|
||||
|
||||
### Extract all mentioned pubkeys
|
||||
|
||||
```typescript
|
||||
import { parse, isProfile, isAddress } from '@welshman/content'
|
||||
|
||||
const parsed = parse({ content: event.content, tags: event.tags })
|
||||
|
||||
const pubkeys = parsed
|
||||
.filter(isProfile)
|
||||
.map(p => p.value.pubkey)
|
||||
|
||||
const addressPubkeys = parsed
|
||||
.filter(isAddress)
|
||||
.map(p => p.value.pubkey)
|
||||
```
|
||||
|
||||
### Extract all links and check for images
|
||||
|
||||
```typescript
|
||||
import { parse, isLink, isImage, isLinkGrid } from '@welshman/content'
|
||||
|
||||
const parsed = parse({ content: event.content, tags: event.tags })
|
||||
|
||||
const images = parsed.filter(isImage)
|
||||
// images[0].value.url → URL object
|
||||
// images[0].value.meta → Record<string, string> from imeta tags
|
||||
|
||||
const allLinks = parsed.filter(isLink)
|
||||
```
|
||||
|
||||
### Render with a custom link handler (e.g. Svelte/React)
|
||||
|
||||
```typescript
|
||||
import { parse, renderAsHtml } from '@welshman/content'
|
||||
|
||||
const html = renderAsHtml(parse({ content }), {
|
||||
renderLink: (href, display) =>
|
||||
`<a href="${href}" class="text-blue-500 underline" rel="noopener">${display}</a>`,
|
||||
renderEntity: (entity) => entity.slice(0, 16) + '…',
|
||||
}).toString()
|
||||
```
|
||||
|
||||
### Server-side rendering (no DOM)
|
||||
|
||||
The default `createElement` calls `document.createElement`, which fails in Node/SSR environments. Override it:
|
||||
|
||||
```typescript
|
||||
import { parse, renderAsHtml } from '@welshman/content'
|
||||
import { JSDOM } from 'jsdom'
|
||||
|
||||
const dom = new JSDOM('')
|
||||
|
||||
const html = renderAsHtml(parse({ content }), {
|
||||
createElement: (tag: string) => dom.window.document.createElement(tag),
|
||||
}).toString()
|
||||
```
|
||||
|
||||
### Using custom emoji tags
|
||||
|
||||
```typescript
|
||||
import { parse, isEmoji } from '@welshman/content'
|
||||
|
||||
const tags = [
|
||||
['emoji', 'parrot', 'https://example.com/parrot.gif'],
|
||||
]
|
||||
|
||||
const parsed = parse({ content: 'Hello :parrot:!', tags })
|
||||
|
||||
const emojiElements = parsed.filter(isEmoji)
|
||||
// emojiElements[0].value → { name: 'parrot', url: 'https://example.com/parrot.gif' }
|
||||
```
|
||||
|
||||
## Integration Notes
|
||||
|
||||
- `@welshman/content` has no dependencies on other welshman packages. It depends on `@braintree/sanitize-url` as a direct dependency and requires `nostr-tools` ^2.x as a peer dependency (consumers must install it).
|
||||
- In `@welshman/app`, content parsing is typically done at the component layer. The `parse` function is called with `event.content` and `event.tags` together so that `imeta` and `emoji` tags are resolved.
|
||||
- `ParsedLinkValue.meta` is populated from `imeta` tags (NIP-92). When an event carries rich media metadata, the parsed link's `meta` object will include fields like `url`, `m` (MIME type), `blurhash`, `dim`, etc.
|
||||
- `reduceLinks` should be called after `parse` and before `truncate` if you want link grids to count as single media units for truncation purposes.
|
||||
|
||||
## Gotchas & Tips
|
||||
|
||||
- **`parse` trims content** before processing. Leading/trailing whitespace in the raw content string is dropped.
|
||||
- **`parse` fallback**: if `content` is empty or whitespace, `parse` will use the first `alt` tag value instead. This is useful for kind-1 reposts and other events with alternative text.
|
||||
- **`truncate` is non-destructive** when content is short: it returns the original array unchanged if the total estimated size is under `maxLength`.
|
||||
- **`reduceLinks` requires block-level links**: a link is only pulled into a grid if it appears at the start of a block (i.e. preceded by a newline or at the very beginning). Inline links in the middle of a sentence are left as `ParsedLink`.
|
||||
- **`isImage` is stricter than `urlIsMedia`**: `isImage` only matches `.jpg/.jpeg/.png/.gif/.webp` — it will not match `.mp4` or `.webm`. Use `urlIsMedia` directly if you need to detect video; note that `urlIsMedia` takes a URL string, not a `Parsed` element — usage would be: `urlIsMedia(parsed.value.url.toString())`.
|
||||
- **`Renderer.toString()`** is how you get the final string out. `renderAsHtml` and `renderAsText` both return a `Renderer` instance, not a string.
|
||||
- **`LinkGrid` is not rendered by default renderers**: `renderOne` has no case for `ParsedType.LinkGrid`. You must handle it yourself when building a custom UI (e.g. render each `value.links` entry as an image or card grid).
|
||||
- **Legacy mentions** (`#[0]`, `#[1]`) are parsed automatically from the `tags` array and emitted as `ParsedProfile` or `ParsedEvent` elements.
|
||||
- **Numeric hashtags are skipped**: `#42` will not produce a `Topic` element.
|
||||
- **Email matching** strips a leading `mailto:` — the resulting `ParsedEmail.value` is always the bare address string.
|
||||
@@ -0,0 +1,347 @@
|
||||
---
|
||||
name: welshman-editor
|
||||
description: "Use this skill when working with @welshman/editor: the batteries-included Tiptap-based rich-text editor for composing nostr notes with mention autocomplete, media upload, and inline nostr objects."
|
||||
---
|
||||
|
||||
# welshman/editor — Nostr Editor Component
|
||||
|
||||
`@welshman/editor` provides a batteries-included text editor for composing nostr notes, built on top of [Tiptap](https://tiptap.dev) and [nostr-editor](https://github.com/cesardeazevedo/nostr-editor). It bundles a curated set of extensions that handle nostr-specific concerns (nprofile mentions, nevent/naddr embeds, file upload, Lightning invoices) as well as general composition features (history, placeholder, inline code, word count). It is framework-agnostic — the core is plain TypeScript/DOM, though it powers the Svelte-based editors of Coracle and Flotilla.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/editor
|
||||
# or
|
||||
pnpm add @welshman/editor
|
||||
# or
|
||||
yarn add @welshman/editor
|
||||
```
|
||||
|
||||
Peer dependencies that must be installed separately:
|
||||
|
||||
```bash
|
||||
npm install @welshman/lib @welshman/util nostr-tools nostr-editor
|
||||
```
|
||||
|
||||
Import the bundled CSS to get default object/suggestion styles (optional but recommended):
|
||||
|
||||
```typescript
|
||||
import "@welshman/editor/index.css"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Exports
|
||||
|
||||
### Extensions
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `WelshmanExtension` | The main all-in-one Tiptap extension. Configure once; it registers every sub-extension below. `submit` is **required**. |
|
||||
| `BreakOrSubmit` | Keyboard handler: `Mod-Enter` always submits; `Enter` submits only when `aggressive: true` (chat-style); `Shift-Enter` inserts a hard break. |
|
||||
| `CodeInline` | Inline `code` node with backtick input/paste rules. |
|
||||
| `WordCount` | Extension that tracks `editor.storage.wordCount.words` and `editor.storage.wordCount.chars` on every document update. |
|
||||
|
||||
### Node Views
|
||||
|
||||
These are drop-in Tiptap node-view factory functions that render inline pill elements with `.tiptap-object` CSS class. Override them via `WelshmanExtensionOptions` to render richer UI.
|
||||
|
||||
| Export | Renders |
|
||||
|--------|---------|
|
||||
| `MentionNodeView` | nprofile nodes — shows `@<bech32 prefix>…` |
|
||||
| `MediaNodeView` | Image and video nodes — shows filename or URL; adds `.tiptap-uploading` animation while uploading |
|
||||
| `EventNodeView` | nevent and naddr nodes — shows bech32 prefix |
|
||||
| `Bolt11NodeView` | bolt11 Lightning invoice nodes — shows the first 16 characters of the Lightning invoice string (the `lnbc` attribute) followed by `...` |
|
||||
|
||||
### Plugins
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `TippySuggestion` | Generic Tippy.js-powered `@tiptap/suggestion` wrapper. Requires `char`, `name`, `editor`, `search`, and `select`. Optional: `updateSignal`, `createSuggestion`. |
|
||||
| `MentionSuggestion` | Pre-configured `TippySuggestion` for `@`-triggered nprofile autocomplete. Requires `editor`, `search`, and `getRelays`. Optional: `updateSignal`, `createSuggestion`. |
|
||||
| `DefaultSuggestionsWrapper` | Default dropdown renderer used by `TippySuggestion`. Implements `ISuggestionsWrapper`; replace to use a framework component. |
|
||||
|
||||
**`TippySuggestion` options:**
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `char` | yes | Trigger character (e.g. `"@"`, `"~"`) |
|
||||
| `name` | yes | ProseMirror node type name to insert on selection |
|
||||
| `editor` | yes | The Tiptap `Editor` instance |
|
||||
| `search` | yes | `(term: string) => string[]` — returns item values matching the query |
|
||||
| `select` | yes | `(value: string, props) => void` — called when the user picks an item; call `props.command({...attrs})` to insert the node |
|
||||
| `updateSignal` | no | A Svelte `Readable` store; when it emits, the suggestion list re-renders (use for async/reactive search results) |
|
||||
| `createSuggestion` | no | `(value: string) => Element` — renders a custom DOM element for each dropdown item |
|
||||
|
||||
`MentionSuggestion` is a pre-wired `TippySuggestion` for nprofile nodes. It handles `select` internally (encodes the pubkey as an nprofile with relay hints from `getRelays`) so you only need to supply `editor`, `search`, and `getRelays`.
|
||||
|
||||
### Re-exports from upstream
|
||||
|
||||
| Export | Source |
|
||||
|--------|--------|
|
||||
| `Editor` | `@tiptap/core` — the editor instance class |
|
||||
| `NodeViewProps` | `@tiptap/core` — prop type for node view factories (Tiptap's type) |
|
||||
| `NodeViewRendererProps` | `@tiptap/core` — alternate props type used in `Node.create({ addNodeView })` |
|
||||
| `UploadTask` | `nostr-editor` — shape of an in-progress or completed file upload |
|
||||
| `FileAttributes` | `nostr-editor` — `{ file: File, … }` passed to the `upload` callback |
|
||||
| `editorProps` | `nostr-editor` — base ProseMirror `editorProps` used by nostr-editor; pass directly to `new Editor({ editorProps })` |
|
||||
|
||||
---
|
||||
|
||||
## WelshmanExtensionOptions Reference
|
||||
|
||||
All keys are optional. Pass `false` to disable a built-in extension entirely. Pass `{ extend?, config? }` to override defaults.
|
||||
|
||||
```typescript
|
||||
type WelshmanExtensionOptions = {
|
||||
bolt11?: false
|
||||
breakOrSubmit?: false | { extend?, config?: BreakOrSubmitOptions }
|
||||
codeInline?: false | { extend?, config? }
|
||||
codeBlock?: false | { extend?, config?: CodeBlockOptions }
|
||||
document?: false
|
||||
dropcursor?: false | { extend?, config?: DropcursorOptions }
|
||||
fileUpload?: { extend?: Partial<any>, config?: Partial<FileUploadOptions> & Pick<FileUploadOptions, "upload"> }
|
||||
gapcursor?: false
|
||||
history?: false | { extend?, config?: HistoryOptions }
|
||||
image?: false | { extend?, config?: ImageOptions }
|
||||
link?: false | { extend?, config?: LinkOptions }
|
||||
naddr?: false | { extend?, config? }
|
||||
nevent?: false | { extend?, config? }
|
||||
nprofile?: false | { extend?, config? }
|
||||
nsecReject?: false | { extend?, config?: NSecRejectOptions }
|
||||
paragraph?: false | { extend?, config?: ParagraphOptions }
|
||||
placeholder?: false | { extend?, config?: PlaceholderOptions }
|
||||
tag?: false
|
||||
text?: false
|
||||
video?: false
|
||||
wordCount?: false
|
||||
}
|
||||
```
|
||||
|
||||
`fileUpload.config` requires at minimum an `upload` function. The `upload` callback must return `Promise<UploadTask>`:
|
||||
|
||||
```typescript
|
||||
interface UploadResult { url: string; sha256: string; tags: string[][] }
|
||||
interface UploadTask { result?: UploadResult; error?: string }
|
||||
```
|
||||
|
||||
Default allowed MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `video/mp4`, `video/mpeg`, `video/webm`. `immediateUpload` defaults to `true`.
|
||||
|
||||
---
|
||||
|
||||
## Example
|
||||
|
||||
A full-featured editor factory covering file upload, @-mention and custom-trigger autocomplete, reactive node views, word count, and draft persistence.
|
||||
|
||||
```typescript
|
||||
import {get, writable} from "svelte/store"
|
||||
import {Node, Extension, mergeAttributes} from "@tiptap/core"
|
||||
import {Plugin, PluginKey} from "@tiptap/pm/state"
|
||||
import type {NodeViewRendererProps} from "@tiptap/core"
|
||||
import {Router} from "@welshman/router"
|
||||
import {createSearch, profiles, searchProfiles, deriveProfileDisplay} from "@welshman/app"
|
||||
import {
|
||||
Editor, WelshmanExtension, MentionSuggestion, TippySuggestion, editorProps,
|
||||
} from "@welshman/editor"
|
||||
import type {FileAttributes, UploadTask} from "@welshman/editor"
|
||||
|
||||
// ── Custom inline node: room reference (~) ───────────────────────────────────
|
||||
// Defines a new ProseMirror node type for inline room references.
|
||||
// Register it alongside WelshmanExtension so Tiptap knows about the "roomref" name.
|
||||
|
||||
const RoomReferenceExtension = Node.create({
|
||||
name: "roomref",
|
||||
atom: true, inline: true, group: "inline", selectable: true, priority: 1000,
|
||||
addAttributes: () => ({url: {default: undefined}, h: {default: undefined}}),
|
||||
parseHTML: () => [{tag: 'span[data-type="roomref"]'}],
|
||||
renderHTML: ({HTMLAttributes}) =>
|
||||
["span", mergeAttributes(HTMLAttributes, {"data-type": "roomref"}), "~"],
|
||||
renderText: ({node}) => `~${node.attrs.url ?? ""}:${node.attrs.h ?? ""}`,
|
||||
addNodeView: () => ({node}: NodeViewRendererProps) => {
|
||||
const dom = document.createElement("span")
|
||||
dom.classList.add("room-ref")
|
||||
const unsub = deriveRoomDisplay(node.attrs.url, node.attrs.h)
|
||||
.subscribe(d => { dom.textContent = "~" + d })
|
||||
return {
|
||||
dom, destroy: unsub,
|
||||
selectNode: () => dom.classList.add("room-ref--active"),
|
||||
deselectNode: () => dom.classList.remove("room-ref--active"),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// ── Editor factory ────────────────────────────────────────────────────────────
|
||||
|
||||
export const makeEditor = ({
|
||||
content = "" as string | object,
|
||||
placeholder = "",
|
||||
uploading, // optional Writable<boolean>
|
||||
wordCount, // optional Writable<number>
|
||||
charCount, // optional Writable<number>
|
||||
submit,
|
||||
}: {
|
||||
content?: string | object
|
||||
placeholder?: string
|
||||
uploading?: ReturnType<typeof writable<boolean>>
|
||||
wordCount?: ReturnType<typeof writable<number>>
|
||||
charCount?: ReturnType<typeof writable<number>>
|
||||
submit: () => void
|
||||
}) => {
|
||||
const profileSearch = createSearch(get(profiles), {
|
||||
onSearch: searchProfiles,
|
||||
getValue: (p: any) => p.event.pubkey,
|
||||
fuseOptions: {keys: ["nip05", "name", "display_name"], threshold: 0.3},
|
||||
})
|
||||
|
||||
const editor = new Editor({
|
||||
content,
|
||||
editorProps,
|
||||
element: document.createElement("div"),
|
||||
extensions: [
|
||||
RoomReferenceExtension,
|
||||
WelshmanExtension.configure({
|
||||
submit,
|
||||
extensions: {
|
||||
// Chat-style: Enter submits, Shift-Enter inserts line break
|
||||
breakOrSubmit: {config: {aggressive: true}},
|
||||
placeholder: {config: {placeholder}},
|
||||
|
||||
// File upload — upload() must return Promise<UploadTask>
|
||||
fileUpload: {
|
||||
config: {
|
||||
upload: async (attrs: FileAttributes): Promise<UploadTask> => {
|
||||
try {
|
||||
const {url, sha256, tags} = await myUploadServer(attrs.file)
|
||||
return {result: {url, sha256, tags}}
|
||||
} catch (e) {
|
||||
return {error: String(e)}
|
||||
}
|
||||
},
|
||||
onDrop: () => uploading?.set(true),
|
||||
onComplete: () => uploading?.set(false),
|
||||
onUploadError: (ed, _task) => {
|
||||
ed.commands.removeFailedUploads()
|
||||
uploading?.set(false)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Custom reactive nprofile node view + "@" and "~" autocomplete
|
||||
nprofile: {
|
||||
extend: {
|
||||
addNodeView: () => ({node}: NodeViewRendererProps) => {
|
||||
const dom = document.createElement("span")
|
||||
dom.classList.add("mention")
|
||||
const unsub = deriveProfileDisplay(node.attrs.pubkey)
|
||||
.subscribe($d => { dom.textContent = "@" + $d })
|
||||
return {
|
||||
dom, destroy: unsub,
|
||||
selectNode: () => dom.classList.add("mention--active"),
|
||||
deselectNode: () => dom.classList.remove("mention--active"),
|
||||
}
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
// "@" — nprofile mention; updateSignal re-renders when search index changes
|
||||
MentionSuggestion({
|
||||
editor: (this as any).editor,
|
||||
search: term => profileSearch.searchValues(term),
|
||||
getRelays: pubkey => Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||
createSuggestion: pubkey => {
|
||||
const el = document.createElement("span")
|
||||
el.textContent = pubkey.slice(0, 12) + "…"
|
||||
return el
|
||||
},
|
||||
}),
|
||||
// "~" — custom roomref node; select must call props.command({...attrs})
|
||||
TippySuggestion({
|
||||
char: "~", name: "roomref",
|
||||
editor: (this as any).editor,
|
||||
search: term => roomSearch.searchValues(term),
|
||||
select: (id, props) => {
|
||||
const [url, h] = splitRoomId(id)
|
||||
if (url && h) props.command({url, h})
|
||||
},
|
||||
createSuggestion: id => {
|
||||
const el = document.createElement("span")
|
||||
el.textContent = id.slice(0, 16) + "…"
|
||||
return el
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
onUpdate({editor}) {
|
||||
wordCount?.set(editor.storage.wordCount.words)
|
||||
charCount?.set(editor.storage.wordCount.chars)
|
||||
},
|
||||
})
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
// ── Reading content on submit ─────────────────────────────────────────────────
|
||||
|
||||
const onSubmit = (editor: Editor) => {
|
||||
const content = editor.getText({blockSeparator: "\n"}).trim()
|
||||
const tags = editor.storage.nostr.getEditorTags() // NIP-10 / NIP-27 tags
|
||||
console.log({content, tags})
|
||||
editor.chain().clearContent().run()
|
||||
}
|
||||
|
||||
// ── Mounting in Svelte ────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type {Editor} from "@welshman/editor"
|
||||
import {onMount, onDestroy} from "svelte"
|
||||
|
||||
const {editor, autofocus = false}: {editor: Editor; autofocus?: boolean} = $props()
|
||||
|
||||
let element: HTMLElement
|
||||
|
||||
onMount(() => {
|
||||
element.append(editor.options.element)
|
||||
if (autofocus) {
|
||||
const atEnd = editor.getText().trim().length > 0
|
||||
requestAnimationFrame(() => editor.commands.focus(atEnd ? "end" : "start"))
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => editor.destroy())
|
||||
</script>
|
||||
|
||||
<div bind:this={element}></div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Notes
|
||||
|
||||
- **`@welshman/app`** — `profileSearch` and `deriveProfileDisplay` are the typical sources for mention autocomplete data and display names.
|
||||
- **`@welshman/router`** — `Router.get().FromPubkeys([pubkey]).getUrls()` provides the relay hints encoded into nprofile bech32 strings.
|
||||
- **`@welshman/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.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas & Tips
|
||||
|
||||
- **`submit` is required.** `WelshmanExtension.configure({submit})` will throw during editor initialization (when extensions are registered) if `submit` is omitted, not at the `configure()` call site.
|
||||
- **Extension options are deep-merged, not replaced.** User-supplied `extensions` options are merged with welshman defaults via `deepMergeLeft`, so you only need to specify the keys you want to change. Supplying `false` for a key fully disables that extension.
|
||||
- **Default node views are plain DOM.** The built-in `MentionNodeView`, `MediaNodeView`, etc. render minimal pill text. Override them via the `extend.addNodeView` pattern (see pattern 4) to render framework components, avatars, or rich previews.
|
||||
- **`selectFiles()` command.** To open a file picker without a UI button inside the editor, call `editor.chain().selectFiles().run()` from any external button click handler.
|
||||
- **CSS variables.** The bundled `index.css` exposes `--tiptap-object-bg`, `--tiptap-object-fg`, `--tiptap-active-bg`, `--tiptap-active-fg` for theming pills and the suggestion dropdown without overriding classes.
|
||||
- **`tiptap-uploading` animation.** While a file is being uploaded, `MediaNodeView` adds the `.tiptap-uploading` class which triggers a pulsing opacity animation defined in `index.css`. No manual wiring is needed.
|
||||
- **Tippy appends to dialog when open.** `TippySuggestion` checks for an open `<dialog>` element and appends the suggestion popover inside it to avoid z-index stacking issues in modal composers.
|
||||
- **`getEditorTags()` returns NIP-10/27 tags.** This method on `editor.storage.nostr` collects all inline nostr objects (mentions, links, embeds) and returns the appropriate `p`, `e`, `a`, `t`, `r` tags for the published event. Always call this when building the event to publish.
|
||||
- **Initial content.** Pass either a plain string or a ProseMirror JSON object to `new Editor({content})`. To restore a draft, save `editor.getJSON()` and pass it back as `content`.
|
||||
- **`removeFailedUploads()` command.** Call `editor.commands.removeFailedUploads()` in `onUploadError` to clean up any partially-inserted upload nodes so the composer stays in a clean state.
|
||||
- **`addFile(file, pos)` command.** Programmatically inserts a file upload node at a given ProseMirror position — useful for native clipboard paste (e.g. mobile) where the browser paste event carries no file data.
|
||||
- **`editor.commands.focus('start' | 'end' | number)`.** Pass `"end"` to place the cursor after existing content (restoring a draft), `"start"` for a fresh empty editor. Call inside `requestAnimationFrame` when the editor element was just mounted.
|
||||
@@ -0,0 +1,301 @@
|
||||
---
|
||||
name: welshman-feeds
|
||||
description: "Use this skill when working with @welshman/feeds: building nostr feeds, FeedController, FeedCompiler, feed definitions, dynamic filtering, or composing feed sources."
|
||||
---
|
||||
|
||||
# welshman/feeds — Dynamic Feed Construction
|
||||
|
||||
`@welshman/feeds` provides a declarative, composable system for defining and executing Nostr event feeds. You describe what you want (authors, kinds, tags, set operations) as a data structure, and the package compiles it into optimized relay requests and handles pagination, deduplication, and exhaustion. It sits on top of `@welshman/net` for relay communication and `@welshman/util` for types.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/feeds
|
||||
# pnpm add @welshman/feeds
|
||||
# yarn add @welshman/feeds
|
||||
```
|
||||
|
||||
## Key Exports
|
||||
|
||||
### Feed Types (enum + tuple types)
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `FeedType` | Enum of all feed type discriminants (`Author`, `Kind`, `Tag`, `Union`, `Intersection`, `Difference`, `DVM`, `List`, `Label`, `WOT`, `Scope`, `Relay`, `Search`, `ID`, `Address`, `CreatedAt`, `Global`) |
|
||||
| `Scope` | Enum: `Followers`, `Follows`, `Network`, `Self` |
|
||||
| `Feed` | Union type of all feed tuple types |
|
||||
| `RequestItem` | `{ relays?: string[], filters?: Filter[] }` — output of compilation |
|
||||
|
||||
### Factory Functions
|
||||
|
||||
All feed definitions are typed tuples. Always use factories rather than raw arrays.
|
||||
|
||||
```typescript
|
||||
// Leaf feeds
|
||||
makeAuthorFeed(...pubkeys: string[]): AuthorFeed
|
||||
makeKindFeed(...kinds: number[]): KindFeed
|
||||
makeTagFeed(key: string, ...values: string[]): TagFeed
|
||||
makeIDFeed(...ids: string[]): IDFeed
|
||||
makeAddressFeed(...addresses: string[]): AddressFeed
|
||||
makeRelayFeed(...urls: string[]): RelayFeed
|
||||
makeSearchFeed(...terms: string[]): SearchFeed
|
||||
makeGlobalFeed(): GlobalFeed
|
||||
makeScopeFeed(...scopes: Scope[]): ScopeFeed
|
||||
makeWOTFeed(...items: WOTItem[]): WOTFeed
|
||||
makeCreatedAtFeed(...items: CreatedAtItem[]): CreatedAtFeed
|
||||
|
||||
// Dynamic / remote feeds
|
||||
makeDVMFeed(...items: DVMItem[]): DVMFeed
|
||||
makeListFeed(...items: ListItem[]): ListFeed
|
||||
makeLabelFeed(...items: LabelItem[]): LabelFeed
|
||||
|
||||
// Set operations
|
||||
makeUnionFeed(...feeds: Feed[]): UnionFeed
|
||||
makeIntersectionFeed(...feeds: Feed[]): IntersectionFeed
|
||||
makeDifferenceFeed(...feeds: Feed[]): DifferenceFeed
|
||||
```
|
||||
|
||||
### Type Guards
|
||||
|
||||
```typescript
|
||||
isAuthorFeed(feed) isKindFeed(feed) isTagFeed(feed)
|
||||
isIDFeed(feed) isAddressFeed(feed) isRelayFeed(feed)
|
||||
isSearchFeed(feed) isGlobalFeed(feed) isScopeFeed(feed)
|
||||
isWOTFeed(feed) isCreatedAtFeed(feed)
|
||||
isDVMFeed(feed) isListFeed(feed) isLabelFeed(feed)
|
||||
isUnionFeed(feed) isIntersectionFeed(feed) isDifferenceFeed(feed)
|
||||
hasSubFeeds(feed) // true for Union | Intersection | Difference
|
||||
```
|
||||
|
||||
### Argument Extraction
|
||||
|
||||
```typescript
|
||||
getFeedArgs(feed: AuthorFeed): string[]
|
||||
getFeedArgs(feed: KindFeed): number[]
|
||||
getFeedArgs(feed: CreatedAtFeed): CreatedAtItem[]
|
||||
getFeedArgs(feed: WOTFeed): WOTItem[]
|
||||
getFeedArgs(feed: UnionFeed): Feed[]
|
||||
// overloaded for every feed type
|
||||
```
|
||||
|
||||
### Conversion Utilities
|
||||
|
||||
```typescript
|
||||
// Tags → feeds
|
||||
feedsFromTags(tags: string[][], mappings?: TagFeedMapping[]): Feed[]
|
||||
feedFromTags(tags: string[][], mappings?: TagFeedMapping[]): IntersectionFeed
|
||||
|
||||
// Filter(s) → feeds
|
||||
feedsFromFilter(filter: Filter): Feed[]
|
||||
feedFromFilter(filter: Filter): Feed
|
||||
feedFromFilters(filters: Filter[]): Feed
|
||||
|
||||
// Default tag-to-feed mappings (override via DVMItem/ListItem mappings)
|
||||
defaultTagFeedMappings: TagFeedMapping[]
|
||||
// [["a", Address], ["e", ID], ["p", Author], ["r", Relay], ["t", Tag "#t"]]
|
||||
```
|
||||
|
||||
### Traversal & Simplification
|
||||
|
||||
```typescript
|
||||
walkFeed(feed: Feed, visit: (feed: Feed) => void): void
|
||||
findFeed(feed: Feed, match: (feed: Feed) => boolean): Feed | undefined
|
||||
simplifyFeed(feed: Feed): Feed // flattens nested same-type set ops
|
||||
```
|
||||
|
||||
### FeedCompiler
|
||||
|
||||
Transforms a `Feed` into `RequestItem[]` for direct relay querying.
|
||||
|
||||
```typescript
|
||||
class FeedCompiler {
|
||||
constructor(options: FeedCompilerOptions)
|
||||
canCompile(feed: Feed): boolean
|
||||
async compile(feed: Feed): Promise<RequestItem[]>
|
||||
}
|
||||
|
||||
type FeedCompilerOptions = {
|
||||
getPubkeysForScope: (scope: Scope) => string[]
|
||||
getPubkeysForWOTRange: (min: number, max: number) => string[]
|
||||
signer?: ISigner
|
||||
signal?: AbortSignal
|
||||
context?: AdapterContext
|
||||
}
|
||||
```
|
||||
|
||||
### FeedController
|
||||
|
||||
Orchestrates loading/listening with pagination, deduplication, and set-operation handling.
|
||||
|
||||
```typescript
|
||||
class FeedController {
|
||||
compiler: FeedCompiler
|
||||
constructor(options: FeedControllerOptions)
|
||||
async load(limit: number): Promise<void>
|
||||
listen(): () => Promise<void>
|
||||
async getLoader(): Promise<(limit: number) => Promise<void>>
|
||||
async getListener(): Promise<() => Promise<void>>
|
||||
async getRequestItems(): Promise<RequestItem[] | undefined>
|
||||
}
|
||||
|
||||
type FeedControllerOptions = FeedCompilerOptions & {
|
||||
feed: Feed
|
||||
tracker?: Tracker
|
||||
onEvent?: (event: TrustedEvent) => void
|
||||
onExhausted?: () => void
|
||||
useWindowing?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### 1. Simple author + kind feed
|
||||
|
||||
```typescript
|
||||
import { FeedController, makeIntersectionFeed, makeAuthorFeed, makeKindFeed } from '@welshman/feeds'
|
||||
import { Scope } from '@welshman/feeds'
|
||||
|
||||
const controller = new FeedController({
|
||||
feed: makeIntersectionFeed(
|
||||
makeAuthorFeed("pubkey1", "pubkey2"),
|
||||
makeKindFeed(1),
|
||||
),
|
||||
getPubkeysForScope: (scope) => [],
|
||||
getPubkeysForWOTRange: (min, max) => [],
|
||||
onEvent: (event) => console.log(event.id),
|
||||
onExhausted: () => console.log('done'),
|
||||
})
|
||||
|
||||
await controller.load(50)
|
||||
```
|
||||
|
||||
### 2. Follows feed with WOT filtering
|
||||
|
||||
```typescript
|
||||
import {
|
||||
FeedController, makeIntersectionFeed, makeScopeFeed,
|
||||
makeWOTFeed, makeKindFeed, Scope
|
||||
} from '@welshman/feeds'
|
||||
|
||||
const controller = new FeedController({
|
||||
feed: makeIntersectionFeed(
|
||||
makeScopeFeed(Scope.Follows),
|
||||
makeWOTFeed({ min: 0.1 }),
|
||||
makeKindFeed(1, 6, 7),
|
||||
),
|
||||
getPubkeysForScope: (scope) => {
|
||||
if (scope === Scope.Follows) return myFollowList
|
||||
return []
|
||||
},
|
||||
getPubkeysForWOTRange: (min, max) => wotIndex.getPubkeys(min, max),
|
||||
onEvent: handleEvent,
|
||||
onExhausted: markExhausted,
|
||||
useWindowing: true,
|
||||
})
|
||||
|
||||
await controller.load(20)
|
||||
```
|
||||
|
||||
### 3. DVM-powered algorithmic feed
|
||||
|
||||
```typescript
|
||||
import {
|
||||
FeedController, makeIntersectionFeed, makeDVMFeed,
|
||||
makeWOTFeed, FeedType
|
||||
} from '@welshman/feeds'
|
||||
|
||||
// DVMItem.mappings controls how DVM result tags become sub-feeds
|
||||
const controller = new FeedController({
|
||||
feed: makeIntersectionFeed(
|
||||
makeDVMFeed({
|
||||
kind: 5300,
|
||||
tags: [['p', 'dvm-pubkey-hex']],
|
||||
mappings: [['p', [FeedType.Author]]],
|
||||
}),
|
||||
makeWOTFeed({ min: 0.05 }),
|
||||
),
|
||||
getPubkeysForScope: () => [],
|
||||
getPubkeysForWOTRange: (min, max) => wotPubkeys,
|
||||
onEvent: handleEvent,
|
||||
})
|
||||
await controller.load(30)
|
||||
```
|
||||
|
||||
### 4. List-based feed (NIP-51 list)
|
||||
|
||||
```typescript
|
||||
import { FeedController, makeListFeed, makeKindFeed, makeUnionFeed, FeedType } from '@welshman/feeds'
|
||||
|
||||
const controller = new FeedController({
|
||||
feed: makeUnionFeed(
|
||||
makeListFeed({
|
||||
addresses: ["10003:pubkey:identifier"],
|
||||
// default tag mappings applied unless overridden
|
||||
}),
|
||||
makeKindFeed(1),
|
||||
),
|
||||
getPubkeysForScope: () => [],
|
||||
getPubkeysForWOTRange: () => [],
|
||||
onEvent: handleEvent,
|
||||
})
|
||||
await controller.load(25)
|
||||
```
|
||||
|
||||
### 5. Converting existing filters to a feed
|
||||
|
||||
```typescript
|
||||
import { ago, HOUR } from '@welshman/lib'
|
||||
import { feedFromFilters, FeedCompiler } from '@welshman/feeds'
|
||||
|
||||
const filters = [
|
||||
{ kinds: [1], authors: ["pubkey1"], since: ago(HOUR) },
|
||||
{ kinds: [6], "#e": ["event-id"] },
|
||||
]
|
||||
|
||||
const feed = feedFromFilters(filters)
|
||||
|
||||
const compiler = new FeedCompiler({
|
||||
getPubkeysForScope: () => [],
|
||||
getPubkeysForWOTRange: () => [],
|
||||
})
|
||||
|
||||
const requestItems = await compiler.compile(feed)
|
||||
// => [{filters: [{kinds:[1], authors:["pubkey1"], since:...}]}, ...]
|
||||
```
|
||||
|
||||
### 6. Traversing a feed tree to inspect contents
|
||||
|
||||
```typescript
|
||||
import { walkFeed, isKindFeed, isAuthorFeed, getFeedArgs } from '@welshman/feeds'
|
||||
|
||||
const kinds = new Set<number>()
|
||||
const authors = new Set<string>()
|
||||
|
||||
walkFeed(myFeed, (node) => {
|
||||
if (isKindFeed(node)) getFeedArgs(node).forEach(k => kinds.add(k))
|
||||
if (isAuthorFeed(node)) getFeedArgs(node).forEach(p => authors.add(p))
|
||||
})
|
||||
|
||||
console.log('Kinds in feed:', [...kinds])
|
||||
console.log('Authors in feed:', [...authors])
|
||||
```
|
||||
|
||||
## Integration Notes
|
||||
|
||||
- **`@welshman/util`** — `Filter`, `TrustedEvent`, and nostr primitives used throughout. `getIdFilters()` is used internally by the compiler for address feeds.
|
||||
- **`@welshman/signer`** — `ISigner` interface, passed optionally through `FeedCompilerOptions` for DVM requests that require signing.
|
||||
- **`@welshman/net`** — The `FeedController` delegates to `requestPage` for relay communication. The `FeedCompiler` delegates to `requestDVM` for DVM-based feeds. Neither accepts `request` or `requestDVM` as constructor options. `AdapterContext` from net is passed through `FeedCompilerOptions`.
|
||||
- **`@welshman/app`** — Higher-level app packages typically wire up `getPubkeysForScope` and `getPubkeysForWOTRange` using their own follow/WOT stores, then construct `FeedController` instances from user-facing feed definitions.
|
||||
- **`Tracker`** — Optional deduplication helper (from `@welshman/net` or app layer). Pass a shared `Tracker` instance to avoid re-emitting events seen in other controllers.
|
||||
|
||||
## Gotchas & Tips
|
||||
|
||||
- **Always use factory functions** (`makeAuthorFeed`, etc.) rather than constructing raw tuples — the tuple structure is internal and type safety depends on using factories.
|
||||
- **`useWindowing: true`** is for relays that may return events out of chronological order. Do not use it for DVM/algorithmic feeds where order is part of the result.
|
||||
- **`FeedController.load()` is stateful** — each call continues from where the last left off (pagination). Create a new controller to reset.
|
||||
- **`canCompile` returns `false` only for `FeedType.Difference`** (and recursively for `Union`/`Intersection` whose sub-feeds include a `Difference`). DVM and List feeds return `true` from `canCompile` and are compiled asynchronously by `_compileDvms` and `_compileLists` inside the compiler's `compile` method. The feeds handled specially by `FeedController` (outside the compiled request flow) are `Difference`, `Union`, and `Intersection` — but only when `canCompile` returns `false` for them.
|
||||
- **`simplifyFeed`** flattens nested same-type set operations (e.g. `union(union(a,b), c)` → `union(a,b,c)`). Run it before storing or serializing feed definitions.
|
||||
- **`makeDifferenceFeed(included, ...excluded)`** — the first argument is the base feed to include; all subsequent feeds define events to exclude.
|
||||
- **Tag key convention** — tag feeds use `#t`, `#e`, etc. (hash-prefixed) to match Nostr filter syntax. `makeTagFeed("#t", "bitcoin")` produces filter `{"#t": ["bitcoin"]}`.
|
||||
- **`CreatedAtItem.relative`** — when set to `["since"]` or `["until"]`, the compiler treats those timestamps as relative offsets from the current time rather than absolute unix timestamps.
|
||||
- **Intersection of feeds is AND logic across relay results** — an event must appear in responses from ALL sub-feeds to be emitted. This can significantly reduce result counts vs. a union.
|
||||
@@ -0,0 +1,415 @@
|
||||
---
|
||||
name: welshman-lib
|
||||
description: "Use this skill when working with @welshman/lib: general-purpose utilities including LRU cache, EventEmitter, Deferred promises, TaskQueue, URL normalization, or other standalone helpers."
|
||||
---
|
||||
|
||||
# welshman/lib — General Utilities
|
||||
|
||||
`@welshman/lib` is a lightweight TypeScript utility library that forms the foundation of the welshman nostr stack. It provides common helpers used across all sibling packages: array/object manipulation, numeric helpers, async primitives, caching, event emission, and encoding utilities. It depends on `@scure/base` (for bech32/utf8 encoding) and `events` (Node.js EventEmitter polyfill).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/lib
|
||||
# or
|
||||
pnpm add @welshman/lib
|
||||
```
|
||||
|
||||
## Key Exports
|
||||
|
||||
### Deferred Promises
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `Deferred<T, E>` | Type: a `Promise<T>` with `.resolve(T)` and `.reject(E)` methods attached |
|
||||
| `defer<T, E>()` | Creates a `Deferred<T, E>` — a promise with exposed `.resolve()` and `.reject()` |
|
||||
| `makePromise<T, E>(executor)` | Creates a strongly-typed promise with typed error |
|
||||
|
||||
`E` defaults to `T` when omitted. `defer<void>()` for a signal-style deferred. `thunk.complete` in `@welshman/app` is a `Deferred<void>`.
|
||||
|
||||
```typescript
|
||||
import { defer } from '@welshman/lib'
|
||||
|
||||
const ready = defer<void>()
|
||||
socket.on('open', () => ready.resolve())
|
||||
await ready
|
||||
```
|
||||
|
||||
### EventEmitter
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `Emitter` | Extends Node.js `EventEmitter`; all events also fire on the `'*'` listener with the event name prepended |
|
||||
|
||||
```typescript
|
||||
import { Emitter } from '@welshman/lib'
|
||||
|
||||
const bus = new Emitter()
|
||||
bus.on('*', (eventType, ...args) => console.log(eventType, args))
|
||||
bus.emit('login', { pubkey: '...' })
|
||||
```
|
||||
|
||||
### LRU Cache
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `LRUCache<K, V>` | LRU cache; evicts least-recently-used entries when full |
|
||||
| `cached(options)` | Memoizes a function with an LRU backing cache; exposes `.cache` and `.pop()` |
|
||||
| `simpleCache(getValue)` | Minimal memoization wrapper with default settings |
|
||||
|
||||
```typescript
|
||||
import { LRUCache, cached } from '@welshman/lib'
|
||||
|
||||
const cache = new LRUCache<string, number>(100)
|
||||
cache.set('x', 42)
|
||||
cache.get('x') // 42
|
||||
cache.pop('x') // 42 and removes entry
|
||||
|
||||
const getProfile = cached({
|
||||
maxSize: 500,
|
||||
getKey: ([pubkey]: [string]) => pubkey,
|
||||
getValue: ([pubkey]: [string]) => fetchProfile(pubkey),
|
||||
})
|
||||
getProfile.pop(pubkey) // invalidate one entry
|
||||
```
|
||||
|
||||
### Task Queue
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `TaskQueue<Item>` | Processes items asynchronously in configurable batches |
|
||||
|
||||
Options: `batchSize`, `batchDelay` (ms), `processItem`. Methods: `.push(item)`, `.remove(item)`, `.start()`, `.stop()`, `.clear()`, `.process()`, `.subscribe(cb)`.
|
||||
|
||||
```typescript
|
||||
import { TaskQueue } from '@welshman/lib'
|
||||
|
||||
const queue = new TaskQueue<string>({
|
||||
batchSize: 10,
|
||||
batchDelay: 0,
|
||||
processItem: async (item) => { /* handle */ },
|
||||
})
|
||||
queue.start()
|
||||
queue.push('task-1')
|
||||
```
|
||||
|
||||
### URL Normalization
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `normalizeUrl(url, options?)` | Normalizes a URL string (ported from sindresorhus/normalize-url) |
|
||||
| `stripProtocol(url)` | Removes the protocol prefix (`http://`, `wss://`, etc.) |
|
||||
| `displayUrl(url)` | Strips protocol, `www.`, and trailing slash for display |
|
||||
| `displayDomain(url)` | Extracts just the domain from a URL |
|
||||
|
||||
```typescript
|
||||
import { normalizeUrl, displayUrl, displayDomain } from '@welshman/lib'
|
||||
|
||||
normalizeUrl('sindresorhus.com/about.html#contact', { stripHash: true })
|
||||
// => 'http://sindresorhus.com/about.html'
|
||||
displayUrl('https://www.example.com/path/') // => 'example.com/path'
|
||||
displayDomain('relay.damus.io/path') // => 'relay.damus.io'
|
||||
```
|
||||
|
||||
> **Note:** `normalizeUrl` defaults to `http://` protocol. Pass `{ defaultProtocol: 'https' }` if needed.
|
||||
|
||||
### Async Utilities
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `sleep(ms)` | Returns a promise that resolves after `ms` milliseconds |
|
||||
| `yieldThread()` | Yields to the event loop (microtask break) |
|
||||
| `poll(options)` | Polls until a condition is met or an AbortSignal fires; options: `{ signal, condition, interval? }` |
|
||||
| `throttle(ms, fn)` | Returns a throttled version of `fn` |
|
||||
| `throttleWithValue(ms, fn)` | Throttled function that returns the cached return value between updates |
|
||||
| `batch(t, fn)` | First call fires `fn([item])` immediately; subsequent calls within `t` ms are collected and `fn` is called with all accumulated items |
|
||||
| `batcher(t, execute)` | Collects calls for `t` ms, then calls `execute` with all accumulated requests; each individual call returns a `Promise<U>` resolved with its result from the batch. Unlike `batch`, the first call is also deferred — nothing fires immediately. |
|
||||
| `race(threshold, promises)` | Resolves when `threshold` fraction of promises complete |
|
||||
|
||||
### Timestamp / Time Constants
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `MINUTE`, `HOUR`, `DAY`, `WEEK`, `MONTH`, `QUARTER`, `YEAR` | Duration constants **in seconds** |
|
||||
| `LOCALE` | User's default locale string |
|
||||
| `TIMEZONE` | User's timezone offset string (e.g. `+05:30`) |
|
||||
| `now()` | Current Unix timestamp in seconds |
|
||||
| `ago(unit, count?)` | Unix timestamp from `count` units ago — e.g. `ago(DAY, 7)` |
|
||||
| `int(unit, count?)` | Multiplies a time unit by count — e.g. `int(HOUR, 2)` = 7200 |
|
||||
| `ms(seconds)` | Converts seconds to milliseconds |
|
||||
| `secondsToDate(ts)` / `dateToSeconds(date)` | Convert between Unix seconds and `Date` |
|
||||
| `createLocalDate(dateString, timezone?)` | Parses a date string as a local date in the given timezone |
|
||||
| `formatTimestamp(ts)` | Formats Unix seconds as a short datetime string |
|
||||
| `formatTimestampAsDate(ts)` | Formats Unix seconds as a long date string |
|
||||
| `formatTimestampAsTime(ts)` | Formats Unix seconds as a time string |
|
||||
| `formatTimestampRelative(ts)` | Formats Unix seconds as "x minutes ago" |
|
||||
|
||||
> **Note:** All time constants are in **seconds**, not milliseconds. Use `ms(n)` to convert for `setTimeout`.
|
||||
|
||||
### Number Utilities
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `ensureNumber(x)` | `parseFloat(x)` — accepts `string \| number` |
|
||||
| `num(x)` | Returns `x \|\| 0` — converts `undefined` to 0 |
|
||||
| `add(x, y)` / `sub(x, y)` / `mul(x, y)` / `div(x, y)` | Arithmetic with `undefined`-safe operands |
|
||||
| `inc(x)` / `dec(x)` | Increment / decrement (undefined-safe) |
|
||||
| `lt(x, y)` / `lte(x, y)` / `gt(x, y)` / `gte(x, y)` | Comparisons (undefined-safe) |
|
||||
| `max(xs)` / `min(xs)` / `sum(xs)` / `avg(xs)` | Aggregates over `(number \| undefined)[]` |
|
||||
| `between([low, high], n)` | `n > low && n < high` (exclusive) |
|
||||
| `within([low, high], n)` | `n >= low && n <= high` (inclusive) |
|
||||
| `clamp([min, max], n)` | Constrains `n` to the range |
|
||||
| `round(precision, x)` | Rounds to `precision` decimal places |
|
||||
|
||||
### Array / Sequence Utilities
|
||||
|
||||
All return new arrays — no mutation.
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `first(xs)` / `last(xs)` | First/last element (`undefined` if empty) |
|
||||
| `ffirst(xs)` | First element of the first iterable in a nested iterable |
|
||||
| `take(n, xs)` / `drop(n, xs)` | Slice from start / drop from start |
|
||||
| `concat(...xs)` | Flattens vararg arrays into one, skipping any argument that is `undefined` |
|
||||
| `append(x, xs)` / `prepend(x, xs)` | Add element to end / start |
|
||||
| `remove(x, xs)` | Remove all occurrences of `x` |
|
||||
| `removeAt(i, xs)` | Remove element at index `i` |
|
||||
| `splitAt(n, xs)` | Split into `[xs.slice(0, n), xs.slice(n)]` |
|
||||
| `insertAt(n, x, xs)` | Insert `x` at index `n` |
|
||||
| `replaceAt(n, x, xs)` | Replace element at index `n` with `x` |
|
||||
| `uniq(xs)` / `uniqBy(f, xs)` | Deduplicate |
|
||||
| `sort(xs)` | Sorted copy (natural order) |
|
||||
| `sortBy(f, xs)` | Sort by key function |
|
||||
| `groupBy(f, xs)` | Returns `Map<K, T[]>` |
|
||||
| `indexBy(f, xs)` | Returns `Map<K, T>` (last item wins per key) |
|
||||
| `countBy(f, xs)` | Returns `Map<K, number>` |
|
||||
| `partition(f, xs)` | Split into `[passing, failing]` |
|
||||
| `chunk(n, xs)` | Split into fixed-size chunks of length `n` |
|
||||
| `chunks(n, xs)` | Split into exactly `n` chunks |
|
||||
| `toggle(x, xs)` | Add if absent, remove if present (pure) |
|
||||
| `union(a, b)` / `intersection(a, b)` / `difference(a, b)` / `without(a, b)` | Set operations |
|
||||
| `sample(n, xs)` / `shuffle(xs)` / `choice(xs)` | Random selection / shuffle / single random pick |
|
||||
| `flatten(xs)` | Flatten one level |
|
||||
| `ensurePlural(x)` | Wraps a value in `[x]` if it isn't already an array |
|
||||
| `removeUndefined(xs)` | Filters out `undefined` values |
|
||||
| `overlappingPairs(xs)` | Returns `[[xs[0],xs[1]], [xs[1],xs[2]], ...]` |
|
||||
| `range(a, b, step?)` | Generator yielding numbers from `a` to `b` (exclusive) |
|
||||
| `enumerate(xs)` | Generator yielding `[index, item]` tuples |
|
||||
| `pluck<T>(k, xs)` | Maps `xs` to `xs[k]` |
|
||||
| `fromPairs(pairs)` | Creates an object from `[key, value]` tuples |
|
||||
| `initArray(n, f)` | Creates an array of length `n` using generator `f` |
|
||||
| `isIterable(x)` / `toIterable(x)` | Check / wrap as iterable |
|
||||
| `map(f, xs)` / `filter(f, xs)` / `reject(f, xs)` | Iterable-safe versions (accept any `Iterable<T>`) |
|
||||
| `find(f, xs)` / `some(f, xs)` | Iterable-safe find / any-match |
|
||||
|
||||
### Object Utilities
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `isPojo(obj)` | Returns `true` if value is a plain object (not class instance, null, or array) |
|
||||
| `pick(keys, obj)` / `omit(keys, obj)` | Include / exclude keys |
|
||||
| `omitVals(vals, obj)` | Remove entries whose value is in `vals` |
|
||||
| `filterVals(f, obj)` | Keep entries where `f(value)` is truthy |
|
||||
| `mapKeys(f, obj)` / `mapVals(f, obj)` | Transform keys or values |
|
||||
| `mergeLeft(a, b)` / `mergeRight(a, b)` | Shallow merge — left/right wins on conflicts |
|
||||
| `deepMergeLeft(a, b)` / `deepMergeRight(a, b)` | Deep merge — left/right wins on conflicts |
|
||||
| `switcher(key, map)` | Lookup with implicit `map.default` fallback |
|
||||
| `mapPop(k, m)` | Gets and deletes key from a `Map<K, T>` — returns `T \| undefined` |
|
||||
|
||||
> **Note:** `mergeLeft(a, b)` means `a` wins — it spreads `b` first, then `a` on top.
|
||||
|
||||
### TypeScript Utility Types
|
||||
|
||||
```typescript
|
||||
import type { Override, MakeOptional, MakeNonOptional, Obj, Maybe, MaybeAsync } from '@welshman/lib'
|
||||
|
||||
type UserWithRole = Override<User, { role: 'admin' | 'user' }>
|
||||
type DraftUser = MakeOptional<User, 'id' | 'createdAt'>
|
||||
type FullUser = MakeNonOptional<User>
|
||||
type AnyRecord = Obj // Record<string, any>
|
||||
type MaybeStr = Maybe<string> // string | undefined
|
||||
```
|
||||
|
||||
### Functional / Combinator Helpers
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `noop` | No-op function |
|
||||
| `identity(x)` | Returns `x` unchanged |
|
||||
| `always(x)` | Returns a function that always returns `x` |
|
||||
| `not(x)` | Logical NOT |
|
||||
| `complement(f)` | Returns `(...args) => !f(...args)` |
|
||||
| `tap(f)` | Returns `(x) => { f(x); return x }` — runs a side effect and passes the value through |
|
||||
| `bind(f, ...args)` | Partially applies `f` with leading `args` |
|
||||
| `equals(a, b)` | Deep equality (handles arrays, Sets, plain objects) |
|
||||
| `tryCatch(f, onError?)` | Calls `f`, swallows errors, returns `undefined` on failure |
|
||||
| `thrower(message)` | Returns a function that throws `new Error(message)` when called |
|
||||
| `once(f)` | Wraps `f` so it only executes once |
|
||||
| `memoize(f)` | Single-slot memoization: caches last call; re-runs when args change |
|
||||
| `call(f)` | Calls `f()` immediately — IIFE alternative; useful with async |
|
||||
| `ifLet(x, f)` | Calls `f(x)` only if `x` is defined |
|
||||
| `doLet(x, f)` | Calls `f(x)` and returns the result — scoped binding without a variable |
|
||||
| `isDefined(x)` / `isUndefined(x)` / `assertDefined(x)` | `undefined` checks (not null) |
|
||||
|
||||
### Curried Collection Helpers
|
||||
|
||||
Useful as `.filter()` / `.map()` callbacks:
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `eq(v)` / `ne(v)` | `x => x === v` / `x => x !== v` |
|
||||
| `prop(k)` | `x => x[k]` — pluck a property |
|
||||
| `propIn(k, xs)` | `x => xs.includes(x[k])` — property is in list |
|
||||
| `nth(i)` | `xs => xs[i]` — element at index |
|
||||
| `nthEq(i, v)` | `xs => xs[i] === v` |
|
||||
| `nthNe(i, v)` | `xs => xs[i] !== v` |
|
||||
| `nthIn(i, vs)` | `xs => vs.includes(xs[i])` |
|
||||
| `nthNotIn(i, vs)` | `xs => !vs.includes(xs[i])` |
|
||||
| `spec(values)` | `x => all key-value pairs in values match x` |
|
||||
| `member(xs)` | `x => xs.includes(x)` |
|
||||
| `assoc(k, v)` | `obj => ({ ...obj, [k]: v })` — add/update property |
|
||||
| `dissoc(k)` | `obj => omit([k], obj)` — remove property |
|
||||
|
||||
```typescript
|
||||
import { eq, prop, nth, nthEq, nthIn, nthNotIn, spec, member, assoc, dissoc } from '@welshman/lib'
|
||||
|
||||
events.filter(spec({ kind: 1 })) // kind === 1
|
||||
events.map(prop('id')) // pluck id
|
||||
tags.filter(nthEq(0, 'p')) // tags where tag[0] === 'p'
|
||||
tags.filter(nthIn(0, ['p', 'e'])) // tags where tag[0] is 'p' or 'e'
|
||||
tags.filter(nthNotIn(0, ['p', 'e'])) // tags where tag[0] is neither
|
||||
items.filter(member(['a', 'b'])) // items in the set
|
||||
items.map(assoc('seen', true)) // add property
|
||||
items.map(dissoc('secret')) // remove property
|
||||
```
|
||||
|
||||
### Bech32 / Hex / Binary Encoding
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `hexToBech32(prefix, hex)` | Encodes hex string to bech32 (e.g. `npub`, `note`) |
|
||||
| `bech32ToHex(b32)` | Decodes bech32 to hex |
|
||||
| `bytesToHex(buffer)` | `ArrayBuffer \| Uint8Array` to hex string |
|
||||
| `hexToBytes(hex)` | Hex string to `Uint8Array` |
|
||||
| `sha256(data)` | SHA-256 hash of binary data — async, returns hex string |
|
||||
| `textEncoder` | Shared `TextEncoder` instance |
|
||||
| `textDecoder` | Shared `TextDecoder` instance |
|
||||
|
||||
### JSON / Storage / Network
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `parseJson(str)` | Safe `JSON.parse` — returns `undefined` on error or empty input |
|
||||
| `getJson(key)` / `setJson(key, val)` | `localStorage` get/set with JSON serialization |
|
||||
| `fetchJson(url, opts?)` | Fetch JSON with optional method/headers/body |
|
||||
| `postJson(url, data, opts?)` | POST JSON to a URL |
|
||||
| `uploadFile(url, file)` | Upload a `File` object via `multipart/form-data` POST |
|
||||
| `on(target, event, cb)` | Type-safe `.on()` wrapper — returns an unsubscribe `() => void` |
|
||||
|
||||
### Randomness / IDs
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `randomId()` | Generates a random string ID |
|
||||
| `randomInt(min?, max?)` | Random integer in range (inclusive; default 0–9) |
|
||||
|
||||
### String Utilities
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `ellipsize(s, len, suffix?)` | Truncates at word boundary with an ellipsis suffix (default `"..."`) |
|
||||
| `displayList(xs, conj?, n?)` | Oxford-comma list — e.g. `"a, b, and c"` |
|
||||
| `hash(s)` | Numeric hash from a string |
|
||||
|
||||
### Map / Set Helpers
|
||||
|
||||
```typescript
|
||||
import { addToKey, pushToKey, addToMapKey, pushToMapKey } from '@welshman/lib'
|
||||
|
||||
// Object-keyed (Record<string, Set<T>> / Record<string, T[]>)
|
||||
addToKey(byTag, 'p', pubkey) // adds to Set at key 'p'
|
||||
pushToKey(byKind, '1', eventId) // appends to array at key '1'
|
||||
|
||||
// Map-keyed (Map<K, Set<T>> / Map<K, T[]>)
|
||||
addToMapKey(m, relay, eventId)
|
||||
pushToMapKey(m, relay, eventId)
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1 — Batching writes (IndexedDB / relay)
|
||||
|
||||
`batch(t, f)` — first call fires immediately; subsequent calls within `t` ms are accumulated and flushed together.
|
||||
|
||||
```typescript
|
||||
import { batch, on } from '@welshman/lib'
|
||||
import type { RepositoryUpdate } from '@welshman/net'
|
||||
|
||||
on(
|
||||
repository,
|
||||
'update',
|
||||
batch(3000, async (updates: RepositoryUpdate[]) => {
|
||||
const toAdd = updates.flatMap(u => u.added)
|
||||
const toRemove = new Set(updates.flatMap(u => u.removed))
|
||||
|
||||
const tx = db.transaction('events', 'readwrite')
|
||||
await Promise.all([
|
||||
...toAdd.map(e => tx.store.put(e)),
|
||||
...Array.from(toRemove).map(id => tx.store.delete(id)),
|
||||
tx.done,
|
||||
])
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
### Pattern 2 — Grouping and indexing nostr events
|
||||
|
||||
```typescript
|
||||
import { groupBy, indexBy, sortBy } from '@welshman/lib'
|
||||
|
||||
const byKind = groupBy((e) => e.kind, events) // Map<number, Event[]>
|
||||
const byId = indexBy((e) => e.id, events) // Map<string, Event>
|
||||
const sorted = sortBy((e) => -e.created_at, events) // newest first
|
||||
```
|
||||
|
||||
### Pattern 3 — Relative timestamp display
|
||||
|
||||
```typescript
|
||||
import { now, ago, DAY, formatTimestampRelative } from '@welshman/lib'
|
||||
|
||||
const recentEvents = events.filter(e => e.created_at > ago(DAY, 7))
|
||||
const label = formatTimestampRelative(event.created_at) // "3 hours ago"
|
||||
```
|
||||
|
||||
### Pattern 4 — Subscribing to EventEmitter-based objects with `on`
|
||||
|
||||
`on(target, event, handler)` returns an unsubscribe function:
|
||||
|
||||
```typescript
|
||||
import { on } from '@welshman/lib'
|
||||
import { repository } from '@welshman/app'
|
||||
|
||||
const unsub = on(repository, 'update', updates => {
|
||||
console.log('added', updates.flatMap(u => u.added).length, 'events')
|
||||
})
|
||||
|
||||
// Later:
|
||||
unsub()
|
||||
```
|
||||
|
||||
### Pattern 5 — IIFE alternative with `call`
|
||||
|
||||
```typescript
|
||||
import { call } from '@welshman/lib'
|
||||
|
||||
call(async () => {
|
||||
const data = await fetchJson('/something')
|
||||
})
|
||||
```
|
||||
|
||||
## Integration Notes
|
||||
|
||||
- **All welshman packages depend on `@welshman/lib`** — `Deferred`, `Emitter`, time constants, and collection utilities are shared across `@welshman/net`, `@welshman/store`, `@welshman/util`, etc.
|
||||
- **`@welshman/net`** uses `Emitter` (via `Tracker`, `Repository`, `WrapManager`), `batch`, and `LRUCache` internally. `Socket` extends Node's built-in `EventEmitter` directly.
|
||||
- **`@welshman/app`** thunks use `Deferred<void>` — `thunk.complete` resolves when all relays have responded or timed out.
|
||||
- **`batcher`** is used in `@welshman/net` for deduplicating concurrent fetch requests — pass it an `execute` function that returns results in the same order as its inputs.
|
||||
@@ -0,0 +1,442 @@
|
||||
---
|
||||
name: welshman-net
|
||||
description: "Use this skill when working with @welshman/net: relay connections, request/publish flows, auth, relay pool management, adapters, policies, or low-level nostr network I/O."
|
||||
---
|
||||
|
||||
# welshman/net — Relay Network Layer
|
||||
|
||||
`@welshman/net` is the core networking layer for welshman-based nostr apps. It manages WebSocket relay connections, subscriptions, event publishing, NIP-42 auth, and NIP-77 negentropy sync. It sits below `@welshman/app` (which provides higher-level reactive stores and routing) and depends on `@welshman/util` for event types and `@welshman/lib` for utilities.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/net
|
||||
# or
|
||||
pnpm add @welshman/net
|
||||
yarn add @welshman/net
|
||||
```
|
||||
|
||||
## Key Exports
|
||||
|
||||
### Pool & Sockets
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `Pool` | Singleton connection pool; creates and manages `Socket` instances per relay URL |
|
||||
| `Pool.get()` | Returns the singleton `Pool` instance |
|
||||
| `pool.get(url)` | Gets or lazily creates a `Socket` for the given relay URL |
|
||||
| `pool.remove(url)` | Removes and cleans up a socket |
|
||||
| `pool.subscribe(cb)` | Fires `cb(socket)` each time a new socket is created; returns unsubscriber |
|
||||
| `Socket` | WebSocket wrapper with status tracking, send queue, and auth state |
|
||||
| `SocketStatus` | Enum: `Open`, `Opening`, `Closing`, `Closed`, `Error` |
|
||||
| `SocketEvent` | Enum: `Status`, `Send`, `Sending`, `Receive`, `Receiving`, `Error` |
|
||||
| `socket.auth` | `AuthState` instance for NIP-42 on this connection |
|
||||
|
||||
### Request
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `requestOne(options)` | Subscribe to a single relay; returns `Promise<TrustedEvent[]>` |
|
||||
| `request(options)` | Subscribe to multiple relays in parallel; returns `Promise<TrustedEvent[]>` |
|
||||
| `makeLoader(options)` | Creates a batching `load` function with configurable delay/timeout/threshold |
|
||||
| `load(options)` | Pre-built loader: 200 ms batch delay, 3 s timeout, 0.5 threshold. Simpler than `request()` when you just want events — auto-closes after EOSE, timeout, or disconnect; resolves when half the relays' subscriptions have closed; returns a `Promise<TrustedEvent[]>`. When used with `@welshman/app`, received events auto-flow into the repository and tracker. |
|
||||
|
||||
`request` / `requestOne` options (key fields):
|
||||
- `relay` / `relays` — relay URL(s)
|
||||
- `filters` — array of nostr `Filter` objects
|
||||
- `autoClose?: boolean` — close subscription after EOSE or on socket disconnect
|
||||
- `signal?: AbortSignal` — cancellation
|
||||
- `tracker?: Tracker` — cross-relay deduplication (shared automatically by `request`)
|
||||
- Callbacks: `onEvent(event, url)`, `onEose(url)`, `onClose()`, `onDisconnect(url)`, `onFiltered`, `onDuplicate`, `onDeleted`, `onInvalid`, `onClosed(reason, url)`
|
||||
|
||||
`request`-only options:
|
||||
- `threshold?: number` — fraction of relays that must close before the promise resolves (default `1`)
|
||||
|
||||
Without `autoClose` or a `signal`, `requestOne` streams indefinitely — the returned promise only resolves if the relay sends CLOSED for all active subscription IDs. Default policies also re-send the REQ when sockets reconnect.
|
||||
|
||||
### Publish
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `publish(options)` | Publishes to multiple relays; resolves to `PublishResultsByRelay` |
|
||||
| `publishOne(options)` | Publishes to a single relay; resolves to `PublishResult` |
|
||||
| `PublishStatus` | Enum: `Sending`, `Pending`, `Success`, `Failure`, `Timeout`, `Aborted` |
|
||||
| `PublishResult` | `{ relay: string, status: PublishStatus, detail: string }` |
|
||||
| `PublishResultsByRelay` | `Record<string, PublishResult>` |
|
||||
|
||||
`publish` options: `event`, `relays`, `timeout?` (default 10 s), `signal?`, `context?`, plus callbacks `onSuccess`, `onFailure`, `onPending`, `onTimeout`, `onAborted`, `onComplete`.
|
||||
|
||||
### Auth (NIP-42)
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `AuthState` | Manages auth state for one socket; available as `socket.auth` |
|
||||
| `AuthStatus` | Enum: `None`, `Requested`, `PendingSignature`, `DeniedSignature`, `PendingResponse`, `Forbidden`, `Ok` |
|
||||
| `AuthStateEvent.Status` | Emitted when auth status changes |
|
||||
| `makeSocketPolicyAuth(options)` | Creates a socket policy that auto-handles auth challenges |
|
||||
| `defaultSocketPolicies` | Mutable array of policies applied to every new socket |
|
||||
|
||||
### Policies
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `socketPolicyPing` | Sends a PING frame every 30 s when the socket is open and idle, to keep the connection alive |
|
||||
| `socketPolicyAuthBuffer` | Buffers outgoing messages during auth and replays after success |
|
||||
| `socketPolicyConnectOnSend` | Auto-opens closed sockets when a message is queued |
|
||||
| `socketPolicyCloseInactive` | Closes idle sockets after 30 s (when no pending work remains); if the socket closes with pending work it delays and reopens, replaying queued messages |
|
||||
| `defaultSocketPolicies` | Array of the four above; passed to every socket created by `Pool` |
|
||||
|
||||
A `SocketPolicy` is `(socket: Socket) => Unsubscriber`.
|
||||
|
||||
### Repository
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `Repository` | In-memory indexed event store with delete/expiry support |
|
||||
| `Repository.get()` | Returns the singleton instance |
|
||||
| `repository.publish(event)` | Stores an event; returns `false` if duplicate/stale |
|
||||
| `repository.query(filters, opts?)` | Returns matching `TrustedEvent[]` sorted by `created_at` desc |
|
||||
| `repository.getEvent(idOrAddress)` | Look up by id or NIP-01 address (`kind:pubkey:d`) |
|
||||
| `repository.isDeleted(event)` | `true` if a kind-5 delete covers this event |
|
||||
| `repository.dump()` | Returns all stored events as `TrustedEvent[]` |
|
||||
| `repository.load(events)` | Bulk-replaces all stored events; emits a single `"update"` diff. Events with `event[verifiedSymbol] = true` skip signature re-verification. |
|
||||
| `LOCAL_RELAY_URL` | `"local://welshman.relay/"` — conventional URL for the local repository |
|
||||
| `RepositoryUpdate` | `{ added: TrustedEvent[], removed: Set<string> }` — payload of `"update"` events |
|
||||
| `mergeRepositoryUpdates(updates)` | Merges an array of `RepositoryUpdate` objects into one |
|
||||
|
||||
Emits `"update"` with `RepositoryUpdate` (`{ added: TrustedEvent[], removed: Set<string> }`) on every change.
|
||||
|
||||
> **Prefer `LOCAL_RELAY_URL` over direct repository access.** Rather than calling `repository.query()` or `repository.publish()` directly, pass `LOCAL_RELAY_URL` as a relay URL to the standard `load()`, `request()`, and `publish()` functions. This keeps local reads/writes going through the same policy, deduplication, and tracking pipeline as remote relay operations. Direct repository access is appropriate only for bulk startup (`repository.load()`) and low-level introspection (`repository.getEvent()`, `repository.isDeleted()`).
|
||||
|
||||
### Tracker
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `Tracker` | Bidirectional map of `eventId ↔ Set<relayUrl>` |
|
||||
| `tracker.track(eventId, relay)` | Records relay; returns `true` if the event was already seen |
|
||||
| `tracker.getRelays(eventId)` | Set of relay URLs that have sent this event |
|
||||
| `tracker.getIds(relay)` | Set of event ids seen from a relay |
|
||||
| `tracker.copy(id1, id2)` | Copies relay associations from one id to another (used for gift wraps) |
|
||||
| `tracker.load(relaysById)` | Bulk-replaces all relay mappings from a `Map<string, Set<string>>`; emits `"load"` |
|
||||
| `tracker.clear()` | Removes all relay mappings; emits `"clear"` |
|
||||
|
||||
### Adapters
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `getAdapter(url, context?)` | Factory: returns `SocketAdapter`, `LocalAdapter`, or custom adapter |
|
||||
| `SocketAdapter` | WebSocket relay adapter |
|
||||
| `LocalAdapter` | In-memory relay adapter |
|
||||
| `MockAdapter` | Test adapter with manual send control |
|
||||
| `AbstractAdapter` | Base class for custom adapters |
|
||||
| `AdapterEvent.Receive` | Emitted when a relay message arrives |
|
||||
|
||||
### Context
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `netContext` | Global `NetContext` config object |
|
||||
| `NetContext` | `{ pool, repository, isEventValid, isEventDeleted, getAdapter? }` |
|
||||
|
||||
Mutate `netContext` fields directly to change global defaults; pass `context` to individual calls to override per-request.
|
||||
|
||||
### Negentropy / Diff (NIP-77)
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `diff(options)` | Compares local events against relays; returns `{ relay, have, need }[]` |
|
||||
| `pull(options)` | Fetches events relays have that you don't |
|
||||
| `push(options)` | Publishes events you have that relays don't |
|
||||
| `Difference` | Low-level per-relay negentropy session |
|
||||
|
||||
### Messages
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `RelayMessageType` | Enum of relay→client message types |
|
||||
| `ClientMessageType` | Enum of client→relay message types |
|
||||
| `isRelayEvent()`, `isRelayEose()`, `isRelayOk()`, `isRelayAuth()`, etc. | Type guards for relay messages |
|
||||
| `isClientReq()`, `isClientEvent()`, etc. | Type guards for client messages |
|
||||
|
||||
### WrapManager
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `WrapManager` | Tracks NIP-59 gift wrap → rumor relationships; stores decrypted rumors in the repository and copies relay tracking from the wrap to the rumor |
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Connect to a relay and stream events
|
||||
|
||||
```typescript
|
||||
import {Pool, SocketEvent, SocketStatus} from '@welshman/net'
|
||||
|
||||
const pool = Pool.get()
|
||||
const socket = pool.get('wss://relay.example.com')
|
||||
|
||||
socket.on(SocketEvent.Status, (status: SocketStatus) => {
|
||||
console.log('status:', status)
|
||||
})
|
||||
|
||||
// Send REQ directly (prefer request() for higher-level use)
|
||||
socket.send(['REQ', 'my-sub', {kinds: [1], limit: 10}])
|
||||
```
|
||||
|
||||
### Load events (one-shot, batched)
|
||||
|
||||
```typescript
|
||||
import {load} from '@welshman/net'
|
||||
|
||||
// load() batches multiple concurrent calls within 200 ms into a single REQ per relay.
|
||||
// It auto-closes after EOSE, timeout, or disconnect, and resolves at 50 % relay threshold.
|
||||
const events = await load({
|
||||
relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
|
||||
filters: [{kinds: [0], authors: ['<pubkey>']}],
|
||||
})
|
||||
```
|
||||
|
||||
### Stream events indefinitely
|
||||
|
||||
```typescript
|
||||
import {request} from '@welshman/net'
|
||||
import {now} from '@welshman/lib'
|
||||
|
||||
// Without autoClose this will stream forever.
|
||||
// The returned promise never settles unless all relays close the subscription.
|
||||
const ctrl = new AbortController()
|
||||
|
||||
request({
|
||||
relays: ['wss://relay.example.com'],
|
||||
filters: [{kinds: [1], since: now()}],
|
||||
signal: ctrl.signal,
|
||||
onEvent: (event, url) => console.log(event.id, 'from', url),
|
||||
})
|
||||
|
||||
// Later:
|
||||
ctrl.abort()
|
||||
```
|
||||
|
||||
### Publish an event
|
||||
|
||||
```typescript
|
||||
import {publish, PublishStatus} from '@welshman/net'
|
||||
|
||||
const results = await publish({
|
||||
event: signedEvent,
|
||||
relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
|
||||
timeout: 5000,
|
||||
onSuccess: r => console.log('accepted by', r.relay),
|
||||
onFailure: r => console.warn('rejected by', r.relay, r.detail),
|
||||
})
|
||||
|
||||
for (const [relay, result] of Object.entries(results)) {
|
||||
if (result.status === PublishStatus.Success) {
|
||||
console.log(relay, 'ok')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Enable NIP-42 auth globally
|
||||
|
||||
```typescript
|
||||
import {defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net'
|
||||
import type {StampedEvent} from '@welshman/util'
|
||||
|
||||
// Call once at app startup, before any sockets are opened.
|
||||
defaultSocketPolicies.push(
|
||||
makeSocketPolicyAuth({
|
||||
sign: (event: StampedEvent) => mySigner.sign(event),
|
||||
shouldAuth: (socket) => true, // auth on every relay
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
### Custom socket policies
|
||||
|
||||
A `SocketPolicy` is `(socket: Socket) => Unsubscriber`. It receives the socket when it is created, attaches listeners or patches socket methods, and returns a cleanup function. Push custom policies onto `defaultSocketPolicies` before any sockets are opened.
|
||||
|
||||
```typescript
|
||||
import {writable} from 'svelte/store'
|
||||
import {on} from '@welshman/lib'
|
||||
import {defaultSocketPolicies, SocketEvent, isRelayEvent} from '@welshman/net'
|
||||
import type {Socket, RelayMessage} from '@welshman/net'
|
||||
|
||||
// Track how many events each relay has delivered this session
|
||||
export const eventCountByRelay = writable<Record<string, number>>({})
|
||||
|
||||
const eventCountPolicy = (socket: Socket) => {
|
||||
const unsub = on(socket, SocketEvent.Receive, (message: RelayMessage) => {
|
||||
if (isRelayEvent(message)) {
|
||||
eventCountByRelay.update(counts => ({
|
||||
...counts,
|
||||
[socket.url]: (counts[socket.url] ?? 0) + 1,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
return unsub // called when the socket is destroyed
|
||||
}
|
||||
|
||||
defaultSocketPolicies.push(eventCountPolicy)
|
||||
```
|
||||
|
||||
The same structure applies to more advanced patterns — patch `socket.open` to block connections, listen to `SocketEvent.Sending`/`SocketEvent.Receiving` to intercept messages before they are processed, or manipulate `socket._recvQueue` directly to suppress or replay messages.
|
||||
|
||||
### Custom adapter (e.g. non-WebSocket backend)
|
||||
|
||||
```typescript
|
||||
import {AbstractAdapter, AdapterEvent, request} from '@welshman/net'
|
||||
import type {ClientMessage} from '@welshman/net'
|
||||
|
||||
class MyAdapter extends AbstractAdapter {
|
||||
constructor(private url: string) {
|
||||
super()
|
||||
// set up your transport here
|
||||
}
|
||||
|
||||
get urls() { return [this.url] }
|
||||
get sockets() { return [] }
|
||||
|
||||
send(message: ClientMessage) {
|
||||
// forward message to your backend; call this.emit(AdapterEvent.Receive, replyMsg, this.url) when data arrives
|
||||
}
|
||||
}
|
||||
|
||||
request({
|
||||
relays: ['myscheme://some-id'],
|
||||
filters: [{kinds: [1]}],
|
||||
autoClose: true,
|
||||
context: {
|
||||
getAdapter: (url) => url.startsWith('myscheme://') ? new MyAdapter(url) : undefined,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Use LOCAL_RELAY_URL to read/write the local repository
|
||||
|
||||
Pass `LOCAL_RELAY_URL` as a relay to the standard net functions so local operations go through the same pipeline as remote ones (policies, deduplication, tracker):
|
||||
|
||||
```typescript
|
||||
import {load, publish, request, LOCAL_RELAY_URL} from '@welshman/net'
|
||||
import {now} from '@welshman/lib'
|
||||
|
||||
// Read from the local repository the same way you'd read from a remote relay
|
||||
const events = await load({
|
||||
relays: [LOCAL_RELAY_URL],
|
||||
filters: [{kinds: [1], authors: ['<pubkey>'], limit: 20}],
|
||||
})
|
||||
|
||||
// Write to the local repository (and any remote relays) in one call
|
||||
await publish({
|
||||
event: signedEvent,
|
||||
relays: [LOCAL_RELAY_URL, 'wss://relay.example.com'],
|
||||
})
|
||||
|
||||
// Subscribe to new local events in real time
|
||||
request({
|
||||
relays: [LOCAL_RELAY_URL],
|
||||
filters: [{kinds: [1], since: now()}],
|
||||
onEvent: (event) => console.log('new local event', event.id),
|
||||
})
|
||||
```
|
||||
|
||||
Direct `repository` API calls (`repository.load()`, `repository.getEvent()`, `repository.isDeleted()`, `repository.dump()`) are still appropriate for bulk startup and low-level introspection — but for routine reads and writes prefer `LOCAL_RELAY_URL`.
|
||||
|
||||
### Startup: bulk-load persisted events (skip re-verification)
|
||||
|
||||
```typescript
|
||||
import {Repository} from '@welshman/net'
|
||||
import {verifiedSymbol} from '@welshman/util'
|
||||
import type {TrustedEvent} from '@welshman/util'
|
||||
|
||||
const repo = Repository.get()
|
||||
|
||||
// Mark events as already-verified so welshman skips signature checks
|
||||
const storedEvents: TrustedEvent[] = await loadFromStorage()
|
||||
for (const event of storedEvents) {
|
||||
event[verifiedSymbol] = true
|
||||
}
|
||||
|
||||
// Replaces all in-memory events in one pass; emits a single "update"
|
||||
repo.load(storedEvents)
|
||||
```
|
||||
|
||||
### Startup: bulk-load Tracker state
|
||||
|
||||
```typescript
|
||||
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>>()
|
||||
for (const {id, relays} of storedTrackerItems) {
|
||||
if (repo.getEvent(id)) { // skip orphaned entries
|
||||
relaysById.set(id, new Set(relays))
|
||||
}
|
||||
}
|
||||
|
||||
// Takes Map<string, Set<string>> — same shape as tracker.relaysById
|
||||
tracker.load(relaysById)
|
||||
```
|
||||
|
||||
### Persist repository changes to IndexedDB (canonical pattern)
|
||||
|
||||
```typescript
|
||||
import {on, batch} from '@welshman/lib'
|
||||
import {repository} from '@welshman/app' // singleton; or Repository.get() standalone
|
||||
import type {RepositoryUpdate} from '@welshman/net'
|
||||
import type {TrustedEvent} from '@welshman/util'
|
||||
|
||||
// batch(ms, fn) collects all "update" events fired within `ms` and calls fn once
|
||||
on(
|
||||
repository,
|
||||
'update',
|
||||
batch(3000, async (updates: RepositoryUpdate[]) => {
|
||||
const toAdd: TrustedEvent[] = []
|
||||
const toRemove = new Set<string>()
|
||||
|
||||
for (const {added, removed} of updates) {
|
||||
for (const event of added) toAdd.push(event)
|
||||
for (const id of removed) toRemove.add(id)
|
||||
}
|
||||
|
||||
const tx = db.transaction('events', 'readwrite')
|
||||
await Promise.all([
|
||||
...toAdd.map(e => tx.store.put(e)),
|
||||
...Array.from(toRemove).map(id => tx.store.delete(id)),
|
||||
tx.done,
|
||||
])
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Notes
|
||||
|
||||
- **`@welshman/util`** — provides `TrustedEvent`, `SignedEvent`, `Filter`, `verifyEvent`, `matchFilters`, `getAddress`, etc. All event objects flowing through `@welshman/net` are `TrustedEvent` (already verified).
|
||||
- **`@welshman/lib`** — utility helpers (`Emitter`, `batcher`, `defer`, `on`, etc.) used internally; `Emitter` (from `@welshman/lib`) is the base class for `Tracker`, `Repository`, and `WrapManager`. `Socket`, `AuthState`, `AbstractAdapter`, and `Difference` extend node's built-in `EventEmitter` directly.
|
||||
- **`@welshman/app`** — wraps `@welshman/net` with reactive Svelte stores, a router, and higher-level helpers. Most app-level code should use `@welshman/app`; drop down to `@welshman/net` only for raw relay I/O or when building non-Svelte clients.
|
||||
- **`netContext`** — shared singleton used as the default by `request`, `requestOne`, and the repository. Override fields on `netContext` at startup, or pass a `context` object per-call to isolate behavior.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas & Tips
|
||||
|
||||
- **Use `LOCAL_RELAY_URL`, not direct repository calls, for routine reads/writes.** Passing `LOCAL_RELAY_URL` to `load()`, `publish()`, or `request()` routes through the normal net pipeline (policies, deduplication, tracker). Calling `repository.query()` / `repository.publish()` directly bypasses all of that. Reserve the direct API for bulk startup (`repository.load()`), introspection (`getEvent`, `isDeleted`, `dump`), and listening to `"update"` events.
|
||||
|
||||
- **`request()` without `autoClose` or `signal` never resolves.** Always pass `autoClose: true` or an `AbortSignal` when you just want a one-shot fetch. Use `load()` for the common case.
|
||||
- **`load()` sets `autoClose: true` internally** and uses a 0.5 relay threshold; it resolves when half the relays' subscriptions have closed (typically after EOSE, timeout, or disconnect) — useful when some relays are slow or offline.
|
||||
- **Relay URL normalization** happens inside `Pool.get(url)` via `normalizeRelayUrl`. Pass raw URLs everywhere; the pool handles canonicalization.
|
||||
- **`defaultSocketPolicies` is mutable.** Push policies before any sockets are created. Sockets created before a policy is pushed will not have it applied.
|
||||
- **`socketPolicyCloseInactive` only replays pending work on unexpected close.** It reopens and replays queued messages when a socket closes while work is pending — it does not proactively open sockets when new work is queued (that is `socketPolicyConnectOnSend`'s job). After `pool.remove(url)` the socket is cleaned up including its policy listeners, so `socketPolicyCloseInactive` can no longer reopen it.
|
||||
- **`Pool.get(url)` lazily creates a new socket on every call after `pool.remove(url)`.** Calling `pool.remove(url)` forgets the URL and cleans up the socket — any subsequent `pool.get(url)` will construct a fresh socket. Call `pool.remove()` only when you want the pool to forget the URL entirely, not merely to disconnect temporarily.
|
||||
- **`Tracker` is shared across relays in `request()`.** This means `onDuplicate` fires for events received from more than one relay — expected behavior for cross-relay deduplication.
|
||||
- **`Repository.publish()` returns `false` for stale replaceable events.** If a newer version of a replaceable event is already stored, the older one is silently dropped.
|
||||
- **`WrapManager` stores the decrypted rumor in the `Repository`** and copies relay tracking from the gift-wrap event id to the rumor id. Keep a reference to the `WrapManager` instance alongside your `Repository` and `Tracker` singletons.
|
||||
- **`makeSocketPolicyAuth` requires a `sign` function** that returns a `Promise<SignedEvent>`. If the user cancels signing, have the `sign` function throw or reject; `doAuth` will catch the failure via `tryCatch` and automatically transition to `AuthStatus.DeniedSignature`, preventing infinite retry loops.
|
||||
- **Each filter in `filters` array generates a separate REQ** inside `requestOne`. For large filter arrays consider merging them with `unionFilters` from `@welshman/util` before calling `request`.
|
||||
- **`repository.load()` replaces all events, not appends.** It clears internal indexes first, then re-inserts every event. Emit a single batched `"update"` diff — do not call it repeatedly for incremental updates; use `repository.publish(event)` for that.
|
||||
- **`RepositoryUpdate.removed` is `Set<string>`, not an array.** Iterate with `for...of` or `Array.from(removed)`. The `batch()` helper from `@welshman/lib` delivers updates as `RepositoryUpdate[]` to your flush callback — merge them yourself or use `mergeRepositoryUpdates`.
|
||||
- **`tracker.load()` takes `Map<string, Set<string>>`** (the same type as `tracker.relaysById`). Load it after `repository.load()` so you can filter out orphaned event ids.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,225 @@
|
||||
---
|
||||
name: welshman-signer
|
||||
description: "Use this skill when working with @welshman/signer: nostr signing, login methods (NIP-07, NIP-46, NIP-55, NIP-59, NIP-01), ISigner interface, or encrypted events."
|
||||
---
|
||||
|
||||
# welshman/signer — Signing & Login
|
||||
|
||||
## Overview
|
||||
|
||||
`@welshman/signer` provides a unified `ISigner` interface and concrete implementations for every major nostr signing method: local keypair (NIP-01), browser extension (NIP-07), remote bunker/Nostr Connect (NIP-46), native mobile app via Capacitor (NIP-55), and Gift Wrap encryption (NIP-59). All signers share the same API surface, so callers can swap signing methods without changing application logic. This package depends on `@welshman/util`, `@welshman/lib`, and (for NIP-46) `@welshman/net`. It has no dependency on `@welshman/app`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/signer
|
||||
# or
|
||||
pnpm add @welshman/signer
|
||||
yarn add @welshman/signer
|
||||
```
|
||||
|
||||
## Key Exports
|
||||
|
||||
### ISigner Interface
|
||||
|
||||
The common contract all signers implement.
|
||||
|
||||
```typescript
|
||||
import type { ISigner, SignOptions, SignWithOptions } from '@welshman/signer'
|
||||
|
||||
interface ISigner {
|
||||
sign: (event: StampedEvent, options?: SignOptions) => Promise<SignedEvent>
|
||||
getPubkey: () => Promise<string>
|
||||
nip04: {
|
||||
encrypt: (pubkey: string, message: string) => Promise<string>
|
||||
decrypt: (pubkey: string, message: string) => Promise<string>
|
||||
}
|
||||
nip44: {
|
||||
encrypt: (pubkey: string, message: string) => Promise<string>
|
||||
decrypt: (pubkey: string, message: string) => Promise<string>
|
||||
}
|
||||
cleanup?: () => Promise<void>
|
||||
}
|
||||
|
||||
type SignOptions = { signal?: AbortSignal }
|
||||
```
|
||||
|
||||
### Nip01Signer (local keypair)
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `new Nip01Signer(secret)` | Create from an existing hex private key |
|
||||
| `Nip01Signer.fromSecret(secret)` | Alias constructor (returns `Nip01Signer`) |
|
||||
| `Nip01Signer.ephemeral()` | Create with a randomly-generated private key |
|
||||
|
||||
### Nip07Signer (browser extension)
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `new Nip07Signer()` | Delegates all operations to the browser extension (nos2x, Alby, etc.) |
|
||||
| `getNip07()` | Returns the `window.nostr` object if present, otherwise `undefined` |
|
||||
|
||||
### Nip46Signer (remote / bunker)
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `new Nip46Signer(broker)` | ISigner that routes operations through a `Nip46Broker` |
|
||||
| `new Nip46Broker(params)` | Create a broker directly from `Nip46BrokerParams` |
|
||||
| `Nip46Broker.parseBunkerUrl(url)` | Parses a `bunker://` URL into `{ signerPubkey, connectSecret, relays }` |
|
||||
| `Nip46Broker.fromBunkerUrl(url)` | Create a broker directly from a `bunker://` URL |
|
||||
| `broker.makeNostrconnectUrl(metadata)` | Generates a `nostrconnect://` URL for QR display |
|
||||
| `broker.waitForNostrconnect(url, signal)` | Resolves when the remote signer approves the connection; `signal` is a required `AbortSignal` |
|
||||
| `broker.getBunkerUrl()` | Returns a `bunker://` URL for persisting the session |
|
||||
|
||||
### Nip55Signer (native mobile)
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `getNip55()` | Returns `Promise<AppInfo[]>` — installed signing apps via Capacitor |
|
||||
| `new Nip55Signer(packageName, pubkey?)` | Communicates with the specified native app; pass saved pubkey to resume a session |
|
||||
|
||||
Requires the peer dependency: `npm install nostr-signer-capacitor-plugin`
|
||||
|
||||
### Nip59 (Gift Wrap)
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `Nip59.fromSigner(signer)` | Create a Gift Wrap helper from any ISigner |
|
||||
| `Nip59.fromSecret(secret)` | Create directly from a hex private key |
|
||||
| `new Nip59(signer, wrapper?)` | Explicit constructor; `wrapper` defaults to an ephemeral signer |
|
||||
| `nip59.wrap(pubkey, template, tags?)` | Encrypt an event for a recipient; returns `Promise<SignedEvent>` — the kind-1059 gift wrap event |
|
||||
| `nip59.unwrap(event)` | Decrypt a received wrapped event |
|
||||
| `nip59.withWrapper(wrapper)` | Return a new `Nip59` instance with a different wrapper signer |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Local keypair login
|
||||
|
||||
```typescript
|
||||
import { makeSecret } from '@welshman/util'
|
||||
import { Nip01Signer } from '@welshman/signer'
|
||||
import type { ISigner } from '@welshman/signer'
|
||||
|
||||
// New random key
|
||||
const signer: ISigner = Nip01Signer.ephemeral()
|
||||
|
||||
// From a stored key
|
||||
const signer: ISigner = new Nip01Signer(localStorage.getItem('nsec')!)
|
||||
|
||||
// With a timeout
|
||||
const event = makeEvent(1, { content: 'hello' })
|
||||
const signed = await signer.sign(event, { signal: AbortSignal.timeout(5_000) })
|
||||
```
|
||||
|
||||
### Browser extension login
|
||||
|
||||
```typescript
|
||||
import { getNip07, Nip07Signer } from '@welshman/signer'
|
||||
|
||||
function loginWithExtension(): ISigner {
|
||||
if (!getNip07()) {
|
||||
throw new Error('No NIP-07 extension found. Install nos2x or Alby.')
|
||||
}
|
||||
return new Nip07Signer()
|
||||
}
|
||||
|
||||
const signer = loginWithExtension()
|
||||
const pubkey = await signer.getPubkey()
|
||||
```
|
||||
|
||||
### Remote signer (bunker) — first connect
|
||||
|
||||
```typescript
|
||||
import { makeSecret } from '@welshman/util'
|
||||
import { Nip46Broker, Nip46Signer } from '@welshman/signer'
|
||||
|
||||
const broker = new Nip46Broker({
|
||||
relays: ['wss://relay.nsec.app'],
|
||||
clientSecret: makeSecret(),
|
||||
})
|
||||
const signer = new Nip46Signer(broker)
|
||||
|
||||
// Show this URL as a QR code or link
|
||||
const ncUrl = await broker.makeNostrconnectUrl({
|
||||
name: 'My App',
|
||||
description: 'Connect your nostr key',
|
||||
})
|
||||
|
||||
// Block until the user approves in their bunker app
|
||||
const abortController = new AbortController()
|
||||
await broker.waitForNostrconnect(ncUrl, abortController.signal)
|
||||
|
||||
// Persist for future sessions
|
||||
localStorage.setItem('bunkerUrl', broker.getBunkerUrl())
|
||||
|
||||
const pubkey = await signer.getPubkey()
|
||||
```
|
||||
|
||||
### Remote signer — reconnect from saved session
|
||||
|
||||
```typescript
|
||||
import { makeSecret } from '@welshman/util'
|
||||
import { Nip46Broker, Nip46Signer } from '@welshman/signer'
|
||||
|
||||
const raw = localStorage.getItem('bunkerUrl')
|
||||
if (raw) {
|
||||
const { signerPubkey, connectSecret, relays } = Nip46Broker.parseBunkerUrl(raw)
|
||||
const broker = new Nip46Broker({
|
||||
relays,
|
||||
clientSecret: makeSecret(),
|
||||
signerPubkey,
|
||||
connectSecret,
|
||||
})
|
||||
const signer = new Nip46Signer(broker)
|
||||
// Ready to use immediately — no user approval needed
|
||||
}
|
||||
```
|
||||
|
||||
### Gift Wrap (NIP-59) — send and receive
|
||||
|
||||
```typescript
|
||||
import { Nip01Signer, Nip59 } from '@welshman/signer'
|
||||
import { makeEvent } from '@welshman/util'
|
||||
|
||||
const signer = new Nip01Signer(mySecret)
|
||||
const nip59 = Nip59.fromSigner(signer)
|
||||
|
||||
// Wrap a DM for a recipient
|
||||
const wrappedEvent = await nip59.wrap(
|
||||
recipientPubkey,
|
||||
makeEvent(14, { content: 'Secret message', tags: [['p', recipientPubkey]] }),
|
||||
)
|
||||
// Publish the kind-1059 gift wrap event to relays
|
||||
await publishToRelays(wrappedEvent)
|
||||
|
||||
// Receive and unwrap
|
||||
const unwrapped = await nip59.unwrap(receivedKind1059Event)
|
||||
console.log(unwrapped.content) // 'Secret message'
|
||||
```
|
||||
|
||||
### NIP-44 encryption between two parties
|
||||
|
||||
```typescript
|
||||
import { Nip01Signer } from '@welshman/signer'
|
||||
|
||||
const signer = new Nip01Signer(mySecret)
|
||||
const ciphertext = await signer.nip44.encrypt(theirPubkey, 'hello')
|
||||
const plaintext = await signer.nip44.decrypt(theirPubkey, ciphertext)
|
||||
```
|
||||
|
||||
## Integration Notes
|
||||
|
||||
- **`@welshman/util`** supplies `makeEvent`, `makeSecret`, `StampedEvent`, `SignedEvent`, and nostr kind constants (`NOTE`, `DIRECT_MESSAGE`, etc.) used in all examples above.
|
||||
- **`@welshman/net`** and **`@welshman/app`** accept an `ISigner` wherever signing is needed (e.g. publishing events). Pass any concrete signer — they are interchangeable.
|
||||
- **`@welshman/app`** exposes a `signer` writable store (`import { signer } from '@welshman/app'`) that the rest of the app stack reads. Set it to your chosen `ISigner` after login.
|
||||
- `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
|
||||
|
||||
- **`Nip07Signer` is browser-only.** Do not instantiate it in SSR or Node environments; always guard with `getNip07()` first.
|
||||
- **`Nip55Signer` requires Capacitor.** It will not work in a plain browser build. Only use it in a Capacitor-wrapped mobile app after confirming `getNip55()` returns apps.
|
||||
- **`waitForNostrconnect` holds an open subscription.** Always pass an `AbortSignal` (e.g., from `new AbortController().signal`) so you can cancel if the user navigates away.
|
||||
- **`makeSecret()`** (from `@welshman/util`) generates a cryptographically secure random hex private key. Use it for the `clientSecret` in NIP-46 — never reuse the user's actual private key as the client secret.
|
||||
- **`nip59.wrap()` returns the gift-wrap `SignedEvent` directly** — the return value itself is the kind-1059 event to publish. There is no `.wrap` sub-property on the return value.
|
||||
- **Both `nip04` and `nip44` are supported** on all signers. Prefer `nip44` for new code; `nip04` is provided for backwards compatibility with older clients.
|
||||
- **`sign()` options accept `signal: AbortSignal`** — always set a timeout when signing in a UI flow to avoid hanging indefinitely if the user ignores the extension prompt.
|
||||
@@ -0,0 +1,314 @@
|
||||
---
|
||||
name: welshman-store
|
||||
description: "Use this skill when working with @welshman/store: Repository pattern for nostr events, synced Svelte stores, throttled stores, or getter/derived store utilities."
|
||||
---
|
||||
|
||||
# welshman/store — Svelte Store Utilities
|
||||
|
||||
## Overview
|
||||
|
||||
`@welshman/store` provides reactive Svelte store primitives tailored for nostr development. It bridges the `Repository` (event cache) from `@welshman/net` with Svelte's reactive system, letting you derive live-updating collections of events or domain objects (profiles, lists, etc.) with minimal boilerplate. It also ships general-purpose utilities: persistence via `synced`, throttling via `throttled`, and optimized access via `withGetter`/`getter`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/store
|
||||
# or
|
||||
pnpm add @welshman/store
|
||||
yarn add @welshman/store
|
||||
```
|
||||
|
||||
## Key Exports
|
||||
|
||||
### Event stores (from Repository)
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `deriveEventsById(options)` | Returns `Readable<Map<string, TrustedEvent>>` — live map of events matching `filters` |
|
||||
| `deriveEvents(options)` | Returns `Readable<TrustedEvent[]>` — calls `deriveEventsById` internally and converts to array |
|
||||
| `deriveEventsAsc(eventsByIdStore)` | Takes a `Readable<Map<string, TrustedEvent>>` and returns events sorted ascending by `created_at` |
|
||||
| `deriveEventsDesc(eventsByIdStore)` | Takes a `Readable<Map<string, TrustedEvent>>` and returns events sorted descending by `created_at` |
|
||||
| `makeDeriveEvent(options)` | Factory returning `(idOrAddress: string) => Readable<TrustedEvent \| undefined>` for single-event lookups |
|
||||
| `deriveIsDeleted(repository, event)` | `Readable<boolean>` — tracks deletion status of an event |
|
||||
|
||||
`deriveEventsById` / `deriveEvents` options (`EventsByIdOptions`):
|
||||
```typescript
|
||||
{
|
||||
repository: Repository
|
||||
filters: Filter[]
|
||||
includeDeleted?: boolean // default: false
|
||||
}
|
||||
```
|
||||
|
||||
`makeDeriveEvent` options (`EventOptions`):
|
||||
```typescript
|
||||
{
|
||||
repository: Repository
|
||||
includeDeleted?: boolean // default: false
|
||||
onDerive?: (filters: Filter[], ...args: any[]) => void
|
||||
}
|
||||
```
|
||||
|
||||
Usage of `makeDeriveEvent`:
|
||||
```typescript
|
||||
const deriveEvent = makeDeriveEvent({ repository })
|
||||
const eventStore = deriveEvent(someIdOrAddress) // Readable<TrustedEvent | undefined>
|
||||
```
|
||||
|
||||
`deriveEventsAsc` / `deriveEventsDesc` take a map store, not an array store:
|
||||
```typescript
|
||||
// correct: pass the Readable<Map<string, TrustedEvent>> directly
|
||||
const notesAsc = deriveEventsAsc(noteEventsById)
|
||||
const notesDesc = deriveEventsDesc(noteEventsById)
|
||||
```
|
||||
|
||||
### Indexed collections
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `deriveItemsByKey<T>(options)` | Maps events to domain objects, indexed by a string key; `Readable<Map<string, T>>` |
|
||||
| `deriveItems<T>(itemsByKey)` | Converts the map to `Readable<T[]>` |
|
||||
| `deriveItemsSorted<T>(sortFn, itemsStore)` | Sorts a `Readable<T[]>` by a numeric sort-value function `(item: T) => number`; returns `Readable<T[]>` |
|
||||
| `makeDeriveItem<T>(itemsByKey, onDerive?)` | Returns a factory `(key) => Readable<T \| undefined>` for per-key reactive lookups |
|
||||
| `makeLoadItem<T>(loadItem, getItem, options?)` | Cached async loader with staleness checks and exponential backoff |
|
||||
| `makeForceLoadItem<T>(loadItem, getItem)` | Async loader that always fetches fresh data |
|
||||
|
||||
`deriveItemsByKey` options:
|
||||
```typescript
|
||||
{
|
||||
repository: Repository
|
||||
filters: Filter[]
|
||||
eventToItem: (event: TrustedEvent) => MaybeAsync<Maybe<T>>
|
||||
getKey: (item: T) => string
|
||||
includeDeleted?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### Persistence
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `synced(config)` | Writable store that auto-persists to a `StorageProvider`; exposes a `.ready` promise |
|
||||
| `localStorageProvider` | Built-in `StorageProvider` backed by `localStorage` |
|
||||
|
||||
`StorageProvider` interface:
|
||||
```typescript
|
||||
interface StorageProvider {
|
||||
get: (key: string) => Promise<any>
|
||||
set: (key: string, value: any) => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
### Throttling
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `throttled(delay, store)` | Wraps any readable store; subscribers notified at most once per `delay` ms. Pass `0` to skip wrapping. |
|
||||
|
||||
### Getter utilities
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `getter<T>(store, options?)` | Returns `() => T`; auto-switches from `get()` to a subscription when call frequency exceeds `threshold` (default 10/s) |
|
||||
| `withGetter<T>(store)` | Adds a `.get()` method to a `Readable` or `Writable` store |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### 1. Reactive list of text notes
|
||||
|
||||
```typescript
|
||||
import { Repository } from "@welshman/net"
|
||||
import { deriveEventsById, deriveEventsDesc } from "@welshman/store"
|
||||
|
||||
const repository = new Repository()
|
||||
|
||||
const noteEventsById = deriveEventsById({
|
||||
repository,
|
||||
filters: [{ kinds: [1], limit: 100 }],
|
||||
})
|
||||
|
||||
// deriveEventsDesc takes the map store directly
|
||||
const notes = deriveEventsDesc(noteEventsById)
|
||||
|
||||
notes.subscribe($notes => {
|
||||
console.log(`${$notes.length} notes, newest first`)
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Profiles indexed by pubkey
|
||||
|
||||
```typescript
|
||||
import { Repository } from "@welshman/net"
|
||||
import { deriveItemsByKey, deriveItems, makeDeriveItem } from "@welshman/store"
|
||||
import { readProfile, PROFILE, type PublishedProfile } from "@welshman/util"
|
||||
|
||||
const repository = new Repository()
|
||||
|
||||
const profilesByPubkey = deriveItemsByKey<PublishedProfile>({
|
||||
repository,
|
||||
filters: [{ kinds: [PROFILE] }],
|
||||
eventToItem: event => readProfile(event),
|
||||
getKey: profile => profile.event.pubkey,
|
||||
})
|
||||
|
||||
// All profiles as array
|
||||
const profiles = deriveItems(profilesByPubkey)
|
||||
|
||||
// Per-pubkey reactive lookup
|
||||
const deriveProfile = makeDeriveItem(profilesByPubkey)
|
||||
const aliceProfile = deriveProfile("alice-pubkey-hex")
|
||||
|
||||
aliceProfile.subscribe($profile => {
|
||||
console.log($profile?.name)
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Persisted user preferences
|
||||
|
||||
```typescript
|
||||
import { synced, localStorageProvider } from "@welshman/store"
|
||||
|
||||
const prefs = synced({
|
||||
key: "app-prefs",
|
||||
storage: localStorageProvider,
|
||||
defaultValue: { theme: "dark", notifs: true },
|
||||
})
|
||||
|
||||
// Wait until storage has been read before rendering
|
||||
await prefs.ready
|
||||
|
||||
prefs.update(p => ({ ...p, theme: "light" }))
|
||||
```
|
||||
|
||||
### 4. Throttled store for high-frequency updates
|
||||
|
||||
```typescript
|
||||
import { writable } from "svelte/store"
|
||||
import { throttled } from "@welshman/store"
|
||||
|
||||
const rawCursor = writable({ x: 0, y: 0 })
|
||||
const cursor = throttled(50, rawCursor) // UI updates at most every 50 ms
|
||||
|
||||
window.addEventListener("mousemove", e => {
|
||||
rawCursor.set({ x: e.clientX, y: e.clientY })
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Optimized getter for hot code paths
|
||||
|
||||
```typescript
|
||||
import { getter, withGetter } from "@welshman/store"
|
||||
import { writable } from "svelte/store"
|
||||
|
||||
const counter = withGetter(writable(0))
|
||||
|
||||
// Safe to call in tight loops — switches internally to subscription when hot
|
||||
function getCount() {
|
||||
return counter.get()
|
||||
}
|
||||
```
|
||||
|
||||
`getter(store)` is useful when you only need the accessor function (not the full store
|
||||
API). A common pattern is using it to look up a single item from a map store:
|
||||
|
||||
```typescript
|
||||
import { getter } from "@welshman/store"
|
||||
|
||||
// bookmarksByPubkey is Readable<Map<string, Bookmark>>
|
||||
const getBookmarksByPubkey = getter(bookmarksByPubkey)
|
||||
|
||||
// Synchronous, dedup-aware lookup — safe in event handlers and callbacks
|
||||
const getBookmark = (pubkey: string) => getBookmarksByPubkey().get(pubkey)
|
||||
```
|
||||
|
||||
This `getBookmark` function is the right shape to pass as `getItem` to `makeLoadItem`
|
||||
(see Pattern 6).
|
||||
|
||||
### 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.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
deriveItemsByKey,
|
||||
deriveItems,
|
||||
getter,
|
||||
makeLoadItem,
|
||||
makeDeriveItem,
|
||||
} from "@welshman/store"
|
||||
import { load } from "@welshman/net"
|
||||
import { repository } from "@welshman/app"
|
||||
import { Router } from "@welshman/router"
|
||||
import { getTagValue, getTagValues } from "@welshman/util"
|
||||
import type { TrustedEvent } from "@welshman/util"
|
||||
|
||||
const BOOKMARK_KIND = 30003
|
||||
|
||||
type Bookmark = {
|
||||
pubkey: string
|
||||
title: string
|
||||
urls: string[]
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const parseBookmark = (event: TrustedEvent): Bookmark => ({
|
||||
pubkey: event.pubkey,
|
||||
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,
|
||||
filters: [{ kinds: [BOOKMARK_KIND] }],
|
||||
getKey: b => b.pubkey,
|
||||
eventToItem: parseBookmark,
|
||||
})
|
||||
|
||||
// Step 2: Reactive array of all bookmarks
|
||||
const bookmarks = deriveItems(bookmarksByPubkey)
|
||||
|
||||
// Step 3: Synchronous getter for use in callbacks and as getItem for makeLoadItem
|
||||
const getBookmarksByPubkey = getter(bookmarksByPubkey)
|
||||
const getBookmark = (pubkey: string) => getBookmarksByPubkey().get(pubkey)
|
||||
|
||||
// Step 4: Cached async loader — concurrent calls for the same key collapse;
|
||||
// re-fetches only after the timeout window (default: 3600 s)
|
||||
const loadBookmark = makeLoadItem<Bookmark>(
|
||||
async (pubkey: string) => {
|
||||
await load({
|
||||
relays: Router.get().ForPubkey(pubkey).getUrls(),
|
||||
filters: [{ kinds: [BOOKMARK_KIND], authors: [pubkey], limit: 1 }],
|
||||
})
|
||||
},
|
||||
getBookmark,
|
||||
)
|
||||
|
||||
// Step 5: Per-key reactive store factory — loadBookmark is called on each unique
|
||||
// key access (makeDeriveItem passes it as onDerive; makeLoadItem handles dedup)
|
||||
const deriveBookmark = makeDeriveItem(bookmarksByPubkey, loadBookmark)
|
||||
|
||||
// Usage: each call returns Readable<Bookmark | undefined>
|
||||
const aliceBookmark = deriveBookmark("alice-pubkey-hex")
|
||||
aliceBookmark.subscribe($b => console.log($b?.title))
|
||||
```
|
||||
|
||||
## Integration Notes
|
||||
|
||||
- **`@welshman/net`** — provides `Repository` and `Tracker`. `Repository` is the event cache that feeds all store primitives in this package. Events flow from the network into the repository, which triggers store updates automatically.
|
||||
- **`@welshman/util`** — provides `TrustedEvent`, `Filter`, `readProfile`, `readList`, and other event-parsing helpers that feed into `deriveItemsByKey` / `deriveEventsById`.
|
||||
- **`@welshman/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.
|
||||
|
||||
## Gotchas & Tips
|
||||
|
||||
- **`eventToItem` can return `null`/`undefined`** — returning a falsy value from `eventToItem` in `deriveItemsByKey` causes that event to be skipped. Use this to filter out malformed events (e.g. `event.tags.length > 1 ? readList(event) : null`).
|
||||
- **`synced` is async on first read** — the store emits `defaultValue` synchronously, then overwrites it once storage resolves. Always `await store.ready` before reading in server-side or initialization code where you need the persisted value.
|
||||
- **`throttled(0, store)` is a no-op** — it returns the original store unchanged, so it is safe to call with a user-configurable delay that may be zero.
|
||||
- **`makeDeriveItem` is a factory** — call it once to create the lookup function, then call the returned function with a key to get a per-key `Readable`. Do not call `deriveItemsByKey` inside a Svelte `$:` block repeatedly; derive once at module level and pass the store down.
|
||||
- **`makeLoadItem` timeout is in seconds** — the `timeout` option is compared against `now()` from `@welshman/lib`, which returns Unix time in seconds. The default is `3600` (one hour). Use `{ timeout: 30 }` for a 30-second staleness window, not `30_000`.
|
||||
- **`makeLoadItem` uses exponential backoff** — repeated calls for the same key that already has a fresh result (item exists AND was fetched within the timeout window) are returned from cache without re-fetching. If the timeout has elapsed, it will re-fetch even if a previous value exists. Use `makeForceLoadItem` when you explicitly need fresh data.
|
||||
- **`deriveEventsAsc`/`deriveEventsDesc` take a map store** — both functions accept a `Readable<Map<string, TrustedEvent>>` (the output of `deriveEventsById`), not an array store. To sort an array store use `deriveItemsSorted`.
|
||||
- **`getter` vs `withGetter`** — use `getter(store)` when you only need the accessor function; use `withGetter(store)` when you want to keep the full store API (`.subscribe`, `.set`, `.update`) plus `.get()` on the same object.
|
||||
@@ -0,0 +1,675 @@
|
||||
---
|
||||
name: welshman-util
|
||||
description: "Use this skill when working with @welshman/util: nostr event types, kinds, tags, filters, addresses, NIPs (42/86/98), profiles, relays, zaps, wallets, or any core nostr data structures."
|
||||
---
|
||||
|
||||
# welshman/util — Core Nostr Utilities
|
||||
|
||||
`@welshman/util` is the foundational layer of the welshman nostr stack, providing types, constants, and helpers for every nostr primitive: events, kinds, tags, filters, addresses, profiles, lists, zaps, relays, and Lightning wallet integration. Higher level welshman packages (`@welshman/net`, `@welshman/app`, `@welshman/store`, etc.) depend on the types and utilities defined here.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @welshman/util
|
||||
# or
|
||||
pnpm add @welshman/util
|
||||
# or
|
||||
yarn add @welshman/util
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Exports
|
||||
|
||||
### Event Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `EventContent` | `{ tags, content }` — base content structure |
|
||||
| `EventTemplate` | `EventContent + kind` |
|
||||
| `StampedEvent` | `EventTemplate + created_at` |
|
||||
| `OwnedEvent` | `StampedEvent + pubkey` |
|
||||
| `HashedEvent` | `OwnedEvent + id` |
|
||||
| `SignedEvent` | `HashedEvent + sig` |
|
||||
| `TrustedEvent` | `HashedEvent + optional sig` — most common in-app type |
|
||||
| `DecryptedEvent` | `TrustedEvent + plaintext` (for encrypted lists/events) |
|
||||
|
||||
### Event Utilities
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `verifiedSymbol` | Symbol (re-exported from `nostr-tools`) used as a key on events; set `event[verifiedSymbol] = true` to skip signature re-validation |
|
||||
| `makeEvent(kind, opts?)` | Create a `StampedEvent` with optional content, tags, created_at |
|
||||
| `verifyEvent(event)` | Verify event signature; returns `false` for unsigned events (no `sig` field) even if `verifiedSymbol` is set, because `isSignedEvent` is checked first; returns `true` immediately for signed events where `event[verifiedSymbol]` is already set |
|
||||
| `getIdentifier(event)` | Get `d` tag value |
|
||||
| `getIdOrAddress(event)` | Returns address string for replaceable events, id otherwise |
|
||||
| `getIdAndAddress(event)` | Returns array with both id and address (if applicable) |
|
||||
| `deduplicateEvents(events)` | Deduplicate by id or address |
|
||||
| `isEphemeral(event)` | True for ephemeral kinds (20000–29999) |
|
||||
| `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
|
||||
|
||||
`isEventTemplate`, `isStampedEvent`, `isOwnedEvent`, `isHashedEvent`, `isSignedEvent`
|
||||
|
||||
### Event Kinds (constants)
|
||||
|
||||
All constants are exported by name from `@welshman/util`.
|
||||
|
||||
**Core / NIP-01**
|
||||
|
||||
```
|
||||
PROFILE = 0 NOTE = 1 FOLLOWS = 3
|
||||
DELETE = 5 REPOST = 6 REACTION = 7
|
||||
BADGE_AWARD = 8 MESSAGE = 9 THREAD = 11
|
||||
SEAL = 13 DIRECT_MESSAGE = 14 DIRECT_MESSAGE_FILE = 15
|
||||
GENERIC_REPOST = 16 PICTURE_NOTE = 20 VANISH = 62
|
||||
COMMENT = 1111 GENERIC_REPOST = 16
|
||||
```
|
||||
|
||||
**Channels (NIP-28)**
|
||||
|
||||
```
|
||||
CHANNEL_CREATE = 40 CHANNEL_UPDATE = 41 CHANNEL_MESSAGE = 42
|
||||
CHANNEL_HIDE_MESSAGE = 43 CHANNEL_MUTE_USER = 44
|
||||
```
|
||||
|
||||
**Wrapped / encrypted (NIP-59)**
|
||||
|
||||
```
|
||||
WRAP = 1059 WRAP_NIP04 = 1060
|
||||
WRAPPED_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE] // convenience array
|
||||
```
|
||||
|
||||
**Media / files**
|
||||
|
||||
```
|
||||
FILE_METADATA = 1063 PICTURE_NOTE = 20 AUDIO = 31337
|
||||
```
|
||||
|
||||
**Polls**
|
||||
|
||||
```
|
||||
POLL = 1068 POLL_RESPONSE = 1018
|
||||
```
|
||||
|
||||
**Marketplace / auction**
|
||||
|
||||
```
|
||||
BID = 1021 BID_CONFIRMATION = 1022
|
||||
STALL = 30017 PRODUCT = 30018 MARKET_UI = 30019
|
||||
PRODUCT_SOLD_AS_AUCTION = 30020
|
||||
CLASSIFIED = 30402 DRAFT_CLASSIFIED = 30403
|
||||
```
|
||||
|
||||
**Git (NIP-34)**
|
||||
|
||||
```
|
||||
GIT_PATCH = 1617 GIT_ISSUE = 1621 GIT_REPLY = 1622
|
||||
GIT_STATUS_OPEN = 1630 GIT_STATUS_COMPLETE = 1631
|
||||
GIT_STATUS_CLOSED = 1632 GIT_STATUS_DRAFT = 1633
|
||||
GIT_REPOSITORY = 30403
|
||||
```
|
||||
|
||||
**Social / community**
|
||||
|
||||
```
|
||||
REMIX = 1808 REPORT = 1984 LABEL = 1985
|
||||
REVIEW = 1986 HIGHLIGHT = 9802 APPROVAL = 4550
|
||||
NOSTROCKET_PROBLEM = 1971
|
||||
COMMUNITY = 34550
|
||||
BADGE_DEFINITION = 30009 BADGES = 30008
|
||||
LIVE_EVENT = 30311 LIVE_CHAT_MESSAGE = 1311
|
||||
```
|
||||
|
||||
**Rooms (NIP-29)**
|
||||
|
||||
```
|
||||
ROOM_CREATE = 9007 ROOM_DELETE = 9008 ROOM = 35834
|
||||
ROOM_JOIN = 9021 ROOM_LEAVE = 9022 ROOM_META = 39000
|
||||
ROOM_ADMINS = 39001 ROOM_MEMBERS = 39002 ROOM_EDIT_META = 9002
|
||||
ROOM_ADD_MEMBER = 9000 ROOM_REMOVE_MEMBER = 9001
|
||||
ROOM_ADD_PERM = 9003 ROOM_REMOVE_PERM = 9004
|
||||
ROOM_DELETE_EVENT = 9005 ROOM_EDIT_STATUS = 9006
|
||||
ROOM_CREATE_PERMISSION = 19004
|
||||
RELAY_MEMBERS = 13534 RELAY_ADD_MEMBER = 8000 RELAY_REMOVE_MEMBER = 8001
|
||||
RELAY_JOIN = 28934 RELAY_INVITE = 28935 RELAY_LEAVE = 28936
|
||||
```
|
||||
|
||||
**Replaceable lists (kinds 10000–10099)**
|
||||
|
||||
```
|
||||
MUTES = 10000 PINS = 10001 RELAYS = 10002
|
||||
BOOKMARKS = 10003 COMMUNITIES = 10004 CHANNELS = 10005
|
||||
BLOCKED_RELAYS = 10006 SEARCH_RELAYS = 10007 ROOMS = 10009
|
||||
FEEDS = 10014 TOPICS = 10015 EMOJIS = 10030
|
||||
MESSAGING_RELAYS = 10050 BLOSSOM_SERVERS = 10063
|
||||
FILE_SERVERS = 10096
|
||||
```
|
||||
|
||||
**Parameterized replaceable lists (kinds 30000–30102)**
|
||||
|
||||
```
|
||||
NAMED_PEOPLE = 30000 NAMED_RELAYS = 30002 NAMED_BOOKMARKS = 30003
|
||||
NAMED_CURATIONS = 30004 NAMED_TOPICS = 30015
|
||||
NAMED_WIKI_AUTHORS = 30101 NAMED_WIKI_RELAYS = 30102
|
||||
NAMED_EMOJIS = 30030 NAMED_ARTIFACTS = 30063
|
||||
NAMED_COMMUNITIES = 30064
|
||||
```
|
||||
|
||||
**Long-form / wiki / publishing (NIP-23)**
|
||||
|
||||
```
|
||||
LONG_FORM = 30023 LONG_FORM_DRAFT = 30024
|
||||
WIKI = 30818 APP_DATA = 30078
|
||||
FEED = 31890
|
||||
```
|
||||
|
||||
**Calendar (NIP-52)**
|
||||
|
||||
```
|
||||
CALENDAR = 31924 EVENT_DATE = 31922 EVENT_TIME = 31923
|
||||
EVENT_RSVP = 31925
|
||||
```
|
||||
|
||||
**Handlers (NIP-89)**
|
||||
|
||||
```
|
||||
HANDLER_INFORMATION = 31990 HANDLER_RECOMMENDATION = 31989
|
||||
```
|
||||
|
||||
**Status / alerts**
|
||||
|
||||
```
|
||||
STATUS = 30315
|
||||
ALERT_EMAIL = 32830 ALERT_STATUS = 32831 ALERT_WEB = 32832
|
||||
ALERT_ANDROID = 32833 ALERT_IOS = 32834
|
||||
```
|
||||
|
||||
**Zaps / wallet / Lightning**
|
||||
|
||||
```
|
||||
ZAP_GOAL = 9041 ZAP_REQUEST = 9734 ZAP_RESPONSE = 9735
|
||||
WALLET_INFO = 13194 WALLET_REQUEST = 23194 WALLET_RESPONSE = 23195
|
||||
LIGHTNING_PUB_RPC = 21000
|
||||
OTS = 1040
|
||||
```
|
||||
|
||||
**Auth**
|
||||
|
||||
```
|
||||
CLIENT_AUTH = 22242 BLOSSOM_AUTH = 24242 HTTP_AUTH = 27235
|
||||
NOSTR_CONNECT = 24133
|
||||
```
|
||||
|
||||
**Follow packs**
|
||||
|
||||
```
|
||||
FOLLOW_PACK = 39089
|
||||
```
|
||||
|
||||
**Promenade protocol**
|
||||
|
||||
```
|
||||
PROMENADE_REGISTER_ACCOUNT = 16430 PROMENADE_SHARD_SHARE = 26428
|
||||
PROMENADE_SHARD_ACK = 26429 PROMENADE_CONFIG = 26430
|
||||
PROMENADE_COMMIT = 26431 PROMENADE_REQUEST = 26432
|
||||
PROMENADE_RESULT = 26433
|
||||
```
|
||||
|
||||
**Deprecated**
|
||||
|
||||
```
|
||||
DEPRECATED_RELAY_RECOMMENDATION = 2
|
||||
DEPRECATED_DIRECT_MESSAGE = 4
|
||||
DEPRECATED_NAMED_GENERIC = 30001
|
||||
```
|
||||
|
||||
**DVM — Data Vending Machines (NIP-90, kinds 5000–7000)**
|
||||
|
||||
Requests (`5xxx`) and their paired responses (`6xxx`):
|
||||
|
||||
```
|
||||
DVM_REQUEST_TEXT_EXTRACTION = 5000 DVM_RESPONSE_TEXT_EXTRACTION = 6000
|
||||
DVM_REQUEST_TEXT_SUMMARY = 5001 DVM_RESPONSE_TEXT_SUMMARY = 6001
|
||||
DVM_REQUEST_TEXT_TRANSLATION = 5002 DVM_RESPONSE_TEXT_TRANSLATION = 6002
|
||||
DVM_REQUEST_TEXT_GENERATION = 5050 DVM_RESPONSE_TEXT_GENERATION = 6050
|
||||
DVM_REQUEST_IMAGE_GENERATION = 5100 DVM_RESPONSE_IMAGE_GENERATION = 6100
|
||||
DVM_REQUEST_VIDEO_CONVERSION = 5200 DVM_RESPONSE_VIDEO_CONVERSION = 6200
|
||||
DVM_REQUEST_VIDEO_TRANSLATION = 5201 DVM_RESPONSE_VIDEO_TRANSLATION = 6201
|
||||
DVM_REQUEST_IMAGE_TO_VIDEO_CONVERSION = 5202
|
||||
DVM_RESPONSE_IMAGE_TO_VIDEO_CONVERSION = 6202
|
||||
DVM_REQUEST_TEXT_TO_SPEECH = 5250 DVM_RESPONSE_TEXT_TO_SPEECH = 6250
|
||||
DVM_REQUEST_DISCOVER_CONTENT = 5300 DVM_RESPONSE_DISCOVER_CONTENT = 6300
|
||||
DVM_REQUEST_DISCOVER_PEOPLE = 5301 DVM_RESPONSE_DISCOVER_PEOPLE = 6301
|
||||
DVM_REQUEST_SEARCH_CONTENT = 5302 DVM_RESPONSE_SEARCH_CONTENT = 6302
|
||||
DVM_REQUEST_SEARCH_PEOPLE = 5303 DVM_RESPONSE_SEARCH_PEOPLE = 6303
|
||||
DVM_REQUEST_COUNT = 5400 DVM_RESPONSE_COUNT = 6400
|
||||
DVM_REQUEST_MALWARE_SCAN = 5500 DVM_RESPONSE_MALWARE_SCAN = 6500
|
||||
DVM_REQUEST_OTS = 5900 DVM_RESPONSE_OTS = 6900
|
||||
DVM_REQUEST_OP_RETURN = 5901 DVM_RESPONSE_OP_RETURN = 6901
|
||||
DVM_REQUEST_PUBLISH_SCHEDULE = 5905 DVM_RESPONSE_PUBLISH_SCHEDULE = 6905
|
||||
DVM_FEEDBACK = 7000
|
||||
```
|
||||
|
||||
Use `isDVMKind(kind)` to test if a kind falls in the DVM range (5000–7000).
|
||||
|
||||
**Kind classifiers**
|
||||
|
||||
```typescript
|
||||
isRegularKind(kind) // 1000–9999 and select low kinds
|
||||
isPlainReplaceableKind(kind) // 0, 3, and 10000–19999
|
||||
isEphemeralKind(kind) // 20000–29999
|
||||
isParameterizedReplaceableKind(kind) // 30000–39999
|
||||
isReplaceableKind(kind) // plain OR parameterized replaceable
|
||||
isDVMKind(kind) // 5000–7000
|
||||
```
|
||||
|
||||
### Tags
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `getTags(types, tags)` | Get all tags matching one or more type strings |
|
||||
| `getTag(types, tags)` | Get first matching tag |
|
||||
| `getTagValues(types, tags)` | Get value (index 1) of all matching tags — types first, then the tags array |
|
||||
| `getTagValue(types, tags)` | Get value of first matching tag — types first, then the tags array |
|
||||
| `getEventTags(tags)` | `e` tags |
|
||||
| `getEventTagValues(tags)` | Values of `e` tags |
|
||||
| `getAddressTags(tags)` | `a` tags |
|
||||
| `getAddressTagValues(tags)` | Values of `a` tags |
|
||||
| `getPubkeyTags(tags)` | `p` tags |
|
||||
| `getPubkeyTagValues(tags)` | Values of `p` tags |
|
||||
| `getTopicTags(tags)` / `getTopicTagValues(tags)` | `t` (hashtag) tags |
|
||||
| `getRelayTags(tags)` / `getRelayTagValues(tags)` | `r` and `relay` tags |
|
||||
| `getKindTags(tags)` / `getKindTagValues(tags)` | `k` tags (returns `number[]`) |
|
||||
| `getGroupTags(tags)` / `getGroupTagValues(tags)` | group tags |
|
||||
| `getReplyTags(tags)` | `{ roots, replies, mentions }` — NIP-10 threading |
|
||||
| `getCommentTags(tags)` | `{ roots, replies }` — NIP-22 uppercase/lowercase tags |
|
||||
| `uniqTags(tags)` | Remove duplicate tags |
|
||||
| `tagsFromIMeta(imeta)` | Parse `imeta` tag into array of tag arrays |
|
||||
|
||||
### Filters
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `matchFilter(filter, event)` | Test if event matches a single filter |
|
||||
| `matchFilters(filters, event)` | Test if event matches any filter |
|
||||
| `getIdFilters(idsOrAddresses)` | Build filters from mixed ids and addresses |
|
||||
| `getReplyFilters(events, filter?)` | Build filters to find replies |
|
||||
| `addRepostFilters(filters)` | Add kind 6/16 repost filters |
|
||||
| `unionFilters(filters)` | Merge overlapping filters |
|
||||
| `intersectFilters(groups)` | Intersect arrays of filter groups |
|
||||
| `trimFilter(filter)` / `trimFilters(filters)` | Limit array fields to 1000 items |
|
||||
| `getFilterId(filter)` | Compact string key for a filter |
|
||||
| `getFilterGenerality(filter)` | 0 = specific, 1 = general |
|
||||
| `guessFilterDelta(filters, max?)` | Estimate appropriate time window in seconds |
|
||||
| `getFilterResultCardinality(filter)` | Expected result count for id-based filters |
|
||||
|
||||
### Address
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `Address` class | Handles `kind:pubkey:identifier` and NIP-19 naddr format |
|
||||
| `Address.isAddress(s)` | Validate address string format |
|
||||
| `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 |
|
||||
| `isLocalUrl(url)` | Local address check |
|
||||
| `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)
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `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
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `WalletType` | Enum: `WebLN`, `NWC` |
|
||||
| `Wallet` | Union: `WebLNWallet | NWCWallet` |
|
||||
| `isWebLNWallet(wallet)` | Type guard |
|
||||
| `isNWCWallet(wallet)` | Type guard |
|
||||
|
||||
### NIP-42 (Relay Auth)
|
||||
|
||||
```typescript
|
||||
makeRelayAuth(url: string, challenge: string): StampedEvent
|
||||
// Creates kind 22242 auth event; sign before sending
|
||||
```
|
||||
|
||||
### NIP-98 (HTTP Auth)
|
||||
|
||||
```typescript
|
||||
makeHttpAuth(url: string, method?: string, body?: string): Promise<StampedEvent>
|
||||
makeHttpAuthHeader(event: SignedEvent): string // Returns "Nostr <base64>"
|
||||
```
|
||||
|
||||
### NIP-86 (Relay Management)
|
||||
|
||||
```typescript
|
||||
sendManagementRequest(url: string, request: ManagementRequest, authEvent: SignedEvent): Promise<ManagementResponse>
|
||||
// ManagementResponse = { result?: any; error?: string }
|
||||
// ManagementMethod enum covers: BanPubkey, AllowPubkey, BanEvent, AllowEvent, etc.
|
||||
```
|
||||
|
||||
### Handlers (NIP-89)
|
||||
|
||||
```typescript
|
||||
readHandlers(event: TrustedEvent): Handler[]
|
||||
getHandlerKey(handler: Handler): string // "kind:address" format
|
||||
getHandlerAddress(event: TrustedEvent): string | undefined
|
||||
displayHandler(handler?: Handler, fallback?: string): string
|
||||
```
|
||||
|
||||
### Links
|
||||
|
||||
```typescript
|
||||
fromNostrURI(s: string): string // strips "nostr:" or "nostr://" prefix
|
||||
toNostrURI(s: string): string // ensures "nostr:" prefix
|
||||
```
|
||||
|
||||
### Blossom (Media Servers)
|
||||
|
||||
```typescript
|
||||
makeBlossomAuthEvent(opts: BlossomAuthEventOpts): StampedEvent
|
||||
uploadBlob(server, blob, opts?): Promise<Response>
|
||||
getBlob(server, sha256, opts?): Promise<Response>
|
||||
deleteBlob(server, sha256, opts?): Promise<Response>
|
||||
listBlobs(server, pubkey, opts?): Promise<Response>
|
||||
checkBlobExists(server, sha256, opts?): Promise<{exists, size?}>
|
||||
buildBlobUrl(server, sha256, extension?): string
|
||||
encryptFile(file: Blob): Promise<EncryptedFile>
|
||||
decryptFile(encryptedFile: EncryptedFile): Promise<Uint8Array>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Creating and inspecting events
|
||||
|
||||
```typescript
|
||||
import { makeEvent, NOTE, PROFILE, RELAYS, LONG_FORM, getIdentifier, getIdOrAddress } from '@welshman/util'
|
||||
|
||||
// Text note (kind 1)
|
||||
const note = makeEvent(NOTE, {
|
||||
content: 'Hello Nostr!',
|
||||
tags: [['t', 'nostr']],
|
||||
})
|
||||
|
||||
// Profile update (kind 0)
|
||||
const profile = makeEvent(PROFILE, {
|
||||
content: JSON.stringify({ name: 'Alice', about: 'Nostr dev' }),
|
||||
tags: [],
|
||||
})
|
||||
|
||||
// Relay list (kind 10002)
|
||||
const relayList = makeEvent(RELAYS, {
|
||||
content: '',
|
||||
tags: [
|
||||
['r', 'wss://relay.example.com', 'read'],
|
||||
['r', 'wss://relay2.example.com', 'write'],
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Pre-verifying persisted events with verifiedSymbol
|
||||
|
||||
When loading events from a local store (IndexedDB, localStorage, etc.) at startup, you
|
||||
can skip expensive signature re-validation by marking them as already verified:
|
||||
|
||||
```typescript
|
||||
import { verifiedSymbol } from '@welshman/util'
|
||||
import type { TrustedEvent } from '@welshman/util'
|
||||
|
||||
// Load from storage
|
||||
const storedEvents: TrustedEvent[] = await db.getAll('events')
|
||||
|
||||
// Mark as pre-verified — verifyEvent() will return true immediately (without
|
||||
// re-running the cryptographic check) for events that have a sig field
|
||||
for (const event of storedEvents) {
|
||||
event[verifiedSymbol] = true
|
||||
}
|
||||
|
||||
repository.load(storedEvents)
|
||||
```
|
||||
|
||||
Only do this for events you persisted yourself after they were validated. Never set
|
||||
`verifiedSymbol` on events received directly from untrusted external sources.
|
||||
|
||||
### Working with tags
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
getPubkeyTagValues,
|
||||
getTopicTagValues,
|
||||
getRelayTagValues,
|
||||
getReplyTags,
|
||||
uniqTags,
|
||||
} from '@welshman/util'
|
||||
|
||||
// getTagValue and getTagValues: types argument FIRST, then the tags array
|
||||
const title = getTagValue('title', event.tags) // string | undefined
|
||||
const urls = getTagValues('r', event.tags) // string[]
|
||||
|
||||
// Multiple types at once
|
||||
const ids = getTagValues(['e', 'a'], event.tags) // string[]
|
||||
|
||||
const mentions = getPubkeyTagValues(event.tags) // string[]
|
||||
const topics = getTopicTagValues(event.tags) // string[]
|
||||
const relays = getRelayTagValues(event.tags) // string[]
|
||||
|
||||
// NIP-10 thread context
|
||||
const { roots, replies, mentions: threadMentions } = getReplyTags(event.tags)
|
||||
```
|
||||
|
||||
### Matching and building filters
|
||||
|
||||
```typescript
|
||||
import { matchFilters, getIdFilters, getReplyFilters, addRepostFilters, NOTE } from '@welshman/util'
|
||||
import { ago, HOUR } from '@welshman/lib'
|
||||
|
||||
// Does this event match our subscription?
|
||||
const active = matchFilters([{ kinds: [NOTE], authors: [myPubkey] }], event)
|
||||
|
||||
// Fetch a set of events by id or address
|
||||
const filters = getIdFilters([
|
||||
'abc123', // event id
|
||||
'30023:deadbeef:my-slug', // address
|
||||
])
|
||||
|
||||
// Find all replies to a set of events
|
||||
const replyFilters = getReplyFilters(events, { since: ago(HOUR) })
|
||||
|
||||
// Automatically include repost kinds
|
||||
const withReposts = addRepostFilters([{ kinds: [NOTE] }])
|
||||
```
|
||||
|
||||
### Addresses for replaceable events
|
||||
|
||||
```typescript
|
||||
import { Address, getAddress } from '@welshman/util'
|
||||
|
||||
// From an addressable event
|
||||
const addr = Address.fromEvent(event, ['wss://relay.example.com'])
|
||||
console.log(addr.toString()) // '30023:deadbeef:my-slug'
|
||||
console.log(addr.toNaddr()) // 'naddr1...'
|
||||
|
||||
// Round-trip from naddr
|
||||
const parsed = Address.fromNaddr('naddr1...')
|
||||
|
||||
// Quick string form
|
||||
const addressStr = getAddress(event) // '30023:deadbeef:my-slug'
|
||||
```
|
||||
|
||||
### Profiles
|
||||
|
||||
```typescript
|
||||
import { readProfile, displayProfile, displayPubkey, editProfile } from '@welshman/util'
|
||||
|
||||
const profile = readProfile(kind0Event)
|
||||
console.log(displayProfile(profile, 'Anonymous')) // name or fallback
|
||||
console.log(displayPubkey(pubkey)) // 'npub1abc...xyz'
|
||||
|
||||
// Update profile
|
||||
const updatedEvent = editProfile({ ...profile, name: 'New Name', about: 'Updated bio' })
|
||||
// sign and publish updatedEvent
|
||||
```
|
||||
|
||||
### Zap flow
|
||||
|
||||
```typescript
|
||||
import { getLnUrl, makeEvent, ZAP_REQUEST, zapFromEvent } from '@welshman/util'
|
||||
|
||||
// Step 1: resolve LNURL
|
||||
const lnurl = getLnUrl('satoshi@getalby.com')
|
||||
if (!lnurl) throw new Error('Invalid lightning address')
|
||||
|
||||
// Step 2: build zap request (kind 9734)
|
||||
const zapRequest = makeEvent(ZAP_REQUEST, {
|
||||
content: 'Great post!',
|
||||
tags: [
|
||||
['p', recipientPubkey],
|
||||
['e', targetEventId],
|
||||
['amount', '5000'], // millisats
|
||||
['lnurl', lnurl],
|
||||
['relays', 'wss://relay.damus.io'],
|
||||
],
|
||||
})
|
||||
|
||||
// Step 3: sign, send to LNURL callback, pay invoice...
|
||||
|
||||
// Step 4: validate receipt (kind 9735)
|
||||
const zap = zapFromEvent(zapReceipt, { nostrPubkey: zapperPubkey, allowsNostr: true, lnurl })
|
||||
if (zap) {
|
||||
console.log(`Received ${zap.invoiceAmount} msat`, zap.request.content)
|
||||
}
|
||||
```
|
||||
|
||||
### NIP-42 relay authentication
|
||||
|
||||
```typescript
|
||||
import { makeRelayAuth } from '@welshman/util'
|
||||
|
||||
// Inside relay AUTH handler
|
||||
const authEvent = makeRelayAuth('wss://relay.example.com', challengeFromRelay)
|
||||
const signed = await signer.sign(authEvent)
|
||||
// send signed AUTH message to relay
|
||||
```
|
||||
|
||||
### NIP-98 HTTP authentication
|
||||
|
||||
```typescript
|
||||
import { makeHttpAuth, makeHttpAuthHeader } from '@welshman/util'
|
||||
|
||||
const body = JSON.stringify({ data: 'example' })
|
||||
const authEvent = await makeHttpAuth('https://api.example.com/upload', 'POST', body)
|
||||
const signed = await signer.signEvent(authEvent)
|
||||
|
||||
await fetch('https://api.example.com/upload', {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: {
|
||||
Authorization: makeHttpAuthHeader(signed),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Notes
|
||||
|
||||
- **`@welshman/net`** — uses `TrustedEvent`, `Filter`, `SignedEvent` from this package as the wire types for relay connections and subscriptions.
|
||||
- **`@welshman/store`** — provides Svelte stores over repositories built on `TrustedEvent`; relies on `isReplaceable`, `getAddress`, etc. for deduplication.
|
||||
- **`@welshman/app`** — high-level application layer; wraps net/store/router and uses profile, list, zap, and handler helpers from this package.
|
||||
- **`@welshman/router`** — uses `RelayMode` and relay URL helpers when computing relay selections.
|
||||
- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here; the `Encrypt` function type used by `Encryptable` is typically provided by a signer.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas & Tips
|
||||
|
||||
- **`TrustedEvent` vs `SignedEvent`**: Most in-app code should accept `TrustedEvent` (has id, may have sig). Only require `SignedEvent` when you need to ensure the event has a signature.
|
||||
|
||||
- **Replaceable event identity**: Use `getIdOrAddress` rather than `event.id` when referencing events that may be addressable — the address string is stable across updates, the id is not.
|
||||
|
||||
- **`getAncestors` handles two protocols**: Kind 1111 (comment/NIP-22) uses uppercase `E`/`A` for roots and lowercase for replies, returning `{ roots, replies }`. All other kinds use NIP-10 positional rules, returning `{ roots, replies, mentions }` where `mentions` is always present but may be an empty array. You do not need to branch on this; `getAncestors`, `getParentIdOrAddr`, and `isChildOf` handle it automatically.
|
||||
|
||||
- **List mutations return `Encryptable`**: `addToListPrivately`, `removeFromList`, etc. do not return an event directly. Call `.reconcile(encryptFn)` on the result to get the final `EventTemplate` ready to sign.
|
||||
|
||||
- **`zapFromEvent` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result.
|
||||
|
||||
- **`getLnUrl` handles three input forms**: bare lightning address (`user@domain`), full HTTPS URL, or already-encoded `lnurl1...`. Returns `undefined` for anything else.
|
||||
|
||||
- **`normalizeRelayUrl` vs `displayRelayUrl`**: Use `normalizeRelayUrl` before storing or comparing relay URLs. Use `displayRelayUrl` only for human-readable display (strips protocol/trailing slash).
|
||||
|
||||
- **`Address.isAddress`** checks the `kind:pubkey:identifier` format only, not naddr. To validate an naddr string, use `Address.fromNaddr` inside a try/catch.
|
||||
|
||||
- **`getTagValue` / `getTagValues` argument order**: the type(s) come **first**, the tags array comes **second** — `getTagValue('title', event.tags)`. This is the opposite of the specialized helpers like `getEventTags(tags)` which take only the tags array. Mixing up the order produces no TypeScript error but silently returns `undefined` or `[]`.
|
||||
|
||||
- **`verifiedSymbol` is a Symbol key**: you must import `verifiedSymbol` from `@welshman/util` and use it as a computed property key — `event[verifiedSymbol] = true`. You cannot use a string key. The symbol is re-exported from `nostr-tools/pure`, so it is the same identity as the one used internally by `verifyEvent`.
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
name: welshman
|
||||
description: "Use this skill for general welshman questions: architecture overview, which package to use, getting started, nostr concepts, or when you're unsure which sub-skill applies. Welshman is a modular TypeScript nostr toolkit for building client applications."
|
||||
---
|
||||
|
||||
## What is welshman
|
||||
|
||||
Welshman is a modular TypeScript nostr toolkit extracted from the [Coracle](https://coracle.social) nostr client, designed for building highly configurable nostr client applications. It is production-tested, powering both Coracle and [Flotilla](https://flotilla.social). Packages are independent and opt-in — you can grab a single utility or use the full batteries-included framework.
|
||||
|
||||
## Package map
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| `@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/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` | 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 |
|
||||
|
||||
## Dependency layering
|
||||
|
||||
Packages are layered so lower-level ones have no welshman dependencies:
|
||||
|
||||
- **Foundational** (no welshman deps): `@welshman/lib`, `@welshman/util`
|
||||
- **Mid-level** (depend only on foundational): `@welshman/net`, `@welshman/router`, `@welshman/store`, `@welshman/signer`
|
||||
- **Composing** (depend on mid-level + foundational): `@welshman/feeds`, `@welshman/app`
|
||||
- **UI-focused** (largely independent, UI rendering concerns): `@welshman/content`, `@welshman/editor`
|
||||
|
||||
For deep-dives on any package, load the `welshman-<name>` skill (e.g. `welshman-net`, `welshman-app`, `welshman-signer`).
|
||||
|
||||
## Getting started
|
||||
|
||||
Install only what you need:
|
||||
|
||||
```bash
|
||||
# Full application framework (includes app, net, router, store, signer, feeds)
|
||||
npm i @welshman/app
|
||||
|
||||
# Or assemble manually for more control
|
||||
npm i @welshman/util @welshman/net @welshman/signer
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
- **event** — the fundamental data unit in nostr; a JSON object signed by a keypair
|
||||
- **kind** — integer field on an event that determines its type (e.g. kind 1 = short text note, kind 0 = profile metadata)
|
||||
- **filter** — a query object (`{kinds, authors, since, until, limit, ...}`) sent to relays to request matching events
|
||||
- **relay** — a WebSocket server that stores and forwards nostr events; clients connect to multiple relays
|
||||
- **NIP** — "Nostr Implementation Possibility"; numbered specifications defining protocol behavior and event kinds
|
||||
- **pubkey** — 32-byte hex public key that identifies a nostr user
|
||||
- **signer** — abstraction over key management; handles signing events and optionally encryption, regardless of where the private key lives (in-memory, browser extension, remote bunker, mobile app)
|
||||
|
||||
## Common use-case routing
|
||||
|
||||
| Goal | Package(s) to use |
|
||||
|---|---|
|
||||
| Fetch notes from relays | `@welshman/net` (low-level) or `@welshman/app` (high-level) |
|
||||
| Select which relays to use | `@welshman/router` |
|
||||
| Sign and publish events | `@welshman/signer` + `@welshman/net` |
|
||||
| Build a feed UI | `@welshman/feeds` + `@welshman/app` |
|
||||
| Parse note text and media | `@welshman/content` |
|
||||
| Embed a composer / editor | `@welshman/editor` |
|
||||
| Cache nostr events client-side | `@welshman/store` |
|
||||
| Core event/filter utilities | `@welshman/util` |
|
||||
| Low-level helpers (LRU, emitter, utility functions) | `@welshman/lib` |
|
||||
|
||||
### App Example
|
||||
|
||||
```typescript
|
||||
import "@welshman/app" // side effects: wires pool → repository + tracker + router
|
||||
|
||||
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" })
|
||||
},
|
||||
})
|
||||
|
||||
const stored: TrustedEvent[] = await db.getAll("events")
|
||||
for (const e of stored) e[verifiedSymbol] = true
|
||||
repository.load(stored)
|
||||
|
||||
// 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. Log in
|
||||
const pk = await window.nostr.getPublicKey()
|
||||
loginWithNip07(pk)
|
||||
|
||||
// 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
|
||||
```
|
||||
|
||||
### Lower-level Example
|
||||
|
||||
```typescript
|
||||
import { AbstractAdapter, ClientMessage, NetContext, isClientEvent, netContext, publish, request } from '@welshman/net'
|
||||
import { call, sleep } from '@welshman/lib'
|
||||
import { Nip01Signer } from '@welshman/signer'
|
||||
import { makeEvent, NOTE } from '@welshman/util'
|
||||
|
||||
const pingSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
|
||||
const pongSigner = Nip01Signer.fromSecret(/* nostr hex secret key */)
|
||||
const RELAY_URL = "bogus.relay"
|
||||
|
||||
// Create an adapter for our relay url which just prints the content
|
||||
export class PrintAdapter extends AbstractAdapter {
|
||||
get sockets() { return [] }
|
||||
get urls() { return [] }
|
||||
send = (message: ClientMessage) => {
|
||||
if (isClientEvent(message)) {
|
||||
const [_, event] = message
|
||||
console.log(event.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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'})
|
||||
)
|
||||
|
||||
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()]}],
|
||||
onEvent: async (ping, url) => {
|
||||
const pong = await pongSigner.sign(
|
||||
makeEvent(NOTE, {content: 'pong', tags: [["q", ping.id, RELAY_URL, ping.pubkey]]})
|
||||
)
|
||||
|
||||
await publish({event: pong, relays: [RELAY_URL]})
|
||||
},
|
||||
})
|
||||
})
|
||||
```
|
||||
@@ -2,3 +2,10 @@ node_modules
|
||||
android
|
||||
ios
|
||||
build
|
||||
|
||||
# Git
|
||||
.gitignore
|
||||
|
||||
# Env files (keep .env for build; exclude local overrides)
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3
|
||||
VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/
|
||||
VITE_POMADE_SIGNERS=
|
||||
VITE_DEFAULT_SPACES=https://chat.flotilla.social/
|
||||
VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works
|
||||
VITE_PLATFORM_URL=https://app.flotilla.social
|
||||
VITE_PLATFORM_TERMS=https://flotilla.social/terms
|
||||
VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
|
||||
@@ -15,8 +16,10 @@ VITE_PUSH_BRIDGE=wss://npb.coracle.social/
|
||||
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi
|
||||
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
|
||||
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
|
||||
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com
|
||||
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=
|
||||
@@ -1,4 +1,6 @@
|
||||
src/assets
|
||||
.claude
|
||||
target
|
||||
build
|
||||
.idea
|
||||
.gradle
|
||||
@@ -12,4 +14,5 @@ ios/App/Pods/
|
||||
android/capacitor-cordova-android-plugins
|
||||
android/app/src/androidTest
|
||||
android/app/src/test
|
||||
|
||||
node_modules
|
||||
.svelte-kit
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
name: Docker
|
||||
name: Container Image Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
REGISTRY: gitea.coracle.social
|
||||
IMAGE_NAME: coracle/flotilla
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
@@ -23,8 +28,8 @@ jobs:
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
username: hodlbod
|
||||
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
@@ -32,6 +37,7 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -45,6 +51,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
+15
-3
@@ -1,10 +1,16 @@
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# Generated assets
|
||||
static/favicon.ico
|
||||
static/pwa-64x64.png
|
||||
@@ -24,8 +30,11 @@ android/app/src/main/assets/public/
|
||||
|
||||
# Web/JavaScript
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
build/
|
||||
build-server/
|
||||
.svelte-kit/
|
||||
.next/
|
||||
|
||||
# iOS
|
||||
ios/App/App/public
|
||||
@@ -61,9 +70,12 @@ GoogleService-Info.plist
|
||||
|
||||
# IDEs and editors
|
||||
.roo
|
||||
.idea/
|
||||
.vscode/
|
||||
.idea
|
||||
.vscode
|
||||
.claude
|
||||
.local
|
||||
|
||||
# OS generated
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
package-lock.json
|
||||
|
||||
@@ -157,7 +157,7 @@ src/
|
||||
- Derive all other data inside the component from identifiers
|
||||
- Example: Don't pass `members` prop, derive it from `h` inside component
|
||||
|
||||
**Code Style:**
|
||||
**CRITICAL Code Style Guidelines:**
|
||||
|
||||
- **No `null`** - only use `undefined`
|
||||
- Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components
|
||||
@@ -168,6 +168,17 @@ src/
|
||||
- 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
|
||||
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
|
||||
|
||||
**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.
|
||||
- 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.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
||||
+110
@@ -1,5 +1,115 @@
|
||||
# Changelog
|
||||
|
||||
# 1.8.0
|
||||
|
||||
* Fix relay badge overflow
|
||||
* Suppress programmatic scroll when user is scrolling
|
||||
* Fix vertical alignment of emoji and overflow buttons in shared event action row
|
||||
* Use type=email for signup/login email inputs, validate password
|
||||
* Improve toggle switch placement on settings screens
|
||||
* Fix relay auth privacy toggle
|
||||
* Improve field layout
|
||||
* Add progress bar to signup flow
|
||||
* Bundle emojis properly
|
||||
* Rework hosting page
|
||||
* Fix padding on pages on small screens
|
||||
* Add richer link preview support
|
||||
* Fix pasting into event summary
|
||||
* Publish fewer join/claim requests
|
||||
* Fix new messages not rendering in safari
|
||||
* Avoid capturing stale cleanup function in chat
|
||||
* Hide keyboard on app resume
|
||||
* Add email rendering support
|
||||
* Fix bunker login
|
||||
* Fix undefined chat draft key
|
||||
* Allow sharing to chat without a message
|
||||
* Make sure to show date on calendar events when embedded
|
||||
* Improve space search
|
||||
|
||||
# 1.7.4
|
||||
|
||||
* Fix safe area inset for FAB
|
||||
|
||||
# 1.7.3
|
||||
|
||||
* Add native share support for space invites
|
||||
* Stop sending duplicate requests per room
|
||||
* Add more robust thumbnail url generation
|
||||
* Make space reordering discoverable with smoother drag animation
|
||||
* Improve relay member list
|
||||
* Add room mentions and clickable room/relay refs
|
||||
* Support native clipboard image paste on mobile
|
||||
* publish kind 9 quote after room content creation for cross-client interoperability
|
||||
* Improve feed pagination logic and performance
|
||||
* Support Aegis URL scheme for NIP-46 login
|
||||
* Various UI and bug fixes
|
||||
* Raise message size limit in chat
|
||||
* Fix realtime updates for room members and admins
|
||||
* Add video to calls
|
||||
* Remove follow graph building
|
||||
* Add start chat FAB
|
||||
* Add drafts
|
||||
* Redesign toast notifications
|
||||
* Remove room/space leave indications
|
||||
* Hide report badge for non-admin users
|
||||
* Add polls
|
||||
* Add search to recent activity page
|
||||
* Fix notification badge on mobile nav
|
||||
* Change audio devices in call
|
||||
|
||||
# 1.7.2
|
||||
|
||||
* Fix race condition in nip 46
|
||||
* Remove duplicate spaces button
|
||||
* Combine discover and space list pages
|
||||
* Fix some chat related bugs
|
||||
* Fix bug with joining spaces
|
||||
|
||||
# 1.7.1
|
||||
|
||||
* Fix pomade registration fallback in case of offline signer
|
||||
|
||||
# 1.7.0
|
||||
|
||||
* Enable email/password login
|
||||
* Add up/edit to direct messages
|
||||
* Fix a number of UI bugs
|
||||
* Improve navigation on mobile
|
||||
* Improve performance and syncing reliability
|
||||
* Add proof of work to DMs
|
||||
* Detect blossom support using supported_nips
|
||||
* Improve notification badges
|
||||
* Add voice rooms (@mplorentz)
|
||||
* Re-design relay onboarding and settings
|
||||
* Add android fallback for push notifications
|
||||
* Fix file uploads on android
|
||||
|
||||
# 1.6.5
|
||||
|
||||
* Attempt to fix permission grant for notifications
|
||||
* Make sync logic more robust
|
||||
* Add unban/unallow support
|
||||
* Improve support for downloading/opening protected images
|
||||
* Add manual send/receive to wallet
|
||||
* Show wallet status when wallet is unreachable
|
||||
* Update nostr signer capacitor plugin
|
||||
* Fix some safe area insets
|
||||
* Update NIP 55 signer plugin (fixes Primal login)
|
||||
* Refine space join dialogs and discover page
|
||||
* Reopen the last DM that was open when navigating back to chat
|
||||
* Get rid of ChatEnable interstitial
|
||||
* Enable auth for relays we're publishing to
|
||||
* Drag and drop space icons
|
||||
* Add better muting support
|
||||
* Add back button to settings menu
|
||||
* Add page titles
|
||||
* Improve scroll to event behavior
|
||||
* Add in-memory search to rooms
|
||||
* Fix editing messages with html tags
|
||||
* Fix DM media detection
|
||||
* Clean up reporting dialogs
|
||||
* Improve room detail
|
||||
|
||||
# 1.6.4
|
||||
|
||||
* Clean up modal design
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
## Project Overview
|
||||
|
||||
Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-29 (relay-based groups) to create Discord-like spaces (servers) and rooms (channels).
|
||||
|
||||
Please visit our [issue tracker](https://gitea.coracle.social/coracle/flotilla/issues) to contribute. Any new issues should be opened without a milestone, label, or project and the project owners will triage them.
|
||||
|
||||
### Milestones
|
||||
|
||||
Milestones indicate how soon a given task should be tackled.
|
||||
|
||||
- [Current](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=1) issues are immediately actionable.
|
||||
- [Next](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=2) means an issue is blocked.
|
||||
- [Future](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&milestone=3) means we're deferring work until a later date.
|
||||
|
||||
### Labels
|
||||
|
||||
- [Design](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=15) issues need design work before being implemented. This might take the form of a high-quality mockup, wireframes, user flows, or just a couple notes about where things go, depending on the nature of the task.
|
||||
- [Dev](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=16) issues are ready to be implemented. Most of the work will be related to architecting and writing code.
|
||||
- [Easy](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=14) issues have no dependencies, and are scoped quite narrowly.
|
||||
- [Priority](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=6) issues include bugs and urgent feature requests. These should get attention first if possible, although sometimes long-standing performance issues or subtle bugs might end up here for a while.
|
||||
- [Ideas](https://gitea.coracle.social/coracle/flotilla/issues?type=all&state=open&labels=13) are for things that aren't scoped out yet, or which need protocol work before getting designed or implemented.
|
||||
|
||||
### Projects
|
||||
|
||||
Issues may or may not have a project. Projects are used to group issues thematically just for organization.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
There are a few conventions that are helpful to know right out of the gate.
|
||||
|
||||
- Most nostr protocol functionality is implemented via the [welshman library](https://welshman.coracle.social/)
|
||||
- Use Svelte 4 **stores** rather than runes for all state outside UI components
|
||||
- Most global state flows through Welshman's `repository` (unidirectional)
|
||||
- Query state using `deriveEventsMapped` or `deriveProfile` etc
|
||||
- Events are published via `publishThunk`, which allows for optimistic UI updates during signing/pow generation.
|
||||
- Components should have minimal props - e.g. instead of passing a whole `relay` through, pass its `url`.
|
||||
- Use `AbortController` when possible instead of request ids
|
||||
- Use `undefined` or optional properties instead of `null`
|
||||
- Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect.
|
||||
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||
- When dynamically building classes, use `cx` from `classnames`.
|
||||
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates.
|
||||
|
||||
## Contributing Workflow
|
||||
|
||||
To contribute, do the following:
|
||||
|
||||
- Find or create an issue and assign yourself (comment instead if you're not able to self-assign)
|
||||
- If the issue is a design task, attach or link out to any mockups/wireframes/flowcharts
|
||||
- Once a design task is completed, a maintainer will remove the `design` label and add the `dev` label
|
||||
- If the issue is a development task, fork the repository and create a branch prefixed by the issue number, e.g. `105-deep-links`
|
||||
- Before requesting a review, be sure to review any agent-generated code, run the pre-commit hooks, and test the changes.
|
||||
- Open a PR and request a review. A maintainer will get back to you with requested changes, or will merge the PR.
|
||||
- Keep your PR up-to-date by rebasing frequently on `dev`. Avoid force-pushing to `dev`.
|
||||
- PRs are rebased, squashed, and merged to keep commit history simple.
|
||||
- An issue may have multiple PRs. Once complete, it can be closed.
|
||||
+25
-18
@@ -1,24 +1,31 @@
|
||||
FROM node:20-slim
|
||||
# Build and run the Flotilla web server.
|
||||
#
|
||||
# docker build -t flotilla .
|
||||
# docker run -p 3000:3000 flotilla
|
||||
#
|
||||
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
|
||||
# A .env in the build context is picked up by build.sh for branding config.
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@latest
|
||||
# https://pnpm.io/docker#example-3-build-on-cicd
|
||||
FROM node:24-slim AS builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm i
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
ENV NODE_OPTIONS=--max_old_space_size=16384
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm i --frozen-lockfile
|
||||
COPY . .
|
||||
ARG VITE_BUILD_HASH
|
||||
RUN pnpm run build
|
||||
RUN pnpm run build:server
|
||||
|
||||
# Default to serving the build directory
|
||||
CMD ["npx", "serve", "build"]
|
||||
|
||||
FROM node:24-slim AS production
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/build /app/build
|
||||
COPY --from=builder /app/build-server/server.js /app/server.js
|
||||
EXPOSE 3000
|
||||
USER node
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -6,21 +6,44 @@ If you would like to be interoperable with Flotilla, please check out this guide
|
||||
|
||||
## Environment
|
||||
|
||||
You can also optionally create an `.env` file and populate it with the following environment variables (see `.env` for examples):
|
||||
You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` for examples):
|
||||
|
||||
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
|
||||
**Platform branding**
|
||||
- `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
|
||||
- `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
|
||||
- `VITE_PLATFORM_LOGO` - A logo url for the app. Can be a local path or https link. Must be a PNG file.
|
||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||
- `VITE_PLATFORM_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
|
||||
|
||||
**Platform mode**
|
||||
- `VITE_PLATFORM_RELAYS` - A comma-separated list of relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page.
|
||||
|
||||
**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
|
||||
- `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
|
||||
|
||||
**Infrastructure**
|
||||
- `VITE_INDEXER_RELAYS` - A comma-separated list of relay urls used for user profile/key lookup
|
||||
- `VITE_SIGNER_RELAYS` - A comma-separated list of relay urls used for NIP-55 remote signers
|
||||
- `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
|
||||
|
||||
These values **won't** be used for a built version. Instead, env variables should be provided to `build.sh` directly or to the built container.
|
||||
|
||||
If you're deploying a custom version of flotilla, be sure to remove the `plausible.coracle.social` script from `app.html`. This sends analytics to a server hosted by the developer.
|
||||
|
||||
## Development
|
||||
|
||||
See [./CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -29,18 +52,18 @@ To run your own Flotilla, it's as simple as:
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm run build
|
||||
npx serve build
|
||||
pnpm run start
|
||||
```
|
||||
|
||||
Or, if you prefer to use a container:
|
||||
|
||||
```sh
|
||||
podman run -d -p 3000:3000 ghcr.io/coracle-social/flotilla:latest
|
||||
docker run -d -p 3000:3000 gitea.coracle.social/coracle/flotilla:latest
|
||||
```
|
||||
|
||||
Alternatively, you can copy the build files into a directory of your choice and serve it yourself:
|
||||
|
||||
```sh
|
||||
mkdir ./mount
|
||||
podman run -v ./mount:/app/mount ghcr.io/coracle-social/flotilla:latest bash -c 'cp -r build/* mount'
|
||||
docker run -v ./mount:/app/mount gitea.coracle.social/coracle/flotilla:latest bash -c 'cp -r build/* mount'
|
||||
```
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
android {
|
||||
namespace = "social.flotilla"
|
||||
@@ -7,8 +8,8 @@ android {
|
||||
applicationId "social.flotilla"
|
||||
minSdk rootProject.ext.minSdkVersion
|
||||
targetSdk rootProject.ext.targetSdkVersion
|
||||
versionCode 40
|
||||
versionName "1.6.4"
|
||||
versionCode 47
|
||||
versionName "1.8.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -35,6 +36,10 @@ dependencies {
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation "androidx.work:work-runtime:2.10.3"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||
implementation "fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.22.0"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
|
||||
@@ -9,12 +9,15 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':aparajita-capacitor-secure-storage')
|
||||
implementation project(':capacitor-community-safe-area')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-clipboard')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-keyboard')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capawesome-capacitor-android-dark-mode-support')
|
||||
implementation project(':capawesome-capacitor-badge')
|
||||
implementation project(':nostr-signer-capacitor-plugin')
|
||||
|
||||
@@ -42,4 +42,9 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
package social.flotilla;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
import social.flotilla.notifications.AndroidPushFallbackPlugin;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(AndroidPushFallbackPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package social.flotilla.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@CapacitorPlugin(name = "AndroidPushFallback")
|
||||
class AndroidPushFallbackPlugin : Plugin() {
|
||||
companion object {
|
||||
const val PREFS_NAME = "CapacitorStorage"
|
||||
const val KEY_STATE = "androidPushFallback.state"
|
||||
const val UNIQUE_PERIODIC_WORK = "androidPushFallback.periodic"
|
||||
const val UNIQUE_IMMEDIATE_WORK = "androidPushFallback.immediate"
|
||||
}
|
||||
|
||||
private fun getPrefs(): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun syncState(call: PluginCall) {
|
||||
val state: JSObject? = call.getObject("state")
|
||||
|
||||
if (state != null) {
|
||||
getPrefs().edit().putString(KEY_STATE, state.toString()).apply()
|
||||
|
||||
if (isEnabled(state.toString())) {
|
||||
scheduleWork()
|
||||
} else {
|
||||
cancelWork()
|
||||
}
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
private fun isEnabled(rawState: String?): Boolean {
|
||||
if (rawState == null || rawState.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return try {
|
||||
val state = JSONObject(rawState)
|
||||
val subscriptions: JSONArray? = state.optJSONArray("subscriptions")
|
||||
subscriptions != null && subscriptions.length() > 0
|
||||
} catch (_: JSONException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleWork() {
|
||||
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
|
||||
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
val periodic = PeriodicWorkRequest.Builder(
|
||||
AndroidPushFallbackWorker::class.java,
|
||||
15,
|
||||
TimeUnit.MINUTES,
|
||||
).setConstraints(constraints).build()
|
||||
|
||||
val immediate = OneTimeWorkRequest.Builder(AndroidPushFallbackWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
UNIQUE_PERIODIC_WORK,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
periodic,
|
||||
)
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
UNIQUE_IMMEDIATE_WORK,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
immediate,
|
||||
)
|
||||
}
|
||||
|
||||
private fun cancelWork() {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.cancelUniqueWork(UNIQUE_IMMEDIATE_WORK)
|
||||
workManager.cancelUniqueWork(UNIQUE_PERIODIC_WORK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
package social.flotilla.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.util.Log
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.app.ActivityManager
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import fr.acinq.secp256k1.Secp256k1
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import social.flotilla.notifications.AndroidPushFallbackPlugin.Companion.KEY_STATE
|
||||
import social.flotilla.notifications.AndroidPushFallbackPlugin.Companion.PREFS_NAME
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.Arrays
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import android.util.Base64
|
||||
|
||||
class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
companion object {
|
||||
private const val TAG = "PushFallback"
|
||||
private const val CHANNEL_ID = "flotilla_fallback"
|
||||
private const val CURSOR_PREFIX = "androidPushFallback.cursor."
|
||||
private const val SOCKET_TIMEOUT_SECONDS = 30L
|
||||
private const val REJECTED = "__REJECTED__"
|
||||
private const val KIND_RELAY_AUTH = 22242
|
||||
private const val KIND_NIP46_RPC = 24133
|
||||
private val SECP = Secp256k1.get()
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val client: OkHttpClient = OkHttpClient.Builder().build()
|
||||
|
||||
// ---- Socket pool ----
|
||||
|
||||
// Opens each relay URL at most once; caller must invoke closeAll() when done.
|
||||
private inner class SocketPool {
|
||||
private val sockets = ConcurrentHashMap<String, WebSocket>()
|
||||
|
||||
fun open(url: String, listener: WebSocketListener): WebSocket =
|
||||
sockets.getOrPut(url) {
|
||||
client.newWebSocket(Request.Builder().url(url).build(), listener)
|
||||
}
|
||||
|
||||
fun closeAll() {
|
||||
for ((_, ws) in sockets) ws.close(1000, "done")
|
||||
sockets.clear()
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
Log.i(TAG, "doWork() started")
|
||||
|
||||
if (isAppInForeground()) {
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val pool = SocketPool()
|
||||
try {
|
||||
val rawState = prefs.getString(KEY_STATE, "") ?: ""
|
||||
if (rawState.isEmpty()) return Result.success()
|
||||
|
||||
val state = JSONObject(rawState)
|
||||
val sessionInfo = getSessionInfo(state)
|
||||
val subscriptions = parseSubscriptions(state)
|
||||
if (subscriptions.isEmpty()) return Result.success()
|
||||
|
||||
val activeSince = state.optLong("activeSince", 0L)
|
||||
val seen = mutableSetOf<String>()
|
||||
val newEvents = mutableListOf<Pair<String, JSONObject>>()
|
||||
|
||||
for (sub in subscriptions) {
|
||||
val cursorKey = CURSOR_PREFIX + sub.relay + ":" + sub.key
|
||||
val since = maxOf(prefs.getLong(cursorKey, 0L), activeSince)
|
||||
val result = pollRelay(sub, since, sessionInfo, pool)
|
||||
|
||||
if (result.lastCursor > prefs.getLong(cursorKey, 0L)) {
|
||||
prefs.edit().putLong(cursorKey, result.lastCursor).apply()
|
||||
}
|
||||
|
||||
for (event in result.events) {
|
||||
val id = event.optString("id", "")
|
||||
if (id.isNotEmpty() && seen.add(id)) {
|
||||
newEvents.add(Pair(sub.relay, event))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for ((relay, event) in newEvents) {
|
||||
postNotification(relay, event)
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Worker failed", e)
|
||||
return Result.retry()
|
||||
} finally {
|
||||
pool.closeAll()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAppInForeground(): Boolean {
|
||||
val am = applicationContext.getSystemService(ActivityManager::class.java) ?: return false
|
||||
val tasks = am.getRunningAppProcesses() ?: return false
|
||||
val pkg = applicationContext.packageName
|
||||
return tasks.any { it.processName == pkg && it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND }
|
||||
}
|
||||
|
||||
private fun getSessionInfo(state: JSONObject): SessionInfo {
|
||||
val session = state.optJSONObject("session") ?: return SessionInfo("anonymous", "", JSONObject())
|
||||
return SessionInfo(
|
||||
session.optString("method", "anonymous"),
|
||||
session.optString("pubkey", ""),
|
||||
session,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSubscriptions(state: JSONObject): List<Subscription> {
|
||||
val result = mutableListOf<Subscription>()
|
||||
val arr = state.optJSONArray("subscriptions") ?: return result
|
||||
|
||||
for (i in 0 until arr.length()) {
|
||||
val item = arr.optJSONObject(i) ?: continue
|
||||
val relay = item.optString("relay", "").trim()
|
||||
|
||||
if (!relay.startsWith("wss://") && !relay.startsWith("ws://")) continue
|
||||
|
||||
val filters = item.optJSONArray("filters")
|
||||
if (filters == null || filters.length() == 0) continue
|
||||
|
||||
val key = item.optString("key", "").trim()
|
||||
result.add(Subscription(relay, key, filters, item.optJSONArray("ignore")))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun pollRelay(sub: Subscription, since: Long, sessionInfo: SessionInfo, pool: SocketPool): RelayResult {
|
||||
val result = RelayResult()
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
val listener = RelayListener(sub, since, sessionInfo, result, latch, pool)
|
||||
pool.open(sub.relay, listener)
|
||||
|
||||
if (!latch.await(SOCKET_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
Log.d(TAG, "Relay ${sub.relay} timed out")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun postNotification(relay: String, event: JSONObject) {
|
||||
val context = applicationContext
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) return
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
if (manager != null && manager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
val channel = NotificationChannel(CHANNEL_ID, "Fallback Notifications", NotificationManager.IMPORTANCE_DEFAULT)
|
||||
channel.description = "Notifications delivered by Android background fallback"
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
val id = event.optString("id", "")
|
||||
val encodedRelay = Uri.encode(relay)
|
||||
val url = "https://app.flotilla.social/?relay=$encodedRelay&id=$id"
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
intent.setPackage(context.packageName)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val body = "New activity"
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setContentTitle("Flotilla")
|
||||
.setContentText(body)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
|
||||
val notificationId = id.hashCode().let { if (it == 0) 1 else it }
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
}
|
||||
|
||||
private fun matchesFilter(filter: JSONObject, event: JSONObject): Boolean {
|
||||
val kinds = filter.optJSONArray("kinds")
|
||||
if (kinds != null && kinds.length() > 0) {
|
||||
val kind = event.optInt("kind", -1)
|
||||
var found = false
|
||||
for (i in 0 until kinds.length()) {
|
||||
if (kinds.optInt(i, -1) == kind) { found = true; break }
|
||||
}
|
||||
if (!found) return false
|
||||
}
|
||||
|
||||
val tags = event.optJSONArray("tags")
|
||||
val iter = filter.keys()
|
||||
while (iter.hasNext()) {
|
||||
val key = iter.next()
|
||||
if (!key.startsWith("#")) continue
|
||||
val tagName = key.substring(1)
|
||||
val allowed = filter.optJSONArray(key) ?: continue
|
||||
if (allowed.length() == 0) continue
|
||||
|
||||
val allowedValues = mutableSetOf<String>()
|
||||
for (i in 0 until allowed.length()) {
|
||||
val v = allowed.optString(i, "")
|
||||
if (v.isNotEmpty()) allowedValues.add(v)
|
||||
}
|
||||
|
||||
var matched = false
|
||||
if (tags != null) {
|
||||
for (i in 0 until tags.length()) {
|
||||
val tag = tags.optJSONArray(i) ?: continue
|
||||
if (tag.optString(0, "") == tagName && allowedValues.contains(tag.optString(1, ""))) {
|
||||
matched = true; break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Crypto helpers ----
|
||||
|
||||
private fun computeEventId(event: JSONObject): String {
|
||||
return try {
|
||||
val serialized = JSONArray()
|
||||
serialized.put(0)
|
||||
serialized.put(event.optString("pubkey", ""))
|
||||
serialized.put(event.optLong("created_at", 0))
|
||||
serialized.put(event.optInt("kind", 0))
|
||||
serialized.put(event.optJSONArray("tags") ?: JSONArray())
|
||||
serialized.put(event.optString("content", ""))
|
||||
// JSONObject escapes forward slashes (/ -> \/), but NIP-01 event ID hashing
|
||||
// requires unescaped slashes. Replace them before hashing.
|
||||
val serializedStr = serialized.toString().replace("\\/", "/")
|
||||
bytesToHex(sha256(serializedStr.toByteArray(StandardCharsets.UTF_8)))
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun deriveXOnlyPubkey(secretHex: String): String {
|
||||
val secret = hexToBytes(secretHex)
|
||||
if (secret.size != 32 || !SECP.secKeyVerify(secret)) return ""
|
||||
val pubkey65 = try { SECP.pubkeyCreate(secret) } catch (_: Exception) { return "" }
|
||||
if (pubkey65.size != 65) return ""
|
||||
return bytesToHex(Arrays.copyOfRange(pubkey65, 1, 33))
|
||||
}
|
||||
|
||||
private fun schnorrSign(secretHex: String, messageHex: String): String {
|
||||
val sk = hexToBytes(secretHex)
|
||||
val msg = hexToBytes(messageHex)
|
||||
if (sk.size != 32 || msg.size != 32 || !SECP.secKeyVerify(sk)) return ""
|
||||
val aux = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val sig = try { SECP.signSchnorr(msg, sk, aux) } catch (_: Exception) { return "" }
|
||||
if (sig.size != 64) return ""
|
||||
return bytesToHex(sig)
|
||||
}
|
||||
|
||||
private fun sha256(input: ByteArray): ByteArray =
|
||||
try { MessageDigest.getInstance("SHA-256").digest(input) } catch (_: Exception) { ByteArray(32) }
|
||||
|
||||
private fun hexToBytes(hex: String?): ByteArray {
|
||||
var s = hex?.trim()?.lowercase() ?: ""
|
||||
if (s.startsWith("0x")) s = s.substring(2)
|
||||
if (s.length % 2 == 1) s = "0$s"
|
||||
val bytes = ByteArray(s.length / 2)
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val hi = Character.digit(s[i], 16)
|
||||
val lo = Character.digit(s[i + 1], 16)
|
||||
if (hi < 0 || lo < 0) return ByteArray(0)
|
||||
bytes[i / 2] = ((hi shl 4) + lo).toByte()
|
||||
i += 2
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun bytesToHex(bytes: ByteArray): String {
|
||||
val hex = "0123456789abcdef".toCharArray()
|
||||
val chars = CharArray(bytes.size * 2)
|
||||
for (i in bytes.indices) {
|
||||
val v = bytes[i].toInt() and 0xFF
|
||||
chars[i * 2] = hex[v ushr 4]
|
||||
chars[i * 2 + 1] = hex[v and 0x0F]
|
||||
}
|
||||
return String(chars)
|
||||
}
|
||||
|
||||
// ---- NIP-44 encryption (v2: ECDH + HKDF + ChaCha20 + HMAC-SHA256) ----
|
||||
|
||||
private fun nip44ConversationKey(clientSecret: String, theirPubkey: String): ByteArray {
|
||||
val sk = hexToBytes(clientSecret)
|
||||
val pk = hexToBytes("02$theirPubkey")
|
||||
if (sk.size != 32 || pk.size != 33) return ByteArray(0)
|
||||
val shared = try { SECP.pubKeyTweakMul(pk, sk) } catch (_: Exception) { return ByteArray(0) }
|
||||
if (shared.size != 65) return ByteArray(0)
|
||||
val sharedX = Arrays.copyOfRange(shared, 1, 33)
|
||||
return hkdfExtract(sharedX, "nip44-v2".toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
private fun hkdfExtract(ikm: ByteArray, salt: ByteArray): ByteArray {
|
||||
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
||||
mac.init(javax.crypto.spec.SecretKeySpec(salt, "HmacSHA256"))
|
||||
return mac.doFinal(ikm)
|
||||
}
|
||||
|
||||
private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
|
||||
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
||||
val result = ByteArray(length)
|
||||
var prev = ByteArray(0)
|
||||
var offset = 0
|
||||
var counter = 1
|
||||
while (offset < length) {
|
||||
mac.init(javax.crypto.spec.SecretKeySpec(prk, "HmacSHA256"))
|
||||
mac.update(prev)
|
||||
mac.update(info)
|
||||
mac.update(counter.toByte())
|
||||
prev = mac.doFinal()
|
||||
val toCopy = minOf(prev.size, length - offset)
|
||||
System.arraycopy(prev, 0, result, offset, toCopy)
|
||||
offset += toCopy
|
||||
counter++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun hmacSha256(key: ByteArray, vararg parts: ByteArray): ByteArray {
|
||||
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
||||
mac.init(javax.crypto.spec.SecretKeySpec(key, "HmacSHA256"))
|
||||
for (part in parts) mac.update(part)
|
||||
return mac.doFinal()
|
||||
}
|
||||
|
||||
// ChaCha20 block function per RFC 8439
|
||||
private fun chacha20Block(key: ByteArray, counter: Int, nonce: ByteArray): ByteArray {
|
||||
fun Int.rotl(n: Int) = (this shl n) or (this ushr (32 - n))
|
||||
val state = IntArray(16)
|
||||
state[0] = 0x61707865; state[1] = 0x3320646e; state[2] = 0x79622d32; state[3] = 0x6b206574
|
||||
for (i in 0..7) state[4 + i] = (key[i*4].toInt() and 0xFF) or
|
||||
((key[i*4+1].toInt() and 0xFF) shl 8) or
|
||||
((key[i*4+2].toInt() and 0xFF) shl 16) or
|
||||
((key[i*4+3].toInt() and 0xFF) shl 24)
|
||||
state[12] = counter
|
||||
for (i in 0..2) state[13 + i] = (nonce[i*4].toInt() and 0xFF) or
|
||||
((nonce[i*4+1].toInt() and 0xFF) shl 8) or
|
||||
((nonce[i*4+2].toInt() and 0xFF) shl 16) or
|
||||
((nonce[i*4+3].toInt() and 0xFF) shl 24)
|
||||
val working = state.copyOf()
|
||||
repeat(10) {
|
||||
fun quarterRound(a: Int, b: Int, c: Int, d: Int) {
|
||||
working[a] += working[b]; working[d] = (working[d] xor working[a]).rotl(16)
|
||||
working[c] += working[d]; working[b] = (working[b] xor working[c]).rotl(12)
|
||||
working[a] += working[b]; working[d] = (working[d] xor working[a]).rotl(8)
|
||||
working[c] += working[d]; working[b] = (working[b] xor working[c]).rotl(7)
|
||||
}
|
||||
quarterRound(0,4,8,12); quarterRound(1,5,9,13); quarterRound(2,6,10,14); quarterRound(3,7,11,15)
|
||||
quarterRound(0,5,10,15); quarterRound(1,6,11,12); quarterRound(2,7,8,13); quarterRound(3,4,9,14)
|
||||
}
|
||||
val out = ByteArray(64)
|
||||
for (i in 0..15) {
|
||||
val v = working[i] + state[i]
|
||||
out[i*4] = v.toByte()
|
||||
out[i*4+1] = (v ushr 8).toByte()
|
||||
out[i*4+2] = (v ushr 16).toByte()
|
||||
out[i*4+3] = (v ushr 24).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun chacha20(key: ByteArray, nonce: ByteArray, data: ByteArray): ByteArray {
|
||||
val out = ByteArray(data.size)
|
||||
var counter = 0
|
||||
var offset = 0
|
||||
while (offset < data.size) {
|
||||
val block = chacha20Block(key, counter, nonce)
|
||||
val len = minOf(64, data.size - offset)
|
||||
for (i in 0 until len) out[offset + i] = (data[offset + i].toInt() xor block[i].toInt()).toByte()
|
||||
offset += len
|
||||
counter++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun nip44CalcPaddedLen(len: Int): Int {
|
||||
if (len <= 32) return 32
|
||||
val nextPower = 1 shl (Math.floor(Math.log((len - 1).toDouble()) / Math.log(2.0)).toInt() + 1)
|
||||
val chunk = if (nextPower <= 256) 32 else nextPower / 8
|
||||
return chunk * ((len - 1) / chunk + 1)
|
||||
}
|
||||
|
||||
private fun nip44Pad(plaintext: String): ByteArray {
|
||||
val unpadded = plaintext.toByteArray(StandardCharsets.UTF_8)
|
||||
val len = unpadded.size
|
||||
val padded = ByteArray(2 + nip44CalcPaddedLen(len))
|
||||
padded[0] = (len ushr 8).toByte()
|
||||
padded[1] = len.toByte()
|
||||
System.arraycopy(unpadded, 0, padded, 2, len)
|
||||
return padded
|
||||
}
|
||||
|
||||
private fun nip44Unpad(padded: ByteArray): String {
|
||||
val len = ((padded[0].toInt() and 0xFF) shl 8) or (padded[1].toInt() and 0xFF)
|
||||
if (len == 0 || len > padded.size - 2) return ""
|
||||
return String(padded, 2, len, StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun encryptNip44(plaintext: String, conversationKey: ByteArray): String {
|
||||
return try {
|
||||
val nonce = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
val keys = hkdfExpand(conversationKey, nonce, 76)
|
||||
val chachaKey = keys.sliceArray(0 until 32)
|
||||
val chachaNonce = keys.sliceArray(32 until 44)
|
||||
val hmacKey = keys.sliceArray(44 until 76)
|
||||
val padded = nip44Pad(plaintext)
|
||||
val ciphertext = chacha20(chachaKey, chachaNonce, padded)
|
||||
val mac = hmacSha256(hmacKey, nonce, ciphertext)
|
||||
val payload = ByteArray(1 + 32 + ciphertext.size + 32)
|
||||
payload[0] = 2
|
||||
System.arraycopy(nonce, 0, payload, 1, 32)
|
||||
System.arraycopy(ciphertext, 0, payload, 33, ciphertext.size)
|
||||
System.arraycopy(mac, 0, payload, 33 + ciphertext.size, 32)
|
||||
Base64.encodeToString(payload, Base64.NO_WRAP)
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun decryptNip44(payload: String, conversationKey: ByteArray): String {
|
||||
return try {
|
||||
if (payload.isEmpty() || payload[0] == '#') return ""
|
||||
val data = Base64.decode(payload, Base64.NO_WRAP)
|
||||
if (data.size < 99 || data[0] != 2.toByte()) return ""
|
||||
val nonce = data.sliceArray(1 until 33)
|
||||
val ciphertext = data.sliceArray(33 until data.size - 32)
|
||||
val mac = data.sliceArray(data.size - 32 until data.size)
|
||||
val keys = hkdfExpand(conversationKey, nonce, 76)
|
||||
val chachaKey = keys.sliceArray(0 until 32)
|
||||
val chachaNonce = keys.sliceArray(32 until 44)
|
||||
val hmacKey = keys.sliceArray(44 until 76)
|
||||
val expectedMac = hmacSha256(hmacKey, nonce, ciphertext)
|
||||
if (!expectedMac.contentEquals(mac)) return ""
|
||||
val padded = chacha20(chachaKey, chachaNonce, ciphertext)
|
||||
nip44Unpad(padded)
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Signing ----
|
||||
|
||||
private fun signWithNip01Secret(secretHex: String, eventJson: String, expectedPubkey: String): String {
|
||||
return try {
|
||||
val secret = hexToBytes(secretHex)
|
||||
if (secret.size != 32) return ""
|
||||
|
||||
val event = JSONObject(eventJson)
|
||||
var pubkey = event.optString("pubkey", expectedPubkey)
|
||||
if (pubkey.isEmpty()) pubkey = deriveXOnlyPubkey(secretHex)
|
||||
if (pubkey.isEmpty()) return ""
|
||||
|
||||
event.put("pubkey", pubkey)
|
||||
val id = computeEventId(event)
|
||||
if (id.isEmpty()) return ""
|
||||
|
||||
val sig = schnorrSign(secretHex, id)
|
||||
if (sig.isEmpty()) return ""
|
||||
|
||||
event.put("id", id)
|
||||
event.put("sig", sig)
|
||||
event.toString()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun signWithNip55ContentResolver(packageName: String, eventJson: String, pubkey: String): String {
|
||||
val uri = Uri.parse("content://$packageName.SIGN_EVENT")
|
||||
var cursor: Cursor? = null
|
||||
return try {
|
||||
cursor = applicationContext.contentResolver.query(uri, arrayOf(eventJson, "", pubkey), "1", null, null)
|
||||
if (cursor == null || !cursor.moveToFirst()) return ""
|
||||
val rejIdx = cursor.getColumnIndex("rejected")
|
||||
if (rejIdx >= 0) {
|
||||
val v = cursor.getString(rejIdx)
|
||||
if (v == "1" || v.equals("true", ignoreCase = true)) return REJECTED
|
||||
}
|
||||
val eventIdx = cursor.getColumnIndex("event")
|
||||
if (eventIdx >= 0) cursor.getString(eventIdx) ?: "" else ""
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
} finally {
|
||||
cursor?.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Data types ----
|
||||
|
||||
private data class SessionInfo(
|
||||
val method: String,
|
||||
val pubkey: String,
|
||||
val session: JSONObject,
|
||||
)
|
||||
|
||||
private data class Subscription(
|
||||
val relay: String,
|
||||
val key: String,
|
||||
val filters: JSONArray,
|
||||
val ignore: JSONArray?,
|
||||
)
|
||||
|
||||
private class RelayResult {
|
||||
val events = mutableListOf<JSONObject>()
|
||||
var lastCursor = 0L
|
||||
}
|
||||
|
||||
// ---- Relay listener ----
|
||||
|
||||
private inner class RelayListener(
|
||||
private val sub: Subscription,
|
||||
private val since: Long,
|
||||
private val sessionInfo: SessionInfo,
|
||||
private val result: RelayResult,
|
||||
private val latch: CountDownLatch,
|
||||
private val pool: SocketPool,
|
||||
) : WebSocketListener() {
|
||||
private val subId = UUID.randomUUID().toString().replace("-", "")
|
||||
private var done = false
|
||||
private var authed = false
|
||||
private var authEventId = ""
|
||||
private var nip46InFlight = false
|
||||
private var pendingDone = false
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
sendReq(webSocket)
|
||||
}
|
||||
|
||||
private fun sendReq(webSocket: WebSocket) {
|
||||
val req = JSONArray()
|
||||
req.put("REQ")
|
||||
req.put(subId)
|
||||
|
||||
for (i in 0 until sub.filters.length()) {
|
||||
val filter = sub.filters.optJSONObject(i) ?: continue
|
||||
val shaped = JSONObject(filter.toString())
|
||||
if (since > 0) shaped.put("since", since + 1)
|
||||
shaped.put("limit", 1)
|
||||
req.put(shaped)
|
||||
}
|
||||
|
||||
if (req.length() <= 2) { finish(); return }
|
||||
|
||||
send(webSocket, req.toString())
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
try {
|
||||
val message = JSONArray(text)
|
||||
Log.d(TAG, "Received message from ${sub.relay}: $text")
|
||||
when (message.optString(0, "")) {
|
||||
"EVENT" -> {
|
||||
val event = message.optJSONObject(2) ?: return
|
||||
if (!matchesAnyFilter(sub.filters, event)) return
|
||||
if (isIgnored(event)) return
|
||||
result.events.add(event)
|
||||
val createdAt = event.optLong("created_at", 0L)
|
||||
if (createdAt > result.lastCursor) result.lastCursor = createdAt
|
||||
}
|
||||
"AUTH" -> {
|
||||
// Only auth once per connection
|
||||
if (!authed) {
|
||||
authed = true
|
||||
tryAuth(webSocket, message.optString(1, ""))
|
||||
}
|
||||
}
|
||||
"OK" -> {
|
||||
val okId = message.optString(1, "")
|
||||
val accepted = message.optBoolean(2, false)
|
||||
if (accepted && okId == authEventId) sendReq(webSocket)
|
||||
}
|
||||
"EOSE" -> {
|
||||
send(webSocket, JSONArray().apply { put("CLOSE"); put(subId) }.toString())
|
||||
finish()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = finish()
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = finish()
|
||||
|
||||
private fun finish() {
|
||||
if (done) return
|
||||
if (nip46InFlight) { pendingDone = true; return }
|
||||
done = true
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
private fun isIgnored(event: JSONObject): Boolean {
|
||||
val ignore = sub.ignore ?: return false
|
||||
for (i in 0 until ignore.length()) {
|
||||
val filter = ignore.optJSONObject(i) ?: continue
|
||||
if (matchesFilter(filter, event)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun matchesAnyFilter(filters: JSONArray, event: JSONObject): Boolean {
|
||||
for (i in 0 until filters.length()) {
|
||||
val filter = filters.optJSONObject(i) ?: continue
|
||||
if (matchesFilter(filter, event)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- NIP-42 auth ----
|
||||
|
||||
private fun tryAuth(webSocket: WebSocket, challenge: String) {
|
||||
if (challenge.isEmpty()) return
|
||||
when (sessionInfo.method) {
|
||||
"nip01" -> tryNip01Auth(webSocket, challenge)
|
||||
"nip55" -> tryNip55Auth(webSocket, challenge)
|
||||
"nip46" -> tryNip46Auth(webSocket, challenge)
|
||||
// Pomade background auth is not supported: properly delegating to the Pomade signer
|
||||
// from a background worker is complex, usage is rare, and relays that require auth
|
||||
// may still be readable without it.
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAuthEvent(challenge: String): JSONObject {
|
||||
return JSONObject().apply {
|
||||
put("kind", KIND_RELAY_AUTH)
|
||||
put("pubkey", sessionInfo.pubkey)
|
||||
put("created_at", System.currentTimeMillis() / 1000L)
|
||||
put("content", "")
|
||||
put("id", "")
|
||||
put("sig", "")
|
||||
put("tags", JSONArray().apply {
|
||||
put(JSONArray().apply { put("relay"); put(sub.relay) })
|
||||
put(JSONArray().apply { put("challenge"); put(challenge) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendAuthMessage(webSocket: WebSocket, signedEventJson: String): Boolean {
|
||||
if (signedEventJson.isEmpty() || signedEventJson == REJECTED) return false
|
||||
return try {
|
||||
val event = JSONObject(signedEventJson)
|
||||
authEventId = event.optString("id", "")
|
||||
send(webSocket, JSONArray().apply { put("AUTH"); put(event) }.toString())
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(webSocket: WebSocket, message: String): Boolean {
|
||||
Log.d(TAG, "Sending message to ${webSocket.request().url}: $message")
|
||||
return webSocket.send(message)
|
||||
}
|
||||
|
||||
private fun tryNip01Auth(webSocket: WebSocket, challenge: String): Boolean {
|
||||
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.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 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
|
||||
|
||||
val clientPubkey = deriveXOnlyPubkey(clientSecret)
|
||||
if (clientPubkey.isEmpty()) return false
|
||||
|
||||
val authEventJson = buildAuthEvent(challenge).toString()
|
||||
|
||||
nip46InFlight = true
|
||||
var success = false
|
||||
try {
|
||||
for (i in 0 until relays.length()) {
|
||||
val signerRelay = relays.optString(i, "").trim()
|
||||
if (!signerRelay.startsWith("wss://") && !signerRelay.startsWith("ws://")) continue
|
||||
if (tryNip46ViaRelay(webSocket, signerRelay, clientSecret, clientPubkey, signerPubkey, authEventJson)) { success = true; break }
|
||||
}
|
||||
} finally {
|
||||
nip46InFlight = false
|
||||
if (pendingDone) finish()
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
private fun tryNip46ViaRelay(
|
||||
relaySocket: WebSocket,
|
||||
signerRelay: String,
|
||||
clientSecret: String,
|
||||
clientPubkey: String,
|
||||
signerPubkey: String,
|
||||
authEventJson: String,
|
||||
): Boolean {
|
||||
val localLatch = CountDownLatch(1)
|
||||
val signedEvent = StringBuilder()
|
||||
val requestId = UUID.randomUUID().toString().replace("-", "")
|
||||
|
||||
val signerSocket = pool.open(signerRelay, object : WebSocketListener() {
|
||||
private var done = false
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
try {
|
||||
val rpcEnvelope = JSONObject().apply {
|
||||
put("kind", KIND_NIP46_RPC)
|
||||
put("pubkey", clientPubkey)
|
||||
put("created_at", System.currentTimeMillis() / 1000L)
|
||||
put("content", encryptNip44(
|
||||
JSONObject().apply {
|
||||
put("id", requestId)
|
||||
put("method", "sign_event")
|
||||
put("params", JSONArray().apply { put(authEventJson) })
|
||||
}.toString(),
|
||||
nip44ConversationKey(clientSecret, signerPubkey),
|
||||
))
|
||||
put("id", "")
|
||||
put("sig", "")
|
||||
put("tags", JSONArray().apply { put(JSONArray().apply { put("p"); put(signerPubkey) }) })
|
||||
}
|
||||
|
||||
val signedEnvelope = signWithNip01Secret(clientSecret, rpcEnvelope.toString(), clientPubkey)
|
||||
if (signedEnvelope.isEmpty()) { finish(); return }
|
||||
|
||||
val sentAt = System.currentTimeMillis() / 1000L
|
||||
send(webSocket, JSONArray().apply { put("EVENT"); put(JSONObject(signedEnvelope)) }.toString())
|
||||
send(webSocket, JSONArray().apply {
|
||||
put("REQ")
|
||||
put(requestId)
|
||||
put(JSONObject().apply {
|
||||
put("#p", JSONArray().apply { put(clientPubkey) })
|
||||
put("kinds", JSONArray().apply { put(KIND_NIP46_RPC) })
|
||||
put("since", sentAt)
|
||||
put("limit", 10)
|
||||
})
|
||||
}.toString())
|
||||
} catch (_: Exception) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
try {
|
||||
val message = JSONArray(text)
|
||||
val msgType = message.optString(0, "")
|
||||
Log.d(TAG, "NIP-46 signer message: $msgType from ${webSocket.request().url}")
|
||||
if (msgType != "EVENT") return
|
||||
val event = message.optJSONObject(2) ?: return
|
||||
|
||||
val tags = event.optJSONArray("tags")
|
||||
var hasP = false
|
||||
if (tags != null) {
|
||||
for (i in 0 until tags.length()) {
|
||||
val tag = tags.optJSONArray(i) ?: continue
|
||||
if (tag.optString(0, "") == "p" && tag.optString(1, "") == clientPubkey) { hasP = true; break }
|
||||
}
|
||||
}
|
||||
if (!hasP) { Log.d(TAG, "NIP-46 event missing p tag for client"); return }
|
||||
|
||||
val decryptedContent = decryptNip44(event.optString("content", ""), nip44ConversationKey(clientSecret, signerPubkey))
|
||||
Log.d(TAG, "NIP-46 decrypted response: $decryptedContent")
|
||||
if (decryptedContent.isEmpty()) return
|
||||
val payload = JSONObject(decryptedContent)
|
||||
if (requestId == payload.optString("id", "")) {
|
||||
val result = payload.optString("result", "")
|
||||
if (result.isNotEmpty()) {
|
||||
signedEvent.setLength(0)
|
||||
signedEvent.append(result)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "NIP-46 signer message error", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = finish()
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = finish()
|
||||
|
||||
private fun finish() {
|
||||
if (!done) { done = true; localLatch.countDown() }
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
localLatch.await(5, TimeUnit.SECONDS)
|
||||
} catch (_: InterruptedException) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (signedEvent.isEmpty()) return false
|
||||
|
||||
val authEvent = JSONObject(signedEvent.toString())
|
||||
authEventId = authEvent.optString("id", "")
|
||||
val authMessage = JSONArray().apply { put("AUTH"); put(authEvent) }.toString()
|
||||
Log.d(TAG, "NIP-46 sending AUTH to relay ${relaySocket.request().url}: $authMessage")
|
||||
return try {
|
||||
relaySocket.send(authMessage)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "NIP-46 failed to send AUTH", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '2.2.20'
|
||||
|
||||
repositories {
|
||||
google()
|
||||
@@ -9,6 +10,7 @@ buildscript {
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.2'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':aparajita-capacitor-secure-storage'
|
||||
project(':aparajita-capacitor-secure-storage').projectDir = new File('../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android')
|
||||
|
||||
include ':capacitor-community-safe-area'
|
||||
project(':capacitor-community-safe-area').projectDir = new File('../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-clipboard'
|
||||
project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem/android')
|
||||
|
||||
@@ -20,6 +26,9 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share/android')
|
||||
|
||||
include ':capawesome-capacitor-android-dark-mode-support'
|
||||
project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-dark-mode-support@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-android-dark-mode-support/android')
|
||||
|
||||
@@ -27,4 +36,4 @@ include ':capawesome-capacitor-badge'
|
||||
project(':capawesome-capacitor-badge').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge/android')
|
||||
|
||||
include ':nostr-signer-capacitor-plugin'
|
||||
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@8.0.1/node_modules/nostr-signer-capacitor-plugin/android')
|
||||
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin/android')
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
temp_env=$(declare -p -x)
|
||||
|
||||
if [ -f .env.template ]; then
|
||||
source .env.template
|
||||
if [ -f .env ]; then
|
||||
source .env
|
||||
fi
|
||||
|
||||
# Avoid overwriting env vars provided directly
|
||||
@@ -14,12 +14,13 @@ if [[ -z $VITE_BUILD_HASH ]]; then
|
||||
export VITE_BUILD_HASH=$(git rev-parse --short HEAD)
|
||||
fi
|
||||
|
||||
if [[ $VITE_PLATFORM_LOGO =~ ^https://* ]]; then
|
||||
curl $VITE_PLATFORM_LOGO > static/logo.png
|
||||
if [[ $VITE_PLATFORM_LOGO =~ ^https:// ]]; then
|
||||
curl -fSL "$VITE_PLATFORM_LOGO" -o static/logo.png
|
||||
export VITE_PLATFORM_LOGO=static/logo.png
|
||||
fi
|
||||
|
||||
npx pwa-assets-generator
|
||||
# Ensure generator uses local path (dotenv may have loaded URL from .env)
|
||||
VITE_PLATFORM_LOGO="${VITE_PLATFORM_LOGO}" npx pwa-assets-generator
|
||||
npx vite build
|
||||
|
||||
# Replace index.html variables with stuff from our env
|
||||
|
||||
@@ -4,6 +4,9 @@ const config: CapacitorConfig = {
|
||||
appId: "social.flotilla",
|
||||
appName: "Flotilla",
|
||||
webDir: "build",
|
||||
ios: {
|
||||
scheme: "Flotilla Chat",
|
||||
},
|
||||
android: {
|
||||
adjustMarginsForEdgeToEdge: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {expect, test} from "@playwright/test"
|
||||
|
||||
test("boots the SPA on the home page", async ({page}) => {
|
||||
const response = await page.goto("/")
|
||||
|
||||
expect(response?.ok()).toBeTruthy()
|
||||
|
||||
// adapter-static serves an empty shell that hydrates client-side, so the presence of
|
||||
// rendered text proves the Svelte app actually mounted (not just that a file was served).
|
||||
// TODO: tighten this to assert concrete onboarding UI once the markup is settled.
|
||||
await expect(page.locator("body")).toContainText(/\S/, {timeout: 15_000})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {SignedEvent} from "@welshman/util"
|
||||
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
|
||||
|
||||
import relay1Events from "./fixtures/relay1.json"
|
||||
|
||||
// Fake relay urls used by tests. Each maps to a json fixture under ./fixtures/ and an entry in
|
||||
// EVENTS_BY_RELAY below. To add a relay: drop a `<name>.json` file in ./fixtures/, import it, add a
|
||||
// url here, and wire it into EVENTS_BY_RELAY.
|
||||
export const FIXTURE_RELAYS = {
|
||||
relay1: "wss://relay1.test/",
|
||||
} as const
|
||||
|
||||
// The events each fake relay serves. The json files hold static, pre-signed events: schnorr
|
||||
// signatures are non-deterministic, so events are signed once and committed verbatim (they pass
|
||||
// verifyEvent, which netContext.isEventValid enforces). Regenerate with @welshman/signer:
|
||||
// await Nip01Signer.fromSecret(secret).sign(makeEvent(kind, {content, created_at}))
|
||||
const EVENTS_BY_RELAY: Record<string, SignedEvent[]> = {
|
||||
[FIXTURE_RELAYS.relay1]: relay1Events as SignedEvent[],
|
||||
}
|
||||
|
||||
// Build a RelayMockConfig populating the given fixture relays (all of them when none are passed).
|
||||
// Any relay not included returns nothing, keeping tests offline.
|
||||
export const relayFixtures = (...urls: string[]): RelayMockConfig => {
|
||||
const selected = urls.length > 0 ? urls : Object.keys(EVENTS_BY_RELAY)
|
||||
|
||||
return {
|
||||
relays: Object.fromEntries(selected.map(url => [url, EVENTS_BY_RELAY[url] ?? []])),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
[
|
||||
{
|
||||
"kind": 0,
|
||||
"content": "{\"name\":\"Alice\"}",
|
||||
"tags": [],
|
||||
"created_at": 1700000000,
|
||||
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||
"id": "9b3d138641b38364945b20d800268006c2cb7d974bb4b1d63a9f90f5ab974b90",
|
||||
"sig": "de6b86274e7bcf6c02aa881ada1feee9e01ba320691711d4975916b3cd231ab43cf469a47c5db99503ed72707d5db85fede1ad3763c4fbd7c998d04f00eda6bc"
|
||||
},
|
||||
{
|
||||
"kind": 1,
|
||||
"content": "hello from the fixture relay",
|
||||
"tags": [],
|
||||
"created_at": 1700000000,
|
||||
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||
"id": "b9874875bfa8d830c5c9ef3673104360cf21b94848a311febfaf52f0a652b1a9",
|
||||
"sig": "85df94a2e9884ac3d280145492d5191cde2948d49a824c443a1f5d2143633eff1e1789fa7e8843b6efc3dd2dc0d7e33322edb628125d8e35de8ddca1d06ca970"
|
||||
},
|
||||
{
|
||||
"kind": 1,
|
||||
"content": "reply from bob",
|
||||
"tags": [],
|
||||
"created_at": 1700000001,
|
||||
"pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
|
||||
"id": "171dcbdd63d474ba46da609e8b0104cbcf4801fbb581b6c343d9426280f9e1be",
|
||||
"sig": "eecf26e616a6b70dc67c7eae16fc4fe159314647ba1d4581332257de7f070410aa9a9e41f571b9403ff145d16c9b32766846fce08516201263e25cf08c1ed8f1"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {Page} from "@playwright/test"
|
||||
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
|
||||
|
||||
// Must match RELAY_MOCKS_KEY in src/lib/test/relayMocks.ts.
|
||||
const RELAY_MOCKS_KEY = "__RELAY_MOCKS__"
|
||||
|
||||
// Hard safety net: intercept every real websocket so a test can never reach the network, even if
|
||||
// some code path opens a socket directly (e.g. relay AUTH) rather than going through the adapter
|
||||
// layer. We never call route.connectToServer(), so the socket connects to Playwright's in-process
|
||||
// mock and simply receives nothing.
|
||||
export const blockWebsockets = (page: Page) => page.routeWebSocket(/^wss?:\/\//, () => {})
|
||||
|
||||
// Inject the relay-mock config the app reads on startup. addInitScript runs before any page script
|
||||
// on every navigation, so this must be called before page.goto().
|
||||
export const injectRelayConfig = (page: Page, config: RelayMockConfig) =>
|
||||
page.addInitScript(
|
||||
([key, value]) => {
|
||||
Object.assign(window, {[key]: value})
|
||||
},
|
||||
[RELAY_MOCKS_KEY, config] as const,
|
||||
)
|
||||
|
||||
// Full network isolation plus optional fixtures, in one call. With no config, every relay returns
|
||||
// nothing (requirement 1). Pass {relays: {url: events}} to populate specific relays (requirement 2).
|
||||
export const setupRelayMocks = async (page: Page, config: RelayMockConfig = {}) => {
|
||||
await blockWebsockets(page)
|
||||
await injectRelayConfig(page, config)
|
||||
}
|
||||
|
||||
export type {RelayMockConfig}
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 48;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -131,8 +131,9 @@
|
||||
504EC2FC1FED79650016851F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 920;
|
||||
LastUpgradeCheck = 920;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
@@ -257,6 +258,7 @@
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
@@ -264,8 +266,10 @@
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
@@ -275,8 +279,10 @@
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
@@ -295,6 +301,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -314,6 +321,7 @@
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
@@ -321,8 +329,10 @@
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
@@ -332,8 +342,10 @@
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
@@ -345,7 +357,9 @@
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
@@ -358,14 +372,16 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.6.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.8.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -385,14 +401,16 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Flotilla Chat.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
DEVELOPMENT_TEAM = S26U9DYW3A;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Flotilla Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.6.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.8.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
+10
-6
@@ -20,8 +20,18 @@
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Flotilla uses the camera when you enable it in a voice room.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Flotilla uses the microphone when you enable it in a voice room.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -47,11 +57,5 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+4
-1
@@ -11,14 +11,17 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/.pnpm/@capacitor+ios@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/ios'
|
||||
pod 'AparajitaCapacitorSecureStorage', :path => '../../node_modules/.pnpm/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage'
|
||||
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor-community/safe-area'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/app'
|
||||
pod 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/clipboard'
|
||||
pod 'CapacitorFilesystem', :path => '../../node_modules/.pnpm/@capacitor+filesystem@8.1.0_@capacitor+core@8.0.1/node_modules/@capacitor/filesystem'
|
||||
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/keyboard'
|
||||
pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/preferences'
|
||||
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@8.0.0_@capacitor+core@8.0.1/node_modules/@capacitor/push-notifications'
|
||||
pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.0.1/node_modules/@capacitor/share'
|
||||
pod 'CapawesomeCapacitorBadge', :path => '../../node_modules/.pnpm/@capawesome+capacitor-badge@8.0.0_@capacitor+core@8.0.1/node_modules/@capawesome/capacitor-badge'
|
||||
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@8.0.1/node_modules/nostr-signer-capacitor-plugin'
|
||||
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@https+++codeload.github.com+coracle-social+nostr-signer-c_a3c0fb15d5bfa83f24d0070ca2583fc9/node_modules/nostr-signer-capacitor-plugin'
|
||||
end
|
||||
|
||||
target 'Flotilla Chat' do
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
if (execSync('git status --porcelain', { encoding: 'utf8' }).trim()) {
|
||||
console.error('Error: Git working tree is dirty. Please commit or stash your changes first.')
|
||||
const force = process.argv.includes('--force')
|
||||
|
||||
if (execSync('git status --porcelain', { encoding: 'utf8' }).trim() && !force) {
|
||||
console.error('Error: Git working tree is dirty. Please commit or stash your changes first, or re-run with --force.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
|
||||
|
||||
pkg.pnpm = pkg.pnpm || {}
|
||||
pkg.pnpm.overrides = pkg.pnpm.overrides || {}
|
||||
pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app"
|
||||
pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content"
|
||||
@@ -22,7 +24,9 @@ pkg.pnpm.overrides["@welshman/router"] = "link:../welshman/packages/router"
|
||||
pkg.pnpm.overrides["@welshman/signer"] = "link:../welshman/packages/signer"
|
||||
pkg.pnpm.overrides["@welshman/store"] = "link:../welshman/packages/store"
|
||||
pkg.pnpm.overrides["@welshman/util"] = "link:../welshman/packages/util"
|
||||
// pkg.pnpm.overrides["nostr-editor"] = "link:../nostr-editor"
|
||||
// pkg.pnpm.overrides["@pomade/core"] = "link:../pomade/packages/core"
|
||||
// pkg.pnpm.overrides["nostr-signer-capacitor-plugin"] = "link:../nostr-signer-capacitor-plugin"
|
||||
|
||||
fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n')
|
||||
|
||||
|
||||
+42
-36
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"name": "flotilla",
|
||||
"version": "1.6.4",
|
||||
"version": "1.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "./build.sh",
|
||||
"build:server": "vite build --config vite.config.server.ts",
|
||||
"start": "node server.js",
|
||||
"release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check src && eslint src",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||
"format:all": "prettier --write src",
|
||||
"prepare": "husky"
|
||||
@@ -16,85 +20,87 @@
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@sveltejs/kit": "^2.61.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^25.9.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"classnames": "^2.5.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"globals": "^15.15.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"svelte": "^5.48.0",
|
||||
"svelte": "^5.55.9",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.53.1",
|
||||
"vite": "^5.4.21"
|
||||
"vite": "^6.4.2"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||
"@capacitor-community/safe-area": "^8.0.1",
|
||||
"@capacitor/android": "^8.0.1",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/cli": "^8.0.1",
|
||||
"@capacitor/clipboard": "^8.0.1",
|
||||
"@capacitor/core": "^8.0.1",
|
||||
"@capacitor/filesystem": "^8.1.0",
|
||||
"@capacitor/ios": "^8.0.1",
|
||||
"@capacitor/keyboard": "^8.0.0",
|
||||
"@capacitor/preferences": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.0.0",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
|
||||
"@capawesome/capacitor-badge": "^8.0.0",
|
||||
"@getalby/lightning-tools": "^6.1.0",
|
||||
"@getalby/sdk": "^5.1.2",
|
||||
"@hono/node-server": "^2.0.0",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@pomade/core": "^0.0.12",
|
||||
"@poppanator/sveltekit-svg": "^4.2.1",
|
||||
"@pomade/core": "^0.3.0",
|
||||
"@poppanator/sveltekit-svg": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@tiptap/core": "^2.27.2",
|
||||
"@tiptap/pm": "^2.27.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"@vite-pwa/sveltekit": "^0.6.8",
|
||||
"@welshman/app": "^0.8.4",
|
||||
"@welshman/content": "^0.8.4",
|
||||
"@welshman/editor": "^0.8.4",
|
||||
"@welshman/feeds": "^0.8.4",
|
||||
"@welshman/lib": "^0.8.4",
|
||||
"@welshman/net": "^0.8.4",
|
||||
"@welshman/router": "^0.8.4",
|
||||
"@welshman/signer": "^0.8.4",
|
||||
"@welshman/store": "^0.8.4",
|
||||
"@welshman/util": "^0.8.4",
|
||||
"compressorjs": "^1.2.1",
|
||||
"daisyui": "^4.12.24",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@welshman/app": "^0.8.16",
|
||||
"@welshman/content": "^0.8.16",
|
||||
"@welshman/editor": "^0.8.16",
|
||||
"@welshman/feeds": "^0.8.16",
|
||||
"@welshman/lib": "^0.8.16",
|
||||
"@welshman/net": "^0.8.16",
|
||||
"@welshman/router": "^0.8.16",
|
||||
"@welshman/signer": "^0.8.16",
|
||||
"@welshman/store": "^0.8.16",
|
||||
"@welshman/util": "^0.8.16",
|
||||
"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",
|
||||
"fuse.js": "^7.1.0",
|
||||
"hono": "^4.12.23",
|
||||
"husky": "^9.1.7",
|
||||
"idb": "^8.0.3",
|
||||
"nostr-signer-capacitor-plugin": "^0.0.4",
|
||||
"livekit-client": "^2.17.2",
|
||||
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
|
||||
"nostr-tools": "^2.19.4",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"throttle-debounce": "^5.0.2",
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"ignoredBuiltDependencies": [
|
||||
"esbuild"
|
||||
],
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp"
|
||||
],
|
||||
"overrides": {
|
||||
"sharp": "0.35.0-rc.0"
|
||||
}
|
||||
}
|
||||
"packageManager": "pnpm@11.5.1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import {defineConfig, devices} from "@playwright/test"
|
||||
|
||||
// E2E tests live in ./e2e and run against the dev server (port 1847 from vite.config.ts).
|
||||
// Run with `pnpm test:e2e` (after `pnpm exec playwright install` to fetch browsers).
|
||||
export default defineConfig({
|
||||
testDir: "e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: "http://localhost:1847",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
// Boots the SvelteKit dev server before the suite and reuses one if already running locally.
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:1847",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [
|
||||
{name: "chromium", use: {...devices["Desktop Chrome"]}},
|
||||
{name: "firefox", use: {...devices["Desktop Firefox"]}},
|
||||
{name: "webkit", use: {...devices["Desktop Safari"]}},
|
||||
],
|
||||
})
|
||||
Generated
+3205
-2697
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
allowBuilds:
|
||||
nostr-signer-capacitor-plugin: true
|
||||
cbor-extract: false
|
||||
esbuild: false
|
||||
sharp: true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@pomade/core'
|
||||
- '@welshman/app'
|
||||
- '@welshman/content'
|
||||
- '@welshman/editor'
|
||||
- '@welshman/feeds'
|
||||
- '@welshman/lib'
|
||||
- '@welshman/net'
|
||||
- '@welshman/router'
|
||||
- '@welshman/signer'
|
||||
- '@welshman/store'
|
||||
- '@welshman/util'
|
||||
overrides:
|
||||
sharp: 0.35.0-rc.0
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import dotenv from "dotenv"
|
||||
import {defineConfig, minimalPreset as preset} from "@vite-pwa/assets-generator/config"
|
||||
|
||||
dotenv.config({path: ".env.local"})
|
||||
dotenv.config({path: ".env"})
|
||||
dotenv.config({path: ".env.template"})
|
||||
|
||||
export default defineConfig({
|
||||
preset,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import path from "node:path"
|
||||
import {promises as fs} from "node:fs"
|
||||
import {fileURLToPath} from "node:url"
|
||||
|
||||
import "dotenv/config"
|
||||
import {serve} from "@hono/node-server"
|
||||
import {serveStatic} from "@hono/node-server/serve-static"
|
||||
import {loadRelay} from "@welshman/app"
|
||||
import {displayRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
||||
import {load} from "cheerio"
|
||||
import {Hono} from "hono"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const BUILD_DIR = path.join(__dirname, "build")
|
||||
const INDEX_PATH = path.join(BUILD_DIR, "index.html")
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "", 10) || 3000
|
||||
const HOST = process.env.HOST || "0.0.0.0"
|
||||
|
||||
let TEMPLATE_HTML = ""
|
||||
try {
|
||||
TEMPLATE_HTML = await fs.readFile(INDEX_PATH, "utf8")
|
||||
} catch (error) {
|
||||
console.error(`Unable to read ${INDEX_PATH}. Run "pnpm run build" first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const PLATFORM_NAME = process.env.VITE_PLATFORM_NAME
|
||||
const PLATFORM_DESCRIPTION = process.env.VITE_PLATFORM_DESCRIPTION
|
||||
|
||||
// Match client-side decode logic
|
||||
const decodeRelay = url => {
|
||||
try {
|
||||
return normalizeRelayUrl(decodeURIComponent(url))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const requestUrlFromContext = context => {
|
||||
const requestUrl = new URL(context.req.url)
|
||||
const forwardedProto = context.req.header("x-forwarded-proto")?.split(",")[0]?.trim()
|
||||
const forwardedHost = context.req.header("x-forwarded-host")?.split(",")[0]?.trim()
|
||||
|
||||
if (forwardedProto === "http" || forwardedProto === "https") {
|
||||
requestUrl.protocol = `${forwardedProto}:`
|
||||
}
|
||||
|
||||
if (forwardedHost) {
|
||||
requestUrl.host = forwardedHost
|
||||
}
|
||||
|
||||
return requestUrl
|
||||
}
|
||||
|
||||
const fetchRelayMeta = async relayUrl => {
|
||||
if (!relayUrl) return undefined
|
||||
try {
|
||||
return await loadRelay(normalizeRelayUrl(relayUrl))
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch relay metadata for ${relayUrl}:`, err)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const buildDefaultImage = requestUrl => {
|
||||
return new URL("/maskable-icon-512x512.png", requestUrl.origin).toString()
|
||||
}
|
||||
|
||||
const getMetadataForInvite = async (url, match) => {
|
||||
const relayParam = url.searchParams.get("r")
|
||||
if (!relayParam) return undefined
|
||||
|
||||
const relayMetadata = await fetchRelayMeta(relayParam)
|
||||
if (!relayMetadata) return undefined
|
||||
|
||||
const relayDisplay = displayRelayUrl(relayParam)
|
||||
const spaceName = relayMetadata.name
|
||||
const relayDescription = relayMetadata.description
|
||||
|
||||
const title = spaceName
|
||||
? `Invite to ${spaceName} on ${PLATFORM_NAME}`
|
||||
: `Invite to a Space on ${PLATFORM_NAME}`
|
||||
|
||||
const parts = []
|
||||
if (spaceName) {
|
||||
parts.push(`You are invited to join ${spaceName} on ${PLATFORM_NAME}.`)
|
||||
} else {
|
||||
parts.push(`You are invited to join a space on ${PLATFORM_NAME}.`)
|
||||
}
|
||||
|
||||
if (relayDisplay) parts.push(`Relay: ${relayDisplay}.`)
|
||||
if (relayDescription) parts.push(relayDescription)
|
||||
else parts.push(PLATFORM_DESCRIPTION)
|
||||
|
||||
const description = parts.join(" ")
|
||||
const image =
|
||||
relayMetadata.icon ||
|
||||
relayMetadata.picture ||
|
||||
relayMetadata.image ||
|
||||
buildDefaultImage(url)
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
url: url.toString(),
|
||||
site: url.origin,
|
||||
}
|
||||
}
|
||||
|
||||
const getMetadataForSpace = async (url, match) => {
|
||||
const relayParam = decodeRelay(match[1])
|
||||
if (!relayParam) return undefined
|
||||
|
||||
const relayMetadata = await fetchRelayMeta(relayParam)
|
||||
if (!relayMetadata) return undefined
|
||||
|
||||
const spaceName = relayMetadata.name || displayRelayUrl(relayParam)
|
||||
|
||||
return {
|
||||
title: `${spaceName} on ${PLATFORM_NAME}`,
|
||||
description: relayMetadata.description || PLATFORM_DESCRIPTION,
|
||||
image:
|
||||
relayMetadata.icon ||
|
||||
relayMetadata.picture ||
|
||||
relayMetadata.image ||
|
||||
buildDefaultImage(url),
|
||||
url: url.toString(),
|
||||
site: url.origin,
|
||||
}
|
||||
}
|
||||
|
||||
const getMetadataForSpaceSection = async (url, match) => {
|
||||
const spaceMeta = await getMetadataForSpace(url, match)
|
||||
if (!spaceMeta) return undefined
|
||||
|
||||
const section = match[2]
|
||||
const sectionName = section.charAt(0).toUpperCase() + section.slice(1)
|
||||
spaceMeta.title = `${sectionName} on ${spaceMeta.title}`
|
||||
return spaceMeta
|
||||
}
|
||||
|
||||
const getMetadataForSpaceItem = async (url, match) => {
|
||||
const spaceMeta = await getMetadataForSpace(url, match)
|
||||
if (!spaceMeta) return undefined
|
||||
|
||||
const section = match[2]
|
||||
let itemType = "Item"
|
||||
if (section === "calendar") itemType = "Event"
|
||||
if (section === "threads") itemType = "Thread"
|
||||
if (section === "polls") itemType = "Poll"
|
||||
if (section === "goals") itemType = "Goal"
|
||||
if (section === "classifieds") itemType = "Listing"
|
||||
|
||||
spaceMeta.title = `${itemType} on ${spaceMeta.title}`
|
||||
return spaceMeta
|
||||
}
|
||||
|
||||
const getMetadataForRoom = async (url, match) => {
|
||||
const spaceMeta = await getMetadataForSpace(url, match)
|
||||
if (!spaceMeta) return undefined
|
||||
|
||||
// Room metadata requires fetching from Nostr, which can be added later.
|
||||
spaceMeta.title = `Room on ${spaceMeta.title}`
|
||||
return spaceMeta
|
||||
}
|
||||
|
||||
const routes = [
|
||||
[/^\/join\/?$/, getMetadataForInvite],
|
||||
[/^\/spaces\/([^/]+)\/(calendar|chat|threads|polls|goals|classifieds|recent)\/?$/, getMetadataForSpaceSection],
|
||||
[/^\/spaces\/([^/]+)\/(calendar|threads|polls|goals|classifieds)\/([^/]+)\/?$/, getMetadataForSpaceItem],
|
||||
[/^\/spaces\/([^/]+)\/([^/]+)\/?$/, getMetadataForRoom],
|
||||
[/^\/spaces\/([^/]+)\/?$/, getMetadataForSpace],
|
||||
]
|
||||
|
||||
const getMetadataForRoute = async url => {
|
||||
for (const [regex, getMetadata] of routes) {
|
||||
const match = url.pathname.match(regex)
|
||||
if (match) {
|
||||
try {
|
||||
return await getMetadata(url, match)
|
||||
} catch (err) {
|
||||
console.error(`Error generating metadata for route ${url.pathname}:`, err)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const injectMeta = metadata => {
|
||||
const $ = load(TEMPLATE_HTML)
|
||||
|
||||
if (metadata.title) {
|
||||
$("title").text(metadata.title)
|
||||
$('meta[property="og:title"]').attr("content", metadata.title)
|
||||
$('meta[name="twitter:title"]').attr("content", metadata.title)
|
||||
}
|
||||
|
||||
if (metadata.description) {
|
||||
$('meta[name="description"]').attr("content", metadata.description)
|
||||
$('meta[property="og:description"]').attr("content", metadata.description)
|
||||
$('meta[name="twitter:description"]').attr("content", metadata.description)
|
||||
}
|
||||
|
||||
if (metadata.image) {
|
||||
$('meta[property="og:image"]').attr("content", metadata.image)
|
||||
$('meta[name="twitter:image"]').attr("content", metadata.image)
|
||||
}
|
||||
|
||||
if (metadata.url) {
|
||||
$('meta[property="og:url"]').attr("content", metadata.url)
|
||||
$('meta[name="twitter:site"]').attr("content", metadata.site)
|
||||
$('meta[name="twitter:url"]').attr("content", metadata.url)
|
||||
$('link[rel="canonical"]').attr("href", metadata.url)
|
||||
}
|
||||
|
||||
return $.html()
|
||||
}
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
// Only allow GET and HEAD requests
|
||||
app.use("*", async (context, next) => {
|
||||
const method = context.req.method
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
return context.text("Method Not Allowed", 405, {Allow: "GET, HEAD"})
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
// Serve static assets with appropriate caching
|
||||
app.use(
|
||||
"*",
|
||||
serveStatic({
|
||||
root: BUILD_DIR,
|
||||
onFound: (filePath, context) => {
|
||||
const isImmutable = filePath.split(path.sep).join("/").includes("/_app/immutable/")
|
||||
const cacheControl =
|
||||
path.basename(filePath) === "index.html"
|
||||
? "no-cache"
|
||||
: isImmutable
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "public, max-age=3600"
|
||||
|
||||
context.header("Cache-Control", cacheControl)
|
||||
|
||||
// Immutable assets are content-hashed by Vite, so the filename is itself a
|
||||
// stable content identifier. Exposing it as an ETag lets clients that
|
||||
// revalidate explicitly (e.g. emoji-picker-element checks its data source
|
||||
// on every load) skip re-downloading large files when nothing changed.
|
||||
if (isImmutable) {
|
||||
context.header("ETag", `"${path.basename(filePath)}"`)
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// SPA fallback for routes that don't match static files
|
||||
app.get("*", async context => {
|
||||
const requestUrl = requestUrlFromContext(context)
|
||||
const metadata = await getMetadataForRoute(requestUrl)
|
||||
const html = metadata ? injectMeta(metadata) : TEMPLATE_HTML
|
||||
|
||||
return context.html(html, 200, {
|
||||
"Cache-Control": metadata ? "no-store" : "no-cache",
|
||||
})
|
||||
})
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
hostname: HOST,
|
||||
port: PORT,
|
||||
},
|
||||
() => {
|
||||
console.log(`Flotilla server running on http://${HOST}:${PORT}`)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"welshman": {
|
||||
"source": "coracle-social/welshman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/welshman/SKILL.md",
|
||||
"computedHash": "586c6b142324e0a9043e7af16c662cee5f114d649367baba088282cc0de12734"
|
||||
},
|
||||
"welshman-app": {
|
||||
"source": "coracle-social/welshman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/welshman-app/SKILL.md",
|
||||
"computedHash": "764a3bed16678b18e3935fee6069ceed965004ce4e624ae1a7edadfca6708ca1"
|
||||
},
|
||||
"welshman-content": {
|
||||
"source": "coracle-social/welshman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/welshman-content/SKILL.md",
|
||||
"computedHash": "8ad4b3646e781d124c5332565cc4e0333664fcbcb5dccafc70b1d21266bcafd8"
|
||||
},
|
||||
"welshman-editor": {
|
||||
"source": "coracle-social/welshman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/welshman-editor/SKILL.md",
|
||||
"computedHash": "dbc39e6506231d1071b75453a78d99bb90017c17a57fd087c659a1daae335536"
|
||||
},
|
||||
"welshman-feeds": {
|
||||
"source": "coracle-social/welshman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/welshman-feeds/SKILL.md",
|
||||
"computedHash": "148bd556a0a08b9dc07b959c7ecd8b3259058dd2b3a3b2a2f2171c1cc669b25c"
|
||||
},
|
||||
"welshman-lib": {
|
||||
"source": "coracle-social/welshman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/welshman-lib/SKILL.md",
|
||||
"computedHash": "16cf693a002d2e781c085a38c3b43f93c25ab5a0f43f9404c3fa2ead39139b50"
|
||||
},
|
||||
"welshman-net": {
|
||||
"source": "coracle-social/welshman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/welshman-net/SKILL.md",
|
||||
"computedHash": "234d48ff9ebea01919011db7f471bf997574424c313090bcfaf440762f6e2284"
|
||||
},
|
||||
"welshman-router": {
|
||||
"source": "coracle-social/welshman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/welshman-router/SKILL.md",
|
||||
"computedHash": "f37e0c08fa32b577786f33fa21462fabf325eee27d32bad87269466e94d7dd72"
|
||||
},
|
||||
"welshman-signer": {
|
||||
"source": "coracle-social/welshman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/welshman-signer/SKILL.md",
|
||||
"computedHash": "a62c6d5211b904e4edb992ad2003765ab9956694bf1c30f3b4179c3d1d159a40"
|
||||
},
|
||||
"welshman-store": {
|
||||
"source": "coracle-social/welshman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/welshman-store/SKILL.md",
|
||||
"computedHash": "2079746f8b5ac0b8ef3ba49afc31a120f9a3a17671b6e2512e8481c5d9979a5c"
|
||||
},
|
||||
"welshman-util": {
|
||||
"source": "coracle-social/welshman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/welshman-util/SKILL.md",
|
||||
"computedHash": "2d4c06937e9417c72347f1a54ae5c3688fbaa001a4f1c2a094f79f6637a99f33"
|
||||
}
|
||||
}
|
||||
}
|
||||
+280
-253
@@ -1,46 +1,6 @@
|
||||
@import "@welshman/editor/index.css";
|
||||
@import "tailwindcss";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind 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");
|
||||
}
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
/* root */
|
||||
|
||||
@@ -52,98 +12,245 @@
|
||||
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
[data-theme] {
|
||||
@apply bg-base-300;
|
||||
--base-100: oklch(var(--b1));
|
||||
--base-200: oklch(var(--b2));
|
||||
--base-300: oklch(var(--b3));
|
||||
--base-content: oklch(var(--bc));
|
||||
--primary: oklch(var(--p));
|
||||
--primary-content: oklch(var(--pc));
|
||||
--secondary: oklch(var(--s));
|
||||
--secondary-content: oklch(var(--sc));
|
||||
--neutral: oklch(var(--n));
|
||||
--neutral-content: oklch(var(--nc));
|
||||
@utility pt-sai {
|
||||
padding-top: var(--sait);
|
||||
}
|
||||
|
||||
.mobile [data-tip]::before {
|
||||
display: none !important;
|
||||
@utility pr-sai {
|
||||
padding-right: var(--sair);
|
||||
}
|
||||
|
||||
/* safe area insets */
|
||||
@utility pb-sai {
|
||||
padding-bottom: var(--saib);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.pt-sai {
|
||||
padding-top: var(--sait);
|
||||
@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 p-4 sm:p-6;
|
||||
}
|
||||
|
||||
@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");
|
||||
}
|
||||
|
||||
.pr-sai {
|
||||
padding-right: var(--sair);
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Lato-Regular.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.pb-sai {
|
||||
padding-bottom: var(--saib);
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: bold;
|
||||
font-weight: 600;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Lato-Bold.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.pl-sai {
|
||||
padding-left: var(--sail);
|
||||
@font-face {
|
||||
font-family: "Lato";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src:
|
||||
local(""),
|
||||
url("/fonts/Italic.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.px-sai {
|
||||
@apply pl-sai pr-sai;
|
||||
/* 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));
|
||||
}
|
||||
|
||||
.py-sai {
|
||||
@apply pt-sai pb-sai;
|
||||
[data-theme] {
|
||||
@apply bg-base-300;
|
||||
}
|
||||
|
||||
.p-sai {
|
||||
@apply py-sai px-sai;
|
||||
.mobile [data-tip]::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mt-sai {
|
||||
margin-top: var(--sait);
|
||||
}
|
||||
|
||||
.mr-sai {
|
||||
margin-right: var(--sair);
|
||||
}
|
||||
|
||||
.mb-sai {
|
||||
margin-bottom: var(--saib);
|
||||
}
|
||||
|
||||
.ml-sai {
|
||||
margin-left: var(--sail);
|
||||
}
|
||||
|
||||
.mx-sai {
|
||||
@apply ml-sai mr-sai;
|
||||
}
|
||||
|
||||
.my-sai {
|
||||
@apply mt-sai mb-sai;
|
||||
}
|
||||
|
||||
.m-sai {
|
||||
@apply my-sai mx-sai;
|
||||
}
|
||||
|
||||
.top-sai {
|
||||
top: var(--sait);
|
||||
}
|
||||
|
||||
.right-sai {
|
||||
right: var(--sair);
|
||||
}
|
||||
|
||||
.bottom-sai {
|
||||
bottom: var(--saib);
|
||||
}
|
||||
|
||||
.left-sai {
|
||||
left: var(--sail);
|
||||
}
|
||||
/* safe area insets */
|
||||
}
|
||||
|
||||
/* utilities */
|
||||
@@ -165,134 +272,42 @@
|
||||
@apply bg-base-300 text-base-content transition-colors;
|
||||
}
|
||||
|
||||
.card2 {
|
||||
@apply rounded-box p-4 text-base-content sm:p-6;
|
||||
}
|
||||
|
||||
.card2.card2-sm {
|
||||
@apply p-2 text-base-content sm:p-4;
|
||||
}
|
||||
|
||||
.column {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.center {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.row-2 {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.row-3 {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.row-4 {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
.col-2 {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.col-3 {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.col-4 {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.col-8 {
|
||||
@apply flex flex-col gap-8;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply justify-start overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.ellipsize {
|
||||
@apply overflow-hidden text-ellipsis;
|
||||
@apply text-base-content p-2 sm:p-4;
|
||||
}
|
||||
|
||||
[data-tip]::before {
|
||||
@apply ellipsize;
|
||||
}
|
||||
|
||||
.content-padding-x {
|
||||
@apply px-4 sm:px-8 md:px-12;
|
||||
}
|
||||
|
||||
.content-padding-t {
|
||||
@apply pt-4 sm:pt-8 md:pt-12;
|
||||
}
|
||||
|
||||
.content-padding-b {
|
||||
@apply pb-4 sm:pb-8 md:pb-12;
|
||||
}
|
||||
|
||||
.content-padding-y {
|
||||
@apply content-padding-t content-padding-b;
|
||||
}
|
||||
|
||||
.content-sizing {
|
||||
@apply m-auto w-full max-w-3xl;
|
||||
}
|
||||
|
||||
.content {
|
||||
@apply content-sizing content-padding-x content-padding-y;
|
||||
}
|
||||
|
||||
.heading {
|
||||
@apply text-center text-2xl;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
@apply text-center text-xl;
|
||||
}
|
||||
|
||||
.superheading {
|
||||
@apply text-center text-4xl;
|
||||
}
|
||||
|
||||
.link {
|
||||
@apply cursor-pointer text-primary underline;
|
||||
@apply overflow-hidden text-ellipsis;
|
||||
}
|
||||
|
||||
.input input::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.shadow-top-xl {
|
||||
@apply shadow-[0_20px_25px_-5px_rgb(0,0,0,0.1)_0_8px_10px_-6px_rgb(0,0,0,0.1)];
|
||||
}
|
||||
|
||||
/* tiptap */
|
||||
|
||||
.input-editor,
|
||||
.chat-editor,
|
||||
.note-editor {
|
||||
@apply -m-1 min-h-12 p-1 text-sm;
|
||||
@apply -m-1 p-1;
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
--tiptap-object-bg: var(--neutral);
|
||||
--tiptap-object-fg: var(--neutral-content);
|
||||
--tiptap-active-bg: var(--primary);
|
||||
--tiptap-active-fg: var(--primary-content);
|
||||
--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(--base-100);
|
||||
--tiptap-object-fg: var(--base-content);
|
||||
--tiptap-active-bg: var(--base-300);
|
||||
--tiptap-active-fg: var(--base-content);
|
||||
--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-l-2 border-solid border-base-100;
|
||||
@apply border-base-100 border-l-2 border-solid;
|
||||
}
|
||||
|
||||
.tiptap-suggestions__selected {
|
||||
@@ -300,7 +315,7 @@
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
@apply max-h-[350px] overflow-y-auto p-2 px-4;
|
||||
@apply max-h-[350px] min-h-10 overflow-y-auto p-2 px-4;
|
||||
}
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
@@ -312,13 +327,13 @@
|
||||
}
|
||||
|
||||
.note-editor .tiptap {
|
||||
--tiptap-object-bg: var(--base-200);
|
||||
@apply input input-bordered h-auto min-h-32 rounded-box p-[.65rem] pb-6;
|
||||
--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(--base-200);
|
||||
@apply input input-bordered h-auto p-[.65rem];
|
||||
--tiptap-object-bg: var(--color-base-200);
|
||||
@apply input block h-auto p-[.65rem];
|
||||
}
|
||||
|
||||
/* link-content, based on tiptap */
|
||||
@@ -330,8 +345,8 @@
|
||||
white-space: nowrap;
|
||||
border-radius: 3px;
|
||||
padding: 0 0.25rem;
|
||||
background-color: var(--base-100);
|
||||
color: var(--base-content);
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
/* content rendered by welshman/content */
|
||||
@@ -347,23 +362,31 @@
|
||||
/* date input */
|
||||
|
||||
.picker {
|
||||
--date-picker-foreground: var(--base-content);
|
||||
--date-picker-background: var(--base-300);
|
||||
--date-picker-highlight-border: var(--primary);
|
||||
--date-picker-selected-color: var(--primary-content);
|
||||
--date-picker-selected-background: var(--primary);
|
||||
--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 input-bordered rounded-lg px-0;
|
||||
@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;
|
||||
@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;
|
||||
}
|
||||
@@ -371,15 +394,15 @@
|
||||
/* emoji picker */
|
||||
|
||||
emoji-picker {
|
||||
--background: var(--base-100);
|
||||
--border-color: var(--base-100);
|
||||
--background: var(--color-base-100);
|
||||
--border-color: var(--color-base-100);
|
||||
--border-radius: var(--rounded-box);
|
||||
--button-active-background: var(--base-content);
|
||||
--button-hover-background: var(--base-content);
|
||||
--indicator-color: var(--base-content);
|
||||
--input-border-color: var(--base-100);
|
||||
--input-font-color: var(--base-content);
|
||||
--outline-color: var(--base-100);
|
||||
--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 */
|
||||
@@ -390,34 +413,38 @@ progress[value]::-webkit-progress-value {
|
||||
|
||||
/* content width for fixed elements */
|
||||
|
||||
.cw {
|
||||
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
|
||||
.left-content {
|
||||
@apply md:left-[calc(18.5rem+var(--sail))];
|
||||
}
|
||||
|
||||
.cw-full {
|
||||
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
|
||||
}
|
||||
|
||||
.cb {
|
||||
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
||||
.left-content-full {
|
||||
@apply md:left-[calc(3.5rem+var(--sail))];
|
||||
}
|
||||
|
||||
/* Keyboard open state adjustments */
|
||||
|
||||
body.keyboard-open .cb {
|
||||
@apply bottom-sai;
|
||||
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 cb cw fixed z-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 fixed bottom-28 right-4 z-feature md:bottom-16;
|
||||
@apply pb-sai z-feature fixed right-4 bottom-28 md:bottom-16;
|
||||
}
|
||||
|
||||
+7
-4
@@ -2,15 +2,18 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>{NAME}</title>
|
||||
<link rel="canonical" href="{URL}" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="{ACCENT}" />
|
||||
<meta name="description" content="{DESCRIPTION}" />
|
||||
<meta name="og:url" content="{URL}" />
|
||||
<meta name="og:type" content="website" />
|
||||
<meta name="og:title" content="{NAME}" />
|
||||
<meta name="og:description" content="{DESCRIPTION}" />
|
||||
<meta property="og:url" content="{URL}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="{NAME}" />
|
||||
<meta property="og:description" content="{DESCRIPTION}" />
|
||||
<meta property="og:image" content="" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:site" content="{URL}" />
|
||||
<meta name="twitter:title" content="{NAME}" />
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
REPORT,
|
||||
ROOM_ADD_MEMBER,
|
||||
ROOM_JOIN,
|
||||
ROOM_LEAVE,
|
||||
ROOM_MEMBERS,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
getPubkeyTagValues,
|
||||
getTagValue,
|
||||
sortEventsDesc,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {first, groupBy, removeUndefined} from "@welshman/lib"
|
||||
import {derived} from "svelte/store"
|
||||
import {deriveEventsForUrl} from "@app/repository"
|
||||
import {getRoomMembers} from "@app/members"
|
||||
// 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],
|
||||
},
|
||||
]),
|
||||
$events => {
|
||||
const getRoomId = (e: TrustedEvent) =>
|
||||
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
|
||||
for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
|
||||
if (!h) continue
|
||||
|
||||
const roomJoins: TrustedEvent[] = []
|
||||
const roomLeaves: TrustedEvent[] = []
|
||||
const roomMembershipEvents: TrustedEvent[] = []
|
||||
|
||||
for (const event of roomEvents) {
|
||||
switch (event.kind) {
|
||||
case ROOM_JOIN:
|
||||
roomJoins.push(event)
|
||||
break
|
||||
case ROOM_LEAVE:
|
||||
roomLeaves.push(event)
|
||||
break
|
||||
case ROOM_MEMBERS:
|
||||
case ROOM_ADD_MEMBER:
|
||||
case ROOM_REMOVE_MEMBER:
|
||||
roomMembershipEvents.push(event)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const roomMembers = new Set(getRoomMembers(url, h, roomMembershipEvents))
|
||||
|
||||
pendingJoins.push(
|
||||
...removeUndefined(
|
||||
Array.from(groupBy(e => e.pubkey, roomJoins).values()).map(events =>
|
||||
first(sortEventsDesc(events)),
|
||||
),
|
||||
).filter(({pubkey, created_at}) => {
|
||||
if (roomMembers.has(pubkey)) return false
|
||||
if (
|
||||
roomMembershipEvents.some(event => {
|
||||
if (event.created_at <= created_at) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (event.kind === ROOM_MEMBERS) {
|
||||
return true
|
||||
}
|
||||
|
||||
return getPubkeyTagValues(event.tags).includes(pubkey)
|
||||
})
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false
|
||||
|
||||
return true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return sortEventsDesc([...reports, ...pendingJoins])
|
||||
},
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint prefer-rest-params: 0 */
|
||||
|
||||
import {page} from "$app/stores"
|
||||
import {getSetting} from "@app/core/state"
|
||||
import {getSetting} from "@app/settings"
|
||||
|
||||
const w = window as any
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* 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 {
|
||||
DisconnectReason,
|
||||
LocalParticipant,
|
||||
LocalTrackPublication,
|
||||
Participant,
|
||||
Room as LiveKitRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
TrackPublication,
|
||||
supportsAudioOutputSelection,
|
||||
type AudioCaptureOptions,
|
||||
} from "livekit-client"
|
||||
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 {
|
||||
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"
|
||||
|
||||
export enum DeviceKind {
|
||||
AudioInput = "audioinput",
|
||||
AudioOutput = "audiooutput",
|
||||
VideoInput = "videoinput",
|
||||
}
|
||||
|
||||
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}`})
|
||||
}
|
||||
}
|
||||
|
||||
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
|
||||
muted: !participant.isMicrophoneEnabled,
|
||||
cameraOn: participant.isCameraEnabled,
|
||||
})
|
||||
|
||||
const deleteParticipant = (identity: string) => {
|
||||
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
|
||||
}
|
||||
|
||||
const syncParticipantMedia = (participant: Participant) => {
|
||||
const state = participantMediaFrom(participant)
|
||||
participantMediaState.update(m => {
|
||||
const prev = m.get(participant.identity)
|
||||
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
|
||||
const next = new Map(m)
|
||||
next.set(participant.identity, state)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
|
||||
const resyncAfterReconnect = (room: LiveKitRoom) => {
|
||||
if (room !== activeRoom) return
|
||||
|
||||
const next = new Map<string, ParticipantMediaState>()
|
||||
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) {
|
||||
next.set(p.identity, participantMediaFrom(p))
|
||||
}
|
||||
participantMediaState.set(next)
|
||||
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
const {localParticipant} = room
|
||||
voiceMicMuted.set(!localParticipant.isMicrophoneEnabled)
|
||||
currentVoiceSession.set({
|
||||
...session,
|
||||
cameraOn: localParticipant.isCameraEnabled,
|
||||
screenShareOn: localParticipant.isScreenShareEnabled,
|
||||
})
|
||||
triggerVideoFeedCount()
|
||||
}
|
||||
|
||||
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
|
||||
syncParticipantMedia(participant)
|
||||
}
|
||||
|
||||
const fetchLivekitToken = async (
|
||||
url: string,
|
||||
groupId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{server_url: string; participant_token: string}> => {
|
||||
const endpoint = getLivekitEndpoint(url, groupId)
|
||||
|
||||
const $signer = signer.get()
|
||||
if (!$signer) throw new Error("No signer available")
|
||||
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
||||
|
||||
const template = await makeHttpAuth(endpoint, "GET")
|
||||
const signedEvent = await $signer.sign(template)
|
||||
const authHeader = makeHttpAuthHeader(signedEvent)
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {Authorization: authHeader},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(`Token request failed (${response.status}): ${text}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const loadVoiceParticipants = (url: string, h: string) =>
|
||||
load({
|
||||
relays: [url],
|
||||
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
|
||||
})
|
||||
|
||||
export const deriveVoiceParticipants = (url: string, h: string) =>
|
||||
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
|
||||
derived(
|
||||
[
|
||||
participantMediaState,
|
||||
currentVoiceRoom,
|
||||
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||
],
|
||||
([$participantMediaState, $currentVoiceRoom, $publishedParticipantList]) => {
|
||||
const inCall = $participantMediaState.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
||||
|
||||
if (inCall) {
|
||||
const participants = [...$participantMediaState.keys()].map(participantFromLiveKitIdentity)
|
||||
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
||||
} else {
|
||||
const latestEvent = $publishedParticipantList as TrustedEvent | undefined
|
||||
if (!latestEvent) return []
|
||||
const participants = removeUndefined(
|
||||
map(
|
||||
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
|
||||
getTags("participant", latestEvent.tags),
|
||||
),
|
||||
)
|
||||
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const setUpMicrophone = async (
|
||||
startMuted: boolean,
|
||||
preferredMicId: string | undefined,
|
||||
participant: LocalParticipant,
|
||||
signal?: AbortSignal,
|
||||
settleSignal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
if (startMuted) {
|
||||
return true
|
||||
}
|
||||
|
||||
let muted = true
|
||||
let capture: AudioCaptureOptions | undefined = undefined
|
||||
if (preferredMicId) {
|
||||
capture = {deviceId: preferredMicId}
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
participant.setMicrophoneEnabled(true, capture),
|
||||
whenTimeout(15_000, {message: "Microphone access timed out.", signal: settleSignal}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
muted = false
|
||||
} catch (e) {
|
||||
// Timeout or microphone rejection: join muted, the call is still usable. A
|
||||
// genuine abort is surfaced to the caller so it can tear down the room.
|
||||
if (e instanceof AbortError) throw e
|
||||
if (!(e instanceof TimeoutError)) {
|
||||
pushToast({theme: "error", message: "Could not access microphone"})
|
||||
}
|
||||
}
|
||||
return muted
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const attemptReconnect = async () => {
|
||||
const target = get(currentVoiceRoom)
|
||||
if (!target) return
|
||||
|
||||
try {
|
||||
await joinVoiceRoom(target.url, target.h)
|
||||
} catch {
|
||||
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
||||
pushToast({theme: "error", message: "Voice connection lost."})
|
||||
clearReconnectSchedule()
|
||||
return
|
||||
}
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (reconnectTimeout !== undefined) return
|
||||
if (!get(currentVoiceRoom)) return
|
||||
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
|
||||
pushToast({theme: "error", message: "Voice connection lost."})
|
||||
return
|
||||
}
|
||||
|
||||
const delay = RECONNECT_DELAYS[reconnectAttempt]!
|
||||
reconnectAttempt++
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
reconnectTimeout = undefined
|
||||
void attemptReconnect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const makeOnRoomReconnected = (room: LiveKitRoom) => () => {
|
||||
if (room !== activeRoom) return
|
||||
resyncAfterReconnect(room)
|
||||
}
|
||||
|
||||
const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason) => {
|
||||
// Ignore disconnects from rooms that are no longer the active session.
|
||||
if (room !== activeRoom) return
|
||||
|
||||
activeRoom = undefined
|
||||
room.removeAllListeners()
|
||||
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
voiceMicMuted.set(true)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
if (reason === DisconnectReason.JOIN_FAILURE) {
|
||||
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
|
||||
} else if (get(currentVoiceRoom)) {
|
||||
clearReconnectSchedule()
|
||||
scheduleReconnect()
|
||||
} else {
|
||||
pushToast({theme: "error", message: "Voice connection lost."})
|
||||
}
|
||||
}
|
||||
speakingParticipants.set([])
|
||||
participantMediaState.set(new Map())
|
||||
}
|
||||
|
||||
const onTrackSubscribed = (track: Track) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
const element = track.attach()
|
||||
element.style.display = "none"
|
||||
document.body.appendChild(element)
|
||||
element.play().catch(() => {})
|
||||
} else if (track.kind === Track.Kind.Video) {
|
||||
triggerVideoFeedCount()
|
||||
}
|
||||
}
|
||||
|
||||
const onTrackUnsubscribed = (track: Track) => {
|
||||
track.detach().forEach(el => el.remove())
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
triggerVideoFeedCount()
|
||||
}
|
||||
}
|
||||
|
||||
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
||||
speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
|
||||
}
|
||||
|
||||
const playJoinSound = () => {
|
||||
const audio = new Audio("/join-voice-room.mp3")
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
const onParticipantConnected = (participant: Participant) => {
|
||||
syncParticipantMedia(participant)
|
||||
playJoinSound()
|
||||
}
|
||||
|
||||
const onParticipantDisconnected = (participant: {identity: string}) => {
|
||||
deleteParticipant(participant.identity)
|
||||
}
|
||||
|
||||
const onLocalTrackUnpublished = (
|
||||
publication: LocalTrackPublication,
|
||||
participant: LocalParticipant,
|
||||
) => {
|
||||
if (publication.source !== Track.Source.ScreenShare) return
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session || participant.identity !== session.room.localParticipant.identity) return
|
||||
if (!session.screenShareOn) return
|
||||
currentVoiceSession.set({...session, screenShareOn: false})
|
||||
}
|
||||
|
||||
let joinAbortController: AbortController | undefined
|
||||
|
||||
const abortJoinVoiceRoom = () => {
|
||||
joinAbortController?.abort()
|
||||
}
|
||||
|
||||
export const cancelJoinVoiceRoom = () => {
|
||||
clearReconnectSchedule()
|
||||
abortJoinVoiceRoom()
|
||||
}
|
||||
|
||||
export const joinVoiceRoom = async (
|
||||
url: string,
|
||||
h: string,
|
||||
startMuted = true,
|
||||
preferredMicId?: string,
|
||||
): Promise<void> => {
|
||||
abortJoinVoiceRoom()
|
||||
|
||||
currentVoiceRoom.set(get(deriveRoom(url, h)))
|
||||
voiceState.set(VoiceState.Joining)
|
||||
|
||||
const controller = new AbortController()
|
||||
joinAbortController = controller
|
||||
const signal = controller.signal
|
||||
const isActive = () => joinAbortController === controller
|
||||
|
||||
// Self-cleaning controller: aborted in finally so whenTimeout/whenAborted
|
||||
// helpers clear their timers/listeners once the races below have settled.
|
||||
const settle = new AbortController()
|
||||
|
||||
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(currentVoiceSession)) {
|
||||
await Promise.race([
|
||||
leaveVoiceRoom(),
|
||||
whenTimeout(15_000, {message: "Leaving previous call timed out.", signal: settle.signal}),
|
||||
whenAborted(signal),
|
||||
]).catch(e => {
|
||||
if (e instanceof AbortError) throw e
|
||||
})
|
||||
|
||||
// leaveVoiceRoom flips voiceState to Disconnected; re-assert Joining.
|
||||
voiceState.set(VoiceState.Joining)
|
||||
}
|
||||
|
||||
if (signal.aborted) throw new AbortError()
|
||||
|
||||
const {server_url, participant_token} = await Promise.race([
|
||||
fetchLivekitToken(url, h, signal),
|
||||
whenTimeout(15_000, {
|
||||
message: "Connection timed out. Please check your network and try again.",
|
||||
signal: settle.signal,
|
||||
}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
|
||||
if (signal.aborted) throw new AbortError()
|
||||
|
||||
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
|
||||
activeRoom = liveKitRoom
|
||||
|
||||
liveKitRoom.on(RoomEvent.Disconnected, makeOnRoomDisconnected(liveKitRoom))
|
||||
liveKitRoom.on(RoomEvent.Reconnected, makeOnRoomReconnected(liveKitRoom))
|
||||
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
||||
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||
liveKitRoom.on(RoomEvent.TrackMuted, onParticipantMediaChanged)
|
||||
liveKitRoom.on(RoomEvent.TrackUnmuted, onParticipantMediaChanged)
|
||||
liveKitRoom.on(RoomEvent.TrackPublished, onParticipantMediaChanged)
|
||||
liveKitRoom.on(RoomEvent.TrackUnpublished, onParticipantMediaChanged)
|
||||
liveKitRoom.on(RoomEvent.LocalTrackPublished, onParticipantMediaChanged)
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
|
||||
whenTimeout(15_000, {
|
||||
message: "Connection timed out. Please check your network and try again.",
|
||||
signal: settle.signal,
|
||||
}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
} catch (e) {
|
||||
if (activeRoom === liveKitRoom) activeRoom = undefined
|
||||
liveKitRoom.removeAllListeners()
|
||||
liveKitRoom.disconnect()
|
||||
throw e
|
||||
}
|
||||
|
||||
participantMediaState.set(new Map())
|
||||
syncParticipantMedia(liveKitRoom.localParticipant)
|
||||
for (const p of liveKitRoom.remoteParticipants.values()) {
|
||||
syncParticipantMedia(p)
|
||||
}
|
||||
|
||||
// Bounded against timeout/abort inside setUpMicrophone: a stuck permission
|
||||
// prompt resolves to muted rather than hanging the join forever.
|
||||
const muted = await setUpMicrophone(
|
||||
startMuted,
|
||||
preferredMicId,
|
||||
liveKitRoom.localParticipant,
|
||||
signal,
|
||||
settle.signal,
|
||||
)
|
||||
|
||||
// A cancel during the mic step must tear down the connected room rather
|
||||
// than leaking it.
|
||||
if (signal.aborted) {
|
||||
if (activeRoom === liveKitRoom) activeRoom = undefined
|
||||
liveKitRoom.removeAllListeners()
|
||||
liveKitRoom.disconnect()
|
||||
throw new AbortError()
|
||||
}
|
||||
|
||||
voiceMicMuted.set(muted)
|
||||
currentVoiceSession.set({
|
||||
url,
|
||||
h,
|
||||
room: liveKitRoom,
|
||||
cameraOn: false,
|
||||
screenShareOn: false,
|
||||
})
|
||||
voiceState.set(VoiceState.Connected)
|
||||
clearReconnectSchedule()
|
||||
playJoinSound()
|
||||
} catch (e) {
|
||||
if (isActive()) voiceState.set(VoiceState.Disconnected)
|
||||
if (e instanceof AbortError) {
|
||||
clearReconnectSchedule()
|
||||
return
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
settle.abort()
|
||||
if (isActive()) joinAbortController = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const leaveVoiceRoom = async () => {
|
||||
clearReconnectSchedule()
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
const audio = new Audio("/leave-voice-room.mp3")
|
||||
audio.play().catch(() => {})
|
||||
|
||||
if (session.cameraOn) {
|
||||
try {
|
||||
await session.room.localParticipant.setCameraEnabled(false)
|
||||
} catch {
|
||||
pushToast({theme: "error", message: "Error turning off camera."})
|
||||
}
|
||||
}
|
||||
|
||||
if (session.screenShareOn) {
|
||||
try {
|
||||
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.
|
||||
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(currentVoiceSession) === session) {
|
||||
voiceState.set(VoiceState.Disconnected)
|
||||
videoPrimaryTileKey.set(undefined)
|
||||
voiceMicMuted.set(true)
|
||||
currentVoiceSession.set(undefined)
|
||||
resetVideoCallLayout()
|
||||
speakingParticipants.set([])
|
||||
participantMediaState.set(new Map())
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleMute = async () => {
|
||||
const session = get(currentVoiceSession)
|
||||
if (!session) return
|
||||
|
||||
voiceMicMuted.update(not)
|
||||
if (get(voiceMicMuted)) {
|
||||
// Disable and re-enable microphone to trigger permission prompt
|
||||
session.room.localParticipant.setMicrophoneEnabled(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await session.room.localParticipant.setMicrophoneEnabled(true)
|
||||
} catch (e) {
|
||||
voiceMicMuted.set(true)
|
||||
pushToast({theme: "error", message: "Could not access microphone"})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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 {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"
|
||||
export type Chat = {
|
||||
id: string
|
||||
pubkeys: string[]
|
||||
messages: TrustedEvent[]
|
||||
last_activity: number
|
||||
search_text: string
|
||||
}
|
||||
|
||||
export const getChatPubkeys = (pubkeys: string[]) => sort(uniq(append(pubkey.get()!, pubkeys)))
|
||||
|
||||
export const getChatPubkeysFromEvent = (event: TrustedEvent) =>
|
||||
getChatPubkeys(getPubkeyTagValues(event.tags).concat(event.pubkey))
|
||||
|
||||
export const makeChatId = (pubkeys: string[]) => {
|
||||
const userPubkey = pubkey.get()!
|
||||
const otherPubkeys = remove(userPubkey, uniq(pubkeys))
|
||||
const visiblePubkeys = otherPubkeys.length === 0 ? [userPubkey] : otherPubkeys
|
||||
|
||||
return sort(visiblePubkeys).join(",")
|
||||
}
|
||||
|
||||
export const splitChatId = (id: string) => getChatPubkeys(id.split(","))
|
||||
|
||||
export const chatsById = call(() => {
|
||||
const chatsById = new Map<string, Chat>()
|
||||
const chatsByPubkey = new Map<string, string[]>()
|
||||
|
||||
const addSearchText = (chat: Override<Chat, {search_text?: string}>) => {
|
||||
chat.search_text =
|
||||
chat.pubkeys.length === 1
|
||||
? displayProfileByPubkey(chat.pubkeys[0]) + " note to self"
|
||||
: remove(pubkey.get()!, chat.pubkeys).map(displayProfileByPubkey).join(" ")
|
||||
|
||||
return chat as Chat
|
||||
}
|
||||
|
||||
return readable(chatsById, set => {
|
||||
const indexChatByPubkeys = (chat: Chat) => {
|
||||
for (const pubkey of chat.pubkeys) {
|
||||
chatsByPubkey.set(pubkey, uniq(append(chat.id, chatsByPubkey.get(pubkey) || [])))
|
||||
}
|
||||
}
|
||||
|
||||
const addEvents = (events: TrustedEvent[]) => {
|
||||
let dirty = false
|
||||
for (const event of events) {
|
||||
if (DM_KINDS.includes(event.kind)) {
|
||||
const pubkeys = getChatPubkeysFromEvent(event)
|
||||
const id = makeChatId(pubkeys)
|
||||
const chat = chatsById.get(id)
|
||||
const messages = sortBy(
|
||||
e => -e.created_at,
|
||||
uniqBy(e => e.id, append(event, chat?.messages || [])),
|
||||
)
|
||||
const last_activity = Math.max(chat?.last_activity || 0, event.created_at)
|
||||
const updatedChat = addSearchText({id, pubkeys, messages, last_activity})
|
||||
|
||||
chatsById.set(id, updatedChat)
|
||||
indexChatByPubkeys(updatedChat)
|
||||
|
||||
dirty = true
|
||||
}
|
||||
|
||||
if (event.kind === PROFILE) {
|
||||
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
|
||||
const chat = chatsById.get(chatId)
|
||||
|
||||
if (chat) {
|
||||
addSearchText(chat)
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
set(chatsById)
|
||||
}
|
||||
}
|
||||
|
||||
const removeEvents = (removed: Set<string>) => {
|
||||
let dirty = false
|
||||
|
||||
for (const id of removed) {
|
||||
const event = repository.getEvent(id)
|
||||
|
||||
if (event && DM_KINDS.includes(event.kind)) {
|
||||
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
|
||||
const chat = chatsById.get(chatId)
|
||||
|
||||
if (chat) {
|
||||
chat.messages = reject(spec({id: event.id}), chat.messages)
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
set(chatsById)
|
||||
}
|
||||
}
|
||||
|
||||
addEvents(repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}]))
|
||||
|
||||
const unsubscribers = [
|
||||
on(repository, "update", ({added, removed}: RepositoryUpdate) => {
|
||||
// Do this async so that profiles are populated
|
||||
setTimeout(() => {
|
||||
addEvents(added)
|
||||
removeEvents(removed)
|
||||
}, 200)
|
||||
}),
|
||||
]
|
||||
|
||||
return () => unsubscribers.forEach(call)
|
||||
})
|
||||
})
|
||||
|
||||
export const deriveChat = makeDeriveItem(chatsById)
|
||||
|
||||
export const chatSearch = derived(throttled(1500, chatsById), $chatsByPubkey => {
|
||||
return createSearch(
|
||||
sortBy(c => -c.last_activity, Array.from($chatsByPubkey.values())),
|
||||
{
|
||||
getValue: (chat: Chat) => chat.id,
|
||||
fuseOptions: {keys: ["search_text"]},
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
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 {modals} from "@app/util/modal"
|
||||
import {modal} from "@app/modal"
|
||||
|
||||
interface Props {
|
||||
children: Snippet
|
||||
@@ -20,8 +19,8 @@
|
||||
<PrimaryNav>
|
||||
{@render children?.()}
|
||||
</PrimaryNav>
|
||||
{:else if !$modals[$page.url.hash.slice(1)]}
|
||||
<Dialog children={{component: Landing, props: {}}} />
|
||||
{:else if !$modal}
|
||||
<Dialog noEscape children={{component: Landing, props: {}}} />
|
||||
{/if}
|
||||
</div>
|
||||
<Toast />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import QRCode from "@app/components/QRCode.svelte"
|
||||
import type {Nip46Controller} from "@app/util/nip46"
|
||||
import type {Nip46Controller} from "@app/nip46"
|
||||
|
||||
type Props = {
|
||||
controller: Nip46Controller
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
import QrCode from "@assets/icons/qr-code.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import InfoBunker from "@app/components/InfoBunker.svelte"
|
||||
import type {Nip46Controller} from "@app/util/nip46"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import type {Nip46Controller} from "@app/nip46"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
controller: Nip46Controller
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
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"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
@@ -38,7 +40,7 @@
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
shareToChat?: boolean
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
const {url, h, shareToChat = false}: Props = $props()
|
||||
</script>
|
||||
|
||||
<CalendarEventForm {url} {h}>
|
||||
<CalendarEventForm {url} {h} {shareToChat}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
<ModalTitle>Create an Event</ModalTitle>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {writable} from "svelte/store"
|
||||
import {randomId, HOUR} from "@welshman/lib"
|
||||
import {makeEvent, EVENT_TIME} from "@welshman/util"
|
||||
import {publishThunk} from "@welshman/app"
|
||||
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"
|
||||
@@ -18,26 +18,36 @@
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {PROTECTED} from "@app/core/state"
|
||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {canEnforceNip70} from "@app/core/commands"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
|
||||
type Values = {
|
||||
d: string
|
||||
title: string
|
||||
content: string | object
|
||||
location: string
|
||||
start?: number
|
||||
end?: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
shareToChat?: boolean
|
||||
header: Snippet
|
||||
initialValues?: {
|
||||
d: string
|
||||
title: string
|
||||
content: string
|
||||
location: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
initialValues?: Values
|
||||
}
|
||||
|
||||
const {url, h, header, initialValues}: Props = $props()
|
||||
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
|
||||
|
||||
const draftKey = new DraftKey<Values>(`calendar:${url}:${h ?? ""}`)
|
||||
|
||||
if (!initialValues) {
|
||||
initialValues = draftKey.get()
|
||||
}
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -48,7 +58,7 @@
|
||||
const selectFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||
|
||||
const submit = async () => {
|
||||
if ($uploading) return
|
||||
if ($uploading || loading) return
|
||||
|
||||
if (!title) {
|
||||
return pushToast({
|
||||
@@ -74,38 +84,68 @@
|
||||
const ed = await editor
|
||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
const tags = [
|
||||
["d", initialValues?.d || randomId()],
|
||||
["d", d],
|
||||
["title", title],
|
||||
["location", location || ""],
|
||||
["location", location],
|
||||
["start", start.toString()],
|
||||
["end", end.toString()],
|
||||
...daysBetween(start, end).map(D => ["D", D.toString()]),
|
||||
...ed.storage.nostr.getEditorTags(),
|
||||
]
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
|
||||
if (shareToChat) {
|
||||
publishRoomQuote({url, h, parent: calendarThunk.event, protect})
|
||||
}
|
||||
|
||||
pushToast({message: "Your event has been saved!"})
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
const event = makeEvent(EVENT_TIME, {content, tags})
|
||||
|
||||
pushToast({message: "Your event has been saved!"})
|
||||
publishThunk({event, relays: [url]})
|
||||
history.back()
|
||||
}
|
||||
|
||||
const content = initialValues?.content || ""
|
||||
const editor = makeEditor({url, submit, uploading, content})
|
||||
let loading = $state(false)
|
||||
|
||||
let title = $state(initialValues?.title || "")
|
||||
let location = $state(initialValues?.location || "")
|
||||
const d = $state(initialValues?.d ?? randomId())
|
||||
let title = $state(initialValues?.title ?? "")
|
||||
let location = $state(initialValues?.location ?? "")
|
||||
let start: number | undefined = $state(initialValues?.start)
|
||||
let end: number | undefined = $state(initialValues?.end)
|
||||
let endDirty = Boolean(initialValues?.end)
|
||||
let endDirty = $state(Boolean(initialValues?.end))
|
||||
let content = $state(initialValues?.content ?? "")
|
||||
|
||||
const onChange = (json: object) => {
|
||||
content = json
|
||||
}
|
||||
|
||||
const editor = makeEditor({url, submit, uploading, onChange, content})
|
||||
|
||||
$effect(() => {
|
||||
draftKey.set({d, title, location, start, end, content})
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!endDirty && start) {
|
||||
@@ -136,10 +176,14 @@
|
||||
{#snippet input()}
|
||||
<div
|
||||
class="relative z-feature flex gap-2 border-t border-solid border-base-100 bg-base-100">
|
||||
<div class="input-editor flex-grow overflow-hidden">
|
||||
<div class="input-editor grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
<Button data-tip="Add an image" class="center btn tooltip" onclick={selectFiles}>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="center btn tooltip"
|
||||
onclick={selectFiles}
|
||||
disabled={loading}>
|
||||
{#if $uploading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
@@ -178,12 +222,12 @@
|
||||
</Field>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Button class="btn btn-link" onclick={back} disabled={loading}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={$uploading}>
|
||||
<Spinner loading={$uploading}>Save Event</Spinner>
|
||||
<Button type="submit" class="btn btn-primary" disabled={$uploading || loading}>
|
||||
<Spinner loading={$uploading || loading}>Save Event</Spinner>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
@@ -19,15 +19,17 @@
|
||||
const end = $derived(parseInt(meta.end))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-wrap justify-between gap-2">
|
||||
<p class="text-xl">{meta.title || meta.name}</p>
|
||||
<div class="flex flex-col justify-between gap-1">
|
||||
<p class="text-lg">{meta.title || meta.name}</p>
|
||||
{#if !isNaN(start) && !isNaN(end)}
|
||||
{@const startDateDisplay = formatTimestampAsDate(start)}
|
||||
{@const endDateDisplay = formatTimestampAsDate(end)}
|
||||
{@const isSingleDay = startDateDisplay === endDateDisplay}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Icon icon={ClockCircle} size={4} />
|
||||
<span class="hidden sm:block">{formatTimestampAsDate(start)}</span>
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={ClockCircle} size={4} />
|
||||
{formatTimestampAsDate(start)}
|
||||
</div>
|
||||
{formatTimestampAsTime(start)} — {isSingleDay
|
||||
? formatTimestampAsTime(end)
|
||||
: formatTimestamp(end)}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import RoomLink from "@app/components/RoomLink.svelte"
|
||||
import {makeCalendarPath} from "@app/util/routes"
|
||||
import {makeCalendarPath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
@@ -19,7 +19,7 @@
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="col-3 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||
class="cv col-3 card2 bg-alt w-full cursor-pointer shadow-md"
|
||||
href={makeCalendarPath(url, getAddress(event))}>
|
||||
<CalendarEventHeader {event} />
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{#if meta.location}
|
||||
<span class="flex items-start gap-1">
|
||||
<Icon icon={MapPoint} class="mt-[2px]" size={4} />
|
||||
<span class="break-words">{meta.location}</span>
|
||||
<span class="wrap-break-word">{meta.location}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+186
-148
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {onMount} from "svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
import {
|
||||
ago,
|
||||
int,
|
||||
ms,
|
||||
partition,
|
||||
ifLet,
|
||||
spec,
|
||||
nthEq,
|
||||
nthNe,
|
||||
@@ -32,26 +35,31 @@
|
||||
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 Link from "@lib/components/Link.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"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import ChatMembers from "@app/components/ChatMembers.svelte"
|
||||
import ChatMessage from "@app/components/ChatMessage.svelte"
|
||||
import ChatCompose from "@app/components/ChatCompose.svelte"
|
||||
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
|
||||
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||
import {userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {prependParent} from "@app/core/commands"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {userSettingsValues} from "@app/settings"
|
||||
import {deriveChat, makeChatId} from "@app/chats"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import {makeDelete} from "@app/deletes"
|
||||
import {prependParent} from "@app/groups"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
pubkeys: string[]
|
||||
@@ -60,15 +68,19 @@
|
||||
|
||||
const {pubkeys, info}: Props = $props()
|
||||
|
||||
const chat = deriveChat(pubkeys)
|
||||
const chatId = makeChatId(pubkeys)
|
||||
const chat = deriveChat(chatId)
|
||||
const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`)
|
||||
const others = remove($pubkey!, pubkeys)
|
||||
const missingRelayLists = $derived(pubkeys.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
||||
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
||||
|
||||
const showMembers = () =>
|
||||
others.length === 1
|
||||
? pushModal(ProfileDetail, {pubkey: others[0]})
|
||||
: pushModal(ChatMembers, {pubkeys: others})
|
||||
|
||||
const back = () => goto("/chat")
|
||||
|
||||
const replyTo = (event: TrustedEvent) => {
|
||||
parent = event
|
||||
compose?.focus()
|
||||
@@ -78,75 +90,117 @@
|
||||
parent = undefined
|
||||
}
|
||||
|
||||
const onSubmit = async (params: EventContent) => {
|
||||
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 = prependParent(parent, params)
|
||||
|
||||
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
|
||||
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
|
||||
const templates: EventTemplate[] = []
|
||||
const buffer = []
|
||||
|
||||
const addTemplate = (kind: number, content: string, tags: string[][]) => {
|
||||
content = content.trim()
|
||||
|
||||
if (content) {
|
||||
templates.push(makeEvent(kind, {content, tags: [...tags, ...ptags]}))
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of parse(params)) {
|
||||
const imeta = isLink(p)
|
||||
? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()])))
|
||||
: undefined
|
||||
|
||||
if (isLink(p) && imeta) {
|
||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||
addTemplate(
|
||||
DIRECT_MESSAGE_FILE,
|
||||
p.value.url.toString(),
|
||||
imeta.slice(1).filter(nthNe(0, "url")),
|
||||
)
|
||||
} else {
|
||||
buffer.push(p.raw)
|
||||
}
|
||||
}
|
||||
|
||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||
|
||||
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
|
||||
// Sleep 1 second between each one to make sure timestamps are distinct
|
||||
const thunks = await Promise.all(
|
||||
Array.from(enumerate(templates)).map(([i, event]) =>
|
||||
sendWrapped({
|
||||
event,
|
||||
recipients: pubkeys,
|
||||
delay: $userSettingsValues.send_delay + ms(i),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
pushToast({
|
||||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk: mergeThunks(thunks)},
|
||||
},
|
||||
})
|
||||
|
||||
clearParent()
|
||||
const clearEventToEdit = () => {
|
||||
eventToEdit = undefined
|
||||
}
|
||||
|
||||
const onSubmit = async (params: EventContent) => {
|
||||
try {
|
||||
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 = prependParent(parent, params)
|
||||
|
||||
if (eventToEdit) {
|
||||
if (eventToEdit.content === params.content) {
|
||||
return
|
||||
}
|
||||
|
||||
await sendWrapped({
|
||||
event: makeDelete({event: eventToEdit, protect: false}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
}
|
||||
|
||||
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
|
||||
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
|
||||
const templates: EventTemplate[] = []
|
||||
const buffer = []
|
||||
|
||||
const addTemplate = (kind: number, content: string, tags: string[][]) => {
|
||||
content = content.trim()
|
||||
|
||||
if (content) {
|
||||
templates.push(
|
||||
makeEvent(kind, {
|
||||
content,
|
||||
tags: [...tags, ...ptags],
|
||||
created_at: eventToEdit?.created_at,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of parse(params)) {
|
||||
const imeta = isLink(p)
|
||||
? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()])))
|
||||
: undefined
|
||||
|
||||
if (isLink(p) && imeta) {
|
||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||
addTemplate(
|
||||
DIRECT_MESSAGE_FILE,
|
||||
p.value.url.toString(),
|
||||
imeta.slice(1).filter(nthNe(0, "url")),
|
||||
)
|
||||
} else {
|
||||
buffer.push(p.raw)
|
||||
}
|
||||
}
|
||||
|
||||
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
||||
|
||||
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
|
||||
// Sleep 1 second between each one to make sure timestamps are distinct
|
||||
const thunks = await Promise.all(
|
||||
Array.from(enumerate(templates)).map(([i, event]) =>
|
||||
sendWrapped({
|
||||
event,
|
||||
recipients: pubkeys,
|
||||
delay: $userSettingsValues.send_delay + ms(i),
|
||||
pow: 16,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
pushToast({
|
||||
timeout: 30_000,
|
||||
children: {
|
||||
component: ThunkToast,
|
||||
props: {thunk: mergeThunks(thunks)},
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
clearParent()
|
||||
clearEventToEdit()
|
||||
}
|
||||
}
|
||||
|
||||
const onEscape = () => {
|
||||
clearParent()
|
||||
clearEventToEdit()
|
||||
}
|
||||
|
||||
const canEditEvent = (event: TrustedEvent) =>
|
||||
event.pubkey === $pubkey &&
|
||||
event.kind === DIRECT_MESSAGE &&
|
||||
event.created_at >= ago(500, MINUTE)
|
||||
|
||||
const onEditEvent = (event: TrustedEvent) => {
|
||||
clearParent()
|
||||
eventToEdit = event
|
||||
}
|
||||
|
||||
const onEditPrevious = () => ifLet($chat?.messages.toReversed().find(canEditEvent), onEditEvent)
|
||||
|
||||
let loading = $state(true)
|
||||
let compose: ChatCompose | undefined = $state()
|
||||
let parent: TrustedEvent | undefined = $state()
|
||||
let chatCompose: HTMLElement | undefined = $state()
|
||||
let dynamicPadding: HTMLElement | undefined = $state()
|
||||
let eventToEdit: TrustedEvent | undefined = $state()
|
||||
|
||||
const elements = $derived.by(() => {
|
||||
const elements = []
|
||||
@@ -182,20 +236,6 @@
|
||||
for (const pubkey of others) {
|
||||
loadMessagingRelayList(pubkey)
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (dynamicPadding && chatCompose) {
|
||||
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(chatCompose!)
|
||||
observer.observe(dynamicPadding!)
|
||||
|
||||
return () => {
|
||||
observer.unobserve(chatCompose!)
|
||||
observer.unobserve(dynamicPadding!)
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -204,75 +244,58 @@
|
||||
</script>
|
||||
|
||||
<PageBar>
|
||||
{#snippet title()}
|
||||
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
|
||||
{#if others.length === 0}
|
||||
<div class="row-2">
|
||||
<ProfileCircle pubkey={$pubkey!} size={5} />
|
||||
<ProfileName pubkey={$pubkey!} />
|
||||
</div>
|
||||
{:else if others.length === 1}
|
||||
<div class="row-2">
|
||||
<ProfileCircle pubkey={others[0]} size={5} />
|
||||
<ProfileName pubkey={others[0]} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<ProfileCircles pubkeys={others} size={5} />
|
||||
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<ProfileName pubkey={others[0]} />
|
||||
and
|
||||
{#if others.length === 2}
|
||||
<ProfileName pubkey={others[1]} />
|
||||
{:else}
|
||||
{others.length - 1}
|
||||
{others.length > 2 ? "others" : "other"}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex">
|
||||
<Button onclick={back} class="place-self-start pr-3 md:hidden flex items-center">
|
||||
<Icon icon={ArrowLeft} size={7} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
{#if remove($pubkey, missingRelayLists).length > 0}
|
||||
{@const count = remove($pubkey, missingRelayLists).length}
|
||||
{@const label = count > 1 ? "lists are" : "list is"}
|
||||
<div
|
||||
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
|
||||
data-tip="{count} messaging {label} not configured.">
|
||||
<Icon icon={Danger} />
|
||||
{count}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageBar>
|
||||
|
||||
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
||||
<div bind:this={dynamicPadding}></div>
|
||||
{#if missingRelayLists.includes($pubkey!)}
|
||||
<div class="py-12">
|
||||
<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} />
|
||||
Your messaging relays are not configured.
|
||||
</p>
|
||||
<p>
|
||||
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
|
||||
your <Link class="link" href="/settings/relays">relay settings page</Link> to receive messages.
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<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="row-2">
|
||||
<ProfileCircle pubkey={$pubkey!} size={5} />
|
||||
<ProfileName pubkey={$pubkey!} />
|
||||
</div>
|
||||
{:else if others.length === 1}
|
||||
<div class="row-2">
|
||||
<ProfileCircle pubkey={others[0]} size={5} />
|
||||
<ProfileName pubkey={others[0]} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<ProfileCircles pubkeys={others} size={5} />
|
||||
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<ProfileName pubkey={others[0]} />
|
||||
and
|
||||
{#if others.length === 2}
|
||||
<ProfileName pubkey={others[1]} />
|
||||
{:else}
|
||||
{others.length - 1}
|
||||
{others.length > 2 ? "others" : "other"}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if missingRelayLists.length > 0}
|
||||
</div>
|
||||
</PageBar>
|
||||
|
||||
<PageContent class="flex flex-col-reverse gap-2 py-2 !mb-0">
|
||||
{#if missingRelayLists.length > 0}
|
||||
<div class="py-12">
|
||||
<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} />
|
||||
{missingRelayLists.length} messaging
|
||||
{missingRelayLists.length > 1 ? "lists are" : "list is"} not configured.
|
||||
Direct messages are not enabled
|
||||
</p>
|
||||
<p>
|
||||
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
|
||||
sure everyone in this conversation has set up their messaging relays.
|
||||
Ask
|
||||
{#each missingRelayLists as pubkey (pubkey)}
|
||||
<ProfileLink {pubkey} />
|
||||
{/each}
|
||||
to enable direct messaging by opening this conversation in their app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,7 +308,9 @@
|
||||
event={$state.snapshot(value as TrustedEvent)}
|
||||
{pubkeys}
|
||||
{showPubkey}
|
||||
{replyTo} />
|
||||
{replyTo}
|
||||
canEdit={canEditEvent}
|
||||
onEdit={onEditEvent} />
|
||||
{/if}
|
||||
{/each}
|
||||
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
|
||||
@@ -298,13 +323,26 @@
|
||||
</Spinner>
|
||||
{@render info?.()}
|
||||
</p>
|
||||
<div class="h-screen"></div>
|
||||
</PageContent>
|
||||
|
||||
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
||||
<div class="chat__compose bg-base-200">
|
||||
<div>
|
||||
{#if parent}
|
||||
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||
{/if}
|
||||
{#if eventToEdit}
|
||||
<ChatComposeEdit clear={clearEventToEdit} />
|
||||
{/if}
|
||||
</div>
|
||||
<ChatCompose bind:this={compose} {onSubmit} />
|
||||
{#key eventToEdit}
|
||||
<ChatCompose
|
||||
bind:this={compose}
|
||||
{onSubmit}
|
||||
{onEscape}
|
||||
{onEditPrevious}
|
||||
initialValues={eventToEdit}
|
||||
draftKey={eventToEdit ? undefined : draftKey}
|
||||
disabled={Boolean(missingRelayLists.length)} />
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {onDestroy, onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import cx from "classnames"
|
||||
import type {EventContent} from "@welshman/util"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||
@@ -8,23 +10,63 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {type DraftKey} from "@app/drafts"
|
||||
|
||||
type Props = {
|
||||
onSubmit: (event: EventContent) => void
|
||||
type Values = {
|
||||
content?: string | object
|
||||
}
|
||||
|
||||
const {onSubmit}: Props = $props()
|
||||
type Props = {
|
||||
disabled?: boolean
|
||||
draftKey?: DraftKey<Values>
|
||||
onEscape?: () => void
|
||||
onEditPrevious?: () => void
|
||||
onSubmit: (event: EventContent) => void
|
||||
initialValues?: Values
|
||||
}
|
||||
|
||||
const autofocus = !isMobile
|
||||
let {
|
||||
initialValues,
|
||||
disabled = false,
|
||||
draftKey,
|
||||
onEscape,
|
||||
onEditPrevious,
|
||||
onSubmit,
|
||||
}: Props = $props()
|
||||
|
||||
if (!initialValues) {
|
||||
initialValues = draftKey?.get()
|
||||
}
|
||||
|
||||
const autofocus = !isMobile && !disabled
|
||||
|
||||
const uploading = writable(false)
|
||||
|
||||
const editorClass = $derived(
|
||||
cx("chat-editor grow overflow-hidden", {
|
||||
"pointer-events-none opacity-50": disabled,
|
||||
}),
|
||||
)
|
||||
|
||||
export const focus = () => editor.then(ed => ed.chain().focus().run())
|
||||
|
||||
export const canEnterEditPrevious = () =>
|
||||
editor.then(ed => ed.getText({blockSeparator: "\n"}) === "")
|
||||
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onEscape?.()
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" && (await canEnterEditPrevious())) {
|
||||
onEditPrevious?.()
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||
|
||||
const submit = async () => {
|
||||
if ($uploading) return
|
||||
if ($uploading || disabled) return
|
||||
|
||||
const ed = await editor
|
||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
@@ -34,23 +76,45 @@
|
||||
|
||||
onSubmit({content, tags})
|
||||
|
||||
draftKey?.clear()
|
||||
ed.chain().clearContent().run()
|
||||
}
|
||||
|
||||
let content = $state(initialValues?.content ?? "")
|
||||
|
||||
const onChange = (json: object) => {
|
||||
content = json
|
||||
}
|
||||
|
||||
const editor = makeEditor({
|
||||
autofocus,
|
||||
content,
|
||||
submit,
|
||||
uploading,
|
||||
onChange,
|
||||
aggressive: true,
|
||||
encryptFiles: true,
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
draftKey?.set({content})
|
||||
})
|
||||
|
||||
onMount(async () => {
|
||||
const ed = await editor
|
||||
ed.view.dom.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onDestroy(async () => {
|
||||
const ed = await editor
|
||||
ed?.view?.dom.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
|
||||
disabled={$uploading}
|
||||
disabled={$uploading || disabled}
|
||||
onclick={uploadFiles}>
|
||||
{#if $uploading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
@@ -58,13 +122,13 @@
|
||||
<Icon icon={GallerySend} />
|
||||
{/if}
|
||||
</Button>
|
||||
<div class="chat-editor flex-grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
<div class={editorClass} aria-disabled={disabled}>
|
||||
<EditorContent {autofocus} {editor} />
|
||||
</div>
|
||||
<Button
|
||||
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
|
||||
class="center tooltip tooltip-left absolute right-4 h-10 w-10 min-w-10 rounded-full"
|
||||
disabled={$uploading}
|
||||
disabled={$uploading || disabled}
|
||||
onclick={submit}>
|
||||
<Icon icon={Plane} />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
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"
|
||||
|
||||
const {
|
||||
clear,
|
||||
}: {
|
||||
clear: () => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative flex h-8 items-center justify-between border-l-2 border-solid border-primary bg-base-300 px-2 pr-7 text-xs"
|
||||
transition:slide>
|
||||
<p class="text-primary">Editing message</p>
|
||||
<Button onclick={clear} class="flex items-center">
|
||||
<Icon icon={CloseCircle} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {goto} from "$app/navigation"
|
||||
import {getRelaysFromList} from "@welshman/util"
|
||||
import {waitForThunkError, setMessagingRelays, userRelayList, setRelays} from "@welshman/app"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import {shouldUnwrap} 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,46 +11,53 @@
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {PLATFORM_NAME} from "@app/core/state"
|
||||
import {clearModals} from "@app/util/modal"
|
||||
import {DEFAULT_RELAYS, DEFAULT_MESSAGING_RELAYS} from "@app/env"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
const {next} = $props()
|
||||
type Props = {
|
||||
next: () => void
|
||||
}
|
||||
|
||||
const nextUrl = $state.snapshot(next)
|
||||
const {next}: Props = $props()
|
||||
|
||||
let loading = $state(false)
|
||||
|
||||
const submit = async () => {
|
||||
const back = () => history.back()
|
||||
|
||||
const enable = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
shouldUnwrap.set(true)
|
||||
clearModals()
|
||||
goto(nextUrl)
|
||||
if (getRelaysFromList($userRelayList).length === 0) {
|
||||
const error = await waitForThunkError(await setRelays(DEFAULT_RELAYS.map(r => ["r", r])))
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const error = await waitForThunkError(await setMessagingRelays(DEFAULT_MESSAGING_RELAYS))
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
return
|
||||
}
|
||||
|
||||
await next()
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const back = () => history.back()
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
<Modal tag="form" onsubmit={preventDefault(enable)}>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Enable Messages</ModalTitle>
|
||||
<ModalSubtitle>Do you want to enable direct messages?</ModalSubtitle>
|
||||
<ModalTitle>Enable direct messaging?</ModalTitle>
|
||||
</ModalHeader>
|
||||
<p>
|
||||
By default, direct messages are disabled, since loading them requires
|
||||
{PLATFORM_NAME} to download and decrypt a lot of data.
|
||||
</p>
|
||||
<p>
|
||||
If you'd like to enable them, please make sure your signer is set up to to auto-approve
|
||||
requests to decrypt data.
|
||||
</p>
|
||||
<p>Direct messaging isn't currently enabled. Would you like to turn it on?</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
@@ -58,7 +65,7 @@
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary" disabled={loading}>
|
||||
<Spinner {loading}>Enable Messages</Spinner>
|
||||
<Spinner {loading}>Enable direct messaging</Spinner>
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {pubkey, loadMessagingRelayList} from "@welshman/app"
|
||||
import {fade} from "@lib/transition"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||
import {makeChatPath} from "@app/util/routes"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
import {makeChatPath, goToChat} from "@app/routes"
|
||||
import {notifications} from "@app/notifications"
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
@@ -24,6 +24,7 @@
|
||||
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) {
|
||||
@@ -32,9 +33,9 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<Link class="flex flex-col justify-start gap-1" href={makeChatPath(props.pubkeys)}>
|
||||
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
|
||||
<div
|
||||
class="cursor-pointer border-t border-solid border-base-100 px-6 py-2 transition-colors hover:bg-base-100 {props.class}"
|
||||
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">
|
||||
@@ -71,4 +72,4 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {assoc} from "@welshman/lib"
|
||||
import ChatSquare from "@assets/icons/chat-square.svg?dataurl"
|
||||
import Check from "@assets/icons/check.svg?dataurl"
|
||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||
@@ -8,12 +7,8 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ChatStart from "@app/components/ChatStart.svelte"
|
||||
import {setChecked} from "@app/util/notifications"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {notificationSettings} from "@app/core/state"
|
||||
|
||||
const startChat = () => pushModal(ChatStart, {}, {replaceState: true})
|
||||
import {setChecked} from "@app/notifications"
|
||||
import {notificationSettings} from "@app/settings"
|
||||
|
||||
const markAsRead = () => {
|
||||
setChecked("/chat/*")
|
||||
@@ -28,10 +23,6 @@
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button class="btn btn-primary" onclick={startChat}>
|
||||
<Icon size={5} icon={ChatSquare} />
|
||||
Start chat
|
||||
</Button>
|
||||
<Button class="btn btn-neutral" onclick={markAsRead}>
|
||||
<Icon size={5} icon={Check} />
|
||||
Mark all read
|
||||
|
||||
@@ -16,18 +16,21 @@
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
|
||||
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
|
||||
import {colors} from "@app/core/state"
|
||||
import {makeDelete, makeReaction} from "@app/core/commands"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {colors} from "@app/theme"
|
||||
import {makeDelete} from "@app/deletes"
|
||||
import {makeReaction} from "@app/reactions"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
interface Props {
|
||||
event: TrustedEvent
|
||||
replyTo: (event: TrustedEvent) => void
|
||||
canEdit?: (event: TrustedEvent) => boolean
|
||||
onEdit?: (event: TrustedEvent) => void
|
||||
pubkeys: string[]
|
||||
showPubkey?: boolean
|
||||
}
|
||||
|
||||
const {event, replyTo, pubkeys, showPubkey = false}: Props = $props()
|
||||
const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props()
|
||||
|
||||
const isOwn = event.pubkey === $pubkey
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey)
|
||||
@@ -35,16 +38,21 @@
|
||||
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
|
||||
|
||||
const reply = () => replyTo(event)
|
||||
const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined
|
||||
|
||||
const deleteReaction = (event: TrustedEvent) =>
|
||||
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys})
|
||||
sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys, pow: 16})
|
||||
|
||||
const createReaction = (template: EventContent) =>
|
||||
sendWrapped({event: makeReaction({event, protect: false, ...template}), recipients: pubkeys})
|
||||
sendWrapped({
|
||||
event: makeReaction({event, protect: false, ...template}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey})
|
||||
|
||||
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply})
|
||||
const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply, edit})
|
||||
|
||||
const togglePopover = () => {
|
||||
if (popoverIsVisible) {
|
||||
@@ -71,7 +79,7 @@
|
||||
<Tippy
|
||||
bind:popover
|
||||
component={ChatMessageMenu}
|
||||
props={{event, pubkeys, popover, replyTo}}
|
||||
props={{event, pubkeys, popover, replyTo, edit}}
|
||||
params={{
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
@@ -93,7 +101,7 @@
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-col" class:items-end={isOwn}>
|
||||
<TapTarget
|
||||
class="bg-alt chat-bubble mx-1 mb-2 flex cursor-auto flex-col gap-1 text-left lg:max-w-2xl"
|
||||
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">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
import {makeReaction} from "@app/core/commands"
|
||||
import {makeReaction} from "@app/reactions"
|
||||
|
||||
interface Props {
|
||||
event: TrustedEvent
|
||||
@@ -18,6 +18,7 @@
|
||||
sendWrapped({
|
||||
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushModal} from "@app/modal"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
|
||||
const {event, pubkeys, popover, replyTo} = $props()
|
||||
const {event, pubkeys, popover, replyTo, edit} = $props()
|
||||
|
||||
const reply = () => replyTo(event)
|
||||
const onEdit = () => edit?.()
|
||||
|
||||
const showInfo = () => {
|
||||
popover.hide()
|
||||
@@ -24,6 +26,11 @@
|
||||
<Icon size={4} icon={Reply} />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if edit}
|
||||
<Button class="btn join-item btn-xs" onclick={onEdit}>
|
||||
<Icon size={4} icon={Pen} />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button class="btn join-item btn-xs" onclick={showInfo}>
|
||||
<Icon size={4} icon={Code2} />
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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"
|
||||
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
@@ -12,23 +13,25 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import {makeReaction} from "@app/core/commands"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {clip} from "@app/util/toast"
|
||||
import {makeReaction} from "@app/reactions"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {clip} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
pubkeys: string[]
|
||||
event: TrustedEvent
|
||||
reply: () => void
|
||||
edit?: () => void
|
||||
}
|
||||
|
||||
const {event, pubkeys, reply}: Props = $props()
|
||||
const {event, pubkeys, reply, edit}: Props = $props()
|
||||
|
||||
const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => {
|
||||
history.back()
|
||||
sendWrapped({
|
||||
event: makeReaction({event, content: emoji.unicode, protect: false}),
|
||||
recipients: pubkeys,
|
||||
pow: 16,
|
||||
})
|
||||
}).bind(undefined, event, pubkeys)
|
||||
|
||||
@@ -39,6 +42,11 @@
|
||||
reply()
|
||||
}
|
||||
|
||||
const sendEdit = () => {
|
||||
history.back()
|
||||
edit?.()
|
||||
}
|
||||
|
||||
const copyText = () => {
|
||||
history.back()
|
||||
clip(event.content)
|
||||
@@ -62,6 +70,12 @@
|
||||
<Icon size={4} icon={Reply} />
|
||||
Send Reply
|
||||
</Button>
|
||||
{#if edit}
|
||||
<Button class="btn btn-neutral w-full" onclick={sendEdit}>
|
||||
<Icon size={4} icon={Pen} />
|
||||
Edit Message
|
||||
</Button>
|
||||
{/if}
|
||||
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
|
||||
<Icon size={4} icon={SmileCircle} />
|
||||
Send Reaction
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import {goto} from "$app/navigation"
|
||||
import {tryCatch, uniq} from "@welshman/lib"
|
||||
import {fromNostrURI} from "@welshman/util"
|
||||
import {loadMessagingRelayList} from "@welshman/app"
|
||||
@@ -19,11 +18,11 @@
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
|
||||
import {makeChatPath} from "@app/util/routes"
|
||||
import {goToChat} from "@app/routes"
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const onSubmit = () => goto(makeChatPath(pubkeys))
|
||||
const onSubmit = () => goToChat(pubkeys)
|
||||
|
||||
const addPubkey = (pubkey: string) => {
|
||||
pubkeys = uniq([...pubkeys, pubkey])
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import {uniq} from "@welshman/lib"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {getTagValue, getAddress} from "@welshman/util"
|
||||
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"
|
||||
@@ -13,9 +15,11 @@
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeClassifiedPath, makeSpacePath} from "@app/util/routes"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
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"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
@@ -27,6 +31,7 @@
|
||||
const {url, event, showRoom, showActivity}: Props = $props()
|
||||
|
||||
const h = getTagValue("h", event.tags)
|
||||
const topics = getTagValues("t", event.tags)
|
||||
const path = makeClassifiedPath(url, getAddress(event))
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -39,12 +44,19 @@
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
{#if h && showRoom}
|
||||
<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="btn btn-xs rounded-full font-normal">
|
||||
#{normalizeTopic(topic)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event}>
|
||||
<ClassifiedStatus {event} />
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
shareToChat?: boolean
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
const {url, h, shareToChat = false}: Props = $props()
|
||||
</script>
|
||||
|
||||
<ClassifiedForm {url} {h}>
|
||||
<ClassifiedForm {url} {h} {shareToChat}>
|
||||
{#snippet header()}
|
||||
<ModalHeader>
|
||||
<ModalTitle>Create a Classified Listing</ModalTitle>
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
const {d, title, status} = fromPairs(event.tags)
|
||||
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
|
||||
const images = getTagValues("image", event.tags)
|
||||
const initialValues = {d, title, status, content, price: Number(price), currency, images}
|
||||
const topics = getTagValues("t", event.tags)
|
||||
const initialValues = {d, title, status, content, price: Number(price), currency, images, topics}
|
||||
</script>
|
||||
|
||||
<ClassifiedForm {url} {initialValues}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {randomId} from "@welshman/lib"
|
||||
import {removeUndefined, randomId, uniq} from "@welshman/lib"
|
||||
import {makeEvent, CLASSIFIED} from "@welshman/util"
|
||||
import {publishThunk} from "@welshman/app"
|
||||
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"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
@@ -14,28 +15,41 @@
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ImagesInput from "@lib/components/ImagesInput.svelte"
|
||||
import CurrencyInput from "@app/components/CurrencyInput.svelte"
|
||||
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {PROTECTED} from "@app/core/state"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {PROTECTED, publishRoomQuote} from "@app/groups"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {canEnforceNip70, uploadFile} from "@app/core/commands"
|
||||
import {DraftKey} from "@app/drafts"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {uploadFile} from "@app/uploads"
|
||||
|
||||
type Values = {
|
||||
d: string
|
||||
title: string
|
||||
content: string | object
|
||||
price: number
|
||||
currency: string
|
||||
images: (string | File)[]
|
||||
status: string
|
||||
topics: string[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
shareToChat?: boolean
|
||||
header: Snippet
|
||||
initialValues?: {
|
||||
d?: string
|
||||
title?: string
|
||||
content?: string
|
||||
price?: number
|
||||
currency?: string
|
||||
images?: string[]
|
||||
status?: string
|
||||
}
|
||||
initialValues?: Values
|
||||
}
|
||||
|
||||
const {url, h, header, initialValues}: Props = $props()
|
||||
let {url, h, shareToChat = false, header, initialValues}: Props = $props()
|
||||
|
||||
const draftKey = new DraftKey<Values>(`classified:${url}:${h ?? ""}`)
|
||||
|
||||
if (!initialValues) {
|
||||
initialValues = draftKey.get()
|
||||
}
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
@@ -63,7 +77,7 @@
|
||||
}
|
||||
|
||||
const tags = [
|
||||
["d", initialValues?.d || randomId()],
|
||||
["d", d],
|
||||
["title", title],
|
||||
["summary", content],
|
||||
["price", String(price), currency],
|
||||
@@ -71,7 +85,13 @@
|
||||
...ed.storage.nostr.getEditorTags(),
|
||||
]
|
||||
|
||||
if (await shouldProtect) {
|
||||
for (const topic of topics) {
|
||||
tags.push(["t", topic])
|
||||
}
|
||||
|
||||
const protect = await shouldProtect
|
||||
|
||||
if (protect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
@@ -98,26 +118,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
publishThunk({
|
||||
const classifiedThunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(CLASSIFIED, {content, tags}),
|
||||
})
|
||||
|
||||
const error = await waitForThunkError(classifiedThunk)
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
}
|
||||
|
||||
draftKey.clear()
|
||||
history.back()
|
||||
|
||||
if (shareToChat) {
|
||||
publishRoomQuote({url, h, parent: classifiedThunk.event, protect})
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const content = initialValues?.content || ""
|
||||
const editor = makeEditor({url, submit, content})
|
||||
|
||||
let loading = $state(false)
|
||||
let title = $state(initialValues?.title || "")
|
||||
let status = $state(initialValues?.status || "active")
|
||||
let price = $state(Number(initialValues?.price || 0))
|
||||
let currency = $state(initialValues?.currency || "SAT")
|
||||
let images = $state<(string | File)[]>(initialValues?.images || [])
|
||||
const d = $state(initialValues?.d ?? randomId())
|
||||
let title = $state(initialValues?.title ?? "")
|
||||
let status = $state(initialValues?.status ?? "active")
|
||||
let price = $state(initialValues?.price ?? 0)
|
||||
let currency = $state(initialValues?.currency ?? "SAT")
|
||||
let images = $state(initialValues?.images ?? [])
|
||||
let topics = $state(uniq(removeUndefined(initialValues?.topics?.map(normalizeTopic) ?? [])))
|
||||
let content = $state(initialValues?.content ?? "")
|
||||
|
||||
const onChange = (json: object) => {
|
||||
content = json
|
||||
}
|
||||
|
||||
const editor = makeEditor({url, submit, onChange, content})
|
||||
|
||||
$effect(() => {
|
||||
draftKey.set({d, title, status, price, currency, images, topics, content})
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
@@ -145,11 +186,19 @@
|
||||
<p>Description*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="note-editor flex-grow overflow-hidden">
|
||||
<div class="note-editor grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Topics</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<TopicMultiSelect bind:value={topics} />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Price*</p>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import ClassifiedActions from "@app/components/ClassifiedActions.svelte"
|
||||
import RoomLink from "@app/components/RoomLink.svelte"
|
||||
import {makeClassifiedPath} from "@app/util/routes"
|
||||
import {makeClassifiedPath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
@@ -20,12 +20,12 @@
|
||||
|
||||
const title = getTagValue("title", event.tags)
|
||||
const h = getTagValue("h", event.tags)
|
||||
const images = getTagValues("image", event.tags)
|
||||
const images = new Set(getTagValues("image", event.tags))
|
||||
const [_, price = 0, currency = "SAT"] = getTag("price", event.tags) || []
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="col-2 card2 bg-alt w-full cursor-pointer shadow-xl"
|
||||
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-xl"
|
||||
href={makeClassifiedPath(url, getAddress(event))}>
|
||||
{#if title}
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
import {publishReaction} from "@app/reactions"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
@@ -28,7 +30,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
<div class="flex grow flex-wrap justify-end gap-2">
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||
import Revote from "@assets/icons/revote.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushModal} from "@app/modal"
|
||||
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
|
||||
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
||||
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
|
||||
import GoalCreate from "@app/components/GoalCreate.svelte"
|
||||
import PollCreate from "@app/components/PollCreate.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
@@ -20,13 +22,15 @@
|
||||
|
||||
const {url, h, onClick}: Props = $props()
|
||||
|
||||
const createGoal = () => pushModal(GoalCreate, {url, h})
|
||||
const createGoal = () => pushModal(GoalCreate, {url, h, shareToChat: true})
|
||||
|
||||
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h})
|
||||
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, h, shareToChat: true})
|
||||
|
||||
const createThread = () => pushModal(ThreadCreate, {url, h})
|
||||
const createThread = () => pushModal(ThreadCreate, {url, h, shareToChat: true})
|
||||
|
||||
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
|
||||
const createClassified = () => pushModal(ClassifiedCreate, {url, h, shareToChat: true})
|
||||
|
||||
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
|
||||
|
||||
let ul: Element
|
||||
|
||||
@@ -60,4 +64,10 @@
|
||||
Create Thread
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={createPoll}>
|
||||
<Icon size={4} icon={Revote} />
|
||||
Ask a Question
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
truncate,
|
||||
renderAsHtml,
|
||||
isText,
|
||||
isEmail,
|
||||
isEmoji,
|
||||
isTopic,
|
||||
isCode,
|
||||
@@ -26,6 +27,7 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ContentToken from "@app/components/ContentToken.svelte"
|
||||
import ContentEmoji from "@app/components/ContentEmoji.svelte"
|
||||
import ContentEmail from "@app/components/ContentEmail.svelte"
|
||||
import ContentCode from "@app/components/ContentCode.svelte"
|
||||
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
|
||||
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
|
||||
@@ -33,7 +35,8 @@
|
||||
import ContentQuote from "@app/components/ContentQuote.svelte"
|
||||
import ContentTopic from "@app/components/ContentTopic.svelte"
|
||||
import ContentMention from "@app/components/ContentMention.svelte"
|
||||
import {entityLink, userSettingsValues} from "@app/core/state"
|
||||
import {entityLink} from "@app/env"
|
||||
import {userSettingsValues} from "@app/settings"
|
||||
|
||||
interface Props {
|
||||
event: any
|
||||
@@ -150,7 +153,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="overflow-hidden text-ellipsis break-words"
|
||||
class="overflow-hidden text-ellipsis wrap-break-word"
|
||||
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
|
||||
{#each shortContent as parsed, i}
|
||||
{#if isNewline(parsed) && !isBlock(i - 1)}
|
||||
@@ -159,6 +162,8 @@
|
||||
<ContentTopic value={parsed.value} />
|
||||
{:else if isEmoji(parsed)}
|
||||
<ContentEmoji value={parsed.value} />
|
||||
{:else if isEmail(parsed)}
|
||||
<ContentEmail value={parsed.value} />
|
||||
{:else if isCode(parsed)}
|
||||
<ContentCode
|
||||
value={parsed.value}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
|
||||
export let value: string
|
||||
</script>
|
||||
|
||||
<Link external href="mailto:{value}">
|
||||
<Icon icon={LinkRound} size={3} />
|
||||
{value}
|
||||
</Link>
|
||||
@@ -1,26 +1,39 @@
|
||||
<script lang="ts">
|
||||
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
|
||||
import {isRelayUrl} from "@welshman/util"
|
||||
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 ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
|
||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {dufflepud, PLATFORM_URL} from "@app/core/state"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {dufflepud, PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
|
||||
import {IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/content"
|
||||
import {isRoomId} from "@app/groups"
|
||||
|
||||
const {value, event} = $props()
|
||||
|
||||
let hideImage = $state(false)
|
||||
|
||||
const url = value.url.toString()
|
||||
const isRoomOrRelay = isRoomId(url) || isRelayUrl(url)
|
||||
const [href, external] = call(() => {
|
||||
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
||||
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||
|
||||
return [url, true]
|
||||
})
|
||||
|
||||
const fileType = getTagValue("file-type", event.tags) || ""
|
||||
|
||||
const getVideoPoster = (videoUrl: string): string | undefined => {
|
||||
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
|
||||
return `${THUMBNAIL_URL}/thumbnail?url=${encodeURIComponent(videoUrl)}`
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const loadPreview = async () => {
|
||||
const json = await postJson(dufflepud("link/preview"), {url})
|
||||
|
||||
@@ -38,41 +51,52 @@
|
||||
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
||||
</script>
|
||||
|
||||
<Link {external} {href} class="my-2 block">
|
||||
<div class="overflow-hidden rounded-box">
|
||||
{#if url.match(/\.(mov|webm|mp4)$/)}
|
||||
<video controls src={url} class="max-h-96 rounded-box object-contain object-center">
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{:else if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
||||
<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>
|
||||
{:then preview}
|
||||
<div class="bg-alt flex max-w-xl flex-col leading-normal">
|
||||
{#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="bg-alt p-12 text-center leading-normal">
|
||||
Unable to load a preview for {url}
|
||||
</p>
|
||||
{/await}
|
||||
{/if}
|
||||
{#if isRoomOrRelay}
|
||||
<div>
|
||||
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
|
||||
</div>
|
||||
</Link>
|
||||
{:else}
|
||||
<Link {external} {href} class="my-2 block">
|
||||
<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>
|
||||
{:then preview}
|
||||
<div class="bg-alt flex max-w-xl flex-col leading-normal">
|
||||
{#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="bg-alt p-12 text-center leading-normal">
|
||||
Unable to load a preview for {url}
|
||||
</p>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
</Link>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {onMount, onDestroy} from "svelte"
|
||||
import {displayUrl} from "@welshman/lib"
|
||||
import {displayUrl, once} from "@welshman/lib"
|
||||
import {
|
||||
getTags,
|
||||
getBlob,
|
||||
@@ -26,8 +26,24 @@
|
||||
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 =
|
||||
getTagValue("filename", meta) ||
|
||||
getTagValue("name", meta) ||
|
||||
decodeURIComponent(new URL(url).pathname.split("/").filter(Boolean).at(-1) || "image")
|
||||
|
||||
const onError = async () => {
|
||||
const revokeSrc = () => {
|
||||
if (src.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(src)
|
||||
}
|
||||
}
|
||||
|
||||
const setBlobSrc = (data: Blob | Uint8Array<ArrayBuffer>, type?: string) => {
|
||||
revokeSrc()
|
||||
src = URL.createObjectURL(new File([data], fileName, type ? {type} : undefined))
|
||||
}
|
||||
|
||||
const onError = once(async () => {
|
||||
// If the image failed to load, try authenticating
|
||||
if (hash && $signer) {
|
||||
const server = new URL(url).origin
|
||||
@@ -36,14 +52,15 @@
|
||||
const res = await getBlob(server, hash, {authEvent})
|
||||
|
||||
if (res.status === 200) {
|
||||
src = URL.createObjectURL(await res.blob())
|
||||
const blob = await res.blob()
|
||||
setBlobSrc(blob, blob.type || undefined)
|
||||
} else {
|
||||
hasError = true
|
||||
}
|
||||
} else {
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let hasError = $state(false)
|
||||
let src = $state("")
|
||||
@@ -57,7 +74,7 @@
|
||||
const ciphertext = new Uint8Array(await response.arrayBuffer())
|
||||
const decryptedData = await decryptFile({ciphertext, key, nonce, algorithm})
|
||||
|
||||
src = URL.createObjectURL(new Blob([new Uint8Array(decryptedData)]))
|
||||
setBlobSrc(new Uint8Array(decryptedData), mime)
|
||||
}
|
||||
} else {
|
||||
src = url
|
||||
@@ -65,7 +82,7 @@
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
URL.revokeObjectURL(src)
|
||||
revokeSrc()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {call, displayUrl} from "@welshman/lib"
|
||||
import {isRelayUrl} from "@welshman/util"
|
||||
import {displayUrl} from "@welshman/lib"
|
||||
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"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {PLATFORM_URL} from "@app/core/state"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {IMAGE_CONTENT_TYPES} from "@app/content"
|
||||
|
||||
const {value, event} = $props()
|
||||
|
||||
const url = value.url.toString()
|
||||
const [href, external] = call(() => {
|
||||
if (isRelayUrl(url)) return [makeSpacePath(url), false]
|
||||
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||
|
||||
return [url, true]
|
||||
})
|
||||
const fileType = getTagValue("file-type", event.tags) || ""
|
||||
|
||||
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
|
||||
</script>
|
||||
|
||||
{#if url.match(/\.(jpe?g|png|gif|webp)$/)}
|
||||
{#if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
|
||||
<!-- Use a real link so people can copy the href -->
|
||||
<a
|
||||
href={url}
|
||||
@@ -33,8 +27,5 @@
|
||||
{displayUrl(url)}
|
||||
</a>
|
||||
{:else}
|
||||
<Link {external} {href} class="link-content whitespace-nowrap">
|
||||
<Icon icon={LinkRound} size={3} class="inline-block" />
|
||||
{displayUrl(url)}
|
||||
</Link>
|
||||
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import {call, displayUrl} from "@welshman/lib"
|
||||
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import {PLATFORM_URL} from "@app/env"
|
||||
import {displayRoom, isRoomId, splitRoomId} from "@app/groups"
|
||||
import {makeRoomPath, makeSpacePath} from "@app/routes"
|
||||
|
||||
const {
|
||||
url,
|
||||
class: className = "",
|
||||
}: {
|
||||
url: string
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
const roomReference = call(() => {
|
||||
if (!isRoomId(url)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [roomUrl, h] = splitRoomId(url)
|
||||
|
||||
if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {url: normalizeRelayUrl(roomUrl), h}
|
||||
})
|
||||
|
||||
const relayReference = call(() => {
|
||||
if (roomReference || !isRelayUrl(url)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return normalizeRelayUrl(url)
|
||||
})
|
||||
|
||||
const [href, external] = call(() => {
|
||||
if (roomReference) return [makeRoomPath(roomReference.url, roomReference.h), false]
|
||||
if (relayReference) return [makeSpacePath(relayReference), false]
|
||||
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
|
||||
|
||||
return [url, true]
|
||||
})
|
||||
</script>
|
||||
|
||||
<Link {external} {href} class={className}>
|
||||
{#if 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}
|
||||
<Icon icon={LinkRound} size={3} class="inline-block" />
|
||||
{displayUrl(url)}
|
||||
{/if}
|
||||
</Link>
|
||||
@@ -4,7 +4,7 @@
|
||||
import {deriveProfileDisplay} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
value: ProfilePointer
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
renderAsHtml,
|
||||
isText,
|
||||
isEmoji,
|
||||
isEmail,
|
||||
isTopic,
|
||||
isCode,
|
||||
isCashu,
|
||||
@@ -24,12 +25,14 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ContentToken from "@app/components/ContentToken.svelte"
|
||||
import ContentEmoji from "@app/components/ContentEmoji.svelte"
|
||||
import ContentEmail from "@app/components/ContentEmail.svelte"
|
||||
import ContentCode from "@app/components/ContentCode.svelte"
|
||||
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
|
||||
import ContentNewline from "@app/components/ContentNewline.svelte"
|
||||
import ContentTopic from "@app/components/ContentTopic.svelte"
|
||||
import ContentMention from "@app/components/ContentMention.svelte"
|
||||
import {entityLink, userSettingsValues} from "@app/core/state"
|
||||
import {entityLink} from "@app/env"
|
||||
import {userSettingsValues} from "@app/settings"
|
||||
|
||||
interface Props {
|
||||
event: any
|
||||
@@ -101,7 +104,7 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden text-ellipsis break-words">
|
||||
<div class="overflow-hidden text-ellipsis wrap-break-word">
|
||||
{#each shortContent as parsed, i}
|
||||
{#if isNewline(parsed)}
|
||||
<ContentNewline value={parsed.value} />
|
||||
@@ -109,6 +112,8 @@
|
||||
<ContentTopic value={parsed.value} />
|
||||
{:else if isEmoji(parsed)}
|
||||
<ContentEmoji value={parsed.value} />
|
||||
{:else if isEmail(parsed)}
|
||||
<ContentEmail value={parsed.value} />
|
||||
{:else if isCode(parsed)}
|
||||
<ContentCode
|
||||
value={parsed.value}
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
import {deriveEvent, entityLink} from "@app/core/state"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
import {deriveEvent} from "@app/repository"
|
||||
import {entityLink} from "@app/env"
|
||||
import {goToEvent} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
value: any
|
||||
@@ -45,11 +46,11 @@
|
||||
{#if $quote.kind === MESSAGE}
|
||||
<div
|
||||
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
|
||||
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 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="bg-alt rounded-box p-4">
|
||||
<NoteCard noShadow event={$quote} {url} class="bg-alt rounded-box p-4">
|
||||
<NoteContentMinimal {url} event={$quote} />
|
||||
</NoteCard>
|
||||
{/if}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import {clip} from "@app/util/toast"
|
||||
import {clip} from "@app/toast"
|
||||
|
||||
const {value} = $props()
|
||||
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
params={{
|
||||
trigger: "manual",
|
||||
interactive: true,
|
||||
placement: "bottom",
|
||||
getReferenceClientRect: () => wrapper!.getBoundingClientRect(),
|
||||
onShow: (instance: Instance) => {
|
||||
instance.popper.style.width = `${wrapper!.getBoundingClientRect().width + 8}px`
|
||||
},
|
||||
}} />
|
||||
</button>
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
import ZapButton from "@app/components/ZapButton.svelte"
|
||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
import EventMenu from "@app/components/EventMenu.svelte"
|
||||
import {ENABLE_ZAPS} from "@app/core/state"
|
||||
import {publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {ENABLE_ZAPS} from "@app/env"
|
||||
import {publishReaction} from "@app/reactions"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
@@ -42,7 +43,7 @@
|
||||
let popover: Instance | undefined = $state()
|
||||
</script>
|
||||
|
||||
<Button class="join rounded-full">
|
||||
<div class="join items-center rounded-full">
|
||||
{#if ENABLE_ZAPS && !hideZap}
|
||||
<ZapButton {url} {event} class="btn join-item btn-neutral btn-xs">
|
||||
<Icon icon={Bolt} size={4} />
|
||||
@@ -52,6 +53,7 @@
|
||||
<Icon icon={SmileCircle} size={4} />
|
||||
</EmojiButton>
|
||||
<Tippy
|
||||
class="flex"
|
||||
bind:popover
|
||||
component={EventMenu}
|
||||
props={{url, noun, event, customActions, onClick: hidePopover}}
|
||||
@@ -60,4 +62,4 @@
|
||||
<Icon icon={MenuDots} size={4} />
|
||||
</Button>
|
||||
</Tippy>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import {deriveArray, deriveEventsById} from "@welshman/store"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {repository} from "@welshman/app"
|
||||
import {deriveChecked} from "@app/util/notifications"
|
||||
import {deriveChecked} from "@app/notifications"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<Icon icon={Reply} />
|
||||
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
|
||||
</div>
|
||||
<div class="btn btn-neutral btn-xs relative hidden rounded-full sm:flex">
|
||||
<div class="btn btn-neutral btn-xs relative rounded-full">
|
||||
{#if gt(lastActive, $checked)}
|
||||
<div class="h-2 w-2 rounded-full bg-primary"></div>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import {publishDelete, canEnforceNip70} from "@app/core/commands"
|
||||
import {clearModals} from "@app/util/modal"
|
||||
import {publishDelete} from "@app/deletes"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {clearModals} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import {clip} from "@app/util/toast"
|
||||
import {clip} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url?: string
|
||||
@@ -101,7 +101,7 @@
|
||||
{/if}
|
||||
<div class="relative">
|
||||
<pre class="card2 card2-sm bg-alt overflow-auto text-xs"><code>{json}</code></pre>
|
||||
<p class="absolute right-2 top-2 flex flex-grow items-center justify-between">
|
||||
<p class="absolute right-2 top-2 flex grow items-center justify-between">
|
||||
<Button onclick={copyJson} class="btn btn-neutral btn-sm flex items-center">
|
||||
<Icon icon={Copy} /> Copy
|
||||
</Button>
|
||||
@@ -109,6 +109,6 @@
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-primary flex-grow" onclick={() => history.back()}>Got it</Button>
|
||||
<Button class="btn btn-primary grow" onclick={() => history.back()}>Got it</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
import Report from "@app/components/Report.svelte"
|
||||
import EventShare from "@app/components/EventShare.svelte"
|
||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||
import {hasNip29, deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {makeSpaceChatPath} from "@app/util/routes"
|
||||
import {hasNip29} from "@app/relays"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/members"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {makeSpaceChatPath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user