Round-6 follow-up: UX polish + client-IP fix #26
@@ -1387,6 +1387,31 @@ 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 soft-keyboard** is a **bottom-sheet** `Modal` lifted above the keyboard by a
|
||||||
|
`transform` driven by `visualViewport` (compositor-only — the board behind and the sheet
|
||||||
|
no longer relayout as the keyboard animates). iOS-specific; needs on-device fine-tuning.
|
||||||
|
The native `Keyboard.setResizeMode('none')` path waits for Capacitor (not yet wired).
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,29 @@ 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('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();
|
||||||
|
|||||||
+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);
|
||||||
|
|||||||
@@ -89,11 +89,25 @@
|
|||||||
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 overlays a band of height --tg-content-top at the top
|
||||||
--tg-content-top, set from the content-safe-area inset). Drop the header content below the
|
of the viewport. Pull our title + menu up into the BOTTOM of that band and centre them as a
|
||||||
nav and lift the menu up into the nav band, centred — Telegram's own controls sit in the
|
pair (hamburger right of the title) so they line up with Telegram's own nav controls rather
|
||||||
corners, leaving the centre clear (Stage 17). */
|
than floating above them (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: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 0;
|
||||||
|
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,8 @@
|
|||||||
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';
|
||||||
|
import { insideTelegram } from '../lib/telegram';
|
||||||
|
|
||||||
// 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 +29,35 @@
|
|||||||
// (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. Armed only from the very left edge (<=24px) so it never competes with the
|
||||||
|
// board's own horizontal gestures; touch/pen only. Skipped inside Telegram, whose native
|
||||||
|
// back button + swipe already cover this (and would otherwise double up).
|
||||||
|
function onEdgeDown(e: PointerEvent): void {
|
||||||
|
if (!back || e.pointerType === 'mouse' || insideTelegram() || e.clientX > 24) return;
|
||||||
|
const x0 = e.clientX;
|
||||||
|
const y0 = e.clientY;
|
||||||
|
const onUp = (ev: PointerEvent) => {
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
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);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="screen">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="screen" onpointerdown={onEdgeDown}>
|
||||||
<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>
|
||||||
|
|||||||
@@ -930,7 +930,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if panel === 'chat'}
|
{#if panel === 'chat'}
|
||||||
<Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
|
<Modal title={t('game.chat')} bottomSheet onclose={() => (panel = 'none')}>
|
||||||
<Chat {messages} myId={app.session?.userId ?? ''} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
|
<Chat {messages} myId={app.session?.userId ?? ''} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user