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
26 changed files with 671 additions and 213 deletions
+31
View File
@@ -1387,6 +1387,37 @@ provided cert) at the contour caddy; prod VPN; rollback.
`kind='message'`, the source via a SQL `CASE`), reusing the now-exported `account.LikePattern`
glob helper. Owner decisions: messages only (no nudges), separate name/ext masks (matching the
Users section), a top-level nav entry plus the card deep-links.
- **Round-6 follow-up — UX polish + client-IP fix (this PR):**
- **Client IP through the edge.** The compose caddy now sets `trusted_proxies static
private_ranges`, so the real client IP survives the host-caddy hop (it was logging the
docker-network caddy hop `172.18.0.x` for chat moderation, and bucketing the gateway's
per-IP rate limiter on it). Correct + spoof-safe in **both** contours (prod has no host
caddy → public clients untrusted → real peer used). `peerIP` unit-tested.
- **Ad banner** gated **off** behind a compile-time `SHOW_AD_BANNER=false` in `Screen.svelte`
— the `{#if}` branch, the `AdBanner` import and `banner.ts` are tree-shaken out of the prod
bundle (code kept for post-release polish).
- **Landing** Telegram entry is now just the **64px logo** (clickable, no button/caption).
- **TG-fullscreen header** reworked again: title + menu are one **centred pair** (hamburger
right of the title) pinned to the **bottom** of the TG nav band, lining up with Telegram's
own controls.
- **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 + 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
with the opponent's name, last word and score; #5 let a player hide finished games from
their lobby (swipe + a desktop affordance).
## Deferred TODOs (cross-stage)
@@ -74,6 +74,7 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
.subnav a.active { color: var(--ink); }
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
.form .export { margin-left: auto; align-self: center; color: var(--accent); white-space: nowrap; }
.form.col { flex-direction: column; align-items: stretch; max-width: 540px; }
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
.form input, .form select, .form textarea {
@@ -7,6 +7,7 @@
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
<button type="submit">Filter</button>
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
</form>
{{if or .GameID .UserID}}
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
+25
View File
@@ -0,0 +1,25 @@
package server
import "testing"
// TestCSVSafe checks the CSV/spreadsheet formula-injection guard used by the admin Messages
// export: a leading formula trigger is quoted, everything else is left intact.
func TestCSVSafe(t *testing.T) {
tests := []struct{ in, want string }{
{"", ""},
{"hello", "hello"},
{"=1+1", "'=1+1"},
{"+cmd", "'+cmd"},
{"-2", "'-2"},
{"@SUM(A1)", "'@SUM(A1)"},
{"\tx", "'\tx"},
{"\rx", "'\rx"},
{"good luck", "good luck"},
{"a=b", "a=b"}, // a formula char that is not leading must be left untouched
}
for _, tc := range tests {
if got := csvSafe(tc.in); got != tc.want {
t.Errorf("csvSafe(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
@@ -2,6 +2,7 @@ package server
import (
"bytes"
"encoding/csv"
"fmt"
"net/http"
"net/url"
@@ -53,6 +54,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/complaints/:id", s.consoleComplaintDetail)
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
gm.GET("/messages", s.consoleMessages)
gm.GET("/messages.csv", s.consoleMessagesCSV)
gm.GET("/dictionary", s.consoleDictionary)
gm.POST("/dictionary/reload", s.consoleReloadDictionary)
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
@@ -186,6 +188,54 @@ func (s *Server) consoleMessages(c *gin.Context) {
s.renderConsole(c, "messages", "messages", "Messages", view)
}
// adminMessagesExportCap bounds the CSV export row count (the moderated chat volume is small).
const adminMessagesExportCap = 100000
// consoleMessagesCSV exports the whole filtered chat-message list (ignoring pagination) as a
// CSV download, for offline moderation review.
func (s *Server) consoleMessagesCSV(c *gin.Context) {
ctx := c.Request.Context()
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := social.AdminMessageFilter{
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
}
items, err := s.social.AdminListMessages(ctx, filter, adminMessagesExportCap, 0)
if err != nil {
s.consoleError(c, err)
return
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="messages.csv"`)
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id"})
for _, m := range items {
// The sender name and message body are user-controlled; defuse spreadsheet formula
// injection so a moderator opening the export can't trigger a formula.
_ = w.Write([]string{
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(),
})
}
w.Flush()
}
// csvSafe defuses CSV/spreadsheet formula injection: a value a spreadsheet would treat as a
// formula (a leading =, +, -, @, tab or CR) is prefixed with a single quote so it renders as
// plain text on open.
func csvSafe(s string) string {
if s == "" {
return s
}
switch s[0] {
case '=', '+', '-', '@', '\t', '\r':
return "'" + s
}
return s
}
// consoleUserDetail renders one account with its stats, identities and games.
func (s *Server) consoleUserDetail(c *gin.Context) {
ctx := c.Request.Context()
+8
View File
@@ -8,6 +8,14 @@
# ACME and the contour is self-contained.
{
admin off
# Trust X-Forwarded-For from private-range upstreams so the real client IP survives
# (chat moderation + per-IP rate limiting in the gateway). Test contour: the host caddy
# (a private IP) is trusted, so its forwarded client IP is preserved. Prod (no host caddy):
# clients connect from public IPs, which are NOT trusted, so Caddy uses the real peer —
# the same config is correct (and spoof-safe) in both contours (Stage 17).
servers {
trusted_proxies static private_ranges
}
}
{$CADDY_SITE_ADDRESS::80} {
+8 -3
View File
@@ -40,8 +40,9 @@ Three executables plus per-platform side-services:
in-memory mock transport (`pnpm start`) runs the whole slice with no backend.
Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.
The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom),
a one-line **announcement banner** under the nav (a client-side mock rotation today —
a server-driven channel later, §10), and a client **board-style** setting (bonus-label
a one-line **announcement banner** under the nav (a client-side mock rotation, **gated off
in the build until polished after release**, Stage 17 — a server-driven channel later, §10),
and a client **board-style** setting (bonus-label
mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md).
- **`platform/telegram`** — the Telegram side-service (the "connector", module
@@ -608,7 +609,11 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
(`.gitea/workflows/ci.yaml``docker compose up -d --build` on the Gitea runner
host, then a `GET /` probe through caddy). The host caddy terminates TLS and
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
(`CADDY_SITE_ADDRESS=:80`).
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
real peer, so the single config is correct and spoof-safe in both contours (Stage 17).
- **Prod** (Stage 18): a manual SSH deploy after `development → master`. There is no
host caddy, so the contour ships its own caddy terminating TLS — set
`CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME.
+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)*
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
@@ -0,0 +1,35 @@
package connectsrv
import (
"net/http"
"testing"
)
// TestPeerIP covers the client-IP extraction the chat-moderation IP and the per-IP rate
// limiter both rely on: the first X-Forwarded-For hop (the real client, once Caddy is
// configured to trust its upstream), falling back to the connection peer (Stage 17).
func TestPeerIP(t *testing.T) {
tests := []struct {
name string
addr string
xff string
want string
}{
{"xff single", "10.0.0.1:5000", "203.0.113.7", "203.0.113.7"},
{"xff client then proxies", "10.0.0.1:5000", "203.0.113.7, 172.18.0.3", "203.0.113.7"},
{"xff trims spaces", "10.0.0.1:5000", " 203.0.113.9 , 10.0.0.2", "203.0.113.9"},
{"no xff uses peer host", "203.0.113.5:42000", "", "203.0.113.5"},
{"no xff no port", "203.0.113.6", "", "203.0.113.6"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h := http.Header{}
if tc.xff != "" {
h.Set("X-Forwarded-For", tc.xff)
}
if got := peerIP(tc.addr, h); got != tc.want {
t.Errorf("peerIP(%q, xff=%q) = %q, want %q", tc.addr, tc.xff, got, tc.want)
}
})
}
}
+42
View File
@@ -139,6 +139,48 @@ test('dropping the game ends it and shows the result', async ({ page }) => {
await expect(page.locator('.status .over')).toBeVisible();
});
test('a placed tile drags from one board cell to another (Stage 17 relocation)', async ({ page }) => {
await openGame(page);
await page.locator('.rack .tile').first().click();
await page.locator('[data-cell]:not(.filled)').nth(30).click();
const pending = page.locator('[data-cell].pending');
await expect(pending).toHaveCount(1);
const from = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`;
const target = page.locator('[data-cell]:not(.filled):not(.pending)').nth(45);
const fb = await pending.first().boundingBox();
const tb = await target.boundingBox();
// Pointer-drag the placed tile to a new cell (mouse events synthesise pointer events).
await page.mouse.move(fb!.x + fb!.width / 2, fb!.y + fb!.height / 2);
await page.mouse.down();
await page.mouse.move(tb!.x + tb!.width / 2, tb!.y + tb!.height / 2, { steps: 10 });
await page.mouse.up();
// Still exactly one pending tile (relocated, not duplicated), now at a different cell.
await expect(pending).toHaveCount(1);
const to = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`;
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.
+12
View File
@@ -122,6 +122,18 @@ test('game: add-to-friends flips to a disabled "request sent"', async ({ page })
await expect(sent).toBeDisabled();
});
test('game: an opponent who is already a friend shows a disabled "in friends"', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Kaya/ }).click(); // the finished game vs Kaya, a seeded friend
await page.locator('.burger').first().click();
// The in-game friend item is derived from the server's friend list (Stage 17): a friend reads
// a disabled "✓ in friends", not the addable "Add to friends".
const inFriends = page.getByRole('button', { name: /in friends/i });
await expect(inFriends).toBeVisible();
await expect(inFriends).toBeDisabled();
await expect(page.getByRole('button', { name: /Add to friends: Kaya/ })).toHaveCount(0);
});
test('profile edit disables Save and flags an invalid display name', async ({ page }) => {
await loginLobby(page);
await page.locator('.burger').first().click();
+27 -4
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte';
import { navigate, router } from './lib/router.svelte';
import { navigate, router, type RouteName } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
import { insideTelegram, telegramBackButton } from './lib/telegram';
import Toast from './components/Toast.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,15 +27,32 @@
// 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
// lobby slides it in from the right (forward); returning to the lobby slides the
// screen out to the right and reveals the lobby (back). Transitions are local, so
// they do not play on the initial mount, and collapse to nothing under reduce-motion.
const dir = $derived(router.route.name === 'lobby' ? 'back' : 'forward');
// Slide direction by route depth: going deeper (lobby → game → chat/check) slides forward,
// returning to a shallower screen slides back. A plain "lobby is back" rule slid the chat/check
// back-to-the-game the wrong way. dir is read with the previous depth (the effect updates it
// only after the transition has captured its sign).
function routeDepth(name: RouteName): number {
if (name === 'gameChat' || name === 'gameCheck') return 2;
if (name === 'lobby' || name === 'login') return 0;
return 1;
}
const curDepth = $derived(routeDepth(router.route.name));
let prevDepth = $state(routeDepth(router.route.name));
const dir = $derived(curDepth < prevDepth ? 'back' : 'forward');
$effect(() => {
prevDepth = curDepth;
});
const enterSign = $derived(dir === 'forward' ? 1 : -1);
const leaveSign = $derived(dir === 'forward' ? -1 : 1);
const routeKey = $derived(router.route.name + (router.route.params.id ?? ''));
@@ -63,6 +82,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'}
+12 -14
View File
@@ -74,9 +74,8 @@
<h1>{about.title}</h1>
<p class="tagline">{t('landing.tagline')}</p>
{#if tgLink}
<a class="play" href={tgLink} target="_blank" rel="noopener noreferrer">
<img src="telegram-logo.svg" alt="" width="22" height="22" />
{t('landing.playTelegram')}
<a class="tg" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
<img src="telegram-logo.svg" alt="" width="64" height="64" />
</a>
{/if}
</section>
@@ -192,21 +191,20 @@
color: var(--text-muted);
font-size: 1.05rem;
}
.play {
/* The Telegram entry is just the bigger logo (no button chrome, no caption); the link
keeps an aria-label for assistive tech (Stage 17). */
.tg {
align-self: center;
display: inline-flex;
align-items: center;
gap: 9px;
padding: 12px 24px;
border-radius: var(--radius-sm);
font-weight: 700;
text-decoration: none;
background: var(--accent);
color: var(--accent-text);
margin-top: 6px;
margin-top: 8px;
border-radius: 50%;
}
.play img {
.tg img {
display: block;
transition: transform 0.12s ease;
}
.tg:hover img {
transform: scale(1.06);
}
.info {
background: var(--surface-2);
+3
View File
@@ -44,6 +44,9 @@
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
content-safe-area inset (Stage 17), 0 elsewhere. */
--tg-content-top: 0px;
/* Telegram device safe-area top (the notch); TG's own nav controls sit between it and
--tg-content-top, so the in-app header aligns to that band (Stage 17), 0 elsewhere. */
--tg-safe-top: 0px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", "Liberation Sans", sans-serif;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
+24 -5
View File
@@ -89,11 +89,30 @@
transform: rotate(45deg);
margin-left: 3px;
}
/* Telegram fullscreen: its native nav overlays the top of the viewport (height
--tg-content-top, set from the content-safe-area inset). Drop the header content below the
nav and lift the menu up into the nav band, centred — Telegram's own controls sit in the
corners, leaving the centre clear (Stage 17). */
/* Telegram fullscreen: TG's native nav occupies the band between the device notch
(--tg-safe-top) and --tg-content-top. Our header spans that full band (so the layout below
is unchanged) and centres the title + menu as a pair (hamburger right of the title) within
it, BELOW the notch — lining them up vertically with Telegram's own back/menu controls,
which sit in the band's corners (Stage 17). */
:global(html.tg-fullscreen) .bar {
padding-top: var(--tg-content-top);
min-height: var(--tg-content-top);
box-sizing: border-box;
align-items: center;
justify-content: center;
/* +12px of vertical breathing room (6 above / 6 below the centred content, on top of the
notch) so Telegram's native controls aren't flush against our header. Applied as
padding because the bar is sized by its content here, not by min-height (owner review
tweaks, Stage 17). */
padding-top: calc(var(--tg-safe-top) + 6px);
padding-bottom: 6px;
}
:global(html.tg-fullscreen) .spacer {
display: none;
}
:global(html.tg-fullscreen) h1 {
flex: 0 1 auto;
}
:global(html.tg-fullscreen) .end {
min-width: 0;
}
</style>
+40 -4
View File
@@ -5,8 +5,15 @@
title = '',
onclose,
overlayKeyboard = false,
bottomSheet = false,
children,
}: { title?: string; onclose?: () => void; overlayKeyboard?: boolean; children?: Snippet } = $props();
}: {
title?: string;
onclose?: () => void;
overlayKeyboard?: boolean;
bottomSheet?: boolean;
children?: Snippet;
} = $props();
// Track the visual viewport so the backdrop covers only the area above an open
// mobile keyboard: dvh alone shrinks the sheet but the fixed, layout-viewport
@@ -14,14 +21,21 @@
// visualViewport keeps the sheet (and the start of a chat) fully on screen.
// overlayKeyboard opts out: the sheet is small and top-anchored, so the keyboard
// simply overlays the empty lower area — no resize, no relayout jank (e.g. check word).
// bottomSheet anchors a tall sheet (the chat) to the bottom and lifts it above the
// keyboard with a transform (kb), driven by the visual viewport — a compositor-only
// move, so neither the page behind nor the sheet relayouts as the keyboard animates
// (Stage 17). The backdrop is not resized in this mode (no per-event reflow).
let vh = $state(0);
let top = $state(0);
let kb = $state(0);
$effect(() => {
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
if (!vv || overlayKeyboard) return;
const update = () => {
vh = vv.height;
top = vv.offsetTop;
// Soft-keyboard height: the layout viewport minus the visible viewport.
kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
};
update();
vv.addEventListener('resize', update);
@@ -38,11 +52,19 @@
<div
class="backdrop"
class:overlay={overlayKeyboard}
style:height={vh ? `${vh}px` : null}
style:top={vh ? `${top}px` : null}
class:bottom={bottomSheet}
style:height={!bottomSheet && vh ? `${vh}px` : null}
style:top={!bottomSheet && vh ? `${top}px` : null}
onclick={() => onclose?.()}
>
<div class="sheet" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<div
class="sheet"
role="dialog"
aria-modal="true"
tabindex="-1"
style:--kb={bottomSheet ? `${kb}px` : null}
onclick={(e) => e.stopPropagation()}
>
{#if title}<h2>{title}</h2>{/if}
{@render children?.()}
</div>
@@ -71,6 +93,20 @@
align-items: flex-start;
padding-top: 12vh;
}
/* Bottom-sheet mode (the chat): a wide sheet pinned to the bottom that lifts above the
soft keyboard via a transform (--kb) — compositor-only, so the page behind and the
sheet itself do not relayout as the keyboard animates (Stage 17). */
.backdrop.bottom {
align-items: flex-end;
padding: 0;
}
.backdrop.bottom .sheet {
width: 100%;
max-width: 640px;
border-radius: var(--radius) var(--radius) 0 0;
transform: translateY(calc(-1 * var(--kb, 0px)));
transition: transform 0.15s ease;
}
.sheet {
background: var(--surface);
color: var(--text);
+33 -2
View File
@@ -2,6 +2,7 @@
import type { Snippet } from 'svelte';
import Header from './Header.svelte';
import AdBanner from './AdBanner.svelte';
import { navigate } from '../lib/router.svelte';
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and
// optional tab bar pin to the bottom (ad directly above the content). Pass `scroll`
@@ -27,11 +28,38 @@
// (the game makes only its board scroll while the score/rack/tab bar stay put).
column?: boolean;
} = $props();
// The promotional banner is feature-gated OFF until it is polished after release. The flag is
// a compile-time `false`, so the {#if} branch — and with it the AdBanner import and its
// banner.ts logic — is dead-code-eliminated from the production bundle (Stage 17). Flip to
// true to bring it back.
const SHOW_AD_BANNER = false;
// Edge-swipe back (Stage 17): a left-edge rightward drag returns to `back`, the standard
// mobile gesture. Listened at the window in the CAPTURE phase so the board's own pointer
// handlers (which capture/stop the event) can never swallow it; armed only from the very
// left edge (<=24px), touch/pen only, so it never competes with the board's gestures.
$effect(() => {
function onDown(e: PointerEvent) {
if (!back || e.pointerType === 'mouse' || e.clientX > 24) return;
const x0 = e.clientX;
const y0 = e.clientY;
const onUp = (ev: PointerEvent) => {
window.removeEventListener('pointerup', onUp, true);
const dx = ev.clientX - x0;
const dy = ev.clientY - y0;
if (back && dx > 64 && Math.abs(dx) > Math.abs(dy) * 1.4) navigate(back);
};
window.addEventListener('pointerup', onUp, true);
}
window.addEventListener('pointerdown', onDown, true);
return () => window.removeEventListener('pointerdown', onDown, true);
});
</script>
<div class="screen">
<Header {title} {back} {menu} grow={growNav} />
<AdBanner />
{#if SHOW_AD_BANNER}<AdBanner />{/if}
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
{#if tabbar}
<nav class="tabbar">{@render tabbar()}</nav>
@@ -42,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>
+6 -175
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;
}
@@ -364,7 +316,7 @@
zoomed = true;
telegramHaptic('light');
}
}, 1000)
}, 700)
: null;
}
}
@@ -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')} 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;
+40 -1
View File
@@ -14,6 +14,7 @@ import {
onTelegramPath,
telegramColorScheme,
telegramContentSafeAreaTop,
telegramSafeAreaTop,
telegramDisableVerticalSwipes,
telegramHaptic,
telegramLaunch,
@@ -46,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,
@@ -59,6 +62,7 @@ export const app = $state<{
boardLines: false,
localeLocked: false,
notifications: 0,
chatUnread: {},
});
let unsubscribeStream: (() => void) | null = null;
@@ -104,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');
@@ -125,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') {
@@ -238,9 +255,23 @@ function syncTelegramSafeArea(): void {
if (typeof document === 'undefined') return;
const top = telegramContentSafeAreaTop();
document.documentElement.style.setProperty('--tg-content-top', `${top}px`);
document.documentElement.style.setProperty('--tg-safe-top', `${telegramSafeAreaTop()}px`);
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';
@@ -259,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
@@ -279,6 +317,7 @@ export async function bootstrap(): Promise<void> {
syncTelegramChrome();
syncTelegramSafeArea();
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
telegramDisableVerticalSwipes();
try {
+18
View File
@@ -8,6 +8,7 @@ import {
decodeGameList,
decodeInvitation,
decodeLinkResult,
decodeOutgoingList,
decodeSession,
decodeStateView,
decodeStats,
@@ -109,6 +110,7 @@ describe('codec', () => {
fb.GameView.addMoveCount(b, 4);
fb.GameView.addEndReason(b, er);
fb.GameView.addSeats(b, seats);
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
const game = fb.GameView.endGameView(b);
const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b);
@@ -120,6 +122,22 @@ describe('codec', () => {
expect(gl.games[0].id).toBe('g1');
expect(gl.games[0].seats[0].displayName).toBe('Ann');
expect(gl.games[0].seats[0].score).toBe(13);
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
});
it('decodes an OutgoingRequestList of account refs', () => {
const b = new Builder(128);
const id = b.createString('o-1');
const dn = b.createString('Pat');
fb.AccountRef.startAccountRef(b);
fb.AccountRef.addAccountId(b, id);
fb.AccountRef.addDisplayName(b, dn);
const ref = fb.AccountRef.endAccountRef(b);
const vec = fb.OutgoingRequestList.createRequestsVector(b, [ref]);
fb.OutgoingRequestList.startOutgoingRequestList(b);
fb.OutgoingRequestList.addRequests(b, vec);
b.finish(fb.OutgoingRequestList.endOutgoingRequestList(b));
expect(decodeOutgoingList(b.asUint8Array())).toEqual([{ accountId: 'o-1', displayName: 'Pat' }]);
});
it('encodes a TargetRequest', () => {
+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':
+11
View File
@@ -11,6 +11,7 @@ interface TelegramWebApp {
themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark';
isFullscreen?: boolean;
safeAreaInset?: { top: number; bottom: number; left: number; right: number };
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
ready?: () => void;
expand?: () => void;
@@ -110,6 +111,16 @@ export function telegramContentSafeAreaTop(): number {
return webApp()?.contentSafeAreaInset?.top ?? 0;
}
/**
* telegramSafeAreaTop returns the device safe-area top inset (px) — the notch / status bar
* (Bot API 8.0). Telegram's own nav controls sit in the band between it and
* telegramContentSafeAreaTop, so aligning our header to that band lines it up with them. 0
* outside Telegram or on older clients.
*/
export function telegramSafeAreaTop(): number {
return webApp()?.safeAreaInset?.top ?? 0;
}
/**
* telegramDisableVerticalSwipes turns off Telegram's swipe-down-to-minimise gesture so
* it does not fight tile drag-and-drop or the board's vertical scroll.