From fb7490f1dfded149a199009c4634b6bbda3c8ee4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 5 Jul 2026 13:58:39 +0200 Subject: [PATCH] fix(ui): poll for out-of-band email confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An email link/code can be confirmed out of band: the recipient taps the one-tap link in the email, which confirms in another browser/session. The backend already publishes a `notify` `profile` re-fetch signal for this (handlers_auth.go handleEmailConfirmLink), and the client re-fetches on it. But the live stream is single-shot with no replay: a Mini App backgrounded while the user is in their mail app tapping the link drops the stream and misses the event (the gateway hub has no subscriber to deliver to), and the reconnect on foreground does not re-sync — so the open code form stayed until a manual reload. On Telegram Desktop the app is never backgrounded, so the push works and there was no bug. Add a client-side fallback: while an add-email confirmation is pending (a code was sent, no email yet), poll `profile.get` on a 4s interval and on foreground regain until the address lands; the effect stops as soon as the email appears. The live push still updates instantly when foregrounded — this only covers the backgrounded-miss gap. Tests: a mock e2e attaches the email WITHOUT emitting a live event (new window.__mock.clearEmail / confirmEmailOutOfBand seams), so it exercises the poll, not the push, and asserts the code form collapses into the email row. Docs: ARCHITECTURE.md §10 notes the single-shot gap + the poll fallback. --- docs/ARCHITECTURE.md | 5 ++++- ui/e2e/social.spec.ts | 30 ++++++++++++++++++++++++++++++ ui/src/lib/gateway.ts | 4 ++++ ui/src/lib/mock/client.ts | 11 +++++++++++ ui/src/screens/Profile.svelte | 22 ++++++++++++++++++++++ 5 files changed, 71 insertions(+), 1 deletion(-) 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) {