Stage 17 (contour round 2): Grafana Live/reload, rate-limit, iOS reconnect, hint/plaque/make-move UX
- Grafana: disable Live (GF_LIVE_MAX_CONNECTIONS=0) so its WebSocket no longer trips caddy Basic-Auth and re-prompts; admin console gains a Grafana nav link - deploy: force-recreate config-only services so reseeded Grafana dashboards / Caddyfile are actually picked up (the move-duration panel was invisible because the bind-mount went stale) - rate-limit: raise per-user budget 120/40 -> 300/80; UI skips reloading on the echo of the player's own move (fewer requests, no double-load) - iOS/Telegram reconnect: suppress the connection banner while backgrounded and for a short grace after resume; reconnect silently; wire visibilitychange + pageshow/pagehide + Telegram activated/deactivated (Bot API 8.0) - hint button disabled when 0 hints remain; nudge button shows a disabled state on your own turn - players plaque: invert so the active seat pops (accent chip, raised) and others recede - make-move UX: a direct ✅ commit button (no hold/popover); the Shuffle tab becomes ↩️ Reset while tiles are pending
This commit is contained in:
@@ -119,4 +119,7 @@
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.iconbtn:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
|
||||
+31
-22
@@ -126,7 +126,11 @@
|
||||
$effect(() => {
|
||||
const e = app.lastEvent;
|
||||
if (!e) return;
|
||||
if ((e.kind === 'opponent_moved' || e.kind === 'your_turn') && e.gameId === id) void load();
|
||||
if (e.kind === 'opponent_moved' && e.gameId === id) {
|
||||
// Skip the echo of my own move (the backend now notifies the actor too, for the
|
||||
// player's other devices): this device already reloaded after the submit.
|
||||
if (e.seat !== view?.seat) void load();
|
||||
} else if (e.kind === 'your_turn' && e.gameId === id) void load();
|
||||
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
|
||||
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
|
||||
});
|
||||
@@ -522,13 +526,7 @@
|
||||
<Rack {slots} {variant} {selected} ondown={onRackDown} />
|
||||
</div>
|
||||
{#if !gameOver && placement.pending.length > 0}
|
||||
<HoldConfirm triggerClass="make" onhold={commit}>
|
||||
{#snippet trigger()}<span class="flag">🏁</span>{/snippet}
|
||||
{#snippet popover(close)}
|
||||
<button class="pop go" onclick={() => { close(); commit(); }}>{t('game.makeMove')} ✅</button>
|
||||
<button class="pop rs" onclick={() => { close(); resetPlacement(); }}>{t('game.reset')} ❌</button>
|
||||
{/snippet}
|
||||
</HoldConfirm>
|
||||
<button class="make" onclick={commit} disabled={busy} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -545,16 +543,22 @@
|
||||
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
|
||||
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||
</HoldConfirm>
|
||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doHint}>
|
||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
|
||||
{#snippet trigger()}
|
||||
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
|
||||
<span class="lbl">{t('game.hint')}</span>
|
||||
{/snippet}
|
||||
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||
</HoldConfirm>
|
||||
<button class="tab" disabled={busy || gameOver || placement.pending.length > 0} onclick={shuffle}>
|
||||
<span class="sq">🔀</span>
|
||||
</button>
|
||||
{#if placement.pending.length > 0}
|
||||
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
||||
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
|
||||
<span class="sq">🔀</span>
|
||||
</button>
|
||||
{/if}
|
||||
</TabBar>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -641,15 +645,18 @@
|
||||
text-align: center;
|
||||
padding: 5px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
/* inactive seats read as "sunk in" */
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.22);
|
||||
/* inactive seats recede: they blend into the bar, slightly sunk */
|
||||
background: transparent;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.seat .nm {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.seat.turn {
|
||||
/* the active seat is "raised": lifted clear of the others with side shadows */
|
||||
background: var(--bg-elev);
|
||||
/* the active seat pops: a raised, accented chip lifted clear of the bar */
|
||||
background: var(--surface-2);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.16),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
-3px 0 6px -2px rgba(0, 0, 0, 0.26),
|
||||
3px 0 6px -2px rgba(0, 0, 0, 0.26);
|
||||
position: relative;
|
||||
@@ -767,16 +774,18 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.flag {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
:global(.make) {
|
||||
.make {
|
||||
min-width: 56px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.make:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.pop {
|
||||
padding: 9px 14px;
|
||||
|
||||
+47
-20
@@ -9,7 +9,7 @@ import { GatewayError } from './client';
|
||||
import { navigate, router } from './router.svelte';
|
||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
||||
import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch } from './telegram';
|
||||
import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch, telegramOnEvent } from './telegram';
|
||||
import { parseStartParam } from './deeplink';
|
||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||
import { clearGameCache } from './gamecache';
|
||||
@@ -52,11 +52,38 @@ let streamAlive = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** documentHidden reports whether the app is currently backgrounded. */
|
||||
// Background/foreground tracking, to silence the reconnect banner during a normal app
|
||||
// suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return.
|
||||
let backgrounded = false;
|
||||
let foregroundedAt = 0;
|
||||
const reconnectGraceMs = 4000;
|
||||
|
||||
/** documentHidden reports whether the page is currently hidden. */
|
||||
function documentHidden(): boolean {
|
||||
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
* bannerSuppressed reports whether the connection banner should stay hidden: while
|
||||
* backgrounded, and for a short grace after returning to the foreground — a connection
|
||||
* dropped while suspended surfaces its error on resume, before the silent reconnect lands.
|
||||
*/
|
||||
function bannerSuppressed(): boolean {
|
||||
return backgrounded || documentHidden() || Date.now() - foregroundedAt < reconnectGraceMs;
|
||||
}
|
||||
|
||||
function goBackground(): void {
|
||||
backgrounded = true;
|
||||
}
|
||||
|
||||
function goForeground(): void {
|
||||
backgrounded = false;
|
||||
foregroundedAt = Date.now();
|
||||
if (!app.session) return;
|
||||
if (!streamAlive) openStream(); // silently re-establish a stream dropped while away
|
||||
void refreshNotifications();
|
||||
}
|
||||
|
||||
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
||||
app.toast = { kind, text };
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
@@ -96,14 +123,10 @@ function openStream(): void {
|
||||
},
|
||||
() => {
|
||||
streamAlive = false;
|
||||
// A background suspend (iOS / Telegram) drops the single-shot stream. Don't
|
||||
// alarm the user with the connection banner while hidden — reconnect silently
|
||||
// on return (the visibilitychange handler). Show the banner only on a failure
|
||||
// seen in the foreground, and retry it.
|
||||
if (!documentHidden()) {
|
||||
showToast(t('error.unavailable'), 'error');
|
||||
scheduleReconnect();
|
||||
}
|
||||
// A background suspend drops the single-shot stream. Keep the banner hidden while
|
||||
// backgrounded or just-resumed (bannerSuppressed); always schedule a retry.
|
||||
if (!bannerSuppressed()) showToast(t('error.unavailable'), 'error');
|
||||
scheduleReconnect();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -114,7 +137,7 @@ function scheduleReconnect(): void {
|
||||
if (reconnectTimer || !app.session) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (app.session && !streamAlive && !documentHidden()) openStream();
|
||||
if (app.session && !streamAlive && !backgrounded && !documentHidden()) openStream();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
@@ -353,14 +376,18 @@ export function setBoardLabels(mode: BoardLabelMode): void {
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
// On return to the foreground: silently re-establish a stream dropped while the app
|
||||
// was backgrounded (iOS/Telegram suspend it), and refresh the lobby badge for any
|
||||
// push 'notify' missed while hidden (poll + push, see §10).
|
||||
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
|
||||
// reconnect quietly on return (and refresh the lobby badge for any push missed while
|
||||
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
|
||||
// pageshow/pagehide pair (iOS), and Telegram's own activated/deactivated (Bot API 8.0).
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && app.session) {
|
||||
if (!streamAlive) openStream();
|
||||
void refreshNotifications();
|
||||
}
|
||||
});
|
||||
document.addEventListener('visibilitychange', () =>
|
||||
document.visibilityState === 'visible' ? goForeground() : goBackground(),
|
||||
);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pageshow', goForeground);
|
||||
window.addEventListener('pagehide', goBackground);
|
||||
}
|
||||
telegramOnEvent('activated', goForeground);
|
||||
telegramOnEvent('deactivated', goBackground);
|
||||
|
||||
@@ -12,6 +12,7 @@ interface TelegramWebApp {
|
||||
colorScheme?: 'light' | 'dark';
|
||||
ready?: () => void;
|
||||
expand?: () => void;
|
||||
onEvent?: (event: string, handler: () => void) => void;
|
||||
}
|
||||
|
||||
function webApp(): TelegramWebApp | undefined {
|
||||
@@ -49,6 +50,15 @@ export function telegramLaunch(): TelegramLaunch {
|
||||
return { initData: w.initData, startParam, theme: w.themeParams };
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramOnEvent subscribes to a Telegram WebApp lifecycle event (e.g. 'activated' /
|
||||
* 'deactivated', added in Bot API 8.0). It is a no-op outside Telegram or on a client
|
||||
* that predates the event, so callers can register defensively.
|
||||
*/
|
||||
export function telegramOnEvent(event: string, handler: () => void): void {
|
||||
webApp()?.onEvent?.(event, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramColorScheme returns Telegram's active colour scheme ('light' | 'dark'),
|
||||
* or undefined outside Telegram. Inside the Mini App this — not the OS
|
||||
|
||||
Reference in New Issue
Block a user