Round-6 follow-up: UX polish + client-IP fix #26

Merged
developer merged 8 commits from feature/ux-polish-ipfix into development 2026-06-08 21:40:13 +00:00
12 changed files with 330 additions and 187 deletions
Showing only changes of commit 70110effd9 - Show all commits
+10 -4
View File
@@ -1403,10 +1403,16 @@ provided cert) at the contour caddy; prod VPN; rollback.
- **Edge-swipe back** (`Screen.svelte`): a left-edge rightward drag navigates to `back`
(touch/pen only, armed only from ≤24px so it never fights the board's gestures; skipped
inside Telegram, which has its own back).
- **Chat soft-keyboard** is a **bottom-sheet** `Modal` lifted above the keyboard by a
`transform` driven by `visualViewport` (compositor-only — the board behind and the sheet
no longer relayout as the keyboard animates). iOS-specific; needs on-device fine-tuning.
The native `Keyboard.setResizeMode('none')` path waits for Capacitor (not yet wired).
- **Chat + word-check are now their own routed screens** (`/game/:id/chat`,
`/game/:id/check`, header back to the game, no tab-bar) so the soft keyboard simply resizes
the **visible viewport** — mirrored into a `--vvh` CSS var the `Screen` height uses, since
iOS doesn't shrink `dvh` for the keyboard — with the input pinned to the bottom: no modal
relayout, no page jump (this superseded a first bottom-sheet-`Modal` attempt). New chat
messages raise an **unread badge** on the in-game hamburger + the Chat menu row (per game,
cleared on open), mirroring the lobby badge; the chat screen is routable for a future
Telegram deep-link. The TG-fullscreen header was also finalised over a couple of review
passes: title + menu as a centred pair inside Telegram's nav band (between `--tg-safe-top`
and `--tg-content-top`), with a small padding bump so the native controls aren't flush.
- **Tests backfilled** for the merged round-6 work: e2e for the in-game "✓ in friends" item
and a board→board tile relocation; codec units for `last_activity_unix` + `OutgoingRequestList`.
- **Deferred to the next PR (agreed):** #4 enrich the out-of-app "your turn" / game-end push
+2
View File
@@ -125,6 +125,8 @@ existing friendship). Per-game chat is for quick reactions: messages are short
(up to 60 characters) and may not contain links, email addresses or phone numbers,
even disguised. Nudge the player whose turn is awaited at most once per hour (the
nudge is part of the game chat); the out-of-app push is delivered via the platform.
Chat and the word-check tool open as their **own screens** (with a back to the game), and a
new chat message raises an **unread badge** on the game's menu until the chat is opened.
### Profile & settings *(Stage 4 / 8)*
Edit the display name (letters joined by a single space / "." / "_" separator, with an
+2
View File
@@ -128,6 +128,8 @@ Mini App** авторизует по подписанным `initData` плат
содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
push доставляется через платформу.
Чат и инструмент проверки слова открываются **отдельными экранами** (с кнопкой «назад» в
партию), а новое сообщение в чате рисует **бейдж непрочитанного** на меню партии до открытия чата.
### Профиль и настройки *(Stage 4 / 8)*
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
+19
View File
@@ -162,6 +162,25 @@ test('a placed tile drags from one board cell to another (Stage 17 relocation)',
expect(to).not.toBe(from);
});
test('chat and word-check open as their own screens and back to the game (Stage 17)', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.getByRole('button', { name: /^Chat$/ }).click();
await expect(page).toHaveURL(/\/game\/g1\/chat$/);
await expect(page.locator('.pane')).toHaveCount(1); // let the slide transition settle
await expect(page.locator('.chat')).toBeVisible();
await page.locator('.back').click(); // header back chevron returns to the game
await expect(page).toHaveURL(/\/game\/g1$/);
await expect(page.locator('.pane')).toHaveCount(1);
await page.locator('.burger').click();
await page.getByRole('button', { name: /Check word/ }).click();
await expect(page).toHaveURL(/\/game\/g1\/check$/);
await expect(page.locator('.pane')).toHaveCount(1);
await expect(page.locator('.check input')).toBeVisible();
});
test('the board-label mode in Settings changes the on-board labels', async ({ page }) => {
await openGame(page);
// beginner (default) renders split "3× / word" labels.
+11 -2
View File
@@ -15,6 +15,8 @@
import Friends from './screens/Friends.svelte';
import Stats from './screens/Stats.svelte';
import Game from './game/Game.svelte';
import ChatScreen from './game/ChatScreen.svelte';
import CheckScreen from './game/CheckScreen.svelte';
onMount(() => {
void bootstrap();
@@ -25,8 +27,11 @@
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
$effect(() => {
if (!insideTelegram()) return;
const name = router.route.name;
telegramBackButton(name !== 'lobby' && name !== 'login', () => navigate('/'));
const r = router.route;
// The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
const sub = r.name === 'gameChat' || r.name === 'gameCheck';
const target = sub ? `/game/${r.params.id}` : '/';
telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
});
// Screen transitions: the lobby is the navigation root. Entering a screen from the
@@ -63,6 +68,10 @@
<NewGame />
{:else if router.route.name === 'game'}
<Game id={router.route.params.id} />
{:else if router.route.name === 'gameChat'}
<ChatScreen id={router.route.params.id} />
{:else if router.route.name === 'gameCheck'}
<CheckScreen id={router.route.params.id} />
{:else if router.route.name === 'profile'}
<Profile />
{:else if router.route.name === 'settings'}
+4 -1
View File
@@ -70,7 +70,10 @@
.screen {
display: flex;
flex-direction: column;
height: 100%;
/* Fit the visible viewport (set from visualViewport, app.svelte.ts) so a screen with a
bottom input — chat, word-check — stays above an open soft keyboard without the page
scrolling; falls back to the full height where the var is unset (Stage 17). */
height: var(--vvh, 100%);
}
.content {
flex: 0 1 auto;
+7 -4
View File
@@ -69,10 +69,13 @@
display: flex;
flex-direction: column;
gap: 10px;
/* dvh so the chat shrinks with an open keyboard, keeping the start of the
conversation on screen instead of pushed above the fold (vh fallback). */
height: 56vh;
height: 56dvh;
/* Fill the chat screen; the list scrolls and the input pins to the bottom. The screen
fits the visual viewport (--vvh), so an open keyboard simply shrinks it and the input
stays visible — no modal relayout, no page jump (Stage 17). */
flex: 1;
min-height: 0;
padding: 10px var(--pad);
box-sizing: border-box;
}
.list {
flex: 1;
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, clearChatUnread } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, StateView } from '../lib/model';
// The chat is its own screen (Stage 17), so the soft keyboard simply resizes the viewport with
// the input pinned to the bottom — no modal relayout jank. It loads the game state (for the
// turn-based chat/nudge toggle) and the message list, and clears the unread badge while open.
let { id }: { id: string } = $props();
let view = $state<StateView | null>(null);
let messages = $state<ChatMessage[]>([]);
let busy = $state(false);
let tick = $state(0);
const myId = $derived(app.session?.userId ?? '');
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const nudgeCooldownSecs = 3600;
// The nudge is one-per-hour-per-game and clears once the player chats (engagement); the
// backend stays authoritative, so a move-based reset is left to it.
const nudgeOnCooldown = $derived.by(() => {
void tick;
let lastNudge = 0;
let lastChat = 0;
for (const m of messages) {
if (m.senderId !== myId) continue;
if (m.kind === 'nudge') lastNudge = Math.max(lastNudge, m.createdAtUnix);
else lastChat = Math.max(lastChat, m.createdAtUnix);
}
if (lastNudge === 0 || Date.now() / 1000 - lastNudge >= nudgeCooldownSecs) return false;
return lastChat <= lastNudge;
});
async function refresh() {
try {
messages = await gateway.chatList(id);
clearChatUnread(id);
} catch {
/* best-effort */
}
}
onMount(async () => {
try {
view = await gateway.gameState(id, false);
} catch (e) {
handleError(e);
}
await refresh();
});
// Live: refresh (and keep the unread cleared) on a chat / nudge for this game.
$effect(() => {
const e = app.lastEvent;
if (!e) return;
if ((e.kind === 'chat_message' && e.message.gameId === id) || (e.kind === 'nudge' && e.gameId === id)) {
void refresh();
}
});
// Re-evaluate the nudge cooldown on a timer so the control re-enables on time.
$effect(() => {
const h = setInterval(() => (tick += 1), 20000);
return () => clearInterval(h);
});
async function sendChat(text: string) {
busy = true;
try {
messages = [...messages, await gateway.chatPost(id, text)];
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
async function nudge() {
busy = true;
try {
messages = [...messages, await gateway.nudge(id)];
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
</script>
<Screen title={t('game.chat')} back={`/game/${id}`} scroll={false} column>
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
</Screen>
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import type { Variant } from '../lib/model';
// Word-check on its own screen (Stage 17): unlimited dictionary lookups, each with a
// complaint, off the board so the soft keyboard never relayouts the play area.
let { id }: { id: string } = $props();
let variant = $state<Variant>('english');
let word = $state('');
let result = $state<{ word: string; legal: boolean } | null>(null);
let cooling = $state(false);
const checked = new Map<string, boolean>();
onMount(async () => {
try {
// Include the alphabet so input sanitising + the check accept the variant's letters.
const st = await gateway.gameState(id, true);
variant = st.game.variant;
} catch (e) {
handleError(e);
}
});
function onInput(e: Event) {
word = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
}
// Disabled while cooling, for an already-checked word, or an out-of-range length.
function canCheck(): boolean {
return canCheckWord(word, checked.has(word.trim().toUpperCase()), cooling);
}
async function runCheck() {
if (!canCheck()) return;
const w = word.trim().toUpperCase();
cooling = true;
setTimeout(() => (cooling = false), 5000);
try {
const r = await gateway.checkWord(id, w, variant);
checked.set(w, r.legal);
result = { word: w, legal: r.legal };
} catch (e) {
handleError(e);
}
}
async function complain() {
if (!result) return;
try {
await gateway.complaint(id, result.word, '');
showToast(t('game.complaintSent'));
result = null;
} catch (e) {
handleError(e);
}
}
</script>
<Screen title={t('game.checkWord')} back={`/game/${id}`}>
<div class="wrap">
<div class="check">
<input
value={word}
oninput={onInput}
onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')}
/>
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div>
{#if result}
<p class="verdict" class:ok={result.legal} class:bad={!result.legal}>
{result.legal
? t('game.wordLegal', { word: result.word })
: t('game.wordIllegal', { word: result.word })}
</p>
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if}
</div>
</Screen>
<style>
.wrap {
padding: 16px var(--pad);
display: flex;
flex-direction: column;
gap: 14px;
}
.check {
display: flex;
gap: 8px;
}
.check input {
flex: 1;
min-width: 0;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
text-transform: uppercase;
}
.check button {
padding: 10px 16px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
.check button:disabled {
opacity: 0.5;
}
.verdict {
margin: 0;
font-weight: 600;
}
.verdict.ok {
color: var(--ok, #2e7d32);
}
.verdict.bad {
color: var(--danger, #c0392b);
}
.complain {
align-self: flex-start;
padding: 8px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
</style>
+5 -174
View File
@@ -7,17 +7,16 @@
import Modal from '../components/Modal.svelte';
import Board from './Board.svelte';
import Rack from './Rack.svelte';
import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model';
import type { Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model';
import { replay } from '../lib/board';
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame } from '../lib/gamecache';
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
@@ -50,21 +49,13 @@
// tiles fly to their new positions (Rack's hop animation) instead of relabelling in place.
let rackIds = $state<number[]>([]);
let shuffling = $state(false);
let panel = $state<'none' | 'chat'>('none');
let historyOpen = $state(false);
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
let exchangeOpen = $state(false);
let exchangeSel = $state<number[]>([]);
let checkOpen = $state(false);
let checkWord = $state('');
let checkResult = $state<{ word: string; legal: boolean } | null>(null);
let resignOpen = $state(false);
let messages = $state<ChatMessage[]>([]);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
const checkedWords = new Map<string, boolean>();
let cooling = $state(false);
const variant = $derived(view?.game.variant ?? 'english');
const board = $derived(replay(moves));
const premium = $derived(premiumGrid(variant));
@@ -96,29 +87,6 @@
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status !== 'active');
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
// Nudge cooldown (one per hour per game, mirrored from the backend): the control is
// disabled for an hour after the player's own last nudge. nudgeTick re-evaluates it on a
// timer while the chat is open, so it re-enables without waiting for a new message.
const nudgeCooldownSecs = 3600;
let nudgeTick = $state(0);
// Unix seconds of the player's own last move, which resets the nudge cooldown (mirrors the
// backend, Stage 17). A chat reset is read from `messages`; a move is tracked client-side
// (the backend stays authoritative across a reload).
let lastActedAt = $state(0);
const nudgeOnCooldown = $derived.by(() => {
void nudgeTick;
const mine = app.session?.userId ?? '';
let lastNudge = 0;
let lastChat = 0;
for (const m of messages) {
if (m.senderId !== mine) continue;
if (m.kind === 'nudge') lastNudge = Math.max(lastNudge, m.createdAtUnix);
else lastChat = Math.max(lastChat, m.createdAtUnix);
}
if (lastNudge === 0 || Date.now() / 1000 - lastNudge >= nudgeCooldownSecs) return false;
// Engagement since the nudge clears the cooldown: a chat or a move.
return lastChat <= lastNudge && lastActedAt <= lastNudge;
});
async function load() {
try {
@@ -169,13 +137,6 @@
const rack = order.map((i) => st.rack[i]);
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
}
async function loadChat() {
try {
messages = await gateway.chatList(id);
} catch (e) {
handleError(e);
}
}
onMount(() => {
// Guard against an accidental swipe-close losing the open game (Telegram).
telegramClosingConfirmation(true);
@@ -200,20 +161,11 @@
// player's other devices): this device already reloaded after the submit.
if (e.seat !== view?.seat) void load();
} else if (e.kind === 'your_turn' && e.gameId === id) void load();
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
// A request the player sent was answered (accepted -> now friends; declined -> stays
// "request sent"): re-derive the in-game friend state.
else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) void loadFriends();
});
// Tick the nudge cooldown while the chat is open so the control re-enables on time.
$effect(() => {
if (panel !== 'chat') return;
const h = setInterval(() => (nudgeTick += 1), 20000);
return () => clearInterval(h);
});
function isCoarse(): boolean {
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
}
@@ -499,7 +451,6 @@
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
telegramHaptic('success');
zoomed = false;
await load();
@@ -521,7 +472,6 @@
busy = true;
try {
await gateway.pass(id);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
await load();
} catch (e) {
handleError(e);
@@ -603,7 +553,6 @@
busy = true;
try {
await gateway.exchange(id, tiles, variant);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
await load();
} catch (e) {
handleError(e);
@@ -612,64 +561,6 @@
}
}
function openCheck() {
checkWord = '';
checkResult = null;
checkOpen = true;
}
function onCheckInput(e: Event) {
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
}
// Check is disabled while cooling down, for an already-checked word, or an out-of-range
// length. The input filter already restricts to the variant's alphabet.
function canCheck(): boolean {
return canCheckWord(checkWord, checkedWords.has(checkWord.trim().toUpperCase()), cooling);
}
async function runCheck() {
if (!canCheck()) return;
const w = checkWord.trim().toUpperCase();
cooling = true;
setTimeout(() => (cooling = false), 5000);
try {
const r = await gateway.checkWord(id, w, variant);
// Key the cache and the displayed result on the upper-case word the player typed; the
// server echoes the decoded concrete word in the solver's lower case.
checkedWords.set(w, r.legal);
checkResult = { word: w, legal: r.legal };
} catch (e) {
handleError(e);
}
}
async function complain() {
if (!checkResult) return;
try {
await gateway.complaint(id, checkResult.word, '');
showToast(t('game.complaintSent'));
checkOpen = false;
} catch (e) {
handleError(e);
}
}
function openChat() {
panel = 'chat';
void loadChat();
}
async function sendChat(text: string) {
try {
messages = [...messages, await gateway.chatPost(id, text)];
} catch (e) {
handleError(e);
}
}
async function nudge() {
try {
messages = [...messages, await gateway.nudge(id)];
} catch (e) {
handleError(e);
}
}
function resultText(): string {
if (!view) return '';
const me = view.game.seats[view.seat];
@@ -724,8 +615,8 @@
// an "add to friends" item flips to a disabled "request sent" once tapped.
const menuItems = $derived([
{ label: t('game.history'), onclick: () => (historyOpen = true) },
{ label: t('game.chat'), onclick: openChat },
...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: openCheck }]),
{ label: t('game.chat'), onclick: () => navigate(`/game/${id}/chat`), badge: app.chatUnread[id] ?? 0 },
...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: () => navigate(`/game/${id}/check`) }]),
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
...(!app.profile?.isGuest
? opponents.map((s) =>
@@ -742,7 +633,7 @@
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
{#snippet menu()}
<Menu items={menuItems} />
<Menu items={menuItems} badge={app.chatUnread[id] ?? 0} />
{/snippet}
{#if view}
@@ -898,28 +789,6 @@
</Modal>
{/if}
{#if checkOpen}
<Modal title={t('game.checkWord')} overlayKeyboard onclose={() => (checkOpen = false)}>
<div class="check">
<input
value={checkWord}
oninput={onCheckInput}
onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')}
/>
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div>
{#if checkResult}
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
{checkResult.legal
? t('game.wordLegal', { word: checkResult.word })
: t('game.wordIllegal', { word: checkResult.word })}
</p>
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if}
</Modal>
{/if}
{#if resignOpen}
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
<div class="confirm-row">
@@ -929,12 +798,6 @@
</Modal>
{/if}
{#if panel === 'chat'}
<Modal title={t('game.chat')} bottomSheet onclose={() => (panel = 'none')}>
<Chat {messages} myId={app.session?.userId ?? ''} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
</Modal>
{/if}
<style>
.scoreboard {
display: flex;
@@ -1193,38 +1056,6 @@
.confirm:disabled {
opacity: 0.5;
}
.check {
display: flex;
gap: 6px;
}
.check input {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
text-transform: uppercase;
}
.check button {
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.ok {
color: var(--ok);
}
.bad {
color: var(--danger);
}
.complain {
background: none;
border: none;
color: var(--accent);
padding: 4px 0;
}
.confirm-row {
display: flex;
gap: 8px;
+37 -1
View File
@@ -47,6 +47,8 @@ export const app = $state<{
localeLocked: boolean;
/** Pending incoming friend requests + invitations, for the lobby badge. */
notifications: number;
/** Unread chat-message count per game id, for the in-game menu/hamburger badge. */
chatUnread: Record<string, number>;
}>({
ready: false,
session: null,
@@ -60,6 +62,7 @@ export const app = $state<{
boardLines: false,
localeLocked: false,
notifications: 0,
chatUnread: {},
});
let unsubscribeStream: (() => void) | null = null;
@@ -105,6 +108,11 @@ export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
toastTimer = setTimeout(() => (app.toast = null), 4000);
}
/** clearChatUnread resets a game's unread chat-message count (called when its chat is opened). */
export function clearChatUnread(gameId: string): void {
if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 };
}
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
export function handleError(err: unknown): void {
telegramHaptic('error');
@@ -126,7 +134,15 @@ function openStream(): void {
(e) => {
app.lastEvent = e;
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
// While the player is on that game's chat screen, neither toast nor bump the unread.
const onChat = router.route.name === 'gameChat' && router.route.params.id === e.message.gameId;
if (!onChat) {
if (e.message.kind !== 'nudge') {
const gid = e.message.gameId;
app.chatUnread = { ...app.chatUnread, [gid]: (app.chatUnread[gid] ?? 0) + 1 };
}
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
}
} else if (e.kind === 'nudge') {
showToast(t('chat.nudge'), 'info');
} else if (e.kind === 'your_turn') {
@@ -243,6 +259,19 @@ function syncTelegramSafeArea(): void {
document.documentElement.classList.toggle('tg-fullscreen', top > 0);
}
/**
* syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard).
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible
* without the page scrolling, so the layout no longer jumps when the keyboard appears (Stage 17).
*/
function syncViewportHeight(): void {
if (typeof document === 'undefined') return;
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0;
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
}
export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
@@ -261,6 +290,13 @@ export async function bootstrap(): Promise<void> {
setLocale(guess);
}
// Track the visual-viewport height so screens fit above an open soft keyboard (--vvh).
syncViewportHeight();
if (typeof window !== 'undefined' && window.visualViewport) {
window.visualViewport.addEventListener('resize', syncViewportHeight);
window.visualViewport.addEventListener('scroll', syncViewportHeight);
}
// Telegram Mini App launch: apply the platform theme, authenticate via initData,
// and route any deep-link start parameter. On the dedicated /telegram/ entry path
// outside Telegram (no initData), refuse to render and send the visitor to the
+6 -1
View File
@@ -7,6 +7,8 @@ export type RouteName =
| 'lobby'
| 'new'
| 'game'
| 'gameChat'
| 'gameCheck'
| 'profile'
| 'settings'
| 'about'
@@ -29,7 +31,10 @@ function parse(hash: string): Route {
case 'new':
return { name: 'new', params: {} };
case 'game':
return seg[1] ? { name: 'game', params: { id: seg[1] } } : { name: 'notfound', params: {} };
if (!seg[1]) return { name: 'notfound', params: {} };
if (seg[2] === 'chat') return { name: 'gameChat', params: { id: seg[1] } };
if (seg[2] === 'check') return { name: 'gameCheck', params: { id: seg[1] } };
return { name: 'game', params: { id: seg[1] } };
case 'profile':
return { name: 'profile', params: {} };
case 'settings':