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

@@ -12,6 +12,7 @@ import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_s
import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favicon_thumbnail.ts";
import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts";
import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts";
interface Migration {
name: string;
@@ -38,6 +39,7 @@ const MIGRATIONS: Migration[] = [
up: up0012FixFaviconReclassification,
},
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
{ name: "0014_chat_read_state", up: up0014ChatReadState },
];
export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,28 @@
import type { DatabaseSync } from "node:sqlite";
// Chat unread state lives on the server so it survives reloads and agrees
// across tabs and devices: `users.chat_read_at` is the created_at of the newest
// message the user has seen. Existing users start caught up (now) rather than
// with the whole history unread; new users fall back to their own created_at.
//
// The history cursor now pages on (created_at, id) so two messages sharing a
// millisecond can't be skipped at a page boundary; the index follows suit.
//
// Idempotent: also runs against fresh databases created from schema.sql, where
// the column and index already exist.
export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string;
}[];
if (!cols.some((c) => c.name === "chat_read_at")) {
db.exec(`ALTER TABLE users ADD COLUMN chat_read_at TEXT;`);
db.prepare(`UPDATE users SET chat_read_at = ?;`).run(
new Date().toISOString(),
);
}
db.exec(`DROP INDEX IF EXISTS idx_chat_messages_created;`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_chat_messages_created_id ON chat_messages(created_at, id);`,
);
}

View File

@@ -30,7 +30,8 @@ CREATE TABLE users (
avatar_mime TEXT,
description TEXT,
invited_by TEXT REFERENCES users(id),
email TEXT NOT NULL
email TEXT NOT NULL,
chat_read_at TEXT
);
CREATE TABLE votes (
@@ -205,4 +206,4 @@ CREATE TABLE chat_messages (
reply_to_id TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_chat_messages_created ON chat_messages(created_at);
CREATE INDEX idx_chat_messages_created_id ON chat_messages(created_at, id);