Chat + word-check as their own screens; in-game unread badge (review item 7)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 34s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 34s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
- 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).
This commit is contained in:
+11
-2
@@ -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'}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user