mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
A role query by name matched both the panel toggle and the board's pill, since the pill's label contains the panel's. The spec now names the control it means.
333 lines
9.4 KiB
Svelte
333 lines
9.4 KiB
Svelte
<script>
|
|
import { fill, t } from '$lib/i18n/vi.js';
|
|
import { scrollBehavior } from '$lib/motion.js';
|
|
import { game } from '$lib/stores/game.svelte.js';
|
|
|
|
/**
|
|
* The room's conversation. Everything it shows comes from the server: the
|
|
* client never appends its own copy of a message, so there is one ordering
|
|
* rather than a guessed one.
|
|
*
|
|
* Three facts about where it is, because each is a different question and
|
|
* the caller is the only one that can answer them:
|
|
*
|
|
* - `collapsible` folds the panel behind an unread count. That is for the
|
|
* board on a narrow screen, where the game and the chat share one column
|
|
* and an open log would crowd the board off it.
|
|
* - `column` says the panel has a column of its own, so the log grows into
|
|
* the height it is given instead of stopping at a phone's worth.
|
|
* The room's refusals are not shown here. They belong beside the button
|
|
* that produced them, which is where the lobby now draws them: down here
|
|
* they were below the fold on a phone, and "Bắt đầu" looked broken.
|
|
*
|
|
* `folded` and `unread` are bindable so a control drawn outside this panel
|
|
* — the pill hoisted into the board's top row — can unfold it and read its
|
|
* count without this component knowing that control exists.
|
|
* @type {{
|
|
* collapsible?: boolean,
|
|
* column?: boolean,
|
|
* onsend: (text: string) => void,
|
|
* folded?: boolean,
|
|
* unread?: number
|
|
* }}
|
|
*/
|
|
let {
|
|
collapsible = false,
|
|
column = false,
|
|
onsend,
|
|
folded = $bindable(true),
|
|
unread = $bindable(0)
|
|
} = $props();
|
|
|
|
/** @type {HTMLElement | undefined} */
|
|
let list = $state();
|
|
/** @type {HTMLInputElement | undefined} */
|
|
let field = $state();
|
|
|
|
let draft = $state('');
|
|
let composing = $state(false);
|
|
// Open is what the panel is; folded is only the toggle's own state, which a
|
|
// panel that cannot fold ignores entirely.
|
|
const open = $derived(!collapsible || !folded);
|
|
|
|
// Where this reader had got to, counted against the store's running total
|
|
// rather than the list length: the list is capped, so past twenty messages
|
|
// a length-based count stops rising and the badge dies. Seeded at the
|
|
// declaration so a panel appearing mid-conversation — the board's, after a
|
|
// lobby full of chat — does not open claiming all of it is new.
|
|
let seenAt = $state(game.state.chatCount);
|
|
|
|
const messages = $derived(game.state.chat);
|
|
|
|
// Written rather than derived: `unread` is bindable, and a bindable prop is
|
|
// assigned to, not declared with $derived.
|
|
$effect(() => {
|
|
unread = open ? 0 : Math.max(0, game.state.chatCount - seenAt);
|
|
});
|
|
|
|
// What the server would be left with after sanitizing: whitespace gone and
|
|
// the invisible characters that survive a trim stripped out. Matching the
|
|
// server's own emptiness test is what keeps a blank message from being
|
|
// sent and silently dropped with nothing on screen to explain it.
|
|
//
|
|
/** @param {string} text */
|
|
function hasContent(text) {
|
|
return text.replace(/[\p{Cf}\p{Cc}]/gu, '').trim().length > 0;
|
|
}
|
|
|
|
const sendable = $derived(hasContent(draft));
|
|
|
|
// Everything is read while the panel is open. A count that went backwards
|
|
// is a resynchronised panel — a reconnect, or an opponent leaving — and
|
|
// whatever it now holds is all there is to have missed.
|
|
$effect(() => {
|
|
const count = game.state.chatCount;
|
|
if (open || count < seenAt) seenAt = count;
|
|
});
|
|
|
|
// Keep the newest line in view, but only for a reader who is already at the
|
|
// bottom: yanking somebody out of scrollback to show them a new message is
|
|
// worse than making them scroll for it.
|
|
$effect(() => {
|
|
messages.length;
|
|
if (!open || !list) return;
|
|
const room = list.scrollHeight - list.scrollTop - list.clientHeight;
|
|
if (room < 80) list.scrollTo({ top: list.scrollHeight, behavior: scrollBehavior() });
|
|
});
|
|
|
|
/**
|
|
* The colour a seat writes in, from the palette in app.css. A line whose
|
|
* seat the server cleared has no colour of its own: it belongs to nobody,
|
|
* and painting it as somebody would be a lie about who said it.
|
|
* @param {string} playerId
|
|
* @returns {string}
|
|
*/
|
|
function colourOf(playerId) {
|
|
const seat = game.seatIndexOf(playerId);
|
|
return seat ? `var(--player-${seat})` : 'var(--text-muted)';
|
|
}
|
|
|
|
/** @param {number} atMs */
|
|
function clock(atMs) {
|
|
const at = new Date(atMs);
|
|
return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
|
|
}
|
|
|
|
/** @param {SubmitEvent} event */
|
|
function submit(event) {
|
|
event.preventDefault();
|
|
// Enter can commit an accent rather than the form. Sending here would
|
|
// post a half-typed word and swallow the keystroke that was choosing
|
|
// the diacritic.
|
|
if (composing || !sendable || !field) return;
|
|
|
|
// Re-tested against the element rather than the mirror: a form value the
|
|
// browser restored without firing `input` would otherwise be sent
|
|
// unchecked, or send an empty message the server drops in silence.
|
|
const text = field.value;
|
|
if (!hasContent(text)) return;
|
|
|
|
onsend(text);
|
|
draft = '';
|
|
field.value = '';
|
|
}
|
|
|
|
function toggle() {
|
|
folded = !folded;
|
|
}
|
|
</script>
|
|
|
|
<section class="chat" aria-label={t.chatTitle}>
|
|
{#if collapsible}
|
|
<button type="button" class="header" aria-expanded={open} onclick={toggle} data-testid="chat-toggle">
|
|
<span>{t.chatTitle}</span>
|
|
{#if unread > 0}
|
|
<span class="badge" data-testid="chat-unread">{fill(t.chatUnread, { n: unread })}</span>
|
|
{/if}
|
|
</button>
|
|
{:else}
|
|
<h2>{t.chatTitle}</h2>
|
|
{/if}
|
|
|
|
{#if open}
|
|
{#if messages.length === 0}
|
|
<p class="empty">{t.chatEmpty}</p>
|
|
{:else}
|
|
<!-- A log, not a stack of bubbles: every line reads "name: text" in
|
|
its author's colour, so four people talking stay tellable apart
|
|
without a shape per speaker. -->
|
|
<ol
|
|
bind:this={list}
|
|
class:column
|
|
aria-live="polite"
|
|
aria-relevant="additions"
|
|
data-testid="chat-log"
|
|
>
|
|
{#each messages as entry (entry.n)}
|
|
<li style:color={colourOf(entry.playerId)}>
|
|
<!-- An author the server cleared belongs to nobody: the seat
|
|
they spoke from may be somebody else's now. -->
|
|
<span class="author">{entry.author || t.chatAuthorLeft}:</span>
|
|
<!-- Interpolated, never {@html}: this is another player's text. -->
|
|
<span class="text">{entry.text}</span>
|
|
<span class="at">{clock(entry.atMs)}</span>
|
|
</li>
|
|
{/each}
|
|
</ol>
|
|
{/if}
|
|
|
|
<form class="row" onsubmit={submit}>
|
|
<!--
|
|
Uncontrolled on purpose, exactly as the word field is: Vietnamese
|
|
diacritics are composed over several keystrokes by a Telex or VNI
|
|
input method, and writing a value back into the element cancels
|
|
that composition and mangles the accent. `draft` mirrors the
|
|
field one way only — it is never bound back.
|
|
-->
|
|
<input
|
|
bind:this={field}
|
|
type="text"
|
|
name="chat"
|
|
autocomplete="off"
|
|
autocorrect="off"
|
|
spellcheck="false"
|
|
enterkeyhint="send"
|
|
placeholder={t.chatPlaceholder}
|
|
aria-label={t.chatTitle}
|
|
data-testid="chat-input"
|
|
oninput={(event) => (draft = event.currentTarget.value)}
|
|
oncompositionstart={() => (composing = true)}
|
|
oncompositionend={() => (composing = false)}
|
|
/>
|
|
<button type="submit" disabled={!sendable} data-testid="chat-send">{t.submit}</button>
|
|
</form>
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
.chat {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
min-height: 0;
|
|
}
|
|
|
|
h2,
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin: 0;
|
|
padding: 0;
|
|
border: 0;
|
|
background: none;
|
|
color: var(--text-muted);
|
|
font-size: var(--text-4);
|
|
font-weight: 600;
|
|
/* Uppercase Vietnamese stacks a tone mark above a capital. */
|
|
line-height: 1.6;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.header {
|
|
width: 100%;
|
|
min-height: 44px;
|
|
padding: 10px 0;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.badge {
|
|
padding: 1px var(--space-2);
|
|
border-radius: var(--radius-pill);
|
|
background: var(--accent);
|
|
color: var(--accent-text);
|
|
font-size: var(--text-1);
|
|
text-transform: none;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.empty {
|
|
margin: 0;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
ol {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
/* Stacked under the game, so bounded: the field it scrolls above has to
|
|
stay on screen. */
|
|
max-height: 180px;
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow-y: auto;
|
|
list-style: none;
|
|
}
|
|
|
|
/* Given a column of its own, the log takes the height of it. */
|
|
ol.column {
|
|
flex: 1;
|
|
min-height: 0;
|
|
max-height: none;
|
|
}
|
|
|
|
li {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: baseline;
|
|
gap: 6px;
|
|
/* Colour is set per line, from the author's seat. Everything else about
|
|
a line is the same for everybody. */
|
|
font-size: var(--text-6);
|
|
}
|
|
|
|
.author {
|
|
font-weight: 700;
|
|
}
|
|
|
|
.text {
|
|
flex: 1;
|
|
min-width: 0;
|
|
/* A single unbroken run of characters must wrap rather than widen the
|
|
page: the sender chooses this text. */
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.at {
|
|
color: var(--text-muted);
|
|
font-size: var(--text-1);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.row {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--border-strong);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--surface);
|
|
/* 16px or larger stops iOS Safari zooming the page on focus. */
|
|
font-size: var(--text-6);
|
|
}
|
|
|
|
.row button {
|
|
padding: 10px 16px;
|
|
border: 0;
|
|
border-radius: var(--radius-sm);
|
|
background: var(--accent);
|
|
color: var(--accent-text);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.row button:disabled {
|
|
background: var(--surface-alt);
|
|
color: var(--text-muted);
|
|
}
|
|
</style>
|