Release v1.10.0: banner colours + urgent, and 3 UI fixes #183

Merged
developer merged 8 commits from development into master 2026-07-05 14:10:25 +00:00
5 changed files with 71 additions and 1 deletions
Showing only changes of commit fb7490f1df - Show all commits
+4 -1
View File
@@ -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
+30
View File
@@ -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);
+4
View File
@@ -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),
};
}
+11
View File
@@ -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;
+22
View File
@@ -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) {