Merge pull request 'Stage 17 #2: Connecting indicator + auto-retry (no more red toasts)' (#29) from feature/connecting-indicator into development
This commit was merged in pull request #29.
This commit is contained in:
@@ -1437,6 +1437,30 @@ provided cert) at the contour caddy; prod VPN; rollback.
|
||||
connector. The backend resolves the mover's display name (the score line and result are
|
||||
built per recipient). Covered by notify round-trip, emit, connector-render (en/ru) and
|
||||
routing tests.
|
||||
- **No-op drain of all bot updates (#1, investigated — no change):** confirmed the Telegram bot
|
||||
already long-polls and the library advances the offset for every delivered update (the default
|
||||
handler no-ops anything but `/start`), so the queue never piles up. Telegram withholds only
|
||||
`message_reaction` / `message_reaction_count` / `chat_member` by default, and — being
|
||||
unrequested — those never queue either. Owner chose to leave `allowed_updates` at the default
|
||||
(zero risk) rather than hand-maintain a full whitelist (a wrong/stale type would break
|
||||
`getUpdates` entirely); a specific type will be requested when a concrete handler needs it.
|
||||
- **Reconnect UX — "Connecting…" + soft-disable (#2, shipped):** connectivity failures became
|
||||
**state, not toasts**. A global `online` signal (`lib/connection.svelte.ts`) flips on a
|
||||
transport `unavailable` / `rate_limited` (and on the live stream's drop), driving a pure-CSS
|
||||
header **spinner + "Connecting…"** in place of the title and softly disabling the in-game
|
||||
server actions (commit / exchange / pass / hint; local board/rack/reset stay live). The
|
||||
transport (`exec`) **auto-retries with capped backoff** — every op on a rate-limit, **reads
|
||||
only** on `unavailable` (mutations are not blindly re-sent; their buttons are disabled while
|
||||
offline, so the player re-issues on reconnect — the idempotency caveat the owner accepted). A
|
||||
reachability **watcher** (`profile.get` probe) and any successful traffic clear the signal; the
|
||||
old red `error.unavailable` toast is gone (the indicator replaces it). A server-data screen
|
||||
still **opens with the spinner** and fills on reconnect (global indicator + read auto-retry),
|
||||
so navigation is never dead. Pure policy unit-tested (`retry.ts`); a mock-only `window.__conn`
|
||||
hook drives a Chromium+WebKit e2e (indicator appears offline, the action disables, both clear
|
||||
on reconnect). The visual soft-disable spans the server-action buttons across the app: the
|
||||
game bar (commit/exchange/pass/hint/resign), chat send + nudge, profile save / link / merge,
|
||||
friends (request/respond/unfriend/block/code), New Game (auto-match + invite) and the lobby
|
||||
hide ❌; purely local controls (board/rack/reset, menu, navigation, settings) stay live.
|
||||
|
||||
## Deferred TODOs (cross-stage)
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ import (
|
||||
// is unbounded; auto-provisioned platform names bypass this editor validation).
|
||||
const maxDisplayName = 32
|
||||
|
||||
// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators —
|
||||
// every name rune that is neither a letter nor a space) an editable display name may
|
||||
// carry, so a still-well-formed name cannot be made of mostly punctuation (Stage 17).
|
||||
const maxDisplayNameSpecials = 5
|
||||
|
||||
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
|
||||
const maxAwayWindow = 12 * time.Hour
|
||||
|
||||
@@ -110,6 +115,15 @@ func ValidateDisplayName(raw string) (string, error) {
|
||||
if !displayNameRe.MatchString(name) {
|
||||
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
|
||||
}
|
||||
specials := 0
|
||||
for _, r := range name {
|
||||
if r != ' ' && !unicode.IsLetter(r) {
|
||||
specials++
|
||||
}
|
||||
}
|
||||
if specials > maxDisplayNameSpecials {
|
||||
return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ func TestValidateDisplayName(t *testing.T) {
|
||||
"digit rejected": {"Name2", "", false},
|
||||
"blank": {" ", "", false},
|
||||
"too long": {strings.Repeat("a", 33), "", false},
|
||||
"five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots
|
||||
"six specials": {"a.a.a.a.a.a.a", "", false}, // 6 dots
|
||||
"initials spaces ok": {"J. R. R. Tolkien", "J. R. R. Tolkien", true}, // 3 dots; spaces don't count
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
|
||||
@@ -89,7 +89,15 @@ dropped). Horizontal scaling is explicit future work.
|
||||
auth operations are unauthenticated and return the minted token. A unary
|
||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||
surface as Connect error codes.
|
||||
surface as Connect error codes. The client (Stage 17) treats a connectivity edge failure as
|
||||
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
|
||||
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
|
||||
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
|
||||
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
|
||||
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
|
||||
response was lost — its button is disabled while offline and the player re-issues it on
|
||||
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
|
||||
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
|
||||
- **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not
|
||||
concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the
|
||||
`Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet
|
||||
|
||||
+8
-2
@@ -46,7 +46,12 @@ request) arrive as a **Telegram notification** instead — unless the player kee
|
||||
notifications in the app only (a profile setting, **on by default**). The "your turn"
|
||||
notification names the opponent and recaps their last move — the word and the running score
|
||||
for a scoring play, or that they swapped or passed — and a finished game sends a "game over"
|
||||
notification with your result and the final score (scores read with yours first).
|
||||
notification with your result and the final score (scores read with yours first). If the
|
||||
connection drops or the server is rate-limiting, the app does not nag with errors: the header
|
||||
shows a quiet **"Connecting…"** spinner while it reconnects, actions that send to the server
|
||||
pause until it is back (a server-data screen still opens, with the spinner, and fills in on
|
||||
reconnect), and pending reads resume on their own — the interface stays usable instead of
|
||||
flashing a red banner each time.
|
||||
|
||||
### Accounts, linking & merge *(Stage 1 / 11)*
|
||||
First platform contact auto-provisions a durable account. From the profile a player
|
||||
@@ -136,7 +141,8 @@ new chat message raises an **unread badge** on the game's menu until the chat is
|
||||
|
||||
### Profile & settings *(Stage 4 / 8)*
|
||||
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||||
optional trailing ".", up to 32 characters), the timezone (chosen as a UTC offset), the
|
||||
optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_"
|
||||
punctuation, spaces aside), the timezone (chosen as a UTC offset), the
|
||||
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
||||
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||
|
||||
@@ -48,7 +48,12 @@ Mini App** авторизует по подписанным `initData` плат
|
||||
Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и
|
||||
текущий счёт для результативного хода либо что он поменял фишки или пропустил, — а по
|
||||
завершении партии приходит уведомление «конец партии» с твоим результатом и финальным
|
||||
счётом (счёт читается, твой первым).
|
||||
счётом (счёт читается, твой первым). Если связь пропадает или сервер ограничивает частоту
|
||||
запросов, приложение не донимает ошибками: в шапке тихо крутится спиннер **«Подключение…»**,
|
||||
пока идёт переподключение, действия, отправляющие данные на сервер, приостанавливаются до
|
||||
восстановления связи (экран с серверными данными всё равно открывается — со спиннером — и
|
||||
подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся
|
||||
рабочим вместо красного баннера каждый раз.
|
||||
|
||||
### Аккаунты, привязка и слияние *(Stage 1 / 11)*
|
||||
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
||||
@@ -141,7 +146,8 @@ push доставляется через платформу.
|
||||
|
||||
### Профиль и настройки *(Stage 4 / 8)*
|
||||
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
||||
«_», с необязательной завершающей «.», до 32 символов), таймзоны (выбор смещения от
|
||||
«_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов —
|
||||
пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от
|
||||
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
|
||||
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||
|
||||
@@ -16,6 +16,25 @@ async function openGame(page: Page): Promise<void> {
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
}
|
||||
|
||||
test('offline shows the Connecting indicator and softly disables server actions (Stage 17)', async ({ page }) => {
|
||||
await openGame(page);
|
||||
// The exchange/draw tab is a server action; on my turn with tiles in the bag it is live.
|
||||
const draw = page.locator('.tab').first();
|
||||
await expect(draw).toBeEnabled();
|
||||
await expect(page.getByText('Connecting…')).toHaveCount(0);
|
||||
|
||||
// Drop the connection (mock-only hook): the header swaps the title for the spinner +
|
||||
// "Connecting…", and the server action goes inert.
|
||||
await page.evaluate(() => (window as unknown as { __conn: { offline(): void } }).__conn.offline());
|
||||
await expect(page.getByText('Connecting…')).toBeVisible();
|
||||
await expect(draw).toBeDisabled();
|
||||
|
||||
// Reconnect: the indicator clears and the action is live again.
|
||||
await page.evaluate(() => (window as unknown as { __conn: { online(): void } }).__conn.online());
|
||||
await expect(page.getByText('Connecting…')).toHaveCount(0);
|
||||
await expect(draw).toBeEnabled();
|
||||
});
|
||||
|
||||
test('placing a tile and confirming via ✅ commits the move', async ({ page }) => {
|
||||
await openGame(page);
|
||||
await page.locator('.rack .tile').first().click();
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
|
||||
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
|
||||
$props();
|
||||
@@ -20,7 +23,11 @@
|
||||
{:else}
|
||||
<span class="spacer"></span>
|
||||
{/if}
|
||||
<h1>{title}</h1>
|
||||
{#if connection.online}
|
||||
<h1>{title}</h1>
|
||||
{:else}
|
||||
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
||||
{/if}
|
||||
<div class="end">{#if menu}{@render menu()}{/if}</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -57,6 +64,16 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* The "Connecting…" indicator replaces the title while offline: a spinner + muted label,
|
||||
centred like the title so the bar does not shift. */
|
||||
h1.connecting {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.icon,
|
||||
.spacer,
|
||||
.end {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
let { size = '1em' }: { size?: string } = $props();
|
||||
</script>
|
||||
|
||||
<span class="sp" style="--sp-size: {size}" aria-hidden="true"></span>
|
||||
|
||||
<style>
|
||||
/* A pure-CSS ring with a single gap (a ~3/4 arc) that rotates — no bundled graphics. It
|
||||
inherits the surrounding text colour via currentColor, so it works in any header/theme. */
|
||||
.sp {
|
||||
display: inline-block;
|
||||
width: var(--sp-size, 1em);
|
||||
height: var(--sp-size, 1em);
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
opacity: 0.85;
|
||||
vertical-align: -0.15em;
|
||||
animation: sp-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes sp-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
/* Reduced motion: slow the rotation rather than freeze it (a still ring reads as broken). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sp {
|
||||
animation-duration: 1.8s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { ChatMessage } from '../lib/model';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
|
||||
let {
|
||||
messages,
|
||||
@@ -55,11 +56,11 @@
|
||||
bind:value={text}
|
||||
onkeydown={(e) => e.key === 'Enter' && send()}
|
||||
/>
|
||||
<button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button>
|
||||
<button class="iconbtn" onclick={send} disabled={busy || !connection.online} aria-label={t('chat.send')}>⬆️</button>
|
||||
{:else}
|
||||
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
|
||||
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
|
||||
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown} aria-label={t('chat.nudgeAction')}>🛎️</button>
|
||||
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { GatewayError } from '../lib/client';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import type { Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model';
|
||||
@@ -720,7 +721,7 @@
|
||||
/>
|
||||
</div>
|
||||
{#if !gameOver && placement.pending.length > 0}
|
||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
|
||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -730,14 +731,14 @@
|
||||
{#snippet tabbar()}
|
||||
{#if view}
|
||||
<TabBar>
|
||||
<button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}>
|
||||
<button class="tab" disabled={busy || !isMyTurn || !connection.online || bagEmpty} onclick={openExchange}>
|
||||
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||
</button>
|
||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
|
||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || !connection.online} onhold={doPass}>
|
||||
{#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 || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
|
||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || !connection.online || (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>
|
||||
@@ -793,7 +794,7 @@
|
||||
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
||||
<div class="confirm-row">
|
||||
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
|
||||
<button class="danger" onclick={doResign}>{t('game.dropGame')}</button>
|
||||
<button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
+17
-12
@@ -23,6 +23,8 @@ import {
|
||||
} from './telegram';
|
||||
import { parseStartParam } from './deeplink';
|
||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
||||
import { isConnectionCode } from './retry';
|
||||
import { clearGameCache } from './gamecache';
|
||||
import { clearLobby } from './lobbycache';
|
||||
import type { BoardLabelMode } from './boardlabels';
|
||||
@@ -113,18 +115,18 @@ 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. A connectivity
|
||||
* failure — or anything raised while the app is mid-reconnect — is shown by the "Connecting…"
|
||||
* header indicator (and auto-retried), never a red toast. */
|
||||
export function handleError(err: unknown): void {
|
||||
telegramHaptic('error');
|
||||
if (err instanceof GatewayError) {
|
||||
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
|
||||
void logout();
|
||||
return;
|
||||
}
|
||||
showToast(t(errorKey(err.code)), 'error');
|
||||
const code = err instanceof GatewayError ? err.code : '';
|
||||
if (code === 'session_invalid' || code === 'unauthenticated') {
|
||||
void logout();
|
||||
return;
|
||||
}
|
||||
showToast(t('error.generic'), 'error');
|
||||
if (isConnectionCode(code) || !connection.online) return;
|
||||
telegramHaptic('error');
|
||||
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
||||
}
|
||||
|
||||
function openStream(): void {
|
||||
@@ -132,6 +134,7 @@ function openStream(): void {
|
||||
streamAlive = true;
|
||||
unsubscribeStream = gateway.subscribe(
|
||||
(e) => {
|
||||
reportOnline(); // a delivered event proves the gateway is reachable
|
||||
app.lastEvent = e;
|
||||
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.
|
||||
@@ -155,9 +158,10 @@ function openStream(): void {
|
||||
},
|
||||
() => {
|
||||
streamAlive = false;
|
||||
// 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');
|
||||
// A background suspend drops the single-shot stream. Keep the indicator hidden while
|
||||
// backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the
|
||||
// reachability watcher and this scheduled retry recover it). Always schedule a retry.
|
||||
if (!bannerSuppressed()) reportOffline();
|
||||
scheduleReconnect();
|
||||
},
|
||||
);
|
||||
@@ -399,6 +403,7 @@ export async function loginEmail(email: string, code: string): Promise<void> {
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
closeStream();
|
||||
resetConnection();
|
||||
clearGameCache();
|
||||
clearLobby();
|
||||
gateway.setToken(null);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Global connectivity signal (Stage 17). `online` is false while the app is actively failing to
|
||||
// reach the gateway — a unary call retrying after a transport/rate-limit failure, or the live
|
||||
// stream dropped. The transport and the live-stream owner report transitions; the UI reads
|
||||
// `connection.online` to show the "Connecting…" indicator and to softly disable proactive
|
||||
// actions. In mock mode nothing ever reports trouble, so it simply stays online.
|
||||
//
|
||||
// Recovery is guaranteed by a reachability watcher: while offline it periodically fires a
|
||||
// registered probe (a lightweight read) until one succeeds, so the indicator clears even when no
|
||||
// other traffic is in flight.
|
||||
|
||||
import { backoffMs } from './retry';
|
||||
|
||||
let online = $state(true);
|
||||
let watchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let probe: (() => Promise<void>) | null = null;
|
||||
|
||||
export const connection = {
|
||||
/** online is true when the app believes it can reach the gateway. */
|
||||
get online(): boolean {
|
||||
return online;
|
||||
},
|
||||
};
|
||||
|
||||
/** registerProbe installs the reachability probe the watcher fires while offline. The transport
|
||||
* wires a cheap authenticated read; it should reject when there is no session. */
|
||||
export function registerProbe(fn: () => Promise<void>): void {
|
||||
probe = fn;
|
||||
}
|
||||
|
||||
/** reportOnline marks the gateway reachable and stops the watcher. */
|
||||
export function reportOnline(): void {
|
||||
online = true;
|
||||
if (watchTimer) {
|
||||
clearTimeout(watchTimer);
|
||||
watchTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** reportOffline marks the gateway unreachable and starts the reachability watcher (once). */
|
||||
export function reportOffline(): void {
|
||||
online = false;
|
||||
if (!watchTimer && probe) scheduleProbe(1);
|
||||
}
|
||||
|
||||
/** resetConnection restores the online state and stops the watcher (e.g. on logout). */
|
||||
export function resetConnection(): void {
|
||||
reportOnline();
|
||||
}
|
||||
|
||||
function scheduleProbe(attempt: number): void {
|
||||
watchTimer = setTimeout(
|
||||
() => {
|
||||
watchTimer = null;
|
||||
if (online || !probe) return;
|
||||
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
|
||||
},
|
||||
backoffMs(attempt),
|
||||
);
|
||||
}
|
||||
@@ -5,9 +5,19 @@
|
||||
import type { GatewayClient } from './client';
|
||||
import { MockGateway } from './mock/client';
|
||||
import { createTransport } from './transport';
|
||||
import { reportOffline, reportOnline } from './connection.svelte';
|
||||
|
||||
const isMock = import.meta.env.MODE === 'mock';
|
||||
|
||||
export const gateway: GatewayClient = isMock
|
||||
? new MockGateway()
|
||||
: createTransport(import.meta.env.VITE_GATEWAY_URL ?? '');
|
||||
|
||||
// Mock-mode test hook (tree-shaken from a production build, where MODE !== 'mock'): the mock
|
||||
// transport never exercises the connectivity indicator, so the Playwright e2e drives it directly.
|
||||
if (isMock && typeof window !== 'undefined') {
|
||||
(window as unknown as { __conn?: { offline(): void; online(): void } }).__conn = {
|
||||
offline: reportOffline,
|
||||
online: reportOnline,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export const en = {
|
||||
'app.title': 'Scrabble',
|
||||
'connection.connecting': 'Connecting…',
|
||||
|
||||
'common.back': 'Back',
|
||||
'common.cancel': 'Cancel',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
|
||||
|
||||
export const ru: Record<MessageKey, string> = {
|
||||
'app.title': 'Scrabble',
|
||||
'connection.connecting': 'Подключение…',
|
||||
|
||||
'common.back': 'Назад',
|
||||
'common.cancel': 'Отмена',
|
||||
|
||||
@@ -19,6 +19,9 @@ describe('validDisplayName', () => {
|
||||
['Name2', false],
|
||||
['', false],
|
||||
['a'.repeat(33), false],
|
||||
['a.a.a.a.a.a', true], // 5 dots — at the special-char limit
|
||||
['a.a.a.a.a.a.a', false], // 6 dots — over the limit
|
||||
['J. R. R. Tolkien', true], // 3 dots; spaces are not special
|
||||
])('%s -> %s', (name, ok) => {
|
||||
expect(validDisplayName(name)).toBe(ok);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
/** maxDisplayName caps the editable display name in runes. */
|
||||
export const maxDisplayName = 32;
|
||||
|
||||
/** maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — every
|
||||
* rune that is neither a letter nor a space) a display name may carry. Mirrors the Go rule. */
|
||||
export const maxDisplayNameSpecials = 5;
|
||||
|
||||
/** maxAwayMinutes bounds the daily away window's length (12 h). */
|
||||
export const maxAwayMinutes = 12 * 60;
|
||||
|
||||
@@ -17,7 +21,10 @@ const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u;
|
||||
/** displayNameError returns true when the trimmed name is a valid display name. */
|
||||
export function validDisplayName(raw: string): boolean {
|
||||
const name = raw.trim();
|
||||
return name.length > 0 && [...name].length <= maxDisplayName && displayNameRe.test(name);
|
||||
const chars = [...name];
|
||||
if (name.length === 0 || chars.length > maxDisplayName || !displayNameRe.test(name)) return false;
|
||||
const specials = chars.filter((c) => c !== ' ' && !/\p{L}/u.test(c)).length;
|
||||
return specials <= maxDisplayNameSpecials;
|
||||
}
|
||||
|
||||
// A pragmatic email check (the backend re-validates with net/mail). Rejects spaces
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Code, ConnectError } from '@connectrpc/connect';
|
||||
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||
|
||||
describe('toGatewayError', () => {
|
||||
it('collapses every transport-level failure to a connection code (so it is retried + suppressed)', () => {
|
||||
for (const c of [Code.Unavailable, Code.DeadlineExceeded, Code.Canceled, Code.Aborted, Code.Unknown]) {
|
||||
const e = toGatewayError(new ConnectError('x', c));
|
||||
expect(e.code).toBe('unavailable');
|
||||
expect(isConnectionCode(e.code)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a raw (non-Connect) network error as a connection failure', () => {
|
||||
expect(toGatewayError(new TypeError('Failed to fetch')).code).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('preserves rate-limit, an invalid session, and a genuine server-internal error', () => {
|
||||
expect(toGatewayError(new ConnectError('x', Code.ResourceExhausted)).code).toBe('rate_limited');
|
||||
expect(toGatewayError(new ConnectError('x', Code.Unauthenticated)).code).toBe('session_invalid');
|
||||
expect(toGatewayError(new ConnectError('x', Code.Internal)).code).toBe('internal');
|
||||
expect(isConnectionCode('internal')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryable', () => {
|
||||
it('retries any op on a rate-limit rejection (it never reached the backend)', () => {
|
||||
expect(retryable('rate_limited', 'game.submit_play')).toBe(true);
|
||||
expect(retryable('rate_limited', 'games.list')).toBe(true);
|
||||
expect(retryable('rate_limited', 'chat.post')).toBe(true);
|
||||
});
|
||||
|
||||
it('retries only read-only ops on a transport unavailable (a mutation could double-apply)', () => {
|
||||
expect(retryable('unavailable', 'games.list')).toBe(true);
|
||||
expect(retryable('unavailable', 'game.state')).toBe(true);
|
||||
expect(retryable('unavailable', 'draft.get')).toBe(true);
|
||||
// mutations are not auto-resent on a dropped connection
|
||||
expect(retryable('unavailable', 'game.submit_play')).toBe(false);
|
||||
expect(retryable('unavailable', 'chat.post')).toBe(false);
|
||||
expect(retryable('unavailable', 'game.hide')).toBe(false);
|
||||
});
|
||||
|
||||
it('never retries a domain rejection or an unknown code', () => {
|
||||
expect(retryable('not_your_turn', 'game.submit_play')).toBe(false);
|
||||
expect(retryable('illegal_play', 'game.submit_play')).toBe(false);
|
||||
expect(retryable('not_found', 'game.state')).toBe(false);
|
||||
expect(retryable('internal', 'games.list')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConnectionCode', () => {
|
||||
it('flags the transport/rate-limit codes the indicator covers', () => {
|
||||
expect(isConnectionCode('unavailable')).toBe(true);
|
||||
expect(isConnectionCode('rate_limited')).toBe(true);
|
||||
expect(isConnectionCode('not_your_turn')).toBe(false);
|
||||
expect(isConnectionCode('internal')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backoffMs', () => {
|
||||
it('grows exponentially and caps at 8s (+ jitter under 250ms)', () => {
|
||||
// attempt 1 ~ 500, 2 ~ 1000, 3 ~ 2000, 4 ~ 4000, 5 ~ 8000, then capped.
|
||||
expect(backoffMs(1)).toBeGreaterThanOrEqual(500);
|
||||
expect(backoffMs(1)).toBeLessThan(750);
|
||||
expect(backoffMs(3)).toBeGreaterThanOrEqual(2000);
|
||||
expect(backoffMs(3)).toBeLessThan(2250);
|
||||
for (const n of [5, 6, 10, 20]) {
|
||||
expect(backoffMs(n)).toBeGreaterThanOrEqual(8000);
|
||||
expect(backoffMs(n)).toBeLessThan(8250);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// Retry policy + error classification for the gateway transport (Stage 17). When a unary call
|
||||
// fails at the transport level the app retries it with capped exponential backoff while showing
|
||||
// the "Connecting…" indicator, instead of flashing a red toast each time.
|
||||
//
|
||||
// Idempotency: a rate-limit rejection (ResourceExhausted) never reached the backend, so any op is
|
||||
// safe to retry. A transport 'unavailable' is ambiguous for a mutation (its response could have
|
||||
// been lost after the backend applied it), so only **read-only** ops are auto-retried on
|
||||
// 'unavailable'; a mutation is surfaced instead (its button is disabled while offline and
|
||||
// re-enables on reconnect, so the player re-issues it deliberately).
|
||||
|
||||
import { Code, ConnectError } from '@connectrpc/connect';
|
||||
import { GatewayError } from './client';
|
||||
|
||||
/**
|
||||
* toGatewayError normalises a thrown Connect/transport error to a GatewayError with a stable code.
|
||||
* Connection-level failures — the server is unreachable, the request timed out, was reset or
|
||||
* cancelled, or a raw network error — all collapse to **'unavailable'**, so they are handled as
|
||||
* connectivity (the indicator + retry), never as a red error toast. A genuine server-side
|
||||
* 'internal' or a domain code is preserved.
|
||||
*/
|
||||
export function toGatewayError(e: unknown): GatewayError {
|
||||
if (e instanceof ConnectError) {
|
||||
switch (e.code) {
|
||||
case Code.Unauthenticated:
|
||||
return new GatewayError('session_invalid', e.message);
|
||||
case Code.ResourceExhausted:
|
||||
return new GatewayError('rate_limited', e.message);
|
||||
case Code.Unavailable:
|
||||
case Code.DeadlineExceeded:
|
||||
case Code.Canceled:
|
||||
case Code.Aborted:
|
||||
case Code.Unknown:
|
||||
return new GatewayError('unavailable', e.message);
|
||||
case Code.NotFound:
|
||||
return new GatewayError('not_found', e.message);
|
||||
default:
|
||||
return new GatewayError('internal', e.message);
|
||||
}
|
||||
}
|
||||
return new GatewayError('unavailable', String(e));
|
||||
}
|
||||
|
||||
/** READ_OPS is the set of side-effect-free message types (safe to auto-retry on any failure). */
|
||||
export const READ_OPS: ReadonlySet<string> = new Set([
|
||||
'profile.get',
|
||||
'games.list',
|
||||
'game.state',
|
||||
'game.history',
|
||||
'game.gcg',
|
||||
'game.evaluate',
|
||||
'game.check_word',
|
||||
'stats.get',
|
||||
'lobby.poll',
|
||||
'chat.list',
|
||||
'draft.get',
|
||||
'friends.list',
|
||||
'friends.incoming',
|
||||
'friends.outgoing',
|
||||
'blocks.list',
|
||||
'invitation.list',
|
||||
]);
|
||||
|
||||
/**
|
||||
* retryable reports whether a failed op should be auto-retried. A rate-limit rejection is always
|
||||
* safe (the gateway rejected it before processing); a transport 'unavailable' is retried only for
|
||||
* read-only ops, never a mutation; every other code (a domain rejection, not-found, …) is final.
|
||||
*/
|
||||
export function retryable(code: string, op: string): boolean {
|
||||
if (code === 'rate_limited') return true;
|
||||
if (code === 'unavailable') return READ_OPS.has(op);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** isConnectionCode reports whether a code is a transport/connectivity failure the Connecting
|
||||
* indicator covers (so the UI suppresses its red toast). */
|
||||
export function isConnectionCode(code: string): boolean {
|
||||
return code === 'unavailable' || code === 'rate_limited';
|
||||
}
|
||||
|
||||
/** backoffMs is the delay before retry attempt n (1-based): capped exponential growth plus a
|
||||
* little jitter, so a fleet of clients does not retry in lockstep after an outage. */
|
||||
export function backoffMs(attempt: number): number {
|
||||
const base = Math.min(8000, 500 * 2 ** Math.max(0, attempt - 1));
|
||||
return base + Math.floor(Math.random() * 250);
|
||||
}
|
||||
+32
-25
@@ -5,29 +5,16 @@
|
||||
// a thrown GatewayError. In dev the Vite proxy forwards the RPC path to the h2c
|
||||
// gateway; in a packaged app VITE_GATEWAY_URL points at the real origin.
|
||||
|
||||
import { Code, ConnectError, createClient } from '@connectrpc/connect';
|
||||
import { createClient } from '@connectrpc/connect';
|
||||
import { createConnectTransport } from '@connectrpc/connect-web';
|
||||
import { Gateway } from '../gen/edge/v1/edge_pb';
|
||||
import { GatewayError, type GatewayClient } from './client';
|
||||
import * as codec from './codec';
|
||||
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
||||
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||
|
||||
function toGatewayError(e: unknown): GatewayError {
|
||||
if (e instanceof ConnectError) {
|
||||
switch (e.code) {
|
||||
case Code.Unauthenticated:
|
||||
return new GatewayError('session_invalid', e.message);
|
||||
case Code.ResourceExhausted:
|
||||
return new GatewayError('rate_limited', e.message);
|
||||
case Code.Unavailable:
|
||||
return new GatewayError('unavailable', e.message);
|
||||
case Code.NotFound:
|
||||
return new GatewayError('not_found', e.message);
|
||||
default:
|
||||
return new GatewayError('internal', e.message);
|
||||
}
|
||||
}
|
||||
return new GatewayError('unavailable', String(e));
|
||||
}
|
||||
const MAX_RETRIES = 6;
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export function createTransport(baseUrl: string): GatewayClient {
|
||||
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
|
||||
@@ -38,15 +25,35 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
const headers = (): Record<string, string> | undefined =>
|
||||
token ? { authorization: `Bearer ${token}` } : undefined;
|
||||
|
||||
// The reachability probe the connection watcher fires while offline: a cheap authenticated read
|
||||
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
||||
registerProbe(async () => {
|
||||
if (!token) throw new Error('no session');
|
||||
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
||||
});
|
||||
|
||||
// exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a
|
||||
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
|
||||
// indicator. A successful round-trip marks the gateway reachable; a domain result_code is final.
|
||||
async function exec(messageType: string, payload: Uint8Array): Promise<Uint8Array> {
|
||||
let res;
|
||||
try {
|
||||
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() });
|
||||
} catch (e) {
|
||||
throw toGatewayError(e);
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
let res;
|
||||
try {
|
||||
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() });
|
||||
} catch (e) {
|
||||
const err = toGatewayError(e);
|
||||
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
|
||||
reportOffline();
|
||||
await sleep(backoffMs(attempt + 1));
|
||||
continue;
|
||||
}
|
||||
if (isConnectionCode(err.code)) reportOffline();
|
||||
throw err;
|
||||
}
|
||||
reportOnline();
|
||||
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
||||
return res.payload;
|
||||
}
|
||||
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
||||
return res.payload;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { friendCodeParam, shareLink } from '../lib/deeplink';
|
||||
@@ -95,7 +96,7 @@
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
/>
|
||||
<button class="btn" onclick={redeem}>{t('friends.redeem')}</button>
|
||||
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
|
||||
</div>
|
||||
{#if code}
|
||||
{@const tg = shareLink(friendCodeParam(code.code))}
|
||||
@@ -112,7 +113,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button class="link" onclick={getCode}>{t('friends.getCode')}</button>
|
||||
<button class="link" onclick={getCode} disabled={!connection.online}>{t('friends.getCode')}</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -123,8 +124,8 @@
|
||||
<div class="item">
|
||||
<span class="who">{r.displayName}</span>
|
||||
<span class="acts">
|
||||
<button class="btn" onclick={() => respond(r.accountId, true)}>{t('friends.accept')}</button>
|
||||
<button class="ghost" onclick={() => respond(r.accountId, false)}>{t('friends.decline')}</button>
|
||||
<button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button>
|
||||
<button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -138,8 +139,8 @@
|
||||
<div class="item">
|
||||
<span class="who">{f.displayName}</span>
|
||||
<span class="acts">
|
||||
<button class="ghost" onclick={() => remove(f.accountId)}>{t('friends.unfriend')}</button>
|
||||
<button class="ghost danger" onclick={() => blockUser(f.accountId)}>{t('friends.block')}</button>
|
||||
<button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button>
|
||||
<button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -154,7 +155,7 @@
|
||||
{#each blocked as b (b.accountId)}
|
||||
<div class="item">
|
||||
<span class="who">{b.displayName}</span>
|
||||
<button class="ghost" onclick={() => unblock(b.accountId)}>{t('friends.unblock')}</button>
|
||||
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Menu from '../components/Menu.svelte';
|
||||
import TabBar from '../components/TabBar.svelte';
|
||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
@@ -191,7 +192,7 @@
|
||||
{#each group.list as g (g.id)}
|
||||
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
|
||||
{#if group.finished}
|
||||
<button class="del" onclick={() => hide(g.id)} aria-label={t('lobby.hideGame')}>❌</button>
|
||||
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
||||
{/if}
|
||||
<div class="row">
|
||||
<button
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import type { AccountRef, Variant } from '../lib/model';
|
||||
@@ -144,7 +145,7 @@
|
||||
<p class="subtitle">{t('new.subtitle')}</p>
|
||||
<div class="variants">
|
||||
{#each variants as v (v.id)}
|
||||
<button class="variant" onclick={() => find(v.id)}>
|
||||
<button class="variant" onclick={() => find(v.id)} disabled={!connection.online}>
|
||||
<span class="vmain">
|
||||
<span class="vname">{t(v.label)}</span>
|
||||
{#if VARIANT_FLAG[v.id]}
|
||||
@@ -196,7 +197,7 @@
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="invite" disabled={selected.length === 0 || !inviteVariant} onclick={sendInvite}>{t('new.invite')}</button>
|
||||
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
@@ -209,7 +210,7 @@
|
||||
<span>{t('profile.notificationsInAppOnly')}</span>
|
||||
</label>
|
||||
<div class="formacts">
|
||||
<button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button>
|
||||
<button type="submit" class="btn" disabled={!formValid || !connection.online}>{t('common.save')}</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -226,7 +227,7 @@
|
||||
placeholder={t('login.emailPlaceholder')}
|
||||
type="email"
|
||||
/>
|
||||
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
|
||||
<button class="ghost" onclick={requestEmail} disabled={!emailOk || !connection.online}>{t('login.sendCode')}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="addrow">
|
||||
@@ -237,11 +238,11 @@
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
/>
|
||||
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
|
||||
<button class="btn" onclick={confirmEmail} disabled={!connection.online}>{t('common.ok')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if telegramLinkable}
|
||||
<button class="ghost tg" onclick={linkTelegram}>{t('profile.linkTelegram')}</button>
|
||||
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>{t('profile.linkTelegram')}</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -257,7 +258,7 @@
|
||||
<p class="warn">{t('profile.mergeIrreversible')}</p>
|
||||
<div class="addrow end">
|
||||
<button class="ghost" onclick={() => (pendingMerge = null)}>{t('common.cancel')}</button>
|
||||
<button class="btn" onclick={confirmMerge}>{t('profile.mergeConfirm')}</button>
|
||||
<button class="btn" onclick={confirmMerge} disabled={!connection.online}>{t('profile.mergeConfirm')}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user