fix(ui): poll for out-of-band email confirmation #180
@@ -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