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`
|
`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
|
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.
|
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)
|
## Deferred TODOs (cross-stage)
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
|||||||
.subnav a.active { color: var(--ink); }
|
.subnav a.active { color: var(--ink); }
|
||||||
|
|
||||||
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
|
.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.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 label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
||||||
.form input, .form select, .form textarea {
|
.form input, .form select, .form textarea {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
|
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
|
||||||
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
|
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
|
||||||
<button type="submit">Filter</button>
|
<button type="submit">Filter</button>
|
||||||
|
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
|
||||||
</form>
|
</form>
|
||||||
{{if or .GameID .UserID}}
|
{{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>
|
<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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -53,6 +54,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
||||||
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
||||||
gm.GET("/messages", s.consoleMessages)
|
gm.GET("/messages", s.consoleMessages)
|
||||||
|
gm.GET("/messages.csv", s.consoleMessagesCSV)
|
||||||
gm.GET("/dictionary", s.consoleDictionary)
|
gm.GET("/dictionary", s.consoleDictionary)
|
||||||
gm.POST("/dictionary/reload", s.consoleReloadDictionary)
|
gm.POST("/dictionary/reload", s.consoleReloadDictionary)
|
||||||
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
|
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)
|
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.
|
// consoleUserDetail renders one account with its stats, identities and games.
|
||||||
func (s *Server) consoleUserDetail(c *gin.Context) {
|
func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
# ACME and the contour is self-contained.
|
# ACME and the contour is self-contained.
|
||||||
{
|
{
|
||||||
admin off
|
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} {
|
{$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.
|
in-memory mock transport (`pnpm start`) runs the whole slice with no backend.
|
||||||
Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.
|
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),
|
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 one-line **announcement banner** under the nav (a client-side mock rotation, **gated off
|
||||||
a server-driven channel later, §10), and a client **board-style** setting (bonus-label
|
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
|
mode). The visual/interaction design system is documented in
|
||||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
[`UI_DESIGN.md`](UI_DESIGN.md).
|
||||||
- **`platform/telegram`** — the Telegram side-service (the "connector", module
|
- **`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
|
(`.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
|
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
|
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
|
- **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
|
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.
|
`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,
|
(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
|
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.
|
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)*
|
### Profile & settings *(Stage 4 / 8)*
|
||||||
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого
|
содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого
|
||||||
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
|
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
|
||||||
push доставляется через платформу.
|
push доставляется через платформу.
|
||||||
|
Чат и инструмент проверки слова открываются **отдельными экранами** (с кнопкой «назад» в
|
||||||
|
партию), а новое сообщение в чате рисует **бейдж непрочитанного** на меню партии до открытия чата.
|
||||||
|
|
||||||
### Профиль и настройки *(Stage 4 / 8)*
|
### Профиль и настройки *(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();
|
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 }) => {
|
test('the board-label mode in Settings changes the on-board labels', async ({ page }) => {
|
||||||
await openGame(page);
|
await openGame(page);
|
||||||
// beginner (default) renders split "3× / word" labels.
|
// 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();
|
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 }) => {
|
test('profile edit disables Save and flags an invalid display name', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await page.locator('.burger').first().click();
|
await page.locator('.burger').first().click();
|
||||||
|
|||||||
+27
-4
@@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { app, bootstrap } from './lib/app.svelte';
|
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 { t } from './lib/i18n/index.svelte';
|
||||||
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
||||||
import Toast from './components/Toast.svelte';
|
import Toast from './components/Toast.svelte';
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
import Friends from './screens/Friends.svelte';
|
import Friends from './screens/Friends.svelte';
|
||||||
import Stats from './screens/Stats.svelte';
|
import Stats from './screens/Stats.svelte';
|
||||||
import Game from './game/Game.svelte';
|
import Game from './game/Game.svelte';
|
||||||
|
import ChatScreen from './game/ChatScreen.svelte';
|
||||||
|
import CheckScreen from './game/CheckScreen.svelte';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
@@ -25,15 +27,32 @@
|
|||||||
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
|
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!insideTelegram()) return;
|
if (!insideTelegram()) return;
|
||||||
const name = router.route.name;
|
const r = router.route;
|
||||||
telegramBackButton(name !== 'lobby' && name !== 'login', () => navigate('/'));
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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 enterSign = $derived(dir === 'forward' ? 1 : -1);
|
||||||
const leaveSign = $derived(dir === 'forward' ? -1 : 1);
|
const leaveSign = $derived(dir === 'forward' ? -1 : 1);
|
||||||
const routeKey = $derived(router.route.name + (router.route.params.id ?? ''));
|
const routeKey = $derived(router.route.name + (router.route.params.id ?? ''));
|
||||||
@@ -63,6 +82,10 @@
|
|||||||
<NewGame />
|
<NewGame />
|
||||||
{:else if router.route.name === 'game'}
|
{:else if router.route.name === 'game'}
|
||||||
<Game id={router.route.params.id} />
|
<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'}
|
{:else if router.route.name === 'profile'}
|
||||||
<Profile />
|
<Profile />
|
||||||
{:else if router.route.name === 'settings'}
|
{:else if router.route.name === 'settings'}
|
||||||
|
|||||||
+12
-14
@@ -74,9 +74,8 @@
|
|||||||
<h1>{about.title}</h1>
|
<h1>{about.title}</h1>
|
||||||
<p class="tagline">{t('landing.tagline')}</p>
|
<p class="tagline">{t('landing.tagline')}</p>
|
||||||
{#if tgLink}
|
{#if tgLink}
|
||||||
<a class="play" href={tgLink} target="_blank" rel="noopener noreferrer">
|
<a class="tg" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
|
||||||
<img src="telegram-logo.svg" alt="" width="22" height="22" />
|
<img src="telegram-logo.svg" alt="" width="64" height="64" />
|
||||||
{t('landing.playTelegram')}
|
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
@@ -192,21 +191,20 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 1.05rem;
|
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;
|
align-self: center;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
margin-top: 8px;
|
||||||
gap: 9px;
|
border-radius: 50%;
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: none;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-text);
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
}
|
||||||
.play img {
|
.tg img {
|
||||||
display: block;
|
display: block;
|
||||||
|
transition: transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.tg:hover img {
|
||||||
|
transform: scale(1.06);
|
||||||
}
|
}
|
||||||
.info {
|
.info {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
|
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
|
||||||
content-safe-area inset (Stage 17), 0 elsewhere. */
|
content-safe-area inset (Stage 17), 0 elsewhere. */
|
||||||
--tg-content-top: 0px;
|
--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,
|
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||||
"Noto Sans", "Liberation Sans", sans-serif;
|
"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);
|
--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);
|
transform: rotate(45deg);
|
||||||
margin-left: 3px;
|
margin-left: 3px;
|
||||||
}
|
}
|
||||||
/* Telegram fullscreen: its native nav overlays the top of the viewport (height
|
/* Telegram fullscreen: TG's native nav occupies the band between the device notch
|
||||||
--tg-content-top, set from the content-safe-area inset). Drop the header content below the
|
(--tg-safe-top) and --tg-content-top. Our header spans that full band (so the layout below
|
||||||
nav and lift the menu up into the nav band, centred — Telegram's own controls sit in the
|
is unchanged) and centres the title + menu as a pair (hamburger right of the title) within
|
||||||
corners, leaving the centre clear (Stage 17). */
|
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 {
|
: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>
|
</style>
|
||||||
|
|||||||
@@ -5,8 +5,15 @@
|
|||||||
title = '',
|
title = '',
|
||||||
onclose,
|
onclose,
|
||||||
overlayKeyboard = false,
|
overlayKeyboard = false,
|
||||||
|
bottomSheet = false,
|
||||||
children,
|
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
|
// 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
|
// 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.
|
// 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
|
// 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).
|
// 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 vh = $state(0);
|
||||||
let top = $state(0);
|
let top = $state(0);
|
||||||
|
let kb = $state(0);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||||
if (!vv || overlayKeyboard) return;
|
if (!vv || overlayKeyboard) return;
|
||||||
const update = () => {
|
const update = () => {
|
||||||
vh = vv.height;
|
vh = vv.height;
|
||||||
top = vv.offsetTop;
|
top = vv.offsetTop;
|
||||||
|
// Soft-keyboard height: the layout viewport minus the visible viewport.
|
||||||
|
kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
||||||
};
|
};
|
||||||
update();
|
update();
|
||||||
vv.addEventListener('resize', update);
|
vv.addEventListener('resize', update);
|
||||||
@@ -38,11 +52,19 @@
|
|||||||
<div
|
<div
|
||||||
class="backdrop"
|
class="backdrop"
|
||||||
class:overlay={overlayKeyboard}
|
class:overlay={overlayKeyboard}
|
||||||
style:height={vh ? `${vh}px` : null}
|
class:bottom={bottomSheet}
|
||||||
style:top={vh ? `${top}px` : null}
|
style:height={!bottomSheet && vh ? `${vh}px` : null}
|
||||||
|
style:top={!bottomSheet && vh ? `${top}px` : null}
|
||||||
onclick={() => onclose?.()}
|
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}
|
{#if title}<h2>{title}</h2>{/if}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
@@ -71,6 +93,20 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding-top: 12vh;
|
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 {
|
.sheet {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import Header from './Header.svelte';
|
import Header from './Header.svelte';
|
||||||
import AdBanner from './AdBanner.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
|
// 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`
|
// 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).
|
// (the game makes only its board scroll while the score/rack/tab bar stay put).
|
||||||
column?: boolean;
|
column?: boolean;
|
||||||
} = $props();
|
} = $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>
|
</script>
|
||||||
|
|
||||||
<div class="screen">
|
<div class="screen">
|
||||||
<Header {title} {back} {menu} grow={growNav} />
|
<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>
|
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
|
||||||
{#if tabbar}
|
{#if tabbar}
|
||||||
<nav class="tabbar">{@render tabbar()}</nav>
|
<nav class="tabbar">{@render tabbar()}</nav>
|
||||||
@@ -42,7 +70,10 @@
|
|||||||
.screen {
|
.screen {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.content {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
|
|||||||
@@ -69,10 +69,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
/* dvh so the chat shrinks with an open keyboard, keeping the start of the
|
/* Fill the chat screen; the list scrolls and the input pins to the bottom. The screen
|
||||||
conversation on screen instead of pushed above the fold (vh fallback). */
|
fits the visual viewport (--vvh), so an open keyboard simply shrinks it and the input
|
||||||
height: 56vh;
|
stays visible — no modal relayout, no page jump (Stage 17). */
|
||||||
height: 56dvh;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px var(--pad);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
flex: 1;
|
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 Modal from '../components/Modal.svelte';
|
||||||
import Board from './Board.svelte';
|
import Board from './Board.svelte';
|
||||||
import Rack from './Rack.svelte';
|
import Rack from './Rack.svelte';
|
||||||
import Chat from './Chat.svelte';
|
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
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 { replay } from '../lib/board';
|
||||||
import { centre, premiumGrid } from '../lib/premiums';
|
import { centre, premiumGrid } from '../lib/premiums';
|
||||||
import { variantNameKey } from '../lib/variants';
|
import { variantNameKey } from '../lib/variants';
|
||||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
|
||||||
import { shareOrDownloadGcg } from '../lib/share';
|
import { shareOrDownloadGcg } from '../lib/share';
|
||||||
import { getCachedGame, setCachedGame } from '../lib/gamecache';
|
import { getCachedGame, setCachedGame } from '../lib/gamecache';
|
||||||
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
|
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.
|
// tiles fly to their new positions (Rack's hop animation) instead of relabelling in place.
|
||||||
let rackIds = $state<number[]>([]);
|
let rackIds = $state<number[]>([]);
|
||||||
let shuffling = $state(false);
|
let shuffling = $state(false);
|
||||||
let panel = $state<'none' | 'chat'>('none');
|
|
||||||
let historyOpen = $state(false);
|
let historyOpen = $state(false);
|
||||||
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
|
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
|
||||||
let exchangeOpen = $state(false);
|
let exchangeOpen = $state(false);
|
||||||
let exchangeSel = $state<number[]>([]);
|
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 resignOpen = $state(false);
|
||||||
let messages = $state<ChatMessage[]>([]);
|
|
||||||
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
|
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 variant = $derived(view?.game.variant ?? 'english');
|
||||||
const board = $derived(replay(moves));
|
const board = $derived(replay(moves));
|
||||||
const premium = $derived(premiumGrid(variant));
|
const premium = $derived(premiumGrid(variant));
|
||||||
@@ -96,29 +87,6 @@
|
|||||||
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
|
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
|
||||||
const gameOver = $derived(!!view && view.game.status !== 'active');
|
const gameOver = $derived(!!view && view.game.status !== 'active');
|
||||||
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
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() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -169,13 +137,6 @@
|
|||||||
const rack = order.map((i) => st.rack[i]);
|
const rack = order.map((i) => st.rack[i]);
|
||||||
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
|
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
|
||||||
}
|
}
|
||||||
async function loadChat() {
|
|
||||||
try {
|
|
||||||
messages = await gateway.chatList(id);
|
|
||||||
} catch (e) {
|
|
||||||
handleError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Guard against an accidental swipe-close losing the open game (Telegram).
|
// Guard against an accidental swipe-close losing the open game (Telegram).
|
||||||
telegramClosingConfirmation(true);
|
telegramClosingConfirmation(true);
|
||||||
@@ -200,20 +161,11 @@
|
|||||||
// player's other devices): this device already reloaded after the submit.
|
// player's other devices): this device already reloaded after the submit.
|
||||||
if (e.seat !== view?.seat) void load();
|
if (e.seat !== view?.seat) void load();
|
||||||
} else if (e.kind === 'your_turn' && e.gameId === id) 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
|
// A request the player sent was answered (accepted -> now friends; declined -> stays
|
||||||
// "request sent"): re-derive the in-game friend state.
|
// "request sent"): re-derive the in-game friend state.
|
||||||
else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) void loadFriends();
|
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 {
|
function isCoarse(): boolean {
|
||||||
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
||||||
}
|
}
|
||||||
@@ -364,7 +316,7 @@
|
|||||||
zoomed = true;
|
zoomed = true;
|
||||||
telegramHaptic('light');
|
telegramHaptic('light');
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 700)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,7 +451,6 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
|
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
|
||||||
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
|
|
||||||
telegramHaptic('success');
|
telegramHaptic('success');
|
||||||
zoomed = false;
|
zoomed = false;
|
||||||
await load();
|
await load();
|
||||||
@@ -521,7 +472,6 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await gateway.pass(id);
|
await gateway.pass(id);
|
||||||
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
|
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
@@ -603,7 +553,6 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await gateway.exchange(id, tiles, variant);
|
await gateway.exchange(id, tiles, variant);
|
||||||
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
|
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(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 {
|
function resultText(): string {
|
||||||
if (!view) return '';
|
if (!view) return '';
|
||||||
const me = view.game.seats[view.seat];
|
const me = view.game.seats[view.seat];
|
||||||
@@ -724,8 +615,8 @@
|
|||||||
// an "add to friends" item flips to a disabled "request sent" once tapped.
|
// an "add to friends" item flips to a disabled "request sent" once tapped.
|
||||||
const menuItems = $derived([
|
const menuItems = $derived([
|
||||||
{ label: t('game.history'), onclick: () => (historyOpen = true) },
|
{ label: t('game.history'), onclick: () => (historyOpen = true) },
|
||||||
{ label: t('game.chat'), onclick: openChat },
|
{ label: t('game.chat'), onclick: () => navigate(`/game/${id}/chat`), badge: app.chatUnread[id] ?? 0 },
|
||||||
...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: openCheck }]),
|
...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: () => navigate(`/game/${id}/check`) }]),
|
||||||
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
|
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
|
||||||
...(!app.profile?.isGuest
|
...(!app.profile?.isGuest
|
||||||
? opponents.map((s) =>
|
? opponents.map((s) =>
|
||||||
@@ -742,7 +633,7 @@
|
|||||||
|
|
||||||
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
|
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<Menu items={menuItems} />
|
<Menu items={menuItems} badge={app.chatUnread[id] ?? 0} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if view}
|
{#if view}
|
||||||
@@ -898,28 +789,6 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/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}
|
{#if resignOpen}
|
||||||
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
||||||
<div class="confirm-row">
|
<div class="confirm-row">
|
||||||
@@ -929,12 +798,6 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/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>
|
<style>
|
||||||
.scoreboard {
|
.scoreboard {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1193,38 +1056,6 @@
|
|||||||
.confirm:disabled {
|
.confirm:disabled {
|
||||||
opacity: 0.5;
|
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 {
|
.confirm-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
onTelegramPath,
|
onTelegramPath,
|
||||||
telegramColorScheme,
|
telegramColorScheme,
|
||||||
telegramContentSafeAreaTop,
|
telegramContentSafeAreaTop,
|
||||||
|
telegramSafeAreaTop,
|
||||||
telegramDisableVerticalSwipes,
|
telegramDisableVerticalSwipes,
|
||||||
telegramHaptic,
|
telegramHaptic,
|
||||||
telegramLaunch,
|
telegramLaunch,
|
||||||
@@ -46,6 +47,8 @@ export const app = $state<{
|
|||||||
localeLocked: boolean;
|
localeLocked: boolean;
|
||||||
/** Pending incoming friend requests + invitations, for the lobby badge. */
|
/** Pending incoming friend requests + invitations, for the lobby badge. */
|
||||||
notifications: number;
|
notifications: number;
|
||||||
|
/** Unread chat-message count per game id, for the in-game menu/hamburger badge. */
|
||||||
|
chatUnread: Record<string, number>;
|
||||||
}>({
|
}>({
|
||||||
ready: false,
|
ready: false,
|
||||||
session: null,
|
session: null,
|
||||||
@@ -59,6 +62,7 @@ export const app = $state<{
|
|||||||
boardLines: false,
|
boardLines: false,
|
||||||
localeLocked: false,
|
localeLocked: false,
|
||||||
notifications: 0,
|
notifications: 0,
|
||||||
|
chatUnread: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
let unsubscribeStream: (() => void) | null = null;
|
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);
|
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. */
|
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
|
||||||
export function handleError(err: unknown): void {
|
export function handleError(err: unknown): void {
|
||||||
telegramHaptic('error');
|
telegramHaptic('error');
|
||||||
@@ -125,7 +134,15 @@ function openStream(): void {
|
|||||||
(e) => {
|
(e) => {
|
||||||
app.lastEvent = e;
|
app.lastEvent = e;
|
||||||
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
|
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
|
||||||
|
// 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');
|
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
|
||||||
|
}
|
||||||
} else if (e.kind === 'nudge') {
|
} else if (e.kind === 'nudge') {
|
||||||
showToast(t('chat.nudge'), 'info');
|
showToast(t('chat.nudge'), 'info');
|
||||||
} else if (e.kind === 'your_turn') {
|
} else if (e.kind === 'your_turn') {
|
||||||
@@ -238,9 +255,23 @@ function syncTelegramSafeArea(): void {
|
|||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
const top = telegramContentSafeAreaTop();
|
const top = telegramContentSafeAreaTop();
|
||||||
document.documentElement.style.setProperty('--tg-content-top', `${top}px`);
|
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);
|
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> {
|
export async function bootstrap(): Promise<void> {
|
||||||
const prefs = await loadPrefs();
|
const prefs = await loadPrefs();
|
||||||
app.theme = prefs.theme ?? 'auto';
|
app.theme = prefs.theme ?? 'auto';
|
||||||
@@ -259,6 +290,13 @@ export async function bootstrap(): Promise<void> {
|
|||||||
setLocale(guess);
|
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,
|
// Telegram Mini App launch: apply the platform theme, authenticate via initData,
|
||||||
// and route any deep-link start parameter. On the dedicated /telegram/ entry path
|
// 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
|
// outside Telegram (no initData), refuse to render and send the visitor to the
|
||||||
@@ -279,6 +317,7 @@ export async function bootstrap(): Promise<void> {
|
|||||||
syncTelegramChrome();
|
syncTelegramChrome();
|
||||||
syncTelegramSafeArea();
|
syncTelegramSafeArea();
|
||||||
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
||||||
|
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
|
||||||
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
|
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
|
||||||
telegramDisableVerticalSwipes();
|
telegramDisableVerticalSwipes();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
decodeGameList,
|
decodeGameList,
|
||||||
decodeInvitation,
|
decodeInvitation,
|
||||||
decodeLinkResult,
|
decodeLinkResult,
|
||||||
|
decodeOutgoingList,
|
||||||
decodeSession,
|
decodeSession,
|
||||||
decodeStateView,
|
decodeStateView,
|
||||||
decodeStats,
|
decodeStats,
|
||||||
@@ -109,6 +110,7 @@ describe('codec', () => {
|
|||||||
fb.GameView.addMoveCount(b, 4);
|
fb.GameView.addMoveCount(b, 4);
|
||||||
fb.GameView.addEndReason(b, er);
|
fb.GameView.addEndReason(b, er);
|
||||||
fb.GameView.addSeats(b, seats);
|
fb.GameView.addSeats(b, seats);
|
||||||
|
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
|
||||||
const game = fb.GameView.endGameView(b);
|
const game = fb.GameView.endGameView(b);
|
||||||
const games = fb.GameList.createGamesVector(b, [game]);
|
const games = fb.GameList.createGamesVector(b, [game]);
|
||||||
fb.GameList.startGameList(b);
|
fb.GameList.startGameList(b);
|
||||||
@@ -120,6 +122,22 @@ describe('codec', () => {
|
|||||||
expect(gl.games[0].id).toBe('g1');
|
expect(gl.games[0].id).toBe('g1');
|
||||||
expect(gl.games[0].seats[0].displayName).toBe('Ann');
|
expect(gl.games[0].seats[0].displayName).toBe('Ann');
|
||||||
expect(gl.games[0].seats[0].score).toBe(13);
|
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', () => {
|
it('encodes a TargetRequest', () => {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export type RouteName =
|
|||||||
| 'lobby'
|
| 'lobby'
|
||||||
| 'new'
|
| 'new'
|
||||||
| 'game'
|
| 'game'
|
||||||
|
| 'gameChat'
|
||||||
|
| 'gameCheck'
|
||||||
| 'profile'
|
| 'profile'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'about'
|
| 'about'
|
||||||
@@ -29,7 +31,10 @@ function parse(hash: string): Route {
|
|||||||
case 'new':
|
case 'new':
|
||||||
return { name: 'new', params: {} };
|
return { name: 'new', params: {} };
|
||||||
case 'game':
|
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':
|
case 'profile':
|
||||||
return { name: 'profile', params: {} };
|
return { name: 'profile', params: {} };
|
||||||
case 'settings':
|
case 'settings':
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface TelegramWebApp {
|
|||||||
themeParams?: TelegramThemeParams;
|
themeParams?: TelegramThemeParams;
|
||||||
colorScheme?: 'light' | 'dark';
|
colorScheme?: 'light' | 'dark';
|
||||||
isFullscreen?: boolean;
|
isFullscreen?: boolean;
|
||||||
|
safeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||||
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||||
ready?: () => void;
|
ready?: () => void;
|
||||||
expand?: () => void;
|
expand?: () => void;
|
||||||
@@ -110,6 +111,16 @@ export function telegramContentSafeAreaTop(): number {
|
|||||||
return webApp()?.contentSafeAreaInset?.top ?? 0;
|
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
|
* 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.
|
* it does not fight tile drag-and-drop or the board's vertical scroll.
|
||||||
|
|||||||
Reference in New Issue
Block a user