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
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:
213
src/App.css
213
src/App.css
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user