From 70110effd972527681d77783b2e60dbb4b4e460c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 8 Jun 2026 23:23:05 +0200 Subject: [PATCH] Chat + word-check as their own screens; in-game unread badge (review item 7) - Chat and word-check are now routed screens (/game/:id/chat, /game/:id/check) with a header back to the game and no tab-bar, replacing their modals. The soft keyboard just resizes the visible viewport (tracked into --vvh, which the Screen height uses since iOS does not shrink dvh for the keyboard) with the input pinned to the bottom: no modal relayout, no page jump. Supersedes the earlier bottom-sheet Modal attempt. - A new chat message raises an unread badge on the in-game hamburger + the Chat menu row (per game, cleared on opening the chat), mirroring the lobby badge. - TG native back + the header back chevron return chat/check to their game. - Exposes --tg-safe-top (device notch) for the finalised TG-fullscreen header. Tests: e2e for chat/check opening as their own screens + back. Docs: PLAN, FUNCTIONAL(+ru). --- PLAN.md | 14 ++- docs/FUNCTIONAL.md | 2 + docs/FUNCTIONAL_ru.md | 2 + ui/e2e/game.spec.ts | 19 ++++ ui/src/App.svelte | 13 ++- ui/src/components/Screen.svelte | 5 +- ui/src/game/Chat.svelte | 11 +- ui/src/game/ChatScreen.svelte | 93 +++++++++++++++++ ui/src/game/CheckScreen.svelte | 134 ++++++++++++++++++++++++ ui/src/game/Game.svelte | 179 +------------------------------- ui/src/lib/app.svelte.ts | 38 ++++++- ui/src/lib/router.svelte.ts | 7 +- 12 files changed, 330 insertions(+), 187 deletions(-) create mode 100644 ui/src/game/ChatScreen.svelte create mode 100644 ui/src/game/CheckScreen.svelte diff --git a/PLAN.md b/PLAN.md index 3cb1e03..4523227 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 39b93c5..c064ce7 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 1add5a3..164e4fe 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -128,6 +128,8 @@ Mini App** авторизует по подписанным `initData` плат содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий push доставляется через платформу. +Чат и инструмент проверки слова открываются **отдельными экранами** (с кнопкой «назад» в +партию), а новое сообщение в чате рисует **бейдж непрочитанного** на меню партии до открытия чата. ### Профиль и настройки *(Stage 4 / 8)* Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 7abf0a2..a903c60 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -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. diff --git a/ui/src/App.svelte b/ui/src/App.svelte index f58157f..a1279d3 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -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 @@ {:else if router.route.name === 'game'} + {:else if router.route.name === 'gameChat'} + + {:else if router.route.name === 'gameCheck'} + {:else if router.route.name === 'profile'} {:else if router.route.name === 'settings'} diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte index 6ddf877..6c13a57 100644 --- a/ui/src/components/Screen.svelte +++ b/ui/src/components/Screen.svelte @@ -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; diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index 039d22d..f1a6dd0 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -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; diff --git a/ui/src/game/ChatScreen.svelte b/ui/src/game/ChatScreen.svelte new file mode 100644 index 0000000..abcadf7 --- /dev/null +++ b/ui/src/game/ChatScreen.svelte @@ -0,0 +1,93 @@ + + + + + diff --git a/ui/src/game/CheckScreen.svelte b/ui/src/game/CheckScreen.svelte new file mode 100644 index 0000000..410fd7a --- /dev/null +++ b/ui/src/game/CheckScreen.svelte @@ -0,0 +1,134 @@ + + + +
+
+ e.key === 'Enter' && runCheck()} + placeholder={t('game.checkWordPrompt')} + /> + +
+ {#if result} +

+ {result.legal + ? t('game.wordLegal', { word: result.word }) + : t('game.wordIllegal', { word: result.word })} +

+ + {/if} +
+
+ + diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index ca9ef6a..015c3b0 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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([]); 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([]); - let checkOpen = $state(false); - let checkWord = $state(''); - let checkResult = $state<{ word: string; legal: boolean } | null>(null); let resignOpen = $state(false); - let messages = $state([]); let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null); - const checkedWords = new Map(); - 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 @@ {#snippet menu()} - + {/snippet} {#if view} @@ -898,28 +789,6 @@ {/if} -{#if checkOpen} - (checkOpen = false)}> -
- e.key === 'Enter' && runCheck()} - placeholder={t('game.checkWordPrompt')} - /> - -
- {#if checkResult} -

- {checkResult.legal - ? t('game.wordLegal', { word: checkResult.word }) - : t('game.wordIllegal', { word: checkResult.word })} -

- - {/if} -
-{/if} - {#if resignOpen} (resignOpen = false)}>
@@ -929,12 +798,6 @@ {/if} -{#if panel === 'chat'} - (panel = 'none')}> - - -{/if} -