Round-6 follow-up: UX polish + client-IP fix #26
@@ -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>
|
||||
|
||||
@@ -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,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} {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
+6
-175
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
Reference in New Issue
Block a user