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:
+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;
|
||||
|
||||
Reference in New Issue
Block a user