v3: the chat stops dropping things and holds up to long messages — a message sent while the socket was down vanished with the draft already cleared, and a rejected one fared no better since the client ignored any error that wasn't about a vote or a like. Sends now carry a nonce and sit dimmed in the list until their echo retires them; the server's refusal, an offline socket or ten seconds of silence turns one into a failed row with retry, edit and discard, and a failed row that turns up in the history after a reconnect is retired as having landed after all. Failed edits and deletes show in a bar above the composer. The live stream only goes to signed-in sockets, matching the history behind auth; sends, edits and deletes share a per-user token bucket (typing and read markers get their own, and are just dropped past it); and moderation re-reads the role from the database on every check rather than trusting the one a week-old socket captured. Unread now lives on the server as users.chat_read_at (migration 0014, existing users start caught up), so the count survives a reload and every tab and device clears together; opening onto unread history lands on a "new" divider rather than the bottom, and marks it read only once you've scrolled down to it. After a reconnect the chat re-reads everything from its oldest loaded message and takes that as the truth, edits and deletions included, or reloads the latest page if too much happened. History pages on (created_at, id) so a shared millisecond can't hide a message at a boundary. In the modal: a run of messages from one author shares a header with the time in the gutter on hover, mentions and replies to you get an accent edge, a pill counts what arrived while you were scrolled up, the draft survives closing and reloading, relative times tick, reply previews are flattened from markdown, owners can delete their own messages, ↑ in an empty composer edits your last one, and there's a proper loading and retry state instead of a flash of "No messages yet". Long messages: the composer and edit box grow only so far and then scroll, instead of squeezing the list away; the counter shows only near the limit and sits above the box, since .comment-reply-textarea's padding shorthand had quietly cancelled the room kept for it inside; TextEditor sizes itself before paint, so opening an edit animates to the right height instead of dipping and snapping; and the list follows the bottom through any change in its content's height while pinned there, so ending an edit returns you to the end. A mention notification opens the chat over the page you're on rather than the home page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-22 20:36:49 +00:00
parent b313f70d31
commit 73b80af08e
20 changed files with 2091 additions and 548 deletions

View File

@@ -4169,10 +4169,19 @@ body.has-player .chat-fab {
gap: 0.75rem;
}
.chat-messages-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat-messages {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
/* Reserve a gutter for the scrollbar so it never sits on top of the
messages — without this, overlay scrollbars overlap the content. The extra
padding keeps the text clear of the scrollbar rather than right against it. */
@@ -4183,10 +4192,19 @@ body.has-player .chat-fab {
scrollbar-color: transparent transparent;
display: flex;
flex-direction: column;
gap: 0.85rem;
padding-right: 0.6rem;
}
/* One box around everything the list scrolls, so its height can be watched as
a whole (see the ResizeObserver in ChatModal). Grows to fill the list, which
keeps the empty state centered. */
.chat-messages-content {
flex: 1 0 auto;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.chat-messages:hover,
.chat-messages:focus-within {
scrollbar-color: color-mix(in srgb, var(--color-text-muted) 45%, transparent)
@@ -4215,6 +4233,14 @@ body.has-player .chat-fab {
margin: auto 0;
text-align: center;
color: var(--color-text-muted);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
}
.chat-empty p {
margin: 0;
}
.chat-load-older {
@@ -4251,6 +4277,7 @@ body.has-player .chat-fab {
/* Brief highlight when a message is jumped to via a reply reference. */
.chat-message {
position: relative;
border-radius: var(--radius-lg);
transition: background-color 0.4s ease;
}
@@ -4259,6 +4286,100 @@ body.has-player .chat-fab {
background-color: color-mix(in srgb, var(--color-accent) 18%, transparent);
}
/* A run of messages from one author: the follow-ups drop the avatar and the
author line and sit close under the first, pulling back most of the list's
gap. The avatar column shows the time instead, on hover. */
.chat-message--grouped {
margin-top: -0.6rem;
}
.chat-message-gutter {
flex: 0 0 32px;
/* The time may be wider than the column; let it spill evenly both sides
rather than widen the column and push the text out of line. */
min-width: 0;
display: flex;
justify-content: center;
/* Drawn, but no height of its own: the tooltip wrapper around the time is
taller than a line of text and would pad every follow-up message. */
height: 0;
overflow: visible;
white-space: nowrap;
padding-top: 0.2rem;
font-size: var(--text-2xs);
line-height: 1.2;
color: var(--color-text-muted);
opacity: 0;
transition: opacity var(--dur-base);
}
.chat-message:hover .chat-message-gutter,
.chat-message:focus-within .chat-message-gutter {
opacity: 1;
}
/* With no meta row to trail, a grouped message's actions float at its top
right, over a patch of surface so they stay legible over the text. */
.chat-message-actions--float {
position: absolute;
top: -0.35rem;
right: 0.25rem;
margin: 0;
padding: 0.1rem 0.25rem;
border-radius: var(--radius-sm);
background: var(--color-surface);
box-shadow: 0 0 0 1px var(--color-border-subtle);
}
/* Messages that mention you (or reply to you) carry an accent edge. */
.chat-message--mention {
background-color: color-mix(in srgb, var(--color-accent) 7%, transparent);
box-shadow: inset 2px 0 0 var(--color-accent);
padding: 0.25rem 0.35rem 0.25rem 0.5rem;
margin-left: -0.5rem;
}
.chat-message--grouped.chat-message--mention {
margin-top: -0.85rem;
}
/* A message on its way: dimmed until the server echoes it back. */
.chat-message--pending .chat-message-text {
opacity: 0.55;
}
.chat-message--failed .chat-message-text {
opacity: 0.75;
}
.chat-pending-status {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.2rem 0.6rem;
margin-top: 0.15rem;
font-size: var(--text-2xs);
color: var(--color-text-muted);
}
.chat-pending-error {
color: var(--color-danger);
}
.chat-text-btn {
padding: 0;
background: none;
border: none;
font: inherit;
font-weight: 600;
color: var(--color-accent);
cursor: pointer;
}
.chat-text-btn:hover {
text-decoration: underline;
}
/* Subtle day separator: a hairline rule with a centered, muted label. */
.chat-day-sep {
display: flex;
@@ -4283,6 +4404,70 @@ body.has-player .chat-fab {
letter-spacing: 0.03em;
}
/* Where the unread messages start — the day separator's rule, in accent. */
.chat-new-sep {
color: var(--color-accent);
}
.chat-new-sep::before,
.chat-new-sep::after {
background: currentColor;
opacity: 0.6;
}
/* Scrolled up while messages arrive: a pill over the list's foot says how
many, and takes you down to them. */
.chat-new-pill {
position: absolute;
left: 50%;
bottom: 0.5rem;
transform: translateX(-50%);
padding: 0.3rem 0.8rem;
border: none;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-on-accent);
font: inherit;
font-size: var(--text-xs);
font-weight: 600;
white-space: nowrap;
cursor: pointer;
box-shadow: var(--shadow-2);
animation: chat-msg-in 0.18s ease-out;
}
.chat-new-pill:hover {
background: var(--color-accent-hover);
}
/* Connection trouble and failed edits/deletes, in a slim bar. */
.chat-status,
.chat-error {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
padding: 0.35rem 0.6rem;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
}
.chat-status {
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
color: var(--color-text-muted);
}
.chat-error {
border-left: 2px solid var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-text);
}
.chat-error-text {
flex: 1;
min-width: 0;
}
.chat-message-body {
flex: 1;
min-width: 0;
@@ -4402,22 +4587,28 @@ body.has-player .chat-fab {
min-width: 0;
}
/* Reserve room on the input's bottom-right for the char counter. `display:
block` removes the inline-block baseline descender gap below the textarea —
that phantom space was making the wrapper taller than the textarea, so the
bottom-aligned Send button landed a few px low. */
.chat-input {
padding-right: 3.5rem;
/* `display: block` removes the inline-block baseline descender gap below the
textarea — that phantom space was making the wrapper taller than the
textarea, so the bottom-aligned Send button landed a few px low.
A long message grows the box only so far, then scrolls inside it, so the
composer can't squeeze the message list away and an edit box can't outgrow
the chat. (Qualified to outrank .comment-reply-textarea's overflow.) */
.comment-reply-textarea.chat-input {
display: block;
max-height: min(35vh, 15rem);
overflow-y: auto;
scrollbar-width: thin;
}
/* Pin the counter inside the textarea instead of letting it add a row below —
that extra height is what knocked the Send button out of alignment. */
/* The counter (only shown near the limit) sits just above the input's right
edge, in the gap over the composer: inside the textarea, the text ran under
it, and in a row below it would knock the Send button out of alignment. */
.chat-composer .text-editor-count {
position: absolute;
right: 0.55rem;
bottom: 0.4rem;
right: 0.25rem;
bottom: calc(100% + 1px);
margin: 0;
line-height: 1;
}
.chat-send-btn {