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:
@@ -11,8 +11,10 @@ const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
// Length cap for the replied-to preview snippet, so a reply to a long message
|
||||
// doesn't ship the whole 2000-char body over the wire for every reply.
|
||||
const REPLY_SNIPPET_LEN = 140;
|
||||
// doesn't ship the whole 2000-char body over the wire for every reply. This is
|
||||
// raw markdown: the client strips it to text before showing a line of it, so
|
||||
// it's cut generously enough that an image URL or link doesn't eat it all.
|
||||
const REPLY_SNIPPET_LEN = 300;
|
||||
|
||||
const SELECT_COLS =
|
||||
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
|
||||
@@ -21,8 +23,7 @@ const SELECT_COLS =
|
||||
|
||||
// Common joins: author (required) plus the optional replied-to message and its
|
||||
// author (LEFT — null when there's no reply or the target was deleted).
|
||||
const FROM_JOINS =
|
||||
`FROM chat_messages m
|
||||
const FROM_JOINS = `FROM chat_messages m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id
|
||||
LEFT JOIN users ru ON rm.user_id = ru.id`;
|
||||
@@ -124,39 +125,34 @@ export function updateChatMessage(
|
||||
return fetchMessage(id);
|
||||
}
|
||||
|
||||
// Deletion is a moderation action only (owners can edit but not delete).
|
||||
export function deleteChatMessage(id: string, canModerate: boolean): void {
|
||||
if (!canModerate) {
|
||||
// Owners may delete their own messages; moderators may delete any.
|
||||
export function deleteChatMessage(
|
||||
id: string,
|
||||
requestingUserId: string,
|
||||
canModerate: boolean,
|
||||
): void {
|
||||
const ownerId = getMessageOwner(id); // 404s if the message doesn't exist
|
||||
if (ownerId !== requestingUserId && !canModerate) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Not authorized to delete this message",
|
||||
);
|
||||
}
|
||||
getMessageOwner(id); // 404s if the message doesn't exist
|
||||
db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to `limit` most recent messages in ascending (display) order.
|
||||
* When `before` (a message created_at ISO string) is given, returns the page
|
||||
* of older messages immediately preceding it — used for "load older" history.
|
||||
*/
|
||||
export function getRecentChatMessages(
|
||||
limit = DEFAULT_LIMIT,
|
||||
before?: string,
|
||||
): ChatMessage[] {
|
||||
const capped = Math.min(Math.max(limit, 1), MAX_LIMIT);
|
||||
const rows = before
|
||||
? db.prepare(
|
||||
`SELECT ${SELECT_COLS} ${FROM_JOINS}
|
||||
WHERE m.created_at < ? ORDER BY m.created_at DESC LIMIT ?;`,
|
||||
).all(before, capped)
|
||||
: db.prepare(
|
||||
`SELECT ${SELECT_COLS} ${FROM_JOINS}
|
||||
ORDER BY m.created_at DESC LIMIT ?;`,
|
||||
).all(capped);
|
||||
/** A position in the chat stream. Messages are ordered by (createdAt, id), so
|
||||
* two sharing a millisecond still have a strict order and a page boundary
|
||||
* between them can't skip either. */
|
||||
export interface ChatCursor {
|
||||
createdAt: string;
|
||||
/** Omitted: the cursor sits before (for `after`) or after (for `before`)
|
||||
* every message sharing that timestamp. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
function toMessages(rows: unknown[]): ChatMessage[] {
|
||||
if (!rows.every(isChatMessageRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
@@ -164,6 +160,103 @@ export function getRecentChatMessages(
|
||||
"Malformed chat message data",
|
||||
);
|
||||
}
|
||||
// Newest-first from SQL; reverse to ascending for display.
|
||||
return rows.map(chatMessageRowToApi).reverse();
|
||||
return rows.map(chatMessageRowToApi);
|
||||
}
|
||||
|
||||
function capLimit(limit: number): number {
|
||||
return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to `limit` most recent messages in ascending (display) order.
|
||||
* When `before` is given, returns the page of older messages immediately
|
||||
* preceding it — used for "load older" history.
|
||||
*/
|
||||
export function getRecentChatMessages(
|
||||
limit = DEFAULT_LIMIT,
|
||||
before?: ChatCursor,
|
||||
): ChatMessage[] {
|
||||
const capped = capLimit(limit);
|
||||
const rows = before
|
||||
? db.prepare(
|
||||
`SELECT ${SELECT_COLS} ${FROM_JOINS}
|
||||
WHERE m.created_at < ? OR (m.created_at = ? AND m.id < ?)
|
||||
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
|
||||
).all(
|
||||
before.createdAt,
|
||||
before.createdAt,
|
||||
// No id: nothing at that timestamp sorts below the empty string.
|
||||
before.id ?? "",
|
||||
capped,
|
||||
)
|
||||
: db.prepare(
|
||||
`SELECT ${SELECT_COLS} ${FROM_JOINS}
|
||||
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
|
||||
).all(capped);
|
||||
// Newest-first from SQL; reverse to ascending for display.
|
||||
return toMessages(rows).reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to `limit` messages following `after`, in ascending order — used
|
||||
* to resync the chat after a reconnect. Without an id the cursor is inclusive
|
||||
* of its timestamp.
|
||||
*/
|
||||
export function getChatMessagesAfter(
|
||||
after: ChatCursor,
|
||||
limit = DEFAULT_LIMIT,
|
||||
): ChatMessage[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} ${FROM_JOINS}
|
||||
WHERE m.created_at > ? OR (m.created_at = ? AND m.id > ?)
|
||||
ORDER BY m.created_at ASC, m.id ASC LIMIT ?;`,
|
||||
).all(after.createdAt, after.createdAt, after.id ?? "", capLimit(limit));
|
||||
return toMessages(rows);
|
||||
}
|
||||
|
||||
export interface ChatReadState {
|
||||
/** created_at of the newest message the user has seen (ISO). */
|
||||
readAt: string;
|
||||
/** Messages from other people since then. */
|
||||
unread: number;
|
||||
}
|
||||
|
||||
export function getChatReadState(userId: string): ChatReadState {
|
||||
const row = db.prepare(
|
||||
`SELECT COALESCE(u.chat_read_at, u.created_at) AS read_at,
|
||||
(SELECT COUNT(*) FROM chat_messages m
|
||||
WHERE m.user_id != u.id
|
||||
AND m.created_at > COALESCE(u.chat_read_at, u.created_at)) AS unread
|
||||
FROM users u WHERE u.id = ?;`,
|
||||
).get(userId) as { read_at: string; unread: number } | undefined;
|
||||
if (!row) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
||||
}
|
||||
return { readAt: row.read_at, unread: row.unread };
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the user's read marker forward to `at` (a message's created_at) —
|
||||
* never backward, so a lagging tab can't undo a newer tab's read, and never
|
||||
* past the newest message, so a bogus timestamp can't pre-read the future.
|
||||
*/
|
||||
export function markChatRead(userId: string, at: string): ChatReadState {
|
||||
const parsed = new Date(at);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid read marker",
|
||||
);
|
||||
}
|
||||
const newest = db.prepare(
|
||||
`SELECT MAX(created_at) AS newest FROM chat_messages;`,
|
||||
).get() as { newest: string | null };
|
||||
const iso = parsed.toISOString();
|
||||
const target = newest.newest && iso > newest.newest ? newest.newest : iso;
|
||||
db.prepare(
|
||||
`UPDATE users SET chat_read_at = ?
|
||||
WHERE id = ? AND (chat_read_at IS NULL OR chat_read_at < ?);`,
|
||||
).run(target, userId, target);
|
||||
return getChatReadState(userId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user