v3: added a chatbox
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
@@ -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 {
|
||||
|
||||
18
api/db/migrations/0008_chat_messages.ts
Normal file
18
api/db/migrations/0008_chat_messages.ts
Normal 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);`,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
26
api/routes/chat.ts
Normal 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;
|
||||
120
api/routes/ws.ts
120
api/routes/ws.ts
@@ -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;
|
||||
|
||||
137
api/services/chat-service.ts
Normal file
137
api/services/chat-service.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
340
src/App.css
340
src/App.css
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
<ChatProvider>
|
||||
<AppRoutes />
|
||||
<GlobalPlayer />
|
||||
</ChatProvider>
|
||||
</BrowserRouter>
|
||||
</CategoriesProvider>
|
||||
</FollowProvider>
|
||||
|
||||
@@ -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"
|
||||
|
||||
27
src/components/ChatButton.tsx
Normal file
27
src/components/ChatButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/components/ChatFab.tsx
Normal file
74
src/components/ChatFab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
375
src/components/ChatModal.tsx
Normal file
375
src/components/ChatModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/contexts/ChatContext.ts
Normal file
13
src/contexts/ChatContext.ts
Normal 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: () => {},
|
||||
});
|
||||
57
src/contexts/ChatProvider.tsx
Normal file
57
src/contexts/ChatProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: () => {},
|
||||
});
|
||||
|
||||
@@ -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
6
src/hooks/useChat.ts
Normal 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
@@ -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
@@ -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."
|
||||
|
||||
|
||||
73
src/model.ts
73
src/model.ts
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ─── */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user