feat: sparser robot nudges, typed unread badge, lobby unread bump
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
Three owner-requested polish changes: - robot: replace the lengthening 60-90 min -> 6 h proactive-nudge ramp with a flat uniform 9-12 h wait before every nudge; the existing sleep-window gate still skips and defers a nudge that would land in the robot's night. - ui: colour the lobby/in-game unread dot by type -- the regular danger colour when a chat message is unread, a softer amber (--warn) when only nudges are. Adds a per-viewer unread_messages flag (chat_messages.kind='message') across the backend DTO, FlatBuffers wire, gateway transcode and the UI store. - ui: float games with any unread notification to the top of the lobby's your-turn and opponent-turn sections (finished keeps its order), reusing the existing unread_chat flag. Docs (ARCHITECTURE 7, FUNCTIONAL + _ru) updated. No DB migration; the new wire field is backward-compatible.
This commit is contained in:
+10
-4
@@ -14,6 +14,7 @@
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
||||
import { lastMoveCells, replay } from '../lib/board';
|
||||
import { badgeKind } from '../lib/unread';
|
||||
import { historyGrid } from '../lib/history';
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
import { variantNameKey } from '../lib/variants';
|
||||
@@ -183,7 +184,7 @@
|
||||
view = st;
|
||||
syncWallet(st.walletBalance);
|
||||
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
||||
seedChatUnread(id, st.game.unreadChat);
|
||||
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
|
||||
moves = hist.moves;
|
||||
setCachedGame(id, st, hist.moves, draft);
|
||||
// Mirror the fresh status into the lobby snapshot so returning there shows it without a
|
||||
@@ -670,7 +671,7 @@
|
||||
};
|
||||
// The move result is an authoritative per-viewer view: a nudge the actor just answered by
|
||||
// moving is already cleared server-side, so reconcile the unread flag from it.
|
||||
seedChatUnread(id, r.game.unreadChat);
|
||||
seedChatUnread(id, r.game.unreadChat, r.game.unreadMessages);
|
||||
moves = [...moves, r.move];
|
||||
// A committed move clears the actor's draft on the server, so clear the cached draft too;
|
||||
// otherwise a same-session re-entry would briefly re-apply the now-stale composition.
|
||||
@@ -1091,10 +1092,11 @@
|
||||
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
|
||||
{#snippet scoreboardBlock()}
|
||||
{#if view}
|
||||
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}>
|
||||
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if}
|
||||
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
||||
{#each view.game.seats as s (s.seat)}
|
||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div>
|
||||
@@ -1582,13 +1584,17 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
/* The unread-chat dot on the score bar's corner; the history's 💬 icon fade-blinks (two cycles)
|
||||
when the history is opened with unread present, rather than carrying a count badge. */
|
||||
when the history is opened with unread present, rather than carrying a count badge. Red for an
|
||||
unread message, a softer amber when only nudges are unread. */
|
||||
.unread-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
}
|
||||
.unread-dot.nudge {
|
||||
background: var(--warn);
|
||||
}
|
||||
.sbadge-dot {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
|
||||
@@ -108,8 +108,13 @@ unreadChat():boolean {
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
unreadMessages():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 32);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
static startGameView(builder:flatbuffers.Builder) {
|
||||
builder.startObject(14);
|
||||
builder.startObject(15);
|
||||
}
|
||||
|
||||
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
||||
@@ -180,12 +185,16 @@ static addUnreadChat(builder:flatbuffers.Builder, unreadChat:boolean) {
|
||||
builder.addFieldInt8(13, +unreadChat, +false);
|
||||
}
|
||||
|
||||
static addUnreadMessages(builder:flatbuffers.Builder, unreadMessages:boolean) {
|
||||
builder.addFieldInt8(14, +unreadMessages, +false);
|
||||
}
|
||||
|
||||
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean):flatbuffers.Offset {
|
||||
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean):flatbuffers.Offset {
|
||||
GameView.startGameView(builder);
|
||||
GameView.addId(builder, idOffset);
|
||||
GameView.addVariant(builder, variantOffset);
|
||||
@@ -201,6 +210,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
|
||||
GameView.addLastActivityUnix(builder, lastActivityUnix);
|
||||
GameView.addVsAi(builder, vsAi);
|
||||
GameView.addUnreadChat(builder, unreadChat);
|
||||
GameView.addUnreadMessages(builder, unreadMessages);
|
||||
return GameView.endGameView(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ export const app = $state<{
|
||||
* game, for the lobby and in-game unread dot. Seeded from the authoritative REST views and
|
||||
* raised by live chat/nudge events; cleared (with a backend ack) on opening the history/chat. */
|
||||
chatUnread: Record<string, boolean>;
|
||||
/** Per-game flag: at least one unread entry is a real message (not just a nudge), so the dot
|
||||
* can be coloured apart from the nudge-only case. Same lifecycle as chatUnread; nudge events
|
||||
* raise only chatUnread, message events raise both. */
|
||||
messageUnread: Record<string, boolean>;
|
||||
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
|
||||
* with friend requests) and the Settings → Info badge. */
|
||||
feedbackReplyUnread: boolean;
|
||||
@@ -93,6 +97,7 @@ export const app = $state<{
|
||||
localeLocked: false,
|
||||
notifications: 0,
|
||||
chatUnread: {},
|
||||
messageUnread: {},
|
||||
feedbackReplyUnread: false,
|
||||
staleInvite: false,
|
||||
welcomeRedeem: false,
|
||||
@@ -170,14 +175,18 @@ export function dismissWelcomeRedeem(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* seedChatUnread sets a game's unread flag from an authoritative per-viewer REST view (the lobby
|
||||
* list, a game's state, or a move result). The live-event GameView omits the flag, so the live
|
||||
* stream raises unread (bumpChatUnread) rather than seeding it from a GameView.
|
||||
* seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby
|
||||
* list, a game's state, or a move result): unread is any unread entry, message whether one of them
|
||||
* is a real message (the badge colour). The live-event GameView omits both, so the live stream
|
||||
* raises them on receive rather than seeding from a GameView.
|
||||
*/
|
||||
export function seedChatUnread(gameId: string, unread: boolean): void {
|
||||
export function seedChatUnread(gameId: string, unread: boolean, message: boolean): void {
|
||||
if ((app.chatUnread[gameId] ?? false) !== unread) {
|
||||
app.chatUnread = { ...app.chatUnread, [gameId]: unread };
|
||||
}
|
||||
if ((app.messageUnread[gameId] ?? false) !== message) {
|
||||
app.messageUnread = { ...app.messageUnread, [gameId]: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,6 +201,7 @@ export function seedChatUnread(gameId: string, unread: boolean): void {
|
||||
export function markChatRead(gameId: string): void {
|
||||
if (!app.chatUnread[gameId]) return;
|
||||
app.chatUnread = { ...app.chatUnread, [gameId]: false };
|
||||
if (app.messageUnread[gameId]) app.messageUnread = { ...app.messageUnread, [gameId]: false };
|
||||
void gateway.markChatRead(gameId).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -262,6 +272,11 @@ function openStream(): void {
|
||||
router.route.params.id === e.message.gameId;
|
||||
if (!inComms) {
|
||||
app.chatUnread = { ...app.chatUnread, [e.message.gameId]: true };
|
||||
// Only a real message colours the badge; a nudge delivered as a chat_message must not
|
||||
// raise the message flag (the separate nudge event below also leaves it untouched).
|
||||
if (e.message.kind !== 'nudge') {
|
||||
app.messageUnread = { ...app.messageUnread, [e.message.gameId]: true };
|
||||
}
|
||||
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
|
||||
}
|
||||
} else if (e.kind === 'nudge') {
|
||||
|
||||
@@ -253,6 +253,7 @@ function decodeGameView(g: fb.GameView): GameView {
|
||||
lastActivityUnix: Number(g.lastActivityUnix()),
|
||||
vsAi: g.vsAi(),
|
||||
unreadChat: g.unreadChat(),
|
||||
unreadMessages: g.unreadMessages(),
|
||||
seats,
|
||||
};
|
||||
}
|
||||
@@ -849,6 +850,7 @@ function emptyGame(): GameView {
|
||||
lastActivityUnix: 0,
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
seats: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ function gameView(id: string): GameView {
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
status: 'active',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
|
||||
@@ -10,6 +10,7 @@ function gameView(moveCount: number, over = false): GameView {
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
status: over ? 'finished' : 'active',
|
||||
players: 2,
|
||||
toMove: 1,
|
||||
|
||||
@@ -26,6 +26,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
status,
|
||||
players: 2,
|
||||
toMove,
|
||||
|
||||
@@ -19,6 +19,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
status,
|
||||
players: 2,
|
||||
toMove,
|
||||
@@ -40,6 +41,7 @@ describe('groupGames', () => {
|
||||
game('c', 'finished', 0, 100),
|
||||
],
|
||||
ME,
|
||||
{},
|
||||
);
|
||||
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
|
||||
expect(g.theirTurn.map((x) => x.id)).toEqual(['b']);
|
||||
@@ -57,12 +59,33 @@ describe('groupGames', () => {
|
||||
game('f_old', 'finished', 0, 100),
|
||||
],
|
||||
ME,
|
||||
{},
|
||||
);
|
||||
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']);
|
||||
expect(g.theirTurn.map((x) => x.id)).toEqual(['t_new', 't_old']);
|
||||
expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_old']);
|
||||
});
|
||||
|
||||
it('bumps unread games first in the active sections, leaving finished by activity', () => {
|
||||
const g = groupGames(
|
||||
[
|
||||
game('y_read', 'active', 0, 50), // my turn, oldest, read
|
||||
game('y_unread', 'active', 0, 200), // my turn, newest, unread -> bumped above y_read
|
||||
game('t_read', 'active', 1, 200), // their turn, newest, read
|
||||
game('t_unread', 'active', 1, 50), // their turn, oldest, unread -> bumped above t_read
|
||||
game('f_unread', 'finished', 0, 100),
|
||||
game('f_new', 'finished', 0, 300), // finished ignores unread, stays newest-first
|
||||
],
|
||||
ME,
|
||||
{ y_unread: true, t_unread: true, f_unread: true },
|
||||
);
|
||||
// Unread is the primary key, so it overrides the within-section activity order.
|
||||
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_unread', 'y_read']);
|
||||
expect(g.theirTurn.map((x) => x.id)).toEqual(['t_unread', 't_read']);
|
||||
// Finished ignores unread: still newest-first by activity.
|
||||
expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_unread']);
|
||||
});
|
||||
|
||||
it('isMyTurn is false for a finished game even at my seat', () => {
|
||||
expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false);
|
||||
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true);
|
||||
@@ -76,6 +99,7 @@ describe('groupGames', () => {
|
||||
game('open_wait', 'open', 1, 100), // the empty seat's turn
|
||||
],
|
||||
ME,
|
||||
{},
|
||||
);
|
||||
expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']);
|
||||
expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']);
|
||||
|
||||
+13
-6
@@ -43,11 +43,16 @@ export interface LobbyGroups {
|
||||
}
|
||||
|
||||
/**
|
||||
* groupGames partitions games for myId into the three lobby sections and orders each: the
|
||||
* your-turn games by ascending last activity (the longest-waiting first), the opponent-turn
|
||||
* and finished games by descending last activity (the most recent first).
|
||||
* groupGames partitions games for myId into the three lobby sections and orders each. In the two
|
||||
* active sections a game with an unread notification (unread[g.id], any chat entry or nudge) sorts
|
||||
* first, then by last activity: your-turn ascending (the longest-waiting first), opponent-turn
|
||||
* descending (the most recent first). Finished games ignore unread and stay descending by activity.
|
||||
*/
|
||||
export function groupGames(games: GameView[], myId: string): LobbyGroups {
|
||||
export function groupGames(
|
||||
games: GameView[],
|
||||
myId: string,
|
||||
unread: Record<string, boolean>,
|
||||
): LobbyGroups {
|
||||
const yourTurn: GameView[] = [];
|
||||
const theirTurn: GameView[] = [];
|
||||
const finished: GameView[] = [];
|
||||
@@ -56,8 +61,10 @@ export function groupGames(games: GameView[], myId: string): LobbyGroups {
|
||||
else if (isMyTurn(g, myId)) yourTurn.push(g);
|
||||
else theirTurn.push(g);
|
||||
}
|
||||
yourTurn.sort((a, b) => a.lastActivityUnix - b.lastActivityUnix);
|
||||
theirTurn.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix);
|
||||
// Unread is the primary key in the active sections (rank 1 before rank 0), last activity the tie-break.
|
||||
const rank = (g: GameView) => (unread[g.id] ? 1 : 0);
|
||||
yourTurn.sort((a, b) => rank(b) - rank(a) || a.lastActivityUnix - b.lastActivityUnix);
|
||||
theirTurn.sort((a, b) => rank(b) - rank(a) || b.lastActivityUnix - a.lastActivityUnix);
|
||||
finished.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix);
|
||||
return { yourTurn, theirTurn, finished };
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ export class MockGateway implements GatewayClient {
|
||||
lastActivityUnix: Math.floor(Date.now() / 1000),
|
||||
vsAi: true,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
seats: [
|
||||
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
||||
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
|
||||
@@ -214,6 +215,7 @@ export class MockGateway implements GatewayClient {
|
||||
lastActivityUnix: Math.floor(Date.now() / 1000),
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
seats: [
|
||||
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
||||
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
|
||||
|
||||
@@ -181,6 +181,7 @@ function activeGame(): MockGame {
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
status: 'active',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
@@ -218,6 +219,7 @@ function finishedG2(): MockGame {
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
status: 'finished',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
@@ -256,6 +258,7 @@ function finishedG3(): MockGame {
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
status: 'finished',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface GameView {
|
||||
* nudge) in this game. Set on the authoritative REST views (lobby list, game state,
|
||||
* move result); the live-event GameView leaves it false (events bump unread instead). */
|
||||
unreadChat: boolean;
|
||||
/** Per-viewer flag: at least one unread entry is a real message (not just a nudge), so the
|
||||
* badge can be coloured apart from the nudge-only case. Same REST-seeded lifecycle as
|
||||
* unreadChat; the live-event GameView leaves it false. */
|
||||
unreadMessages: boolean;
|
||||
seats: Seat[];
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
status,
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
|
||||
@@ -18,6 +18,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
status,
|
||||
players: seats.length,
|
||||
toMove,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { badgeKind } from './unread';
|
||||
|
||||
describe('badgeKind', () => {
|
||||
it('shows nothing when nothing is unread', () => {
|
||||
expect(badgeKind(false, false)).toBeNull();
|
||||
// message without unread is inconsistent input; the unread guard still wins.
|
||||
expect(badgeKind(false, true)).toBeNull();
|
||||
});
|
||||
|
||||
it('is a nudge badge when only nudges are unread', () => {
|
||||
expect(badgeKind(true, false)).toBe('nudge');
|
||||
});
|
||||
|
||||
it('is a message badge when a real message is unread', () => {
|
||||
expect(badgeKind(true, true)).toBe('message');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// Pure resolution of the per-game unread dot's kind, kept out of the .svelte components so it can
|
||||
// be unit-tested. A game's dot is shown when anything is unread; its colour distinguishes a real
|
||||
// chat message (the regular danger colour) from nudge-only unread (a softer nudge colour).
|
||||
|
||||
/** BadgeKind is the unread dot to show for a game, or null when nothing is unread. */
|
||||
export type BadgeKind = 'message' | 'nudge' | null;
|
||||
|
||||
/**
|
||||
* badgeKind picks the unread dot for a game from its two per-viewer flags: null when nothing is
|
||||
* unread, 'message' when an unread real chat message is present (the regular colour), else 'nudge'
|
||||
* when only nudges are unread (the softer colour). message implies unread, so a stray message=true
|
||||
* with unread=false still shows nothing.
|
||||
*/
|
||||
export function badgeKind(unread: boolean, message: boolean): BadgeKind {
|
||||
if (!unread) return null;
|
||||
return message ? 'message' : 'nudge';
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import { resultBadge } from '../lib/result';
|
||||
import { badgeKind } from '../lib/unread';
|
||||
import { getLobby, setLobby } from '../lib/lobbycache';
|
||||
import { preloadGames } from '../lib/preload';
|
||||
import { gamePhase, groupGames, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
|
||||
@@ -35,7 +36,7 @@
|
||||
games = list.games;
|
||||
// Seed the per-game unread badges from the authoritative list. The live stream only raises
|
||||
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
|
||||
for (const g of games) seedChatUnread(g.id, g.unreadChat);
|
||||
for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
|
||||
atGameLimit = list.atGameLimit;
|
||||
if (!guest) {
|
||||
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
||||
@@ -75,7 +76,7 @@
|
||||
});
|
||||
|
||||
const myId = $derived(app.session?.userId ?? '');
|
||||
const groups = $derived(groupGames(games, myId));
|
||||
const groups = $derived(groupGames(games, myId, app.chatUnread));
|
||||
|
||||
// Per-card "look here" blink. When a game changes lobby bucket into "your turn" or "finished"
|
||||
// while the lobby is on screen, its status emoji blinks twice (an opponent's-turn change is
|
||||
@@ -253,6 +254,7 @@
|
||||
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
|
||||
<div class="list">
|
||||
{#each group.list as g (g.id)}
|
||||
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
|
||||
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
|
||||
{#if group.finished}
|
||||
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
||||
@@ -267,7 +269,7 @@
|
||||
<span class="info">
|
||||
<span class="who">
|
||||
<span class="who-name">{opponents(g) || '—'}</span>
|
||||
{#if app.chatUnread[g.id]}<span class="unread-dot"></span>{/if}
|
||||
{#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
||||
</span>
|
||||
<span class="sub">{scoreline(g)}</span>
|
||||
</span>
|
||||
@@ -458,7 +460,8 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* A small red dot beside the opponent name: this game has an unread chat entry. */
|
||||
/* A small dot beside the opponent name: this game has an unread chat entry. Red for an unread
|
||||
message, a softer amber when only nudges are unread. */
|
||||
.unread-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
@@ -466,6 +469,9 @@
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
}
|
||||
.unread-dot.nudge {
|
||||
background: var(--warn);
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
Reference in New Issue
Block a user