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

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

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

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

View File

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