v3: added a chatbox
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
@@ -6,6 +6,8 @@ import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts";
|
||||
import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts";
|
||||
import { up as up0006Categories } from "./migrations/0006_categories.ts";
|
||||
import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts";
|
||||
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
|
||||
import { up as up0009ChatMessageUpdatedAt } from "./migrations/0009_chat_message_updated_at.ts";
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@@ -23,6 +25,8 @@ const MIGRATIONS: Migration[] = [
|
||||
{ name: "0005_drop_is_admin", up: up0005DropIsAdmin },
|
||||
{ name: "0006_categories", up: up0006Categories },
|
||||
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
|
||||
{ name: "0008_chat_messages", up: up0008ChatMessages },
|
||||
{ name: "0009_chat_message_updated_at", up: up0009ChatMessageUpdatedAt },
|
||||
];
|
||||
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
|
||||
18
api/db/migrations/0008_chat_messages.ts
Normal file
18
api/db/migrations/0008_chat_messages.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
// Idempotent: safe to run against a fresh db (already created from schema.sql
|
||||
// with this table) or an existing one that predates it.
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);`);
|
||||
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_chat_messages_created ON chat_messages(created_at);`,
|
||||
);
|
||||
}
|
||||
@@ -192,3 +192,13 @@ CREATE INDEX idx_notifications_user ON notifications(user_id, created_at);
|
||||
CREATE UNIQUE INDEX idx_notifications_dedup
|
||||
ON notifications(user_id, source_key)
|
||||
WHERE source_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE chat_messages (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_chat_messages_created ON chat_messages(created_at);
|
||||
|
||||
@@ -6,6 +6,7 @@ export type Permission =
|
||||
| "dump:moderate"
|
||||
| "comment:moderate"
|
||||
| "playlist:moderate"
|
||||
| "chat:moderate"
|
||||
| "user:manage"
|
||||
| "category:manage";
|
||||
|
||||
@@ -13,6 +14,7 @@ const ALL_PERMISSIONS: readonly Permission[] = [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
"playlist:moderate",
|
||||
"chat:moderate",
|
||||
"user:manage",
|
||||
"category:manage",
|
||||
];
|
||||
@@ -21,7 +23,12 @@ const ALL_PERMISSIONS: readonly Permission[] = [
|
||||
// and future); `moderator` gets the content-moderation permissions only.
|
||||
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
||||
admin: ALL_PERMISSIONS,
|
||||
moderator: ["dump:moderate", "comment:moderate", "playlist:moderate"],
|
||||
moderator: [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
"playlist:moderate",
|
||||
"chat:moderate",
|
||||
],
|
||||
user: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import wsRouter from "./routes/ws.ts";
|
||||
import previewRouter from "./routes/preview.ts";
|
||||
import playlistsRouter from "./routes/playlists.ts";
|
||||
import commentsRouter from "./routes/comments.ts";
|
||||
import chatRouter from "./routes/chat.ts";
|
||||
import followsRouter from "./routes/follows.ts";
|
||||
import notificationsRouter from "./routes/notifications.ts";
|
||||
import invitesRouter from "./routes/invites.ts";
|
||||
@@ -78,6 +79,10 @@ app.use(
|
||||
commentsRouter.routes(),
|
||||
commentsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
chatRouter.routes(),
|
||||
chatRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
followsRouter.routes(),
|
||||
followsRouter.allowedMethods(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomBytes, scryptSync } from "node:crypto";
|
||||
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
||||
import {
|
||||
type Category,
|
||||
type ChatMessage,
|
||||
type Comment,
|
||||
Dump,
|
||||
isRole,
|
||||
@@ -304,6 +305,43 @@ export function commentRowToApi(row: CommentRow): Comment {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatMessageRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
author_username: string;
|
||||
author_avatar_mime: string | null;
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
|
||||
return !!obj && typeof obj === "object" &&
|
||||
"id" in obj && typeof obj.id === "string" &&
|
||||
"user_id" in obj && typeof obj.user_id === "string" &&
|
||||
"body" in obj && typeof obj.body === "string" &&
|
||||
"created_at" in obj && typeof obj.created_at === "string" &&
|
||||
"updated_at" in obj &&
|
||||
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
|
||||
"author_username" in obj && typeof obj.author_username === "string" &&
|
||||
"author_avatar_mime" in obj &&
|
||||
(typeof obj.author_avatar_mime === "string" ||
|
||||
obj.author_avatar_mime === null);
|
||||
}
|
||||
|
||||
export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
body: row.body,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||
authorUsername: row.author_username,
|
||||
authorAvatarMime: row.author_avatar_mime ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlaylistRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
|
||||
@@ -309,6 +309,25 @@ export interface CreateCommentRequest {
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat
|
||||
*/
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
userId: string;
|
||||
body: string;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
authorUsername: string;
|
||||
authorAvatarMime?: string;
|
||||
}
|
||||
|
||||
/** Wire format — createdAt arrives as an ISO string over JSON. */
|
||||
export type RawChatMessage = Omit<ChatMessage, "createdAt"> & {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export function isCreateCommentRequest(
|
||||
obj: unknown,
|
||||
): obj is CreateCommentRequest {
|
||||
@@ -532,13 +551,40 @@ export interface CommentLikeRemoveMessage {
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
export interface ChatSendMessage {
|
||||
type: "chat_send";
|
||||
body: string;
|
||||
}
|
||||
|
||||
// Tells the server whether this client currently has the chatbox open, so that
|
||||
// mention notifications can be skipped for users who are already reading chat.
|
||||
export interface ChatFocusMessage {
|
||||
type: "chat_focus";
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface ChatEditMessage {
|
||||
type: "chat_edit";
|
||||
id: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ChatDeleteMessage {
|
||||
type: "chat_delete";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type ClientToServerMessage =
|
||||
| PingMessage
|
||||
| PongMessage
|
||||
| VoteCastMessage
|
||||
| VoteRemoveMessage
|
||||
| CommentLikeCastMessage
|
||||
| CommentLikeRemoveMessage;
|
||||
| CommentLikeRemoveMessage
|
||||
| ChatSendMessage
|
||||
| ChatFocusMessage
|
||||
| ChatEditMessage
|
||||
| ChatDeleteMessage;
|
||||
|
||||
// ── Server → Client ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -669,6 +715,21 @@ export interface ForceLogoutMessage {
|
||||
type: "force_logout";
|
||||
}
|
||||
|
||||
export interface ChatMessageMessage {
|
||||
type: "chat_message";
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
export interface ChatMessageUpdatedMessage {
|
||||
type: "chat_message_updated";
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
export interface ChatMessageDeletedMessage {
|
||||
type: "chat_message_deleted";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type ServerToClientMessage =
|
||||
| PingMessage
|
||||
| WelcomeMessage
|
||||
@@ -690,7 +751,10 @@ export type ServerToClientMessage =
|
||||
| CommentDeletedMessage
|
||||
| NotificationCreatedMessage
|
||||
| ErrorMessage
|
||||
| ForceLogoutMessage;
|
||||
| ForceLogoutMessage
|
||||
| ChatMessageMessage
|
||||
| ChatMessageUpdatedMessage
|
||||
| ChatMessageDeletedMessage;
|
||||
|
||||
/**
|
||||
* Follows
|
||||
@@ -751,7 +815,7 @@ export interface DumpUpvotedData {
|
||||
export interface UserMentionedData {
|
||||
mentionerId: string;
|
||||
mentionerUsername: string;
|
||||
contextType: "comment" | "dump" | "playlist";
|
||||
contextType: "comment" | "dump" | "playlist" | "chat";
|
||||
contextId: string;
|
||||
contextTitle: string;
|
||||
dumpId?: string;
|
||||
|
||||
26
api/routes/chat.ts
Normal file
26
api/routes/chat.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse, ChatMessage } from "../model/interfaces.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { getRecentChatMessages } from "../services/chat-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/chat" });
|
||||
|
||||
// GET /api/chat — recent messages (ascending). Optional `before` (ISO created_at)
|
||||
// cursor loads the page of older messages, and `limit` caps the page size.
|
||||
router.get("/", authMiddleware, (ctx) => {
|
||||
const params = ctx.request.url.searchParams;
|
||||
const before = params.get("before") ?? undefined;
|
||||
const limitParam = params.get("limit");
|
||||
const limit = limitParam ? Number(limitParam) : undefined;
|
||||
const messages = getRecentChatMessages(
|
||||
Number.isFinite(limit) ? limit : undefined,
|
||||
before,
|
||||
);
|
||||
const responseBody: APIResponse<ChatMessage[]> = {
|
||||
success: true,
|
||||
data: messages,
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
120
api/routes/ws.ts
120
api/routes/ws.ts
@@ -2,15 +2,26 @@ import { Router } from "@oak/oak";
|
||||
import { ALLOWED_ORIGINS } from "../config.ts";
|
||||
import { verifyJWT } from "../lib/jwt.ts";
|
||||
import {
|
||||
broadcastChatMessage,
|
||||
broadcastChatMessageDeleted,
|
||||
broadcastChatMessageUpdated,
|
||||
broadcastCommentLikeUpdate,
|
||||
broadcastPresence,
|
||||
broadcastVoteUpdate,
|
||||
getOnlineUsers,
|
||||
handleClientPong,
|
||||
isUserChatOpen,
|
||||
register,
|
||||
unregister,
|
||||
type WsClient,
|
||||
} from "../services/ws-service.ts";
|
||||
import {
|
||||
createChatMessage,
|
||||
deleteChatMessage,
|
||||
updateChatMessage,
|
||||
} from "../services/chat-service.ts";
|
||||
import { notifyMentions } from "../services/notification-service.ts";
|
||||
import { hasPermission } from "../lib/permissions.ts";
|
||||
import {
|
||||
castVote,
|
||||
getUserVotes,
|
||||
@@ -59,6 +70,7 @@ router.get("/ws", async (ctx) => {
|
||||
socket,
|
||||
userId: authPayload?.userId,
|
||||
username: authPayload?.username,
|
||||
role: authPayload?.role,
|
||||
avatarMime,
|
||||
};
|
||||
|
||||
@@ -114,6 +126,18 @@ router.get("/ws", async (ctx) => {
|
||||
case "comment_like_remove":
|
||||
handleCommentLike(client, msg.commentId, "remove");
|
||||
break;
|
||||
case "chat_send":
|
||||
handleChatSend(client, msg.body);
|
||||
break;
|
||||
case "chat_focus":
|
||||
client.chatOpen = msg.open;
|
||||
break;
|
||||
case "chat_edit":
|
||||
handleChatEdit(client, msg.id, msg.body);
|
||||
break;
|
||||
case "chat_delete":
|
||||
handleChatDelete(client, msg.id);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,4 +214,100 @@ function handleCommentLike(
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatSend(client: WsClient, body: string): void {
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = createChatMessage(client.userId, body);
|
||||
// Broadcast to everyone including the sender, so the sender's own message
|
||||
// simply arrives over the socket — no optimistic-send machinery needed.
|
||||
broadcastChatMessage(message);
|
||||
// Skip mention notifications for users who already have the chat open —
|
||||
// they've seen the message live.
|
||||
notifyMentions(
|
||||
client.userId,
|
||||
message.body,
|
||||
"chat",
|
||||
message.id,
|
||||
"",
|
||||
undefined,
|
||||
isUserChatOpen,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof APIException
|
||||
? err.message
|
||||
: "Failed to send message";
|
||||
socket.send(JSON.stringify({ type: "error", message }));
|
||||
}
|
||||
}
|
||||
|
||||
function canModerateChat(client: WsClient): boolean {
|
||||
return !!client.role && hasPermission(client.role, "chat:moderate");
|
||||
}
|
||||
|
||||
function handleChatEdit(client: WsClient, id: string, body: string): void {
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = updateChatMessage(
|
||||
id,
|
||||
body,
|
||||
client.userId,
|
||||
canModerateChat(client),
|
||||
);
|
||||
broadcastChatMessageUpdated(message);
|
||||
// A newly added @mention in an edit should still notify (deduped per
|
||||
// message+user), skipping anyone already reading the chat.
|
||||
notifyMentions(
|
||||
client.userId,
|
||||
message.body,
|
||||
"chat",
|
||||
message.id,
|
||||
"",
|
||||
undefined,
|
||||
isUserChatOpen,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof APIException
|
||||
? err.message
|
||||
: "Failed to edit message";
|
||||
socket.send(JSON.stringify({ type: "error", message }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatDelete(client: WsClient, id: string): void {
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
deleteChatMessage(id, canModerateChat(client));
|
||||
broadcastChatMessageDeleted(id);
|
||||
} catch (err) {
|
||||
const message = err instanceof APIException
|
||||
? err.message
|
||||
: "Failed to delete message";
|
||||
socket.send(JSON.stringify({ type: "error", message }));
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
137
api/services/chat-service.ts
Normal file
137
api/services/chat-service.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type ChatMessage,
|
||||
} from "../model/interfaces.ts";
|
||||
import { chatMessageRowToApi, db, isChatMessageRow } from "../model/db.ts";
|
||||
|
||||
export const MAX_CHAT_LENGTH = 2000;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
const SELECT_COLS =
|
||||
`m.id, m.user_id, m.body, m.created_at, m.updated_at,
|
||||
u.username as author_username, u.avatar_mime as author_avatar_mime`;
|
||||
|
||||
function fetchMessage(id: string): ChatMessage {
|
||||
const row = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?;`,
|
||||
).get(id);
|
||||
if (!row || !isChatMessageRow(row)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found");
|
||||
}
|
||||
return chatMessageRowToApi(row);
|
||||
}
|
||||
|
||||
export function createChatMessage(userId: string, body: string): ChatMessage {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Message cannot be empty",
|
||||
);
|
||||
}
|
||||
if (trimmed.length > MAX_CHAT_LENGTH) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
db.prepare(
|
||||
`INSERT INTO chat_messages (id, user_id, body, created_at) VALUES (?, ?, ?, ?);`,
|
||||
).run(id, userId, trimmed, createdAt);
|
||||
return fetchMessage(id);
|
||||
}
|
||||
|
||||
function getMessageOwner(id: string): string {
|
||||
const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get(
|
||||
id,
|
||||
) as { user_id: string } | undefined;
|
||||
if (!row) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found");
|
||||
}
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
// Owners may edit their own messages; moderators may edit any.
|
||||
export function updateChatMessage(
|
||||
id: string,
|
||||
body: string,
|
||||
requestingUserId: string,
|
||||
canModerate: boolean,
|
||||
): ChatMessage {
|
||||
const ownerId = getMessageOwner(id);
|
||||
if (ownerId !== requestingUserId && !canModerate) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Not authorized to edit this message",
|
||||
);
|
||||
}
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Message cannot be empty",
|
||||
);
|
||||
}
|
||||
if (trimmed.length > MAX_CHAT_LENGTH) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)
|
||||
.run(trimmed, new Date().toISOString(), id);
|
||||
return fetchMessage(id);
|
||||
}
|
||||
|
||||
// Deletion is a moderation action only (owners can edit but not delete).
|
||||
export function deleteChatMessage(id: string, canModerate: boolean): void {
|
||||
if (!canModerate) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Not authorized to delete this message",
|
||||
);
|
||||
}
|
||||
getMessageOwner(id); // 404s if the message doesn't exist
|
||||
db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to `limit` most recent messages in ascending (display) order.
|
||||
* When `before` (a message created_at ISO string) is given, returns the page
|
||||
* of older messages immediately preceding it — used for "load older" history.
|
||||
*/
|
||||
export function getRecentChatMessages(
|
||||
limit = DEFAULT_LIMIT,
|
||||
before?: string,
|
||||
): ChatMessage[] {
|
||||
const capped = Math.min(Math.max(limit, 1), MAX_LIMIT);
|
||||
const rows = before
|
||||
? db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id
|
||||
WHERE m.created_at < ? ORDER BY m.created_at DESC LIMIT ?;`,
|
||||
).all(before, capped)
|
||||
: db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id
|
||||
ORDER BY m.created_at DESC LIMIT ?;`,
|
||||
).all(capped);
|
||||
|
||||
if (!rows.every(isChatMessageRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed chat message data",
|
||||
);
|
||||
}
|
||||
// Newest-first from SQL; reverse to ascending for display.
|
||||
return rows.map(chatMessageRowToApi).reverse();
|
||||
}
|
||||
@@ -235,10 +235,13 @@ export function notifyDumpOwnerUpvote(
|
||||
export function notifyMentions(
|
||||
mentionerUserId: string,
|
||||
body: string,
|
||||
contextType: "comment" | "dump" | "playlist",
|
||||
contextType: "comment" | "dump" | "playlist" | "chat",
|
||||
contextId: string,
|
||||
contextTitle: string,
|
||||
dumpId?: string,
|
||||
// Optional: skip notifying a mentioned user (e.g. they're already reading the
|
||||
// chat live, so the persistent notification would be redundant).
|
||||
skipUserId?: (userId: string) => boolean,
|
||||
): void {
|
||||
const mentionerRow = db.prepare(
|
||||
`SELECT username FROM users WHERE id = ?;`,
|
||||
@@ -256,6 +259,7 @@ export function notifyMentions(
|
||||
`SELECT id FROM users WHERE lower(username) = ?;`,
|
||||
).get(username) as { id: string } | undefined;
|
||||
if (!mentionedRow || mentionedRow.id === mentionerUserId) continue;
|
||||
if (skipUserId?.(mentionedRow.id)) continue;
|
||||
|
||||
createNotification(
|
||||
mentionedRow.id,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {
|
||||
ChatMessage,
|
||||
Comment,
|
||||
Dump,
|
||||
OnlineUser,
|
||||
Playlist,
|
||||
Role,
|
||||
ServerToClientMessage,
|
||||
User,
|
||||
} from "../model/interfaces.ts";
|
||||
@@ -11,9 +13,12 @@ export interface WsClient {
|
||||
socket: WebSocket;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
role?: Role;
|
||||
avatarMime?: string;
|
||||
avatarVersion?: number;
|
||||
pongReceived?: boolean;
|
||||
/** Whether this client currently has the chatbox open (is reading chat). */
|
||||
chatOpen?: boolean;
|
||||
}
|
||||
|
||||
const clients = new Set<WsClient>();
|
||||
@@ -209,6 +214,34 @@ export function broadcastCommentUpdated(comment: Comment): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Global chat room: every connected client receives every message.
|
||||
export function broadcastChatMessage(message: ChatMessage): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "chat_message", message });
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastChatMessageUpdated(message: ChatMessage): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "chat_message_updated", message });
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastChatMessageDeleted(id: string): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "chat_message_deleted", id });
|
||||
}
|
||||
}
|
||||
|
||||
// True if the user has the chatbox open on any of their connected clients — i.e.
|
||||
// they're already reading chat, so a mention notification would be redundant.
|
||||
export function isUserChatOpen(userId: string): boolean {
|
||||
for (const client of clients) {
|
||||
if (client.userId === userId && client.chatOpen) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function handleClientPong(client: WsClient): void {
|
||||
client.pongReceived = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user