Compare commits

...

5 Commits

Author SHA1 Message Date
userAdityaa 32cf909d45 feat: show per-relay failure, pending breakdown on outgoing messages 2026-06-04 21:09:52 +00:00
Jon Staab 1b8d6e50e2 Fix link_deps, normalize relays less aggressively 2026-06-03 11:08:08 -07:00
Jon Staab e3e13563d5 bump pnpm version 2026-06-02 15:27:15 -07:00
userAdityaa ee3da3893c fix: resync voice state after LiveKit reconnect (#289)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-06-02 16:00:11 +00:00
Jon Staab 91145c38fb Scaffold playwright 2026-06-01 17:00:47 -07:00
20 changed files with 466 additions and 61 deletions
+1
View File
@@ -15,3 +15,4 @@ android/capacitor-cordova-android-plugins
android/app/src/androidTest
android/app/src/test
node_modules
.svelte-kit
+5
View File
@@ -6,6 +6,11 @@
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Playwright
/test-results/
/playwright-report/
/playwright/.cache/
# Generated assets
static/favicon.ico
static/pwa-64x64.png
+12
View File
@@ -0,0 +1,12 @@
import {expect, test} from "@playwright/test"
test("boots the SPA on the home page", async ({page}) => {
const response = await page.goto("/")
expect(response?.ok()).toBeTruthy()
// adapter-static serves an empty shell that hydrates client-side, so the presence of
// rendered text proves the Svelte app actually mounted (not just that a file was served).
// TODO: tighten this to assert concrete onboarding UI once the markup is settled.
await expect(page.locator("body")).toContainText(/\S/, {timeout: 15_000})
})
+29
View File
@@ -0,0 +1,29 @@
import type {SignedEvent} from "@welshman/util"
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
import relay1Events from "./fixtures/relay1.json"
// Fake relay urls used by tests. Each maps to a json fixture under ./fixtures/ and an entry in
// EVENTS_BY_RELAY below. To add a relay: drop a `<name>.json` file in ./fixtures/, import it, add a
// url here, and wire it into EVENTS_BY_RELAY.
export const FIXTURE_RELAYS = {
relay1: "wss://relay1.test/",
} as const
// The events each fake relay serves. The json files hold static, pre-signed events: schnorr
// signatures are non-deterministic, so events are signed once and committed verbatim (they pass
// verifyEvent, which netContext.isEventValid enforces). Regenerate with @welshman/signer:
// await Nip01Signer.fromSecret(secret).sign(makeEvent(kind, {content, created_at}))
const EVENTS_BY_RELAY: Record<string, SignedEvent[]> = {
[FIXTURE_RELAYS.relay1]: relay1Events as SignedEvent[],
}
// Build a RelayMockConfig populating the given fixture relays (all of them when none are passed).
// Any relay not included returns nothing, keeping tests offline.
export const relayFixtures = (...urls: string[]): RelayMockConfig => {
const selected = urls.length > 0 ? urls : Object.keys(EVENTS_BY_RELAY)
return {
relays: Object.fromEntries(selected.map(url => [url, EVENTS_BY_RELAY[url] ?? []])),
}
}
+29
View File
@@ -0,0 +1,29 @@
[
{
"kind": 0,
"content": "{\"name\":\"Alice\"}",
"tags": [],
"created_at": 1700000000,
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"id": "9b3d138641b38364945b20d800268006c2cb7d974bb4b1d63a9f90f5ab974b90",
"sig": "de6b86274e7bcf6c02aa881ada1feee9e01ba320691711d4975916b3cd231ab43cf469a47c5db99503ed72707d5db85fede1ad3763c4fbd7c998d04f00eda6bc"
},
{
"kind": 1,
"content": "hello from the fixture relay",
"tags": [],
"created_at": 1700000000,
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"id": "b9874875bfa8d830c5c9ef3673104360cf21b94848a311febfaf52f0a652b1a9",
"sig": "85df94a2e9884ac3d280145492d5191cde2948d49a824c443a1f5d2143633eff1e1789fa7e8843b6efc3dd2dc0d7e33322edb628125d8e35de8ddca1d06ca970"
},
{
"kind": 1,
"content": "reply from bob",
"tags": [],
"created_at": 1700000001,
"pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
"id": "171dcbdd63d474ba46da609e8b0104cbcf4801fbb581b6c343d9426280f9e1be",
"sig": "eecf26e616a6b70dc67c7eae16fc4fe159314647ba1d4581332257de7f070410aa9a9e41f571b9403ff145d16c9b32766846fce08516201263e25cf08c1ed8f1"
}
]
+30
View File
@@ -0,0 +1,30 @@
import type {Page} from "@playwright/test"
import type {RelayMockConfig} from "../../src/lib/test/relayMocks"
// Must match RELAY_MOCKS_KEY in src/lib/test/relayMocks.ts.
const RELAY_MOCKS_KEY = "__RELAY_MOCKS__"
// Hard safety net: intercept every real websocket so a test can never reach the network, even if
// some code path opens a socket directly (e.g. relay AUTH) rather than going through the adapter
// layer. We never call route.connectToServer(), so the socket connects to Playwright's in-process
// mock and simply receives nothing.
export const blockWebsockets = (page: Page) => page.routeWebSocket(/^wss?:\/\//, () => {})
// Inject the relay-mock config the app reads on startup. addInitScript runs before any page script
// on every navigation, so this must be called before page.goto().
export const injectRelayConfig = (page: Page, config: RelayMockConfig) =>
page.addInitScript(
([key, value]) => {
Object.assign(window, {[key]: value})
},
[RELAY_MOCKS_KEY, config] as const,
)
// Full network isolation plus optional fixtures, in one call. With no config, every relay returns
// nothing (requirement 1). Pass {relays: {url: events}} to populate specific relays (requirement 2).
export const setupRelayMocks = async (page: Page, config: RelayMockConfig = {}) => {
await blockWebsockets(page)
await injectRelayConfig(page, config)
}
export type {RelayMockConfig}
+1
View File
@@ -12,6 +12,7 @@ if (execSync('git status --porcelain', { encoding: 'utf8' }).trim() && !force) {
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
pkg.pnpm = pkg.pnpm || {}
pkg.pnpm.overrides = pkg.pnpm.overrides || {}
pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app"
pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content"
+4 -1
View File
@@ -11,6 +11,8 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src",
"test": "playwright test",
"test:ui": "playwright test --ui",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
"format:all": "prettier --write src",
"prepare": "husky"
@@ -18,6 +20,7 @@
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@eslint/js": "^9.39.2",
"@playwright/test": "^1.49.1",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/postcss": "^4.2.2",
@@ -99,5 +102,5 @@
"throttle-debounce": "^5.0.2",
"tippy.js": "^6.3.7"
},
"packageManager": "pnpm@11.4.0"
"packageManager": "pnpm@11.5.1"
}
+27
View File
@@ -0,0 +1,27 @@
import {defineConfig, devices} from "@playwright/test"
// E2E tests live in ./e2e and run against the dev server (port 1847 from vite.config.ts).
// Run with `pnpm test:e2e` (after `pnpm exec playwright install` to fetch browsers).
export default defineConfig({
testDir: "e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "html",
use: {
baseURL: "http://localhost:1847",
trace: "on-first-retry",
},
// Boots the SvelteKit dev server before the suite and reuses one if already running locally.
webServer: {
command: "pnpm dev",
url: "http://localhost:1847",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{name: "chromium", use: {...devices["Desktop Chrome"]}},
{name: "firefox", use: {...devices["Desktop Firefox"]}},
{name: "webkit", use: {...devices["Desktop Safari"]}},
],
})
+38
View File
@@ -189,6 +189,9 @@ importers:
'@eslint/js':
specifier: ^9.39.2
version: 9.39.4
'@playwright/test':
specifier: ^1.49.1
version: 1.60.0
'@sveltejs/kit':
specifier: ^2.61.1
version: 2.61.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.9(@typescript-eslint/types@8.60.0))(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)))(svelte@5.55.9(@typescript-eslint/types@8.60.0))(typescript@5.9.3)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
@@ -1465,6 +1468,11 @@ packages:
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
'@playwright/test@1.60.0':
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -3013,6 +3021,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3975,6 +3988,16 @@ packages:
resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==}
engines: {node: '>=4'}
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.60.0:
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
engines: {node: '>=18'}
hasBin: true
plist@3.1.1:
resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==}
engines: {node: '>=10.4.0'}
@@ -6549,6 +6572,10 @@ snapshots:
tslib: 2.8.1
tsyringe: 4.10.0
'@playwright/test@1.60.0':
dependencies:
playwright: 1.60.0
'@polka/url@1.0.0-next.29': {}
'@pomade/core@0.2.5(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/signer@0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/436adec9ed1e71569748cd56aa697f361e7a8d47(@capacitor/core@8.3.4))(nostr-tools@2.23.5(typescript@5.9.3)))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-tools@2.23.5(typescript@5.9.3))':
@@ -8240,6 +8267,9 @@ snapshots:
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -9129,6 +9159,14 @@ snapshots:
pify@3.0.0: {}
playwright-core@1.60.0: {}
playwright@1.60.0:
dependencies:
playwright-core: 1.60.0
optionalDependencies:
fsevents: 2.3.2
plist@3.1.1:
dependencies:
'@xmldom/xmldom': 0.9.10
+6 -3
View File
@@ -17,6 +17,11 @@ export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export type ParticipantMediaState = {
muted: boolean
cameraOn: boolean
}
export enum VoiceState {
Joining = "joining",
Connected = "connected",
@@ -41,9 +46,7 @@ export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity
export const speakingParticipants = writable<VoiceParticipant[]>([])
export const participantMediaState = writable(
new Map<string, {muted: boolean; cameraOn: boolean}>(),
)
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())
export const mediaStateByIdentity = derived(
[participantMediaState, currentVoiceSession, voiceMicMuted],
+101 -15
View File
@@ -30,6 +30,7 @@ import {
participantMediaState,
speakingParticipants,
VoiceState,
type ParticipantMediaState,
type VoiceParticipant,
voiceState,
} from "@app/call/stores"
@@ -77,12 +78,17 @@ export const switchVoiceActiveDevice = async (
}
}
const participantMediaFrom = (participant: Participant): ParticipantMediaState => ({
muted: !participant.isMicrophoneEnabled,
cameraOn: participant.isCameraEnabled,
})
const deleteParticipant = (identity: string) => {
participantMediaState.update(m => new Map(reject(nthEq(0, identity), [...m])))
}
const syncParticipantMedia = (participant: Participant) => {
const state = {muted: !participant.isMicrophoneEnabled, cameraOn: participant.isCameraEnabled}
const state = participantMediaFrom(participant)
participantMediaState.update(m => {
const prev = m.get(participant.identity)
if (prev?.muted === state.muted && prev?.cameraOn === state.cameraOn) return m
@@ -92,6 +98,29 @@ const syncParticipantMedia = (participant: Participant) => {
})
}
// LiveKit does not emit ParticipantConnected/Disconnected during reconnect.
const resyncAfterReconnect = (room: LiveKitRoom) => {
if (room !== activeRoom) return
const next = new Map<string, ParticipantMediaState>()
for (const p of [room.localParticipant, ...room.remoteParticipants.values()]) {
next.set(p.identity, participantMediaFrom(p))
}
participantMediaState.set(next)
const session = get(currentVoiceSession)
if (!session) return
const {localParticipant} = room
voiceMicMuted.set(!localParticipant.isMicrophoneEnabled)
currentVoiceSession.set({
...session,
cameraOn: localParticipant.isCameraEnabled,
screenShareOn: localParticipant.isScreenShareEnabled,
})
triggerVideoFeedCount()
}
const onParticipantMediaChanged = (_publication: TrackPublication, participant: Participant) => {
syncParticipantMedia(participant)
}
@@ -191,6 +220,55 @@ const setUpMicrophone = async (
// (after switching calls or an engine reconnect give-up) must not clobber it.
let activeRoom: LiveKitRoom | undefined
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
let reconnectAttempt = 0
const clearReconnectSchedule = () => {
if (reconnectTimeout !== undefined) {
clearTimeout(reconnectTimeout)
reconnectTimeout = undefined
}
reconnectAttempt = 0
}
const attemptReconnect = async () => {
const target = get(currentVoiceRoom)
if (!target) return
try {
await joinVoiceRoom(target.url, target.h)
} catch {
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
clearReconnectSchedule()
return
}
scheduleReconnect()
}
}
const scheduleReconnect = () => {
if (reconnectTimeout !== undefined) return
if (!get(currentVoiceRoom)) return
if (reconnectAttempt >= RECONNECT_DELAYS.length) {
pushToast({theme: "error", message: "Voice connection lost."})
return
}
const delay = RECONNECT_DELAYS[reconnectAttempt]!
reconnectAttempt++
reconnectTimeout = setTimeout(() => {
reconnectTimeout = undefined
void attemptReconnect()
}, delay)
}
const makeOnRoomReconnected = (room: LiveKitRoom) => () => {
if (room !== activeRoom) return
resyncAfterReconnect(room)
}
const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason) => {
// Ignore disconnects from rooms that are no longer the active session.
if (room !== activeRoom) return
@@ -204,11 +282,14 @@ const makeOnRoomDisconnected = (room: LiveKitRoom) => (reason?: DisconnectReason
resetVideoCallLayout()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set(VoiceState.Disconnected)
const message =
reason === DisconnectReason.JOIN_FAILURE
? "Could not connect to voice room. Please try again."
: "Voice connection lost."
pushToast({theme: "error", message})
if (reason === DisconnectReason.JOIN_FAILURE) {
pushToast({theme: "error", message: "Could not connect to voice room. Please try again."})
} else if (get(currentVoiceRoom)) {
clearReconnectSchedule()
scheduleReconnect()
} else {
pushToast({theme: "error", message: "Voice connection lost."})
}
}
speakingParticipants.set([])
participantMediaState.set(new Map())
@@ -263,17 +344,22 @@ const onLocalTrackUnpublished = (
let joinAbortController: AbortController | undefined
export const cancelJoinVoiceRoom = () => {
const abortJoinVoiceRoom = () => {
joinAbortController?.abort()
}
export const cancelJoinVoiceRoom = () => {
clearReconnectSchedule()
abortJoinVoiceRoom()
}
export const joinVoiceRoom = async (
url: string,
h: string,
startMuted = true,
preferredMicId?: string,
): Promise<void> => {
cancelJoinVoiceRoom()
abortJoinVoiceRoom()
currentVoiceRoom.set(get(deriveRoom(url, h)))
voiceState.set(VoiceState.Joining)
@@ -320,6 +406,7 @@ export const joinVoiceRoom = async (
activeRoom = liveKitRoom
liveKitRoom.on(RoomEvent.Disconnected, makeOnRoomDisconnected(liveKitRoom))
liveKitRoom.on(RoomEvent.Reconnected, makeOnRoomReconnected(liveKitRoom))
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
@@ -382,10 +469,14 @@ export const joinVoiceRoom = async (
screenShareOn: false,
})
voiceState.set(VoiceState.Connected)
clearReconnectSchedule()
playJoinSound()
} catch (e) {
if (isActive()) voiceState.set(VoiceState.Disconnected)
if (e instanceof AbortError) return
if (e instanceof AbortError) {
clearReconnectSchedule()
return
}
throw e
} finally {
settle.abort()
@@ -394,6 +485,7 @@ export const joinVoiceRoom = async (
}
export const leaveVoiceRoom = async () => {
clearReconnectSchedule()
const session = get(currentVoiceSession)
if (!session) return
@@ -435,12 +527,6 @@ export const leaveVoiceRoom = async () => {
}
}
export const rejoinVoiceRoom = async (): Promise<void> => {
const target = get(currentVoiceRoom)
if (!target) return
return joinVoiceRoom(target.url, target.h)
}
export const toggleMute = async () => {
const session = get(currentVoiceSession)
if (!session) return
+1 -1
View File
@@ -88,7 +88,7 @@
<input bind:value={term} class="grow" type="text" placeholder="Search for relays..." />
</label>
<div class="column -m-6 mt-0 h-[50vh] gap-2 overflow-auto p-6 pt-2" bind:this={element}>
{#if customUrl && isShareableRelayUrl(customUrl) && !$relays.includes(normalizeRelayUrl(customUrl))}
{#if customUrl && isShareableRelayUrl(customUrl) && !$relays.includes(customUrl)}
<RelayItem url={term}>
<Button
class="btn btn-outline btn-sm flex items-center"
+1 -1
View File
@@ -127,7 +127,7 @@
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
<RoomItemContent {url} event={$innerEvent ?? event} />
{#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
<ThunkFailure showToastOnRetry {thunk} class="mt-1 flex justify-end" />
{/if}
</div>
</div>
+26 -21
View File
@@ -2,7 +2,7 @@
import {stopPropagation} from "svelte/legacy"
import {noop} from "@welshman/lib"
import type {AbstractThunk} from "@welshman/app"
import {retryThunk, thunkIsComplete, getFailedThunkUrls} from "@welshman/app"
import {flattenThunks, getFailedThunkUrls, publishThunk, thunkIsComplete} from "@welshman/app"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
@@ -16,40 +16,45 @@
class?: string
}
let {thunk, showToastOnRetry, ...restProps}: Props = $props()
const {thunk, showToastOnRetry, ...restProps}: Props = $props()
const retry = () => {
thunk = retryThunk(thunk)
const showFailure = $derived(thunkIsComplete($thunk) && getFailedThunkUrls($thunk).length > 0)
if (showToastOnRetry) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
const retry = (url: string) => {
for (const child of flattenThunks([thunk])) {
if (!child.options.relays.includes(url)) {
continue
}
const retried = publishThunk({...child.options, relays: [url]})
if (showToastOnRetry) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk: retried},
},
})
}
return
}
}
const failedUrls = $derived(getFailedThunkUrls($thunk))
const showFailure = $derived(thunkIsComplete($thunk) && failedUrls.length > 0)
</script>
{#if showFailure}
{@const url = failedUrls[0]}
{@const {status, detail: message} = $thunk.results[url]}
<button
class="flex w-full justify-end px-1 text-xs {restProps.class}"
onclick={stopPropagation(noop)}>
<Tippy
class="flex items-center"
component={ThunkStatusDetail}
props={{url, message, status, retry}}
params={{interactive: true}}>
props={{thunk, retry}}
params={{interactive: true, maxWidth: "none"}}>
{#snippet children()}
<span class="flex cursor-pointer items-center gap-1 text-error">
<Icon icon={Danger} size={3} />
<span class="flex cursor-pointer items-center gap-1 opacity-75">
<Icon icon={Danger} class="text-error" size={3} />
<span>Failed to send!</span>
</span>
{/snippet}
+3 -2
View File
@@ -6,17 +6,18 @@
interface Props {
thunk: AbstractThunk
showToastOnRetry?: boolean
class?: string
}
const {thunk, ...restProps}: Props = $props()
const {thunk, showToastOnRetry, ...restProps}: Props = $props()
const showFailure = $derived(thunkIsComplete($thunk) && getFailedThunkUrls($thunk).length > 0)
const showPending = $derived(!thunkIsComplete($thunk))
</script>
{#if showFailure}
<ThunkFailure class={restProps.class} {thunk} />
<ThunkFailure class={restProps.class} {thunk} {showToastOnRetry} />
{:else if showPending}
<ThunkPending class={restProps.class} {thunk} />
{/if}
+53 -16
View File
@@ -1,32 +1,69 @@
<script lang="ts">
import {stopPropagation} from "svelte/legacy"
import type {AbstractThunk} from "@welshman/app"
import {getFailedThunkUrls, getThunkUrlsWithStatus} from "@welshman/app"
import {PublishStatus} from "@welshman/net"
import {displayRelayUrl} from "@welshman/util"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {addPeriod} from "@lib/util"
interface Props {
url: string
status: string
message: string
retry: () => void
thunk: AbstractThunk
retry: (url: string) => void
}
let {url, status, message = $bindable(), retry}: Props = $props()
const {thunk, retry}: Props = $props()
$effect(() => {
if (!message && status === PublishStatus.Timeout) {
message = "request timed out"
const successUrls = $derived(getThunkUrlsWithStatus(PublishStatus.Success, $thunk))
const failedUrls = $derived(getFailedThunkUrls($thunk))
const total = $derived(successUrls.length + failedUrls.length)
const isPartial = $derived(successUrls.length > 0 && failedUrls.length > 0)
const title = $derived(
isPartial ? `Partial delivery ${successUrls.length}/${total} relays` : "Failed to send!",
)
const relayMessage = (status: PublishStatus | undefined, detail: string | undefined) => {
if (detail) {
return detail
}
if (!message) {
message = "no details recieved"
if (status === PublishStatus.Timeout) {
return "request timed out"
}
})
return "no details received"
}
</script>
<div class="card2 bg-alt col-2 shadow-lg">
<p>
Failed to publish to {displayRelayUrl(url)}: {addPeriod(message)}
</p>
<Button class="link" onclick={retry}>Retry</Button>
<div class="card2 bg-alt flex min-w-72 max-w-sm flex-col gap-3 px-4 py-3 shadow-lg">
<span class="flex items-center gap-2 text-sm font-medium">
<Icon icon={Danger} class="text-error" size={4} />
{title}
</span>
<div class="divider my-0"></div>
<div class="flex flex-col gap-3">
{#each successUrls as url (url)}
<div class="flex items-start gap-2 text-sm">
<Icon icon={CheckCircle} class="mt-0.5 shrink-0 text-success" size={4} />
<span>{displayRelayUrl(url)}</span>
</div>
{/each}
{#each failedUrls as url (url)}
{@const {detail, status} = $thunk.results[url] || {}}
<div class="grid grid-cols-[1rem_1fr_auto] items-start gap-x-3 gap-y-1 text-sm">
<Icon icon={Danger} class="mt-0.5 text-error" size={4} />
<div class="min-w-0">
<p class="break-all">{displayRelayUrl(url)}</p>
<p class="text-xs opacity-60">{addPeriod(relayMessage(status, detail))}</p>
</div>
<Button class="link shrink-0 px-1" onclick={stopPropagation(() => retry(url))}>
Retry
</Button>
</div>
{/each}
</div>
</div>
+1 -1
View File
@@ -755,7 +755,7 @@ export const getSpaceUrlsFromGroupList = (groupList: List | undefined) => {
}
}
return uniq(urls.map(normalizeRelayUrl))
return uniqBy(normalizeRelayUrl, urls)
}
export const getSpaceRoomsFromGroupList = (url: string, groupList: List | undefined) => {
+91
View File
@@ -0,0 +1,91 @@
import {on} from "@welshman/lib"
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {AbstractAdapter, AdapterEvent, LocalAdapter, Repository, netContext} from "@welshman/net"
import type {ClientMessage, NetContext, RelayMessage} from "@welshman/net"
// The window key Playwright writes the mock config to (see e2e/support/relayMocks.ts). Keep it in
// sync with the literal duplicated there.
export const RELAY_MOCKS_KEY = "__RELAY_MOCKS__"
export type RelayMockConfig = {
// Map of relay url -> events that relay should return. Any relay NOT listed returns nothing (an
// immediate EOSE), which is what keeps tests offline and reproducible by default.
relays?: Record<string, TrustedEvent[]>
}
// Wraps welshman's LocalAdapter so we reuse its REQ/EVENT/CLOSE handling against an in-memory
// Repository, but re-emits its messages under the real relay url instead of LOCAL_RELAY_URL. That
// keeps relay attribution / relay-scoped behaviour (e.g. NIP-29 groups) working as it would over a
// real socket. (Composition rather than inheritance because LocalAdapter emits via a private method
// that hardcodes LOCAL_RELAY_URL.)
class FixtureAdapter extends AbstractAdapter {
readonly local: LocalAdapter
constructor(
readonly url: string,
repository: Repository,
) {
super()
this.local = new LocalAdapter(repository)
const forward = (message: RelayMessage) => this.emit(AdapterEvent.Receive, message, this.url)
this._unsubscribers.push(on(this.local, AdapterEvent.Receive, forward), () =>
this.local.cleanup(),
)
}
get sockets() {
return this.local.sockets
}
get urls() {
return [this.url]
}
send(message: ClientMessage) {
this.local.send(message)
}
}
// Override netContext.getAdapter so every real relay url is served from memory and no websocket is
// ever created for it. Non-relay urls (e.g. the local:// repository relay) fall through to
// welshman's default handling.
export const installRelayMocks = (config: RelayMockConfig) => {
const reposByUrl = new Map<string, Repository>()
for (const [url, events] of Object.entries(config.relays ?? {})) {
const repository = new Repository()
repository.load(events)
reposByUrl.set(normalizeRelayUrl(url), repository)
}
const emptyRepository = new Repository()
const fallback = netContext.getAdapter
netContext.getAdapter = ((url: string, context: NetContext) => {
if (!isRelayUrl(url)) {
return fallback ? fallback(url, context) : undefined
}
const repository = reposByUrl.get(normalizeRelayUrl(url)) ?? emptyRepository
return new FixtureAdapter(url, repository)
}) as NetContext["getAdapter"]
}
// Called once on app startup. Installs the mocks only when Playwright has injected a config, so it
// is a no-op for real users.
export const maybeInstallRelayMocks = () => {
const config = (globalThis as Record<string, unknown>)[RELAY_MOCKS_KEY] as
| RelayMockConfig
| undefined
if (config) {
installRelayMocks(config)
}
return Boolean(config)
}
+7
View File
@@ -23,6 +23,7 @@
import * as app from "@welshman/app"
import {isMobile} from "@lib/html"
import * as implicit from "@lib/implicit"
import {maybeInstallRelayMocks} from "@lib/test/relayMocks"
import AppContainer from "@app/components/AppContainer.svelte"
import ModalContainer from "@app/components/ModalContainer.svelte"
import {setupHistory} from "@app/util/history"
@@ -46,6 +47,12 @@
const {children} = $props()
// Test-only: when Playwright has injected window.__RELAY_MOCKS__, serve relays from in-memory
// fixtures instead of the network. No-op for real users; stripped from production builds.
if (import.meta.env.DEV) {
maybeInstallRelayMocks()
}
const policies = [authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy]
// Add stuff to window for convenience