diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 491cc929..3cf69caf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - run: npm run build - run: npx playwright install --with-deps chromium - run: npm run test:e2e:pwa - - run: npm run test:e2e:local -- e2e/app.spec.ts e2e/dm-relay-discovery.spec.ts e2e/group-history.spec.ts e2e/public-groups.spec.ts e2e/message-formatting.spec.ts e2e/parity/dm/link-preview.spec.ts + - run: npm run test:e2e:local -- e2e/app.spec.ts e2e/blossom-server-list.spec.ts e2e/dm-relay-discovery.spec.ts e2e/group-history.spec.ts e2e/public-groups.spec.ts e2e/message-formatting.spec.ts e2e/parity/dm/link-preview.spec.ts - uses: actions/upload-artifact@v4 if: failure() with: diff --git a/e2e/blossom-server-list.spec.ts b/e2e/blossom-server-list.spec.ts new file mode 100644 index 00000000..fe6f46f7 --- /dev/null +++ b/e2e/blossom-server-list.spec.ts @@ -0,0 +1,102 @@ +import { expect, test, type Page } from '@playwright/test'; +import { finalizeEvent } from 'nostr-tools'; +import { WebSocket } from 'ws'; +import { bootstrapUser, disposeUsers, E2E_RELAY_URL, TEST_ACCOUNTS } from './parity/helpers'; + +const DEFAULT_SERVER = 'https://blossom.nostr.build'; +const PUBLISHED_SERVER = 'https://published.example.org'; +const SECOND_PUBLISHED_SERVER = 'https://second.example.org'; +const UPDATED_SERVER = 'https://updated.example.org'; + +// The app never publishes a server list itself, so the test publishes one the way another +// client would: a signed kind 10063 event with one "server" tag per Blossom server. +async function publishServerList( + privateKey: string, + servers: string[], + createdAt: number, +): Promise { + const socket = new WebSocket(E2E_RELAY_URL); + await new Promise((resolve, reject) => { + socket.once('open', () => resolve()); + socket.once('error', reject); + }); + const event = finalizeEvent( + { + kind: 10063, + created_at: createdAt, + tags: servers.map((server) => ['server', server]), + content: '', + }, + Uint8Array.from(Buffer.from(privateKey, 'hex')), + ); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Relay accepted no server list')), 10_000); + socket.on('message', (data) => { + const [verb, id] = JSON.parse(data.toString()) as [string, string]; + if (verb === 'OK' && id === event.id) { + clearTimeout(timer); + resolve(); + } + }); + socket.send(JSON.stringify(['EVENT', event])); + }); + socket.close(); +} + +function uploadServer(page: Page) { + return page.evaluate(async () => { + const { useNostrStore } = await import('/src/stores/nostrStore.svelte.ts'); + return useNostrStore().getBlossomServerUrl(); + }); +} + +test('uses the Blossom server list published to relays, with the saved setting and default as fallbacks', async ({ + browser, +}) => { + const user = await bootstrapUser(browser, TEST_ACCOUNTS.blossomServerList); + try { + const { page } = user; + await page.getByRole('button', { name: 'settings', exact: true }).click(); + await page.getByTestId('settings-media-data-storage-item').click(); + const serverInput = page.getByTestId('settings-blossom-server-input'); + const sourceNote = page.getByTestId('settings-blossom-server-source'); + + // No list published: the built-in default, and no claim about a published list. + await expect(serverInput).toHaveValue(DEFAULT_SERVER); + await expect(sourceNote).toHaveCount(0); + + // A list published while the app is running is picked up without a reload. Relays keep + // only the newest replaceable event, so each list in this test is dated later than the one + // before it, the way a second client publishing a replacement would date it. + const publishedAt = Math.floor(Date.now() / 1000); + await publishServerList( + user.account.privateKey, + [PUBLISHED_SERVER, SECOND_PUBLISHED_SERVER], + publishedAt, + ); + await expect.poll(() => uploadServer(page), { timeout: 15_000 }).toBe(PUBLISHED_SERVER); + + await publishServerList(user.account.privateKey, [UPDATED_SERVER], publishedAt + 60); + await expect.poll(() => uploadServer(page), { timeout: 15_000 }).toBe(UPDATED_SERVER); + await page.reload(); + await expect(serverInput).toBeEnabled(); + await expect(serverInput).toHaveValue(UPDATED_SERVER); + await expect(sourceNote).toBeVisible(); + + // A saved choice still wins over the published list, and restoring the default + // hands the decision back to the list. + await serverInput.fill('https://saved.example.com'); + await page.getByTestId('settings-blossom-save').click(); + await expect + .poll(() => uploadServer(page), { timeout: 15_000 }) + .toBe('https://saved.example.com'); + await expect(sourceNote).toHaveCount(0); + + await page.getByTestId('settings-blossom-restore-default').click(); + await expect.poll(() => uploadServer(page), { timeout: 15_000 }).toBe(UPDATED_SERVER); + await expect(serverInput).toHaveValue(UPDATED_SERVER); + await expect(sourceNote).toBeVisible(); + } finally { + await disposeUsers(user); + } +}); diff --git a/src/lib/components/settings/PreferenceSettings.svelte b/src/lib/components/settings/PreferenceSettings.svelte index 5bae8ecb..7fd4cec5 100644 --- a/src/lib/components/settings/PreferenceSettings.svelte +++ b/src/lib/components/settings/PreferenceSettings.svelte @@ -18,19 +18,34 @@ export let section: string; const nostr = useNostrStore(); const startup = toStore(() => nostr.isRestoringStartupState); + const publishedServerList = toStore(() => nostr.blossomServerListVersion); let dark = readDarkModePreference() ?? document.body.classList.contains('body--dark'); let layout = readDesktopMessageLayoutPreference(); let accent = readThemeAccent(); let saved = nostr.getBlossomServerUrl(), + source = nostr.getBlossomServerOrigin(), server = saved, busy = false, error = '', notice = ''; + let appliedPublishedList = -1; $: normalized = normalizeBlossomServerUrl(server); - $: if (!$startup && !busy && server === saved) { + function reloadServerFromStore(): void { saved = nostr.getBlossomServerUrl(); + source = nostr.getBlossomServerOrigin(); server = saved; } + $: if (!$startup && !busy && server === saved) { + reloadServerFromStore(); + } + // The published server list can arrive while this screen is open, and it decides the + // server the next upload uses. + $: if (appliedPublishedList !== $publishedServerList) { + appliedPublishedList = $publishedServerList; + if (!$startup && !busy && server === saved) { + reloadServerFromStore(); + } + } async function persist(value: string) { if (busy) return; busy = true; @@ -38,6 +53,7 @@ notice = ''; try { saved = await nostr.saveBlossomServerUrl(value); + source = nostr.getBlossomServerOrigin(); server = saved; notice = $translate('mediaDataStorage.serverSaved'); } catch { @@ -49,8 +65,7 @@ function sync() { accent = readThemeAccent(); if (!busy && server === saved) { - saved = nostr.getBlossomServerUrl(); - server = saved; + reloadServerFromStore(); } } onMount(() => { @@ -157,6 +172,11 @@ autocapitalize="none" />{$translate('mediaDataStorage.serverUrlHint')} + {#if source === 'published'}{$translate('mediaDataStorage.serverUrlFromPublishedList')}{/if} {#if !normalized}

{$translate( server.trim() @@ -169,9 +189,7 @@ type="button" class="outline" data-testid="settings-blossom-restore-default" - disabled={busy || - $startup || - (server === DEFAULT_BLOSSOM_SERVER_URL && saved === DEFAULT_BLOSSOM_SERVER_URL)} + disabled={busy || $startup || source !== 'preference'} onclick={() => persist(DEFAULT_BLOSSOM_SERVER_URL)} >{$translate('mediaDataStorage.restoreDefault')}