fix(ui): poll for out-of-band email confirmation
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
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.
This commit is contained in:
@@ -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
|
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
|
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
|
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
|
> 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
|
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
|
||||||
|
|||||||
@@ -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);
|
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 }) => {
|
test('link then unlink Telegram from the sign-in methods', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openProfile(page);
|
await openProfile(page);
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
joinOpponentSilently(): void;
|
joinOpponentSilently(): void;
|
||||||
adminReply(): void;
|
adminReply(): void;
|
||||||
setGameLimit(v: boolean): void;
|
setGameLimit(v: boolean): void;
|
||||||
|
clearEmail(): void;
|
||||||
|
confirmEmailOutOfBand(email: string): void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
).__mock = {
|
).__mock = {
|
||||||
@@ -46,5 +48,7 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
|
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
|
||||||
adminReply: () => (gateway as MockGateway).mockAdminReply(),
|
adminReply: () => (gateway as MockGateway).mockAdminReply(),
|
||||||
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
|
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
|
||||||
|
clearEmail: () => (gateway as MockGateway).mockClearEmail(),
|
||||||
|
confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -659,6 +659,17 @@ export class MockGateway implements GatewayClient {
|
|||||||
this.profile.email = email;
|
this.profile.email = email;
|
||||||
return emptyLinked();
|
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<LinkResult> {
|
async linkEmailMerge(email: string, _code: string): Promise<LinkResult> {
|
||||||
this.profile.isGuest = false;
|
this.profile.isGuest = false;
|
||||||
this.profile.email = email;
|
this.profile.email = email;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
confirmDeleteAccount,
|
confirmDeleteAccount,
|
||||||
handleError,
|
handleError,
|
||||||
logout,
|
logout,
|
||||||
|
refreshProfile,
|
||||||
requestDeleteAccount,
|
requestDeleteAccount,
|
||||||
showToast,
|
showToast,
|
||||||
} from '../lib/app.svelte';
|
} from '../lib/app.svelte';
|
||||||
@@ -112,6 +113,27 @@
|
|||||||
);
|
);
|
||||||
const canUnlink = $derived(linkedCount >= 2);
|
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 —
|
// toggleVariant flips a variant in the preference set, refusing to remove the last one —
|
||||||
// the player must allow themselves at least one variant.
|
// the player must allow themselves at least one variant.
|
||||||
function toggleVariant(id: Variant) {
|
function toggleVariant(id: Variant) {
|
||||||
|
|||||||
Reference in New Issue
Block a user