diff --git a/artifacts/api-server/src/routes/conversations.ts b/artifacts/api-server/src/routes/conversations.ts index 608e5c16..c8850b57 100644 --- a/artifacts/api-server/src/routes/conversations.ts +++ b/artifacts/api-server/src/routes/conversations.ts @@ -21,6 +21,9 @@ import { AddConversationParticipantsParams, AddConversationParticipantsBody, RemoveConversationParticipantParams, + UpdateConversationStateParams, + UpdateConversationStateBody, + LeaveConversationParams, } from "@workspace/api-zod"; const router: IRouter = Router(); @@ -41,6 +44,25 @@ async function isConversationMember( return !!row; } +async function getParticipantState( + conversationId: number, + userId: number, +): Promise<{ isMuted: boolean; isArchived: boolean } | null> { + const [row] = await db + .select({ + isMuted: conversationParticipantsTable.isMuted, + isArchived: conversationParticipantsTable.isArchived, + }) + .from(conversationParticipantsTable) + .where( + and( + eq(conversationParticipantsTable.conversationId, conversationId), + eq(conversationParticipantsTable.userId, userId), + ), + ); + return row ?? null; +} + async function buildConversationDetails(conversationId: number, currentUserId: number) { const [conv] = await db .select() @@ -102,11 +124,15 @@ async function buildConversationDetails(conversationId: number, currentUserId: n const unreadCount = Number(unreadResult[0]?.count ?? 0); + const state = await getParticipantState(conversationId, currentUserId); + return { ...conv, participants, lastMessage, unreadCount, + isMuted: state?.isMuted ?? false, + isArchived: state?.isArchived ?? false, }; } @@ -276,6 +302,17 @@ router.patch("/conversations/:id", requireAuth, async (req, res): Promise res.json(details); }); +async function evictUserFromConversationRoom( + userId: number, + conversationId: number, +): Promise { + const { io } = await import("../index.js"); + const sockets = await io.in(`user:${userId}`).fetchSockets(); + for (const s of sockets) { + s.leave(`conversation:${conversationId}`); + } +} + async function emitConversationUpdate(conversationId: number): Promise { const { io } = await import("../index.js"); const members = await db @@ -415,6 +452,8 @@ router.delete( ), ); + await evictUserFromConversationRoom(params.data.userId, params.data.id); + const details = await buildConversationDetails(params.data.id, userId); if (!details) { res.status(404).json({ error: "Conversation not found" }); @@ -425,6 +464,96 @@ router.delete( }, ); +router.patch( + "/conversations/:id/state", + requireAuth, + async (req, res): Promise => { + const params = UpdateConversationStateParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + const parsed = UpdateConversationStateBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const userId = req.session.userId!; + + if (!(await isConversationMember(params.data.id, userId))) { + res.status(403).json({ error: "Forbidden" }); + return; + } + + const updates: { isMuted?: boolean; isArchived?: boolean } = {}; + if (parsed.data.isMuted !== undefined) updates.isMuted = parsed.data.isMuted; + if (parsed.data.isArchived !== undefined) updates.isArchived = parsed.data.isArchived; + + if (Object.keys(updates).length > 0) { + await db + .update(conversationParticipantsTable) + .set(updates) + .where( + and( + eq(conversationParticipantsTable.conversationId, params.data.id), + eq(conversationParticipantsTable.userId, userId), + ), + ); + } + + const details = await buildConversationDetails(params.data.id, userId); + if (!details) { + res.status(404).json({ error: "Conversation not found" }); + return; + } + res.json(details); + }, +); + +router.post( + "/conversations/:id/leave", + requireAuth, + async (req, res): Promise => { + const params = LeaveConversationParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + const userId = req.session.userId!; + + const [conv] = await db + .select({ isGroup: conversationsTable.isGroup }) + .from(conversationsTable) + .where(eq(conversationsTable.id, params.data.id)); + if (!conv) { + res.status(404).json({ error: "Conversation not found" }); + return; + } + if (!conv.isGroup) { + res.status(400).json({ error: "Cannot leave a direct conversation" }); + return; + } + + if (!(await isConversationMember(params.data.id, userId))) { + res.status(403).json({ error: "Forbidden" }); + return; + } + + await db + .delete(conversationParticipantsTable) + .where( + and( + eq(conversationParticipantsTable.conversationId, params.data.id), + eq(conversationParticipantsTable.userId, userId), + ), + ); + + await evictUserFromConversationRoom(userId, params.data.id); + await emitConversationUpdate(params.data.id); + res.json({ success: true }); + }, +); + router.get("/conversations/:id/messages", requireAuth, async (req, res): Promise => { const params = ListMessagesParams.safeParse(req.params); if (!params.success) { @@ -496,6 +625,17 @@ router.post("/conversations/:id/messages", requireAuth, async (req, res): Promis }) .returning(); + // Auto-unarchive for all participants when a new message arrives + await db + .update(conversationParticipantsTable) + .set({ isArchived: false }) + .where( + and( + eq(conversationParticipantsTable.conversationId, params.data.id), + eq(conversationParticipantsTable.isArchived, true), + ), + ); + const [sender] = await db .select({ id: usersTable.id, diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index 5fc27c2a..19221c97 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 f6161e91..af0795d0 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -160,6 +160,27 @@ "admin": "مشرف", "you": "(أنت)", "viewOnly": "فقط مشرف المجموعة يستطيع تغيير الاسم أو الأعضاء." + }, + "filterAll": "نشطة", + "filterArchived": "المؤرشفة", + "noArchived": "لا توجد محادثات مؤرشفة", + "actions": { + "title": "خيارات المحادثة", + "openActions": "فتح خيارات المحادثة", + "mute": "كتم الإشعارات", + "unmute": "إلغاء كتم الإشعارات", + "archive": "أرشفة المحادثة", + "unarchive": "إلغاء الأرشفة", + "leave": "مغادرة المجموعة", + "leaveConfirmTitle": "مغادرة هذه المجموعة؟", + "leaveConfirmBody": "ستتم إزالتك من \"{{name}}\" ولن تتلقى رسائلها بعد الآن.", + "muted": "تم كتم الإشعارات", + "unmuted": "الإشعارات مفعّلة", + "archived": "تمت أرشفة المحادثة", + "unarchived": "أعيدت المحادثة إلى النشطة", + "left": "غادرت المجموعة", + "actionFailed": "تعذّر تنفيذ الإجراء", + "mutedLabel": "مكتومة" } }, "notifications": { diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 51afa9ba..efdfb9b5 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -157,6 +157,27 @@ "admin": "Admin", "you": "(you)", "viewOnly": "Only the group admin can change the name or members." + }, + "filterAll": "Active", + "filterArchived": "Archived", + "noArchived": "No archived chats", + "actions": { + "title": "Chat options", + "openActions": "Open chat options", + "mute": "Mute notifications", + "unmute": "Unmute notifications", + "archive": "Archive chat", + "unarchive": "Unarchive chat", + "leave": "Leave group", + "leaveConfirmTitle": "Leave this group?", + "leaveConfirmBody": "You will be removed from \"{{name}}\" and stop receiving its messages.", + "muted": "Notifications muted", + "unmuted": "Notifications on", + "archived": "Chat archived", + "unarchived": "Chat moved back to active", + "left": "You left the group", + "actionFailed": "Could not complete action", + "mutedLabel": "Muted" } }, "notifications": { diff --git a/artifacts/teaboy-os/src/pages/chat.tsx b/artifacts/teaboy-os/src/pages/chat.tsx index e13f97f6..a8855ccc 100644 --- a/artifacts/teaboy-os/src/pages/chat.tsx +++ b/artifacts/teaboy-os/src/pages/chat.tsx @@ -12,6 +12,8 @@ import { useUpdateConversation, useAddConversationParticipants, useRemoveConversationParticipant, + useUpdateConversationState, + useLeaveConversation, } from "@workspace/api-client-react"; import { useUpload, type UploadResponse } from "@workspace/object-storage-web"; import { resolveServiceImageUrl } from "@/lib/image-url"; @@ -31,6 +33,12 @@ import { Settings, UserPlus, Trash2, + MoreVertical, + Bell, + BellOff, + Archive, + ArchiveRestore, + LogOut, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -70,6 +78,9 @@ export default function ChatPage() { const [userSearch, setUserSearch] = useState(""); const [showSettings, setShowSettings] = useState(false); const [showAddMembers, setShowAddMembers] = useState(false); + const [showActions, setShowActions] = useState(false); + const [showArchived, setShowArchived] = useState(false); + const [confirmLeave, setConfirmLeave] = useState(false); const [editNameAr, setEditNameAr] = useState(""); const [editNameEn, setEditNameEn] = useState(""); const [addMembersIds, setAddMembersIds] = useState([]); @@ -293,6 +304,62 @@ export default function ChatPage() { const updateConv = useUpdateConversation(); const addParticipants = useAddConversationParticipants(); const removeParticipant = useRemoveConversationParticipant(); + const updateConvState = useUpdateConversationState(); + const leaveConv = useLeaveConversation(); + + const handleToggleMute = () => { + if (!selectedConv) return; + const next = !selectedConv.isMuted; + updateConvState.mutate( + { id: selectedConv.id, data: { isMuted: next } }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); + toast({ title: t(next ? "chat.actions.muted" : "chat.actions.unmuted") }); + }, + onError: (err: Error) => + toast({ title: t("chat.actions.actionFailed"), description: err.message, variant: "destructive" }), + }, + ); + }; + + const handleToggleArchive = () => { + if (!selectedConv) return; + const next = !selectedConv.isArchived; + updateConvState.mutate( + { id: selectedConv.id, data: { isArchived: next } }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); + toast({ title: t(next ? "chat.actions.archived" : "chat.actions.unarchived") }); + if (next) { + setShowActions(false); + setSelectedConvId(null); + } + }, + onError: (err: Error) => + toast({ title: t("chat.actions.actionFailed"), description: err.message, variant: "destructive" }), + }, + ); + }; + + const handleLeave = () => { + if (!selectedConv) return; + leaveConv.mutate( + { id: selectedConv.id }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); + toast({ title: t("chat.actions.left") }); + setConfirmLeave(false); + setShowActions(false); + setSelectedConvId(null); + }, + onError: (err: Error) => + toast({ title: t("chat.actions.actionFailed"), description: err.message, variant: "destructive" }), + }, + ); + }; const openSettings = () => { if (!selectedConv) return; @@ -509,6 +576,16 @@ export default function ChatPage() { )} + {selectedConvId && selectedConv && ( + + )} {!selectedConvId && ( + + + + {selectedConv.isGroup && ( + + )} + + + )} + + {/* Leave confirmation */} + {confirmLeave && selectedConv?.isGroup && ( +
setConfirmLeave(false)} + > +
e.stopPropagation()} + data-testid="dialog-confirm-leave" + > +

+ {t("chat.actions.leaveConfirmTitle")} +

+

+ {t("chat.actions.leaveConfirmBody", { name: convName })} +

+
+ + +
+
+
+ )} + {selectedConvId ? ( /* Message View */
@@ -1080,17 +1270,71 @@ export default function ChatPage() { ) : ( /* Conversation List */
- {convsLoading ? ( -
- {t("common.loading")} -
- ) : !conversations?.length ? ( -
- - {t("chat.noConversations")} -
- ) : ( - conversations.map((conv) => { + {(() => { + const allConvs = conversations ?? []; + const archivedCount = allConvs.filter((c) => c.isArchived).length; + const visibleConvs = allConvs.filter((c) => + showArchived ? c.isArchived : !c.isArchived, + ); + return ( + <> +
+ + +
+ {convsLoading ? ( +
+ {t("common.loading")} +
+ ) : visibleConvs.length === 0 ? ( +
+ {showArchived ? ( + <> + + {t("chat.noArchived")} + + ) : ( + <> + + {t("chat.noConversations")} + + )} +
+ ) : ( + visibleConvs.map((conv) => { const name = convDisplayName(conv); const lastMsg = conv.lastMessage; @@ -1136,8 +1380,15 @@ export default function ChatPage() { {t("chat.groupChat")} )} + {conv.isMuted && ( + + )}
- {conv.unreadCount > 0 && ( + {conv.unreadCount > 0 && !conv.isMuted && ( {conv.unreadCount} @@ -1151,8 +1402,11 @@ export default function ChatPage() {
); - }) - )} + }) + )} + + ); + })()} )} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 7e1e6f3b..aff37058 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -284,6 +284,8 @@ export interface ConversationWithDetails { participants: ParticipantInfo[]; lastMessage?: MessageWithSender | null; unreadCount: number; + isMuted: boolean; + isArchived: boolean; createdAt: string; updatedAt: string; } @@ -308,6 +310,11 @@ export interface UpdateConversationBody { nameEn?: string | null; } +export interface UpdateConversationStateBody { + isMuted?: boolean; + isArchived?: boolean; +} + export interface AddParticipantsBody { userIds: number[]; } diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index d9e9e481..12d4efd1 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -49,6 +49,7 @@ import type { UpdateClockHour12Body, UpdateClockStyleBody, UpdateConversationBody, + UpdateConversationStateBody, UpdateLanguageBody, UpdateMyAppOrderBody, UpdateServiceBody, @@ -2883,6 +2884,181 @@ export const useRemoveConversationParticipant = < return useMutation(getRemoveConversationParticipantMutationOptions(options)); }; +/** + * @summary Update per-user state (mute, archive) + */ +export const getUpdateConversationStateUrl = (id: number) => { + return `/api/conversations/${id}/state`; +}; + +export const updateConversationState = async ( + id: number, + updateConversationStateBody: UpdateConversationStateBody, + options?: RequestInit, +): Promise => { + return customFetch( + getUpdateConversationStateUrl(id), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(updateConversationStateBody), + }, + ); +}; + +export const getUpdateConversationStateMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + const mutationKey = ["updateConversationState"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return updateConversationState(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateConversationStateMutationResult = NonNullable< + Awaited> +>; +export type UpdateConversationStateMutationBody = + BodyType; +export type UpdateConversationStateMutationError = ErrorType; + +/** + * @summary Update per-user state (mute, archive) + */ +export const useUpdateConversationState = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + return useMutation(getUpdateConversationStateMutationOptions(options)); +}; + +/** + * @summary Leave a group conversation + */ +export const getLeaveConversationUrl = (id: number) => { + return `/api/conversations/${id}/leave`; +}; + +export const leaveConversation = async ( + id: number, + options?: RequestInit, +): Promise => { + return customFetch(getLeaveConversationUrl(id), { + ...options, + method: "POST", + }); +}; + +export const getLeaveConversationMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext +> => { + const mutationKey = ["leaveConversation"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number } + > = (props) => { + const { id } = props ?? {}; + + return leaveConversation(id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type LeaveConversationMutationResult = NonNullable< + Awaited> +>; + +export type LeaveConversationMutationError = ErrorType; + +/** + * @summary Leave a group conversation + */ +export const useLeaveConversation = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number }, + TContext +> => { + return useMutation(getLeaveConversationMutationOptions(options)); +}; + /** * @summary List messages in a conversation */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 0e9f0cd8..cdbdad52 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -729,6 +729,50 @@ paths: schema: $ref: "#/components/schemas/ConversationWithDetails" + /conversations/{id}/state: + patch: + operationId: updateConversationState + tags: [conversations] + summary: Update per-user state (mute, archive) + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateConversationStateBody" + responses: + "200": + description: Updated conversation + content: + application/json: + schema: + $ref: "#/components/schemas/ConversationWithDetails" + + /conversations/{id}/leave: + post: + operationId: leaveConversation + tags: [conversations] + summary: Leave a group conversation + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Left conversation + content: + application/json: + schema: + $ref: "#/components/schemas/SuccessResponse" + /conversations/{id}/messages: get: operationId: listMessages @@ -1458,6 +1502,10 @@ components: nullable: true unreadCount: type: integer + isMuted: + type: boolean + isArchived: + type: boolean createdAt: type: string format: date-time @@ -1470,6 +1518,8 @@ components: - createdBy - participants - unreadCount + - isMuted + - isArchived - createdAt - updatedAt @@ -1521,6 +1571,14 @@ components: nameEn: type: ["string", "null"] + UpdateConversationStateBody: + type: object + properties: + isMuted: + type: boolean + isArchived: + type: boolean + AddParticipantsBody: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 9b0d931f..b7a22233 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -604,6 +604,8 @@ export const ListConversationsResponseItem = zod.object({ }) .nullish(), unreadCount: zod.number(), + isMuted: zod.boolean(), + isArchived: zod.boolean(), createdAt: zod.coerce.date(), updatedAt: zod.coerce.date(), }); @@ -665,6 +667,8 @@ export const GetConversationResponse = zod.object({ }) .nullish(), unreadCount: zod.number(), + isMuted: zod.boolean(), + isArchived: zod.boolean(), createdAt: zod.coerce.date(), updatedAt: zod.coerce.date(), }); @@ -718,6 +722,8 @@ export const UpdateConversationResponse = zod.object({ }) .nullish(), unreadCount: zod.number(), + isMuted: zod.boolean(), + isArchived: zod.boolean(), createdAt: zod.coerce.date(), updatedAt: zod.coerce.date(), }); @@ -769,6 +775,8 @@ export const AddConversationParticipantsResponse = zod.object({ }) .nullish(), unreadCount: zod.number(), + isMuted: zod.boolean(), + isArchived: zod.boolean(), createdAt: zod.coerce.date(), updatedAt: zod.coerce.date(), }); @@ -817,10 +825,77 @@ export const RemoveConversationParticipantResponse = zod.object({ }) .nullish(), unreadCount: zod.number(), + isMuted: zod.boolean(), + isArchived: zod.boolean(), createdAt: zod.coerce.date(), updatedAt: zod.coerce.date(), }); +/** + * @summary Update per-user state (mute, archive) + */ +export const UpdateConversationStateParams = zod.object({ + id: zod.coerce.number(), +}); + +export const UpdateConversationStateBody = zod.object({ + isMuted: zod.boolean().optional(), + isArchived: zod.boolean().optional(), +}); + +export const UpdateConversationStateResponse = zod.object({ + id: zod.number(), + nameAr: zod.string().nullish(), + nameEn: zod.string().nullish(), + avatarUrl: zod.string().nullish(), + isGroup: zod.boolean(), + createdBy: zod.number(), + participants: zod.array( + zod.object({ + id: zod.number(), + username: zod.string(), + displayNameAr: zod.string().nullish(), + displayNameEn: zod.string().nullish(), + avatarUrl: zod.string().nullish(), + isAdmin: zod.boolean(), + }), + ), + lastMessage: zod + .object({ + id: zod.number(), + conversationId: zod.number(), + senderId: zod.number(), + content: zod.string(), + sender: zod.object({ + id: zod.number(), + username: zod.string(), + displayNameAr: zod.string().nullish(), + displayNameEn: zod.string().nullish(), + avatarUrl: zod.string().nullish(), + isAdmin: zod.boolean(), + }), + createdAt: zod.coerce.date(), + updatedAt: zod.coerce.date(), + }) + .nullish(), + unreadCount: zod.number(), + isMuted: zod.boolean(), + isArchived: zod.boolean(), + createdAt: zod.coerce.date(), + updatedAt: zod.coerce.date(), +}); + +/** + * @summary Leave a group conversation + */ +export const LeaveConversationParams = zod.object({ + id: zod.coerce.number(), +}); + +export const LeaveConversationResponse = zod.object({ + success: zod.boolean(), +}); + /** * @summary List messages in a conversation */ diff --git a/lib/db/src/schema/conversations.ts b/lib/db/src/schema/conversations.ts index d1506ff1..a36d96c2 100644 --- a/lib/db/src/schema/conversations.ts +++ b/lib/db/src/schema/conversations.ts @@ -19,6 +19,8 @@ export const conversationParticipantsTable = pgTable("conversation_participants" userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }), joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(), isAdmin: boolean("is_admin").notNull().default(false), + isMuted: boolean("is_muted").notNull().default(false), + isArchived: boolean("is_archived").notNull().default(false), }, (t) => [primaryKey({ columns: [t.conversationId, t.userId] })]); export const messagesTable = pgTable("messages", {