Merge pull request 'fix(ui): poll for out-of-band email confirmation' (#180) from feature/email-confirm-poll-fallback into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m45s
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m45s
This commit was merged in pull request #180.
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
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<LinkResult> {
|
||||
this.profile.isGuest = false;
|
||||
this.profile.email = email;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user