diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f053c97..c92b9e4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -983,7 +983,10 @@ and the open client re-fetches `profile.get` to show or hide the banner in place edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's own account changed out of band (an email confirmed through the one-tap deeplink opened in another -browser), so an open in-app session reflects it at once. +browser), so an open in-app session reflects it at once. The live stream is single-shot with no +replay, so a **backgrounded Mini App** — suspended while the user is in their mail app tapping the +link — misses the event; while an add-email confirmation is pending the client therefore also +**polls `profile.get`** (and on foreground regain) as a fallback until the address lands. > A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into > one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index ac06423..415056f 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -327,6 +327,36 @@ test('change email: a free address replaces the current one', async ({ page }) = await expect(page.getByText('you@example.com')).toHaveCount(0); }); +// The add-email confirmation can complete out of band: the recipient taps the one-tap link in the +// email, which confirms in another browser/session. A backgrounded Mini App misses the live +// 'profile' event (the stream is single-shot, no replay), so the open code form falls back to +// polling the profile until the address lands. The mock attaches the email WITHOUT emitting an +// event, so only the poll can surface it. +test('add email: an out-of-band confirmation surfaces on the open form via polling', async ({ page }) => { + await loginLobby(page); + await openProfile(page); + + // Drop the seeded email so the sign-in section offers the add-email flow. + await page.evaluate(() => (window as unknown as { __mock: { clearEmail(): void } }).__mock.clearEmail()); + const emailInput = page.locator('.accounts input[type="email"]'); + await expect(emailInput).toBeVisible(); + + await emailInput.fill('linked@example.com'); + await page.getByRole('button', { name: 'Send code' }).click(); + await expect(page.locator('.accounts .codein')).toBeVisible(); + + // Confirmed elsewhere via the one-tap link, with no live event delivered: only the poll surfaces it. + await page.evaluate(() => + ( + window as unknown as { __mock: { confirmEmailOutOfBand(email: string): void } } + ).__mock.confirmEmailOutOfBand('linked@example.com'), + ); + + // The confirmed address surfaces as the email row, and the code form is gone. + await expect(page.locator('.acctrow').filter({ hasText: 'linked@example.com' })).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.accounts .codein')).toHaveCount(0); +}); + test('link then unlink Telegram from the sign-in methods', async ({ page }) => { await loginLobby(page); await openProfile(page); diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts index a8bef98..02a9482 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -39,6 +39,8 @@ if (isMock && typeof window !== 'undefined') { joinOpponentSilently(): void; adminReply(): void; setGameLimit(v: boolean): void; + clearEmail(): void; + confirmEmailOutOfBand(email: string): void; }; } ).__mock = { @@ -46,5 +48,7 @@ if (isMock && typeof window !== 'undefined') { joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(), adminReply: () => (gateway as MockGateway).mockAdminReply(), setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v), + clearEmail: () => (gateway as MockGateway).mockClearEmail(), + confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email), }; } diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index b84bb95..d0d589b 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -659,6 +659,17 @@ export class MockGateway implements GatewayClient { this.profile.email = email; return emptyLinked(); } + // e2e hooks (window.__mock, see lib/gateway.ts): mockClearEmail drops the seeded email so the + // sign-in section offers the add-email flow; mockConfirmEmailOutOfBand attaches the address + // WITHOUT a live event — standing in for the one-tap confirm link opened in another browser, + // so the open code form must reflect it by polling the profile, not by a push. + mockClearEmail(): void { + this.profile.email = ''; + this.emit({ kind: 'notify', sub: 'profile' }); + } + mockConfirmEmailOutOfBand(email: string): void { + this.profile.email = email; + } async linkEmailMerge(email: string, _code: string): Promise { this.profile.isGuest = false; this.profile.email = email; diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index 525be5f..ab15882 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -7,6 +7,7 @@ confirmDeleteAccount, handleError, logout, + refreshProfile, requestDeleteAccount, showToast, } from '../lib/app.svelte'; @@ -112,6 +113,27 @@ ); const canUnlink = $derived(linkedCount >= 2); + // While an add-email confirmation is pending, the address may be confirmed out of band — the + // one-tap link in the email confirms in another browser/session. A backgrounded Mini App drops + // the single-shot live stream and misses the 'profile' event (there is no replay), so poll the + // profile as a fallback until the address lands; the live push still updates it instantly while + // the app stays foregrounded. Scoped to the add-email wait (a code sent, no email yet). + const awaitingEmailConfirm = $derived(emailSent && !app.profile?.email); + $effect(() => { + if (!awaitingEmailConfirm) return; + const poll = (): void => void refreshProfile(); + const id = setInterval(poll, 4000); + // Re-check the moment the Mini App returns to the foreground, where the missed event landed. + const onVisible = (): void => { + if (document.visibilityState === 'visible') poll(); + }; + document.addEventListener('visibilitychange', onVisible); + return () => { + clearInterval(id); + document.removeEventListener('visibilitychange', onVisible); + }; + }); + // toggleVariant flips a variant in the preference set, refusing to remove the last one — // the player must allow themselves at least one variant. function toggleVariant(id: Variant) {