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 up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts";
|
||||||
import { up as up0006Categories } from "./migrations/0006_categories.ts";
|
import { up as up0006Categories } from "./migrations/0006_categories.ts";
|
||||||
import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts";
|
import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts";
|
||||||
|
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
|
||||||
|
import { up as up0009ChatMessageUpdatedAt } from "./migrations/0009_chat_message_updated_at.ts";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,6 +25,8 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ name: "0005_drop_is_admin", up: up0005DropIsAdmin },
|
{ name: "0005_drop_is_admin", up: up0005DropIsAdmin },
|
||||||
{ name: "0006_categories", up: up0006Categories },
|
{ name: "0006_categories", up: up0006Categories },
|
||||||
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
|
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
|
||||||
|
{ name: "0008_chat_messages", up: up0008ChatMessages },
|
||||||
|
{ name: "0009_chat_message_updated_at", up: up0009ChatMessageUpdatedAt },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: DatabaseSync): void {
|
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
|
CREATE UNIQUE INDEX idx_notifications_dedup
|
||||||
ON notifications(user_id, source_key)
|
ON notifications(user_id, source_key)
|
||||||
WHERE source_key IS NOT NULL;
|
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"
|
| "dump:moderate"
|
||||||
| "comment:moderate"
|
| "comment:moderate"
|
||||||
| "playlist:moderate"
|
| "playlist:moderate"
|
||||||
|
| "chat:moderate"
|
||||||
| "user:manage"
|
| "user:manage"
|
||||||
| "category:manage";
|
| "category:manage";
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ const ALL_PERMISSIONS: readonly Permission[] = [
|
|||||||
"dump:moderate",
|
"dump:moderate",
|
||||||
"comment:moderate",
|
"comment:moderate",
|
||||||
"playlist:moderate",
|
"playlist:moderate",
|
||||||
|
"chat:moderate",
|
||||||
"user:manage",
|
"user:manage",
|
||||||
"category:manage",
|
"category:manage",
|
||||||
];
|
];
|
||||||
@@ -21,7 +23,12 @@ const ALL_PERMISSIONS: readonly Permission[] = [
|
|||||||
// and future); `moderator` gets the content-moderation permissions only.
|
// and future); `moderator` gets the content-moderation permissions only.
|
||||||
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
||||||
admin: ALL_PERMISSIONS,
|
admin: ALL_PERMISSIONS,
|
||||||
moderator: ["dump:moderate", "comment:moderate", "playlist:moderate"],
|
moderator: [
|
||||||
|
"dump:moderate",
|
||||||
|
"comment:moderate",
|
||||||
|
"playlist:moderate",
|
||||||
|
"chat:moderate",
|
||||||
|
],
|
||||||
user: [],
|
user: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import wsRouter from "./routes/ws.ts";
|
|||||||
import previewRouter from "./routes/preview.ts";
|
import previewRouter from "./routes/preview.ts";
|
||||||
import playlistsRouter from "./routes/playlists.ts";
|
import playlistsRouter from "./routes/playlists.ts";
|
||||||
import commentsRouter from "./routes/comments.ts";
|
import commentsRouter from "./routes/comments.ts";
|
||||||
|
import chatRouter from "./routes/chat.ts";
|
||||||
import followsRouter from "./routes/follows.ts";
|
import followsRouter from "./routes/follows.ts";
|
||||||
import notificationsRouter from "./routes/notifications.ts";
|
import notificationsRouter from "./routes/notifications.ts";
|
||||||
import invitesRouter from "./routes/invites.ts";
|
import invitesRouter from "./routes/invites.ts";
|
||||||
@@ -78,6 +79,10 @@ app.use(
|
|||||||
commentsRouter.routes(),
|
commentsRouter.routes(),
|
||||||
commentsRouter.allowedMethods(),
|
commentsRouter.allowedMethods(),
|
||||||
);
|
);
|
||||||
|
app.use(
|
||||||
|
chatRouter.routes(),
|
||||||
|
chatRouter.allowedMethods(),
|
||||||
|
);
|
||||||
app.use(
|
app.use(
|
||||||
followsRouter.routes(),
|
followsRouter.routes(),
|
||||||
followsRouter.allowedMethods(),
|
followsRouter.allowedMethods(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomBytes, scryptSync } from "node:crypto";
|
|||||||
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
||||||
import {
|
import {
|
||||||
type Category,
|
type Category,
|
||||||
|
type ChatMessage,
|
||||||
type Comment,
|
type Comment,
|
||||||
Dump,
|
Dump,
|
||||||
isRole,
|
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 {
|
export interface PlaylistRow {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
|||||||
@@ -309,6 +309,25 @@ export interface CreateCommentRequest {
|
|||||||
parentId?: string;
|
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(
|
export function isCreateCommentRequest(
|
||||||
obj: unknown,
|
obj: unknown,
|
||||||
): obj is CreateCommentRequest {
|
): obj is CreateCommentRequest {
|
||||||
@@ -532,13 +551,40 @@ export interface CommentLikeRemoveMessage {
|
|||||||
commentId: string;
|
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 =
|
export type ClientToServerMessage =
|
||||||
| PingMessage
|
| PingMessage
|
||||||
| PongMessage
|
| PongMessage
|
||||||
| VoteCastMessage
|
| VoteCastMessage
|
||||||
| VoteRemoveMessage
|
| VoteRemoveMessage
|
||||||
| CommentLikeCastMessage
|
| CommentLikeCastMessage
|
||||||
| CommentLikeRemoveMessage;
|
| CommentLikeRemoveMessage
|
||||||
|
| ChatSendMessage
|
||||||
|
| ChatFocusMessage
|
||||||
|
| ChatEditMessage
|
||||||
|
| ChatDeleteMessage;
|
||||||
|
|
||||||
// ── Server → Client ──────────────────────────────────────────────────────────
|
// ── Server → Client ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -669,6 +715,21 @@ export interface ForceLogoutMessage {
|
|||||||
type: "force_logout";
|
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 =
|
export type ServerToClientMessage =
|
||||||
| PingMessage
|
| PingMessage
|
||||||
| WelcomeMessage
|
| WelcomeMessage
|
||||||
@@ -690,7 +751,10 @@ export type ServerToClientMessage =
|
|||||||
| CommentDeletedMessage
|
| CommentDeletedMessage
|
||||||
| NotificationCreatedMessage
|
| NotificationCreatedMessage
|
||||||
| ErrorMessage
|
| ErrorMessage
|
||||||
| ForceLogoutMessage;
|
| ForceLogoutMessage
|
||||||
|
| ChatMessageMessage
|
||||||
|
| ChatMessageUpdatedMessage
|
||||||
|
| ChatMessageDeletedMessage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Follows
|
* Follows
|
||||||
@@ -751,7 +815,7 @@ export interface DumpUpvotedData {
|
|||||||
export interface UserMentionedData {
|
export interface UserMentionedData {
|
||||||
mentionerId: string;
|
mentionerId: string;
|
||||||
mentionerUsername: string;
|
mentionerUsername: string;
|
||||||
contextType: "comment" | "dump" | "playlist";
|
contextType: "comment" | "dump" | "playlist" | "chat";
|
||||||
contextId: string;
|
contextId: string;
|
||||||
contextTitle: string;
|
contextTitle: string;
|
||||||
dumpId?: 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 { ALLOWED_ORIGINS } from "../config.ts";
|
||||||
import { verifyJWT } from "../lib/jwt.ts";
|
import { verifyJWT } from "../lib/jwt.ts";
|
||||||
import {
|
import {
|
||||||
|
broadcastChatMessage,
|
||||||
|
broadcastChatMessageDeleted,
|
||||||
|
broadcastChatMessageUpdated,
|
||||||
broadcastCommentLikeUpdate,
|
broadcastCommentLikeUpdate,
|
||||||
broadcastPresence,
|
broadcastPresence,
|
||||||
broadcastVoteUpdate,
|
broadcastVoteUpdate,
|
||||||
getOnlineUsers,
|
getOnlineUsers,
|
||||||
handleClientPong,
|
handleClientPong,
|
||||||
|
isUserChatOpen,
|
||||||
register,
|
register,
|
||||||
unregister,
|
unregister,
|
||||||
type WsClient,
|
type WsClient,
|
||||||
} from "../services/ws-service.ts";
|
} 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 {
|
import {
|
||||||
castVote,
|
castVote,
|
||||||
getUserVotes,
|
getUserVotes,
|
||||||
@@ -59,6 +70,7 @@ router.get("/ws", async (ctx) => {
|
|||||||
socket,
|
socket,
|
||||||
userId: authPayload?.userId,
|
userId: authPayload?.userId,
|
||||||
username: authPayload?.username,
|
username: authPayload?.username,
|
||||||
|
role: authPayload?.role,
|
||||||
avatarMime,
|
avatarMime,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,6 +126,18 @@ router.get("/ws", async (ctx) => {
|
|||||||
case "comment_like_remove":
|
case "comment_like_remove":
|
||||||
handleCommentLike(client, msg.commentId, "remove");
|
handleCommentLike(client, msg.commentId, "remove");
|
||||||
break;
|
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;
|
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(
|
export function notifyMentions(
|
||||||
mentionerUserId: string,
|
mentionerUserId: string,
|
||||||
body: string,
|
body: string,
|
||||||
contextType: "comment" | "dump" | "playlist",
|
contextType: "comment" | "dump" | "playlist" | "chat",
|
||||||
contextId: string,
|
contextId: string,
|
||||||
contextTitle: string,
|
contextTitle: string,
|
||||||
dumpId?: 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 {
|
): void {
|
||||||
const mentionerRow = db.prepare(
|
const mentionerRow = db.prepare(
|
||||||
`SELECT username FROM users WHERE id = ?;`,
|
`SELECT username FROM users WHERE id = ?;`,
|
||||||
@@ -256,6 +259,7 @@ export function notifyMentions(
|
|||||||
`SELECT id FROM users WHERE lower(username) = ?;`,
|
`SELECT id FROM users WHERE lower(username) = ?;`,
|
||||||
).get(username) as { id: string } | undefined;
|
).get(username) as { id: string } | undefined;
|
||||||
if (!mentionedRow || mentionedRow.id === mentionerUserId) continue;
|
if (!mentionedRow || mentionedRow.id === mentionerUserId) continue;
|
||||||
|
if (skipUserId?.(mentionedRow.id)) continue;
|
||||||
|
|
||||||
createNotification(
|
createNotification(
|
||||||
mentionedRow.id,
|
mentionedRow.id,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ChatMessage,
|
||||||
Comment,
|
Comment,
|
||||||
Dump,
|
Dump,
|
||||||
OnlineUser,
|
OnlineUser,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
Role,
|
||||||
ServerToClientMessage,
|
ServerToClientMessage,
|
||||||
User,
|
User,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
@@ -11,9 +13,12 @@ export interface WsClient {
|
|||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
role?: Role;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
avatarVersion?: number;
|
avatarVersion?: number;
|
||||||
pongReceived?: boolean;
|
pongReceived?: boolean;
|
||||||
|
/** Whether this client currently has the chatbox open (is reading chat). */
|
||||||
|
chatOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clients = new Set<WsClient>();
|
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 {
|
export function handleClientPong(client: WsClient): void {
|
||||||
client.pongReceived = true;
|
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;
|
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 {
|
.rich-content-thumbnail-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -2148,6 +2217,7 @@ body.has-fab .page-content {
|
|||||||
/* Icon-only controls are square, so the row reads as a tidy set. */
|
/* Icon-only controls are square, so the row reads as a tidy set. */
|
||||||
.app-header-nav .nav-search-btn,
|
.app-header-nav .nav-search-btn,
|
||||||
.app-header-nav .notification-bell,
|
.app-header-nav .notification-bell,
|
||||||
|
.app-header-nav .chat-button,
|
||||||
.app-header-nav .user-menu-trigger {
|
.app-header-nav .user-menu-trigger {
|
||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -3221,6 +3291,255 @@ body.has-fab .page-content {
|
|||||||
gap: 0.75rem;
|
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 {
|
.confirm-modal-message {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
@@ -4359,6 +4678,27 @@ body.has-fab .page-content {
|
|||||||
box-shadow: 0 0 0 2px var(--color-bg);
|
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 ── */
|
||||||
.notifications-page {
|
.notifications-page {
|
||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import { WSProvider } from "./contexts/WSProvider.tsx";
|
|||||||
import { FollowProvider } from "./contexts/FollowProvider.tsx";
|
import { FollowProvider } from "./contexts/FollowProvider.tsx";
|
||||||
import { CategoriesProvider } from "./contexts/CategoriesProvider.tsx";
|
import { CategoriesProvider } from "./contexts/CategoriesProvider.tsx";
|
||||||
import { ThemeProvider } from "./contexts/ThemeProvider.tsx";
|
import { ThemeProvider } from "./contexts/ThemeProvider.tsx";
|
||||||
|
import { ChatProvider } from "./contexts/ChatProvider.tsx";
|
||||||
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
||||||
import { DumpFab } from "./components/DumpFab.tsx";
|
import { DumpFab } from "./components/DumpFab.tsx";
|
||||||
|
import { ChatFab } from "./components/ChatFab.tsx";
|
||||||
|
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
@@ -162,6 +164,7 @@ function AppRoutes() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<DumpFab />
|
<DumpFab />
|
||||||
|
<ChatFab />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -175,8 +178,10 @@ function App() {
|
|||||||
<FollowProvider>
|
<FollowProvider>
|
||||||
<CategoriesProvider>
|
<CategoriesProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppRoutes />
|
<ChatProvider>
|
||||||
<GlobalPlayer />
|
<AppRoutes />
|
||||||
|
<GlobalPlayer />
|
||||||
|
</ChatProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</CategoriesProvider>
|
</CategoriesProvider>
|
||||||
</FollowProvider>
|
</FollowProvider>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Trans } from "@lingui/react/macro";
|
|||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { NotificationBell } from "./NotificationBell.tsx";
|
import { NotificationBell } from "./NotificationBell.tsx";
|
||||||
|
import { ChatButton } from "./ChatButton.tsx";
|
||||||
import { UserMenu } from "./UserMenu.tsx";
|
import { UserMenu } from "./UserMenu.tsx";
|
||||||
import { SearchBar } from "./SearchBar.tsx";
|
import { SearchBar } from "./SearchBar.tsx";
|
||||||
import { SITE_EMOJI, SITE_NAME } from "../hooks/useDocumentTitle.ts";
|
import { SITE_EMOJI, SITE_NAME } from "../hooks/useDocumentTitle.ts";
|
||||||
@@ -91,6 +92,7 @@ export function AppHeader(
|
|||||||
<UserMenu user={user} />
|
<UserMenu user={user} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChatButton />
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<button
|
<button
|
||||||
type="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 { createContext } from "react";
|
||||||
import type {
|
import type {
|
||||||
|
ChatMessage,
|
||||||
Comment,
|
Comment,
|
||||||
Dump,
|
Dump,
|
||||||
Notification,
|
Notification,
|
||||||
@@ -58,6 +59,12 @@ export interface WSContextValue {
|
|||||||
lastUserEvent: UserEvent | null;
|
lastUserEvent: UserEvent | null;
|
||||||
unreadNotificationCount: number;
|
unreadNotificationCount: number;
|
||||||
lastNotification: Notification | null;
|
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. */
|
/** Increments on each WS reconnect so pages can backfill missed events. */
|
||||||
connectionEpoch: number;
|
connectionEpoch: number;
|
||||||
castVote: (dumpId: string) => void;
|
castVote: (dumpId: string) => void;
|
||||||
@@ -66,6 +73,13 @@ export interface WSContextValue {
|
|||||||
removeCommentLike: (commentId: string) => void;
|
removeCommentLike: (commentId: string) => void;
|
||||||
injectDump: (dump: Dump) => void;
|
injectDump: (dump: Dump) => void;
|
||||||
clearUnreadNotifications: () => void;
|
clearUnreadNotifications: () => void;
|
||||||
|
sendChatMessage: (body: string) => void;
|
||||||
|
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>({
|
export const WSContext = createContext<WSContextValue>({
|
||||||
@@ -87,6 +101,9 @@ export const WSContext = createContext<WSContextValue>({
|
|||||||
lastUserEvent: null,
|
lastUserEvent: null,
|
||||||
unreadNotificationCount: 0,
|
unreadNotificationCount: 0,
|
||||||
lastNotification: null,
|
lastNotification: null,
|
||||||
|
chatMessages: [],
|
||||||
|
deletedChatIds: new Set(),
|
||||||
|
unreadChatCount: 0,
|
||||||
connectionEpoch: 0,
|
connectionEpoch: 0,
|
||||||
castVote: () => {},
|
castVote: () => {},
|
||||||
removeVote: () => {},
|
removeVote: () => {},
|
||||||
@@ -94,4 +111,9 @@ export const WSContext = createContext<WSContextValue>({
|
|||||||
removeCommentLike: () => {},
|
removeCommentLike: () => {},
|
||||||
injectDump: () => {},
|
injectDump: () => {},
|
||||||
clearUnreadNotifications: () => {},
|
clearUnreadNotifications: () => {},
|
||||||
|
sendChatMessage: () => {},
|
||||||
|
editChatMessage: () => {},
|
||||||
|
deleteChatMessage: () => {},
|
||||||
|
clearUnreadChat: () => {},
|
||||||
|
setChatOpen: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "./AvatarOverrideContext.ts";
|
} from "./AvatarOverrideContext.ts";
|
||||||
import { WS_URL } from "../config/api.ts";
|
import { WS_URL } from "../config/api.ts";
|
||||||
import type {
|
import type {
|
||||||
|
ChatMessage,
|
||||||
Dump,
|
Dump,
|
||||||
IncomingWSMessage,
|
IncomingWSMessage,
|
||||||
Notification,
|
Notification,
|
||||||
@@ -29,6 +30,7 @@ import type {
|
|||||||
OutgoingWSMessage,
|
OutgoingWSMessage,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import {
|
import {
|
||||||
|
deserializeChatMessage,
|
||||||
deserializeComment,
|
deserializeComment,
|
||||||
deserializeDump,
|
deserializeDump,
|
||||||
deserializeNotification,
|
deserializeNotification,
|
||||||
@@ -49,6 +51,9 @@ const CONNECT_TIMEOUT = 2_500;
|
|||||||
// bound. Feeds merge these on top of their own paginated list, so a generous
|
// 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.
|
// cap is plenty — older live arrivals are reachable via normal pagination.
|
||||||
const MAX_RECENT_DUMPS = 100;
|
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 {
|
interface PendingVote {
|
||||||
timeout: ReturnType<typeof setTimeout>;
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
@@ -111,6 +116,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
const [lastNotification, setLastNotification] = useState<Notification | null>(
|
const [lastNotification, setLastNotification] = useState<Notification | null>(
|
||||||
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).
|
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
|
||||||
const [avatarOverrides, setAvatarOverrides] = useState<
|
const [avatarOverrides, setAvatarOverrides] = useState<
|
||||||
Record<string, AvatarOverride>
|
Record<string, AvatarOverride>
|
||||||
@@ -145,6 +153,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
setLastUserEvent(null);
|
setLastUserEvent(null);
|
||||||
setUnreadNotificationCount(0);
|
setUnreadNotificationCount(0);
|
||||||
setLastNotification(null);
|
setLastNotification(null);
|
||||||
|
setChatMessages([]);
|
||||||
|
setDeletedChatIds(new Set());
|
||||||
|
setUnreadChatCount(0);
|
||||||
setAvatarOverrides({});
|
setAvatarOverrides({});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +178,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const socketRef = useRef<WebSocket | null>(null);
|
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
|
// Tracks pending optimistic votes: dumpId → pending rollback handler
|
||||||
const pendingRef = useRef<Map<string, PendingVote>>(
|
const pendingRef = useRef<Map<string, PendingVote>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
@@ -291,6 +305,15 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
// dumps/comments they couldn't receive while disconnected.
|
// dumps/comments they couldn't receive while disconnected.
|
||||||
welcomeCount += 1;
|
welcomeCount += 1;
|
||||||
if (welcomeCount > 1) setConnectionEpoch((n) => n + 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;
|
break;
|
||||||
|
|
||||||
case "presence_update":
|
case "presence_update":
|
||||||
@@ -474,6 +497,38 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
break;
|
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":
|
case "force_logout":
|
||||||
onForceLogoutRef.current();
|
onForceLogoutRef.current();
|
||||||
break;
|
break;
|
||||||
@@ -704,6 +759,55 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
setUnreadNotificationCount(0);
|
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(() => ({
|
const value: WSContextValue = useMemo(() => ({
|
||||||
wsStatus,
|
wsStatus,
|
||||||
wsErrorMessage,
|
wsErrorMessage,
|
||||||
@@ -723,6 +827,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
lastUserEvent,
|
lastUserEvent,
|
||||||
unreadNotificationCount,
|
unreadNotificationCount,
|
||||||
lastNotification,
|
lastNotification,
|
||||||
|
chatMessages,
|
||||||
|
deletedChatIds,
|
||||||
|
unreadChatCount,
|
||||||
connectionEpoch,
|
connectionEpoch,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
@@ -730,6 +837,11 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
removeCommentLike,
|
removeCommentLike,
|
||||||
injectDump,
|
injectDump,
|
||||||
clearUnreadNotifications,
|
clearUnreadNotifications,
|
||||||
|
sendChatMessage,
|
||||||
|
editChatMessage,
|
||||||
|
deleteChatMessage,
|
||||||
|
clearUnreadChat,
|
||||||
|
setChatOpen,
|
||||||
}), [
|
}), [
|
||||||
wsStatus,
|
wsStatus,
|
||||||
wsErrorMessage,
|
wsErrorMessage,
|
||||||
@@ -749,6 +861,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
lastUserEvent,
|
lastUserEvent,
|
||||||
unreadNotificationCount,
|
unreadNotificationCount,
|
||||||
lastNotification,
|
lastNotification,
|
||||||
|
chatMessages,
|
||||||
|
deletedChatIds,
|
||||||
|
unreadChatCount,
|
||||||
connectionEpoch,
|
connectionEpoch,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
@@ -756,6 +871,11 @@ export function WSProvider({ children }: WSProviderProps) {
|
|||||||
removeCommentLike,
|
removeCommentLike,
|
||||||
injectDump,
|
injectDump,
|
||||||
clearUnreadNotifications,
|
clearUnreadNotifications,
|
||||||
|
sendChatMessage,
|
||||||
|
editChatMessage,
|
||||||
|
deleteChatMessage,
|
||||||
|
clearUnreadChat,
|
||||||
|
setChatOpen,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
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"
|
msgid "← Back"
|
||||||
msgstr "← Back"
|
msgstr "← Back"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:261
|
#: src/pages/Dump.tsx:264
|
||||||
#: src/pages/Dump.tsx:491
|
#: src/pages/Dump.tsx:494
|
||||||
#: src/pages/DumpEdit.tsx:179
|
#: src/pages/DumpEdit.tsx:179
|
||||||
msgid "← Back to all dumps"
|
msgid "← Back to all dumps"
|
||||||
msgstr "← Back to all dumps"
|
msgstr "← Back to all dumps"
|
||||||
@@ -67,61 +67,61 @@ msgstr "+ Invite someone"
|
|||||||
msgid "+ New playlist"
|
msgid "+ New playlist"
|
||||||
msgstr "+ New playlist"
|
msgstr "+ New playlist"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:332
|
#: src/pages/Dump.tsx:335
|
||||||
msgid "+ Playlist"
|
msgid "+ Playlist"
|
||||||
msgstr "+ Playlist"
|
msgstr "+ Playlist"
|
||||||
|
|
||||||
#. placeholder {0}: d.commenterUsername
|
#. placeholder {0}: d.commenterUsername
|
||||||
#. placeholder {1}: d.dumpTitle
|
#. placeholder {1}: d.dumpTitle
|
||||||
#: src/pages/Notifications.tsx:195
|
#: src/pages/Notifications.tsx:196
|
||||||
msgid "<0>{0}</0> commented on <1>{1}</1>"
|
msgid "<0>{0}</0> commented on <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> commented on <1>{1}</1>"
|
msgstr "<0>{0}</0> commented on <1>{1}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: d.followerUsername
|
#. placeholder {0}: d.followerUsername
|
||||||
#. placeholder {1}: d.playlistTitle
|
#. placeholder {1}: d.playlistTitle
|
||||||
#: src/pages/Notifications.tsx:155
|
#: src/pages/Notifications.tsx:156
|
||||||
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
|
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: d.likerUsername
|
#. placeholder {0}: d.likerUsername
|
||||||
#. placeholder {1}: d.dumpTitle
|
#. 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>"
|
msgid "<0>{0}</0> liked your comment on <1>{1}</1>"
|
||||||
msgstr "<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
|
#. placeholder {0}: d.mentionerUsername
|
||||||
#: src/pages/Notifications.tsx:217
|
#: src/pages/Notifications.tsx:222
|
||||||
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||||
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
|
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: d.dumperUsername
|
#. placeholder {0}: d.dumperUsername
|
||||||
#. placeholder {1}: d.dumpTitle
|
#. placeholder {1}: d.dumpTitle
|
||||||
#: src/pages/Notifications.tsx:165
|
#: src/pages/Notifications.tsx:166
|
||||||
msgid "<0>{0}</0> posted <1>{1}</1>"
|
msgid "<0>{0}</0> posted <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> posted <1>{1}</1>"
|
msgstr "<0>{0}</0> posted <1>{1}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: d.followerUsername
|
#. placeholder {0}: d.followerUsername
|
||||||
#: src/pages/Notifications.tsx:146
|
#: src/pages/Notifications.tsx:147
|
||||||
msgid "<0>{0}</0> started following you"
|
msgid "<0>{0}</0> started following you"
|
||||||
msgstr "<0>{0}</0> started following you"
|
msgstr "<0>{0}</0> started following you"
|
||||||
|
|
||||||
#. placeholder {0}: d.voterUsername
|
#. placeholder {0}: d.voterUsername
|
||||||
#. placeholder {1}: d.dumpTitle
|
#. placeholder {1}: d.dumpTitle
|
||||||
#: src/pages/Notifications.tsx:185
|
#: src/pages/Notifications.tsx:186
|
||||||
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> upvoted <1>{1}</1>"
|
msgstr "<0>{0}</0> upvoted <1>{1}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: d.dumpTitle
|
#. placeholder {0}: d.dumpTitle
|
||||||
#. placeholder {1}: d.playlistTitle
|
#. placeholder {1}: d.playlistTitle
|
||||||
#: src/pages/Notifications.tsx:175
|
#: src/pages/Notifications.tsx:176
|
||||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||||
msgstr "<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"
|
msgid "a comment"
|
||||||
msgstr "a comment"
|
msgstr "a comment"
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:215
|
#: src/pages/Notifications.tsx:220
|
||||||
msgid "a post"
|
msgid "a post"
|
||||||
msgstr "a post"
|
msgstr "a post"
|
||||||
|
|
||||||
@@ -180,17 +180,18 @@ msgstr "Auto"
|
|||||||
msgid "Back to login"
|
msgid "Back to login"
|
||||||
msgstr "Back to login"
|
msgstr "Back to login"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:211
|
#: src/contexts/WSProvider.tsx:268
|
||||||
#: src/contexts/WSProvider.tsx:438
|
#: src/contexts/WSProvider.tsx:570
|
||||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||||
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||||
|
|
||||||
#: src/components/CategoryManager.tsx:210
|
#: src/components/CategoryManager.tsx:210
|
||||||
#: src/components/CategoryManager.tsx:211
|
#: src/components/CategoryManager.tsx:211
|
||||||
|
#: src/components/ChatModal.tsx:158
|
||||||
#: src/components/CommentThread.tsx:124
|
#: src/components/CommentThread.tsx:124
|
||||||
#: src/components/ConfirmModal.tsx:32
|
#: src/components/ConfirmModal.tsx:32
|
||||||
#: src/components/form/FormActions.tsx:32
|
#: src/components/form/FormActions.tsx:32
|
||||||
#: src/pages/Dump.tsx:373
|
#: src/pages/Dump.tsx:376
|
||||||
#: src/pages/DumpEdit.tsx:463
|
#: src/pages/DumpEdit.tsx:463
|
||||||
#: src/pages/PlaylistDetail.tsx:920
|
#: src/pages/PlaylistDetail.tsx:920
|
||||||
#: src/pages/UserPublicProfile.tsx:1674
|
#: src/pages/UserPublicProfile.tsx:1674
|
||||||
@@ -202,7 +203,7 @@ msgstr "Cancel"
|
|||||||
msgid "Cancel removal"
|
msgid "Cancel removal"
|
||||||
msgstr "Cancel removal"
|
msgstr "Cancel removal"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:65
|
#: src/components/AppHeader.tsx:66
|
||||||
msgid "Cancel search"
|
msgid "Cancel search"
|
||||||
msgstr "Cancel search"
|
msgstr "Cancel search"
|
||||||
|
|
||||||
@@ -228,6 +229,18 @@ msgstr "Change password"
|
|||||||
msgid "Change password…"
|
msgid "Change password…"
|
||||||
msgstr "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
|
#: src/pages/UserRegister.tsx:85
|
||||||
msgid "Checking invite…"
|
msgid "Checking invite…"
|
||||||
msgstr "Checking invite…"
|
msgstr "Checking invite…"
|
||||||
@@ -298,6 +311,9 @@ msgstr "Default tab"
|
|||||||
|
|
||||||
#: src/components/CategoryManager.tsx:221
|
#: src/components/CategoryManager.tsx:221
|
||||||
#: src/components/CategoryManager.tsx:222
|
#: src/components/CategoryManager.tsx:222
|
||||||
|
#: src/components/ChatModal.tsx:112
|
||||||
|
#: src/components/ChatModal.tsx:113
|
||||||
|
#: src/components/ChatModal.tsx:168
|
||||||
#: src/components/CommentThread.tsx:376
|
#: src/components/CommentThread.tsx:376
|
||||||
#: src/components/CommentThread.tsx:382
|
#: src/components/CommentThread.tsx:382
|
||||||
#: src/components/ConfirmModal.tsx:16
|
#: src/components/ConfirmModal.tsx:16
|
||||||
@@ -333,6 +349,10 @@ msgstr "Delete this comment?"
|
|||||||
msgid "Delete this dump? This cannot be undone."
|
msgid "Delete this dump? This cannot be undone."
|
||||||
msgstr "Delete this dump? This cannot be undone."
|
msgstr "Delete this dump? This cannot be undone."
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:167
|
||||||
|
msgid "Delete this message?"
|
||||||
|
msgstr "Delete this message?"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:757
|
#: src/pages/PlaylistDetail.tsx:757
|
||||||
#: src/pages/UserPlaylists.tsx:467
|
#: src/pages/UserPlaylists.tsx:467
|
||||||
msgid "Delete this playlist? This cannot be undone."
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
@@ -355,7 +375,7 @@ msgstr "Drop a file here"
|
|||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Drop a replacement here"
|
msgstr "Drop a replacement here"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:103
|
#: src/components/AppHeader.tsx:105
|
||||||
msgid "Dump"
|
msgid "Dump"
|
||||||
msgstr "Dump"
|
msgstr "Dump"
|
||||||
|
|
||||||
@@ -379,12 +399,14 @@ msgstr "Dumps"
|
|||||||
msgid "Dumps ({0}{1})"
|
msgid "Dumps ({0}{1})"
|
||||||
msgstr "Dumps ({0}{1})"
|
msgstr "Dumps ({0}{1})"
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:395
|
#: src/pages/Notifications.tsx:400
|
||||||
msgid "Earlier"
|
msgid "Earlier"
|
||||||
msgstr "Earlier"
|
msgstr "Earlier"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:101
|
||||||
|
#: src/components/ChatModal.tsx:102
|
||||||
#: src/components/CommentThread.tsx:367
|
#: src/components/CommentThread.tsx:367
|
||||||
#: src/pages/Dump.tsx:487
|
#: src/pages/Dump.tsx:490
|
||||||
#: src/pages/PlaylistDetail.tsx:625
|
#: src/pages/PlaylistDetail.tsx:625
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Edit"
|
msgstr "Edit"
|
||||||
@@ -400,18 +422,20 @@ msgstr "Edit title"
|
|||||||
|
|
||||||
#. placeholder {0}: relativeTime(comment.updatedAt)
|
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||||
#. placeholder {0}: relativeTime(playlist.updatedAt)
|
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||||
|
#: src/components/ChatModal.tsx:90
|
||||||
#: src/components/CommentThread.tsx:317
|
#: src/components/CommentThread.tsx:317
|
||||||
#: src/pages/Dump.tsx:426
|
#: src/pages/Dump.tsx:429
|
||||||
#: src/pages/PlaylistDetail.tsx:664
|
#: src/pages/PlaylistDetail.tsx:664
|
||||||
msgid "edited {0}"
|
msgid "edited {0}"
|
||||||
msgstr "edited {0}"
|
msgstr "edited {0}"
|
||||||
|
|
||||||
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||||
#. placeholder {0}: playlist.updatedAt.toLocaleString()
|
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||||
|
#: src/components/ChatModal.tsx:88
|
||||||
#: src/components/CommentThread.tsx:315
|
#: src/components/CommentThread.tsx:315
|
||||||
#: src/pages/Dump.tsx:424
|
#: src/pages/Dump.tsx:427
|
||||||
#: src/pages/PlaylistDetail.tsx:661
|
#: src/pages/PlaylistDetail.tsx:661
|
||||||
msgid "Edited {0}"
|
msgid "Edited {0}"
|
||||||
msgstr "Edited {0}"
|
msgstr "Edited {0}"
|
||||||
@@ -455,7 +479,7 @@ msgstr "Failed to generate invite"
|
|||||||
#: src/pages/index/HotFeed.tsx:36
|
#: src/pages/index/HotFeed.tsx:36
|
||||||
#: src/pages/index/JournalFeed.tsx:33
|
#: src/pages/index/JournalFeed.tsx:33
|
||||||
#: src/pages/index/NewFeed.tsx:36
|
#: 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:1022
|
||||||
#: src/pages/UserPublicProfile.tsx:1064
|
#: src/pages/UserPublicProfile.tsx:1064
|
||||||
#: src/pages/UserPublicProfile.tsx:1109
|
#: src/pages/UserPublicProfile.tsx:1109
|
||||||
@@ -619,20 +643,24 @@ msgstr "Light"
|
|||||||
msgid "Like"
|
msgid "Like"
|
||||||
msgstr "Like"
|
msgstr "Like"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:437
|
#: src/contexts/WSProvider.tsx:569
|
||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:130
|
#: src/components/AppHeader.tsx:132
|
||||||
msgid "Live updates unavailable."
|
msgid "Live updates unavailable."
|
||||||
msgstr "Live updates unavailable."
|
msgstr "Live updates unavailable."
|
||||||
|
|
||||||
#: src/components/UserListPopover.tsx:231
|
#: src/components/UserListPopover.tsx:231
|
||||||
#: src/pages/Notifications.tsx:442
|
#: src/pages/Notifications.tsx:447
|
||||||
msgid "Load more"
|
msgid "Load more"
|
||||||
msgstr "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
|
#: src/pages/DumpEdit.tsx:155
|
||||||
msgid "Loading dump…"
|
msgid "Loading dump…"
|
||||||
msgstr "Loading dump…"
|
msgstr "Loading dump…"
|
||||||
@@ -658,6 +686,7 @@ msgid "Loading profile…"
|
|||||||
msgstr "Loading profile…"
|
msgstr "Loading profile…"
|
||||||
|
|
||||||
#: src/components/CategoryManager.tsx:52
|
#: src/components/CategoryManager.tsx:52
|
||||||
|
#: src/components/ChatModal.tsx:320
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||||
#: src/components/TextEditor.tsx:289
|
#: src/components/TextEditor.tsx:289
|
||||||
#: src/components/UserListPopover.tsx:192
|
#: src/components/UserListPopover.tsx:192
|
||||||
@@ -666,8 +695,8 @@ msgstr "Loading profile…"
|
|||||||
#: src/pages/index/HotFeed.tsx:32
|
#: src/pages/index/HotFeed.tsx:32
|
||||||
#: src/pages/index/JournalFeed.tsx:29
|
#: src/pages/index/JournalFeed.tsx:29
|
||||||
#: src/pages/index/NewFeed.tsx:32
|
#: src/pages/index/NewFeed.tsx:32
|
||||||
#: src/pages/Notifications.tsx:365
|
#: src/pages/Notifications.tsx:370
|
||||||
#: src/pages/Notifications.tsx:441
|
#: src/pages/Notifications.tsx:446
|
||||||
#: src/pages/UserDumps.tsx:54
|
#: src/pages/UserDumps.tsx:54
|
||||||
#: src/pages/UserPlaylists.tsx:345
|
#: src/pages/UserPlaylists.tsx:345
|
||||||
#: src/pages/UserPublicProfile.tsx:1016
|
#: src/pages/UserPublicProfile.tsx:1016
|
||||||
@@ -677,7 +706,7 @@ msgstr "Loading profile…"
|
|||||||
msgid "Loading…"
|
msgid "Loading…"
|
||||||
msgstr "Loading…"
|
msgstr "Loading…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:111
|
#: src/components/AppHeader.tsx:113
|
||||||
#: src/pages/UserLogin.tsx:34
|
#: src/pages/UserLogin.tsx:34
|
||||||
#: src/pages/UserLogin.tsx:59
|
#: src/pages/UserLogin.tsx:59
|
||||||
#: src/pages/UserLogin.tsx:78
|
#: src/pages/UserLogin.tsx:78
|
||||||
@@ -722,7 +751,7 @@ msgstr "Moderator"
|
|||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Name"
|
msgstr "Name"
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:358
|
#: src/pages/Notifications.tsx:363
|
||||||
msgid "new"
|
msgid "new"
|
||||||
msgstr "new"
|
msgstr "new"
|
||||||
|
|
||||||
@@ -776,6 +805,10 @@ msgstr "No followed playlists yet."
|
|||||||
msgid "No invitees yet."
|
msgid "No invitees yet."
|
||||||
msgstr "No invitees yet."
|
msgstr "No invitees yet."
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:328
|
||||||
|
msgid "No messages yet. Say hello!"
|
||||||
|
msgstr "No messages yet. Say hello!"
|
||||||
|
|
||||||
#: src/components/UserListPopover.tsx:200
|
#: src/components/UserListPopover.tsx:200
|
||||||
msgid "No one yet."
|
msgid "No one yet."
|
||||||
msgstr "No one yet."
|
msgstr "No one yet."
|
||||||
@@ -798,7 +831,7 @@ msgstr "No users match \"{q}\"."
|
|||||||
msgid "Not following anyone yet."
|
msgid "Not following anyone yet."
|
||||||
msgstr "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/UserDumps.tsx:99
|
||||||
#: src/pages/UserPublicProfile.tsx:1338
|
#: src/pages/UserPublicProfile.tsx:1338
|
||||||
#: src/pages/UserPublicProfile.tsx:1460
|
#: src/pages/UserPublicProfile.tsx:1460
|
||||||
@@ -807,8 +840,8 @@ msgid "Nothing here yet."
|
|||||||
msgstr "Nothing here yet."
|
msgstr "Nothing here yet."
|
||||||
|
|
||||||
#: src/components/NotificationBell.tsx:42
|
#: src/components/NotificationBell.tsx:42
|
||||||
#: src/pages/Notifications.tsx:260
|
#: src/pages/Notifications.tsx:265
|
||||||
#: src/pages/Notifications.tsx:354
|
#: src/pages/Notifications.tsx:359
|
||||||
msgid "Notifications"
|
msgid "Notifications"
|
||||||
msgstr "Notifications"
|
msgstr "Notifications"
|
||||||
|
|
||||||
@@ -855,7 +888,7 @@ msgstr "Passwords do not match"
|
|||||||
msgid "Playlist title"
|
msgid "Playlist title"
|
||||||
msgstr "Playlist title"
|
msgstr "Playlist title"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:85
|
#: src/components/AppHeader.tsx:86
|
||||||
#: src/components/UserMenu.tsx:62
|
#: src/components/UserMenu.tsx:62
|
||||||
#: src/pages/Search.tsx:177
|
#: src/pages/Search.tsx:177
|
||||||
#: src/pages/UserPlaylists.tsx:371
|
#: src/pages/UserPlaylists.tsx:371
|
||||||
@@ -890,7 +923,7 @@ msgstr "Posting…"
|
|||||||
#: src/components/JournalCard.tsx:121
|
#: src/components/JournalCard.tsx:121
|
||||||
#: src/components/PlaylistCard.tsx:73
|
#: src/components/PlaylistCard.tsx:73
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:55
|
#: src/components/PlaylistMembershipPanel.tsx:55
|
||||||
#: src/pages/Dump.tsx:432
|
#: src/pages/Dump.tsx:435
|
||||||
#: src/pages/PlaylistDetail.tsx:644
|
#: src/pages/PlaylistDetail.tsx:644
|
||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "private"
|
msgstr "private"
|
||||||
@@ -932,7 +965,7 @@ msgstr "Registering…"
|
|||||||
msgid "Registration failed"
|
msgid "Registration failed"
|
||||||
msgstr "Registration failed"
|
msgstr "Registration failed"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:499
|
#: src/pages/Dump.tsx:502
|
||||||
msgid "Related"
|
msgid "Related"
|
||||||
msgstr "Related"
|
msgstr "Related"
|
||||||
|
|
||||||
@@ -977,7 +1010,7 @@ msgstr "Reset password"
|
|||||||
msgid "Reset to default"
|
msgid "Reset to default"
|
||||||
msgstr "Reset to default"
|
msgstr "Reset to default"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:254
|
#: src/pages/Dump.tsx:257
|
||||||
#: src/pages/DumpEdit.tsx:172
|
#: src/pages/DumpEdit.tsx:172
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
msgstr "Retry"
|
msgstr "Retry"
|
||||||
@@ -986,8 +1019,9 @@ msgstr "Retry"
|
|||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr "Role"
|
msgstr "Role"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:151
|
||||||
#: src/components/CommentThread.tsx:328
|
#: src/components/CommentThread.tsx:328
|
||||||
#: src/pages/Dump.tsx:365
|
#: src/pages/Dump.tsx:368
|
||||||
#: src/pages/DumpEdit.tsx:466
|
#: src/pages/DumpEdit.tsx:466
|
||||||
#: src/pages/PlaylistDetail.tsx:927
|
#: src/pages/PlaylistDetail.tsx:927
|
||||||
#: src/pages/UserPublicProfile.tsx:1666
|
#: src/pages/UserPublicProfile.tsx:1666
|
||||||
@@ -997,7 +1031,7 @@ msgstr "Save"
|
|||||||
|
|
||||||
#: src/components/ChangePasswordModal.tsx:100
|
#: src/components/ChangePasswordModal.tsx:100
|
||||||
#: src/components/CommentThread.tsx:329
|
#: src/components/CommentThread.tsx:329
|
||||||
#: src/pages/Dump.tsx:364
|
#: src/pages/Dump.tsx:367
|
||||||
#: src/pages/PlaylistDetail.tsx:923
|
#: src/pages/PlaylistDetail.tsx:923
|
||||||
#: src/pages/ResetPassword.tsx:126
|
#: src/pages/ResetPassword.tsx:126
|
||||||
#: src/pages/UserPublicProfile.tsx:1663
|
#: src/pages/UserPublicProfile.tsx:1663
|
||||||
@@ -1005,7 +1039,7 @@ msgstr "Save"
|
|||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Saving…"
|
msgstr "Saving…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:65
|
#: src/components/AppHeader.tsx:66
|
||||||
#: src/components/SearchBar.tsx:78
|
#: src/components/SearchBar.tsx:78
|
||||||
#: src/pages/Search.tsx:50
|
#: src/pages/Search.tsx:50
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
@@ -1023,6 +1057,10 @@ msgstr "Search failed"
|
|||||||
msgid "Searching…"
|
msgid "Searching…"
|
||||||
msgstr "Searching…"
|
msgstr "Searching…"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:369
|
||||||
|
msgid "Send"
|
||||||
|
msgstr "Send"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:141
|
#: src/pages/UserLogin.tsx:141
|
||||||
msgid "Send reset link"
|
msgid "Send reset link"
|
||||||
msgstr "Send reset link"
|
msgstr "Send reset link"
|
||||||
@@ -1031,7 +1069,7 @@ msgstr "Send reset link"
|
|||||||
msgid "Sending…"
|
msgid "Sending…"
|
||||||
msgstr "Sending…"
|
msgstr "Sending…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:100
|
#: src/components/AppHeader.tsx:102
|
||||||
msgid "Server unreachable"
|
msgid "Server unreachable"
|
||||||
msgstr "Server unreachable"
|
msgstr "Server unreachable"
|
||||||
|
|
||||||
@@ -1061,6 +1099,10 @@ msgstr "Style"
|
|||||||
msgid "Submit search"
|
msgid "Submit search"
|
||||||
msgstr "Submit search"
|
msgstr "Submit search"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:219
|
||||||
|
msgid "the chat"
|
||||||
|
msgstr "the chat"
|
||||||
|
|
||||||
#: src/pages/UserRegister.tsx:97
|
#: src/pages/UserRegister.tsx:97
|
||||||
msgid "This invite link is missing, expired, or already used."
|
msgid "This invite link is missing, expired, or already used."
|
||||||
msgstr "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."
|
msgid "Title is required."
|
||||||
msgstr "Title is required."
|
msgstr "Title is required."
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:392
|
#: src/pages/Notifications.tsx:397
|
||||||
msgid "Today"
|
msgid "Today"
|
||||||
msgstr "Today"
|
msgstr "Today"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:350
|
||||||
|
msgid "Type a message…"
|
||||||
|
msgstr "Type a message…"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:747
|
#: src/pages/PlaylistDetail.tsx:747
|
||||||
msgid "Undo"
|
msgid "Undo"
|
||||||
msgstr "Undo"
|
msgstr "Undo"
|
||||||
@@ -1194,11 +1240,11 @@ msgstr "Why?"
|
|||||||
msgid "Write a reply…"
|
msgid "Write a reply…"
|
||||||
msgstr "Write a reply…"
|
msgstr "Write a reply…"
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:394
|
#: src/pages/Notifications.tsx:399
|
||||||
msgid "Yesterday"
|
msgid "Yesterday"
|
||||||
msgstr "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."
|
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."
|
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"
|
msgid "← Back"
|
||||||
msgstr "← Retour"
|
msgstr "← Retour"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:261
|
#: src/pages/Dump.tsx:264
|
||||||
#: src/pages/Dump.tsx:491
|
#: src/pages/Dump.tsx:494
|
||||||
#: src/pages/DumpEdit.tsx:179
|
#: src/pages/DumpEdit.tsx:179
|
||||||
msgid "← Back to all dumps"
|
msgid "← Back to all dumps"
|
||||||
msgstr "← Retour à toutes les recos"
|
msgstr "← Retour à toutes les recos"
|
||||||
@@ -67,61 +67,61 @@ msgstr "+ Inviter quelqu'un"
|
|||||||
msgid "+ New playlist"
|
msgid "+ New playlist"
|
||||||
msgstr "+ Nouvelle collection"
|
msgstr "+ Nouvelle collection"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:332
|
#: src/pages/Dump.tsx:335
|
||||||
msgid "+ Playlist"
|
msgid "+ Playlist"
|
||||||
msgstr "+ Collection"
|
msgstr "+ Collection"
|
||||||
|
|
||||||
#. placeholder {0}: d.commenterUsername
|
#. placeholder {0}: d.commenterUsername
|
||||||
#. placeholder {1}: d.dumpTitle
|
#. placeholder {1}: d.dumpTitle
|
||||||
#: src/pages/Notifications.tsx:195
|
#: src/pages/Notifications.tsx:196
|
||||||
msgid "<0>{0}</0> commented on <1>{1}</1>"
|
msgid "<0>{0}</0> commented on <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> a commenté sur <1>{1}</1>"
|
msgstr "<0>{0}</0> a commenté sur <1>{1}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: d.followerUsername
|
#. placeholder {0}: d.followerUsername
|
||||||
#. placeholder {1}: d.playlistTitle
|
#. placeholder {1}: d.playlistTitle
|
||||||
#: src/pages/Notifications.tsx:155
|
#: src/pages/Notifications.tsx:156
|
||||||
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
|
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: d.likerUsername
|
#. placeholder {0}: d.likerUsername
|
||||||
#. placeholder {1}: d.dumpTitle
|
#. 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>"
|
msgid "<0>{0}</0> liked your comment on <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> a aimé votre commentaire sur <1>{1}</1>"
|
msgstr "<0>{0}</0> a aimé votre commentaire sur <1>{1}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: d.mentionerUsername
|
#. placeholder {0}: d.mentionerUsername
|
||||||
#: src/pages/Notifications.tsx:217
|
#: src/pages/Notifications.tsx:222
|
||||||
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||||
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
|
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: d.dumperUsername
|
#. placeholder {0}: d.dumperUsername
|
||||||
#. placeholder {1}: d.dumpTitle
|
#. placeholder {1}: d.dumpTitle
|
||||||
#: src/pages/Notifications.tsx:165
|
#: src/pages/Notifications.tsx:166
|
||||||
msgid "<0>{0}</0> posted <1>{1}</1>"
|
msgid "<0>{0}</0> posted <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> a publié <1>{1}</1>"
|
msgstr "<0>{0}</0> a publié <1>{1}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: d.followerUsername
|
#. placeholder {0}: d.followerUsername
|
||||||
#: src/pages/Notifications.tsx:146
|
#: src/pages/Notifications.tsx:147
|
||||||
msgid "<0>{0}</0> started following you"
|
msgid "<0>{0}</0> started following you"
|
||||||
msgstr "<0>{0}</0> a commencé à vous suivre"
|
msgstr "<0>{0}</0> a commencé à vous suivre"
|
||||||
|
|
||||||
#. placeholder {0}: d.voterUsername
|
#. placeholder {0}: d.voterUsername
|
||||||
#. placeholder {1}: d.dumpTitle
|
#. placeholder {1}: d.dumpTitle
|
||||||
#: src/pages/Notifications.tsx:185
|
#: src/pages/Notifications.tsx:186
|
||||||
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
|
msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
|
||||||
|
|
||||||
#. placeholder {0}: d.dumpTitle
|
#. placeholder {0}: d.dumpTitle
|
||||||
#. placeholder {1}: d.playlistTitle
|
#. placeholder {1}: d.playlistTitle
|
||||||
#: src/pages/Notifications.tsx:175
|
#: src/pages/Notifications.tsx:176
|
||||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||||
msgstr "<0>{0}</0> a été ajouté à <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"
|
msgid "a comment"
|
||||||
msgstr "un commentaire"
|
msgstr "un commentaire"
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:215
|
#: src/pages/Notifications.tsx:220
|
||||||
msgid "a post"
|
msgid "a post"
|
||||||
msgstr "une publication"
|
msgstr "une publication"
|
||||||
|
|
||||||
@@ -180,17 +180,18 @@ msgstr "Auto"
|
|||||||
msgid "Back to login"
|
msgid "Back to login"
|
||||||
msgstr "Retour à la connexion"
|
msgstr "Retour à la connexion"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:211
|
#: src/contexts/WSProvider.tsx:268
|
||||||
#: src/contexts/WSProvider.tsx:438
|
#: src/contexts/WSProvider.tsx:570
|
||||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||||
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
|
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
|
||||||
|
|
||||||
#: src/components/CategoryManager.tsx:210
|
#: src/components/CategoryManager.tsx:210
|
||||||
#: src/components/CategoryManager.tsx:211
|
#: src/components/CategoryManager.tsx:211
|
||||||
|
#: src/components/ChatModal.tsx:158
|
||||||
#: src/components/CommentThread.tsx:124
|
#: src/components/CommentThread.tsx:124
|
||||||
#: src/components/ConfirmModal.tsx:32
|
#: src/components/ConfirmModal.tsx:32
|
||||||
#: src/components/form/FormActions.tsx:32
|
#: src/components/form/FormActions.tsx:32
|
||||||
#: src/pages/Dump.tsx:373
|
#: src/pages/Dump.tsx:376
|
||||||
#: src/pages/DumpEdit.tsx:463
|
#: src/pages/DumpEdit.tsx:463
|
||||||
#: src/pages/PlaylistDetail.tsx:920
|
#: src/pages/PlaylistDetail.tsx:920
|
||||||
#: src/pages/UserPublicProfile.tsx:1674
|
#: src/pages/UserPublicProfile.tsx:1674
|
||||||
@@ -202,7 +203,7 @@ msgstr "Annuler"
|
|||||||
msgid "Cancel removal"
|
msgid "Cancel removal"
|
||||||
msgstr "Annuler la suppression"
|
msgstr "Annuler la suppression"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:65
|
#: src/components/AppHeader.tsx:66
|
||||||
msgid "Cancel search"
|
msgid "Cancel search"
|
||||||
msgstr "Annuler la recherche"
|
msgstr "Annuler la recherche"
|
||||||
|
|
||||||
@@ -228,6 +229,18 @@ msgstr "Changer le mot de passe"
|
|||||||
msgid "Change password…"
|
msgid "Change password…"
|
||||||
msgstr "Changer le mot de passe…"
|
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
|
#: src/pages/UserRegister.tsx:85
|
||||||
msgid "Checking invite…"
|
msgid "Checking invite…"
|
||||||
msgstr "Vérification de l'invitation…"
|
msgstr "Vérification de l'invitation…"
|
||||||
@@ -298,6 +311,9 @@ msgstr "Onglet par défaut"
|
|||||||
|
|
||||||
#: src/components/CategoryManager.tsx:221
|
#: src/components/CategoryManager.tsx:221
|
||||||
#: src/components/CategoryManager.tsx:222
|
#: src/components/CategoryManager.tsx:222
|
||||||
|
#: src/components/ChatModal.tsx:112
|
||||||
|
#: src/components/ChatModal.tsx:113
|
||||||
|
#: src/components/ChatModal.tsx:168
|
||||||
#: src/components/CommentThread.tsx:376
|
#: src/components/CommentThread.tsx:376
|
||||||
#: src/components/CommentThread.tsx:382
|
#: src/components/CommentThread.tsx:382
|
||||||
#: src/components/ConfirmModal.tsx:16
|
#: src/components/ConfirmModal.tsx:16
|
||||||
@@ -333,6 +349,10 @@ msgstr "Supprimer ce commentaire ?"
|
|||||||
msgid "Delete this dump? This cannot be undone."
|
msgid "Delete this dump? This cannot be undone."
|
||||||
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:167
|
||||||
|
msgid "Delete this message?"
|
||||||
|
msgstr "Supprimer ce message ?"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:757
|
#: src/pages/PlaylistDetail.tsx:757
|
||||||
#: src/pages/UserPlaylists.tsx:467
|
#: src/pages/UserPlaylists.tsx:467
|
||||||
msgid "Delete this playlist? This cannot be undone."
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
@@ -355,7 +375,7 @@ msgstr "Déposez un fichier ici"
|
|||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Déposez un fichier de remplacement ici"
|
msgstr "Déposez un fichier de remplacement ici"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:103
|
#: src/components/AppHeader.tsx:105
|
||||||
msgid "Dump"
|
msgid "Dump"
|
||||||
msgstr "Reco"
|
msgstr "Reco"
|
||||||
|
|
||||||
@@ -379,12 +399,14 @@ msgstr "Recos"
|
|||||||
msgid "Dumps ({0}{1})"
|
msgid "Dumps ({0}{1})"
|
||||||
msgstr "Recos ({0}{1})"
|
msgstr "Recos ({0}{1})"
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:395
|
#: src/pages/Notifications.tsx:400
|
||||||
msgid "Earlier"
|
msgid "Earlier"
|
||||||
msgstr "Plus tôt"
|
msgstr "Plus tôt"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:101
|
||||||
|
#: src/components/ChatModal.tsx:102
|
||||||
#: src/components/CommentThread.tsx:367
|
#: src/components/CommentThread.tsx:367
|
||||||
#: src/pages/Dump.tsx:487
|
#: src/pages/Dump.tsx:490
|
||||||
#: src/pages/PlaylistDetail.tsx:625
|
#: src/pages/PlaylistDetail.tsx:625
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Modifier"
|
msgstr "Modifier"
|
||||||
@@ -400,18 +422,20 @@ msgstr "Modifier le titre"
|
|||||||
|
|
||||||
#. placeholder {0}: relativeTime(comment.updatedAt)
|
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||||
#. placeholder {0}: relativeTime(playlist.updatedAt)
|
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||||
|
#: src/components/ChatModal.tsx:90
|
||||||
#: src/components/CommentThread.tsx:317
|
#: src/components/CommentThread.tsx:317
|
||||||
#: src/pages/Dump.tsx:426
|
#: src/pages/Dump.tsx:429
|
||||||
#: src/pages/PlaylistDetail.tsx:664
|
#: src/pages/PlaylistDetail.tsx:664
|
||||||
msgid "edited {0}"
|
msgid "edited {0}"
|
||||||
msgstr "modifié {0}"
|
msgstr "modifié {0}"
|
||||||
|
|
||||||
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||||
#. placeholder {0}: playlist.updatedAt.toLocaleString()
|
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||||
|
#: src/components/ChatModal.tsx:88
|
||||||
#: src/components/CommentThread.tsx:315
|
#: src/components/CommentThread.tsx:315
|
||||||
#: src/pages/Dump.tsx:424
|
#: src/pages/Dump.tsx:427
|
||||||
#: src/pages/PlaylistDetail.tsx:661
|
#: src/pages/PlaylistDetail.tsx:661
|
||||||
msgid "Edited {0}"
|
msgid "Edited {0}"
|
||||||
msgstr "Modifié le {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/HotFeed.tsx:36
|
||||||
#: src/pages/index/JournalFeed.tsx:33
|
#: src/pages/index/JournalFeed.tsx:33
|
||||||
#: src/pages/index/NewFeed.tsx:36
|
#: 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:1022
|
||||||
#: src/pages/UserPublicProfile.tsx:1064
|
#: src/pages/UserPublicProfile.tsx:1064
|
||||||
#: src/pages/UserPublicProfile.tsx:1109
|
#: src/pages/UserPublicProfile.tsx:1109
|
||||||
@@ -619,20 +643,24 @@ msgstr "Clair"
|
|||||||
msgid "Like"
|
msgid "Like"
|
||||||
msgstr "Aimer"
|
msgstr "Aimer"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:437
|
#: src/contexts/WSProvider.tsx:569
|
||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:130
|
#: src/components/AppHeader.tsx:132
|
||||||
msgid "Live updates unavailable."
|
msgid "Live updates unavailable."
|
||||||
msgstr "Mises à jour en direct indisponibles."
|
msgstr "Mises à jour en direct indisponibles."
|
||||||
|
|
||||||
#: src/components/UserListPopover.tsx:231
|
#: src/components/UserListPopover.tsx:231
|
||||||
#: src/pages/Notifications.tsx:442
|
#: src/pages/Notifications.tsx:447
|
||||||
msgid "Load more"
|
msgid "Load more"
|
||||||
msgstr "Charger plus"
|
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
|
#: src/pages/DumpEdit.tsx:155
|
||||||
msgid "Loading dump…"
|
msgid "Loading dump…"
|
||||||
msgstr "Chargement de la reco…"
|
msgstr "Chargement de la reco…"
|
||||||
@@ -658,6 +686,7 @@ msgid "Loading profile…"
|
|||||||
msgstr "Chargement du profil…"
|
msgstr "Chargement du profil…"
|
||||||
|
|
||||||
#: src/components/CategoryManager.tsx:52
|
#: src/components/CategoryManager.tsx:52
|
||||||
|
#: src/components/ChatModal.tsx:320
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||||
#: src/components/TextEditor.tsx:289
|
#: src/components/TextEditor.tsx:289
|
||||||
#: src/components/UserListPopover.tsx:192
|
#: src/components/UserListPopover.tsx:192
|
||||||
@@ -666,8 +695,8 @@ msgstr "Chargement du profil…"
|
|||||||
#: src/pages/index/HotFeed.tsx:32
|
#: src/pages/index/HotFeed.tsx:32
|
||||||
#: src/pages/index/JournalFeed.tsx:29
|
#: src/pages/index/JournalFeed.tsx:29
|
||||||
#: src/pages/index/NewFeed.tsx:32
|
#: src/pages/index/NewFeed.tsx:32
|
||||||
#: src/pages/Notifications.tsx:365
|
#: src/pages/Notifications.tsx:370
|
||||||
#: src/pages/Notifications.tsx:441
|
#: src/pages/Notifications.tsx:446
|
||||||
#: src/pages/UserDumps.tsx:54
|
#: src/pages/UserDumps.tsx:54
|
||||||
#: src/pages/UserPlaylists.tsx:345
|
#: src/pages/UserPlaylists.tsx:345
|
||||||
#: src/pages/UserPublicProfile.tsx:1016
|
#: src/pages/UserPublicProfile.tsx:1016
|
||||||
@@ -677,7 +706,7 @@ msgstr "Chargement du profil…"
|
|||||||
msgid "Loading…"
|
msgid "Loading…"
|
||||||
msgstr "Chargement…"
|
msgstr "Chargement…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:111
|
#: src/components/AppHeader.tsx:113
|
||||||
#: src/pages/UserLogin.tsx:34
|
#: src/pages/UserLogin.tsx:34
|
||||||
#: src/pages/UserLogin.tsx:59
|
#: src/pages/UserLogin.tsx:59
|
||||||
#: src/pages/UserLogin.tsx:78
|
#: src/pages/UserLogin.tsx:78
|
||||||
@@ -722,7 +751,7 @@ msgstr "Modérateur"
|
|||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Nom"
|
msgstr "Nom"
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:358
|
#: src/pages/Notifications.tsx:363
|
||||||
msgid "new"
|
msgid "new"
|
||||||
msgstr "nouveau"
|
msgstr "nouveau"
|
||||||
|
|
||||||
@@ -776,6 +805,10 @@ msgstr "Pas encore de collections suivies."
|
|||||||
msgid "No invitees yet."
|
msgid "No invitees yet."
|
||||||
msgstr "Aucun invité pour le moment."
|
msgstr "Aucun invité pour le moment."
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:328
|
||||||
|
msgid "No messages yet. Say hello!"
|
||||||
|
msgstr "Aucun message pour l'instant. Dites bonjour !"
|
||||||
|
|
||||||
#: src/components/UserListPopover.tsx:200
|
#: src/components/UserListPopover.tsx:200
|
||||||
msgid "No one yet."
|
msgid "No one yet."
|
||||||
msgstr "Personne pour le moment."
|
msgstr "Personne pour le moment."
|
||||||
@@ -798,7 +831,7 @@ msgstr "Aucun utilisateur ne correspond à « {q} »."
|
|||||||
msgid "Not following anyone yet."
|
msgid "Not following anyone yet."
|
||||||
msgstr "Aucun abonnement pour le moment."
|
msgstr "Aucun abonnement pour le moment."
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:376
|
#: src/pages/Notifications.tsx:381
|
||||||
#: src/pages/UserDumps.tsx:99
|
#: src/pages/UserDumps.tsx:99
|
||||||
#: src/pages/UserPublicProfile.tsx:1338
|
#: src/pages/UserPublicProfile.tsx:1338
|
||||||
#: src/pages/UserPublicProfile.tsx:1460
|
#: src/pages/UserPublicProfile.tsx:1460
|
||||||
@@ -807,8 +840,8 @@ msgid "Nothing here yet."
|
|||||||
msgstr "Rien ici pour l'instant."
|
msgstr "Rien ici pour l'instant."
|
||||||
|
|
||||||
#: src/components/NotificationBell.tsx:42
|
#: src/components/NotificationBell.tsx:42
|
||||||
#: src/pages/Notifications.tsx:260
|
#: src/pages/Notifications.tsx:265
|
||||||
#: src/pages/Notifications.tsx:354
|
#: src/pages/Notifications.tsx:359
|
||||||
msgid "Notifications"
|
msgid "Notifications"
|
||||||
msgstr "Notifications"
|
msgstr "Notifications"
|
||||||
|
|
||||||
@@ -855,7 +888,7 @@ msgstr "Les mots de passe ne correspondent pas"
|
|||||||
msgid "Playlist title"
|
msgid "Playlist title"
|
||||||
msgstr "Titre de la collection"
|
msgstr "Titre de la collection"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:85
|
#: src/components/AppHeader.tsx:86
|
||||||
#: src/components/UserMenu.tsx:62
|
#: src/components/UserMenu.tsx:62
|
||||||
#: src/pages/Search.tsx:177
|
#: src/pages/Search.tsx:177
|
||||||
#: src/pages/UserPlaylists.tsx:371
|
#: src/pages/UserPlaylists.tsx:371
|
||||||
@@ -890,7 +923,7 @@ msgstr "Publication…"
|
|||||||
#: src/components/JournalCard.tsx:121
|
#: src/components/JournalCard.tsx:121
|
||||||
#: src/components/PlaylistCard.tsx:73
|
#: src/components/PlaylistCard.tsx:73
|
||||||
#: src/components/PlaylistMembershipPanel.tsx:55
|
#: src/components/PlaylistMembershipPanel.tsx:55
|
||||||
#: src/pages/Dump.tsx:432
|
#: src/pages/Dump.tsx:435
|
||||||
#: src/pages/PlaylistDetail.tsx:644
|
#: src/pages/PlaylistDetail.tsx:644
|
||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "privé"
|
msgstr "privé"
|
||||||
@@ -932,7 +965,7 @@ msgstr "Inscription…"
|
|||||||
msgid "Registration failed"
|
msgid "Registration failed"
|
||||||
msgstr "Inscription échouée"
|
msgstr "Inscription échouée"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:499
|
#: src/pages/Dump.tsx:502
|
||||||
msgid "Related"
|
msgid "Related"
|
||||||
msgstr "Connexe"
|
msgstr "Connexe"
|
||||||
|
|
||||||
@@ -977,7 +1010,7 @@ msgstr "Réinitialiser le mot de passe"
|
|||||||
msgid "Reset to default"
|
msgid "Reset to default"
|
||||||
msgstr "Réinitialiser par défaut"
|
msgstr "Réinitialiser par défaut"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:254
|
#: src/pages/Dump.tsx:257
|
||||||
#: src/pages/DumpEdit.tsx:172
|
#: src/pages/DumpEdit.tsx:172
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
msgstr "Réessayer"
|
msgstr "Réessayer"
|
||||||
@@ -986,8 +1019,9 @@ msgstr "Réessayer"
|
|||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr "Rôle"
|
msgstr "Rôle"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:151
|
||||||
#: src/components/CommentThread.tsx:328
|
#: src/components/CommentThread.tsx:328
|
||||||
#: src/pages/Dump.tsx:365
|
#: src/pages/Dump.tsx:368
|
||||||
#: src/pages/DumpEdit.tsx:466
|
#: src/pages/DumpEdit.tsx:466
|
||||||
#: src/pages/PlaylistDetail.tsx:927
|
#: src/pages/PlaylistDetail.tsx:927
|
||||||
#: src/pages/UserPublicProfile.tsx:1666
|
#: src/pages/UserPublicProfile.tsx:1666
|
||||||
@@ -997,7 +1031,7 @@ msgstr "Enregistrer"
|
|||||||
|
|
||||||
#: src/components/ChangePasswordModal.tsx:100
|
#: src/components/ChangePasswordModal.tsx:100
|
||||||
#: src/components/CommentThread.tsx:329
|
#: src/components/CommentThread.tsx:329
|
||||||
#: src/pages/Dump.tsx:364
|
#: src/pages/Dump.tsx:367
|
||||||
#: src/pages/PlaylistDetail.tsx:923
|
#: src/pages/PlaylistDetail.tsx:923
|
||||||
#: src/pages/ResetPassword.tsx:126
|
#: src/pages/ResetPassword.tsx:126
|
||||||
#: src/pages/UserPublicProfile.tsx:1663
|
#: src/pages/UserPublicProfile.tsx:1663
|
||||||
@@ -1005,7 +1039,7 @@ msgstr "Enregistrer"
|
|||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Enregistrement…"
|
msgstr "Enregistrement…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:65
|
#: src/components/AppHeader.tsx:66
|
||||||
#: src/components/SearchBar.tsx:78
|
#: src/components/SearchBar.tsx:78
|
||||||
#: src/pages/Search.tsx:50
|
#: src/pages/Search.tsx:50
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
@@ -1023,6 +1057,10 @@ msgstr "Recherche échouée"
|
|||||||
msgid "Searching…"
|
msgid "Searching…"
|
||||||
msgstr "Recherche…"
|
msgstr "Recherche…"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:369
|
||||||
|
msgid "Send"
|
||||||
|
msgstr "Envoyer"
|
||||||
|
|
||||||
#: src/pages/UserLogin.tsx:141
|
#: src/pages/UserLogin.tsx:141
|
||||||
msgid "Send reset link"
|
msgid "Send reset link"
|
||||||
msgstr "Envoyer le lien de réinitialisation"
|
msgstr "Envoyer le lien de réinitialisation"
|
||||||
@@ -1031,7 +1069,7 @@ msgstr "Envoyer le lien de réinitialisation"
|
|||||||
msgid "Sending…"
|
msgid "Sending…"
|
||||||
msgstr "Envoi…"
|
msgstr "Envoi…"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:100
|
#: src/components/AppHeader.tsx:102
|
||||||
msgid "Server unreachable"
|
msgid "Server unreachable"
|
||||||
msgstr "Serveur inaccessible"
|
msgstr "Serveur inaccessible"
|
||||||
|
|
||||||
@@ -1061,6 +1099,10 @@ msgstr "Style"
|
|||||||
msgid "Submit search"
|
msgid "Submit search"
|
||||||
msgstr "Lancer la recherche"
|
msgstr "Lancer la recherche"
|
||||||
|
|
||||||
|
#: src/pages/Notifications.tsx:219
|
||||||
|
msgid "the chat"
|
||||||
|
msgstr "la tribune"
|
||||||
|
|
||||||
#: src/pages/UserRegister.tsx:97
|
#: src/pages/UserRegister.tsx:97
|
||||||
msgid "This invite link is missing, expired, or already used."
|
msgid "This invite link is missing, expired, or already used."
|
||||||
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
|
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
|
||||||
@@ -1091,10 +1133,14 @@ msgstr "Titre"
|
|||||||
msgid "Title is required."
|
msgid "Title is required."
|
||||||
msgstr "Un titre est requis."
|
msgstr "Un titre est requis."
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:392
|
#: src/pages/Notifications.tsx:397
|
||||||
msgid "Today"
|
msgid "Today"
|
||||||
msgstr "Aujourd'hui"
|
msgstr "Aujourd'hui"
|
||||||
|
|
||||||
|
#: src/components/ChatModal.tsx:350
|
||||||
|
msgid "Type a message…"
|
||||||
|
msgstr "Écrivez un message…"
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:747
|
#: src/pages/PlaylistDetail.tsx:747
|
||||||
msgid "Undo"
|
msgid "Undo"
|
||||||
msgstr "Annuler"
|
msgstr "Annuler"
|
||||||
@@ -1194,11 +1240,11 @@ msgstr "Pourquoi ?"
|
|||||||
msgid "Write a reply…"
|
msgid "Write a reply…"
|
||||||
msgstr "Écrire une réponse…"
|
msgstr "Écrire une réponse…"
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:394
|
#: src/pages/Notifications.tsx:399
|
||||||
msgid "Yesterday"
|
msgid "Yesterday"
|
||||||
msgstr "Hier"
|
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."
|
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."
|
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
|
* Playlists
|
||||||
*/
|
*/
|
||||||
@@ -328,7 +352,7 @@ export interface DumpUpvotedData {
|
|||||||
export interface UserMentionedData {
|
export interface UserMentionedData {
|
||||||
mentionerId: string;
|
mentionerId: string;
|
||||||
mentionerUsername: string;
|
mentionerUsername: string;
|
||||||
contextType: "comment" | "dump" | "playlist";
|
contextType: "comment" | "dump" | "playlist" | "chat";
|
||||||
contextId: string;
|
contextId: string;
|
||||||
contextTitle: string;
|
contextTitle: string;
|
||||||
dumpId?: string;
|
dumpId?: string;
|
||||||
@@ -498,6 +522,21 @@ export interface WSForceLogoutMessage {
|
|||||||
type: "force_logout";
|
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 =
|
export type IncomingWSMessage =
|
||||||
| WSPingMessage
|
| WSPingMessage
|
||||||
| WSWelcomeMessage
|
| WSWelcomeMessage
|
||||||
@@ -519,7 +558,10 @@ export type IncomingWSMessage =
|
|||||||
| WSCommentDeletedMessage
|
| WSCommentDeletedMessage
|
||||||
| WSNotificationCreatedMessage
|
| WSNotificationCreatedMessage
|
||||||
| WSErrorMessage
|
| WSErrorMessage
|
||||||
| WSForceLogoutMessage;
|
| WSForceLogoutMessage
|
||||||
|
| WSChatMessageMessage
|
||||||
|
| WSChatMessageUpdatedMessage
|
||||||
|
| WSChatMessageDeletedMessage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket messages — client → server (outgoing)
|
* WebSocket messages — client → server (outgoing)
|
||||||
@@ -545,12 +587,37 @@ export interface WSCommentLikeRemoveMessage {
|
|||||||
commentId: string;
|
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 =
|
export type OutgoingWSMessage =
|
||||||
| WSPongMessage
|
| WSPongMessage
|
||||||
| WSVoteCastMessage
|
| WSVoteCastMessage
|
||||||
| WSVoteRemoveMessage
|
| WSVoteRemoveMessage
|
||||||
| WSCommentLikeCastMessage
|
| WSCommentLikeCastMessage
|
||||||
| WSCommentLikeRemoveMessage;
|
| WSCommentLikeRemoveMessage
|
||||||
|
| WSChatSendMessage
|
||||||
|
| WSChatFocusMessage
|
||||||
|
| WSChatEditMessage
|
||||||
|
| WSChatDeleteMessage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Follows
|
* Follows
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ function notificationLink(n: Notification): string {
|
|||||||
return `/dumps/${d.dumpId}#comment-${d.contextId}`;
|
return `/dumps/${d.dumpId}#comment-${d.contextId}`;
|
||||||
}
|
}
|
||||||
if (d.contextType === "dump") return `/dumps/${d.contextId}`;
|
if (d.contextType === "dump") return `/dumps/${d.contextId}`;
|
||||||
|
if (d.contextType === "chat") return `/?chat=open`;
|
||||||
return `/playlists/${d.contextId}`;
|
return `/playlists/${d.contextId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +213,11 @@ function notificationContent(n: Notification): React.ReactNode {
|
|||||||
case "user_mentioned": {
|
case "user_mentioned": {
|
||||||
const d = data as UserMentionedData;
|
const d = data as UserMentionedData;
|
||||||
const where = d.contextTitle ||
|
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 (
|
return (
|
||||||
<Trans>
|
<Trans>
|
||||||
<strong>{d.mentionerUsername}</strong>
|
<strong>{d.mentionerUsername}</strong>
|
||||||
|
|||||||
@@ -151,11 +151,12 @@
|
|||||||
/* ── Buttons — square corners, modest hard shadow ────────────────── */
|
/* ── Buttons — square corners, modest hard shadow ────────────────── */
|
||||||
/* `.dump-fab` is exempt from the generic button chrome — it keeps its own
|
/* `.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. */
|
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;
|
--fab-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-style="brutalist"] button:not(.dump-fab) {
|
[data-style="brutalist"] button:not(.dump-fab, .chat-fab) {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: 2px solid var(--color-border);
|
border: 2px solid var(--color-border);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -163,7 +164,7 @@
|
|||||||
transition: box-shadow 0.08s ease, transform 0.08s ease, border-color 0.1s;
|
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);
|
border-color: var(--color-border);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transform: translate(2px, 2px);
|
transform: translate(2px, 2px);
|
||||||
|
|||||||
@@ -254,11 +254,12 @@
|
|||||||
/* ── Buttons — chunky Win95 outset bevel, press = inset ──────────── */
|
/* ── Buttons — chunky Win95 outset bevel, press = inset ──────────── */
|
||||||
/* `.dump-fab` is exempt from the generic button chrome — it keeps its own
|
/* `.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. */
|
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;
|
--fab-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-style="geocities"] button:not(.dump-fab) {
|
[data-style="geocities"] button:not(.dump-fab, .chat-fab) {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: 3px outset var(--color-border);
|
border: 3px outset var(--color-border);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -325,7 +326,10 @@
|
|||||||
|
|
||||||
[data-style="geocities"] .visibility-toggle button {
|
[data-style="geocities"] .visibility-toggle button {
|
||||||
border-radius: 0;
|
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 ─── */
|
/* ── Cards & panels — ridged "table border" with a soft neon halo ─── */
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Sharp, rectilinear corners throughout — newsprint has no rounding. */
|
/* 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"] .dump-card,
|
||||||
[data-style="nyt"] .playlist-card,
|
[data-style="nyt"] .playlist-card,
|
||||||
[data-style="nyt"] .journal-card,
|
[data-style="nyt"] .journal-card,
|
||||||
@@ -184,7 +184,8 @@
|
|||||||
|
|
||||||
/* This theme squares everything off — opt the FAB into a square too (it keeps
|
/* This theme squares everything off — opt the FAB into a square too (it keeps
|
||||||
its animations; only the corners change). */
|
its animations; only the corners change). */
|
||||||
[data-style="nyt"] .dump-fab {
|
[data-style="nyt"] .dump-fab,
|
||||||
|
[data-style="nyt"] .chat-fab {
|
||||||
--fab-radius: 0;
|
--fab-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type Permission =
|
|||||||
| "dump:moderate"
|
| "dump:moderate"
|
||||||
| "comment:moderate"
|
| "comment:moderate"
|
||||||
| "playlist:moderate"
|
| "playlist:moderate"
|
||||||
|
| "chat:moderate"
|
||||||
| "user:manage"
|
| "user:manage"
|
||||||
| "category:manage";
|
| "category:manage";
|
||||||
|
|
||||||
@@ -12,13 +13,19 @@ const ALL_PERMISSIONS: readonly Permission[] = [
|
|||||||
"dump:moderate",
|
"dump:moderate",
|
||||||
"comment:moderate",
|
"comment:moderate",
|
||||||
"playlist:moderate",
|
"playlist:moderate",
|
||||||
|
"chat:moderate",
|
||||||
"user:manage",
|
"user:manage",
|
||||||
"category:manage",
|
"category:manage",
|
||||||
];
|
];
|
||||||
|
|
||||||
const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
||||||
admin: ALL_PERMISSIONS,
|
admin: ALL_PERMISSIONS,
|
||||||
moderator: ["dump:moderate", "comment:moderate", "playlist:moderate"],
|
moderator: [
|
||||||
|
"dump:moderate",
|
||||||
|
"comment:moderate",
|
||||||
|
"playlist:moderate",
|
||||||
|
"chat:moderate",
|
||||||
|
],
|
||||||
user: [],
|
user: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user