Consolidate storage, sync, NIP-98, push server, OpenRouter, audio, call and title helpers in src/app and src/lib

This commit is contained in:
Jon Staab 2026-09-26 00:30:16 -07:00
parent 5343a28e6c
commit c8c6bd4465
36 changed files with 878 additions and 994 deletions

View file

@ -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

View file

@ -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

View file

@ -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 |

View file

@ -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`.

View file

@ -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";

View file

@ -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],

View file

@ -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
}

View file

@ -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[]> =>

View file

@ -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)

View file

@ -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>

View file

@ -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))

View file

@ -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>

View file

@ -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)

View file

@ -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}

View file

@ -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
} }

View file

@ -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`}>

View file

@ -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(() => {

View file

@ -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) {

View file

@ -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(() => {

View file

@ -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()

View file

@ -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,
}, },
}) })

View file

@ -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()])
},
)

View file

@ -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
View 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
}

View file

@ -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
View 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"})

View file

@ -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"}
}), }),
) )

View file

@ -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})`)

View file

@ -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]}]})
}

View file

@ -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
}

View file

@ -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()))
} }

View file

@ -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")

View file

@ -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

View file

@ -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
View 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
View 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
}