Consolidate storage, sync, NIP-98, push server, OpenRouter, audio, call and title helpers in src/app and src/lib
This commit is contained in:
parent
5343a28e6c
commit
c8c6bd4465
36 changed files with 878 additions and 994 deletions
|
|
@ -114,19 +114,22 @@ imports. `flotilla-views` covers component conventions.
|
||||||
- `classifieds.ts`, `articles.ts`, `pins.ts` (a person's pinned notes), `pinboards.ts`
|
- `classifieds.ts`, `articles.ts`, `pins.ts` (a person's pinned notes), `pinboards.ts`
|
||||||
- `reactions.ts`, `social.ts` (display names, comment trees, muting), `render.ts` (events as
|
- `reactions.ts`, `social.ts` (display names, comment trees, muting), `render.ts` (events as
|
||||||
text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom, image compression)
|
text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom, image compression)
|
||||||
- `notifications.ts` (unread state, badges), `inbox.ts` (the home inbox)
|
- `notifications.ts` (unread state, badges, the home inbox)
|
||||||
|
|
||||||
**Messaging and calls**
|
**Messaging and calls**
|
||||||
- `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices, and the
|
- `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices, the video
|
||||||
`AbortError`/`TimeoutError` a join rejects with)
|
tiles a call shows, and the `AbortError`/`TimeoutError` a join rejects with). `call.ts` stays
|
||||||
|
free of livekit values, since the root layout loads it and `callEngine.ts` is imported lazily.
|
||||||
|
|
||||||
**Identity and payments**
|
**Identity and payments**
|
||||||
- `nip46.ts`, `pomade.ts` (email login), `lightning.ts` (wallet, invoices), `healthChecks.ts`
|
- `nip46.ts`, `pomade.ts` (email login), `lightning.ts` (wallet, invoices), `healthChecks.ts`
|
||||||
(prompts for missing inbox/outbox relays)
|
(prompts for missing inbox/outbox relays)
|
||||||
|
|
||||||
**Platform and voice**
|
**Platform and voice**
|
||||||
- `push/` (notification adapters), `share.ts`, `deepLinks.ts`, `keyboard.ts`
|
- `push/` (notification adapters, and `server.ts` for the push server's HTTP API), `share.ts`,
|
||||||
- `dictation.ts` (speech-to-text) and `speech.ts` (read aloud), both through OpenRouter
|
`deepLinks.ts`, `keyboard.ts`
|
||||||
|
- `dictation.ts` (speech-to-text) and `speech.ts` (read aloud), both through `postOpenRouter` in
|
||||||
|
`openrouter.ts`
|
||||||
|
|
||||||
A feature gets a `src/app/<feature>.ts` only when it has non-UI logic to hold. Polls, goals,
|
A feature gets a `src/app/<feature>.ts` only when it has non-UI logic to hold. Polls, goals,
|
||||||
threads and calendar events have no module; their components use domain readers directly.
|
threads and calendar events have no module; their components use domain readers directly.
|
||||||
|
|
@ -142,6 +145,8 @@ client.
|
||||||
- `indexeddb.ts`: the `IDB` wrapper that `storage.ts` builds on
|
- `indexeddb.ts`: the `IDB` wrapper that `storage.ts` builds on
|
||||||
- `feeds.ts`: saved feed definitions (kind `FEED`) over `@welshman/feeds`. It is unrelated to
|
- `feeds.ts`: saved feed definitions (kind `FEED`) over `@welshman/feeds`. It is unrelated to
|
||||||
`@app/feeds`, which loads events.
|
`@app/feeds`, which loads events.
|
||||||
|
- `audio.ts` (device lists, a live input level, WAV encoding), `tileGrid.ts` (the call grid's
|
||||||
|
geometry)
|
||||||
- `currency.ts`, `transition.ts`, `implicit.ts` (hands state from one page to the next)
|
- `currency.ts`, `transition.ts`, `implicit.ts` (hands state from one page to the next)
|
||||||
- `test/`: the DEV-only hooks the e2e harness injects through
|
- `test/`: the DEV-only hooks the e2e harness injects through
|
||||||
- `components/`: the design system, entered through `theme.css` (see `flotilla-views`), which
|
- `components/`: the design system, entered through `theme.css` (see `flotilla-views`), which
|
||||||
|
|
|
||||||
|
|
@ -300,7 +300,7 @@ These predate the rule or are waiting on welshman. Don't copy them; fix one when
|
||||||
- **No factory yet:** `MESSAGE` (9) in `RoomChat` and `publishRoomQuote`; 9009 room invites in
|
- **No factory yet:** `MESSAGE` (9) in `RoomChat` and `publishRoomQuote`; 9009 room invites in
|
||||||
`src/app/access.ts`; `LIVEKIT_PARTICIPANTS` (39004, a local constant in `src/app/call.ts`);
|
`src/app/access.ts`; `LIVEKIT_PARTICIPANTS` (39004, a local constant in `src/app/call.ts`);
|
||||||
`STATUS` (30315) in `src/app/statuses.ts`, where `ProfileStatus` still uses `tags.find`; push
|
`STATUS` (30315) in `src/app/statuses.ts`, where `ProfileStatus` still uses `tags.find`; push
|
||||||
subscriptions (a literal 30390) in `src/app/push/adapters/capacitor.ts`; `DIRECT_MESSAGE_FILE`
|
subscriptions (`PUSH_SUBSCRIPTION`, 30390, local to `src/app/push/adapters/capacitor.ts`); `DIRECT_MESSAGE_FILE`
|
||||||
(15) in `Chat.svelte`.
|
(15) in `Chat.svelte`.
|
||||||
- **Factory exists but bypassed:** DMs built with `makeEvent` in `Chat.svelte` (`DirectMessage`);
|
- **Factory exists but bypassed:** DMs built with `makeEvent` in `Chat.svelte` (`DirectMessage`);
|
||||||
relay lists in `SignUp.svelte` (`RelayList`, `MessagingRelayList`); deletes in
|
relay lists in `SignUp.svelte` (`RelayList`, `MessagingRelayList`); deletes in
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,4 @@ Gift wraps are only synced once the user opts in (`shouldUnwrap` in `src/app/syn
|
||||||
| Follows and mutes | `FOLLOWS` (3), `MUTES` (10000) | `FollowList`, `MuteList` | `src/app/social.ts` | people pages, muting |
|
| Follows and mutes | `FOLLOWS` (3), `MUTES` (10000) | `FollowList`, `MuteList` | `src/app/social.ts` | people pages, muting |
|
||||||
| Relay lists | `RELAYS` (10002), `MESSAGING_RELAYS` (10050), `SEARCH_RELAYS` (10007), `BLOCKED_RELAYS` (10006), `BLOSSOM_SERVERS` (10063) | `RelayList`, `MessagingRelayList`, `SearchRelayList`, `BlockedRelayList`, `BlossomServerList` | plugins in `src/app/core.ts` | `settings/*` |
|
| Relay lists | `RELAYS` (10002), `MESSAGING_RELAYS` (10050), `SEARCH_RELAYS` (10007), `BLOCKED_RELAYS` (10006), `BLOSSOM_SERVERS` (10063) | `RelayList`, `MessagingRelayList`, `SearchRelayList`, `BlockedRelayList`, `BlossomServerList` | plugins in `src/app/core.ts` | `settings/*` |
|
||||||
| Account deletion | `VANISH` (62, written as a literal), `DELETE` (5) | none; `Delete` bypassed | none | `ProfileDelete` |
|
| Account deletion | `VANISH` (62, written as a literal), `DELETE` (5) | none; `Delete` bypassed | none | `ProfileDelete` |
|
||||||
| Push subscriptions | 30390 (a literal, no constant) | none | `src/app/push/adapters/capacitor.ts` | background |
|
| Push subscriptions | 30390 (`PUSH_SUBSCRIPTION`, local) | none | `src/app/push/adapters/capacitor.ts` | background |
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,8 @@ The following run outside that gate:
|
||||||
- every app policy
|
- every app policy
|
||||||
|
|
||||||
This code reads `app.get().user?.pubkey` and bails when it is missing, as
|
This code reads `app.get().user?.pubkey` and bails when it is missing, as
|
||||||
`syncUserSpaceMembership` in `src/app/sync.ts` and `nip98Header` in `src/app/notifications.ts`
|
`syncUserSpaceMembership` in `src/app/sync.ts` and `syncCheckedRemote` in
|
||||||
do.
|
`src/app/notifications.ts` do.
|
||||||
|
|
||||||
Stores derived from `user` throw as soon as they are subscribed signed out. That includes
|
Stores derived from `user` throw as soon as they are subscribed signed out. That includes
|
||||||
`isEventMuted` (`social.ts`), `deriveUserIsRoomAdmin` (`rooms.ts`) and `deriveUserCanCreateRoom`
|
`isEventMuted` (`social.ts`), `deriveUserIsRoomAdmin` (`rooms.ts`) and `deriveUserCanCreateRoom`
|
||||||
|
|
@ -106,7 +106,8 @@ Bookkeeping for one identity lives on a plugin instance, so it is discarded with
|
||||||
`Commands` in `src/app/commands.ts` keeps its `pulled` set on the plugin for this reason.
|
`Commands` in `src/app/commands.ts` keeps its `pulled` set on the plugin for this reason.
|
||||||
|
|
||||||
Module-level caches are fine when their contents do not depend on who is signed in, or when what
|
Module-level caches are fine when their contents do not depend on who is signed in, or when what
|
||||||
they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores.
|
they cache is itself a rebinding store. `deriveCommandsForUrl` (`commands.ts`) caches `fromApp`
|
||||||
|
stores with `simpleCache`.
|
||||||
`hasBlossomSupport` (`uploads.ts`) and `deriveHasLivekit` (`relays.ts`) use `simpleCache` from
|
`hasBlossomSupport` (`uploads.ts`) and `deriveHasLivekit` (`relays.ts`) use `simpleCache` from
|
||||||
`@welshman/lib` to share one store per url across every component that asks.
|
`@welshman/lib` to share one store per url across every component that asks.
|
||||||
|
|
||||||
|
|
@ -272,8 +273,9 @@ Room messages, threads and other content are not kept, and background sync pulls
|
||||||
next tick.
|
next tick.
|
||||||
|
|
||||||
To persist another kind, add it to `kinds` in `storage.ts`. To persist a new map-backed plugin,
|
To persist another kind, add it to `kinds` in `storage.ts`. To persist a new map-backed plugin,
|
||||||
add a `TABLES` entry and an `init*` method shaped like `initHandles`: load the rows, subscribe
|
add a `TABLES` entry and an `init*` method that calls `persistMapPlugin` with the table, the
|
||||||
to `onItem`, and batch the writes.
|
plugin, and how to turn a row into a key and item and back. It loads the rows, subscribes to
|
||||||
|
`onItem`, and batches the writes.
|
||||||
|
|
||||||
### kv, ss and synced stores
|
### kv, ss and synced stores
|
||||||
|
|
||||||
|
|
@ -346,8 +348,9 @@ call tears down the previous run.
|
||||||
- `syncDMs` pulls gift wraps from the user's messaging relays, only when `shouldUnwrap` is on.
|
- `syncDMs` pulls gift wraps from the user's messaging relays, only when `shouldUnwrap` is on.
|
||||||
|
|
||||||
`pullAndListen` is a negentropy `Sync.pull` plus a live `limit: 0` request, both stopped through
|
`pullAndListen` is a negentropy `Sync.pull` plus a live `limit: 0` request, both stopped through
|
||||||
an `AbortController`. `syncSpaces` and `syncUserData` diff their `unsubscribersBy*` maps against
|
an `AbortController`. `syncUserData`, `syncSpaces` and `syncDMs` each keep one sync per url with
|
||||||
the room list, so a new filter goes into the right `pullAndListen` call.
|
`makeKeyedSyncs`, whose `reconcile(urls)` starts the new ones and stops the dropped ones. A run
|
||||||
|
reads the pubkey once when it starts, since login triggers a fresh `syncApplicationData`.
|
||||||
|
|
||||||
Background sync keeps badges, navigation, the inbox and notifications correct on any page. Data
|
Background sync keeps badges, navigation, the inbox and notifications correct on any page. Data
|
||||||
that must be current app-wide belongs here. Data only one page shows is loaded by that page's
|
that must be current app-wide belongs here. Data only one page shows is loaded by that page's
|
||||||
|
|
@ -415,8 +418,9 @@ NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return
|
||||||
## Other app-level stores
|
## Other app-level stores
|
||||||
|
|
||||||
- **A join over many sources.** `notifications.ts` derives `latestActivityByPath`, then
|
- **A join over many sources.** `notifications.ts` derives `latestActivityByPath`, then
|
||||||
`allNotifications`, then `notifications` and the counts. `inbox.ts` derives from the same two
|
`allNotifications`, then `notifications` and the counts. The inbox stores
|
||||||
stores, so the inbox matches the badges.
|
(`inboxConversations`, `inboxSpaceContent`) derive from the same two, so the inbox matches the
|
||||||
|
badges.
|
||||||
- **Singleton session state.** `call.ts` keeps call state in plain writables (`callState`,
|
- **Singleton session state.** `call.ts` keeps call state in plain writables (`callState`,
|
||||||
`currentCallSession`, …).
|
`currentCallSession`, …).
|
||||||
- **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`.
|
- **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`.
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,4 @@
|
||||||
@import "./components/room.css";
|
@import "./components/room.css";
|
||||||
@import "./components/space-menu.css";
|
@import "./components/space-menu.css";
|
||||||
|
|
||||||
/* SocketStatusIndicator builds its class as bg-{status.theme}, which the source
|
|
||||||
scanner can't see. Must stay below every @import to keep those valid. */
|
|
||||||
@source inline("bg-success bg-warning bg-error bg-gray-500");
|
|
||||||
|
|
||||||
@config "../../tailwind.config.js";
|
@config "../../tailwind.config.js";
|
||||||
|
|
|
||||||
180
src/app/call.ts
180
src/app/call.ts
|
|
@ -9,181 +9,6 @@ import {events, network} from "@app/core"
|
||||||
|
|
||||||
export const LIVEKIT_PARTICIPANTS = 39004
|
export const LIVEKIT_PARTICIPANTS = 39004
|
||||||
|
|
||||||
/** The lower aspect bound is 1:1 on landscape and 3:4 on portrait, and the upper is 16:9. */
|
|
||||||
const TILE_ASPECT_PORTRAIT = 3 / 4
|
|
||||||
const TILE_ASPECT_LANDSCAPE = 16 / 9
|
|
||||||
const TILE_GAP = 8
|
|
||||||
|
|
||||||
/** Below this the grid scrolls rather than forcing portrait tiles, which is ~6-8 tiles on a portrait phone. */
|
|
||||||
const MIN_TILE_HEIGHT = 120
|
|
||||||
|
|
||||||
export type TileRow = {
|
|
||||||
columnCount: number
|
|
||||||
tileWidth: number
|
|
||||||
tileHeight: number
|
|
||||||
/** Total width of this row including gaps — for the template's max-width */
|
|
||||||
rowWidth: number
|
|
||||||
aspectRatio: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All tiles share one size. Full rows fill the container width and a partial row is centered. */
|
|
||||||
export type AdaptiveTileGrid = {
|
|
||||||
rows: TileRow[]
|
|
||||||
totalWidth: number
|
|
||||||
totalHeight: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Score for comparing candidate layouts. Lower is better. */
|
|
||||||
type LayoutScore = {
|
|
||||||
/** Penalty for vertical overflow: 0 if none, else huge */
|
|
||||||
verticalOverflowPenalty: number
|
|
||||||
/** Penalty for horizontal overflow: 0 if none, else huge */
|
|
||||||
horizontalOverflowPenalty: number
|
|
||||||
/** Penalty when tiles fall below MIN_TILE_HEIGHT even while fitting */
|
|
||||||
minTileHeightPenalty: number
|
|
||||||
/** Negative tile area (we want to maximize area, so negate) */
|
|
||||||
negativeArea: number
|
|
||||||
/** Leftover whitespace (underflow) in px */
|
|
||||||
whitespace: number
|
|
||||||
/** Average absolute deviation from 16:9 across all tiles */
|
|
||||||
aspectDeviation: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const compareScores = (a: LayoutScore, b: LayoutScore): number => {
|
|
||||||
const fields: (keyof LayoutScore)[] = [
|
|
||||||
"verticalOverflowPenalty",
|
|
||||||
"horizontalOverflowPenalty",
|
|
||||||
"minTileHeightPenalty",
|
|
||||||
"negativeArea",
|
|
||||||
"whitespace",
|
|
||||||
"aspectDeviation",
|
|
||||||
]
|
|
||||||
for (const field of fields) {
|
|
||||||
if (a[field] !== b[field]) {
|
|
||||||
return a[field] - b[field]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The largest tile that fits, shrunk to whichever dimension is more constraining. */
|
|
||||||
const fitTile = (availWidth: number, availHeight: number, minAspect: number) => {
|
|
||||||
const fillAspect = availWidth / availHeight
|
|
||||||
const aspectRatio = Math.max(minAspect, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect))
|
|
||||||
if (fillAspect >= aspectRatio) {
|
|
||||||
const tileWidth = availHeight * aspectRatio
|
|
||||||
return {tileWidth, tileHeight: availHeight, aspectRatio}
|
|
||||||
}
|
|
||||||
const tileHeight = availWidth / aspectRatio
|
|
||||||
return {tileWidth: availWidth, tileHeight, aspectRatio}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A candidate grid for a given column count, with the partial last row centered. */
|
|
||||||
const buildCandidate = (
|
|
||||||
tileCount: number,
|
|
||||||
columnCount: number,
|
|
||||||
containerWidth: number,
|
|
||||||
containerHeight: number,
|
|
||||||
minAspect: number,
|
|
||||||
): AdaptiveTileGrid | undefined => {
|
|
||||||
const gap = TILE_GAP
|
|
||||||
const fullRowCount = Math.floor(tileCount / columnCount)
|
|
||||||
const remainder = tileCount % columnCount
|
|
||||||
const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0)
|
|
||||||
|
|
||||||
const availHeight = (containerHeight - (totalRowCount - 1) * gap) / totalRowCount
|
|
||||||
const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
|
|
||||||
if (availWidth <= 0 || availHeight <= 0) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect)
|
|
||||||
|
|
||||||
const rows: TileRow[] = []
|
|
||||||
for (let r = 0; r < fullRowCount; r++) {
|
|
||||||
rows.push({
|
|
||||||
columnCount,
|
|
||||||
tileWidth,
|
|
||||||
tileHeight,
|
|
||||||
rowWidth: columnCount * tileWidth + (columnCount - 1) * gap,
|
|
||||||
aspectRatio,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (remainder > 0) {
|
|
||||||
rows.push({
|
|
||||||
columnCount: remainder,
|
|
||||||
tileWidth,
|
|
||||||
tileHeight,
|
|
||||||
rowWidth: remainder * tileWidth + (remainder - 1) * gap,
|
|
||||||
aspectRatio,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalHeight = totalRowCount * tileHeight + (totalRowCount - 1) * gap
|
|
||||||
const totalWidth = columnCount * tileWidth + (columnCount - 1) * gap
|
|
||||||
|
|
||||||
return {rows, totalWidth, totalHeight}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Scored on overflow first, then whitespace, then distance from 16:9, then tile size. */
|
|
||||||
export const computeAdaptiveGrid = (
|
|
||||||
tileCount: number,
|
|
||||||
containerWidth: number,
|
|
||||||
containerHeight: number,
|
|
||||||
): AdaptiveTileGrid | undefined => {
|
|
||||||
if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const minAspect = containerWidth / containerHeight >= 4 / 3 ? 1 : TILE_ASPECT_PORTRAIT
|
|
||||||
|
|
||||||
let best: AdaptiveTileGrid | undefined
|
|
||||||
let bestScore: LayoutScore | undefined
|
|
||||||
|
|
||||||
for (let columnCount = 1; columnCount <= tileCount; columnCount++) {
|
|
||||||
const candidate = buildCandidate(
|
|
||||||
tileCount,
|
|
||||||
columnCount,
|
|
||||||
containerWidth,
|
|
||||||
containerHeight,
|
|
||||||
minAspect,
|
|
||||||
)
|
|
||||||
if (!candidate) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const {tileWidth, tileHeight, aspectRatio} = candidate.rows[0]
|
|
||||||
const verticalOverflow = Math.max(0, candidate.totalHeight - containerHeight)
|
|
||||||
const horizontalOverflow = Math.max(0, candidate.totalWidth - containerWidth)
|
|
||||||
const whitespace =
|
|
||||||
Math.max(0, containerWidth - candidate.totalWidth) +
|
|
||||||
Math.max(0, containerHeight - candidate.totalHeight)
|
|
||||||
const totalArea = tileWidth * tileHeight * tileCount
|
|
||||||
|
|
||||||
const overflowPenaltyMultiplier = tileHeight >= MIN_TILE_HEIGHT ? 1_000_000 : 1
|
|
||||||
const verticalOverflowPenalty = verticalOverflow * overflowPenaltyMultiplier
|
|
||||||
const horizontalOverflowPenalty = horizontalOverflow * overflowPenaltyMultiplier
|
|
||||||
const minTileHeightPenalty =
|
|
||||||
tileHeight < MIN_TILE_HEIGHT ? (MIN_TILE_HEIGHT - tileHeight) * 1000 : 0
|
|
||||||
|
|
||||||
const score: LayoutScore = {
|
|
||||||
verticalOverflowPenalty,
|
|
||||||
horizontalOverflowPenalty,
|
|
||||||
minTileHeightPenalty,
|
|
||||||
negativeArea: -totalArea,
|
|
||||||
whitespace,
|
|
||||||
aspectDeviation: Math.abs(aspectRatio - TILE_ASPECT_LANDSCAPE),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bestScore || compareScores(score, bestScore) < 0) {
|
|
||||||
bestScore = score
|
|
||||||
best = candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CallSession = {
|
export type CallSession = {
|
||||||
url: string
|
url: string
|
||||||
h: string
|
h: string
|
||||||
|
|
@ -231,6 +56,11 @@ export const isCallActive = derived(
|
||||||
$state => $state === CallState.Joining || $state === CallState.Connected,
|
$state => $state === CallState.Joining || $state === CallState.Connected,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const deriveRoomCallState = (url: string, h?: string) =>
|
||||||
|
derived([callState, callTargetRoom], ([$callState, $targetRoom]) =>
|
||||||
|
$targetRoom?.url === url && $targetRoom.h === h ? $callState : CallState.Disconnected,
|
||||||
|
)
|
||||||
|
|
||||||
export const deriveIsCallActiveElsewhere = (url: string | undefined, h: string | undefined) =>
|
export const deriveIsCallActiveElsewhere = (url: string | undefined, h: string | undefined) =>
|
||||||
derived(
|
derived(
|
||||||
[isCallActive, callTargetRoom],
|
[isCallActive, callTargetRoom],
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,10 @@ import {
|
||||||
import {App} from "@capacitor/app"
|
import {App} from "@capacitor/app"
|
||||||
import type {PluginListenerHandle} from "@capacitor/core"
|
import type {PluginListenerHandle} from "@capacitor/core"
|
||||||
import {get} from "svelte/store"
|
import {get} from "svelte/store"
|
||||||
import {not, nthEq, reject} from "@welshman/lib"
|
import {not, nthEq, reject, spec} from "@welshman/lib"
|
||||||
import {makeHttpAuth, makeHttpAuthHeader} from "@welshman/util"
|
|
||||||
import {makeRoomKey} from "@welshman/app"
|
import {makeRoomKey} from "@welshman/app"
|
||||||
import {user} from "@app/core"
|
|
||||||
import {getLivekitEndpoint} from "@app/relays"
|
import {getLivekitEndpoint} from "@app/relays"
|
||||||
|
import {getHttpAuthHeader} from "@app/signer"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {
|
import {
|
||||||
CallState,
|
CallState,
|
||||||
|
|
@ -32,6 +31,7 @@ import {
|
||||||
speakingParticipants,
|
speakingParticipants,
|
||||||
triggerVideoTrackRevision,
|
triggerVideoTrackRevision,
|
||||||
videoPrimaryTileKey,
|
videoPrimaryTileKey,
|
||||||
|
type CallSession,
|
||||||
type ParticipantMediaState,
|
type ParticipantMediaState,
|
||||||
} from "@app/call"
|
} from "@app/call"
|
||||||
|
|
||||||
|
|
@ -443,12 +443,8 @@ const fetchLivekitToken = async (
|
||||||
throw new DOMException("Aborted", "AbortError")
|
throw new DOMException("Aborted", "AbortError")
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await makeHttpAuth(endpoint, "GET")
|
|
||||||
const signedEvent = await user.get().signer.sign(template)
|
|
||||||
const authHeader = makeHttpAuthHeader(signedEvent)
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
headers: {Authorization: authHeader},
|
headers: {Authorization: await getHttpAuthHeader(endpoint)},
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -667,3 +663,86 @@ const onLocalTrackUnpublished = (
|
||||||
const abortJoinVoiceRoom = () => {
|
const abortJoinVoiceRoom = () => {
|
||||||
joinAbortController?.abort()
|
joinAbortController?.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VideoTile = {
|
||||||
|
liveKitIdentity: string
|
||||||
|
isLocal: boolean
|
||||||
|
trackSid: string
|
||||||
|
track: Track | undefined
|
||||||
|
source: Track.Source.Camera | Track.Source.ScreenShare
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveKit can change trackSid after publish, so a tile is keyed by identity and source.
|
||||||
|
export const getVideoTileKey = (tile: VideoTile) => `${tile.liveKitIdentity}\x1f${tile.source}`
|
||||||
|
|
||||||
|
export const getVideoTiles = ({livekit, cameraOn, screenShareOn}: CallSession) => {
|
||||||
|
const videoTiles: VideoTile[] = []
|
||||||
|
const user = livekit.localParticipant
|
||||||
|
|
||||||
|
if (cameraOn) {
|
||||||
|
const localPub = user.getTrackPublication(Track.Source.Camera)
|
||||||
|
videoTiles.push({
|
||||||
|
liveKitIdentity: user.identity,
|
||||||
|
isLocal: true,
|
||||||
|
trackSid: localPub?.trackSid ?? "local-camera",
|
||||||
|
track: localPub?.track,
|
||||||
|
source: Track.Source.Camera,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenShareOn) {
|
||||||
|
const localPub = user.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
videoTiles.push({
|
||||||
|
liveKitIdentity: user.identity,
|
||||||
|
isLocal: true,
|
||||||
|
trackSid: localPub?.trackSid ?? "local-screen",
|
||||||
|
track: localPub?.track,
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rp of livekit.remoteParticipants.values()) {
|
||||||
|
const camPub = rp.getTrackPublication(Track.Source.Camera)
|
||||||
|
// Camera off mutes the publication rather than unsubscribing; still render avatar.
|
||||||
|
if (camPub?.isSubscribed && camPub.track && !camPub.isMuted) {
|
||||||
|
videoTiles.push({
|
||||||
|
liveKitIdentity: rp.identity,
|
||||||
|
isLocal: false,
|
||||||
|
trackSid: camPub.trackSid,
|
||||||
|
track: camPub.track,
|
||||||
|
source: Track.Source.Camera,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
if (screenPub?.isSubscribed && screenPub.track && !screenPub.isMuted) {
|
||||||
|
videoTiles.push({
|
||||||
|
liveKitIdentity: rp.identity,
|
||||||
|
isLocal: false,
|
||||||
|
trackSid: screenPub.trackSid,
|
||||||
|
track: screenPub.track,
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!videoTiles.some(spec({liveKitIdentity: rp.identity}))) {
|
||||||
|
videoTiles.push({
|
||||||
|
liveKitIdentity: rp.identity,
|
||||||
|
isLocal: false,
|
||||||
|
trackSid: `avatar-${rp.identity}`,
|
||||||
|
track: undefined,
|
||||||
|
source: Track.Source.Camera,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoTiles.some(spec({liveKitIdentity: user.identity}))) {
|
||||||
|
videoTiles.push({
|
||||||
|
liveKitIdentity: user.identity,
|
||||||
|
isLocal: true,
|
||||||
|
trackSid: "local-avatar",
|
||||||
|
track: undefined,
|
||||||
|
source: Track.Source.Camera,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return videoTiles
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import {derived} from "svelte/store"
|
import {derived} from "svelte/store"
|
||||||
import type {Readable} from "svelte/store"
|
import type {Readable} from "svelte/store"
|
||||||
|
import {simpleCache} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
Address,
|
Address,
|
||||||
COMMAND,
|
COMMAND,
|
||||||
|
|
@ -76,16 +77,8 @@ export const getCommandsForTarget = (target: CommandScopeTarget) =>
|
||||||
.filter(command => command.matches(target))
|
.filter(command => command.matches(target))
|
||||||
|
|
||||||
// One store per url, and subscribing to it is what pulls the definitions.
|
// One store per url, and subscribing to it is what pulls the definitions.
|
||||||
const commandsByUrl = new Map<string, Readable<CommandReader[]>>()
|
const deriveCommandsForUrl = simpleCache(([url]: [string]) =>
|
||||||
|
fromApp($app => {
|
||||||
const deriveCommandsForUrl = (url: string): Readable<CommandReader[]> => {
|
|
||||||
const cached = commandsByUrl.get(url)
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = fromApp($app => {
|
|
||||||
const plugin = $app.use(Commands)
|
const plugin = $app.use(Commands)
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
|
|
@ -93,12 +86,8 @@ const deriveCommandsForUrl = (url: string): Readable<CommandReader[]> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return plugin.forUrl(url).$
|
return plugin.forUrl(url).$
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
commandsByUrl.set(url, store)
|
|
||||||
|
|
||||||
return store
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content outside a space has no url, and so no commands.
|
// Content outside a space has no url, and so no commands.
|
||||||
export const deriveValidCommands = (target: CommandScopeTarget): Readable<CommandReader[]> =>
|
export const deriveValidCommands = (target: CommandScopeTarget): Readable<CommandReader[]> =>
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,8 @@
|
||||||
videoCallLayout,
|
videoCallLayout,
|
||||||
CallState,
|
CallState,
|
||||||
currentCallSession,
|
currentCallSession,
|
||||||
callTargetRoom,
|
|
||||||
callMicMuted,
|
callMicMuted,
|
||||||
callState,
|
deriveRoomCallState,
|
||||||
} from "@app/call"
|
} from "@app/call"
|
||||||
import {
|
import {
|
||||||
cancelJoinVoiceRoom,
|
cancelJoinVoiceRoom,
|
||||||
|
|
@ -49,12 +48,9 @@
|
||||||
const room = $derived($roomStore)
|
const room = $derived($roomStore)
|
||||||
const isVoiceRoom = $derived(room !== undefined && getRoomType(room) === RoomType.Voice)
|
const isVoiceRoom = $derived(room !== undefined && getRoomType(room) === RoomType.Voice)
|
||||||
|
|
||||||
const isTargetingThisRoom = $derived(
|
const roomCallState = $derived(deriveRoomCallState(url, h))
|
||||||
$callTargetRoom !== undefined && $callTargetRoom.url === url && $callTargetRoom.h === h,
|
const joiningHere = $derived($roomCallState === CallState.Joining)
|
||||||
)
|
const connectedHere = $derived($roomCallState === CallState.Connected)
|
||||||
|
|
||||||
const joiningHere = $derived($callState === CallState.Joining && isTargetingThisRoom)
|
|
||||||
const connectedHere = $derived($callState === CallState.Connected && isTargetingThisRoom)
|
|
||||||
// callTargetRoom isn't cleared on cancel or leave, so it can still point at this room.
|
// callTargetRoom isn't cleared on cancel or leave, so it can still point at this room.
|
||||||
const showJoin = $derived(isVoiceRoom && !joiningHere && !connectedHere)
|
const showJoin = $derived(isVoiceRoom && !joiningHere && !connectedHere)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
import {displayContentCount} from "@app/content"
|
import {displayContentCount} from "@app/content"
|
||||||
import {inboxSpaceContent} from "@app/inbox"
|
import {inboxSpaceContent} from "@app/notifications"
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import HomeSection from "@app/components/HomeSection.svelte"
|
import HomeSection from "@app/components/HomeSection.svelte"
|
||||||
import HomeInboxItem from "@app/components/HomeInboxItem.svelte"
|
import HomeInboxItem from "@app/components/HomeInboxItem.svelte"
|
||||||
import {inboxConversations} from "@app/inbox"
|
|
||||||
import {navigate} from "@app/modal"
|
import {navigate} from "@app/modal"
|
||||||
import {allNotifications, setChecked} from "@app/notifications"
|
import {allNotifications, inboxConversations, setChecked} from "@app/notifications"
|
||||||
|
|
||||||
const conversations = $derived($inboxConversations.slice(0, 8))
|
const conversations = $derived($inboxConversations.slice(0, 8))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import {watchAudioLevel} from "@lib/audio"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
track: MediaStreamTrack | undefined
|
track: MediaStreamTrack | undefined
|
||||||
|
|
@ -11,57 +12,34 @@
|
||||||
|
|
||||||
const {track, error, offMessage = "Microphone is off"}: Props = $props()
|
const {track, error, offMessage = "Microphone is off"}: Props = $props()
|
||||||
|
|
||||||
|
const NOISE_FLOOR = 0.03
|
||||||
|
|
||||||
let level = $state(0)
|
let level = $state(0)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const t = track
|
const t = track
|
||||||
|
|
||||||
level = 0
|
level = 0
|
||||||
|
|
||||||
if (!t || error) {
|
if (t && !error) {
|
||||||
return
|
// Reading the `$state` this effect writes would make it depend on its own output.
|
||||||
}
|
let smoothed = 0
|
||||||
|
|
||||||
let frame: number | undefined
|
return watchAudioLevel(new MediaStream([t]), rms => {
|
||||||
const context = new AudioContext()
|
if (t.enabled && t.readyState === "live") {
|
||||||
const analyser = context.createAnalyser()
|
const gated = rms < NOISE_FLOOR ? 0 : (rms - NOISE_FLOOR) / (1 - NOISE_FLOOR)
|
||||||
analyser.fftSize = 512
|
|
||||||
context.createMediaStreamSource(new MediaStream([t])).connect(analyser)
|
|
||||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
|
||||||
|
|
||||||
// Reading the `$state` this effect writes would make it depend on its own output.
|
smoothed += (Math.min(1, gated * 4) - smoothed) * 0.5
|
||||||
let smoothed = 0
|
|
||||||
|
|
||||||
const tick = () => {
|
if (smoothed < 0.005) {
|
||||||
if (!t.enabled || t.readyState !== "live") {
|
smoothed = 0
|
||||||
smoothed = 0
|
}
|
||||||
level = 0
|
} else {
|
||||||
frame = requestAnimationFrame(tick)
|
smoothed = 0
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
analyser.getByteTimeDomainData(data)
|
level = smoothed
|
||||||
let sumSquares = 0
|
})
|
||||||
for (const v of data) {
|
|
||||||
const normalized = (v - 128) / 128
|
|
||||||
sumSquares += normalized * normalized
|
|
||||||
}
|
|
||||||
const rms = Math.sqrt(sumSquares / data.length)
|
|
||||||
const NOISE_FLOOR = 0.03
|
|
||||||
const gated = rms < NOISE_FLOOR ? 0 : (rms - NOISE_FLOOR) / (1 - NOISE_FLOOR)
|
|
||||||
smoothed += (Math.min(1, gated * 4) - smoothed) * 0.5
|
|
||||||
if (smoothed < 0.005) {
|
|
||||||
smoothed = 0
|
|
||||||
}
|
|
||||||
level = smoothed
|
|
||||||
frame = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
tick()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (frame !== undefined) {
|
|
||||||
cancelAnimationFrame(frame)
|
|
||||||
}
|
|
||||||
context.close().catch(() => {})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,13 @@
|
||||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||||
import CallControlBar from "@app/components/CallControlBar.svelte"
|
import CallControlBar from "@app/components/CallControlBar.svelte"
|
||||||
import {deletes, relays, rooms, thunks, user, writer} from "@app/core"
|
import {deletes, relays, rooms, thunks, user, writer} from "@app/core"
|
||||||
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
|
import {
|
||||||
|
CallState,
|
||||||
|
callState,
|
||||||
|
deriveRoomCallState,
|
||||||
|
VideoCallLayout,
|
||||||
|
videoCallLayout,
|
||||||
|
} from "@app/call"
|
||||||
import {
|
import {
|
||||||
RoomType,
|
RoomType,
|
||||||
deriveUserRoomMembershipStatus,
|
deriveUserRoomMembershipStatus,
|
||||||
|
|
@ -42,11 +48,9 @@
|
||||||
const room = h ? $rooms.forRoom(url, h) : readable(undefined)
|
const room = h ? $rooms.forRoom(url, h) : readable(undefined)
|
||||||
const isVoiceRoom = $derived($room && getRoomType($room) === RoomType.Voice)
|
const isVoiceRoom = $derived($room && getRoomType($room) === RoomType.Voice)
|
||||||
|
|
||||||
const isCallTargetingThisRoom = $derived($callTargetRoom?.url === url && $callTargetRoom?.h === h)
|
const roomCallState = deriveRoomCallState(url, h)
|
||||||
|
|
||||||
const voiceConnectedHere = $derived(
|
const voiceConnectedHere = $derived(isVoiceRoom && $roomCallState === CallState.Connected)
|
||||||
isVoiceRoom && $callState === CallState.Connected && isCallTargetingThisRoom,
|
|
||||||
)
|
|
||||||
|
|
||||||
// voiceConnectedHere can't stay true for a voice room the call isn't targeting.
|
// voiceConnectedHere can't stay true for a voice room the call isn't targeting.
|
||||||
const callChatOpen = $derived(voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split)
|
const callChatOpen = $derived(voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import StatusIndicator from "@lib/components/StatusIndicator.svelte"
|
import StatusIndicator from "@lib/components/StatusIndicator.svelte"
|
||||||
import {deriveSocketStatus} from "@app/relays"
|
import {RelayConnection, deriveSocketStatus} from "@app/relays"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -10,4 +10,18 @@
|
||||||
const status = deriveSocketStatus(url)
|
const status = deriveSocketStatus(url)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<StatusIndicator class="bg-{$status.theme}">{$status.title}</StatusIndicator>
|
{#if $status === RelayConnection.Connecting}
|
||||||
|
<StatusIndicator class="bg-warning">Connecting</StatusIndicator>
|
||||||
|
{:else if $status === RelayConnection.Disconnected}
|
||||||
|
<StatusIndicator class="bg-gray-500">Not Connected</StatusIndicator>
|
||||||
|
{:else if $status === RelayConnection.ConnectFailed}
|
||||||
|
<StatusIndicator class="bg-error">Failed to Connect</StatusIndicator>
|
||||||
|
{:else if $status === RelayConnection.Authenticating}
|
||||||
|
<StatusIndicator class="bg-warning">Authenticating</StatusIndicator>
|
||||||
|
{:else if $status === RelayConnection.AuthFailed}
|
||||||
|
<StatusIndicator class="bg-error">Failed to Authenticate</StatusIndicator>
|
||||||
|
{:else if $status === RelayConnection.AccessDenied}
|
||||||
|
<StatusIndicator class="bg-error">Access Denied</StatusIndicator>
|
||||||
|
{:else}
|
||||||
|
<StatusIndicator class="bg-success">Connected</StatusIndicator>
|
||||||
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onDestroy} from "svelte"
|
import {onDestroy} from "svelte"
|
||||||
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
||||||
import {deriveSocketStatus} from "@app/relays"
|
import {RelayConnection, deriveSocketStatus} from "@app/relays"
|
||||||
import {pushToast, popToast} from "@app/toast"
|
import {pushToast, popToast} from "@app/toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($status.theme !== "warning") {
|
if ($status !== RelayConnection.Connecting && $status !== RelayConnection.Authenticating) {
|
||||||
hide()
|
hide()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {removeUndefined, spec} from "@welshman/lib"
|
import {removeUndefined} from "@welshman/lib"
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import {Track} from "livekit-client"
|
import {Track} from "livekit-client"
|
||||||
import Pin from "@assets/icons/pin.svg?dataurl"
|
import Pin from "@assets/icons/pin.svg?dataurl"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import {computeAdaptiveGrid} from "@lib/tileGrid"
|
||||||
|
import type {AdaptiveTileGrid} from "@lib/tileGrid"
|
||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
import VideoCallTile from "@app/components/VideoCallTile.svelte"
|
import VideoCallTile from "@app/components/VideoCallTile.svelte"
|
||||||
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
||||||
|
|
@ -17,9 +19,9 @@
|
||||||
participantMediaState,
|
participantMediaState,
|
||||||
pubkeyFromLiveKitIdentity,
|
pubkeyFromLiveKitIdentity,
|
||||||
videoTrackRevision,
|
videoTrackRevision,
|
||||||
computeAdaptiveGrid,
|
|
||||||
type AdaptiveTileGrid,
|
|
||||||
} from "@app/call"
|
} from "@app/call"
|
||||||
|
import {getVideoTileKey, getVideoTiles} from "@app/callEngine"
|
||||||
|
import type {VideoTile} from "@app/callEngine"
|
||||||
import {deriveDisplaysByPubkey} from "@app/social"
|
import {deriveDisplaysByPubkey} from "@app/social"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -28,108 +30,26 @@
|
||||||
class?: string
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoTileData = {
|
|
||||||
liveKitIdentity: string
|
|
||||||
isLocal: boolean
|
|
||||||
trackSid: string
|
|
||||||
track: Track | undefined
|
|
||||||
source: Track.Source.Camera | Track.Source.ScreenShare
|
|
||||||
}
|
|
||||||
|
|
||||||
type TileLayoutVariant = "spotlight" | "default" | "strip"
|
type TileLayoutVariant = "spotlight" | "default" | "strip"
|
||||||
|
|
||||||
const {url, h, class: className}: Props = $props()
|
const {url, h, class: className}: Props = $props()
|
||||||
|
|
||||||
const videoTiles = $derived.by(() => {
|
const videoTiles = $derived.by(() => {
|
||||||
const session = $currentCallSession
|
|
||||||
// LiveKit mutates remoteParticipants and tracks in place, so these stores are what change.
|
// LiveKit mutates remoteParticipants and tracks in place, so these stores are what change.
|
||||||
void $participantMediaState
|
void $participantMediaState
|
||||||
void $videoTrackRevision
|
void $videoTrackRevision
|
||||||
if (!session || $callTargetRoom?.url !== url || $callTargetRoom?.h !== h) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const livekit = session.livekit
|
return $currentCallSession && $callTargetRoom?.url === url && $callTargetRoom?.h === h
|
||||||
const videoTiles: VideoTileData[] = []
|
? getVideoTiles($currentCallSession)
|
||||||
const user = livekit.localParticipant
|
: []
|
||||||
|
|
||||||
if (session.cameraOn) {
|
|
||||||
const localPub = user.getTrackPublication(Track.Source.Camera)
|
|
||||||
videoTiles.push({
|
|
||||||
liveKitIdentity: user.identity,
|
|
||||||
isLocal: true,
|
|
||||||
trackSid: localPub?.trackSid ?? "local-camera",
|
|
||||||
track: localPub?.track,
|
|
||||||
source: Track.Source.Camera,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.screenShareOn) {
|
|
||||||
const localPub = user.getTrackPublication(Track.Source.ScreenShare)
|
|
||||||
videoTiles.push({
|
|
||||||
liveKitIdentity: user.identity,
|
|
||||||
isLocal: true,
|
|
||||||
trackSid: localPub?.trackSid ?? "local-screen",
|
|
||||||
track: localPub?.track,
|
|
||||||
source: Track.Source.ScreenShare,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const rp of livekit.remoteParticipants.values()) {
|
|
||||||
const camPub = rp.getTrackPublication(Track.Source.Camera)
|
|
||||||
// Camera off mutes the publication rather than unsubscribing; still render avatar.
|
|
||||||
if (camPub?.isSubscribed && camPub.track && !camPub.isMuted) {
|
|
||||||
videoTiles.push({
|
|
||||||
liveKitIdentity: rp.identity,
|
|
||||||
isLocal: false,
|
|
||||||
trackSid: camPub.trackSid,
|
|
||||||
track: camPub.track,
|
|
||||||
source: Track.Source.Camera,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
|
|
||||||
if (screenPub?.isSubscribed && screenPub.track && !screenPub.isMuted) {
|
|
||||||
videoTiles.push({
|
|
||||||
liveKitIdentity: rp.identity,
|
|
||||||
isLocal: false,
|
|
||||||
trackSid: screenPub.trackSid,
|
|
||||||
track: screenPub.track,
|
|
||||||
source: Track.Source.ScreenShare,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (!videoTiles.some(spec({liveKitIdentity: rp.identity}))) {
|
|
||||||
videoTiles.push({
|
|
||||||
liveKitIdentity: rp.identity,
|
|
||||||
isLocal: false,
|
|
||||||
trackSid: `avatar-${rp.identity}`,
|
|
||||||
track: undefined,
|
|
||||||
source: Track.Source.Camera,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!videoTiles.some(spec({liveKitIdentity: user.identity}))) {
|
|
||||||
videoTiles.push({
|
|
||||||
liveKitIdentity: user.identity,
|
|
||||||
isLocal: true,
|
|
||||||
trackSid: "local-avatar",
|
|
||||||
track: undefined,
|
|
||||||
source: Track.Source.Camera,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return videoTiles
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** LiveKit identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
|
|
||||||
const tileKey = (t: VideoTileData) => `${t.liveKitIdentity}\x1f${t.source}`
|
|
||||||
|
|
||||||
const primaryTile = $derived.by(() => {
|
const primaryTile = $derived.by(() => {
|
||||||
const k = $videoPrimaryTileKey
|
const k = $videoPrimaryTileKey
|
||||||
if (k === undefined) {
|
if (k === undefined) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return videoTiles.find(t => tileKey(t) === k)
|
return videoTiles.find(t => getVideoTileKey(t) === k)
|
||||||
})
|
})
|
||||||
|
|
||||||
const secondaryTiles = $derived.by(() => {
|
const secondaryTiles = $derived.by(() => {
|
||||||
|
|
@ -137,8 +57,8 @@
|
||||||
if (p === undefined) {
|
if (p === undefined) {
|
||||||
return videoTiles
|
return videoTiles
|
||||||
}
|
}
|
||||||
const pk = tileKey(p)
|
const pk = getVideoTileKey(p)
|
||||||
return videoTiles.filter(t => tileKey(t) !== pk)
|
return videoTiles.filter(t => getVideoTileKey(t) !== pk)
|
||||||
})
|
})
|
||||||
|
|
||||||
let gridWidth = $state(0)
|
let gridWidth = $state(0)
|
||||||
|
|
@ -156,7 +76,7 @@
|
||||||
if (k === undefined) {
|
if (k === undefined) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!videoTiles.some(t => tileKey(t) === k)) {
|
if (!videoTiles.some(t => getVideoTileKey(t) === k)) {
|
||||||
videoPrimaryTileKey.set(undefined)
|
videoPrimaryTileKey.set(undefined)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -168,7 +88,7 @@
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const labelFor = (liveKitIdentity: string, source: VideoTileData["source"]) => {
|
const labelFor = (liveKitIdentity: string, source: VideoTile["source"]) => {
|
||||||
const pk = pubkeyFromLiveKitIdentity(liveKitIdentity)
|
const pk = pubkeyFromLiveKitIdentity(liveKitIdentity)
|
||||||
const name = (pk && $displays.get(pk)) || "Unknown"
|
const name = (pk && $displays.get(pk)) || "Unknown"
|
||||||
|
|
||||||
|
|
@ -182,7 +102,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet videoTile(tile: VideoTileData, layout: TileLayoutVariant)}
|
{#snippet videoTile(tile: VideoTile, layout: TileLayoutVariant)}
|
||||||
{@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
|
{@const media = $mediaStateByIdentity(tile.liveKitIdentity)}
|
||||||
{@const label = labelFor(tile.liveKitIdentity, tile.source)}
|
{@const label = labelFor(tile.liveKitIdentity, tile.source)}
|
||||||
<div
|
<div
|
||||||
|
|
@ -219,7 +139,7 @@
|
||||||
{label}{tile.isLocal ? " (you)" : ""}
|
{label}{tile.isLocal ? " (you)" : ""}
|
||||||
</span>
|
</span>
|
||||||
{#if videoTiles.length > 1}
|
{#if videoTiles.length > 1}
|
||||||
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
|
{@const pinned = $videoPrimaryTileKey === getVideoTileKey(tile)}
|
||||||
<Button
|
<Button
|
||||||
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
||||||
aria-label={pinned ? `Exit spotlight for ${label}` : `Spotlight ${label}`}
|
aria-label={pinned ? `Exit spotlight for ${label}` : `Spotlight ${label}`}
|
||||||
|
|
@ -229,7 +149,7 @@
|
||||||
"absolute right-1 top-1 z-20",
|
"absolute right-1 top-1 z-20",
|
||||||
!pinned && "bg-surface",
|
!pinned && "bg-surface",
|
||||||
)}
|
)}
|
||||||
onclick={spotlightHandlerFor(tileKey(tile))}>
|
onclick={spotlightHandlerFor(getVideoTileKey(tile))}>
|
||||||
<Icon icon={Pin} size={3} />
|
<Icon icon={Pin} size={3} />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -244,7 +164,7 @@
|
||||||
{#if secondaryTiles.length > 0}
|
{#if secondaryTiles.length > 0}
|
||||||
<div
|
<div
|
||||||
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
|
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
|
||||||
{#each secondaryTiles as tile (tileKey(tile))}
|
{#each secondaryTiles as tile (getVideoTileKey(tile))}
|
||||||
{@render videoTile(tile, "strip")}
|
{@render videoTile(tile, "strip")}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -264,7 +184,7 @@
|
||||||
<div
|
<div
|
||||||
class="flex flex-nowrap justify-center gap-2"
|
class="flex flex-nowrap justify-center gap-2"
|
||||||
style={`max-width: ${row.rowWidth}px`}>
|
style={`max-width: ${row.rowWidth}px`}>
|
||||||
{#each videoTiles.slice(offset, offset + row.columnCount) as tile (tileKey(tile))}
|
{#each videoTiles.slice(offset, offset + row.columnCount) as tile (getVideoTileKey(tile))}
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden rounded-2xl shrink-0"
|
class="overflow-hidden rounded-2xl shrink-0"
|
||||||
style={`width: ${row.tileWidth}px; height: ${row.tileHeight}px`}>
|
style={`width: ${row.tileWidth}px; height: ${row.tileHeight}px`}>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import {listMediaDevices} from "@lib/audio"
|
||||||
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
|
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
|
||||||
import {currentCallSession, DeviceKind, type CallSession} from "@app/call"
|
import {currentCallSession, DeviceKind, type CallSession} from "@app/call"
|
||||||
import {switchCallActiveDevice} from "@app/callEngine"
|
import {switchCallActiveDevice} from "@app/callEngine"
|
||||||
|
|
@ -35,19 +36,11 @@
|
||||||
?.mediaStreamTrack
|
?.mediaStreamTrack
|
||||||
|
|
||||||
const loadDevices = async () => {
|
const loadDevices = async () => {
|
||||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
const devices = await listMediaDevices()
|
||||||
return
|
|
||||||
}
|
audioInputs = devices.filter(spec({kind: "audioinput"}))
|
||||||
try {
|
audioOutputs = devices.filter(spec({kind: "audiooutput"}))
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
videoInputs = devices.filter(spec({kind: "videoinput"}))
|
||||||
audioInputs = devices.filter(spec({kind: "audioinput"}))
|
|
||||||
audioOutputs = devices.filter(spec({kind: "audiooutput"}))
|
|
||||||
videoInputs = devices.filter(spec({kind: "videoinput"}))
|
|
||||||
} catch {
|
|
||||||
audioInputs = []
|
|
||||||
audioOutputs = []
|
|
||||||
videoInputs = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import {makeRoomKey} from "@welshman/app"
|
|
||||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
|
|
@ -14,12 +13,11 @@
|
||||||
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
import VoiceParticipantMediaBadges from "@app/components/VoiceParticipantMediaBadges.svelte"
|
||||||
import {
|
import {
|
||||||
CallState,
|
CallState,
|
||||||
callTargetRoom,
|
|
||||||
isParticipantSpeaking,
|
isParticipantSpeaking,
|
||||||
mediaStateByIdentity,
|
mediaStateByIdentity,
|
||||||
participantKey,
|
participantKey,
|
||||||
callState,
|
|
||||||
deriveCallParticipants,
|
deriveCallParticipants,
|
||||||
|
deriveRoomCallState,
|
||||||
loadCallParticipants,
|
loadCallParticipants,
|
||||||
type CallParticipant,
|
type CallParticipant,
|
||||||
} from "@app/call"
|
} from "@app/call"
|
||||||
|
|
@ -39,12 +37,9 @@
|
||||||
|
|
||||||
const participants = deriveCallParticipants(url, h)
|
const participants = deriveCallParticipants(url, h)
|
||||||
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
|
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
|
||||||
const isActive = $derived(
|
const roomCallState = deriveRoomCallState(url, h)
|
||||||
$callState === CallState.Connected && $callTargetRoom?.id === makeRoomKey(url, h),
|
const isActive = $derived($roomCallState === CallState.Connected)
|
||||||
)
|
const isJoining = $derived($roomCallState === CallState.Joining)
|
||||||
const isJoining = $derived(
|
|
||||||
$callState === CallState.Joining && $callTargetRoom?.id === makeRoomKey(url, h),
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClick = async (e: MouseEvent) => {
|
const handleClick = async (e: MouseEvent) => {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import {listMediaDevices} from "@lib/audio"
|
||||||
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
|
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
|
||||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
import {displayRoom} from "@app/rooms"
|
import {displayRoom} from "@app/rooms"
|
||||||
|
|
@ -37,15 +38,7 @@
|
||||||
let micError = $state(false)
|
let micError = $state(false)
|
||||||
|
|
||||||
const loadDevices = async () => {
|
const loadDevices = async () => {
|
||||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
audioInputs = (await listMediaDevices()).filter(spec({kind: "audioinput"}))
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
|
||||||
audioInputs = devices.filter(spec({kind: "audioinput"}))
|
|
||||||
} catch {
|
|
||||||
audioInputs = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import {getSetting} from "@app/settings"
|
import {watchAudioLevel} from "@lib/audio"
|
||||||
|
import {postOpenRouter} from "@app/openrouter"
|
||||||
|
|
||||||
const TRANSCRIPTION_MODEL = "openai/whisper-large-v3-turbo"
|
const TRANSCRIPTION_MODEL = "openai/whisper-large-v3-turbo"
|
||||||
|
|
||||||
|
|
@ -17,17 +18,8 @@ const transcribe = async (audio: File) => {
|
||||||
body.append("model", TRANSCRIPTION_MODEL)
|
body.append("model", TRANSCRIPTION_MODEL)
|
||||||
body.append("file", audio, audio.name)
|
body.append("file", audio, audio.name)
|
||||||
|
|
||||||
const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
|
const response = await postOpenRouter("audio/transcriptions", body)
|
||||||
method: "POST",
|
const {text}: {text?: string} = await response.json()
|
||||||
headers: {Authorization: `Bearer ${getSetting("openrouter_key")}`},
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
|
|
||||||
const {text, error}: {text?: string; error?: {message?: string}} = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(error?.message || `OpenRouter returned a ${response.status}.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return text?.trim() ?? ""
|
return text?.trim() ?? ""
|
||||||
}
|
}
|
||||||
|
|
@ -70,39 +62,17 @@ export const startDictation = async (key: string, onLevel: (level: number) => vo
|
||||||
recorder.addEventListener("dataavailable", event => chunks.push(event.data))
|
recorder.addEventListener("dataavailable", event => chunks.push(event.data))
|
||||||
recorder.start()
|
recorder.start()
|
||||||
|
|
||||||
const context = new AudioContext()
|
|
||||||
const analyser = context.createAnalyser()
|
|
||||||
|
|
||||||
analyser.fftSize = 512
|
|
||||||
|
|
||||||
context.createMediaStreamSource(stream).connect(analyser)
|
|
||||||
|
|
||||||
const samples = new Uint8Array(analyser.frequencyBinCount)
|
|
||||||
|
|
||||||
let level = 0
|
let level = 0
|
||||||
|
|
||||||
const measure = () => {
|
const stopMeter = watchAudioLevel(stream, rms => {
|
||||||
analyser.getByteTimeDomainData(samples)
|
level = Math.max(rms, level * 0.92)
|
||||||
|
|
||||||
let sumSquares = 0
|
|
||||||
|
|
||||||
for (const sample of samples) {
|
|
||||||
sumSquares += ((sample - 128) / 128) ** 2
|
|
||||||
}
|
|
||||||
|
|
||||||
level = Math.max(Math.sqrt(sumSquares / samples.length), level * 0.92)
|
|
||||||
|
|
||||||
onLevel(level)
|
onLevel(level)
|
||||||
|
})
|
||||||
frame = requestAnimationFrame(measure)
|
|
||||||
}
|
|
||||||
|
|
||||||
let frame = requestAnimationFrame(measure)
|
|
||||||
|
|
||||||
const audio = new Promise<File>(resolve => {
|
const audio = new Promise<File>(resolve => {
|
||||||
recorder.addEventListener("stop", () => {
|
recorder.addEventListener("stop", () => {
|
||||||
cancelAnimationFrame(frame)
|
stopMeter()
|
||||||
context.close()
|
|
||||||
|
|
||||||
for (const track of stream.getTracks()) {
|
for (const track of stream.getTracks()) {
|
||||||
track.stop()
|
track.stop()
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import type {Readable} from "svelte/store"
|
||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {sortBy, spec, int, MINUTE, ms} from "@welshman/lib"
|
import {sortBy, spec, int, MINUTE, ms} from "@welshman/lib"
|
||||||
import {makeHttpAuth, makeHttpAuthHeader, normalizeRelayUrl} from "@welshman/util"
|
import {normalizeRelayUrl} from "@welshman/util"
|
||||||
import {User} from "@welshman/app"
|
import {User} from "@welshman/app"
|
||||||
import type {IApp} from "@welshman/app"
|
import type {IApp} from "@welshman/app"
|
||||||
import {roomLists, usePlugin, user} from "@app/core"
|
import {roomLists, usePlugin, user} from "@app/core"
|
||||||
import {HOSTING_BACKEND_URL, HOSTING_RELAY_DOMAIN, PLATFORM_URL} from "@app/env"
|
import {HOSTING_BACKEND_URL, HOSTING_RELAY_DOMAIN, PLATFORM_URL} from "@app/env"
|
||||||
import {goToMovedSpace} from "@app/routes"
|
import {goToMovedSpace} from "@app/routes"
|
||||||
|
import {getHttpAuthHeader} from "@app/signer"
|
||||||
|
|
||||||
// Apple doesn't allow selling hosting outside their payment system.
|
// Apple doesn't allow selling hosting outside their payment system.
|
||||||
export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios"
|
export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios"
|
||||||
|
|
@ -180,32 +181,6 @@ export const hostingErrorMessage = (e: unknown, fallback: string) => {
|
||||||
|
|
||||||
// Requests
|
// Requests
|
||||||
|
|
||||||
const AUTH_TTL = ms(int(10, MINUTE))
|
|
||||||
|
|
||||||
// The backend checks the signature and `u` tag but not freshness, so one token per pubkey is reusable.
|
|
||||||
let cachedAuth: Maybe<{pubkey: string; expiresAt: number; header: Promise<string>}>
|
|
||||||
|
|
||||||
const getAuthHeader = (): Promise<string> => {
|
|
||||||
const {pubkey, signer} = user.get()
|
|
||||||
|
|
||||||
if (cachedAuth?.pubkey !== pubkey || cachedAuth.expiresAt < Date.now()) {
|
|
||||||
const header = makeHttpAuth(HOSTING_BACKEND_URL, "GET")
|
|
||||||
.then(template => signer.sign(template))
|
|
||||||
.then(makeHttpAuthHeader)
|
|
||||||
|
|
||||||
// A declined signature shouldn't be cached, or the user can't retry.
|
|
||||||
header.catch(() => {
|
|
||||||
if (cachedAuth?.header === header) {
|
|
||||||
cachedAuth = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
cachedAuth = {pubkey, expiresAt: Date.now() + AUTH_TTL, header}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cachedAuth.header
|
|
||||||
}
|
|
||||||
|
|
||||||
type HostingResponse<T> = {data: T; error?: string}
|
type HostingResponse<T> = {data: T; error?: string}
|
||||||
|
|
||||||
const hostingRequest = async (method: string, path: string, init: RequestInit = {}) =>
|
const hostingRequest = async (method: string, path: string, init: RequestInit = {}) =>
|
||||||
|
|
@ -214,7 +189,8 @@ const hostingRequest = async (method: string, path: string, init: RequestInit =
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Authorization: await getAuthHeader(),
|
// The backend checks the signature and `u` tag but not freshness, so a token is reusable.
|
||||||
|
Authorization: await getHttpAuthHeader(HOSTING_BACKEND_URL, int(10, MINUTE)),
|
||||||
...init.headers,
|
...init.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import {derived} from "svelte/store"
|
|
||||||
import {sortBy} from "@welshman/lib"
|
|
||||||
import {allNotifications, latestActivityByPath} from "@app/notifications"
|
|
||||||
|
|
||||||
// The inbox shows what the badges show, so reading a conversation takes it out of the list.
|
|
||||||
export const inboxConversations = derived(
|
|
||||||
[latestActivityByPath, allNotifications],
|
|
||||||
([$latestActivityByPath, $allNotifications]) =>
|
|
||||||
sortBy(
|
|
||||||
activity => -activity.event.created_at,
|
|
||||||
[...$latestActivityByPath.values()].filter(
|
|
||||||
activity => !activity.contentKind && $allNotifications.has(activity.path),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
export type SpaceContent = {
|
|
||||||
url: string
|
|
||||||
timestamp: number
|
|
||||||
countsByKind: Map<number, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counted only where unread, since a total would be the same number every day.
|
|
||||||
export const inboxSpaceContent = derived(
|
|
||||||
[latestActivityByPath, allNotifications],
|
|
||||||
([$latestActivityByPath, $allNotifications]) => {
|
|
||||||
const byUrl = new Map<string, SpaceContent>()
|
|
||||||
|
|
||||||
for (const {path, url, contentKind, event} of $latestActivityByPath.values()) {
|
|
||||||
if (url && contentKind && $allNotifications.has(path)) {
|
|
||||||
const content = byUrl.get(url) ?? {url, timestamp: 0, countsByKind: new Map()}
|
|
||||||
|
|
||||||
content.timestamp = Math.max(content.timestamp, event.created_at)
|
|
||||||
content.countsByKind.set(contentKind, (content.countsByKind.get(contentKind) ?? 0) + 1)
|
|
||||||
byUrl.set(url, content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortBy(content => -content.timestamp, [...byUrl.values()])
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
import {derived, get, writable} from "svelte/store"
|
import {derived, get, writable} from "svelte/store"
|
||||||
import {Badge} from "@capawesome/capacitor-badge"
|
import {Badge} from "@capawesome/capacitor-badge"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {assoc, first, identity, groupBy, now, remove, throttle, parseJson, gt} from "@welshman/lib"
|
|
||||||
import type {SignedEvent, TrustedEvent} from "@welshman/util"
|
|
||||||
import {
|
import {
|
||||||
getIdOrAddress,
|
assoc,
|
||||||
sortEventsDesc,
|
first,
|
||||||
tagSpec,
|
identity,
|
||||||
tagValue,
|
groupBy,
|
||||||
COMMENT,
|
now,
|
||||||
MESSAGE,
|
remove,
|
||||||
makeHttpAuth,
|
sortBy,
|
||||||
makeHttpAuthHeader,
|
throttle,
|
||||||
} from "@welshman/util"
|
parseJson,
|
||||||
|
gt,
|
||||||
|
int,
|
||||||
|
HOUR,
|
||||||
|
} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getIdOrAddress, sortEventsDesc, tagSpec, tagValue, COMMENT, MESSAGE} from "@welshman/util"
|
||||||
import {Comment} from "@welshman/domain"
|
import {Comment} from "@welshman/domain"
|
||||||
import {throttled} from "@welshman/store"
|
import {throttled} from "@welshman/store"
|
||||||
import {Events, Relays} from "@welshman/app"
|
import {Events, Relays} from "@welshman/app"
|
||||||
|
|
@ -24,6 +28,7 @@ import {Chats} from "@app/chats"
|
||||||
import {dufflepud, DUFFLEPUD_URL} from "@app/env"
|
import {dufflepud, DUFFLEPUD_URL} from "@app/env"
|
||||||
import {isGroupSpace} from "@app/relays"
|
import {isGroupSpace} from "@app/relays"
|
||||||
import {activeSpaceUrls} from "@app/rooms"
|
import {activeSpaceUrls} from "@app/rooms"
|
||||||
|
import {getHttpAuthHeader} from "@app/signer"
|
||||||
import {kv, syncedWithGetter} from "@app/storage"
|
import {kv, syncedWithGetter} from "@app/storage"
|
||||||
import {notificationSettings} from "@app/push/adapters/common"
|
import {notificationSettings} from "@app/push/adapters/common"
|
||||||
|
|
||||||
|
|
@ -65,14 +70,13 @@ export const syncChecked = () => {
|
||||||
|
|
||||||
// Set checked when we visit a given page - but delay it a tad
|
// Set checked when we visit a given page - but delay it a tad
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const defer = get(deferredRoomPath)
|
const $deferredRoomPath = get(deferredRoomPath)
|
||||||
|
|
||||||
checked.update($checked => {
|
checked.update($checked => {
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
if (defer && path === defer) {
|
if (path !== $deferredRoomPath) {
|
||||||
continue
|
$checked[path] = now()
|
||||||
}
|
}
|
||||||
$checked[path] = now()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $checked
|
return $checked
|
||||||
|
|
@ -84,31 +88,10 @@ export const syncChecked = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHECKED_KV_KEY = "checked"
|
const CHECKED_KV_KEY = "checked"
|
||||||
const NIP98_MAX_AGE = 23 * 60 * 60
|
const getAuthHeader = () => getHttpAuthHeader(DUFFLEPUD_URL, int(23, HOUR))
|
||||||
|
|
||||||
let nip98Auth: SignedEvent | undefined
|
|
||||||
|
|
||||||
const nip98Header = async () => {
|
|
||||||
const $signer = app.get().user?.signer
|
|
||||||
|
|
||||||
if (!$signer) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nip98Auth || now() - nip98Auth.created_at > NIP98_MAX_AGE) {
|
|
||||||
nip98Auth = await $signer.sign(await makeHttpAuth(DUFFLEPUD_URL, "GET"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return makeHttpAuthHeader(nip98Auth)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pullCheckedRemote = async () => {
|
const pullCheckedRemote = async () => {
|
||||||
const authorization = await nip98Header()
|
const authorization = await getAuthHeader()
|
||||||
|
|
||||||
if (!authorization) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(dufflepud(`kv/${CHECKED_KV_KEY}`), {headers: {authorization}})
|
const res = await fetch(dufflepud(`kv/${CHECKED_KV_KEY}`), {headers: {authorization}})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -133,11 +116,7 @@ const pullCheckedRemote = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const pushCheckedRemote = throttle(3000, async () => {
|
const pushCheckedRemote = throttle(3000, async () => {
|
||||||
const authorization = await nip98Header()
|
const authorization = await getAuthHeader()
|
||||||
|
|
||||||
if (!authorization) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(dufflepud(`kv/${CHECKED_KV_KEY}`), {
|
await fetch(dufflepud(`kv/${CHECKED_KV_KEY}`), {
|
||||||
|
|
@ -155,7 +134,6 @@ export const syncCheckedRemote = () => {
|
||||||
|
|
||||||
const unsubscribeUser = app.subscribe($app => {
|
const unsubscribeUser = app.subscribe($app => {
|
||||||
ready = false
|
ready = false
|
||||||
nip98Auth = undefined
|
|
||||||
|
|
||||||
if ($app.user) {
|
if ($app.user) {
|
||||||
pullCheckedRemote().then(() => {
|
pullCheckedRemote().then(() => {
|
||||||
|
|
@ -331,15 +309,9 @@ export const notifications = derived(
|
||||||
[page, allNotifications, deferredRoomPath],
|
[page, allNotifications, deferredRoomPath],
|
||||||
([$page, $allNotifications, $deferredRoomPath]) =>
|
([$page, $allNotifications, $deferredRoomPath]) =>
|
||||||
new Set(
|
new Set(
|
||||||
[...$allNotifications].filter(p => {
|
[...$allNotifications].filter(
|
||||||
if (!$page.url.pathname.startsWith(p)) {
|
p => !$page.url.pathname.startsWith(p) || p === $deferredRoomPath,
|
||||||
return true
|
),
|
||||||
}
|
|
||||||
if ($deferredRoomPath && p === $deferredRoomPath) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -381,3 +353,43 @@ export const clearBadges = async () => {
|
||||||
// pass - firefox doesn't support this
|
// pass - firefox doesn't support this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inbox
|
||||||
|
|
||||||
|
// The inbox shows what the badges show, so reading a conversation takes it out of the list.
|
||||||
|
export const inboxConversations = derived(
|
||||||
|
[latestActivityByPath, allNotifications],
|
||||||
|
([$latestActivityByPath, $allNotifications]) =>
|
||||||
|
sortBy(
|
||||||
|
activity => -activity.event.created_at,
|
||||||
|
[...$latestActivityByPath.values()].filter(
|
||||||
|
activity => !activity.contentKind && $allNotifications.has(activity.path),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export type SpaceContent = {
|
||||||
|
url: string
|
||||||
|
timestamp: number
|
||||||
|
countsByKind: Map<number, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counted only where unread, since a total would be the same number every day.
|
||||||
|
export const inboxSpaceContent = derived(
|
||||||
|
[latestActivityByPath, allNotifications],
|
||||||
|
([$latestActivityByPath, $allNotifications]) => {
|
||||||
|
const byUrl = new Map<string, SpaceContent>()
|
||||||
|
|
||||||
|
for (const {path, url, contentKind, event} of $latestActivityByPath.values()) {
|
||||||
|
if (url && contentKind && $allNotifications.has(path)) {
|
||||||
|
const content = byUrl.get(url) ?? {url, timestamp: 0, countsByKind: new Map()}
|
||||||
|
|
||||||
|
content.timestamp = Math.max(content.timestamp, event.created_at)
|
||||||
|
content.countsByKind.set(contentKind, (content.countsByKind.get(contentKind) ?? 0) + 1)
|
||||||
|
byUrl.set(url, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy(content => -content.timestamp, [...byUrl.values()])
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
25
src/app/openrouter.ts
Normal file
25
src/app/openrouter.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {getSetting} from "@app/settings"
|
||||||
|
|
||||||
|
// A successful response can be audio rather than json, so only a failure is read as json.
|
||||||
|
export const postOpenRouter = async (
|
||||||
|
path: string,
|
||||||
|
body: BodyInit,
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
) => {
|
||||||
|
const response = await fetch(`https://openrouter.ai/api/v1/${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {Authorization: `Bearer ${getSetting("openrouter_key")}`, ...headers},
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload: Maybe<{error?: {message?: string}}> = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => undefined)
|
||||||
|
|
||||||
|
throw new Error(payload?.error?.message || `OpenRouter returned a ${response.status}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
@ -4,15 +4,18 @@ import {assoc, hash, maybe, ms, poll} from "@welshman/lib"
|
||||||
import type {Filter} from "@welshman/util"
|
import type {Filter} from "@welshman/util"
|
||||||
import {Address, DELETE, makeEvent} from "@welshman/util"
|
import {Address, DELETE, makeEvent} from "@welshman/util"
|
||||||
import {Relays, User} from "@welshman/app"
|
import {Relays, User} from "@welshman/app"
|
||||||
import {buildUrl} from "@lib/util"
|
|
||||||
import {app, messagingRelayLists, roomLists, thunks} from "@app/core"
|
import {app, messagingRelayLists, roomLists, thunks} from "@app/core"
|
||||||
import {device} from "@app/device"
|
import {device} from "@app/device"
|
||||||
import {PUSH_BRIDGE, PUSH_SERVER} from "@app/env"
|
import {PUSH_BRIDGE} from "@app/env"
|
||||||
import {pushState} from "@app/push/adapters/common"
|
import {pushState} from "@app/push/adapters/common"
|
||||||
import type {IPushAdapter} from "@app/push/adapters/common"
|
import type {IPushAdapter} from "@app/push/adapters/common"
|
||||||
import {onPushNotificationAction, syncRelaySubscriptions} from "@app/push/adapters/common"
|
import {onPushNotificationAction, syncRelaySubscriptions} from "@app/push/adapters/common"
|
||||||
|
import {createPushSubscription, deletePushSubscription} from "@app/push/server"
|
||||||
import {syncEventReminders} from "@app/reminders"
|
import {syncEventReminders} from "@app/reminders"
|
||||||
|
|
||||||
|
// NIP-9a: asks a relay to post matching events to the push server's callback
|
||||||
|
const PUSH_SUBSCRIPTION = 30390
|
||||||
|
|
||||||
const requestPermissions = async () => {
|
const requestPermissions = async () => {
|
||||||
let status = await PushNotifications.checkPermissions()
|
let status = await PushNotifications.checkPermissions()
|
||||||
|
|
||||||
|
|
@ -71,16 +74,7 @@ export class CapacitorNotifications implements IPushAdapter {
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
try {
|
try {
|
||||||
const channel = Capacitor.getPlatform() === "ios" ? "apns" : "fcm"
|
const channel = Capacitor.getPlatform() === "ios" ? "apns" : "fcm"
|
||||||
const url = buildUrl(PUSH_SERVER, "subscription", channel)
|
const res = await createPushSubscription(channel, token, signal)
|
||||||
const res = await fetch(url, {
|
|
||||||
signal,
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({token}),
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.warn(`Failed to register with push server (status ${res.status})`)
|
console.warn(`Failed to register with push server (status ${res.status})`)
|
||||||
|
|
@ -131,7 +125,7 @@ export class CapacitorNotifications implements IPushAdapter {
|
||||||
|
|
||||||
const thunk = thunks.get().publish({
|
const thunk = thunks.get().publish({
|
||||||
relays: [url],
|
relays: [url],
|
||||||
event: makeEvent(30390, {
|
event: makeEvent(PUSH_SUBSCRIPTION, {
|
||||||
tags: [
|
tags: [
|
||||||
["d", identifier],
|
["d", identifier],
|
||||||
["relay", relay],
|
["relay", relay],
|
||||||
|
|
@ -160,7 +154,11 @@ export class CapacitorNotifications implements IPushAdapter {
|
||||||
|
|
||||||
const relays = [url]
|
const relays = [url]
|
||||||
const identifier = this._getSubscriptionIdentifier(relay, key)
|
const identifier = this._getSubscriptionIdentifier(relay, key)
|
||||||
const address = new Address(30390, User.require(app.get()).pubkey, identifier).toString()
|
const address = new Address(
|
||||||
|
PUSH_SUBSCRIPTION,
|
||||||
|
User.require(app.get()).pubkey,
|
||||||
|
identifier,
|
||||||
|
).toString()
|
||||||
const event = makeEvent(DELETE, {tags: [["a", address]]})
|
const event = makeEvent(DELETE, {tags: [["a", address]]})
|
||||||
const error = await thunks.get().publish({relays, event}).waitForError()
|
const error = await thunks.get().publish({relays, event}).waitForError()
|
||||||
|
|
||||||
|
|
@ -204,9 +202,7 @@ export class CapacitorNotifications implements IPushAdapter {
|
||||||
const {subscription} = pushState.get()
|
const {subscription} = pushState.get()
|
||||||
|
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
const res = await fetch(buildUrl(PUSH_SERVER, "subscription", subscription.key), {
|
const res = await deletePushSubscription(subscription.key)
|
||||||
method: "delete",
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.warn("Failed to delete push subscription")
|
console.warn("Failed to delete push subscription")
|
||||||
|
|
|
||||||
40
src/app/push/server.ts
Normal file
40
src/app/push/server.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type {SignedEvent} from "@welshman/util"
|
||||||
|
import {buildUrl} from "@lib/util"
|
||||||
|
import {PUSH_SERVER} from "@app/env"
|
||||||
|
|
||||||
|
export const createPushSubscription = (channel: string, token: string, signal: AbortSignal) =>
|
||||||
|
fetch(buildUrl(PUSH_SERVER, "subscription", channel), {
|
||||||
|
signal,
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({token}),
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deletePushSubscription = (key: string) =>
|
||||||
|
fetch(buildUrl(PUSH_SERVER, "subscription", key), {method: "delete"})
|
||||||
|
|
||||||
|
export type Reminder = {
|
||||||
|
at: number
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReminderSchedule = {
|
||||||
|
id: string
|
||||||
|
relay: string
|
||||||
|
event?: SignedEvent
|
||||||
|
notifications: Reminder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const putReminderSchedule = (key: string, schedule: ReminderSchedule) =>
|
||||||
|
fetch(buildUrl(PUSH_SERVER, "subscription", key, "schedule"), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {Accept: "application/json", "Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify(schedule),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteReminderSchedule = (key: string) =>
|
||||||
|
fetch(buildUrl(PUSH_SERVER, "subscription", key, "schedule"), {method: "DELETE"})
|
||||||
|
|
@ -47,51 +47,49 @@ export const deriveSocket = (url: string) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum RelayConnection {
|
||||||
|
Connecting = "connecting",
|
||||||
|
Disconnected = "disconnected",
|
||||||
|
ConnectFailed = "connect-failed",
|
||||||
|
Authenticating = "authenticating",
|
||||||
|
AuthFailed = "auth-failed",
|
||||||
|
AccessDenied = "access-denied",
|
||||||
|
Connected = "connected",
|
||||||
|
}
|
||||||
|
|
||||||
export const deriveSocketStatus = (url: string) =>
|
export const deriveSocketStatus = (url: string) =>
|
||||||
throttled(
|
throttled(
|
||||||
800,
|
800,
|
||||||
derived([deriveSocket(url), relaysMostlyRestricted], ([$socket, $relaysMostlyRestricted]) => {
|
derived([deriveSocket(url), relaysMostlyRestricted], ([$socket, $relaysMostlyRestricted]) => {
|
||||||
if ($socket.status === SocketStatus.Opening) {
|
if ($socket.status === SocketStatus.Opening) {
|
||||||
return {theme: "warning", title: "Connecting"}
|
return RelayConnection.Connecting
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($socket.status === SocketStatus.Closing) {
|
if ($socket.status === SocketStatus.Closing || $socket.status === SocketStatus.Closed) {
|
||||||
return {theme: "gray-500", title: "Not Connected"}
|
return RelayConnection.Disconnected
|
||||||
}
|
|
||||||
|
|
||||||
if ($socket.status === SocketStatus.Closed) {
|
|
||||||
return {theme: "gray-500", title: "Not Connected"}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($socket.status === SocketStatus.Error) {
|
if ($socket.status === SocketStatus.Error) {
|
||||||
return {theme: "error", title: "Failed to Connect"}
|
return RelayConnection.ConnectFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($socket.auth.status === AuthStatus.Requested) {
|
if (
|
||||||
return {theme: "warning", title: "Authenticating"}
|
$socket.auth.status === AuthStatus.Requested ||
|
||||||
}
|
$socket.auth.status === AuthStatus.PendingSignature ||
|
||||||
|
$socket.auth.status === AuthStatus.PendingResponse
|
||||||
if ($socket.auth.status === AuthStatus.PendingSignature) {
|
) {
|
||||||
return {theme: "warning", title: "Authenticating"}
|
return RelayConnection.Authenticating
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($socket.auth.status === AuthStatus.DeniedSignature) {
|
if ($socket.auth.status === AuthStatus.DeniedSignature) {
|
||||||
return {theme: "error", title: "Failed to Authenticate"}
|
return RelayConnection.AuthFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($socket.auth.status === AuthStatus.PendingResponse) {
|
if ($socket.auth.status === AuthStatus.Forbidden || $relaysMostlyRestricted[url]) {
|
||||||
return {theme: "warning", title: "Authenticating"}
|
return RelayConnection.AccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($socket.auth.status === AuthStatus.Forbidden) {
|
return RelayConnection.Connected
|
||||||
return {theme: "error", title: "Access Denied"}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($relaysMostlyRestricted[url]) {
|
|
||||||
return {theme: "error", title: "Access Denied"}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {theme: "success", title: "Connected"}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,11 @@ import {MINUTE, first, formatTimestampRelative, int, ms, now, uniq} from "@welsh
|
||||||
import {EVENT_RSVP, getIdFilters, isSignedEvent} from "@welshman/util"
|
import {EVENT_RSVP, getIdFilters, isSignedEvent} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {CalendarRsvp} from "@welshman/domain"
|
import {CalendarRsvp} from "@welshman/domain"
|
||||||
import {buildUrl} from "@lib/util"
|
|
||||||
import {getEventStart, getEventTitle, isAttending} from "@app/calendar"
|
import {getEventStart, getEventTitle, isAttending} from "@app/calendar"
|
||||||
import {app, network, reader, roomLists} from "@app/core"
|
import {app, network, reader, roomLists} from "@app/core"
|
||||||
import {PUSH_SERVER} from "@app/env"
|
|
||||||
import {notificationSettings, pushState} from "@app/push/adapters/common"
|
import {notificationSettings, pushState} from "@app/push/adapters/common"
|
||||||
|
import {deleteReminderSchedule, putReminderSchedule} from "@app/push/server"
|
||||||
type Reminder = {
|
import type {Reminder} from "@app/push/server"
|
||||||
at: number
|
|
||||||
title: string
|
|
||||||
body: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const getScheduleUrl = () => {
|
|
||||||
const {subscription} = pushState.get()
|
|
||||||
|
|
||||||
return subscription && buildUrl(PUSH_SERVER, "subscription", subscription.key, "schedule")
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLeadTimes = () => {
|
const getLeadTimes = () => {
|
||||||
const {events, reminder_minutes} = notificationSettings.get()
|
const {events, reminder_minutes} = notificationSettings.get()
|
||||||
|
|
@ -36,18 +24,14 @@ const makeReminders = (event: TrustedEvent, start: number): Reminder[] =>
|
||||||
.filter(({at}) => at > now())
|
.filter(({at}) => at > now())
|
||||||
|
|
||||||
const putSchedule = async (relay: string, event: TrustedEvent, reminders: Reminder[]) => {
|
const putSchedule = async (relay: string, event: TrustedEvent, reminders: Reminder[]) => {
|
||||||
const url = getScheduleUrl()
|
const {subscription} = pushState.get()
|
||||||
|
|
||||||
if (url) {
|
if (subscription) {
|
||||||
const res = await fetch(url, {
|
const res = await putReminderSchedule(subscription.key, {
|
||||||
method: "PUT",
|
id: event.id,
|
||||||
headers: {Accept: "application/json", "Content-Type": "application/json"},
|
relay,
|
||||||
body: JSON.stringify({
|
event: isSignedEvent(event) ? event : undefined,
|
||||||
id: event.id,
|
notifications: reminders,
|
||||||
relay,
|
|
||||||
event: isSignedEvent(event) ? event : undefined,
|
|
||||||
notifications: reminders,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -97,11 +81,11 @@ const loadAttendedEvents = async (pubkey: string) => {
|
||||||
|
|
||||||
// Every schedule the push server holds was built from these settings, so redo the lot.
|
// Every schedule the push server holds was built from these settings, so redo the lot.
|
||||||
export const syncEventReminders = async () => {
|
export const syncEventReminders = async () => {
|
||||||
const url = getScheduleUrl()
|
const {subscription} = pushState.get()
|
||||||
const pubkey = app.get().user?.pubkey
|
const pubkey = app.get().user?.pubkey
|
||||||
|
|
||||||
if (url && pubkey) {
|
if (subscription && pubkey) {
|
||||||
const res = await fetch(url, {method: "DELETE"})
|
const res = await deleteReminderSchedule(subscription.key)
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.warn(`Failed to clear event reminders (status ${res.status})`)
|
console.warn(`Failed to clear event reminders (status ${res.status})`)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type {Writable} from "svelte/store"
|
import type {Writable} from "svelte/store"
|
||||||
import {derived, writable} from "svelte/store"
|
import {derived, writable} from "svelte/store"
|
||||||
import {append, equals, remove, spec} from "@welshman/lib"
|
import {append, equals, remove, spec, toggle} from "@welshman/lib"
|
||||||
import {APP_DATA} from "@welshman/util"
|
import {APP_DATA} from "@welshman/util"
|
||||||
import {withGetter} from "@welshman/store"
|
import {withGetter} from "@welshman/store"
|
||||||
import {AppData} from "@welshman/domain"
|
import {AppData} from "@welshman/domain"
|
||||||
|
|
@ -180,44 +180,24 @@ export const addTrustedRelay = (url: string) =>
|
||||||
export const removeTrustedRelay = (url: string) =>
|
export const removeTrustedRelay = (url: string) =>
|
||||||
publishSettings({trusted_relays: remove(url, getSetting("trusted_relays"))})
|
publishSettings({trusted_relays: remove(url, getSetting("trusted_relays"))})
|
||||||
|
|
||||||
export const setSpaceNotifications = (url: string, notify: boolean) => {
|
const updateSpaceAlert = (
|
||||||
|
url: string,
|
||||||
|
update: (alert: SpaceNotificationSettings) => SpaceNotificationSettings,
|
||||||
|
) => {
|
||||||
const alerts = getSetting("alerts")
|
const alerts = getSetting("alerts")
|
||||||
const existing = alerts.find(spec({url}))
|
|
||||||
|
|
||||||
if (existing) {
|
if (alerts.some(spec({url}))) {
|
||||||
return publishSettings({
|
return publishSettings({alerts: alerts.map(s => (s.url === url ? update(s) : s))})
|
||||||
alerts: alerts.map(s => (s.url === url ? {...s, notify, exceptions: []} : s)),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return publishSettings({alerts: [...alerts, {url, notify, exceptions: []}]})
|
return publishSettings({alerts: [...alerts, update({url, notify: true, exceptions: []})]})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const toggleRoomNotifications = (url: string, h: string) => {
|
export const setSpaceNotifications = (url: string, notify: boolean) =>
|
||||||
const alerts = getSetting("alerts")
|
updateSpaceAlert(url, alert => ({...alert, notify, exceptions: []}))
|
||||||
const existing = alerts.find(spec({url}))
|
|
||||||
|
|
||||||
if (existing) {
|
export const toggleRoomNotifications = (url: string, h: string) =>
|
||||||
const exceptions = existing.exceptions.includes(h)
|
updateSpaceAlert(url, alert => ({...alert, exceptions: toggle(h, alert.exceptions)}))
|
||||||
? remove(h, existing.exceptions)
|
|
||||||
: append(h, existing.exceptions)
|
|
||||||
|
|
||||||
return publishSettings({alerts: alerts.map(s => (s.url === url ? {...s, exceptions} : s))})
|
export const toggleRoomMuted = (url: string, h: string) =>
|
||||||
}
|
updateSpaceAlert(url, alert => ({...alert, muted: toggle(h, alert.muted ?? [])}))
|
||||||
|
|
||||||
return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [h]}]})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toggleRoomMuted = (url: string, h: string) => {
|
|
||||||
const alerts = getSetting("alerts")
|
|
||||||
const existing = alerts.find(spec({url}))
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const current = existing.muted ?? []
|
|
||||||
const muted = current.includes(h) ? remove(h, current) : append(h, current)
|
|
||||||
|
|
||||||
return publishSettings({alerts: alerts.map(s => (s.url === url ? {...s, muted} : s))})
|
|
||||||
}
|
|
||||||
|
|
||||||
return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [], muted: [h]}]})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import {derived, get, readable} from "svelte/store"
|
import {derived, get, readable} from "svelte/store"
|
||||||
import {ago, avg, ms, now, spec} from "@welshman/lib"
|
import {ago, avg, ms, now, spec} from "@welshman/lib"
|
||||||
|
import {makeHttpAuth, makeHttpAuthHeader} from "@welshman/util"
|
||||||
import {Logger} from "@welshman/app"
|
import {Logger} from "@welshman/app"
|
||||||
import type {LogMessage} from "@welshman/app"
|
import type {LogMessage} from "@welshman/app"
|
||||||
import {fromApp} from "@app/core"
|
import {fromApp, user} from "@app/core"
|
||||||
import {navigate} from "@app/modal"
|
import {navigate} from "@app/modal"
|
||||||
import {pushToast, toast} from "@app/toast"
|
import {pushToast, toast} from "@app/toast"
|
||||||
|
|
||||||
|
|
@ -83,3 +84,33 @@ export const syncSignerAlerts = () =>
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const httpAuthByUrl = new Map<
|
||||||
|
string,
|
||||||
|
{pubkey: string; expiresAt: number; header: Promise<string>}
|
||||||
|
>()
|
||||||
|
|
||||||
|
// A NIP-98 GET token for url, reused for maxAge seconds by the same pubkey where the server allows it.
|
||||||
|
export const getHttpAuthHeader = (url: string, maxAge = 0) => {
|
||||||
|
const {pubkey, signer} = user.get()
|
||||||
|
const cached = httpAuthByUrl.get(url)
|
||||||
|
|
||||||
|
if (cached?.pubkey === pubkey && cached.expiresAt > now()) {
|
||||||
|
return cached.header
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = makeHttpAuth(url, "GET")
|
||||||
|
.then(template => signer.sign(template))
|
||||||
|
.then(makeHttpAuthHeader)
|
||||||
|
|
||||||
|
// A declined signature shouldn't be cached, or the user can't retry.
|
||||||
|
header.catch(() => {
|
||||||
|
if (httpAuthByUrl.get(url)?.header === header) {
|
||||||
|
httpAuthByUrl.delete(url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
httpAuthByUrl.set(url, {pubkey, expiresAt: now() + maxAge, header})
|
||||||
|
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import {get, writable} from "svelte/store"
|
import {get, writable} from "svelte/store"
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {toWav} from "@lib/audio"
|
||||||
import {errorMessage} from "@lib/util"
|
import {errorMessage} from "@lib/util"
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import OpenRouterEnable from "@app/components/OpenRouterEnable.svelte"
|
import OpenRouterEnable from "@app/components/OpenRouterEnable.svelte"
|
||||||
import {endCall, isCallActive} from "@app/call"
|
import {endCall, isCallActive} from "@app/call"
|
||||||
import {profiles} from "@app/core"
|
import {profiles} from "@app/core"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
import {postOpenRouter} from "@app/openrouter"
|
||||||
import {renderEventAsText} from "@app/render"
|
import {renderEventAsText} from "@app/render"
|
||||||
import {getSetting} from "@app/settings"
|
import {getSetting} from "@app/settings"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
@ -21,10 +23,6 @@ const SPEECH_FORMAT = "mp3"
|
||||||
// Decoding resamples to the context's rate, so this is the rate the wav ends up at.
|
// Decoding resamples to the context's rate, so this is the rate the wav ends up at.
|
||||||
const SPEECH_RATE = 48000
|
const SPEECH_RATE = 48000
|
||||||
|
|
||||||
const SPEECH_BIT_DEPTH = 16
|
|
||||||
|
|
||||||
const WAV_HEADER_LENGTH = 44
|
|
||||||
|
|
||||||
export type Speech = {
|
export type Speech = {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -37,70 +35,17 @@ export const speech = writable<Maybe<Speech>>(undefined)
|
||||||
const decode = (data: ArrayBuffer) =>
|
const decode = (data: ArrayBuffer) =>
|
||||||
new OfflineAudioContext(1, 1, SPEECH_RATE).decodeAudioData(data)
|
new OfflineAudioContext(1, 1, SPEECH_RATE).decodeAudioData(data)
|
||||||
|
|
||||||
const toWav = (audio: AudioBuffer) => {
|
|
||||||
const {numberOfChannels, sampleRate, length} = audio
|
|
||||||
const bytesPerFrame = (numberOfChannels * SPEECH_BIT_DEPTH) / 8
|
|
||||||
const dataLength = length * bytesPerFrame
|
|
||||||
const wav = new ArrayBuffer(WAV_HEADER_LENGTH + dataLength)
|
|
||||||
const view = new DataView(wav)
|
|
||||||
const ascii = (offset: number, value: string) => {
|
|
||||||
for (let i = 0; i < value.length; i++) {
|
|
||||||
view.setUint8(offset + i, value.charCodeAt(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ascii(0, "RIFF")
|
|
||||||
view.setUint32(4, 36 + dataLength, true)
|
|
||||||
ascii(8, "WAVEfmt ")
|
|
||||||
view.setUint32(16, 16, true)
|
|
||||||
view.setUint16(20, 1, true)
|
|
||||||
view.setUint16(22, numberOfChannels, true)
|
|
||||||
view.setUint32(24, sampleRate, true)
|
|
||||||
view.setUint32(28, sampleRate * bytesPerFrame, true)
|
|
||||||
view.setUint16(32, bytesPerFrame, true)
|
|
||||||
view.setUint16(34, SPEECH_BIT_DEPTH, true)
|
|
||||||
ascii(36, "data")
|
|
||||||
view.setUint32(40, dataLength, true)
|
|
||||||
|
|
||||||
const channels = Array.from({length: numberOfChannels}, (_, i) => audio.getChannelData(i))
|
|
||||||
|
|
||||||
let offset = WAV_HEADER_LENGTH
|
|
||||||
|
|
||||||
for (let frame = 0; frame < length; frame++) {
|
|
||||||
for (const samples of channels) {
|
|
||||||
// A decoded sample runs from -1 to 1, and the two ends of a signed 16 bit range differ in size.
|
|
||||||
const sample = Math.max(-1, Math.min(1, samples[frame]))
|
|
||||||
|
|
||||||
view.setInt16(offset, Math.round(sample * (sample < 0 ? 0x8000 : 0x7fff)), true)
|
|
||||||
|
|
||||||
offset += 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Blob([wav], {type: "audio/wav"})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const synthesize = async (text: string) => {
|
export const synthesize = async (text: string) => {
|
||||||
const response = await fetch("https://openrouter.ai/api/v1/audio/speech", {
|
const response = await postOpenRouter(
|
||||||
method: "POST",
|
"audio/speech",
|
||||||
headers: {
|
JSON.stringify({
|
||||||
Authorization: `Bearer ${getSetting("openrouter_key")}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: SPEECH_MODEL,
|
model: SPEECH_MODEL,
|
||||||
voice: SPEECH_VOICE,
|
voice: SPEECH_VOICE,
|
||||||
input: text,
|
input: text,
|
||||||
response_format: SPEECH_FORMAT,
|
response_format: SPEECH_FORMAT,
|
||||||
}),
|
}),
|
||||||
})
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
// A successful response is audio rather than json.
|
|
||||||
if (!response.ok) {
|
|
||||||
const {error} = await response.json().catch(() => ({error: undefined}))
|
|
||||||
|
|
||||||
throw new Error(error?.message || `OpenRouter returned a ${response.status}.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return toWav(await decode(await response.arrayBuffer()))
|
return toWav(await decode(await response.arrayBuffer()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ import type {RepositoryUpdate, WrapItem} from "@welshman/net"
|
||||||
import {Relay, Zapper} from "@welshman/domain"
|
import {Relay, Zapper} from "@welshman/domain"
|
||||||
import type {RelayInfo, ZapperValues} from "@welshman/domain"
|
import type {RelayInfo, ZapperValues} from "@welshman/domain"
|
||||||
import {Handles, Plaintext, Relays, RelayStats, User, Zappers} from "@welshman/app"
|
import {Handles, Plaintext, Relays, RelayStats, User, Zappers} from "@welshman/app"
|
||||||
import type {AppPolicy, IApp, RelayStatsItem} from "@welshman/app"
|
import type {AppPolicy, IApp, MapPlugin, RelayStatsItem} from "@welshman/app"
|
||||||
import {IDB} from "@lib/indexeddb"
|
import {IDB} from "@lib/indexeddb"
|
||||||
import {appPolicies} from "@app/core"
|
import {appPolicies} from "@app/core"
|
||||||
import {DM_KINDS} from "@app/content"
|
import {DM_KINDS} from "@app/content"
|
||||||
|
|
@ -60,37 +60,51 @@ export const syncedWithGetter = <T>(config: SyncedConfig<T>) => {
|
||||||
return {...withGetter(store), ready: store.ready}
|
return {...withGetter(store), ready: store.ready}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const kv = call(() => {
|
type StringStorage = {
|
||||||
|
get: (key: string) => Promise<Maybe<string>>
|
||||||
|
set: (key: string, value: string) => Promise<void>
|
||||||
|
remove: (key: string) => Promise<void>
|
||||||
|
clear: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeJsonStore = (storage: StringStorage) => {
|
||||||
const enqueue = makeQueue()
|
const enqueue = makeQueue()
|
||||||
|
|
||||||
const get = async <T>(key: string): Promise<T | undefined> => {
|
const get = async <T>(key: string): Promise<T | undefined> => {
|
||||||
const result = await Preferences.get({key})
|
const value = await storage.get(key)
|
||||||
if (!result.value) {
|
|
||||||
return undefined
|
if (value) {
|
||||||
}
|
try {
|
||||||
try {
|
return JSON.parse(value)
|
||||||
return JSON.parse(result.value)
|
} catch (e) {
|
||||||
} catch (e) {
|
return undefined
|
||||||
return undefined
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const set = async <T>(key: string, value: T): Promise<void> => {
|
const set = async <T>(key: string, value: T): Promise<void> => {
|
||||||
await enqueue(() => Preferences.set({key, value: JSON.stringify(value)}))
|
await enqueue(() => storage.set(key, JSON.stringify(value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove = async (key: string) => {
|
const remove = async (key: string) => {
|
||||||
await enqueue(() => Preferences.remove({key}))
|
await enqueue(() => storage.remove(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
const clear = async () => {
|
const clear = async () => {
|
||||||
await enqueue(() => Preferences.clear())
|
await enqueue(() => storage.clear())
|
||||||
}
|
}
|
||||||
|
|
||||||
return {get, set, remove, clear}
|
return {get, set, remove, clear}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const kv = makeJsonStore({
|
||||||
|
get: async key => (await Preferences.get({key})).value ?? undefined,
|
||||||
|
set: (key, value) => Preferences.set({key, value}),
|
||||||
|
remove: key => Preferences.remove({key}),
|
||||||
|
clear: () => Preferences.clear(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const secretStorage =
|
const secretStorage: StringStorage =
|
||||||
Capacitor.getPlatform() === "electron"
|
Capacitor.getPlatform() === "electron"
|
||||||
? call(() => {
|
? call(() => {
|
||||||
const plugin = registerPlugin<{
|
const plugin = registerPlugin<{
|
||||||
|
|
@ -101,14 +115,15 @@ const secretStorage =
|
||||||
}>("DesktopSecureStorage")
|
}>("DesktopSecureStorage")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get: async (key: string) => (await plugin.get({key})).value,
|
get: async key => (await plugin.get({key})).value,
|
||||||
set: (key: string, value: string | undefined) =>
|
set: (key, value) =>
|
||||||
value === undefined ? plugin.remove({key}) : plugin.set({key, value}),
|
value === undefined ? plugin.remove({key}) : plugin.set({key, value}),
|
||||||
|
remove: key => plugin.remove({key}),
|
||||||
clear: () => plugin.clear(),
|
clear: () => plugin.clear(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
: {
|
: {
|
||||||
get: async (key: string) => {
|
get: async key => {
|
||||||
let value = await SecureStorage.getItem(key)
|
let value = await SecureStorage.getItem(key)
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|
@ -121,40 +136,16 @@ const secretStorage =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
return value ?? undefined
|
||||||
},
|
},
|
||||||
// Android's SecureStorage rejects undefined
|
// Android's SecureStorage rejects undefined
|
||||||
set: (key: string, value: string | undefined) =>
|
set: (key, value) =>
|
||||||
value === undefined ? SecureStorage.removeItem(key) : SecureStorage.setItem(key, value),
|
value === undefined ? SecureStorage.removeItem(key) : SecureStorage.setItem(key, value),
|
||||||
|
remove: key => SecureStorage.removeItem(key),
|
||||||
clear: () => SecureStorage.clear(),
|
clear: () => SecureStorage.clear(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ss = call(() => {
|
export const ss = makeJsonStore(secretStorage)
|
||||||
const enqueue = makeQueue()
|
|
||||||
|
|
||||||
const get = async <T>(key: string): Promise<T | undefined> => {
|
|
||||||
const value = await secretStorage.get(key)
|
|
||||||
if (!value) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(value)
|
|
||||||
} catch (e) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const set = async <T>(key: string, value: T): Promise<void> => {
|
|
||||||
await enqueue(() => secretStorage.set(key, JSON.stringify(value)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const clear = async () => {
|
|
||||||
await enqueue(() => secretStorage.clear())
|
|
||||||
}
|
|
||||||
|
|
||||||
return {get, set, clear}
|
|
||||||
})
|
|
||||||
|
|
||||||
const TABLES = [
|
const TABLES = [
|
||||||
{name: "events", keyPath: "id"},
|
{name: "events", keyPath: "id"},
|
||||||
|
|
@ -422,94 +413,72 @@ class Storage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private initRelays = async () => {
|
private persistMapPlugin = async <T, Row>(
|
||||||
const table = this.db.table<RelayInfo & {url: string}>("relays")
|
name: string,
|
||||||
|
plugin: MapPlugin<T>,
|
||||||
|
fromRow: (row: Row) => Maybe<[string, T]>,
|
||||||
|
toRow: (key: string, item: T) => Row,
|
||||||
|
) => {
|
||||||
|
const table = this.db.table<Row>(name)
|
||||||
|
|
||||||
for (const row of await table.getAll()) {
|
for (const row of await table.getAll()) {
|
||||||
this.app.use(Relays).set(row.url, new Relay(row.url, row))
|
const entry = fromRow(row)
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
|
plugin.set(...entry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const enqueue = batch(
|
const enqueue = batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))
|
||||||
FLUSH_INTERVAL,
|
|
||||||
idleWrite((relays: Relay[]) => table.bulkPut(relays.map(relay => ({...relay})))),
|
return plugin.onItem((key, item) => {
|
||||||
|
if (item) {
|
||||||
|
enqueue(toRow(key, item))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private initRelays = () =>
|
||||||
|
this.persistMapPlugin(
|
||||||
|
"relays",
|
||||||
|
this.app.use(Relays),
|
||||||
|
(row: RelayInfo & {url: string}) => [row.url, new Relay(row.url, row)],
|
||||||
|
(_url, relay) => ({...relay}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return this.app.use(Relays).onItem((_url, relay) => {
|
private initRelayStats = () =>
|
||||||
if (relay) {
|
this.persistMapPlugin(
|
||||||
enqueue(relay)
|
"relayStats",
|
||||||
}
|
this.app.use(RelayStats),
|
||||||
})
|
(row: RelayStatsItem) => [row.url, row],
|
||||||
}
|
(_url, stats) => stats,
|
||||||
|
|
||||||
private initRelayStats = async () => {
|
|
||||||
const table = this.db.table<RelayStatsItem>("relayStats")
|
|
||||||
|
|
||||||
for (const row of await table.getAll()) {
|
|
||||||
this.app.use(RelayStats).set(row.url, row)
|
|
||||||
}
|
|
||||||
|
|
||||||
const enqueue = batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))
|
|
||||||
|
|
||||||
return this.app.use(RelayStats).onItem((_url, stats) => {
|
|
||||||
if (stats) {
|
|
||||||
enqueue(stats)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private initHandles = async () => {
|
|
||||||
const table = this.db.table<Handle>("handles")
|
|
||||||
|
|
||||||
for (const row of await table.getAll()) {
|
|
||||||
this.app.use(Handles).set(row.nip05, row)
|
|
||||||
}
|
|
||||||
|
|
||||||
const enqueue = batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))
|
|
||||||
|
|
||||||
return this.app.use(Handles).onItem((_nip05, handle) => {
|
|
||||||
if (handle) {
|
|
||||||
enqueue(handle)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private initZappers = async () => {
|
|
||||||
const table = this.db.table<ZapperValues>("zappers")
|
|
||||||
|
|
||||||
for (const row of await table.getAll()) {
|
|
||||||
// Validation is meaningless without these, and rows cached before they were required lack them.
|
|
||||||
if (row.pubkey && row.nostrPubkey) {
|
|
||||||
this.app.use(Zappers).set(row.lnurl, new Zapper(row))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const enqueue = batch(
|
|
||||||
FLUSH_INTERVAL,
|
|
||||||
idleWrite((zappers: Zapper[]) => table.bulkPut(zappers.map(zapper => ({...zapper})))),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return this.app.use(Zappers).onItem((_lnurl, zapper) => {
|
private initHandles = () =>
|
||||||
if (zapper) {
|
this.persistMapPlugin(
|
||||||
enqueue(zapper)
|
"handles",
|
||||||
}
|
this.app.use(Handles),
|
||||||
})
|
(row: Handle) => [row.nip05, row],
|
||||||
}
|
(_nip05, handle) => handle,
|
||||||
|
)
|
||||||
|
|
||||||
private initPlaintext = async () => {
|
// Validation is meaningless without pubkey and nostrPubkey, and rows cached before they were required lack them.
|
||||||
const table = this.db.table<PlaintextItem>("plaintext")
|
private initZappers = () =>
|
||||||
|
this.persistMapPlugin(
|
||||||
|
"zappers",
|
||||||
|
this.app.use(Zappers),
|
||||||
|
(row: ZapperValues) =>
|
||||||
|
row.pubkey && row.nostrPubkey ? [row.lnurl, new Zapper(row)] : undefined,
|
||||||
|
(_lnurl, zapper) => ({...zapper}),
|
||||||
|
)
|
||||||
|
|
||||||
for (const {key, value} of await table.getAll()) {
|
private initPlaintext = () =>
|
||||||
this.app.use(Plaintext).set(key, value)
|
this.persistMapPlugin(
|
||||||
}
|
"plaintext",
|
||||||
|
this.app.use(Plaintext),
|
||||||
const enqueue = batch(FLUSH_INTERVAL, idleWrite(table.bulkPut))
|
(row: PlaintextItem) => [row.key, row.value],
|
||||||
|
(key, value) => ({key, value}),
|
||||||
return this.app.use(Plaintext).onItem((key, value) => {
|
)
|
||||||
if (value) {
|
|
||||||
enqueue({key, value})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private initWrapManager = async () => {
|
private initWrapManager = async () => {
|
||||||
const table = this.db.table<WrapItem>("wrapManager")
|
const table = this.db.table<WrapItem>("wrapManager")
|
||||||
|
|
|
||||||
149
src/app/sync.ts
149
src/app/sync.ts
|
|
@ -91,6 +91,32 @@ const pullAndListen = ({url, signal, filters}: SyncOpts) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runs one sync per key, starting it when the key appears and stopping it when the key goes away.
|
||||||
|
const makeKeyedSyncs = (start: (key: string) => Unsubscriber) => {
|
||||||
|
const stopsByKey = new Map<string, Unsubscriber>()
|
||||||
|
|
||||||
|
const reconcile = (keys: Iterable<string>) => {
|
||||||
|
const keep = new Set(keys)
|
||||||
|
|
||||||
|
for (const [key, stop] of stopsByKey) {
|
||||||
|
if (!keep.has(key)) {
|
||||||
|
stopsByKey.delete(key)
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keep) {
|
||||||
|
if (!stopsByKey.has(key)) {
|
||||||
|
stopsByKey.set(key, start(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => reconcile([])
|
||||||
|
|
||||||
|
return {reconcile, stop}
|
||||||
|
}
|
||||||
|
|
||||||
const userRelayList = deriveUserItem(RelayLists)
|
const userRelayList = deriveUserItem(RelayLists)
|
||||||
|
|
||||||
const userFollowList = deriveUserItem(FollowLists)
|
const userFollowList = deriveUserItem(FollowLists)
|
||||||
|
|
@ -157,25 +183,8 @@ const syncUserSpaceMembership = (url: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncUserData = () => {
|
const syncUserData = () => {
|
||||||
const unsubscribersByUrl = new Map<string, Unsubscriber>()
|
|
||||||
const $pubkey = app.get().user?.pubkey
|
const $pubkey = app.get().user?.pubkey
|
||||||
|
const membershipSyncs = makeKeyedSyncs(syncUserSpaceMembership)
|
||||||
const syncSpaceMembership = ($activeSpaceUrls: string[]) => {
|
|
||||||
const urls = new Set($activeSpaceUrls)
|
|
||||||
|
|
||||||
for (const url of urls) {
|
|
||||||
if (!unsubscribersByUrl.has(url)) {
|
|
||||||
unsubscribersByUrl.set(url, syncUserSpaceMembership(url))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
|
|
||||||
if (!urls.has(url)) {
|
|
||||||
unsubscribersByUrl.delete(url)
|
|
||||||
unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncUserLists = () => {
|
const syncUserLists = () => {
|
||||||
if ($pubkey) {
|
if ($pubkey) {
|
||||||
|
|
@ -207,12 +216,12 @@ const syncUserData = () => {
|
||||||
relayLists.get().load($pubkey)
|
relayLists.get().load($pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribeSpaceMembership = activeSpaceUrls.subscribe(syncSpaceMembership)
|
const unsubscribeSpaceMembership = activeSpaceUrls.subscribe(membershipSyncs.reconcile)
|
||||||
const unsubscribeRelayList = userRelayList.subscribe(syncUserLists)
|
const unsubscribeRelayList = userRelayList.subscribe(syncUserLists)
|
||||||
const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork)
|
const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribersByUrl.forEach(call)
|
membershipSyncs.stop()
|
||||||
unsubscribeSpaceMembership()
|
unsubscribeSpaceMembership()
|
||||||
unsubscribeRelayList()
|
unsubscribeRelayList()
|
||||||
unsubscribeFollowList()
|
unsubscribeFollowList()
|
||||||
|
|
@ -288,33 +297,15 @@ const syncSpaceDetail = (url: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncSpaces = () => {
|
const syncSpaces = () => {
|
||||||
const unsubscribersByUrl = new Map<string, Unsubscriber>()
|
const activitySyncs = makeKeyedSyncs(syncSpaceActivity)
|
||||||
|
|
||||||
let detailUrl: Maybe<string>
|
let detailUrl: Maybe<string>
|
||||||
let unsubscribeDetail: Unsubscriber = noop
|
let unsubscribeDetail: Unsubscriber = noop
|
||||||
|
|
||||||
const unsubscribe = merged([activeSpaceUrls, page]).subscribe(([$activeSpaceUrls, $page]) => {
|
const unsubscribe = merged([activeSpaceUrls, page]).subscribe(([$activeSpaceUrls, $page]) => {
|
||||||
const urls = new Set($activeSpaceUrls)
|
|
||||||
const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined
|
const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined
|
||||||
|
|
||||||
if (currentUrl) {
|
activitySyncs.reconcile(currentUrl ? [...$activeSpaceUrls, currentUrl] : $activeSpaceUrls)
|
||||||
urls.add(currentUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop syncing removed spaces
|
|
||||||
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
|
|
||||||
if (!urls.has(url)) {
|
|
||||||
unsubscribersByUrl.delete(url)
|
|
||||||
unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start syncing for new spaces
|
|
||||||
for (const url of urls) {
|
|
||||||
if (!unsubscribersByUrl.has(url)) {
|
|
||||||
unsubscribersByUrl.set(url, syncSpaceActivity(url))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detailUrl !== currentUrl) {
|
if (detailUrl !== currentUrl) {
|
||||||
detailUrl = currentUrl
|
detailUrl = currentUrl
|
||||||
|
|
@ -324,10 +315,7 @@ const syncSpaces = () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const unsubscriber of unsubscribersByUrl.values()) {
|
activitySyncs.stop()
|
||||||
unsubscriber()
|
|
||||||
}
|
|
||||||
|
|
||||||
unsubscribeDetail()
|
unsubscribeDetail()
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
}
|
}
|
||||||
|
|
@ -348,72 +336,37 @@ const syncDMRelay = (url: string, pubkey: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncDMs = () => {
|
const syncDMs = () => {
|
||||||
const unsubscribersByUrl = new Map<string, Unsubscriber>()
|
const $pubkey = app.get().user?.pubkey
|
||||||
|
|
||||||
let currentPubkey: string | undefined
|
if ($pubkey) {
|
||||||
let currentShouldUnwrap = false
|
const relaySyncs = makeKeyedSyncs(url => syncDMRelay(url, $pubkey))
|
||||||
|
|
||||||
const unsubscribeAll = () => {
|
const syncList = () => {
|
||||||
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
|
if (shouldUnwrap.get()) {
|
||||||
unsubscribersByUrl.delete(url)
|
relaySyncs.reconcile(messagingRelayLists.get().urls($pubkey).get())
|
||||||
unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscribeAll = (pubkey: string, urls: string[]) => {
|
|
||||||
// Start syncing newly added relays
|
|
||||||
for (const url of urls) {
|
|
||||||
if (!unsubscribersByUrl.has(url)) {
|
|
||||||
unsubscribersByUrl.set(url, syncDMRelay(url, pubkey))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop syncing removed spaces
|
const loadList = async ($shouldUnwrap: boolean) => {
|
||||||
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
|
if ($shouldUnwrap) {
|
||||||
if (!urls.includes(url)) {
|
await relayLists.get().load($pubkey).catch(noop)
|
||||||
unsubscribersByUrl.delete(url)
|
await messagingRelayLists.get().load($pubkey).catch(noop)
|
||||||
unsubscribe()
|
|
||||||
|
syncList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const syncPubkey = async ($pubkey: Maybe<string>, $shouldUnwrap: boolean) => {
|
const unsubscribeUnwrap = shouldUnwrap.subscribe(loadList)
|
||||||
if ($pubkey !== currentPubkey) {
|
const unsubscribeList = userMessagingRelayList.subscribe(syncList)
|
||||||
unsubscribeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPubkey = $pubkey
|
return () => {
|
||||||
currentShouldUnwrap = $shouldUnwrap
|
relaySyncs.stop()
|
||||||
|
unsubscribeUnwrap()
|
||||||
if ($pubkey && $shouldUnwrap) {
|
unsubscribeList()
|
||||||
await relayLists.get().load($pubkey).catch(noop)
|
|
||||||
await messagingRelayLists.get().load($pubkey).catch(noop)
|
|
||||||
|
|
||||||
if (currentPubkey === $pubkey && currentShouldUnwrap === $shouldUnwrap) {
|
|
||||||
subscribeAll($pubkey, messagingRelayLists.get().urls($pubkey).get())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncList = () => {
|
return noop
|
||||||
const $pubkey = app.get().user?.pubkey
|
|
||||||
|
|
||||||
if ($pubkey && shouldUnwrap.get()) {
|
|
||||||
subscribeAll($pubkey, messagingRelayLists.get().urls($pubkey).get())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsubscribeUser = merged([app, shouldUnwrap]).subscribe(([$app, $shouldUnwrap]) => {
|
|
||||||
syncPubkey($app.user?.pubkey, $shouldUnwrap)
|
|
||||||
})
|
|
||||||
|
|
||||||
const unsubscribeList = userMessagingRelayList.subscribe(syncList)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribeAll()
|
|
||||||
unsubscribeUser()
|
|
||||||
unsubscribeList()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge all synchronization functions
|
// Merge all synchronization functions
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import {identity, uniq} from "@welshman/lib"
|
import {identity, uniq} from "@welshman/lib"
|
||||||
|
import {CLASSIFIED, EVENT_TIME, LONG_FORM, THREAD, ZAP_GOAL} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {Article, Classified, Thread, ZapGoal, displayPubkey} from "@welshman/domain"
|
import {Article, Classified, Thread, ZapGoal, displayPubkey} from "@welshman/domain"
|
||||||
import {makeRoomKey} from "@welshman/app"
|
import {makeRoomKey} from "@welshman/app"
|
||||||
import {decodePubkey} from "@lib/util"
|
import {decodePubkey} from "@lib/util"
|
||||||
|
|
@ -33,13 +35,12 @@ const staticTitles = new Map<string, string>([
|
||||||
["/[bech32]", "Opening Link"],
|
["/[bech32]", "Opening Link"],
|
||||||
])
|
])
|
||||||
|
|
||||||
const eventRoutes = new Set([
|
const contentFallbackTitles = new Map<string, string>([
|
||||||
"/spaces/[relay]/threads/[id]",
|
["/spaces/[relay]/threads/[id]", "Thread"],
|
||||||
"/spaces/[relay]/goals/[id]",
|
["/spaces/[relay]/goals/[id]", "Goal"],
|
||||||
"/spaces/[relay]/calendar/[address]",
|
["/spaces/[relay]/calendar/[address]", "Event"],
|
||||||
"/spaces/[relay]/classifieds/[address]",
|
["/spaces/[relay]/classifieds/[address]", "Listing"],
|
||||||
"/spaces/[relay]/articles/[address]",
|
["/spaces/[relay]/articles/[address]", "Article"],
|
||||||
"/spaces/[relay]/polls/[id]",
|
|
||||||
])
|
])
|
||||||
|
|
||||||
type RouteParams = Record<string, string | undefined>
|
type RouteParams = Record<string, string | undefined>
|
||||||
|
|
@ -67,18 +68,19 @@ const getRoomTitle = (params: RouteParams) => {
|
||||||
return room?.meta?.name() || "Room"
|
return room?.meta?.name() || "Room"
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEventForTitle = (routeId: string, params: RouteParams) => {
|
const getContentTitle = (event: TrustedEvent) => {
|
||||||
if (!eventRoutes.has(routeId)) {
|
switch (event.kind) {
|
||||||
return
|
case THREAD:
|
||||||
|
return reader(Thread)(event).title()
|
||||||
|
case EVENT_TIME:
|
||||||
|
return getEventTitle(event)
|
||||||
|
case CLASSIFIED:
|
||||||
|
return reader(Classified)(event).title()
|
||||||
|
case LONG_FORM:
|
||||||
|
return reader(Article)(event).title()
|
||||||
|
case ZAP_GOAL:
|
||||||
|
return event.content || reader(ZapGoal)(event).summary()
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventId = params.id || params.address
|
|
||||||
|
|
||||||
if (!eventId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return app.get().repository.getEvent(eventId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getChatTitle = (chatId: string | undefined, pubkey: string | undefined) => {
|
const getChatTitle = (chatId: string | undefined, pubkey: string | undefined) => {
|
||||||
|
|
@ -130,26 +132,12 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => {
|
||||||
return makeTitle(getRoomTitle(page.params))
|
return makeTitle(getRoomTitle(page.params))
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = getEventForTitle(routeId, page.params)
|
const contentFallbackTitle = contentFallbackTitles.get(routeId)
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/threads/[id]") {
|
if (contentFallbackTitle) {
|
||||||
return makeTitle((event && reader(Thread)(event).title()) || "Thread")
|
const event = app.get().repository.getEvent(page.params.id || page.params.address || "")
|
||||||
}
|
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/calendar/[address]") {
|
return makeTitle((event && getContentTitle(event)) || contentFallbackTitle)
|
||||||
return makeTitle((event && getEventTitle(event)) || "Event")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/classifieds/[address]") {
|
|
||||||
return makeTitle((event && reader(Classified)(event).title()) || "Listing")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/articles/[address]") {
|
|
||||||
return makeTitle((event && reader(Article)(event).title()) || "Article")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/goals/[id]") {
|
|
||||||
return makeTitle(event?.content || (event && reader(ZapGoal)(event).summary()) || "Goal")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return makeTitle()
|
return makeTitle()
|
||||||
|
|
|
||||||
89
src/lib/audio.ts
Normal file
89
src/lib/audio.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import {noop} from "@welshman/lib"
|
||||||
|
|
||||||
|
const WAV_BIT_DEPTH = 16
|
||||||
|
|
||||||
|
const WAV_HEADER_LENGTH = 44
|
||||||
|
|
||||||
|
export const toWav = (audio: AudioBuffer) => {
|
||||||
|
const {numberOfChannels, sampleRate, length} = audio
|
||||||
|
const bytesPerFrame = (numberOfChannels * WAV_BIT_DEPTH) / 8
|
||||||
|
const dataLength = length * bytesPerFrame
|
||||||
|
const wav = new ArrayBuffer(WAV_HEADER_LENGTH + dataLength)
|
||||||
|
const view = new DataView(wav)
|
||||||
|
const ascii = (offset: number, value: string) => {
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
view.setUint8(offset + i, value.charCodeAt(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ascii(0, "RIFF")
|
||||||
|
view.setUint32(4, 36 + dataLength, true)
|
||||||
|
ascii(8, "WAVEfmt ")
|
||||||
|
view.setUint32(16, 16, true)
|
||||||
|
view.setUint16(20, 1, true)
|
||||||
|
view.setUint16(22, numberOfChannels, true)
|
||||||
|
view.setUint32(24, sampleRate, true)
|
||||||
|
view.setUint32(28, sampleRate * bytesPerFrame, true)
|
||||||
|
view.setUint16(32, bytesPerFrame, true)
|
||||||
|
view.setUint16(34, WAV_BIT_DEPTH, true)
|
||||||
|
ascii(36, "data")
|
||||||
|
view.setUint32(40, dataLength, true)
|
||||||
|
|
||||||
|
const channels = Array.from({length: numberOfChannels}, (_, i) => audio.getChannelData(i))
|
||||||
|
|
||||||
|
let offset = WAV_HEADER_LENGTH
|
||||||
|
|
||||||
|
for (let frame = 0; frame < length; frame++) {
|
||||||
|
for (const samples of channels) {
|
||||||
|
// A decoded sample runs from -1 to 1, and the two ends of a signed 16 bit range differ in size.
|
||||||
|
const sample = Math.max(-1, Math.min(1, samples[frame]))
|
||||||
|
|
||||||
|
view.setInt16(offset, Math.round(sample * (sample < 0 ? 0x8000 : 0x7fff)), true)
|
||||||
|
|
||||||
|
offset += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([wav], {type: "audio/wav"})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listMediaDevices = async (): Promise<MediaDeviceInfo[]> => {
|
||||||
|
try {
|
||||||
|
return (await navigator.mediaDevices?.enumerateDevices?.()) ?? []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calls onFrame with the root mean square of the stream's waveform, from 0 to 1, on every animation frame.
|
||||||
|
export const watchAudioLevel = (stream: MediaStream, onFrame: (rms: number) => void) => {
|
||||||
|
const context = new AudioContext()
|
||||||
|
const analyser = context.createAnalyser()
|
||||||
|
|
||||||
|
analyser.fftSize = 512
|
||||||
|
|
||||||
|
context.createMediaStreamSource(stream).connect(analyser)
|
||||||
|
|
||||||
|
const samples = new Uint8Array(analyser.frequencyBinCount)
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
analyser.getByteTimeDomainData(samples)
|
||||||
|
|
||||||
|
let sumSquares = 0
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
sumSquares += ((sample - 128) / 128) ** 2
|
||||||
|
}
|
||||||
|
|
||||||
|
onFrame(Math.sqrt(sumSquares / samples.length))
|
||||||
|
|
||||||
|
frame = requestAnimationFrame(measure)
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = requestAnimationFrame(measure)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame)
|
||||||
|
context.close().catch(noop)
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/lib/tileGrid.ts
Normal file
174
src/lib/tileGrid.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
/** The lower aspect bound is 1:1 on landscape and 3:4 on portrait, and the upper is 16:9. */
|
||||||
|
const TILE_ASPECT_PORTRAIT = 3 / 4
|
||||||
|
const TILE_ASPECT_LANDSCAPE = 16 / 9
|
||||||
|
const TILE_GAP = 8
|
||||||
|
|
||||||
|
/** Below this the grid scrolls rather than forcing portrait tiles, which is ~6-8 tiles on a portrait phone. */
|
||||||
|
const MIN_TILE_HEIGHT = 120
|
||||||
|
|
||||||
|
export type TileRow = {
|
||||||
|
columnCount: number
|
||||||
|
tileWidth: number
|
||||||
|
tileHeight: number
|
||||||
|
/** Total width of this row including gaps — for the template's max-width */
|
||||||
|
rowWidth: number
|
||||||
|
aspectRatio: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All tiles share one size. Full rows fill the container width and a partial row is centered. */
|
||||||
|
export type AdaptiveTileGrid = {
|
||||||
|
rows: TileRow[]
|
||||||
|
totalWidth: number
|
||||||
|
totalHeight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Score for comparing candidate layouts. Lower is better. */
|
||||||
|
type LayoutScore = {
|
||||||
|
/** Penalty for vertical overflow: 0 if none, else huge */
|
||||||
|
verticalOverflowPenalty: number
|
||||||
|
/** Penalty for horizontal overflow: 0 if none, else huge */
|
||||||
|
horizontalOverflowPenalty: number
|
||||||
|
/** Penalty when tiles fall below MIN_TILE_HEIGHT even while fitting */
|
||||||
|
minTileHeightPenalty: number
|
||||||
|
/** Negative tile area (we want to maximize area, so negate) */
|
||||||
|
negativeArea: number
|
||||||
|
/** Leftover whitespace (underflow) in px */
|
||||||
|
whitespace: number
|
||||||
|
/** Average absolute deviation from 16:9 across all tiles */
|
||||||
|
aspectDeviation: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareScores = (a: LayoutScore, b: LayoutScore): number => {
|
||||||
|
const fields: (keyof LayoutScore)[] = [
|
||||||
|
"verticalOverflowPenalty",
|
||||||
|
"horizontalOverflowPenalty",
|
||||||
|
"minTileHeightPenalty",
|
||||||
|
"negativeArea",
|
||||||
|
"whitespace",
|
||||||
|
"aspectDeviation",
|
||||||
|
]
|
||||||
|
for (const field of fields) {
|
||||||
|
if (a[field] !== b[field]) {
|
||||||
|
return a[field] - b[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The largest tile that fits, shrunk to whichever dimension is more constraining. */
|
||||||
|
const fitTile = (availWidth: number, availHeight: number, minAspect: number) => {
|
||||||
|
const fillAspect = availWidth / availHeight
|
||||||
|
const aspectRatio = Math.max(minAspect, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect))
|
||||||
|
if (fillAspect >= aspectRatio) {
|
||||||
|
const tileWidth = availHeight * aspectRatio
|
||||||
|
return {tileWidth, tileHeight: availHeight, aspectRatio}
|
||||||
|
}
|
||||||
|
const tileHeight = availWidth / aspectRatio
|
||||||
|
return {tileWidth: availWidth, tileHeight, aspectRatio}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A candidate grid for a given column count, with the partial last row centered. */
|
||||||
|
const buildCandidate = (
|
||||||
|
tileCount: number,
|
||||||
|
columnCount: number,
|
||||||
|
containerWidth: number,
|
||||||
|
containerHeight: number,
|
||||||
|
minAspect: number,
|
||||||
|
): AdaptiveTileGrid | undefined => {
|
||||||
|
const gap = TILE_GAP
|
||||||
|
const fullRowCount = Math.floor(tileCount / columnCount)
|
||||||
|
const remainder = tileCount % columnCount
|
||||||
|
const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0)
|
||||||
|
|
||||||
|
const availHeight = (containerHeight - (totalRowCount - 1) * gap) / totalRowCount
|
||||||
|
const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
|
||||||
|
if (availWidth <= 0 || availHeight <= 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect)
|
||||||
|
|
||||||
|
const rows: TileRow[] = []
|
||||||
|
for (let r = 0; r < fullRowCount; r++) {
|
||||||
|
rows.push({
|
||||||
|
columnCount,
|
||||||
|
tileWidth,
|
||||||
|
tileHeight,
|
||||||
|
rowWidth: columnCount * tileWidth + (columnCount - 1) * gap,
|
||||||
|
aspectRatio,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (remainder > 0) {
|
||||||
|
rows.push({
|
||||||
|
columnCount: remainder,
|
||||||
|
tileWidth,
|
||||||
|
tileHeight,
|
||||||
|
rowWidth: remainder * tileWidth + (remainder - 1) * gap,
|
||||||
|
aspectRatio,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalHeight = totalRowCount * tileHeight + (totalRowCount - 1) * gap
|
||||||
|
const totalWidth = columnCount * tileWidth + (columnCount - 1) * gap
|
||||||
|
|
||||||
|
return {rows, totalWidth, totalHeight}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scored on overflow first, then whitespace, then distance from 16:9, then tile size. */
|
||||||
|
export const computeAdaptiveGrid = (
|
||||||
|
tileCount: number,
|
||||||
|
containerWidth: number,
|
||||||
|
containerHeight: number,
|
||||||
|
): AdaptiveTileGrid | undefined => {
|
||||||
|
if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const minAspect = containerWidth / containerHeight >= 4 / 3 ? 1 : TILE_ASPECT_PORTRAIT
|
||||||
|
|
||||||
|
let best: AdaptiveTileGrid | undefined
|
||||||
|
let bestScore: LayoutScore | undefined
|
||||||
|
|
||||||
|
for (let columnCount = 1; columnCount <= tileCount; columnCount++) {
|
||||||
|
const candidate = buildCandidate(
|
||||||
|
tileCount,
|
||||||
|
columnCount,
|
||||||
|
containerWidth,
|
||||||
|
containerHeight,
|
||||||
|
minAspect,
|
||||||
|
)
|
||||||
|
if (!candidate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const {tileWidth, tileHeight, aspectRatio} = candidate.rows[0]
|
||||||
|
const verticalOverflow = Math.max(0, candidate.totalHeight - containerHeight)
|
||||||
|
const horizontalOverflow = Math.max(0, candidate.totalWidth - containerWidth)
|
||||||
|
const whitespace =
|
||||||
|
Math.max(0, containerWidth - candidate.totalWidth) +
|
||||||
|
Math.max(0, containerHeight - candidate.totalHeight)
|
||||||
|
const totalArea = tileWidth * tileHeight * tileCount
|
||||||
|
|
||||||
|
const overflowPenaltyMultiplier = tileHeight >= MIN_TILE_HEIGHT ? 1_000_000 : 1
|
||||||
|
const verticalOverflowPenalty = verticalOverflow * overflowPenaltyMultiplier
|
||||||
|
const horizontalOverflowPenalty = horizontalOverflow * overflowPenaltyMultiplier
|
||||||
|
const minTileHeightPenalty =
|
||||||
|
tileHeight < MIN_TILE_HEIGHT ? (MIN_TILE_HEIGHT - tileHeight) * 1000 : 0
|
||||||
|
|
||||||
|
const score: LayoutScore = {
|
||||||
|
verticalOverflowPenalty,
|
||||||
|
horizontalOverflowPenalty,
|
||||||
|
minTileHeightPenalty,
|
||||||
|
negativeArea: -totalArea,
|
||||||
|
whitespace,
|
||||||
|
aspectDeviation: Math.abs(aspectRatio - TILE_ASPECT_LANDSCAPE),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestScore || compareScores(score, bestScore) < 0) {
|
||||||
|
bestScore = score
|
||||||
|
best = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue