v3: chatbox improvements (typing indicators, answer to previous messages, backlog days separators, scroll to last message position, visual tweaks)
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
This commit is contained in:
@@ -7,6 +7,7 @@ import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts";
|
|||||||
import { up as up0006Categories } from "./migrations/0006_categories.ts";
|
import { up as up0006Categories } from "./migrations/0006_categories.ts";
|
||||||
import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.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 up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
|
||||||
|
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,6 +26,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ name: "0006_categories", up: up0006Categories },
|
{ name: "0006_categories", up: up0006Categories },
|
||||||
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
|
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
|
||||||
{ name: "0008_chat_messages", up: up0008ChatMessages },
|
{ name: "0008_chat_messages", up: up0008ChatMessages },
|
||||||
|
{ name: "0009_chat_reply", up: up0009ChatReply },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: DatabaseSync): void {
|
export function runMigrations(db: DatabaseSync): void {
|
||||||
|
|||||||
17
api/db/migrations/0009_chat_reply.ts
Normal file
17
api/db/migrations/0009_chat_reply.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
// Adds an optional self-reference so a chat message can reply to another.
|
||||||
|
// Kept as a plain column (no FK): when the replied-to message is later deleted
|
||||||
|
// the id is retained but the join yields null, letting the UI show a subtle
|
||||||
|
// "deleted message" reference instead of breaking the insert.
|
||||||
|
//
|
||||||
|
// Idempotent: also runs against fresh databases created from schema.sql, where
|
||||||
|
// the column already exists.
|
||||||
|
export function up(db: DatabaseSync): void {
|
||||||
|
const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as {
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
if (!cols.some((c) => c.name === "reply_to_id")) {
|
||||||
|
db.exec(`ALTER TABLE chat_messages ADD COLUMN reply_to_id TEXT;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -199,6 +199,7 @@ CREATE TABLE chat_messages (
|
|||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
|
reply_to_id TEXT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_chat_messages_created ON chat_messages(created_at);
|
CREATE INDEX idx_chat_messages_created ON chat_messages(created_at);
|
||||||
|
|||||||
@@ -313,6 +313,12 @@ export interface ChatMessageRow {
|
|||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
author_username: string;
|
author_username: string;
|
||||||
author_avatar_mime: string | null;
|
author_avatar_mime: string | null;
|
||||||
|
// Reply columns: reply_to_id is the stored target; the author/body preview are
|
||||||
|
// LEFT-JOINed from the target and go null when there's no reply (or it was
|
||||||
|
// since deleted).
|
||||||
|
reply_to_id: string | null;
|
||||||
|
reply_to_author: string | null;
|
||||||
|
reply_to_body: string | null;
|
||||||
[key: string]: SQLOutputValue;
|
[key: string]: SQLOutputValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +333,13 @@ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
|
|||||||
"author_username" in obj && typeof obj.author_username === "string" &&
|
"author_username" in obj && typeof obj.author_username === "string" &&
|
||||||
"author_avatar_mime" in obj &&
|
"author_avatar_mime" in obj &&
|
||||||
(typeof obj.author_avatar_mime === "string" ||
|
(typeof obj.author_avatar_mime === "string" ||
|
||||||
obj.author_avatar_mime === null);
|
obj.author_avatar_mime === null) &&
|
||||||
|
"reply_to_id" in obj &&
|
||||||
|
(typeof obj.reply_to_id === "string" || obj.reply_to_id === null) &&
|
||||||
|
"reply_to_author" in obj &&
|
||||||
|
(typeof obj.reply_to_author === "string" || obj.reply_to_author === null) &&
|
||||||
|
"reply_to_body" in obj &&
|
||||||
|
(typeof obj.reply_to_body === "string" || obj.reply_to_body === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
|
export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
|
||||||
@@ -339,6 +351,9 @@ export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
|
|||||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||||
authorUsername: row.author_username,
|
authorUsername: row.author_username,
|
||||||
authorAvatarMime: row.author_avatar_mime ?? undefined,
|
authorAvatarMime: row.author_avatar_mime ?? undefined,
|
||||||
|
replyToId: row.reply_to_id ?? undefined,
|
||||||
|
replyToAuthor: row.reply_to_author ?? undefined,
|
||||||
|
replyToBody: row.reply_to_body ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,13 @@ export interface ChatMessage {
|
|||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
authorUsername: string;
|
authorUsername: string;
|
||||||
authorAvatarMime?: string;
|
authorAvatarMime?: string;
|
||||||
|
/** Id of the message this one replies to, if any. Retained even when the
|
||||||
|
* target is later deleted (the preview fields below then go undefined). */
|
||||||
|
replyToId?: string;
|
||||||
|
/** Author/snippet of the replied-to message, for a subtle inline reference.
|
||||||
|
* Absent when there's no reply, or the target has since been deleted. */
|
||||||
|
replyToAuthor?: string;
|
||||||
|
replyToBody?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wire format — createdAt arrives as an ISO string over JSON. */
|
/** Wire format — createdAt arrives as an ISO string over JSON. */
|
||||||
@@ -554,6 +561,15 @@ export interface CommentLikeRemoveMessage {
|
|||||||
export interface ChatSendMessage {
|
export interface ChatSendMessage {
|
||||||
type: "chat_send";
|
type: "chat_send";
|
||||||
body: string;
|
body: string;
|
||||||
|
/** Id of the message being replied to, if this is a reply. */
|
||||||
|
replyToId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent while the user is composing (true) and when they stop (false). The
|
||||||
|
// server rebroadcasts the live set of typers to everyone as ChatTypingUpdate.
|
||||||
|
export interface ChatTypingMessage {
|
||||||
|
type: "chat_typing";
|
||||||
|
typing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tells the server whether this client currently has the chatbox open, so that
|
// Tells the server whether this client currently has the chatbox open, so that
|
||||||
@@ -582,6 +598,7 @@ export type ClientToServerMessage =
|
|||||||
| CommentLikeCastMessage
|
| CommentLikeCastMessage
|
||||||
| CommentLikeRemoveMessage
|
| CommentLikeRemoveMessage
|
||||||
| ChatSendMessage
|
| ChatSendMessage
|
||||||
|
| ChatTypingMessage
|
||||||
| ChatFocusMessage
|
| ChatFocusMessage
|
||||||
| ChatEditMessage
|
| ChatEditMessage
|
||||||
| ChatDeleteMessage;
|
| ChatDeleteMessage;
|
||||||
@@ -730,6 +747,13 @@ export interface ChatMessageDeletedMessage {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The current set of users composing a chat message. Reuses OnlineUser so the
|
||||||
|
// client can render the same avatars as the presence row.
|
||||||
|
export interface ChatTypingUpdateMessage {
|
||||||
|
type: "chat_typing_update";
|
||||||
|
users: OnlineUser[];
|
||||||
|
}
|
||||||
|
|
||||||
export type ServerToClientMessage =
|
export type ServerToClientMessage =
|
||||||
| PingMessage
|
| PingMessage
|
||||||
| WelcomeMessage
|
| WelcomeMessage
|
||||||
@@ -754,7 +778,8 @@ export type ServerToClientMessage =
|
|||||||
| ForceLogoutMessage
|
| ForceLogoutMessage
|
||||||
| ChatMessageMessage
|
| ChatMessageMessage
|
||||||
| ChatMessageUpdatedMessage
|
| ChatMessageUpdatedMessage
|
||||||
| ChatMessageDeletedMessage;
|
| ChatMessageDeletedMessage
|
||||||
|
| ChatTypingUpdateMessage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Follows
|
* Follows
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import {
|
|||||||
broadcastChatMessageUpdated,
|
broadcastChatMessageUpdated,
|
||||||
broadcastCommentLikeUpdate,
|
broadcastCommentLikeUpdate,
|
||||||
broadcastPresence,
|
broadcastPresence,
|
||||||
|
broadcastTyping,
|
||||||
broadcastVoteUpdate,
|
broadcastVoteUpdate,
|
||||||
getOnlineUsers,
|
getOnlineUsers,
|
||||||
handleClientPong,
|
handleClientPong,
|
||||||
isUserChatOpen,
|
isUserChatOpen,
|
||||||
register,
|
register,
|
||||||
|
setClientTyping,
|
||||||
unregister,
|
unregister,
|
||||||
type WsClient,
|
type WsClient,
|
||||||
} from "../services/ws-service.ts";
|
} from "../services/ws-service.ts";
|
||||||
@@ -127,7 +129,10 @@ router.get("/ws", async (ctx) => {
|
|||||||
handleCommentLike(client, msg.commentId, "remove");
|
handleCommentLike(client, msg.commentId, "remove");
|
||||||
break;
|
break;
|
||||||
case "chat_send":
|
case "chat_send":
|
||||||
handleChatSend(client, msg.body);
|
handleChatSend(client, msg.body, msg.replyToId);
|
||||||
|
break;
|
||||||
|
case "chat_typing":
|
||||||
|
if (client.userId) setClientTyping(client, msg.typing);
|
||||||
break;
|
break;
|
||||||
case "chat_focus":
|
case "chat_focus":
|
||||||
client.chatOpen = msg.open;
|
client.chatOpen = msg.open;
|
||||||
@@ -144,6 +149,8 @@ router.get("/ws", async (ctx) => {
|
|||||||
socket.addEventListener("close", () => {
|
socket.addEventListener("close", () => {
|
||||||
unregister(client);
|
unregister(client);
|
||||||
broadcastPresence();
|
broadcastPresence();
|
||||||
|
// If they were mid-compose, drop them from the typing set right away.
|
||||||
|
if (client.typingAt) broadcastTyping();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,7 +221,11 @@ function handleCommentLike(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChatSend(client: WsClient, body: string): void {
|
function handleChatSend(
|
||||||
|
client: WsClient,
|
||||||
|
body: string,
|
||||||
|
replyToId?: string,
|
||||||
|
): void {
|
||||||
const { socket } = client;
|
const { socket } = client;
|
||||||
|
|
||||||
if (!client.userId) {
|
if (!client.userId) {
|
||||||
@@ -225,7 +236,10 @@ function handleChatSend(client: WsClient, body: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = createChatMessage(client.userId, body);
|
const message = createChatMessage(client.userId, body, replyToId);
|
||||||
|
// Sending a message ends composition — clear the typing flag before the
|
||||||
|
// message broadcast so the indicator doesn't linger next to it.
|
||||||
|
setClientTyping(client, false);
|
||||||
// Broadcast to everyone including the sender, so the sender's own message
|
// Broadcast to everyone including the sender, so the sender's own message
|
||||||
// simply arrives over the socket — no optimistic-send machinery needed.
|
// simply arrives over the socket — no optimistic-send machinery needed.
|
||||||
broadcastChatMessage(message);
|
broadcastChatMessage(message);
|
||||||
|
|||||||
@@ -9,13 +9,26 @@ export const MAX_CHAT_LENGTH = 2000;
|
|||||||
const DEFAULT_LIMIT = 50;
|
const DEFAULT_LIMIT = 50;
|
||||||
const MAX_LIMIT = 100;
|
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;
|
||||||
|
|
||||||
const SELECT_COLS =
|
const SELECT_COLS =
|
||||||
`m.id, m.user_id, m.body, m.created_at, m.updated_at,
|
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
|
||||||
u.username as author_username, u.avatar_mime as author_avatar_mime`;
|
u.username as author_username, u.avatar_mime as author_avatar_mime,
|
||||||
|
ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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`;
|
||||||
|
|
||||||
function fetchMessage(id: string): ChatMessage {
|
function fetchMessage(id: string): ChatMessage {
|
||||||
const row = db.prepare(
|
const row = db.prepare(
|
||||||
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?;`,
|
`SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`,
|
||||||
).get(id);
|
).get(id);
|
||||||
if (!row || !isChatMessageRow(row)) {
|
if (!row || !isChatMessageRow(row)) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found");
|
||||||
@@ -23,7 +36,11 @@ function fetchMessage(id: string): ChatMessage {
|
|||||||
return chatMessageRowToApi(row);
|
return chatMessageRowToApi(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createChatMessage(userId: string, body: string): ChatMessage {
|
export function createChatMessage(
|
||||||
|
userId: string,
|
||||||
|
body: string,
|
||||||
|
replyToId?: string,
|
||||||
|
): ChatMessage {
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
@@ -39,11 +56,20 @@ export function createChatMessage(userId: string, body: string): ChatMessage {
|
|||||||
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
|
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Only store the reply target if it actually exists — drop dangling ids so a
|
||||||
|
// message never references a phantom parent.
|
||||||
|
let replyTo: string | null = null;
|
||||||
|
if (replyToId) {
|
||||||
|
const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get(
|
||||||
|
replyToId,
|
||||||
|
);
|
||||||
|
if (exists) replyTo = replyToId;
|
||||||
|
}
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO chat_messages (id, user_id, body, created_at) VALUES (?, ?, ?, ?);`,
|
`INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`,
|
||||||
).run(id, userId, trimmed, createdAt);
|
).run(id, userId, trimmed, createdAt, replyTo);
|
||||||
return fetchMessage(id);
|
return fetchMessage(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,11 +143,11 @@ export function getRecentChatMessages(
|
|||||||
const capped = Math.min(Math.max(limit, 1), MAX_LIMIT);
|
const capped = Math.min(Math.max(limit, 1), MAX_LIMIT);
|
||||||
const rows = before
|
const rows = before
|
||||||
? db.prepare(
|
? db.prepare(
|
||||||
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id
|
`SELECT ${SELECT_COLS} ${FROM_JOINS}
|
||||||
WHERE m.created_at < ? ORDER BY m.created_at DESC LIMIT ?;`,
|
WHERE m.created_at < ? ORDER BY m.created_at DESC LIMIT ?;`,
|
||||||
).all(before, capped)
|
).all(before, capped)
|
||||||
: db.prepare(
|
: db.prepare(
|
||||||
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id
|
`SELECT ${SELECT_COLS} ${FROM_JOINS}
|
||||||
ORDER BY m.created_at DESC LIMIT ?;`,
|
ORDER BY m.created_at DESC LIMIT ?;`,
|
||||||
).all(capped);
|
).all(capped);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export interface WsClient {
|
|||||||
pongReceived?: boolean;
|
pongReceived?: boolean;
|
||||||
/** Whether this client currently has the chatbox open (is reading chat). */
|
/** Whether this client currently has the chatbox open (is reading chat). */
|
||||||
chatOpen?: boolean;
|
chatOpen?: boolean;
|
||||||
|
/** Timestamp (ms) of the client's last "typing" signal; undefined when not
|
||||||
|
* composing. Entries older than TYPING_TTL are treated as stale. */
|
||||||
|
typingAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clients = new Set<WsClient>();
|
const clients = new Set<WsClient>();
|
||||||
@@ -233,6 +236,64 @@ export function broadcastChatMessageDeleted(id: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Typing indicator: a client's "typing" signal is considered live for this long
|
||||||
|
// after its last refresh. Must exceed the client's typing-ping cadence so a
|
||||||
|
// steady typist never flickers out between pings.
|
||||||
|
const TYPING_TTL = 6_000;
|
||||||
|
|
||||||
|
// Deduped set of users currently composing a chat message (typing signal within
|
||||||
|
// the TTL), shaped like OnlineUser so the client renders the same avatars.
|
||||||
|
export function getTypingUsers(): OnlineUser[] {
|
||||||
|
const now = Date.now();
|
||||||
|
const seen = new Map<string, OnlineUser>();
|
||||||
|
for (const client of clients) {
|
||||||
|
if (
|
||||||
|
client.userId && client.typingAt && now - client.typingAt < TYPING_TTL &&
|
||||||
|
!seen.has(client.userId)
|
||||||
|
) {
|
||||||
|
seen.set(client.userId, {
|
||||||
|
userId: client.userId,
|
||||||
|
username: client.username!,
|
||||||
|
hasAvatar: !!client.avatarMime,
|
||||||
|
avatarVersion: client.avatarVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(seen.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastTyping(): void {
|
||||||
|
const users = getTypingUsers();
|
||||||
|
for (const client of clients) {
|
||||||
|
send(client.socket, { type: "chat_typing_update", users });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record (or clear) a client's typing state and tell everyone. The client also
|
||||||
|
// resends "true" periodically while composing; the sweep below expires it if
|
||||||
|
// they go quiet without a clean "false" (e.g. they closed the tab).
|
||||||
|
export function setClientTyping(client: WsClient, typing: boolean): void {
|
||||||
|
const wasTyping = !!client.typingAt;
|
||||||
|
client.typingAt = typing ? Date.now() : undefined;
|
||||||
|
// Only rebroadcast on an actual edge to avoid a flood of identical updates
|
||||||
|
// from the periodic typing pings.
|
||||||
|
if (typing !== wasTyping) broadcastTyping();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sweep stale typing entries (clients that stopped pinging without a clean
|
||||||
|
// stop) and rebroadcast when the visible set shrinks.
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
let changed = false;
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.typingAt && now - client.typingAt >= TYPING_TTL) {
|
||||||
|
client.typingAt = undefined;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) broadcastTyping();
|
||||||
|
}, 2_000);
|
||||||
|
|
||||||
// True if the user has the chatbox open on any of their connected clients — i.e.
|
// 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.
|
// they're already reading chat, so a mention notification would be redundant.
|
||||||
export function isUserChatOpen(userId: string): boolean {
|
export function isUserChatOpen(userId: string): boolean {
|
||||||
|
|||||||
214
src/App.css
214
src/App.css
@@ -3292,12 +3292,14 @@ body.has-player .chat-fab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Live chat ── */
|
/* ── Live chat ── */
|
||||||
|
/* The chat fills the (now taller) modal so it makes better use of the viewport
|
||||||
|
on desktop — see the .modal-card:has(.chat) height override below. */
|
||||||
.chat {
|
.chat {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: min(60vh, 480px);
|
height: 100%;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3306,12 +3308,13 @@ body.has-player .chat-fab {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
/* Reserve a gutter for the scrollbar so it never sits on top of the
|
/* Reserve a gutter for the scrollbar so it never sits on top of the
|
||||||
messages — without this, overlay scrollbars overlap the content. */
|
messages — without this, overlay scrollbars overlap the content. The extra
|
||||||
|
padding keeps the text clear of the scrollbar rather than right against it. */
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
padding-right: 0.5rem;
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-empty {
|
.chat-empty {
|
||||||
@@ -3352,6 +3355,40 @@ body.has-player .chat-fab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Brief highlight when a message is jumped to via a reply reference. */
|
||||||
|
.chat-message {
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message--flash {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle day separator: a hairline rule with a centered, muted label. */
|
||||||
|
.chat-day-sep {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-day-sep::before,
|
||||||
|
.chat-day-sep::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-day-sep-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-message-body {
|
.chat-message-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -3497,6 +3534,177 @@ body.has-player .chat-fab {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Reply reference (subtle, above a message's body) ── */
|
||||||
|
.chat-reply-ref {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-ref:disabled {
|
||||||
|
cursor: default;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-ref:not(:disabled):hover .chat-reply-ref-author,
|
||||||
|
.chat-reply-ref:not(:disabled):hover .chat-reply-ref-text {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-ref-mark {
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-ref-author {
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-ref-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.85;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reply composer bar (subtle, above the input) ── */
|
||||||
|
.chat-reply-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border-left: 2px solid var(--color-accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-bar-mark {
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-bar-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-bar-author {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-bar-snippet {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-bar-cancel {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── "Is typing" indicator (reuses presence avatars) ── */
|
||||||
|
.chat-typing {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 1.25rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing-avatars {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlap multiple typers' avatars slightly, like a stacked presence cluster. */
|
||||||
|
.chat-typing-avatar:not(:first-child) {
|
||||||
|
margin-left: -0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing-avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing-dots {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing-dots span {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.4;
|
||||||
|
animation: chat-typing-bounce 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing-dots span:nth-child(2) {
|
||||||
|
animation-delay: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing-dots span:nth-child(3) {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes chat-typing-bounce {
|
||||||
|
0%, 60%, 100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.chat-typing-dots span {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Give the chat a tall, stable panel so it uses the viewport well on desktop
|
||||||
|
instead of floating as a small box. Bounded by max-height so it never spills
|
||||||
|
off short screens; the inner message list scrolls within it. */
|
||||||
|
.modal-card:has(.chat) {
|
||||||
|
height: min(86vh, 820px);
|
||||||
|
max-height: 92vh;
|
||||||
|
}
|
||||||
|
|
||||||
/* The composer's popovers (mention list, emoji picker) anchor to the input,
|
/* 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
|
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
|
— let them escape the body entirely so they're never cropped. The message
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
@@ -12,6 +19,7 @@ import { useWS } from "../hooks/useWS.ts";
|
|||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { can } from "../utils/permissions.ts";
|
import { can } from "../utils/permissions.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
|
import { i18n } from "../i18n.ts";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import {
|
import {
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
@@ -24,17 +32,40 @@ import {
|
|||||||
const MAX_CHAT_LENGTH = 2000;
|
const MAX_CHAT_LENGTH = 2000;
|
||||||
// Matches the server's default page size; a full page means there may be more.
|
// Matches the server's default page size; a full page means there may be more.
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
// How long the composer stays "typing" after the last keystroke, and how often
|
||||||
|
// we refresh that signal while typing continuously. The refresh interval must
|
||||||
|
// stay below the server's TYPING_TTL so a steady typist never flickers out.
|
||||||
|
const TYPING_STOP_DELAY = 3_500;
|
||||||
|
const TYPING_REFRESH = 3_000;
|
||||||
|
|
||||||
|
interface ReplyTarget {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatMessageItemProps {
|
interface ChatMessageItemProps {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
currentUser: PublicUser | null;
|
currentUser: PublicUser | null;
|
||||||
canModerate: boolean;
|
canModerate: boolean;
|
||||||
|
flashed: boolean;
|
||||||
onEdit: (id: string, body: string) => void;
|
onEdit: (id: string, body: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
onReply: (message: ChatMessage) => void;
|
||||||
|
onJumpTo: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatMessageItem(
|
function ChatMessageItem(
|
||||||
{ message, currentUser, canModerate, onEdit, onDelete }: ChatMessageItemProps,
|
{
|
||||||
|
message,
|
||||||
|
currentUser,
|
||||||
|
canModerate,
|
||||||
|
flashed,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onReply,
|
||||||
|
onJumpTo,
|
||||||
|
}: ChatMessageItemProps,
|
||||||
) {
|
) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editDraft, setEditDraft] = useState(message.body);
|
const [editDraft, setEditDraft] = useState(message.body);
|
||||||
@@ -61,7 +92,10 @@ function ChatMessageItem(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-message">
|
<div
|
||||||
|
className={`chat-message${flashed ? " chat-message--flash" : ""}`}
|
||||||
|
data-mid={message.id}
|
||||||
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
userId={message.userId}
|
userId={message.userId}
|
||||||
username={message.authorUsername}
|
username={message.authorUsername}
|
||||||
@@ -69,6 +103,34 @@ function ChatMessageItem(
|
|||||||
size={32}
|
size={32}
|
||||||
/>
|
/>
|
||||||
<div className="chat-message-body">
|
<div className="chat-message-body">
|
||||||
|
{message.replyToId && (
|
||||||
|
// Subtle one-line reference to the replied-to message; clicking jumps
|
||||||
|
// to it. Falls back to a muted note when the target was deleted.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-reply-ref"
|
||||||
|
onClick={() => onJumpTo(message.replyToId!)}
|
||||||
|
disabled={!message.replyToAuthor}
|
||||||
|
>
|
||||||
|
<span className="chat-reply-ref-mark">↩</span>
|
||||||
|
{message.replyToAuthor
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<span className="chat-reply-ref-author">
|
||||||
|
{message.replyToAuthor}
|
||||||
|
</span>
|
||||||
|
<span className="chat-reply-ref-text">
|
||||||
|
{message.replyToBody}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span className="chat-reply-ref-text">
|
||||||
|
<Trans>deleted message</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="chat-message-meta">
|
<div className="chat-message-meta">
|
||||||
<Link
|
<Link
|
||||||
to={`/users/${message.authorUsername}`}
|
to={`/users/${message.authorUsername}`}
|
||||||
@@ -91,8 +153,17 @@ function ChatMessageItem(
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!editing && (canEdit || canDelete) && (
|
{!editing && (
|
||||||
<span className="chat-message-actions">
|
<span className="chat-message-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-icon-btn"
|
||||||
|
onClick={() => onReply(message)}
|
||||||
|
title={t`Reply`}
|
||||||
|
aria-label={t`Reply`}
|
||||||
|
>
|
||||||
|
↩
|
||||||
|
</button>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -178,14 +249,38 @@ function ChatMessageItem(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSameDay(a: Date, b: Date): boolean {
|
||||||
|
return a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human label for a day separator: "Today"/"Yesterday" for the two most recent
|
||||||
|
// days, otherwise a localized full date.
|
||||||
|
function dayLabel(date: Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
if (isSameDay(date, now)) return t`Today`;
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(now.getDate() - 1);
|
||||||
|
if (isSameDay(date, yesterday)) return t`Yesterday`;
|
||||||
|
return date.toLocaleDateString(i18n.locale, {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: now.getFullYear() === date.getFullYear() ? undefined : "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatModal({ onClose }: { onClose: () => void }) {
|
export function ChatModal({ onClose }: { onClose: () => void }) {
|
||||||
const {
|
const {
|
||||||
chatMessages,
|
chatMessages,
|
||||||
deletedChatIds,
|
deletedChatIds,
|
||||||
|
typingUsers,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
editChatMessage,
|
editChatMessage,
|
||||||
deleteChatMessage,
|
deleteChatMessage,
|
||||||
clearUnreadChat,
|
clearUnreadChat,
|
||||||
|
setChatTyping,
|
||||||
setChatOpen,
|
setChatOpen,
|
||||||
} = useWS();
|
} = useWS();
|
||||||
const { authFetch, user } = useAuth();
|
const { authFetch, user } = useAuth();
|
||||||
@@ -195,12 +290,23 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
|||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
|
const [replyTarget, setReplyTarget] = useState<ReplyTarget | null>(null);
|
||||||
|
const [flashId, setFlashId] = useState<string | null>(null);
|
||||||
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const composerRef = useRef<TextEditorHandle>(null);
|
const composerRef = useRef<TextEditorHandle>(null);
|
||||||
// Whether the view is pinned to the bottom — controls auto-scroll on new
|
// Whether the view is pinned to the bottom — controls auto-scroll on new
|
||||||
// messages so reading older history isn't yanked away.
|
// messages so reading older history isn't yanked away.
|
||||||
const stickToBottomRef = useRef(true);
|
const stickToBottomRef = useRef(true);
|
||||||
|
// Set once the first history load has scrolled into place, so the layout
|
||||||
|
// effect can force the initial jump to the bottom even before the user has
|
||||||
|
// interacted.
|
||||||
|
const didInitialScrollRef = useRef(false);
|
||||||
|
// Typing-signal bookkeeping: timer that sends "stopped" after a pause, and
|
||||||
|
// the timestamp of the last "typing" refresh we sent.
|
||||||
|
const typingStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const lastTypingSentRef = useRef(0);
|
||||||
|
const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Focus the composer when the chat opens so you can type straight away.
|
// Focus the composer when the chat opens so you can type straight away.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -218,6 +324,12 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
|||||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||||
}, [history, chatMessages, deletedChatIds]);
|
}, [history, chatMessages, deletedChatIds]);
|
||||||
|
|
||||||
|
// Others (not me) currently composing — drives the typing indicator.
|
||||||
|
const typingOthers = useMemo(
|
||||||
|
() => typingUsers.filter((u) => u.userId !== user?.id),
|
||||||
|
[typingUsers, user?.id],
|
||||||
|
);
|
||||||
|
|
||||||
// Initial history load.
|
// Initial history load.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -250,12 +362,36 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
|||||||
return () => setChatOpen(false);
|
return () => setChatOpen(false);
|
||||||
}, [setChatOpen]);
|
}, [setChatOpen]);
|
||||||
|
|
||||||
// Auto-scroll to the bottom when pinned (initial load + new messages).
|
// Stop announcing "typing" when the chatbox closes, whatever state we're in.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stickToBottomRef.current) return;
|
return () => {
|
||||||
|
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
|
||||||
|
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
|
||||||
|
if (lastTypingSentRef.current) setChatTyping(false);
|
||||||
|
};
|
||||||
|
}, [setChatTyping]);
|
||||||
|
|
||||||
|
// Auto-scroll to the bottom when pinned (initial load + new messages + the
|
||||||
|
// typing row appearing). A layout effect runs before paint so there's no
|
||||||
|
// visible flash, and a follow-up frame catches late height changes (e.g. an
|
||||||
|
// avatar/image that resizes after first paint).
|
||||||
|
useLayoutEffect(() => {
|
||||||
const el = listRef.current;
|
const el = listRef.current;
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (!el) return;
|
||||||
}, [messages]);
|
// Force the very first positioning to the bottom even though no scrolling
|
||||||
|
// has happened yet, then honour the pin afterwards.
|
||||||
|
if (!didInitialScrollRef.current) {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
didInitialScrollRef.current = true;
|
||||||
|
} else if (!stickToBottomRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const node = listRef.current;
|
||||||
|
if (node && stickToBottomRef.current) node.scrollTop = node.scrollHeight;
|
||||||
|
});
|
||||||
|
}, [messages, typingOthers.length]);
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = listRef.current;
|
const el = listRef.current;
|
||||||
@@ -296,13 +432,102 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
|||||||
}
|
}
|
||||||
}, [authFetch, messages, loadingOlder]);
|
}, [authFetch, messages, loadingOlder]);
|
||||||
|
|
||||||
|
// Announce that the user is composing, throttled, and schedule a "stopped"
|
||||||
|
// signal once they pause. Called on every keystroke via the composer.
|
||||||
|
const pingTyping = useCallback(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTypingSentRef.current > TYPING_REFRESH) {
|
||||||
|
lastTypingSentRef.current = now;
|
||||||
|
setChatTyping(true);
|
||||||
|
}
|
||||||
|
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
|
||||||
|
typingStopTimerRef.current = setTimeout(() => {
|
||||||
|
lastTypingSentRef.current = 0;
|
||||||
|
setChatTyping(false);
|
||||||
|
}, TYPING_STOP_DELAY);
|
||||||
|
}, [setChatTyping]);
|
||||||
|
|
||||||
|
const stopTyping = useCallback(() => {
|
||||||
|
if (typingStopTimerRef.current) {
|
||||||
|
clearTimeout(typingStopTimerRef.current);
|
||||||
|
typingStopTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (lastTypingSentRef.current) {
|
||||||
|
lastTypingSentRef.current = 0;
|
||||||
|
setChatTyping(false);
|
||||||
|
}
|
||||||
|
}, [setChatTyping]);
|
||||||
|
|
||||||
|
const handleDraftChange = useCallback((value: string) => {
|
||||||
|
setDraft(value);
|
||||||
|
if (value.trim()) pingTyping();
|
||||||
|
else stopTyping();
|
||||||
|
}, [pingTyping, stopTyping]);
|
||||||
|
|
||||||
|
const startReply = useCallback((message: ChatMessage) => {
|
||||||
|
setReplyTarget({
|
||||||
|
id: message.id,
|
||||||
|
author: message.authorUsername,
|
||||||
|
body: message.body,
|
||||||
|
});
|
||||||
|
composerRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scroll a referenced message into view and flash it briefly. No-op if it's
|
||||||
|
// not currently loaded (older history the user hasn't paged in).
|
||||||
|
const jumpToMessage = useCallback((id: string) => {
|
||||||
|
const node = listRef.current?.querySelector<HTMLElement>(
|
||||||
|
`[data-mid="${CSS.escape(id)}"]`,
|
||||||
|
);
|
||||||
|
if (!node) return;
|
||||||
|
stickToBottomRef.current = false;
|
||||||
|
node.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||||
|
setFlashId(id);
|
||||||
|
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
|
||||||
|
flashTimerRef.current = setTimeout(() => setFlashId(null), 1_200);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
const body = draft.trim();
|
const body = draft.trim();
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
sendChatMessage(body);
|
sendChatMessage(body, replyTarget?.id);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
|
setReplyTarget(null);
|
||||||
|
stopTyping();
|
||||||
stickToBottomRef.current = true;
|
stickToBottomRef.current = true;
|
||||||
}, [draft, sendChatMessage]);
|
}, [draft, replyTarget, sendChatMessage, stopTyping]);
|
||||||
|
|
||||||
|
// Build the rendered stream, inserting a day separator whenever the calendar
|
||||||
|
// day changes between consecutive messages.
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const out: Array<
|
||||||
|
{ kind: "sep"; key: string; label: string } | {
|
||||||
|
kind: "msg";
|
||||||
|
key: string;
|
||||||
|
message: ChatMessage;
|
||||||
|
}
|
||||||
|
> = [];
|
||||||
|
let prev: Date | null = null;
|
||||||
|
for (const m of messages) {
|
||||||
|
if (!prev || !isSameDay(prev, m.createdAt)) {
|
||||||
|
out.push({
|
||||||
|
kind: "sep",
|
||||||
|
key: `sep-${m.id}`,
|
||||||
|
label: dayLabel(m.createdAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.push({ kind: "msg", key: m.id, message: m });
|
||||||
|
prev = m.createdAt;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const typingLabel = useMemo(() => {
|
||||||
|
const names = typingOthers.map((u) => u.username);
|
||||||
|
if (names.length === 1) return t`${names[0]} is typing…`;
|
||||||
|
if (names.length === 2) return t`${names[0]} and ${names[1]} are typing…`;
|
||||||
|
return t`Several people are typing…`;
|
||||||
|
}, [typingOthers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title={t`Chat`} onClose={onClose} wide>
|
<Modal title={t`Chat`} onClose={onClose} wide>
|
||||||
@@ -329,24 +554,81 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
|||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
messages.map((m) => (
|
rows.map((row) =>
|
||||||
|
row.kind === "sep"
|
||||||
|
? (
|
||||||
|
<div key={row.key} className="chat-day-sep">
|
||||||
|
<span className="chat-day-sep-label">{row.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
<ChatMessageItem
|
<ChatMessageItem
|
||||||
key={m.id}
|
key={row.key}
|
||||||
message={m}
|
message={row.message}
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
canModerate={canModerate}
|
canModerate={canModerate}
|
||||||
|
flashed={flashId === row.message.id}
|
||||||
onEdit={editChatMessage}
|
onEdit={editChatMessage}
|
||||||
onDelete={deleteChatMessage}
|
onDelete={deleteChatMessage}
|
||||||
|
onReply={startReply}
|
||||||
|
onJumpTo={jumpToMessage}
|
||||||
/>
|
/>
|
||||||
))
|
)
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{typingOthers.length > 0 && (
|
||||||
|
<div className="chat-typing" aria-live="polite">
|
||||||
|
<span className="chat-typing-avatars">
|
||||||
|
{typingOthers.slice(0, 3).map((u) => (
|
||||||
|
<span key={u.userId} className="chat-typing-avatar">
|
||||||
|
<Avatar
|
||||||
|
userId={u.userId}
|
||||||
|
username={u.username}
|
||||||
|
hasAvatar={u.hasAvatar}
|
||||||
|
size={20}
|
||||||
|
version={u.avatarVersion}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="chat-typing-label">{typingLabel}</span>
|
||||||
|
<span className="chat-typing-dots" aria-hidden="true">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{replyTarget && (
|
||||||
|
<div className="chat-reply-bar">
|
||||||
|
<span className="chat-reply-bar-mark">↩</span>
|
||||||
|
<span className="chat-reply-bar-text">
|
||||||
|
<Trans>Replying to</Trans>{" "}
|
||||||
|
<span className="chat-reply-bar-author">
|
||||||
|
{replyTarget.author}
|
||||||
|
</span>
|
||||||
|
<span className="chat-reply-bar-snippet">{replyTarget.body}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-icon-btn chat-reply-bar-cancel"
|
||||||
|
onClick={() => setReplyTarget(null)}
|
||||||
|
title={t`Cancel reply`}
|
||||||
|
aria-label={t`Cancel reply`}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="chat-composer">
|
<div className="chat-composer">
|
||||||
<TextEditor
|
<TextEditor
|
||||||
ref={composerRef}
|
ref={composerRef}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={setDraft}
|
onChange={handleDraftChange}
|
||||||
placeholder={t`Type a message…`}
|
placeholder={t`Type a message…`}
|
||||||
rows={2}
|
rows={2}
|
||||||
autoResize
|
autoResize
|
||||||
@@ -357,6 +639,10 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
|
|||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
handleSend();
|
||||||
|
} else if (e.key === "Escape" && replyTarget) {
|
||||||
|
// Cancel the reply first; a second Escape closes the modal.
|
||||||
|
e.preventDefault();
|
||||||
|
setReplyTarget(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export interface WSContextValue {
|
|||||||
* filter them out. */
|
* filter them out. */
|
||||||
deletedChatIds: Set<string>;
|
deletedChatIds: Set<string>;
|
||||||
unreadChatCount: number;
|
unreadChatCount: number;
|
||||||
|
/** Users currently composing a chat message (excludes nobody — the chatbox
|
||||||
|
* filters out the current user itself). Shaped like presence for avatars. */
|
||||||
|
typingUsers: OnlineUser[];
|
||||||
/** Increments on each WS reconnect so pages can backfill missed events. */
|
/** Increments on each WS reconnect so pages can backfill missed events. */
|
||||||
connectionEpoch: number;
|
connectionEpoch: number;
|
||||||
castVote: (dumpId: string) => void;
|
castVote: (dumpId: string) => void;
|
||||||
@@ -73,10 +76,13 @@ export interface WSContextValue {
|
|||||||
removeCommentLike: (commentId: string) => void;
|
removeCommentLike: (commentId: string) => void;
|
||||||
injectDump: (dump: Dump) => void;
|
injectDump: (dump: Dump) => void;
|
||||||
clearUnreadNotifications: () => void;
|
clearUnreadNotifications: () => void;
|
||||||
sendChatMessage: (body: string) => void;
|
sendChatMessage: (body: string, replyToId?: string) => void;
|
||||||
editChatMessage: (id: string, body: string) => void;
|
editChatMessage: (id: string, body: string) => void;
|
||||||
deleteChatMessage: (id: string) => void;
|
deleteChatMessage: (id: string) => void;
|
||||||
clearUnreadChat: () => void;
|
clearUnreadChat: () => void;
|
||||||
|
/** Inform the server whether the user is currently composing a message, so it
|
||||||
|
* can show/hide the "is typing" indicator to other clients. */
|
||||||
|
setChatTyping: (typing: boolean) => void;
|
||||||
/** Inform the server whether the chatbox is open (suppresses redundant
|
/** Inform the server whether the chatbox is open (suppresses redundant
|
||||||
* mention notifications while the user is reading chat). */
|
* mention notifications while the user is reading chat). */
|
||||||
setChatOpen: (open: boolean) => void;
|
setChatOpen: (open: boolean) => void;
|
||||||
@@ -104,6 +110,7 @@ export const WSContext = createContext<WSContextValue>({
|
|||||||
chatMessages: [],
|
chatMessages: [],
|
||||||
deletedChatIds: new Set(),
|
deletedChatIds: new Set(),
|
||||||
unreadChatCount: 0,
|
unreadChatCount: 0,
|
||||||
|
typingUsers: [],
|
||||||
connectionEpoch: 0,
|
connectionEpoch: 0,
|
||||||
castVote: () => {},
|
castVote: () => {},
|
||||||
removeVote: () => {},
|
removeVote: () => {},
|
||||||
@@ -115,5 +122,6 @@ export const WSContext = createContext<WSContextValue>({
|
|||||||
editChatMessage: () => {},
|
editChatMessage: () => {},
|
||||||
deleteChatMessage: () => {},
|
deleteChatMessage: () => {},
|
||||||
clearUnreadChat: () => {},
|
clearUnreadChat: () => {},
|
||||||
|
setChatTyping: () => {},
|
||||||
setChatOpen: () => {},
|
setChatOpen: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||||
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
|
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
|
||||||
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
||||||
|
const [typingUsers, setTypingUsers] = useState<OnlineUser[]>([]);
|
||||||
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
|
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
|
||||||
const [avatarOverrides, setAvatarOverrides] = useState<
|
const [avatarOverrides, setAvatarOverrides] = useState<
|
||||||
Record<string, AvatarOverride>
|
Record<string, AvatarOverride>
|
||||||
@@ -156,6 +157,7 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
setChatMessages([]);
|
setChatMessages([]);
|
||||||
setDeletedChatIds(new Set());
|
setDeletedChatIds(new Set());
|
||||||
setUnreadChatCount(0);
|
setUnreadChatCount(0);
|
||||||
|
setTypingUsers([]);
|
||||||
setAvatarOverrides({});
|
setAvatarOverrides({});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,6 +531,10 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "chat_typing_update":
|
||||||
|
setTypingUsers(msg.users);
|
||||||
|
break;
|
||||||
|
|
||||||
case "force_logout":
|
case "force_logout":
|
||||||
onForceLogoutRef.current();
|
onForceLogoutRef.current();
|
||||||
break;
|
break;
|
||||||
@@ -759,13 +765,17 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
setUnreadNotificationCount(0);
|
setUnreadNotificationCount(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sendChatMessage = useCallback((body: string) => {
|
const sendChatMessage = useCallback((body: string, replyToId?: string) => {
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
socketRef.current.send(
|
socketRef.current.send(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{ type: "chat_send", body: trimmed } satisfies OutgoingWSMessage,
|
{
|
||||||
|
type: "chat_send",
|
||||||
|
body: trimmed,
|
||||||
|
...(replyToId ? { replyToId } : {}),
|
||||||
|
} satisfies OutgoingWSMessage,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -797,6 +807,16 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
setUnreadChatCount(0);
|
setUnreadChatCount(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setChatTyping = useCallback((typing: boolean) => {
|
||||||
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
socketRef.current.send(
|
||||||
|
JSON.stringify(
|
||||||
|
{ type: "chat_typing", typing } satisfies OutgoingWSMessage,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setChatOpen = useCallback((open: boolean) => {
|
const setChatOpen = useCallback((open: boolean) => {
|
||||||
chatOpenRef.current = open;
|
chatOpenRef.current = open;
|
||||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
@@ -830,6 +850,7 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
chatMessages,
|
chatMessages,
|
||||||
deletedChatIds,
|
deletedChatIds,
|
||||||
unreadChatCount,
|
unreadChatCount,
|
||||||
|
typingUsers,
|
||||||
connectionEpoch,
|
connectionEpoch,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
@@ -841,6 +862,7 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
editChatMessage,
|
editChatMessage,
|
||||||
deleteChatMessage,
|
deleteChatMessage,
|
||||||
clearUnreadChat,
|
clearUnreadChat,
|
||||||
|
setChatTyping,
|
||||||
setChatOpen,
|
setChatOpen,
|
||||||
}), [
|
}), [
|
||||||
wsStatus,
|
wsStatus,
|
||||||
@@ -864,6 +886,7 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
chatMessages,
|
chatMessages,
|
||||||
deletedChatIds,
|
deletedChatIds,
|
||||||
unreadChatCount,
|
unreadChatCount,
|
||||||
|
typingUsers,
|
||||||
connectionEpoch,
|
connectionEpoch,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
@@ -875,6 +898,7 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
editChatMessage,
|
editChatMessage,
|
||||||
deleteChatMessage,
|
deleteChatMessage,
|
||||||
clearUnreadChat,
|
clearUnreadChat,
|
||||||
|
setChatTyping,
|
||||||
setChatOpen,
|
setChatOpen,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -28,6 +28,17 @@ msgstr "{0, plural, one {# comment} other {# comments}}"
|
|||||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||||
msgstr "{0, plural, one {# dump} other {# dumps}}"
|
msgstr "{0, plural, one {# dump} other {# dumps}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: names[0]
|
||||||
|
#. placeholder {1}: names[1]
|
||||||
|
#: src/components/ChatModal.tsx:528
|
||||||
|
msgid "{0} and {1} are typing…"
|
||||||
|
msgstr "{0} and {1} are typing…"
|
||||||
|
|
||||||
|
#. placeholder {0}: names[0]
|
||||||
|
#: src/components/ChatModal.tsx:527
|
||||||
|
msgid "{0} is typing…"
|
||||||
|
msgstr "{0} is typing…"
|
||||||
|
|
||||||
#. placeholder {0}: VALIDATION.USERNAME_MIN
|
#. placeholder {0}: VALIDATION.USERNAME_MIN
|
||||||
#. placeholder {1}: VALIDATION.USERNAME_MAX
|
#. placeholder {1}: VALIDATION.USERNAME_MAX
|
||||||
#: src/pages/UserRegister.tsx:126
|
#: src/pages/UserRegister.tsx:126
|
||||||
@@ -180,14 +191,14 @@ msgstr "Auto"
|
|||||||
msgid "Back to login"
|
msgid "Back to login"
|
||||||
msgstr "Back to login"
|
msgstr "Back to login"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:268
|
#: src/contexts/WSProvider.tsx:270
|
||||||
#: src/contexts/WSProvider.tsx:570
|
#: src/contexts/WSProvider.tsx:576
|
||||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
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."
|
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:210
|
||||||
#: src/components/CategoryManager.tsx:211
|
#: src/components/CategoryManager.tsx:211
|
||||||
#: src/components/ChatModal.tsx:158
|
#: src/components/ChatModal.tsx:229
|
||||||
#: src/components/CommentThread.tsx:124
|
#: src/components/CommentThread.tsx:124
|
||||||
#: src/components/ConfirmModal.tsx:32
|
#: src/components/ConfirmModal.tsx:32
|
||||||
#: src/components/form/FormActions.tsx:32
|
#: src/components/form/FormActions.tsx:32
|
||||||
@@ -203,6 +214,11 @@ msgstr "Cancel"
|
|||||||
msgid "Cancel removal"
|
msgid "Cancel removal"
|
||||||
msgstr "Cancel removal"
|
msgstr "Cancel removal"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:619
|
||||||
|
#: src/components/ChatModal.tsx:620
|
||||||
|
msgid "Cancel reply"
|
||||||
|
msgstr "Cancel reply"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:66
|
#: src/components/AppHeader.tsx:66
|
||||||
msgid "Cancel search"
|
msgid "Cancel search"
|
||||||
msgstr "Cancel search"
|
msgstr "Cancel search"
|
||||||
@@ -232,7 +248,7 @@ msgstr "Change password…"
|
|||||||
#: src/components/ChatButton.tsx:17
|
#: src/components/ChatButton.tsx:17
|
||||||
#: src/components/ChatFab.tsx:49
|
#: src/components/ChatFab.tsx:49
|
||||||
#: src/components/ChatFab.tsx:50
|
#: src/components/ChatFab.tsx:50
|
||||||
#: src/components/ChatModal.tsx:308
|
#: src/components/ChatModal.tsx:533
|
||||||
msgid "Chat"
|
msgid "Chat"
|
||||||
msgstr "Chat"
|
msgstr "Chat"
|
||||||
|
|
||||||
@@ -311,9 +327,9 @@ msgstr "Default tab"
|
|||||||
|
|
||||||
#: src/components/CategoryManager.tsx:221
|
#: src/components/CategoryManager.tsx:221
|
||||||
#: src/components/CategoryManager.tsx:222
|
#: src/components/CategoryManager.tsx:222
|
||||||
#: src/components/ChatModal.tsx:112
|
#: src/components/ChatModal.tsx:183
|
||||||
#: src/components/ChatModal.tsx:113
|
#: src/components/ChatModal.tsx:184
|
||||||
#: src/components/ChatModal.tsx:168
|
#: src/components/ChatModal.tsx:239
|
||||||
#: src/components/CommentThread.tsx:376
|
#: src/components/CommentThread.tsx:376
|
||||||
#: src/components/CommentThread.tsx:382
|
#: src/components/CommentThread.tsx:382
|
||||||
#: src/components/ConfirmModal.tsx:16
|
#: src/components/ConfirmModal.tsx:16
|
||||||
@@ -349,7 +365,7 @@ msgstr "Delete this comment?"
|
|||||||
msgid "Delete this dump? This cannot be undone."
|
msgid "Delete this dump? This cannot be undone."
|
||||||
msgstr "Delete this dump? This cannot be undone."
|
msgstr "Delete this dump? This cannot be undone."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:167
|
#: src/components/ChatModal.tsx:238
|
||||||
msgid "Delete this message?"
|
msgid "Delete this message?"
|
||||||
msgstr "Delete this message?"
|
msgstr "Delete this message?"
|
||||||
|
|
||||||
@@ -358,6 +374,10 @@ msgstr "Delete this message?"
|
|||||||
msgid "Delete this playlist? This cannot be undone."
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
msgstr "Delete this playlist? This cannot be undone."
|
msgstr "Delete this playlist? This cannot be undone."
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:129
|
||||||
|
msgid "deleted message"
|
||||||
|
msgstr "deleted message"
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:78
|
#: src/components/PlaylistCreateForm.tsx:78
|
||||||
#: src/pages/PlaylistDetail.tsx:877
|
#: src/pages/PlaylistDetail.tsx:877
|
||||||
msgid "Description (optional)"
|
msgid "Description (optional)"
|
||||||
@@ -403,8 +423,8 @@ msgstr "Dumps ({0}{1})"
|
|||||||
msgid "Earlier"
|
msgid "Earlier"
|
||||||
msgstr "Earlier"
|
msgstr "Earlier"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:101
|
#: src/components/ChatModal.tsx:172
|
||||||
#: src/components/ChatModal.tsx:102
|
#: src/components/ChatModal.tsx:173
|
||||||
#: src/components/CommentThread.tsx:367
|
#: src/components/CommentThread.tsx:367
|
||||||
#: src/pages/Dump.tsx:490
|
#: src/pages/Dump.tsx:490
|
||||||
#: src/pages/PlaylistDetail.tsx:625
|
#: src/pages/PlaylistDetail.tsx:625
|
||||||
@@ -423,7 +443,7 @@ msgstr "Edit title"
|
|||||||
#. placeholder {0}: relativeTime(comment.updatedAt)
|
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||||
#: src/components/ChatModal.tsx:90
|
#: src/components/ChatModal.tsx:152
|
||||||
#: src/components/CommentThread.tsx:317
|
#: src/components/CommentThread.tsx:317
|
||||||
#: src/pages/Dump.tsx:429
|
#: src/pages/Dump.tsx:429
|
||||||
#: src/pages/PlaylistDetail.tsx:664
|
#: src/pages/PlaylistDetail.tsx:664
|
||||||
@@ -433,7 +453,7 @@ msgstr "edited {0}"
|
|||||||
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||||
#: src/components/ChatModal.tsx:88
|
#: src/components/ChatModal.tsx:150
|
||||||
#: src/components/CommentThread.tsx:315
|
#: src/components/CommentThread.tsx:315
|
||||||
#: src/pages/Dump.tsx:427
|
#: src/pages/Dump.tsx:427
|
||||||
#: src/pages/PlaylistDetail.tsx:661
|
#: src/pages/PlaylistDetail.tsx:661
|
||||||
@@ -643,7 +663,7 @@ msgstr "Light"
|
|||||||
msgid "Like"
|
msgid "Like"
|
||||||
msgstr "Like"
|
msgstr "Like"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:569
|
#: src/contexts/WSProvider.tsx:575
|
||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
|
|
||||||
@@ -656,7 +676,7 @@ msgstr "Live updates unavailable."
|
|||||||
msgid "Load more"
|
msgid "Load more"
|
||||||
msgstr "Load more"
|
msgstr "Load more"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:321
|
#: src/components/ChatModal.tsx:546
|
||||||
msgid "Load older messages"
|
msgid "Load older messages"
|
||||||
msgstr "Load older messages"
|
msgstr "Load older messages"
|
||||||
|
|
||||||
@@ -686,7 +706,7 @@ msgid "Loading profile…"
|
|||||||
msgstr "Loading profile…"
|
msgstr "Loading profile…"
|
||||||
|
|
||||||
#: src/components/CategoryManager.tsx:52
|
#: src/components/CategoryManager.tsx:52
|
||||||
#: src/components/ChatModal.tsx:320
|
#: src/components/ChatModal.tsx:545
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||||
#: src/components/TextEditor.tsx:289
|
#: src/components/TextEditor.tsx:289
|
||||||
#: src/components/UserListPopover.tsx:192
|
#: src/components/UserListPopover.tsx:192
|
||||||
@@ -805,7 +825,7 @@ msgstr "No followed playlists yet."
|
|||||||
msgid "No invitees yet."
|
msgid "No invitees yet."
|
||||||
msgstr "No invitees yet."
|
msgstr "No invitees yet."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:328
|
#: src/components/ChatModal.tsx:553
|
||||||
msgid "No messages yet. Say hello!"
|
msgid "No messages yet. Say hello!"
|
||||||
msgstr "No messages yet. Say hello!"
|
msgstr "No messages yet. Say hello!"
|
||||||
|
|
||||||
@@ -989,10 +1009,16 @@ msgstr "Remove vote"
|
|||||||
msgid "Replace file"
|
msgid "Replace file"
|
||||||
msgstr "Replace file"
|
msgstr "Replace file"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:162
|
||||||
|
#: src/components/ChatModal.tsx:163
|
||||||
#: src/components/CommentThread.tsx:355
|
#: src/components/CommentThread.tsx:355
|
||||||
msgid "Reply"
|
msgid "Reply"
|
||||||
msgstr "Reply"
|
msgstr "Reply"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:609
|
||||||
|
msgid "Replying to"
|
||||||
|
msgstr "Replying to"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:132
|
#: src/pages/UserLogin.tsx:132
|
||||||
msgid "Request failed"
|
msgid "Request failed"
|
||||||
msgstr "Request failed"
|
msgstr "Request failed"
|
||||||
@@ -1019,7 +1045,7 @@ msgstr "Retry"
|
|||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr "Role"
|
msgstr "Role"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:151
|
#: src/components/ChatModal.tsx:222
|
||||||
#: src/components/CommentThread.tsx:328
|
#: src/components/CommentThread.tsx:328
|
||||||
#: src/pages/Dump.tsx:368
|
#: src/pages/Dump.tsx:368
|
||||||
#: src/pages/DumpEdit.tsx:466
|
#: src/pages/DumpEdit.tsx:466
|
||||||
@@ -1057,7 +1083,7 @@ msgstr "Search failed"
|
|||||||
msgid "Searching…"
|
msgid "Searching…"
|
||||||
msgstr "Searching…"
|
msgstr "Searching…"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:369
|
#: src/components/ChatModal.tsx:655
|
||||||
msgid "Send"
|
msgid "Send"
|
||||||
msgstr "Send"
|
msgstr "Send"
|
||||||
|
|
||||||
@@ -1082,6 +1108,10 @@ msgstr "Set new password"
|
|||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr "Settings"
|
msgstr "Settings"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:529
|
||||||
|
msgid "Several people are typing…"
|
||||||
|
msgstr "Several people are typing…"
|
||||||
|
|
||||||
#: src/components/CategoryManager.tsx:191
|
#: src/components/CategoryManager.tsx:191
|
||||||
msgid "slug"
|
msgid "slug"
|
||||||
msgstr "slug"
|
msgstr "slug"
|
||||||
@@ -1133,11 +1163,12 @@ msgstr "Title"
|
|||||||
msgid "Title is required."
|
msgid "Title is required."
|
||||||
msgstr "Title is required."
|
msgstr "Title is required."
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:262
|
||||||
#: src/pages/Notifications.tsx:397
|
#: src/pages/Notifications.tsx:397
|
||||||
msgid "Today"
|
msgid "Today"
|
||||||
msgstr "Today"
|
msgstr "Today"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:350
|
#: src/components/ChatModal.tsx:632
|
||||||
msgid "Type a message…"
|
msgid "Type a message…"
|
||||||
msgstr "Type a message…"
|
msgstr "Type a message…"
|
||||||
|
|
||||||
@@ -1240,6 +1271,7 @@ msgstr "Why?"
|
|||||||
msgid "Write a reply…"
|
msgid "Write a reply…"
|
||||||
msgstr "Write a reply…"
|
msgstr "Write a reply…"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:265
|
||||||
#: src/pages/Notifications.tsx:399
|
#: src/pages/Notifications.tsx:399
|
||||||
msgid "Yesterday"
|
msgid "Yesterday"
|
||||||
msgstr "Yesterday"
|
msgstr "Yesterday"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -28,6 +28,17 @@ msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
|||||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||||
msgstr "{0, plural, one {# reco} other {# recos}}"
|
msgstr "{0, plural, one {# reco} other {# recos}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: names[0]
|
||||||
|
#. placeholder {1}: names[1]
|
||||||
|
#: src/components/ChatModal.tsx:528
|
||||||
|
msgid "{0} and {1} are typing…"
|
||||||
|
msgstr "{0} et {1} sont en train d'écrire…"
|
||||||
|
|
||||||
|
#. placeholder {0}: names[0]
|
||||||
|
#: src/components/ChatModal.tsx:527
|
||||||
|
msgid "{0} is typing…"
|
||||||
|
msgstr "{0} est en train d'écrire…"
|
||||||
|
|
||||||
#. placeholder {0}: VALIDATION.USERNAME_MIN
|
#. placeholder {0}: VALIDATION.USERNAME_MIN
|
||||||
#. placeholder {1}: VALIDATION.USERNAME_MAX
|
#. placeholder {1}: VALIDATION.USERNAME_MAX
|
||||||
#: src/pages/UserRegister.tsx:126
|
#: src/pages/UserRegister.tsx:126
|
||||||
@@ -180,14 +191,14 @@ msgstr "Auto"
|
|||||||
msgid "Back to login"
|
msgid "Back to login"
|
||||||
msgstr "Retour à la connexion"
|
msgstr "Retour à la connexion"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:268
|
#: src/contexts/WSProvider.tsx:270
|
||||||
#: src/contexts/WSProvider.tsx:570
|
#: src/contexts/WSProvider.tsx:576
|
||||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
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."
|
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:210
|
||||||
#: src/components/CategoryManager.tsx:211
|
#: src/components/CategoryManager.tsx:211
|
||||||
#: src/components/ChatModal.tsx:158
|
#: src/components/ChatModal.tsx:229
|
||||||
#: src/components/CommentThread.tsx:124
|
#: src/components/CommentThread.tsx:124
|
||||||
#: src/components/ConfirmModal.tsx:32
|
#: src/components/ConfirmModal.tsx:32
|
||||||
#: src/components/form/FormActions.tsx:32
|
#: src/components/form/FormActions.tsx:32
|
||||||
@@ -203,6 +214,11 @@ msgstr "Annuler"
|
|||||||
msgid "Cancel removal"
|
msgid "Cancel removal"
|
||||||
msgstr "Annuler la suppression"
|
msgstr "Annuler la suppression"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:619
|
||||||
|
#: src/components/ChatModal.tsx:620
|
||||||
|
msgid "Cancel reply"
|
||||||
|
msgstr "Annuler la réponse"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:66
|
#: src/components/AppHeader.tsx:66
|
||||||
msgid "Cancel search"
|
msgid "Cancel search"
|
||||||
msgstr "Annuler la recherche"
|
msgstr "Annuler la recherche"
|
||||||
@@ -232,7 +248,7 @@ msgstr "Changer le mot de passe…"
|
|||||||
#: src/components/ChatButton.tsx:17
|
#: src/components/ChatButton.tsx:17
|
||||||
#: src/components/ChatFab.tsx:49
|
#: src/components/ChatFab.tsx:49
|
||||||
#: src/components/ChatFab.tsx:50
|
#: src/components/ChatFab.tsx:50
|
||||||
#: src/components/ChatModal.tsx:308
|
#: src/components/ChatModal.tsx:533
|
||||||
msgid "Chat"
|
msgid "Chat"
|
||||||
msgstr "Tribune"
|
msgstr "Tribune"
|
||||||
|
|
||||||
@@ -311,9 +327,9 @@ msgstr "Onglet par défaut"
|
|||||||
|
|
||||||
#: src/components/CategoryManager.tsx:221
|
#: src/components/CategoryManager.tsx:221
|
||||||
#: src/components/CategoryManager.tsx:222
|
#: src/components/CategoryManager.tsx:222
|
||||||
#: src/components/ChatModal.tsx:112
|
#: src/components/ChatModal.tsx:183
|
||||||
#: src/components/ChatModal.tsx:113
|
#: src/components/ChatModal.tsx:184
|
||||||
#: src/components/ChatModal.tsx:168
|
#: src/components/ChatModal.tsx:239
|
||||||
#: src/components/CommentThread.tsx:376
|
#: src/components/CommentThread.tsx:376
|
||||||
#: src/components/CommentThread.tsx:382
|
#: src/components/CommentThread.tsx:382
|
||||||
#: src/components/ConfirmModal.tsx:16
|
#: src/components/ConfirmModal.tsx:16
|
||||||
@@ -349,7 +365,7 @@ msgstr "Supprimer ce commentaire ?"
|
|||||||
msgid "Delete this dump? This cannot be undone."
|
msgid "Delete this dump? This cannot be undone."
|
||||||
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:167
|
#: src/components/ChatModal.tsx:238
|
||||||
msgid "Delete this message?"
|
msgid "Delete this message?"
|
||||||
msgstr "Supprimer ce message ?"
|
msgstr "Supprimer ce message ?"
|
||||||
|
|
||||||
@@ -358,6 +374,10 @@ msgstr "Supprimer ce message ?"
|
|||||||
msgid "Delete this playlist? This cannot be undone."
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:129
|
||||||
|
msgid "deleted message"
|
||||||
|
msgstr "message supprimé"
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:78
|
#: src/components/PlaylistCreateForm.tsx:78
|
||||||
#: src/pages/PlaylistDetail.tsx:877
|
#: src/pages/PlaylistDetail.tsx:877
|
||||||
msgid "Description (optional)"
|
msgid "Description (optional)"
|
||||||
@@ -403,8 +423,8 @@ msgstr "Recos ({0}{1})"
|
|||||||
msgid "Earlier"
|
msgid "Earlier"
|
||||||
msgstr "Plus tôt"
|
msgstr "Plus tôt"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:101
|
#: src/components/ChatModal.tsx:172
|
||||||
#: src/components/ChatModal.tsx:102
|
#: src/components/ChatModal.tsx:173
|
||||||
#: src/components/CommentThread.tsx:367
|
#: src/components/CommentThread.tsx:367
|
||||||
#: src/pages/Dump.tsx:490
|
#: src/pages/Dump.tsx:490
|
||||||
#: src/pages/PlaylistDetail.tsx:625
|
#: src/pages/PlaylistDetail.tsx:625
|
||||||
@@ -423,7 +443,7 @@ msgstr "Modifier le titre"
|
|||||||
#. placeholder {0}: relativeTime(comment.updatedAt)
|
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||||
#: src/components/ChatModal.tsx:90
|
#: src/components/ChatModal.tsx:152
|
||||||
#: src/components/CommentThread.tsx:317
|
#: src/components/CommentThread.tsx:317
|
||||||
#: src/pages/Dump.tsx:429
|
#: src/pages/Dump.tsx:429
|
||||||
#: src/pages/PlaylistDetail.tsx:664
|
#: src/pages/PlaylistDetail.tsx:664
|
||||||
@@ -433,7 +453,7 @@ msgstr "modifié {0}"
|
|||||||
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||||
#: src/components/ChatModal.tsx:88
|
#: src/components/ChatModal.tsx:150
|
||||||
#: src/components/CommentThread.tsx:315
|
#: src/components/CommentThread.tsx:315
|
||||||
#: src/pages/Dump.tsx:427
|
#: src/pages/Dump.tsx:427
|
||||||
#: src/pages/PlaylistDetail.tsx:661
|
#: src/pages/PlaylistDetail.tsx:661
|
||||||
@@ -643,7 +663,7 @@ msgstr "Clair"
|
|||||||
msgid "Like"
|
msgid "Like"
|
||||||
msgstr "Aimer"
|
msgstr "Aimer"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:569
|
#: src/contexts/WSProvider.tsx:575
|
||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||||
|
|
||||||
@@ -656,7 +676,7 @@ msgstr "Mises à jour en direct indisponibles."
|
|||||||
msgid "Load more"
|
msgid "Load more"
|
||||||
msgstr "Charger plus"
|
msgstr "Charger plus"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:321
|
#: src/components/ChatModal.tsx:546
|
||||||
msgid "Load older messages"
|
msgid "Load older messages"
|
||||||
msgstr "Charger les messages plus anciens"
|
msgstr "Charger les messages plus anciens"
|
||||||
|
|
||||||
@@ -686,7 +706,7 @@ msgid "Loading profile…"
|
|||||||
msgstr "Chargement du profil…"
|
msgstr "Chargement du profil…"
|
||||||
|
|
||||||
#: src/components/CategoryManager.tsx:52
|
#: src/components/CategoryManager.tsx:52
|
||||||
#: src/components/ChatModal.tsx:320
|
#: src/components/ChatModal.tsx:545
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||||
#: src/components/TextEditor.tsx:289
|
#: src/components/TextEditor.tsx:289
|
||||||
#: src/components/UserListPopover.tsx:192
|
#: src/components/UserListPopover.tsx:192
|
||||||
@@ -805,7 +825,7 @@ msgstr "Pas encore de collections suivies."
|
|||||||
msgid "No invitees yet."
|
msgid "No invitees yet."
|
||||||
msgstr "Aucun invité pour le moment."
|
msgstr "Aucun invité pour le moment."
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:328
|
#: src/components/ChatModal.tsx:553
|
||||||
msgid "No messages yet. Say hello!"
|
msgid "No messages yet. Say hello!"
|
||||||
msgstr "Aucun message pour l'instant. Dites bonjour !"
|
msgstr "Aucun message pour l'instant. Dites bonjour !"
|
||||||
|
|
||||||
@@ -989,10 +1009,16 @@ msgstr "Retirer le vote"
|
|||||||
msgid "Replace file"
|
msgid "Replace file"
|
||||||
msgstr "Remplacer le fichier"
|
msgstr "Remplacer le fichier"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:162
|
||||||
|
#: src/components/ChatModal.tsx:163
|
||||||
#: src/components/CommentThread.tsx:355
|
#: src/components/CommentThread.tsx:355
|
||||||
msgid "Reply"
|
msgid "Reply"
|
||||||
msgstr "Répondre"
|
msgstr "Répondre"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:609
|
||||||
|
msgid "Replying to"
|
||||||
|
msgstr "En réponse à"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:132
|
#: src/pages/UserLogin.tsx:132
|
||||||
msgid "Request failed"
|
msgid "Request failed"
|
||||||
msgstr "Échec de la demande"
|
msgstr "Échec de la demande"
|
||||||
@@ -1019,7 +1045,7 @@ msgstr "Réessayer"
|
|||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr "Rôle"
|
msgstr "Rôle"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:151
|
#: src/components/ChatModal.tsx:222
|
||||||
#: src/components/CommentThread.tsx:328
|
#: src/components/CommentThread.tsx:328
|
||||||
#: src/pages/Dump.tsx:368
|
#: src/pages/Dump.tsx:368
|
||||||
#: src/pages/DumpEdit.tsx:466
|
#: src/pages/DumpEdit.tsx:466
|
||||||
@@ -1057,7 +1083,7 @@ msgstr "Recherche échouée"
|
|||||||
msgid "Searching…"
|
msgid "Searching…"
|
||||||
msgstr "Recherche…"
|
msgstr "Recherche…"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:369
|
#: src/components/ChatModal.tsx:655
|
||||||
msgid "Send"
|
msgid "Send"
|
||||||
msgstr "Envoyer"
|
msgstr "Envoyer"
|
||||||
|
|
||||||
@@ -1082,6 +1108,10 @@ msgstr "Définir un nouveau mot de passe"
|
|||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr "Paramètres"
|
msgstr "Paramètres"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:529
|
||||||
|
msgid "Several people are typing…"
|
||||||
|
msgstr "Plusieurs personnes écrivent…"
|
||||||
|
|
||||||
#: src/components/CategoryManager.tsx:191
|
#: src/components/CategoryManager.tsx:191
|
||||||
msgid "slug"
|
msgid "slug"
|
||||||
msgstr "identifiant"
|
msgstr "identifiant"
|
||||||
@@ -1133,11 +1163,12 @@ msgstr "Titre"
|
|||||||
msgid "Title is required."
|
msgid "Title is required."
|
||||||
msgstr "Un titre est requis."
|
msgstr "Un titre est requis."
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:262
|
||||||
#: src/pages/Notifications.tsx:397
|
#: src/pages/Notifications.tsx:397
|
||||||
msgid "Today"
|
msgid "Today"
|
||||||
msgstr "Aujourd'hui"
|
msgstr "Aujourd'hui"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:350
|
#: src/components/ChatModal.tsx:632
|
||||||
msgid "Type a message…"
|
msgid "Type a message…"
|
||||||
msgstr "Écrivez un message…"
|
msgstr "Écrivez un message…"
|
||||||
|
|
||||||
@@ -1240,6 +1271,7 @@ msgstr "Pourquoi ?"
|
|||||||
msgid "Write a reply…"
|
msgid "Write a reply…"
|
||||||
msgstr "Écrire une réponse…"
|
msgstr "Écrire une réponse…"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:265
|
||||||
#: src/pages/Notifications.tsx:399
|
#: src/pages/Notifications.tsx:399
|
||||||
msgid "Yesterday"
|
msgid "Yesterday"
|
||||||
msgstr "Hier"
|
msgstr "Hier"
|
||||||
|
|||||||
22
src/model.ts
22
src/model.ts
@@ -228,6 +228,13 @@ export interface ChatMessage {
|
|||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
authorUsername: string;
|
authorUsername: string;
|
||||||
authorAvatarMime?: string;
|
authorAvatarMime?: string;
|
||||||
|
/** Id of the message this one replies to, if any. Kept even when the target
|
||||||
|
* was deleted (the preview fields below then go undefined). */
|
||||||
|
replyToId?: string;
|
||||||
|
/** Author/snippet of the replied-to message, for a subtle inline reference.
|
||||||
|
* Absent when there's no reply, or the target has since been deleted. */
|
||||||
|
replyToAuthor?: string;
|
||||||
|
replyToBody?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RawChatMessage = WithStringDate<ChatMessage>;
|
export type RawChatMessage = WithStringDate<ChatMessage>;
|
||||||
@@ -537,6 +544,11 @@ export interface WSChatMessageDeletedMessage {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WSChatTypingUpdateMessage {
|
||||||
|
type: "chat_typing_update";
|
||||||
|
users: OnlineUser[];
|
||||||
|
}
|
||||||
|
|
||||||
export type IncomingWSMessage =
|
export type IncomingWSMessage =
|
||||||
| WSPingMessage
|
| WSPingMessage
|
||||||
| WSWelcomeMessage
|
| WSWelcomeMessage
|
||||||
@@ -561,7 +573,8 @@ export type IncomingWSMessage =
|
|||||||
| WSForceLogoutMessage
|
| WSForceLogoutMessage
|
||||||
| WSChatMessageMessage
|
| WSChatMessageMessage
|
||||||
| WSChatMessageUpdatedMessage
|
| WSChatMessageUpdatedMessage
|
||||||
| WSChatMessageDeletedMessage;
|
| WSChatMessageDeletedMessage
|
||||||
|
| WSChatTypingUpdateMessage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket messages — client → server (outgoing)
|
* WebSocket messages — client → server (outgoing)
|
||||||
@@ -590,6 +603,12 @@ export interface WSCommentLikeRemoveMessage {
|
|||||||
export interface WSChatSendMessage {
|
export interface WSChatSendMessage {
|
||||||
type: "chat_send";
|
type: "chat_send";
|
||||||
body: string;
|
body: string;
|
||||||
|
replyToId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WSChatTypingMessage {
|
||||||
|
type: "chat_typing";
|
||||||
|
typing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSChatFocusMessage {
|
export interface WSChatFocusMessage {
|
||||||
@@ -615,6 +634,7 @@ export type OutgoingWSMessage =
|
|||||||
| WSCommentLikeCastMessage
|
| WSCommentLikeCastMessage
|
||||||
| WSCommentLikeRemoveMessage
|
| WSCommentLikeRemoveMessage
|
||||||
| WSChatSendMessage
|
| WSChatSendMessage
|
||||||
|
| WSChatTypingMessage
|
||||||
| WSChatFocusMessage
|
| WSChatFocusMessage
|
||||||
| WSChatEditMessage
|
| WSChatEditMessage
|
||||||
| WSChatDeleteMessage;
|
| WSChatDeleteMessage;
|
||||||
|
|||||||
Reference in New Issue
Block a user