v3: added a chatbox
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
khannurien
2026-06-29 20:25:10 +00:00
parent f171027673
commit 6c4f410d9b
35 changed files with 1800 additions and 152 deletions

View File

@@ -6,6 +6,8 @@ import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts";
import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts";
import { up as up0006Categories } from "./migrations/0006_categories.ts";
import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts";
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
import { up as up0009ChatMessageUpdatedAt } from "./migrations/0009_chat_message_updated_at.ts";
interface Migration {
name: string;
@@ -23,6 +25,8 @@ const MIGRATIONS: Migration[] = [
{ name: "0005_drop_is_admin", up: up0005DropIsAdmin },
{ name: "0006_categories", up: up0006Categories },
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
{ name: "0008_chat_messages", up: up0008ChatMessages },
{ name: "0009_chat_message_updated_at", up: up0009ChatMessageUpdatedAt },
];
export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,18 @@
import type { DatabaseSync } from "node:sqlite";
// Idempotent: safe to run against a fresh db (already created from schema.sql
// with this table) or an existing one that predates it.
export function up(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS chat_messages (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_chat_messages_created ON chat_messages(created_at);`,
);
}

View File

@@ -192,3 +192,13 @@ CREATE INDEX idx_notifications_user ON notifications(user_id, created_at);
CREATE UNIQUE INDEX idx_notifications_dedup
ON notifications(user_id, source_key)
WHERE source_key IS NOT NULL;
CREATE TABLE chat_messages (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_chat_messages_created ON chat_messages(created_at);

View File

@@ -6,6 +6,7 @@ export type Permission =
| "dump:moderate"
| "comment:moderate"
| "playlist:moderate"
| "chat:moderate"
| "user:manage"
| "category:manage";
@@ -13,6 +14,7 @@ const ALL_PERMISSIONS: readonly Permission[] = [
"dump:moderate",
"comment:moderate",
"playlist:moderate",
"chat:moderate",
"user:manage",
"category:manage",
];
@@ -21,7 +23,12 @@ const ALL_PERMISSIONS: readonly Permission[] = [
// and future); `moderator` gets the content-moderation permissions only.
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
admin: ALL_PERMISSIONS,
moderator: ["dump:moderate", "comment:moderate", "playlist:moderate"],
moderator: [
"dump:moderate",
"comment:moderate",
"playlist:moderate",
"chat:moderate",
],
user: [],
};

View File

@@ -11,6 +11,7 @@ import wsRouter from "./routes/ws.ts";
import previewRouter from "./routes/preview.ts";
import playlistsRouter from "./routes/playlists.ts";
import commentsRouter from "./routes/comments.ts";
import chatRouter from "./routes/chat.ts";
import followsRouter from "./routes/follows.ts";
import notificationsRouter from "./routes/notifications.ts";
import invitesRouter from "./routes/invites.ts";
@@ -78,6 +79,10 @@ app.use(
commentsRouter.routes(),
commentsRouter.allowedMethods(),
);
app.use(
chatRouter.routes(),
chatRouter.allowedMethods(),
);
app.use(
followsRouter.routes(),
followsRouter.allowedMethods(),

View File

@@ -2,6 +2,7 @@ import { randomBytes, scryptSync } from "node:crypto";
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
import {
type Category,
type ChatMessage,
type Comment,
Dump,
isRole,
@@ -304,6 +305,43 @@ export function commentRowToApi(row: CommentRow): Comment {
};
}
export interface ChatMessageRow {
id: string;
user_id: string;
body: string;
created_at: string;
updated_at: string | null;
author_username: string;
author_avatar_mime: string | null;
[key: string]: SQLOutputValue;
}
export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"body" in obj && typeof obj.body === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"author_username" in obj && typeof obj.author_username === "string" &&
"author_avatar_mime" in obj &&
(typeof obj.author_avatar_mime === "string" ||
obj.author_avatar_mime === null);
}
export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
return {
id: row.id,
userId: row.user_id,
body: row.body,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
authorUsername: row.author_username,
authorAvatarMime: row.author_avatar_mime ?? undefined,
};
}
export interface PlaylistRow {
id: string;
user_id: string;

View File

@@ -309,6 +309,25 @@ export interface CreateCommentRequest {
parentId?: string;
}
/**
* Chat
*/
export interface ChatMessage {
id: string;
userId: string;
body: string;
createdAt: Date;
updatedAt?: Date;
authorUsername: string;
authorAvatarMime?: string;
}
/** Wire format — createdAt arrives as an ISO string over JSON. */
export type RawChatMessage = Omit<ChatMessage, "createdAt"> & {
createdAt: string;
};
export function isCreateCommentRequest(
obj: unknown,
): obj is CreateCommentRequest {
@@ -532,13 +551,40 @@ export interface CommentLikeRemoveMessage {
commentId: string;
}
export interface ChatSendMessage {
type: "chat_send";
body: string;
}
// Tells the server whether this client currently has the chatbox open, so that
// mention notifications can be skipped for users who are already reading chat.
export interface ChatFocusMessage {
type: "chat_focus";
open: boolean;
}
export interface ChatEditMessage {
type: "chat_edit";
id: string;
body: string;
}
export interface ChatDeleteMessage {
type: "chat_delete";
id: string;
}
export type ClientToServerMessage =
| PingMessage
| PongMessage
| VoteCastMessage
| VoteRemoveMessage
| CommentLikeCastMessage
| CommentLikeRemoveMessage;
| CommentLikeRemoveMessage
| ChatSendMessage
| ChatFocusMessage
| ChatEditMessage
| ChatDeleteMessage;
// ── Server → Client ──────────────────────────────────────────────────────────
@@ -669,6 +715,21 @@ export interface ForceLogoutMessage {
type: "force_logout";
}
export interface ChatMessageMessage {
type: "chat_message";
message: ChatMessage;
}
export interface ChatMessageUpdatedMessage {
type: "chat_message_updated";
message: ChatMessage;
}
export interface ChatMessageDeletedMessage {
type: "chat_message_deleted";
id: string;
}
export type ServerToClientMessage =
| PingMessage
| WelcomeMessage
@@ -690,7 +751,10 @@ export type ServerToClientMessage =
| CommentDeletedMessage
| NotificationCreatedMessage
| ErrorMessage
| ForceLogoutMessage;
| ForceLogoutMessage
| ChatMessageMessage
| ChatMessageUpdatedMessage
| ChatMessageDeletedMessage;
/**
* Follows
@@ -751,7 +815,7 @@ export interface DumpUpvotedData {
export interface UserMentionedData {
mentionerId: string;
mentionerUsername: string;
contextType: "comment" | "dump" | "playlist";
contextType: "comment" | "dump" | "playlist" | "chat";
contextId: string;
contextTitle: string;
dumpId?: string;

26
api/routes/chat.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Router } from "@oak/oak";
import type { APIResponse, ChatMessage } from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { getRecentChatMessages } from "../services/chat-service.ts";
const router = new Router({ prefix: "/api/chat" });
// GET /api/chat — recent messages (ascending). Optional `before` (ISO created_at)
// cursor loads the page of older messages, and `limit` caps the page size.
router.get("/", authMiddleware, (ctx) => {
const params = ctx.request.url.searchParams;
const before = params.get("before") ?? undefined;
const limitParam = params.get("limit");
const limit = limitParam ? Number(limitParam) : undefined;
const messages = getRecentChatMessages(
Number.isFinite(limit) ? limit : undefined,
before,
);
const responseBody: APIResponse<ChatMessage[]> = {
success: true,
data: messages,
};
ctx.response.body = responseBody;
});
export default router;

View File

@@ -2,15 +2,26 @@ import { Router } from "@oak/oak";
import { ALLOWED_ORIGINS } from "../config.ts";
import { verifyJWT } from "../lib/jwt.ts";
import {
broadcastChatMessage,
broadcastChatMessageDeleted,
broadcastChatMessageUpdated,
broadcastCommentLikeUpdate,
broadcastPresence,
broadcastVoteUpdate,
getOnlineUsers,
handleClientPong,
isUserChatOpen,
register,
unregister,
type WsClient,
} from "../services/ws-service.ts";
import {
createChatMessage,
deleteChatMessage,
updateChatMessage,
} from "../services/chat-service.ts";
import { notifyMentions } from "../services/notification-service.ts";
import { hasPermission } from "../lib/permissions.ts";
import {
castVote,
getUserVotes,
@@ -59,6 +70,7 @@ router.get("/ws", async (ctx) => {
socket,
userId: authPayload?.userId,
username: authPayload?.username,
role: authPayload?.role,
avatarMime,
};
@@ -114,6 +126,18 @@ router.get("/ws", async (ctx) => {
case "comment_like_remove":
handleCommentLike(client, msg.commentId, "remove");
break;
case "chat_send":
handleChatSend(client, msg.body);
break;
case "chat_focus":
client.chatOpen = msg.open;
break;
case "chat_edit":
handleChatEdit(client, msg.id, msg.body);
break;
case "chat_delete":
handleChatDelete(client, msg.id);
break;
}
});
@@ -190,4 +214,100 @@ function handleCommentLike(
}
}
function handleChatSend(client: WsClient, body: string): void {
const { socket } = client;
if (!client.userId) {
socket.send(
JSON.stringify({ type: "error", message: "Authentication required" }),
);
return;
}
try {
const message = createChatMessage(client.userId, body);
// Broadcast to everyone including the sender, so the sender's own message
// simply arrives over the socket — no optimistic-send machinery needed.
broadcastChatMessage(message);
// Skip mention notifications for users who already have the chat open —
// they've seen the message live.
notifyMentions(
client.userId,
message.body,
"chat",
message.id,
"",
undefined,
isUserChatOpen,
);
} catch (err) {
const message = err instanceof APIException
? err.message
: "Failed to send message";
socket.send(JSON.stringify({ type: "error", message }));
}
}
function canModerateChat(client: WsClient): boolean {
return !!client.role && hasPermission(client.role, "chat:moderate");
}
function handleChatEdit(client: WsClient, id: string, body: string): void {
const { socket } = client;
if (!client.userId) {
socket.send(
JSON.stringify({ type: "error", message: "Authentication required" }),
);
return;
}
try {
const message = updateChatMessage(
id,
body,
client.userId,
canModerateChat(client),
);
broadcastChatMessageUpdated(message);
// A newly added @mention in an edit should still notify (deduped per
// message+user), skipping anyone already reading the chat.
notifyMentions(
client.userId,
message.body,
"chat",
message.id,
"",
undefined,
isUserChatOpen,
);
} catch (err) {
const message = err instanceof APIException
? err.message
: "Failed to edit message";
socket.send(JSON.stringify({ type: "error", message }));
}
}
function handleChatDelete(client: WsClient, id: string): void {
const { socket } = client;
if (!client.userId) {
socket.send(
JSON.stringify({ type: "error", message: "Authentication required" }),
);
return;
}
try {
deleteChatMessage(id, canModerateChat(client));
broadcastChatMessageDeleted(id);
} catch (err) {
const message = err instanceof APIException
? err.message
: "Failed to delete message";
socket.send(JSON.stringify({ type: "error", message }));
}
}
export default router;

View File

@@ -0,0 +1,137 @@
import {
APIErrorCode,
APIException,
type ChatMessage,
} from "../model/interfaces.ts";
import { chatMessageRowToApi, db, isChatMessageRow } from "../model/db.ts";
export const MAX_CHAT_LENGTH = 2000;
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
const SELECT_COLS =
`m.id, m.user_id, m.body, m.created_at, m.updated_at,
u.username as author_username, u.avatar_mime as author_avatar_mime`;
function fetchMessage(id: string): ChatMessage {
const row = db.prepare(
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?;`,
).get(id);
if (!row || !isChatMessageRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found");
}
return chatMessageRowToApi(row);
}
export function createChatMessage(userId: string, body: string): ChatMessage {
const trimmed = body.trim();
if (!trimmed) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Message cannot be empty",
);
}
if (trimmed.length > MAX_CHAT_LENGTH) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
);
}
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
db.prepare(
`INSERT INTO chat_messages (id, user_id, body, created_at) VALUES (?, ?, ?, ?);`,
).run(id, userId, trimmed, createdAt);
return fetchMessage(id);
}
function getMessageOwner(id: string): string {
const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get(
id,
) as { user_id: string } | undefined;
if (!row) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found");
}
return row.user_id;
}
// Owners may edit their own messages; moderators may edit any.
export function updateChatMessage(
id: string,
body: string,
requestingUserId: string,
canModerate: boolean,
): ChatMessage {
const ownerId = getMessageOwner(id);
if (ownerId !== requestingUserId && !canModerate) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
401,
"Not authorized to edit this message",
);
}
const trimmed = body.trim();
if (!trimmed) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Message cannot be empty",
);
}
if (trimmed.length > MAX_CHAT_LENGTH) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
);
}
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)
.run(trimmed, new Date().toISOString(), id);
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) {
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 chat_messages m JOIN users u ON m.user_id = u.id
WHERE m.created_at < ? ORDER BY m.created_at DESC LIMIT ?;`,
).all(before, capped)
: db.prepare(
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id
ORDER BY m.created_at DESC LIMIT ?;`,
).all(capped);
if (!rows.every(isChatMessageRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed chat message data",
);
}
// Newest-first from SQL; reverse to ascending for display.
return rows.map(chatMessageRowToApi).reverse();
}

View File

@@ -235,10 +235,13 @@ export function notifyDumpOwnerUpvote(
export function notifyMentions(
mentionerUserId: string,
body: string,
contextType: "comment" | "dump" | "playlist",
contextType: "comment" | "dump" | "playlist" | "chat",
contextId: string,
contextTitle: string,
dumpId?: string,
// Optional: skip notifying a mentioned user (e.g. they're already reading the
// chat live, so the persistent notification would be redundant).
skipUserId?: (userId: string) => boolean,
): void {
const mentionerRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
@@ -256,6 +259,7 @@ export function notifyMentions(
`SELECT id FROM users WHERE lower(username) = ?;`,
).get(username) as { id: string } | undefined;
if (!mentionedRow || mentionedRow.id === mentionerUserId) continue;
if (skipUserId?.(mentionedRow.id)) continue;
createNotification(
mentionedRow.id,

View File

@@ -1,8 +1,10 @@
import type {
ChatMessage,
Comment,
Dump,
OnlineUser,
Playlist,
Role,
ServerToClientMessage,
User,
} from "../model/interfaces.ts";
@@ -11,9 +13,12 @@ export interface WsClient {
socket: WebSocket;
userId?: string;
username?: string;
role?: Role;
avatarMime?: string;
avatarVersion?: number;
pongReceived?: boolean;
/** Whether this client currently has the chatbox open (is reading chat). */
chatOpen?: boolean;
}
const clients = new Set<WsClient>();
@@ -209,6 +214,34 @@ export function broadcastCommentUpdated(comment: Comment): void {
}
}
// Global chat room: every connected client receives every message.
export function broadcastChatMessage(message: ChatMessage): void {
for (const client of clients) {
send(client.socket, { type: "chat_message", message });
}
}
export function broadcastChatMessageUpdated(message: ChatMessage): void {
for (const client of clients) {
send(client.socket, { type: "chat_message_updated", message });
}
}
export function broadcastChatMessageDeleted(id: string): void {
for (const client of clients) {
send(client.socket, { type: "chat_message_deleted", id });
}
}
// True if the user has the chatbox open on any of their connected clients — i.e.
// they're already reading chat, so a mention notification would be redundant.
export function isUserChatOpen(userId: string): boolean {
for (const client of clients) {
if (client.userId === userId && client.chatOpen) return true;
}
return false;
}
export function handleClientPong(client: WsClient): void {
client.pongReceived = true;
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#99AAB5" d="M20 2.047V2c0-1.104-.896-2-2-2s-2 .896-2 2v.047C7.737 2.422 6 5.127 6 7v17c0 6.627 5.373 12 12 12s12-5.373 12-12V7c0-1.873-1.737-4.578-10-4.953z"/><path fill="#292F33" d="M22 9.199v-7c-.622-.066-1.288-.116-2-.151V9c0 1.104-.896 2-2 2s-2-.896-2-2V2.048c-.712.034-1.378.085-2 .151v7C7.459 9.89 6 12.29 6 14v2c0-1.725 1.482-4.153 8.169-4.819C14.646 12.228 16.171 13 18 13c1.83 0 3.355-.772 3.831-1.819C28.518 11.847 30 14.275 30 16v-2c0-1.71-1.459-4.11-8-4.801z"/></svg>

Before

Width:  |  Height:  |  Size: 551 B

View File

@@ -1,35 +0,0 @@
{
"name": "coucou",
"short_name": "coucou",
"start_url": "/",
"display": "standalone",
"background_color": "#111827",
"theme_color": "#111827",
"icons": [
{
"src": "/favicon.svg",
"type": "image/svg+xml",
"sizes": "any"
},
{
"src": "/favicon-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/favicon-512x512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
],
"share_target": {
"action": "/",
"method": "GET",
"params": {
"url": "share_url",
"title": "share_title",
"text": "share_text"
}
}
}

View File

@@ -1162,6 +1162,75 @@ body.has-fab .page-content {
padding-bottom: 5rem;
}
/* Chat FAB — mirrors .dump-fab and stacks directly above it (the "+" dump
button keeps the bottom slot; chat sits one button-height up) so the two
never overlap. */
.chat-fab {
position: fixed;
--fab-size: 3.5rem;
/* Share the dump-fab's column. */
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) - 1rem));
/* One button-height + gap above the dump-fab's bottom-anchored resting spot. */
top: calc(100vh - var(--fab-size) * 2 - 2rem - env(safe-area-inset-bottom, 0px));
top: calc(100dvh - var(--fab-size) * 2 - 2rem - env(safe-area-inset-bottom, 0px));
z-index: 900;
width: var(--fab-size);
height: var(--fab-size);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: var(--fab-radius, 50%);
background: var(--color-accent);
color: var(--color-on-accent);
cursor: pointer;
box-shadow: 0 4px 16px color-mix(in srgb, var(--color-accent) 45%, transparent),
0 2px 6px rgba(0, 0, 0, 0.3);
opacity: 0;
transform: translateY(1rem) scale(0.9);
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease, background 0.15s ease,
top 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-fab-icon {
width: 1.6rem;
height: 1.6rem;
}
.chat-fab--visible {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.chat-fab:hover {
background: var(--color-accent-hover);
transform: translateY(-2px) scale(1);
box-shadow: 0 6px 20px color-mix(in srgb, var(--color-accent) 55%, transparent),
0 2px 6px rgba(0, 0, 0, 0.3);
}
.chat-fab:active {
transform: translateY(0) scale(0.96);
}
/* Match the dump-fab's clearance shift when a player is mounted. */
body.has-player .chat-fab {
top: calc(1.25rem + env(safe-area-inset-top, 0px));
/* Stack below the dump-fab in the top-right corner to avoid overlap. */
right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) - 1rem));
top: calc(1.25rem + var(--fab-size) + 0.75rem + env(safe-area-inset-top, 0px));
}
/* The badge sits on the circular FAB; nudge it onto the rim. */
.chat-fab .notification-badge {
top: 2px;
right: 2px;
box-shadow: 0 0 0 2px var(--color-accent);
}
.rich-content-thumbnail-btn {
position: relative;
padding: 0;
@@ -2148,6 +2217,7 @@ body.has-fab .page-content {
/* Icon-only controls are square, so the row reads as a tidy set. */
.app-header-nav .nav-search-btn,
.app-header-nav .notification-bell,
.app-header-nav .chat-button,
.app-header-nav .user-menu-trigger {
width: 2.25rem;
padding: 0;
@@ -3221,6 +3291,255 @@ body.has-fab .page-content {
gap: 0.75rem;
}
/* ── Live chat ── */
.chat {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
height: min(60vh, 480px);
gap: 0.75rem;
}
.chat-messages {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
/* Reserve a gutter for the scrollbar so it never sits on top of the
messages — without this, overlay scrollbars overlap the content. */
scrollbar-gutter: stable;
display: flex;
flex-direction: column;
gap: 0.85rem;
padding-right: 0.5rem;
}
.chat-empty {
margin: auto 0;
text-align: center;
color: var(--color-text-muted);
}
.chat-load-older {
text-align: center;
padding-bottom: 0.25rem;
}
.chat-message {
display: flex;
gap: 0.6rem;
align-items: flex-start;
/* Each message animates once, when its element first mounts — so newly
arriving messages slide in while existing ones stay put (React reuses
keyed DOM nodes). */
animation: chat-msg-in 0.18s ease-out;
}
@keyframes chat-msg-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.chat-message {
animation: none;
}
}
.chat-message-body {
flex: 1;
min-width: 0;
}
.chat-message-meta {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.1rem;
}
.chat-message-author {
font-weight: 700;
font-size: 0.85rem;
color: inherit;
text-decoration: none;
}
.chat-message-author:hover {
color: var(--color-accent);
}
.chat-message-time {
font-size: 0.7rem;
color: var(--color-text-muted);
}
.chat-message-text {
font-size: 0.9rem;
}
.chat-message-text.md {
line-height: 1.45;
}
.chat-message-edited {
font-size: 0.72rem;
color: var(--color-text-muted);
opacity: 0.7;
font-style: italic;
}
/* Edit/Delete actions: bare inline icons that follow the author/time in the
meta row, sitting right next to the message they belong to. Faded in on hover
(or focus, for keyboard use) so they stay unobtrusive. */
.chat-message-actions {
margin-left: 0.35rem;
display: inline-flex;
gap: 0.15rem;
opacity: 0;
transition: opacity 0.15s;
}
.chat-message:hover .chat-message-actions,
.chat-message:focus-within .chat-message-actions {
opacity: 1;
}
@media (hover: none) {
/* No hover on touch devices — keep the actions visible. */
.chat-message-actions {
opacity: 1;
}
}
.chat-icon-btn {
background: none;
border: none;
padding: 0 0.1rem;
font-size: 0.8rem;
line-height: 1;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s;
}
.chat-icon-btn:hover {
color: var(--color-text);
}
.chat-icon-btn--delete:hover {
color: var(--color-danger);
}
.chat-edit {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.chat-edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
/* Compact buttons for the inline edit box — the full-size form buttons are too
bulky for the narrow message column. */
.chat-edit-actions .btn-primary,
.chat-edit-actions .btn-secondary {
font-size: 0.8rem;
padding: 0.25rem 0.7rem;
box-shadow: none;
}
.chat-composer {
display: flex;
gap: 0.5rem;
align-items: flex-end;
flex-shrink: 0;
}
.chat-composer .mention-textarea-wrap {
flex: 1 1 auto;
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;
}
/* 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. */
.chat-composer .text-editor-count {
position: absolute;
right: 0.55rem;
bottom: 0.4rem;
margin: 0;
}
.chat-send-btn {
flex-shrink: 0;
/* Match the input's resting height so the two line up exactly (both edges),
while the input is free to grow taller as you type. */
height: 2.4rem;
box-sizing: border-box;
}
/* The composer's popovers (mention list, emoji picker) anchor to the input,
which sits at the very bottom of the modal. The modal body must not clip them
— let them escape the body entirely so they're never cropped. The message
list keeps its own scroll, so nothing else relies on the body clipping. */
.modal-card:has(.chat) .modal-body {
overflow: visible;
}
/* Open the mention list upward, into the roomy message area above the input. */
.chat-composer .mention-dropdown {
top: auto;
bottom: 100%;
margin: 0 0 4px;
}
/* Very narrow viewports: stack the input and Send button. */
@media (max-width: 400px) {
.chat-composer {
flex-direction: column;
align-items: stretch;
}
.chat-send-btn {
height: auto;
width: 100%;
}
/* The author/time/edited row gets cramped when squeezed side-by-side here —
stack the metadata instead so each piece stays readable. Keep the stacked
lines tight so they don't balloon the message height. */
.chat-message-meta {
flex-direction: column;
align-items: flex-start;
gap: 0;
line-height: 1.25;
}
/* No longer trailing the time inline; align it with the stacked metadata. */
.chat-message-actions {
margin-left: 0;
}
}
.confirm-modal-message {
margin: 0;
font-size: 0.95rem;
@@ -4359,6 +4678,27 @@ body.has-fab .page-content {
box-shadow: 0 0 0 2px var(--color-bg);
}
/* ── Header chat button ── */
.chat-button {
position: relative;
background: var(--color-header-user-bg);
border: none;
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
padding: 0.35rem 0.85rem;
border-radius: 8px;
transition: background 0.15s;
display: inline-flex;
align-items: center;
}
.chat-button:hover {
background: var(--color-header-user-bg-hover);
}
.chat-button-icon {
display: inline-block;
}
/* ── Notifications page ── */
.notifications-page {
max-width: 680px;

View File

@@ -18,8 +18,10 @@ import { WSProvider } from "./contexts/WSProvider.tsx";
import { FollowProvider } from "./contexts/FollowProvider.tsx";
import { CategoriesProvider } from "./contexts/CategoriesProvider.tsx";
import { ThemeProvider } from "./contexts/ThemeProvider.tsx";
import { ChatProvider } from "./contexts/ChatProvider.tsx";
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
import { DumpFab } from "./components/DumpFab.tsx";
import { ChatFab } from "./components/ChatFab.tsx";
import "./App.css";
@@ -162,6 +164,7 @@ function AppRoutes() {
</Routes>
</Suspense>
<DumpFab />
<ChatFab />
</>
);
}
@@ -175,8 +178,10 @@ function App() {
<FollowProvider>
<CategoriesProvider>
<BrowserRouter>
<AppRoutes />
<GlobalPlayer />
<ChatProvider>
<AppRoutes />
<GlobalPlayer />
</ChatProvider>
</BrowserRouter>
</CategoriesProvider>
</FollowProvider>

View File

@@ -5,6 +5,7 @@ import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { NotificationBell } from "./NotificationBell.tsx";
import { ChatButton } from "./ChatButton.tsx";
import { UserMenu } from "./UserMenu.tsx";
import { SearchBar } from "./SearchBar.tsx";
import { SITE_EMOJI, SITE_NAME } from "../hooks/useDocumentTitle.ts";
@@ -91,6 +92,7 @@ export function AppHeader(
<UserMenu user={user} />
</div>
<ChatButton />
<NotificationBell />
<button
type="button"

View File

@@ -0,0 +1,27 @@
import { t } from "@lingui/core/macro";
import { useWS } from "../hooks/useWS.ts";
import { useChat } from "../hooks/useChat.ts";
/** Header chat button with an unread-message badge. Opens the app-wide chat. */
export function ChatButton() {
const { unreadChatCount } = useWS();
const { openChat } = useChat();
return (
<button
type="button"
className="chat-button"
onClick={openChat}
aria-label={unreadChatCount > 0
? t`Chat (${unreadChatCount} unread)`
: t`Chat`}
>
<span className="chat-button-icon">💬</span>
{unreadChatCount > 0 && (
<span className="notification-badge">
{unreadChatCount > 99 ? "99+" : unreadChatCount}
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { useLocation } from "react-router";
import { t } from "@lingui/core/macro";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useChat } from "../hooks/useChat.ts";
/**
* Floating chat button shown once the header has scrolled out of view, mirroring
* {@link DumpFab}. Opens the single app-wide chat modal (owned by ChatProvider).
* Only rendered for signed-in users.
*/
export function ChatFab() {
const { user } = useAuth();
const { unreadChatCount } = useWS();
const { openChat } = useChat();
const location = useLocation();
const [headerHidden, setHeaderHidden] = useState(false);
// Reveal the button once the header's bottom edge scrolls above the viewport.
// Re-reading the header on every tick keeps this correct across navigation
// and lazily-mounted pages, where the header element is replaced.
useEffect(() => {
if (!user) return;
const update = () => {
const header = document.querySelector(".app-header");
setHeaderHidden(!!header && header.getBoundingClientRect().bottom <= 0);
};
update();
globalThis.addEventListener("scroll", update, { passive: true });
globalThis.addEventListener("resize", update);
const ro = new ResizeObserver(update);
ro.observe(document.body);
return () => {
globalThis.removeEventListener("scroll", update);
globalThis.removeEventListener("resize", update);
ro.disconnect();
};
}, [user, location.pathname]);
if (!user) return null;
return (
<button
type="button"
className={`chat-fab${headerHidden ? " chat-fab--visible" : ""}`}
aria-label={unreadChatCount > 0
? t`Chat (${unreadChatCount} unread)`
: t`Chat`}
title={t`Chat`}
aria-hidden={!headerHidden}
tabIndex={headerHidden ? 0 : -1}
onClick={openChat}
>
<svg
className="chat-fab-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.8-.9L3 21l1.9-5.7a8.5 8.5 0 0 1-.9-3.8A8.38 8.38 0 0 1 12.5 3a8.38 8.38 0 0 1 8.5 8.5z" />
</svg>
{unreadChatCount > 0 && (
<span className="notification-badge">
{unreadChatCount > 99 ? "99+" : unreadChatCount}
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,375 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Link } from "react-router";
import { Modal } from "./Modal.tsx";
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
import { Markdown } from "./Markdown.tsx";
import { Avatar } from "./Avatar.tsx";
import { ConfirmModal } from "./ConfirmModal.tsx";
import { Tooltip } from "./Tooltip.tsx";
import { useWS } from "../hooks/useWS.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { can } from "../utils/permissions.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { API_URL } from "../config/api.ts";
import {
type ChatMessage,
deserializeChatMessage,
type PublicUser,
type RawChatMessage,
} from "../model.ts";
// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts.
const MAX_CHAT_LENGTH = 2000;
// Matches the server's default page size; a full page means there may be more.
const PAGE_SIZE = 50;
interface ChatMessageItemProps {
message: ChatMessage;
currentUser: PublicUser | null;
canModerate: boolean;
onEdit: (id: string, body: string) => void;
onDelete: (id: string) => void;
}
function ChatMessageItem(
{ message, currentUser, canModerate, onEdit, onDelete }: ChatMessageItemProps,
) {
const [editing, setEditing] = useState(false);
const [editDraft, setEditDraft] = useState(message.body);
const [confirmDelete, setConfirmDelete] = useState(false);
const editorRef = useRef<TextEditorHandle>(null);
// Owners may edit their own messages; moderators may edit any. Deletion is a
// moderation action only (mirrors api/services/chat-service.ts).
const isOwn = !!currentUser && currentUser.id === message.userId;
const canEdit = isOwn || canModerate;
const canDelete = canModerate;
const openEdit = () => {
setEditDraft(message.body);
setEditing(true);
setTimeout(() => editorRef.current?.focus(), 0);
};
const saveEdit = () => {
const body = editDraft.trim();
if (!body) return;
onEdit(message.id, body);
setEditing(false);
};
return (
<div className="chat-message">
<Avatar
userId={message.userId}
username={message.authorUsername}
hasAvatar={!!message.authorAvatarMime}
size={32}
/>
<div className="chat-message-body">
<div className="chat-message-meta">
<Link
to={`/users/${message.authorUsername}`}
className="chat-message-author"
>
{message.authorUsername}
</Link>
<Tooltip text={message.createdAt.toLocaleString()}>
<time
className="chat-message-time"
dateTime={message.createdAt.toISOString()}
>
{relativeTime(message.createdAt)}
</time>
</Tooltip>
{message.updatedAt && (
<Tooltip text={t`Edited ${message.updatedAt.toLocaleString()}`}>
<span className="chat-message-edited">
<Trans>edited {relativeTime(message.updatedAt)}</Trans>
</span>
</Tooltip>
)}
{!editing && (canEdit || canDelete) && (
<span className="chat-message-actions">
{canEdit && (
<button
type="button"
className="chat-icon-btn"
onClick={openEdit}
title={t`Edit`}
aria-label={t`Edit`}
>
</button>
)}
{canDelete && (
<button
type="button"
className="chat-icon-btn chat-icon-btn--delete"
onClick={() => setConfirmDelete(true)}
title={t`Delete`}
aria-label={t`Delete`}
>
🗑
</button>
)}
</span>
)}
</div>
{editing
? (
<div className="chat-edit">
<TextEditor
ref={editorRef}
value={editDraft}
onChange={setEditDraft}
rows={2}
autoResize
maxLength={MAX_CHAT_LENGTH}
className="comment-reply-textarea chat-input"
onSubmit={saveEdit}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
saveEdit();
} else if (e.key === "Escape") {
e.preventDefault();
setEditing(false);
}
}}
/>
<div className="chat-edit-actions">
<button
type="button"
className="btn-primary"
onClick={saveEdit}
disabled={!editDraft.trim()}
>
<Trans>Save</Trans>
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setEditing(false)}
>
<Trans>Cancel</Trans>
</button>
</div>
</div>
)
: <Markdown className="chat-message-text">{message.body}</Markdown>}
{confirmDelete && (
<ConfirmModal
message={t`Delete this message?`}
confirmLabel={t`Delete`}
onConfirm={() => {
setConfirmDelete(false);
onDelete(message.id);
}}
onCancel={() => setConfirmDelete(false)}
/>
)}
</div>
</div>
);
}
export function ChatModal({ onClose }: { onClose: () => void }) {
const {
chatMessages,
deletedChatIds,
sendChatMessage,
editChatMessage,
deleteChatMessage,
clearUnreadChat,
setChatOpen,
} = useWS();
const { authFetch, user } = useAuth();
const canModerate = can(user, "chat:moderate");
const [history, setHistory] = useState<ChatMessage[]>([]);
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [draft, setDraft] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<TextEditorHandle>(null);
// Whether the view is pinned to the bottom — controls auto-scroll on new
// messages so reading older history isn't yanked away.
const stickToBottomRef = useRef(true);
// Focus the composer when the chat opens so you can type straight away.
useEffect(() => {
composerRef.current?.focus();
}, []);
// Merge fetched history with the live buffer, drop deleted ones, dedupe by id
// (live buffer wins so edits show), sort ascending.
const messages = useMemo(() => {
const byId = new Map<string, ChatMessage>();
for (const m of history) byId.set(m.id, m);
for (const m of chatMessages) byId.set(m.id, m);
return [...byId.values()]
.filter((m) => !deletedChatIds.has(m.id))
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}, [history, chatMessages, deletedChatIds]);
// Initial history load.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await authFetch(`${API_URL}/api/chat?limit=${PAGE_SIZE}`);
const json = await res.json();
if (cancelled || !json.success) return;
const items = (json.data as RawChatMessage[]).map(deserializeChatMessage);
setHistory(items);
setHasMore(items.length === PAGE_SIZE);
} catch {
// Live buffer still renders; the user can retry by reopening.
}
})();
return () => {
cancelled = true;
};
}, [authFetch]);
// Clear the unread badge on open and as new messages arrive while open.
useEffect(() => {
clearUnreadChat();
}, [chatMessages, clearUnreadChat]);
// Tell the server the chatbox is open so it skips redundant mention
// notifications while the user is reading; clear the flag on close.
useEffect(() => {
setChatOpen(true);
return () => setChatOpen(false);
}, [setChatOpen]);
// Auto-scroll to the bottom when pinned (initial load + new messages).
useEffect(() => {
if (!stickToBottomRef.current) return;
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages]);
const handleScroll = useCallback(() => {
const el = listRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
stickToBottomRef.current = distanceFromBottom < 80;
}, []);
const loadOlder = useCallback(async () => {
const oldest = messages[0];
if (!oldest || loadingOlder) return;
setLoadingOlder(true);
const el = listRef.current;
const prevHeight = el?.scrollHeight ?? 0;
try {
const res = await authFetch(
`${API_URL}/api/chat?limit=${PAGE_SIZE}&before=${
encodeURIComponent(oldest.createdAt.toISOString())
}`,
);
const json = await res.json();
if (!json.success) return;
const older = (json.data as RawChatMessage[]).map(deserializeChatMessage);
setHasMore(older.length === PAGE_SIZE);
if (older.length > 0) {
stickToBottomRef.current = false;
setHistory((prev) => [...older, ...prev]);
// Preserve scroll position after older messages are prepended.
requestAnimationFrame(() => {
const node = listRef.current;
if (node) node.scrollTop = node.scrollHeight - prevHeight;
});
}
} catch {
// ignore — user can retry
} finally {
setLoadingOlder(false);
}
}, [authFetch, messages, loadingOlder]);
const handleSend = useCallback(() => {
const body = draft.trim();
if (!body) return;
sendChatMessage(body);
setDraft("");
stickToBottomRef.current = true;
}, [draft, sendChatMessage]);
return (
<Modal title={t`Chat`} onClose={onClose} wide>
<div className="chat">
<div className="chat-messages" ref={listRef} onScroll={handleScroll}>
{hasMore && (
<div className="chat-load-older">
<button
type="button"
className="btn-secondary"
onClick={loadOlder}
disabled={loadingOlder}
>
{loadingOlder
? <Trans>Loading</Trans>
: <Trans>Load older messages</Trans>}
</button>
</div>
)}
{messages.length === 0
? (
<p className="chat-empty">
<Trans>No messages yet. Say hello!</Trans>
</p>
)
: (
messages.map((m) => (
<ChatMessageItem
key={m.id}
message={m}
currentUser={user}
canModerate={canModerate}
onEdit={editChatMessage}
onDelete={deleteChatMessage}
/>
))
)}
</div>
<div className="chat-composer">
<TextEditor
ref={composerRef}
value={draft}
onChange={setDraft}
placeholder={t`Type a message…`}
rows={2}
autoResize
maxLength={MAX_CHAT_LENGTH}
className="comment-reply-textarea chat-input"
onSubmit={handleSend}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<button
type="button"
className="btn-primary chat-send-btn"
onClick={handleSend}
disabled={!draft.trim()}
>
<Trans>Send</Trans>
</button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,13 @@
import { createContext } from "react";
export interface ChatContextValue {
isChatOpen: boolean;
openChat: () => void;
closeChat: () => void;
}
export const ChatContext = createContext<ChatContextValue>({
isChatOpen: false,
openChat: () => {},
closeChat: () => {},
});

View File

@@ -0,0 +1,57 @@
import {
lazy,
type ReactNode,
Suspense,
useCallback,
useMemo,
useState,
} from "react";
import { useSearchParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
import { ChatContext, type ChatContextValue } from "./ChatContext.ts";
const ChatModal = lazy(() =>
import("../components/ChatModal.tsx").then((m) => ({ default: m.ChatModal }))
);
/**
* Owns the single live-chat modal instance so the header button, the floating
* button, and notification deep-links all drive one modal. Open state is derived
* (no effects): a manual open, or a `?chat=open` query param (used by mention
* notifications), opens it — and it's only ever open for signed-in users.
*/
export function ChatProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const [manuallyOpen, setManuallyOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkOpen = searchParams.get("chat") === "open";
const isChatOpen = !!user && (manuallyOpen || deepLinkOpen);
const openChat = useCallback(() => setManuallyOpen(true), []);
const closeChat = useCallback(() => {
setManuallyOpen(false);
// Strip the deep-link param so a reload/back-nav doesn't reopen the modal.
if (searchParams.get("chat") === "open") {
const next = new URLSearchParams(searchParams);
next.delete("chat");
setSearchParams(next, { replace: true });
}
}, [searchParams, setSearchParams]);
const value: ChatContextValue = useMemo(
() => ({ isChatOpen, openChat, closeChat }),
[isChatOpen, openChat, closeChat],
);
return (
<ChatContext.Provider value={value}>
{children}
{isChatOpen && (
<Suspense>
<ChatModal onClose={closeChat} />
</Suspense>
)}
</ChatContext.Provider>
);
}

View File

@@ -1,5 +1,6 @@
import { createContext } from "react";
import type {
ChatMessage,
Comment,
Dump,
Notification,
@@ -58,6 +59,12 @@ export interface WSContextValue {
lastUserEvent: UserEvent | null;
unreadNotificationCount: number;
lastNotification: Notification | null;
/** Live buffer of chat messages received this session (capped). */
chatMessages: ChatMessage[];
/** Ids of chat messages deleted live this session, so fetched history can
* filter them out. */
deletedChatIds: Set<string>;
unreadChatCount: number;
/** Increments on each WS reconnect so pages can backfill missed events. */
connectionEpoch: number;
castVote: (dumpId: string) => void;
@@ -66,6 +73,13 @@ export interface WSContextValue {
removeCommentLike: (commentId: string) => void;
injectDump: (dump: Dump) => void;
clearUnreadNotifications: () => void;
sendChatMessage: (body: string) => void;
editChatMessage: (id: string, body: string) => void;
deleteChatMessage: (id: string) => void;
clearUnreadChat: () => void;
/** Inform the server whether the chatbox is open (suppresses redundant
* mention notifications while the user is reading chat). */
setChatOpen: (open: boolean) => void;
}
export const WSContext = createContext<WSContextValue>({
@@ -87,6 +101,9 @@ export const WSContext = createContext<WSContextValue>({
lastUserEvent: null,
unreadNotificationCount: 0,
lastNotification: null,
chatMessages: [],
deletedChatIds: new Set(),
unreadChatCount: 0,
connectionEpoch: 0,
castVote: () => {},
removeVote: () => {},
@@ -94,4 +111,9 @@ export const WSContext = createContext<WSContextValue>({
removeCommentLike: () => {},
injectDump: () => {},
clearUnreadNotifications: () => {},
sendChatMessage: () => {},
editChatMessage: () => {},
deleteChatMessage: () => {},
clearUnreadChat: () => {},
setChatOpen: () => {},
});

View File

@@ -22,6 +22,7 @@ import {
} from "./AvatarOverrideContext.ts";
import { WS_URL } from "../config/api.ts";
import type {
ChatMessage,
Dump,
IncomingWSMessage,
Notification,
@@ -29,6 +30,7 @@ import type {
OutgoingWSMessage,
} from "../model.ts";
import {
deserializeChatMessage,
deserializeComment,
deserializeDump,
deserializeNotification,
@@ -49,6 +51,9 @@ const CONNECT_TIMEOUT = 2_500;
// bound. Feeds merge these on top of their own paginated list, so a generous
// cap is plenty — older live arrivals are reachable via normal pagination.
const MAX_RECENT_DUMPS = 100;
// Cap the live chat buffer so a long-lived session can't grow it without bound.
// Older messages remain reachable via the /api/chat history endpoint.
const MAX_CHAT_MESSAGES = 200;
interface PendingVote {
timeout: ReturnType<typeof setTimeout>;
@@ -111,6 +116,9 @@ export function WSProvider({ children }: WSProviderProps) {
const [lastNotification, setLastNotification] = useState<Notification | null>(
null,
);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
const [unreadChatCount, setUnreadChatCount] = useState(0);
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
const [avatarOverrides, setAvatarOverrides] = useState<
Record<string, AvatarOverride>
@@ -145,6 +153,9 @@ export function WSProvider({ children }: WSProviderProps) {
setLastUserEvent(null);
setUnreadNotificationCount(0);
setLastNotification(null);
setChatMessages([]);
setDeletedChatIds(new Set());
setUnreadChatCount(0);
setAvatarOverrides({});
}
@@ -167,6 +178,9 @@ export function WSProvider({ children }: WSProviderProps) {
});
const socketRef = useRef<WebSocket | null>(null);
// Tracks whether the chatbox is open so it can be re-announced to the server
// after a reconnect (the fresh socket starts with the flag cleared).
const chatOpenRef = useRef(false);
// Tracks pending optimistic votes: dumpId → pending rollback handler
const pendingRef = useRef<Map<string, PendingVote>>(
new Map(),
@@ -291,6 +305,15 @@ export function WSProvider({ children }: WSProviderProps) {
// dumps/comments they couldn't receive while disconnected.
welcomeCount += 1;
if (welcomeCount > 1) setConnectionEpoch((n) => n + 1);
// Restore the chat-open flag on the fresh connection so mention
// suppression keeps working across reconnects.
if (chatOpenRef.current) {
ws.send(
JSON.stringify(
{ type: "chat_focus", open: true } satisfies OutgoingWSMessage,
),
);
}
break;
case "presence_update":
@@ -474,6 +497,38 @@ export function WSProvider({ children }: WSProviderProps) {
break;
}
case "chat_message": {
const message = deserializeChatMessage(msg.message);
setChatMessages((prev) => {
if (prev.some((m) => m.id === message.id)) return prev;
return [...prev, message].slice(-MAX_CHAT_MESSAGES);
});
// Count as unread unless it's the current user's own message.
if (message.userId !== userIdRef.current) {
setUnreadChatCount((prev) => prev + 1);
}
break;
}
case "chat_message_updated": {
const message = deserializeChatMessage(msg.message);
setChatMessages((prev) => {
const idx = prev.findIndex((m) => m.id === message.id);
if (idx === -1) return [...prev, message];
const next = [...prev];
next[idx] = message;
return next;
});
break;
}
case "chat_message_deleted": {
const { id } = msg;
setDeletedChatIds((prev) => new Set(prev).add(id));
setChatMessages((prev) => prev.filter((m) => m.id !== id));
break;
}
case "force_logout":
onForceLogoutRef.current();
break;
@@ -704,6 +759,55 @@ export function WSProvider({ children }: WSProviderProps) {
setUnreadNotificationCount(0);
}, []);
const sendChatMessage = useCallback((body: string) => {
const trimmed = body.trim();
if (!trimmed) return;
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_send", body: trimmed } satisfies OutgoingWSMessage,
),
);
}
}, []);
const editChatMessage = useCallback((id: string, body: string) => {
const trimmed = body.trim();
if (!trimmed) return;
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_edit", id, body: trimmed } satisfies OutgoingWSMessage,
),
);
}
}, []);
const deleteChatMessage = useCallback((id: string) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_delete", id } satisfies OutgoingWSMessage,
),
);
}
}, []);
const clearUnreadChat = useCallback(() => {
setUnreadChatCount(0);
}, []);
const setChatOpen = useCallback((open: boolean) => {
chatOpenRef.current = open;
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_focus", open } satisfies OutgoingWSMessage,
),
);
}
}, []);
const value: WSContextValue = useMemo(() => ({
wsStatus,
wsErrorMessage,
@@ -723,6 +827,9 @@ export function WSProvider({ children }: WSProviderProps) {
lastUserEvent,
unreadNotificationCount,
lastNotification,
chatMessages,
deletedChatIds,
unreadChatCount,
connectionEpoch,
castVote,
removeVote,
@@ -730,6 +837,11 @@ export function WSProvider({ children }: WSProviderProps) {
removeCommentLike,
injectDump,
clearUnreadNotifications,
sendChatMessage,
editChatMessage,
deleteChatMessage,
clearUnreadChat,
setChatOpen,
}), [
wsStatus,
wsErrorMessage,
@@ -749,6 +861,9 @@ export function WSProvider({ children }: WSProviderProps) {
lastUserEvent,
unreadNotificationCount,
lastNotification,
chatMessages,
deletedChatIds,
unreadChatCount,
connectionEpoch,
castVote,
removeVote,
@@ -756,6 +871,11 @@ export function WSProvider({ children }: WSProviderProps) {
removeCommentLike,
injectDump,
clearUnreadNotifications,
sendChatMessage,
editChatMessage,
deleteChatMessage,
clearUnreadChat,
setChatOpen,
]);
return (

6
src/hooks/useChat.ts Normal file
View File

@@ -0,0 +1,6 @@
import { useContext } from "react";
import { ChatContext } from "../contexts/ChatContext.ts";
export function useChat() {
return useContext(ChatContext);
}

File diff suppressed because one or more lines are too long

View File

@@ -47,8 +47,8 @@ msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
msgid "← Back"
msgstr "← Back"
#: src/pages/Dump.tsx:261
#: src/pages/Dump.tsx:491
#: src/pages/Dump.tsx:264
#: src/pages/Dump.tsx:494
#: src/pages/DumpEdit.tsx:179
msgid "← Back to all dumps"
msgstr "← Back to all dumps"
@@ -67,61 +67,61 @@ msgstr "+ Invite someone"
msgid "+ New playlist"
msgstr "+ New playlist"
#: src/pages/Dump.tsx:332
#: src/pages/Dump.tsx:335
msgid "+ Playlist"
msgstr "+ Playlist"
#. placeholder {0}: d.commenterUsername
#. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:195
#: src/pages/Notifications.tsx:196
msgid "<0>{0}</0> commented on <1>{1}</1>"
msgstr "<0>{0}</0> commented on <1>{1}</1>"
#. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:155
#: src/pages/Notifications.tsx:156
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
#. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:205
#: src/pages/Notifications.tsx:206
msgid "<0>{0}</0> liked your comment on <1>{1}</1>"
msgstr "<0>{0}</0> liked your comment on <1>{1}</1>"
#. placeholder {0}: d.mentionerUsername
#: src/pages/Notifications.tsx:217
#: src/pages/Notifications.tsx:222
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
#. placeholder {0}: d.dumperUsername
#. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:165
#: src/pages/Notifications.tsx:166
msgid "<0>{0}</0> posted <1>{1}</1>"
msgstr "<0>{0}</0> posted <1>{1}</1>"
#. placeholder {0}: d.followerUsername
#: src/pages/Notifications.tsx:146
#: src/pages/Notifications.tsx:147
msgid "<0>{0}</0> started following you"
msgstr "<0>{0}</0> started following you"
#. placeholder {0}: d.voterUsername
#. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:185
#: src/pages/Notifications.tsx:186
msgid "<0>{0}</0> upvoted <1>{1}</1>"
msgstr "<0>{0}</0> upvoted <1>{1}</1>"
#. placeholder {0}: d.dumpTitle
#. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:175
#: src/pages/Notifications.tsx:176
msgid "<0>{0}</0> was added to <1>{1}</1>"
msgstr "<0>{0}</0> was added to <1>{1}</1>"
#: src/pages/Notifications.tsx:215
#: src/pages/Notifications.tsx:217
msgid "a comment"
msgstr "a comment"
#: src/pages/Notifications.tsx:215
#: src/pages/Notifications.tsx:220
msgid "a post"
msgstr "a post"
@@ -180,17 +180,18 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Back to login"
#: src/contexts/WSProvider.tsx:211
#: src/contexts/WSProvider.tsx:438
#: src/contexts/WSProvider.tsx:268
#: src/contexts/WSProvider.tsx:570
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
#: src/components/CategoryManager.tsx:210
#: src/components/CategoryManager.tsx:211
#: src/components/ChatModal.tsx:158
#: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:373
#: src/pages/Dump.tsx:376
#: src/pages/DumpEdit.tsx:463
#: src/pages/PlaylistDetail.tsx:920
#: src/pages/UserPublicProfile.tsx:1674
@@ -202,7 +203,7 @@ msgstr "Cancel"
msgid "Cancel removal"
msgstr "Cancel removal"
#: src/components/AppHeader.tsx:65
#: src/components/AppHeader.tsx:66
msgid "Cancel search"
msgstr "Cancel search"
@@ -228,6 +229,18 @@ msgstr "Change password"
msgid "Change password…"
msgstr "Change password…"
#: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:308
msgid "Chat"
msgstr "Chat"
#: src/components/ChatButton.tsx:16
#: src/components/ChatFab.tsx:48
msgid "Chat ({unreadChatCount} unread)"
msgstr "Chat ({unreadChatCount} unread)"
#: src/pages/UserRegister.tsx:85
msgid "Checking invite…"
msgstr "Checking invite…"
@@ -298,6 +311,9 @@ msgstr "Default tab"
#: src/components/CategoryManager.tsx:221
#: src/components/CategoryManager.tsx:222
#: src/components/ChatModal.tsx:112
#: src/components/ChatModal.tsx:113
#: src/components/ChatModal.tsx:168
#: src/components/CommentThread.tsx:376
#: src/components/CommentThread.tsx:382
#: src/components/ConfirmModal.tsx:16
@@ -333,6 +349,10 @@ msgstr "Delete this comment?"
msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone."
#: src/components/ChatModal.tsx:167
msgid "Delete this message?"
msgstr "Delete this message?"
#: src/pages/PlaylistDetail.tsx:757
#: src/pages/UserPlaylists.tsx:467
msgid "Delete this playlist? This cannot be undone."
@@ -355,7 +375,7 @@ msgstr "Drop a file here"
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
#: src/components/AppHeader.tsx:103
#: src/components/AppHeader.tsx:105
msgid "Dump"
msgstr "Dump"
@@ -379,12 +399,14 @@ msgstr "Dumps"
msgid "Dumps ({0}{1})"
msgstr "Dumps ({0}{1})"
#: src/pages/Notifications.tsx:395
#: src/pages/Notifications.tsx:400
msgid "Earlier"
msgstr "Earlier"
#: src/components/ChatModal.tsx:101
#: src/components/ChatModal.tsx:102
#: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:487
#: src/pages/Dump.tsx:490
#: src/pages/PlaylistDetail.tsx:625
msgid "Edit"
msgstr "Edit"
@@ -400,18 +422,20 @@ msgstr "Edit title"
#. placeholder {0}: relativeTime(comment.updatedAt)
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(playlist.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:90
#: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:426
#: src/pages/Dump.tsx:429
#: src/pages/PlaylistDetail.tsx:664
msgid "edited {0}"
msgstr "edited {0}"
#. placeholder {0}: comment.updatedAt.toLocaleString()
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: playlist.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:88
#: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:424
#: src/pages/Dump.tsx:427
#: src/pages/PlaylistDetail.tsx:661
msgid "Edited {0}"
msgstr "Edited {0}"
@@ -455,7 +479,7 @@ msgstr "Failed to generate invite"
#: src/pages/index/HotFeed.tsx:36
#: src/pages/index/JournalFeed.tsx:33
#: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:369
#: src/pages/Notifications.tsx:374
#: src/pages/UserPublicProfile.tsx:1022
#: src/pages/UserPublicProfile.tsx:1064
#: src/pages/UserPublicProfile.tsx:1109
@@ -619,20 +643,24 @@ msgstr "Light"
msgid "Like"
msgstr "Like"
#: src/contexts/WSProvider.tsx:437
#: src/contexts/WSProvider.tsx:569
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
#: src/components/AppHeader.tsx:130
#: src/components/AppHeader.tsx:132
msgid "Live updates unavailable."
msgstr "Live updates unavailable."
#: src/components/UserListPopover.tsx:231
#: src/pages/Notifications.tsx:442
#: src/pages/Notifications.tsx:447
msgid "Load more"
msgstr "Load more"
#: src/pages/Dump.tsx:237
#: src/components/ChatModal.tsx:321
msgid "Load older messages"
msgstr "Load older messages"
#: src/pages/Dump.tsx:240
#: src/pages/DumpEdit.tsx:155
msgid "Loading dump…"
msgstr "Loading dump…"
@@ -658,6 +686,7 @@ msgid "Loading profile…"
msgstr "Loading profile…"
#: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:320
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:289
#: src/components/UserListPopover.tsx:192
@@ -666,8 +695,8 @@ msgstr "Loading profile…"
#: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:29
#: src/pages/index/NewFeed.tsx:32
#: src/pages/Notifications.tsx:365
#: src/pages/Notifications.tsx:441
#: src/pages/Notifications.tsx:370
#: src/pages/Notifications.tsx:446
#: src/pages/UserDumps.tsx:54
#: src/pages/UserPlaylists.tsx:345
#: src/pages/UserPublicProfile.tsx:1016
@@ -677,7 +706,7 @@ msgstr "Loading profile…"
msgid "Loading…"
msgstr "Loading…"
#: src/components/AppHeader.tsx:111
#: src/components/AppHeader.tsx:113
#: src/pages/UserLogin.tsx:34
#: src/pages/UserLogin.tsx:59
#: src/pages/UserLogin.tsx:78
@@ -722,7 +751,7 @@ msgstr "Moderator"
msgid "Name"
msgstr "Name"
#: src/pages/Notifications.tsx:358
#: src/pages/Notifications.tsx:363
msgid "new"
msgstr "new"
@@ -776,6 +805,10 @@ msgstr "No followed playlists yet."
msgid "No invitees yet."
msgstr "No invitees yet."
#: src/components/ChatModal.tsx:328
msgid "No messages yet. Say hello!"
msgstr "No messages yet. Say hello!"
#: src/components/UserListPopover.tsx:200
msgid "No one yet."
msgstr "No one yet."
@@ -798,7 +831,7 @@ msgstr "No users match \"{q}\"."
msgid "Not following anyone yet."
msgstr "Not following anyone yet."
#: src/pages/Notifications.tsx:376
#: src/pages/Notifications.tsx:381
#: src/pages/UserDumps.tsx:99
#: src/pages/UserPublicProfile.tsx:1338
#: src/pages/UserPublicProfile.tsx:1460
@@ -807,8 +840,8 @@ msgid "Nothing here yet."
msgstr "Nothing here yet."
#: src/components/NotificationBell.tsx:42
#: src/pages/Notifications.tsx:260
#: src/pages/Notifications.tsx:354
#: src/pages/Notifications.tsx:265
#: src/pages/Notifications.tsx:359
msgid "Notifications"
msgstr "Notifications"
@@ -855,7 +888,7 @@ msgstr "Passwords do not match"
msgid "Playlist title"
msgstr "Playlist title"
#: src/components/AppHeader.tsx:85
#: src/components/AppHeader.tsx:86
#: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:177
#: src/pages/UserPlaylists.tsx:371
@@ -890,7 +923,7 @@ msgstr "Posting…"
#: src/components/JournalCard.tsx:121
#: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:432
#: src/pages/Dump.tsx:435
#: src/pages/PlaylistDetail.tsx:644
msgid "private"
msgstr "private"
@@ -932,7 +965,7 @@ msgstr "Registering…"
msgid "Registration failed"
msgstr "Registration failed"
#: src/pages/Dump.tsx:499
#: src/pages/Dump.tsx:502
msgid "Related"
msgstr "Related"
@@ -977,7 +1010,7 @@ msgstr "Reset password"
msgid "Reset to default"
msgstr "Reset to default"
#: src/pages/Dump.tsx:254
#: src/pages/Dump.tsx:257
#: src/pages/DumpEdit.tsx:172
msgid "Retry"
msgstr "Retry"
@@ -986,8 +1019,9 @@ msgstr "Retry"
msgid "Role"
msgstr "Role"
#: src/components/ChatModal.tsx:151
#: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:365
#: src/pages/Dump.tsx:368
#: src/pages/DumpEdit.tsx:466
#: src/pages/PlaylistDetail.tsx:927
#: src/pages/UserPublicProfile.tsx:1666
@@ -997,7 +1031,7 @@ msgstr "Save"
#: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:364
#: src/pages/Dump.tsx:367
#: src/pages/PlaylistDetail.tsx:923
#: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1663
@@ -1005,7 +1039,7 @@ msgstr "Save"
msgid "Saving…"
msgstr "Saving…"
#: src/components/AppHeader.tsx:65
#: src/components/AppHeader.tsx:66
#: src/components/SearchBar.tsx:78
#: src/pages/Search.tsx:50
msgid "Search"
@@ -1023,6 +1057,10 @@ msgstr "Search failed"
msgid "Searching…"
msgstr "Searching…"
#: src/components/ChatModal.tsx:369
msgid "Send"
msgstr "Send"
#: src/pages/UserLogin.tsx:141
msgid "Send reset link"
msgstr "Send reset link"
@@ -1031,7 +1069,7 @@ msgstr "Send reset link"
msgid "Sending…"
msgstr "Sending…"
#: src/components/AppHeader.tsx:100
#: src/components/AppHeader.tsx:102
msgid "Server unreachable"
msgstr "Server unreachable"
@@ -1061,6 +1099,10 @@ msgstr "Style"
msgid "Submit search"
msgstr "Submit search"
#: src/pages/Notifications.tsx:219
msgid "the chat"
msgstr "the chat"
#: src/pages/UserRegister.tsx:97
msgid "This invite link is missing, expired, or already used."
msgstr "This invite link is missing, expired, or already used."
@@ -1091,10 +1133,14 @@ msgstr "Title"
msgid "Title is required."
msgstr "Title is required."
#: src/pages/Notifications.tsx:392
#: src/pages/Notifications.tsx:397
msgid "Today"
msgstr "Today"
#: src/components/ChatModal.tsx:350
msgid "Type a message…"
msgstr "Type a message…"
#: src/pages/PlaylistDetail.tsx:747
msgid "Undo"
msgstr "Undo"
@@ -1194,11 +1240,11 @@ msgstr "Why?"
msgid "Write a reply…"
msgstr "Write a reply…"
#: src/pages/Notifications.tsx:394
#: src/pages/Notifications.tsx:399
msgid "Yesterday"
msgstr "Yesterday"
#: src/pages/Notifications.tsx:379
#: src/pages/Notifications.tsx:384
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."

File diff suppressed because one or more lines are too long

View File

@@ -47,8 +47,8 @@ msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
msgid "← Back"
msgstr "← Retour"
#: src/pages/Dump.tsx:261
#: src/pages/Dump.tsx:491
#: src/pages/Dump.tsx:264
#: src/pages/Dump.tsx:494
#: src/pages/DumpEdit.tsx:179
msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos"
@@ -67,61 +67,61 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist"
msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:332
#: src/pages/Dump.tsx:335
msgid "+ Playlist"
msgstr "+ Collection"
#. placeholder {0}: d.commenterUsername
#. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:195
#: src/pages/Notifications.tsx:196
msgid "<0>{0}</0> commented on <1>{1}</1>"
msgstr "<0>{0}</0> a commenté sur <1>{1}</1>"
#. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:155
#: src/pages/Notifications.tsx:156
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
#. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:205
#: src/pages/Notifications.tsx:206
msgid "<0>{0}</0> liked your comment on <1>{1}</1>"
msgstr "<0>{0}</0> a aimé votre commentaire sur <1>{1}</1>"
#. placeholder {0}: d.mentionerUsername
#: src/pages/Notifications.tsx:217
#: src/pages/Notifications.tsx:222
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
#. placeholder {0}: d.dumperUsername
#. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:165
#: src/pages/Notifications.tsx:166
msgid "<0>{0}</0> posted <1>{1}</1>"
msgstr "<0>{0}</0> a publié <1>{1}</1>"
#. placeholder {0}: d.followerUsername
#: src/pages/Notifications.tsx:146
#: src/pages/Notifications.tsx:147
msgid "<0>{0}</0> started following you"
msgstr "<0>{0}</0> a commencé à vous suivre"
#. placeholder {0}: d.voterUsername
#. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:185
#: src/pages/Notifications.tsx:186
msgid "<0>{0}</0> upvoted <1>{1}</1>"
msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
#. placeholder {0}: d.dumpTitle
#. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:175
#: src/pages/Notifications.tsx:176
msgid "<0>{0}</0> was added to <1>{1}</1>"
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
#: src/pages/Notifications.tsx:215
#: src/pages/Notifications.tsx:217
msgid "a comment"
msgstr "un commentaire"
#: src/pages/Notifications.tsx:215
#: src/pages/Notifications.tsx:220
msgid "a post"
msgstr "une publication"
@@ -180,17 +180,18 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Retour à la connexion"
#: src/contexts/WSProvider.tsx:211
#: src/contexts/WSProvider.tsx:438
#: src/contexts/WSProvider.tsx:268
#: src/contexts/WSProvider.tsx:570
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
#: src/components/CategoryManager.tsx:210
#: src/components/CategoryManager.tsx:211
#: src/components/ChatModal.tsx:158
#: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:373
#: src/pages/Dump.tsx:376
#: src/pages/DumpEdit.tsx:463
#: src/pages/PlaylistDetail.tsx:920
#: src/pages/UserPublicProfile.tsx:1674
@@ -202,7 +203,7 @@ msgstr "Annuler"
msgid "Cancel removal"
msgstr "Annuler la suppression"
#: src/components/AppHeader.tsx:65
#: src/components/AppHeader.tsx:66
msgid "Cancel search"
msgstr "Annuler la recherche"
@@ -228,6 +229,18 @@ msgstr "Changer le mot de passe"
msgid "Change password…"
msgstr "Changer le mot de passe…"
#: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:308
msgid "Chat"
msgstr "Tribune"
#: src/components/ChatButton.tsx:16
#: src/components/ChatFab.tsx:48
msgid "Chat ({unreadChatCount} unread)"
msgstr "{unreadChatCount} messages non lus"
#: src/pages/UserRegister.tsx:85
msgid "Checking invite…"
msgstr "Vérification de l'invitation…"
@@ -298,6 +311,9 @@ msgstr "Onglet par défaut"
#: src/components/CategoryManager.tsx:221
#: src/components/CategoryManager.tsx:222
#: src/components/ChatModal.tsx:112
#: src/components/ChatModal.tsx:113
#: src/components/ChatModal.tsx:168
#: src/components/CommentThread.tsx:376
#: src/components/CommentThread.tsx:382
#: src/components/ConfirmModal.tsx:16
@@ -333,6 +349,10 @@ msgstr "Supprimer ce commentaire ?"
msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible."
#: src/components/ChatModal.tsx:167
msgid "Delete this message?"
msgstr "Supprimer ce message ?"
#: src/pages/PlaylistDetail.tsx:757
#: src/pages/UserPlaylists.tsx:467
msgid "Delete this playlist? This cannot be undone."
@@ -355,7 +375,7 @@ msgstr "Déposez un fichier ici"
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
#: src/components/AppHeader.tsx:103
#: src/components/AppHeader.tsx:105
msgid "Dump"
msgstr "Reco"
@@ -379,12 +399,14 @@ msgstr "Recos"
msgid "Dumps ({0}{1})"
msgstr "Recos ({0}{1})"
#: src/pages/Notifications.tsx:395
#: src/pages/Notifications.tsx:400
msgid "Earlier"
msgstr "Plus tôt"
#: src/components/ChatModal.tsx:101
#: src/components/ChatModal.tsx:102
#: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:487
#: src/pages/Dump.tsx:490
#: src/pages/PlaylistDetail.tsx:625
msgid "Edit"
msgstr "Modifier"
@@ -400,18 +422,20 @@ msgstr "Modifier le titre"
#. placeholder {0}: relativeTime(comment.updatedAt)
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(playlist.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:90
#: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:426
#: src/pages/Dump.tsx:429
#: src/pages/PlaylistDetail.tsx:664
msgid "edited {0}"
msgstr "modifié {0}"
#. placeholder {0}: comment.updatedAt.toLocaleString()
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: playlist.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:88
#: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:424
#: src/pages/Dump.tsx:427
#: src/pages/PlaylistDetail.tsx:661
msgid "Edited {0}"
msgstr "Modifié le {0}"
@@ -455,7 +479,7 @@ msgstr "Impossible de générer une invitation"
#: src/pages/index/HotFeed.tsx:36
#: src/pages/index/JournalFeed.tsx:33
#: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:369
#: src/pages/Notifications.tsx:374
#: src/pages/UserPublicProfile.tsx:1022
#: src/pages/UserPublicProfile.tsx:1064
#: src/pages/UserPublicProfile.tsx:1109
@@ -619,20 +643,24 @@ msgstr "Clair"
msgid "Like"
msgstr "Aimer"
#: src/contexts/WSProvider.tsx:437
#: src/contexts/WSProvider.tsx:569
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
#: src/components/AppHeader.tsx:130
#: src/components/AppHeader.tsx:132
msgid "Live updates unavailable."
msgstr "Mises à jour en direct indisponibles."
#: src/components/UserListPopover.tsx:231
#: src/pages/Notifications.tsx:442
#: src/pages/Notifications.tsx:447
msgid "Load more"
msgstr "Charger plus"
#: src/pages/Dump.tsx:237
#: src/components/ChatModal.tsx:321
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
#: src/pages/Dump.tsx:240
#: src/pages/DumpEdit.tsx:155
msgid "Loading dump…"
msgstr "Chargement de la reco…"
@@ -658,6 +686,7 @@ msgid "Loading profile…"
msgstr "Chargement du profil…"
#: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:320
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:289
#: src/components/UserListPopover.tsx:192
@@ -666,8 +695,8 @@ msgstr "Chargement du profil…"
#: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:29
#: src/pages/index/NewFeed.tsx:32
#: src/pages/Notifications.tsx:365
#: src/pages/Notifications.tsx:441
#: src/pages/Notifications.tsx:370
#: src/pages/Notifications.tsx:446
#: src/pages/UserDumps.tsx:54
#: src/pages/UserPlaylists.tsx:345
#: src/pages/UserPublicProfile.tsx:1016
@@ -677,7 +706,7 @@ msgstr "Chargement du profil…"
msgid "Loading…"
msgstr "Chargement…"
#: src/components/AppHeader.tsx:111
#: src/components/AppHeader.tsx:113
#: src/pages/UserLogin.tsx:34
#: src/pages/UserLogin.tsx:59
#: src/pages/UserLogin.tsx:78
@@ -722,7 +751,7 @@ msgstr "Modérateur"
msgid "Name"
msgstr "Nom"
#: src/pages/Notifications.tsx:358
#: src/pages/Notifications.tsx:363
msgid "new"
msgstr "nouveau"
@@ -776,6 +805,10 @@ msgstr "Pas encore de collections suivies."
msgid "No invitees yet."
msgstr "Aucun invité pour le moment."
#: src/components/ChatModal.tsx:328
msgid "No messages yet. Say hello!"
msgstr "Aucun message pour l'instant. Dites bonjour !"
#: src/components/UserListPopover.tsx:200
msgid "No one yet."
msgstr "Personne pour le moment."
@@ -798,7 +831,7 @@ msgstr "Aucun utilisateur ne correspond à « {q} »."
msgid "Not following anyone yet."
msgstr "Aucun abonnement pour le moment."
#: src/pages/Notifications.tsx:376
#: src/pages/Notifications.tsx:381
#: src/pages/UserDumps.tsx:99
#: src/pages/UserPublicProfile.tsx:1338
#: src/pages/UserPublicProfile.tsx:1460
@@ -807,8 +840,8 @@ msgid "Nothing here yet."
msgstr "Rien ici pour l'instant."
#: src/components/NotificationBell.tsx:42
#: src/pages/Notifications.tsx:260
#: src/pages/Notifications.tsx:354
#: src/pages/Notifications.tsx:265
#: src/pages/Notifications.tsx:359
msgid "Notifications"
msgstr "Notifications"
@@ -855,7 +888,7 @@ msgstr "Les mots de passe ne correspondent pas"
msgid "Playlist title"
msgstr "Titre de la collection"
#: src/components/AppHeader.tsx:85
#: src/components/AppHeader.tsx:86
#: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:177
#: src/pages/UserPlaylists.tsx:371
@@ -890,7 +923,7 @@ msgstr "Publication…"
#: src/components/JournalCard.tsx:121
#: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:432
#: src/pages/Dump.tsx:435
#: src/pages/PlaylistDetail.tsx:644
msgid "private"
msgstr "privé"
@@ -932,7 +965,7 @@ msgstr "Inscription…"
msgid "Registration failed"
msgstr "Inscription échouée"
#: src/pages/Dump.tsx:499
#: src/pages/Dump.tsx:502
msgid "Related"
msgstr "Connexe"
@@ -977,7 +1010,7 @@ msgstr "Réinitialiser le mot de passe"
msgid "Reset to default"
msgstr "Réinitialiser par défaut"
#: src/pages/Dump.tsx:254
#: src/pages/Dump.tsx:257
#: src/pages/DumpEdit.tsx:172
msgid "Retry"
msgstr "Réessayer"
@@ -986,8 +1019,9 @@ msgstr "Réessayer"
msgid "Role"
msgstr "Rôle"
#: src/components/ChatModal.tsx:151
#: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:365
#: src/pages/Dump.tsx:368
#: src/pages/DumpEdit.tsx:466
#: src/pages/PlaylistDetail.tsx:927
#: src/pages/UserPublicProfile.tsx:1666
@@ -997,7 +1031,7 @@ msgstr "Enregistrer"
#: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:364
#: src/pages/Dump.tsx:367
#: src/pages/PlaylistDetail.tsx:923
#: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1663
@@ -1005,7 +1039,7 @@ msgstr "Enregistrer"
msgid "Saving…"
msgstr "Enregistrement…"
#: src/components/AppHeader.tsx:65
#: src/components/AppHeader.tsx:66
#: src/components/SearchBar.tsx:78
#: src/pages/Search.tsx:50
msgid "Search"
@@ -1023,6 +1057,10 @@ msgstr "Recherche échouée"
msgid "Searching…"
msgstr "Recherche…"
#: src/components/ChatModal.tsx:369
msgid "Send"
msgstr "Envoyer"
#: src/pages/UserLogin.tsx:141
msgid "Send reset link"
msgstr "Envoyer le lien de réinitialisation"
@@ -1031,7 +1069,7 @@ msgstr "Envoyer le lien de réinitialisation"
msgid "Sending…"
msgstr "Envoi…"
#: src/components/AppHeader.tsx:100
#: src/components/AppHeader.tsx:102
msgid "Server unreachable"
msgstr "Serveur inaccessible"
@@ -1061,6 +1099,10 @@ msgstr "Style"
msgid "Submit search"
msgstr "Lancer la recherche"
#: src/pages/Notifications.tsx:219
msgid "the chat"
msgstr "la tribune"
#: src/pages/UserRegister.tsx:97
msgid "This invite link is missing, expired, or already used."
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
@@ -1091,10 +1133,14 @@ msgstr "Titre"
msgid "Title is required."
msgstr "Un titre est requis."
#: src/pages/Notifications.tsx:392
#: src/pages/Notifications.tsx:397
msgid "Today"
msgstr "Aujourd'hui"
#: src/components/ChatModal.tsx:350
msgid "Type a message…"
msgstr "Écrivez un message…"
#: src/pages/PlaylistDetail.tsx:747
msgid "Undo"
msgstr "Annuler"
@@ -1194,11 +1240,11 @@ msgstr "Pourquoi ?"
msgid "Write a reply…"
msgstr "Écrire une réponse…"
#: src/pages/Notifications.tsx:394
#: src/pages/Notifications.tsx:399
msgid "Yesterday"
msgstr "Hier"
#: src/pages/Notifications.tsx:379
#: src/pages/Notifications.tsx:384
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."

View File

@@ -216,6 +216,30 @@ export function deserializeComment(raw: RawComment): Comment {
};
}
/**
* Chat
*/
export interface ChatMessage {
id: string;
userId: string;
body: string;
createdAt: Date;
updatedAt?: Date;
authorUsername: string;
authorAvatarMime?: string;
}
export type RawChatMessage = WithStringDate<ChatMessage>;
export function deserializeChatMessage(raw: RawChatMessage): ChatMessage {
return {
...raw,
createdAt: new Date(raw.createdAt),
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
};
}
/**
* Playlists
*/
@@ -328,7 +352,7 @@ export interface DumpUpvotedData {
export interface UserMentionedData {
mentionerId: string;
mentionerUsername: string;
contextType: "comment" | "dump" | "playlist";
contextType: "comment" | "dump" | "playlist" | "chat";
contextId: string;
contextTitle: string;
dumpId?: string;
@@ -498,6 +522,21 @@ export interface WSForceLogoutMessage {
type: "force_logout";
}
export interface WSChatMessageMessage {
type: "chat_message";
message: RawChatMessage;
}
export interface WSChatMessageUpdatedMessage {
type: "chat_message_updated";
message: RawChatMessage;
}
export interface WSChatMessageDeletedMessage {
type: "chat_message_deleted";
id: string;
}
export type IncomingWSMessage =
| WSPingMessage
| WSWelcomeMessage
@@ -519,7 +558,10 @@ export type IncomingWSMessage =
| WSCommentDeletedMessage
| WSNotificationCreatedMessage
| WSErrorMessage
| WSForceLogoutMessage;
| WSForceLogoutMessage
| WSChatMessageMessage
| WSChatMessageUpdatedMessage
| WSChatMessageDeletedMessage;
/**
* WebSocket messages — client → server (outgoing)
@@ -545,12 +587,37 @@ export interface WSCommentLikeRemoveMessage {
commentId: string;
}
export interface WSChatSendMessage {
type: "chat_send";
body: string;
}
export interface WSChatFocusMessage {
type: "chat_focus";
open: boolean;
}
export interface WSChatEditMessage {
type: "chat_edit";
id: string;
body: string;
}
export interface WSChatDeleteMessage {
type: "chat_delete";
id: string;
}
export type OutgoingWSMessage =
| WSPongMessage
| WSVoteCastMessage
| WSVoteRemoveMessage
| WSCommentLikeCastMessage
| WSCommentLikeRemoveMessage;
| WSCommentLikeRemoveMessage
| WSChatSendMessage
| WSChatFocusMessage
| WSChatEditMessage
| WSChatDeleteMessage;
/**
* Follows

View File

@@ -132,6 +132,7 @@ function notificationLink(n: Notification): string {
return `/dumps/${d.dumpId}#comment-${d.contextId}`;
}
if (d.contextType === "dump") return `/dumps/${d.contextId}`;
if (d.contextType === "chat") return `/?chat=open`;
return `/playlists/${d.contextId}`;
}
}
@@ -212,7 +213,11 @@ function notificationContent(n: Notification): React.ReactNode {
case "user_mentioned": {
const d = data as UserMentionedData;
const where = d.contextTitle ||
(d.contextType === "comment" ? t`a comment` : t`a post`);
(d.contextType === "comment"
? t`a comment`
: d.contextType === "chat"
? t`the chat`
: t`a post`);
return (
<Trans>
<strong>{d.mentionerUsername}</strong>

View File

@@ -151,11 +151,12 @@
/* ── Buttons — square corners, modest hard shadow ────────────────── */
/* `.dump-fab` is exempt from the generic button chrome — it keeps its own
shape, shadow and animations — but this hard-edged theme squares it off. */
[data-style="brutalist"] .dump-fab {
[data-style="brutalist"] .dump-fab,
[data-style="brutalist"] .chat-fab {
--fab-radius: 0;
}
[data-style="brutalist"] button:not(.dump-fab) {
[data-style="brutalist"] button:not(.dump-fab, .chat-fab) {
border-radius: 0;
border: 2px solid var(--color-border);
font-weight: 700;
@@ -163,7 +164,7 @@
transition: box-shadow 0.08s ease, transform 0.08s ease, border-color 0.1s;
}
[data-style="brutalist"] button:not(.dump-fab):hover:not(:disabled) {
[data-style="brutalist"] button:not(.dump-fab, .chat-fab):hover:not(:disabled) {
border-color: var(--color-border);
box-shadow: none;
transform: translate(2px, 2px);

View File

@@ -254,11 +254,12 @@
/* ── Buttons — chunky Win95 outset bevel, press = inset ──────────── */
/* `.dump-fab` is exempt from the generic button chrome — it keeps its own
shape, glow and animations — but this squared-off retro theme squares it. */
[data-style="geocities"] .dump-fab {
[data-style="geocities"] .dump-fab,
[data-style="geocities"] .chat-fab {
--fab-radius: 0;
}
[data-style="geocities"] button:not(.dump-fab) {
[data-style="geocities"] button:not(.dump-fab, .chat-fab) {
border-radius: 0;
border: 3px outset var(--color-border);
box-shadow: none;
@@ -325,7 +326,10 @@
[data-style="geocities"] .visibility-toggle button {
border-radius: 0;
border: none;
/* Zero the width (not just the style): the generic `button:active` rule flips
border-style to `inset`, and a `none` border keeps its `medium` width — so
on press a 3px border would reappear inside the toggle's own frame. */
border: 0;
}
/* ── Cards & panels — ridged "table border" with a soft neon halo ─── */

View File

@@ -150,7 +150,7 @@
}
/* Sharp, rectilinear corners throughout — newsprint has no rounding. */
[data-style="nyt"] button:not(.dump-fab),
[data-style="nyt"] button:not(.dump-fab, .chat-fab),
[data-style="nyt"] .dump-card,
[data-style="nyt"] .playlist-card,
[data-style="nyt"] .journal-card,
@@ -184,7 +184,8 @@
/* This theme squares everything off — opt the FAB into a square too (it keeps
its animations; only the corners change). */
[data-style="nyt"] .dump-fab {
[data-style="nyt"] .dump-fab,
[data-style="nyt"] .chat-fab {
--fab-radius: 0;
}

View File

@@ -5,6 +5,7 @@ export type Permission =
| "dump:moderate"
| "comment:moderate"
| "playlist:moderate"
| "chat:moderate"
| "user:manage"
| "category:manage";
@@ -12,13 +13,19 @@ const ALL_PERMISSIONS: readonly Permission[] = [
"dump:moderate",
"comment:moderate",
"playlist:moderate",
"chat:moderate",
"user:manage",
"category:manage",
];
const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
admin: ALL_PERMISSIONS,
moderator: ["dump:moderate", "comment:moderate", "playlist:moderate"],
moderator: [
"dump:moderate",
"comment:moderate",
"playlist:moderate",
"chat:moderate",
],
user: [],
};