9471341a0e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.
The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
163 lines
6.1 KiB
Svelte
163 lines
6.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import Chat from './Chat.svelte';
|
|
import { gateway } from '../lib/gateway';
|
|
import { app, handleError, markChatRead } from '../lib/app.svelte';
|
|
import { canSendChat, sentThisTurn } from '../lib/chatlimit';
|
|
import type { ChatMessage, StateView } from '../lib/model';
|
|
|
|
// The Chat tab body, hosted by CommsHub (which supplies the nav bar + tab bar). The
|
|
// hub lays it out as a non-scrolling column, so the soft keyboard simply resizes the
|
|
// viewport with the input pinned to the bottom. It loads the game state (for the
|
|
// turn-based chat/nudge toggle) and the message list, and clears the unread 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);
|
|
// The opponents the viewer has blocked: when every opponent is blocked the composer is hidden
|
|
// (only the chat log remains). blockedIds are blocked humans (by account); blockedRobotSeats are
|
|
// the seats of blocked disguised robots in this game (a robot block is per game+seat).
|
|
let blockedIds = $state(new Set<string>());
|
|
let blockedRobotSeats = $state(new Set<number>());
|
|
|
|
const myId = $derived(app.session?.userId ?? '');
|
|
const isMyTurn = $derived(
|
|
!!view && (view.game.status === 'active' || view.game.status === 'open') && view.game.toMove === view.seat,
|
|
);
|
|
// At most one chat message per turn: "already wrote this turn" is derived from the
|
|
// message list against the move-driven turn start (lastActivityUnix), so it resets when
|
|
// the turn advances. canSend hides the field once the limit is reached (and always on
|
|
// the opponent's turn).
|
|
const canSend = $derived(canSendChat(isMyTurn, view ? sentThisTurn(messages, myId, view.game.lastActivityUnix) : 0));
|
|
// The nudge is offered only on the opponent's turn of an active game (you hurry the player
|
|
// who is to move). A finished game is read-only: neither chat nor nudge is offered.
|
|
const canNudge = $derived(!!view && view.game.status === 'active' && view.game.toMove !== view.seat);
|
|
// While the auto-match game still has no opponent, chat and nudge are both disabled.
|
|
const waiting = $derived(!!view && view.game.status === 'open');
|
|
// peerUnreachable is true when no seated opponent can be reached — each is either one the viewer
|
|
// has blocked or a deleted account: the whole composer (message field, send and nudge) is then
|
|
// hidden, leaving only the chat log.
|
|
const peerUnreachable = $derived.by(() => {
|
|
const v = view;
|
|
if (!v) return false;
|
|
const opponents = v.game.seats.filter((s) => s.seat !== v.seat && !!s.accountId);
|
|
return (
|
|
opponents.length > 0 &&
|
|
opponents.every((s) => s.deleted || blockedIds.has(s.accountId) || blockedRobotSeats.has(s.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);
|
|
markChatRead(id);
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
// loadState (re)reads the game state that drives the chat/nudge toggle and the per-turn
|
|
// limit. The initial load surfaces an error; live reloads are best-effort.
|
|
async function loadState(initial = false) {
|
|
try {
|
|
view = await gateway.gameState(id, false);
|
|
} catch (e) {
|
|
if (initial) handleError(e);
|
|
}
|
|
}
|
|
// loadBlocked refreshes the viewer's blocked set (best-effort); guests have none.
|
|
async function loadBlocked() {
|
|
if (app.profile?.isGuest) return;
|
|
try {
|
|
const bl = await gateway.blocksList();
|
|
blockedIds = new Set(bl.blocked.map((b) => b.accountId));
|
|
blockedRobotSeats = new Set(bl.robots.filter((r) => r.gameId === id).map((r) => r.seat));
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
onMount(async () => {
|
|
await loadState(true);
|
|
await refresh();
|
|
void loadBlocked();
|
|
});
|
|
|
|
// Live: refresh the message list on a chat / nudge for this game, and reload the state on
|
|
// a turn / game-state change so the chat/nudge toggle and the per-turn limit (keyed on the
|
|
// turn start) follow the live 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();
|
|
}
|
|
if (
|
|
(e.kind === 'your_turn' || e.kind === 'opponent_moved' || e.kind === 'game_over' || e.kind === 'opponent_joined') &&
|
|
e.gameId === id
|
|
) {
|
|
void loadState();
|
|
}
|
|
// A block/unblock applied: re-derive whether the composer should be hidden.
|
|
if (e.kind === 'notify' && (e.sub === 'user_blocked' || e.sub === 'user_unblocked')) {
|
|
void loadBlocked();
|
|
}
|
|
});
|
|
// 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>
|
|
|
|
<Chat
|
|
{messages}
|
|
{myId}
|
|
{busy}
|
|
myTurn={isMyTurn}
|
|
{canSend}
|
|
{canNudge}
|
|
{waiting}
|
|
{nudgeOnCooldown}
|
|
vsAi={!!view && view.game.vsAi}
|
|
blocked={peerUnreachable}
|
|
onsend={sendChat}
|
|
onnudge={nudge}
|
|
/>
|