diff --git a/artifacts/api-server/src/routes/conversations.ts b/artifacts/api-server/src/routes/conversations.ts index c8850b57..06843cb7 100644 --- a/artifacts/api-server/src/routes/conversations.ts +++ b/artifacts/api-server/src/routes/conversations.ts @@ -284,6 +284,10 @@ router.patch("/conversations/:id", requireAuth, async (req, res): Promise return; } + const nameChanged = + (updates.nameAr !== undefined && updates.nameAr !== conv.nameAr) || + (updates.nameEn !== undefined && updates.nameEn !== conv.nameEn); + if (Object.keys(updates).length > 0) { await db .update(conversationsTable) @@ -291,6 +295,17 @@ router.patch("/conversations/:id", requireAuth, async (req, res): Promise .where(eq(conversationsTable.id, params.data.id)); } + if (nameChanged) { + const actor = await getUserDisplay(userId); + await insertAndEmitSystemMessage(params.data.id, userId, "group_renamed", { + actor, + oldNameAr: conv.nameAr, + oldNameEn: conv.nameEn, + newNameAr: finalNameAr, + newNameEn: finalNameEn, + }); + } + const details = await buildConversationDetails(params.data.id, userId); if (!details) { res.status(404).json({ error: "Conversation not found" }); @@ -313,6 +328,80 @@ async function evictUserFromConversationRoom( } } +type SystemMessageMeta = Record; + +async function getUserDisplay(userId: number): Promise<{ + id: number; + username: string; + displayNameAr: string | null; + displayNameEn: string | null; +} | null> { + const [u] = await db + .select({ + id: usersTable.id, + username: usersTable.username, + displayNameAr: usersTable.displayNameAr, + displayNameEn: usersTable.displayNameEn, + }) + .from(usersTable) + .where(eq(usersTable.id, userId)); + return u ?? null; +} + +async function getUsersDisplay(userIds: number[]): Promise< + Array<{ + id: number; + username: string; + displayNameAr: string | null; + displayNameEn: string | null; + }> +> { + if (userIds.length === 0) return []; + return db + .select({ + id: usersTable.id, + username: usersTable.username, + displayNameAr: usersTable.displayNameAr, + displayNameEn: usersTable.displayNameEn, + }) + .from(usersTable) + .where(sql`${usersTable.id} IN (${sql.join(userIds.map((id) => sql`${id}`), sql`, `)})`); +} + +async function insertAndEmitSystemMessage( + conversationId: number, + actorId: number, + kind: string, + meta: SystemMessageMeta, +): Promise { + const [msg] = await db + .insert(messagesTable) + .values({ + conversationId, + senderId: actorId, + content: "", + kind, + meta, + }) + .returning(); + + const [sender] = await db + .select({ + id: usersTable.id, + username: usersTable.username, + displayNameAr: usersTable.displayNameAr, + displayNameEn: usersTable.displayNameEn, + avatarUrl: usersTable.avatarUrl, + isAdmin: sql`false`, + }) + .from(usersTable) + .where(eq(usersTable.id, actorId)); + + const fullMessage = { ...msg, sender }; + const { io } = await import("../index.js"); + io.to(`conversation:${conversationId}`).emit("new_message", fullMessage); +} + async function emitConversationUpdate(conversationId: number): Promise { const { io } = await import("../index.js"); const members = await db @@ -410,6 +499,15 @@ router.post( })), ) .onConflictDoNothing(); + + const [actor, addedUsers] = await Promise.all([ + getUserDisplay(userId), + getUsersDisplay(validIds), + ]); + await insertAndEmitSystemMessage(params.data.id, userId, "members_added", { + actor, + addedUsers, + }); } const details = await buildConversationDetails(params.data.id, userId); @@ -443,17 +541,29 @@ router.delete( return; } - await db + const deleted = await db .delete(conversationParticipantsTable) .where( and( eq(conversationParticipantsTable.conversationId, params.data.id), eq(conversationParticipantsTable.userId, params.data.userId), ), - ); + ) + .returning({ userId: conversationParticipantsTable.userId }); await evictUserFromConversationRoom(params.data.userId, params.data.id); + if (deleted.length > 0) { + const [actor, removedUser] = await Promise.all([ + getUserDisplay(userId), + getUserDisplay(params.data.userId), + ]); + await insertAndEmitSystemMessage(params.data.id, userId, "member_removed", { + actor, + removedUser, + }); + } + const details = await buildConversationDetails(params.data.id, userId); if (!details) { res.status(404).json({ error: "Conversation not found" }); diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index 4d6d4b26..8c2ac7c4 100644 Binary files a/artifacts/teaboy-os/public/opengraph.jpg and b/artifacts/teaboy-os/public/opengraph.jpg differ diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 011b1ac5..d2cfd102 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -181,6 +181,14 @@ "left": "غادرت المجموعة", "actionFailed": "تعذّر تنفيذ الإجراء", "mutedLabel": "مكتومة" + }, + "system": { + "someone": "شخص ما", + "listSeparator": "، ", + "groupRenamed": "غيّر {{actor}} اسم المجموعة إلى \"{{name}}\"", + "membersAdded_one": "أضاف {{actor}} {{names}}", + "membersAdded_other": "أضاف {{actor}} {{names}}", + "memberRemoved": "أزال {{actor}} {{target}}" } }, "notifications": { diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 63ebfa8c..9242c53b 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -178,6 +178,14 @@ "left": "You left the group", "actionFailed": "Could not complete action", "mutedLabel": "Muted" + }, + "system": { + "someone": "Someone", + "listSeparator": ", ", + "groupRenamed": "{{actor}} renamed the group to \"{{name}}\"", + "membersAdded_one": "{{actor}} added {{names}}", + "membersAdded_other": "{{actor}} added {{names}}", + "memberRemoved": "{{actor}} removed {{target}}" } }, "notifications": { diff --git a/artifacts/teaboy-os/src/pages/chat.tsx b/artifacts/teaboy-os/src/pages/chat.tsx index 63450849..d94607b7 100644 --- a/artifacts/teaboy-os/src/pages/chat.tsx +++ b/artifacts/teaboy-os/src/pages/chat.tsx @@ -50,6 +50,67 @@ import { Camera, Loader2, X } from "lucide-react"; const BASE = import.meta.env.BASE_URL.replace(/\/$/, ""); +type UserDisplay = { + id: number; + username: string; + displayNameAr?: string | null; + displayNameEn?: string | null; +}; + +function pickDisplayName(u: UserDisplay | null | undefined, lang: string): string { + if (!u) return ""; + return (lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username; +} + +type SystemMessageMeta = { + actor?: UserDisplay | null; + oldNameAr?: string | null; + oldNameEn?: string | null; + newNameAr?: string | null; + newNameEn?: string | null; + addedUsers?: UserDisplay[]; + removedUser?: UserDisplay | null; +}; + +type SystemRenderable = { + kind?: string | null; + meta?: unknown; +}; + +function renderSystemMessage( + msg: SystemRenderable, + lang: string, + t: (k: string, opts?: Record) => string, +): string { + const meta = (msg.meta ?? {}) as SystemMessageMeta; + const actor = pickDisplayName(meta.actor, lang) || t("chat.system.someone"); + switch (msg.kind) { + case "group_renamed": { + const newName = + (lang === "ar" ? meta.newNameAr : meta.newNameEn) ?? + (lang === "ar" ? meta.newNameEn : meta.newNameAr) ?? + ""; + return t("chat.system.groupRenamed", { actor, name: newName }); + } + case "members_added": { + const names = (meta.addedUsers ?? []) + .map((u) => pickDisplayName(u, lang)) + .filter(Boolean); + return t("chat.system.membersAdded", { + actor, + names: names.join(t("chat.system.listSeparator")), + count: names.length, + }); + } + case "member_removed": { + const target = pickDisplayName(meta.removedUser, lang) || t("chat.system.someone"); + return t("chat.system.memberRemoved", { actor, target }); + } + default: + return ""; + } +} + function getSocket() { return io(window.location.origin, { path: `${BASE}/api/socket.io`, @@ -1242,6 +1303,16 @@ export default function ChatPage() {
{messages?.map((msg) => { + if (msg.kind && msg.kind !== "user") { + const text = renderSystemMessage(msg, lang, t); + return ( +
+
+ {text} +
+
+ ); + } const isMe = msg.senderId === user?.id; const senderName = (lang === "ar" ? msg.sender?.displayNameAr : msg.sender?.displayNameEn) ?? @@ -1426,7 +1497,9 @@ export default function ChatPage() {
{lastMsg && (

- {lastMsg.content} + {lastMsg.kind && lastMsg.kind !== "user" + ? renderSystemMessage(lastMsg, lang, t) + : lastMsg.content}

)} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index aff37058..d1b6310a 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -261,11 +261,25 @@ export interface ParticipantInfo { isAdmin: boolean; } +export type MessageWithSenderKind = + (typeof MessageWithSenderKind)[keyof typeof MessageWithSenderKind]; + +export const MessageWithSenderKind = { + user: "user", + group_renamed: "group_renamed", + members_added: "members_added", + member_removed: "member_removed", +} as const; + +export type MessageWithSenderMeta = { [key: string]: unknown } | null; + export interface MessageWithSender { id: number; conversationId: number; senderId: number; content: string; + kind: MessageWithSenderKind; + meta?: MessageWithSenderMeta; sender: ParticipantInfo; createdAt: string; updatedAt: string; diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 781ff744..a0694733 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -1606,6 +1606,13 @@ components: type: integer content: type: string + kind: + type: string + enum: [user, group_renamed, members_added, member_removed] + meta: + type: object + nullable: true + additionalProperties: true sender: $ref: "#/components/schemas/ParticipantInfo" createdAt: @@ -1619,6 +1626,7 @@ components: - conversationId - senderId - content + - kind - sender - createdAt - updatedAt diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index b7a22233..089a835d 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -591,6 +591,13 @@ export const ListConversationsResponseItem = zod.object({ conversationId: zod.number(), senderId: zod.number(), content: zod.string(), + kind: zod.enum([ + "user", + "group_renamed", + "members_added", + "member_removed", + ]), + meta: zod.record(zod.string(), zod.unknown()).nullish(), sender: zod.object({ id: zod.number(), username: zod.string(), @@ -654,6 +661,13 @@ export const GetConversationResponse = zod.object({ conversationId: zod.number(), senderId: zod.number(), content: zod.string(), + kind: zod.enum([ + "user", + "group_renamed", + "members_added", + "member_removed", + ]), + meta: zod.record(zod.string(), zod.unknown()).nullish(), sender: zod.object({ id: zod.number(), username: zod.string(), @@ -709,6 +723,13 @@ export const UpdateConversationResponse = zod.object({ conversationId: zod.number(), senderId: zod.number(), content: zod.string(), + kind: zod.enum([ + "user", + "group_renamed", + "members_added", + "member_removed", + ]), + meta: zod.record(zod.string(), zod.unknown()).nullish(), sender: zod.object({ id: zod.number(), username: zod.string(), @@ -762,6 +783,13 @@ export const AddConversationParticipantsResponse = zod.object({ conversationId: zod.number(), senderId: zod.number(), content: zod.string(), + kind: zod.enum([ + "user", + "group_renamed", + "members_added", + "member_removed", + ]), + meta: zod.record(zod.string(), zod.unknown()).nullish(), sender: zod.object({ id: zod.number(), username: zod.string(), @@ -812,6 +840,13 @@ export const RemoveConversationParticipantResponse = zod.object({ conversationId: zod.number(), senderId: zod.number(), content: zod.string(), + kind: zod.enum([ + "user", + "group_renamed", + "members_added", + "member_removed", + ]), + meta: zod.record(zod.string(), zod.unknown()).nullish(), sender: zod.object({ id: zod.number(), username: zod.string(), @@ -866,6 +901,13 @@ export const UpdateConversationStateResponse = zod.object({ conversationId: zod.number(), senderId: zod.number(), content: zod.string(), + kind: zod.enum([ + "user", + "group_renamed", + "members_added", + "member_removed", + ]), + meta: zod.record(zod.string(), zod.unknown()).nullish(), sender: zod.object({ id: zod.number(), username: zod.string(), @@ -908,6 +950,8 @@ export const ListMessagesResponseItem = zod.object({ conversationId: zod.number(), senderId: zod.number(), content: zod.string(), + kind: zod.enum(["user", "group_renamed", "members_added", "member_removed"]), + meta: zod.record(zod.string(), zod.unknown()).nullish(), sender: zod.object({ id: zod.number(), username: zod.string(), diff --git a/lib/db/src/schema/conversations.ts b/lib/db/src/schema/conversations.ts index a36d96c2..46db5ef6 100644 --- a/lib/db/src/schema/conversations.ts +++ b/lib/db/src/schema/conversations.ts @@ -1,4 +1,4 @@ -import { pgTable, text, serial, timestamp, integer, varchar, boolean, primaryKey } from "drizzle-orm/pg-core"; +import { pgTable, text, serial, timestamp, integer, varchar, boolean, primaryKey, jsonb } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod/v4"; import { usersTable } from "./users"; @@ -28,6 +28,8 @@ export const messagesTable = pgTable("messages", { conversationId: integer("conversation_id").notNull().references(() => conversationsTable.id, { onDelete: "cascade" }), senderId: integer("sender_id").notNull().references(() => usersTable.id), content: text("content").notNull(), + kind: varchar("kind", { length: 32 }).notNull().default("user"), + meta: jsonb("meta"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()), });