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:
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -19,7 +20,9 @@ import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
export interface TextEditorHandle {
|
||||
focus(): void;
|
||||
/** `atEnd` puts the caret after the last character (and scrolls the text
|
||||
* there); `preventScroll` leaves the page and its scrollers where they are. */
|
||||
focus(options?: { atEnd?: boolean; preventScroll?: boolean }): void;
|
||||
}
|
||||
|
||||
interface TextEditorProps {
|
||||
@@ -34,6 +37,9 @@ interface TextEditorProps {
|
||||
onSubmit?: () => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
maxLength?: number;
|
||||
/** Hide the character counter until the text is at least this long, for
|
||||
* places where a standing "0 / 2000" is just noise. */
|
||||
countFrom?: number;
|
||||
}
|
||||
|
||||
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
@@ -50,6 +56,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
onSubmit,
|
||||
onKeyDown,
|
||||
maxLength,
|
||||
countFrom = 0,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -67,7 +74,15 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
}, [value]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => textareaRef.current?.focus(),
|
||||
focus: (options) => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus({ preventScroll: options?.preventScroll });
|
||||
if (options?.atEnd) {
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
},
|
||||
}), []);
|
||||
|
||||
const {
|
||||
@@ -110,7 +125,9 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
maxContentHeight: 220,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Before paint, so the first frame — and anything measuring the layout in
|
||||
// its own layout effect, like a height transition — sees the final size.
|
||||
useLayoutEffect(() => {
|
||||
if (!autoResize) return;
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
@@ -229,7 +246,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
id={id}
|
||||
className={className}
|
||||
/>
|
||||
{maxLength != null && (
|
||||
{maxLength != null && value.length >= countFrom && (
|
||||
<span
|
||||
className={`text-editor-count${
|
||||
charCountClass(value.length, maxLength)
|
||||
|
||||
Reference in New Issue
Block a user