From 5fe43bc0c8efa83ea127c77c1144e03703c4bd6c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:29:40 +0100 Subject: [PATCH] fix: DM close visibility flag, wire Remove Friend button, harden deploy script - Redesign DM close as a visibility flag (closed column) instead of row deletion, so message broadcasts still reach users who closed a DM conversation - Add broadcastDmMessage() helper that auto-resurfaces closed DMs when new messages arrive - Wire Remove Friend button in DM welcome header with onClick handler and friend check - Fix deploy.sh to cd to its own directory so rsync always runs from project root --- deploy.sh | 1 + packages/server/src/db/migrate.ts | 6 + packages/server/src/db/schema.ts | 1 + packages/server/src/routes/dm.ts | 172 +++++++++--------- packages/server/src/ws/events.ts | 15 +- .../web/src/components/chat/MessageList.js | 6 +- .../web/src/components/chat/MessageList.tsx | 19 +- 7 files changed, 112 insertions(+), 108 deletions(-) diff --git a/deploy.sh b/deploy.sh index 3114f056..a954310a 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,4 +1,5 @@ #!/bin/bash +cd "$(dirname "$0")" # Configuration PI_IP="192.168.1.10" diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index ba341ffd..c4b4310b 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -35,6 +35,12 @@ export function runMigrations(db: Database.Database): void { columns: [ { name: 'dm_message_id', type: 'TEXT' } ] + }, + { + name: 'dm_members', + columns: [ + { name: 'closed', type: 'INTEGER DEFAULT 0' } + ] } ]; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index f1b57a68..f8f2fa41 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -74,6 +74,7 @@ export const dmChannels = sqliteTable('dm_channels', { export const dmMembers = sqliteTable('dm_members', { dmChannelId: text('dm_channel_id').notNull().references(() => dmChannels.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + closed: integer('closed').default(0), }, (table) => ({ pk: primaryKey({ columns: [table.dmChannelId, table.userId] }), })); diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index f665756d..55d9604a 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -27,6 +27,65 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User { }; } +/** + * Broadcasts a DM message to all members of a DM channel. + * For members who have closed the channel (closed=1), also sends a + * dm_channel_created event to resurface the channel in their sidebar, + * and flips their closed flag back to 0. + */ +export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUser): void { + const db = getDb(); + const dmMembers = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) + .all(); + + for (const member of dmMembers) { + // If this member had closed the DM, resurface it first + if (member.closed === 1) { + db.update(schema.dmMembers) + .set({ closed: 0 }) + .where(and( + eq(schema.dmMembers.dmChannelId, dmChannelId), + eq(schema.dmMembers.userId, member.userId), + )) + .run(); + + // Build and send dm_channel_created so their sidebar picks it up + const allMemberRows = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) + .all(); + const memberUserIds = allMemberRows.map(m => m.userId); + const users = memberUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all() + : []; + + const dmChannel = db.select() + .from(schema.dmChannels) + .where(eq(schema.dmChannels.id, dmChannelId)) + .get(); + + if (dmChannel) { + connectionManager.sendToUser(member.userId, { + type: 'dm_channel_created', + dmChannel: { + id: dmChannel.id, + createdAt: dmChannel.createdAt, + members: users.map(sanitizeUser), + lastMessage: message, + }, + }); + } + } + + connectionManager.sendToUser(member.userId, { + type: 'dm_message_created', + message, + }); + } +} + export async function dmRoutes(app: FastifyInstance): Promise { // GET /api/dm - List user's DM channels app.get('/api/dm', { @@ -36,7 +95,10 @@ export async function dmRoutes(app: FastifyInstance): Promise { const memberships = db.select() .from(schema.dmMembers) - .where(eq(schema.dmMembers.userId, request.userId)) + .where(and( + eq(schema.dmMembers.userId, request.userId), + eq(schema.dmMembers.closed, 0), + )) .all(); const dmChannels: DmChannel[] = []; @@ -115,7 +177,8 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'User not found', statusCode: 404 }); } - // Check if DM channel already exists between these two users (both are members) + // Check if DM channel already exists between these two users + // (both have membership rows, regardless of closed state) const myDms = db.select() .from(schema.dmMembers) .where(eq(schema.dmMembers.userId, request.userId)) @@ -131,7 +194,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { .get(); if (otherMember) { - // DM channel already exists, both users are members + // DM channel already exists between these users const dmChannel = db.select() .from(schema.dmChannels) .where(eq(schema.dmChannels.id, myDm.dmChannelId)) @@ -139,6 +202,17 @@ export async function dmRoutes(app: FastifyInstance): Promise { if (!dmChannel) continue; + // Reopen if the requesting user had closed it + if (myDm.closed === 1) { + db.update(schema.dmMembers) + .set({ closed: 0 }) + .where(and( + eq(schema.dmMembers.dmChannelId, myDm.dmChannelId), + eq(schema.dmMembers.userId, request.userId), + )) + .run(); + } + const dmMemberRows = db.select() .from(schema.dmMembers) .where(eq(schema.dmMembers.dmChannelId, myDm.dmChannelId)) @@ -173,79 +247,6 @@ export async function dmRoutes(app: FastifyInstance): Promise { } } - // Check if the other user has a DM channel with us that we left (closed) - // If so, re-add ourselves to it instead of creating a new one - const theirDms = db.select() - .from(schema.dmMembers) - .where(eq(schema.dmMembers.userId, userId)) - .all(); - - for (const theirDm of theirDms) { - // Check if any messages exist between us on this channel (indicates a previous DM) - const dmChannel = db.select() - .from(schema.dmChannels) - .where(eq(schema.dmChannels.id, theirDm.dmChannelId)) - .get(); - - if (!dmChannel) continue; - - // Check total members — a DM channel should only have the other user if we left - const allMembers = db.select() - .from(schema.dmMembers) - .where(eq(schema.dmMembers.dmChannelId, theirDm.dmChannelId)) - .all(); - - // If it's a 1-member channel (just them) or we see past messages from us, re-join - const onlyOtherUser = allMembers.length === 1 && allMembers[0]!.userId === userId; - if (onlyOtherUser) { - // Check if there are any messages from us in this channel (confirms it was our DM) - const ourOldMessages = db.select() - .from(schema.dmMessages) - .where(and( - eq(schema.dmMessages.dmChannelId, theirDm.dmChannelId), - eq(schema.dmMessages.userId, request.userId), - )) - .limit(1) - .all(); - - if (ourOldMessages.length > 0) { - // Re-add ourselves to this existing DM channel - db.insert(schema.dmMembers).values({ - dmChannelId: theirDm.dmChannelId, - userId: request.userId, - }).run(); - - const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); - const members = [currentUserRow, targetUser] - .filter((u): u is NonNullable => u !== undefined) - .map(sanitizeUser); - - const lastMsgRows = db.select() - .from(schema.dmMessages) - .where(eq(schema.dmMessages.dmChannelId, theirDm.dmChannelId)) - .orderBy(desc(schema.dmMessages.createdAt)) - .limit(1) - .all(); - const lastMsg = lastMsgRows[0] ?? null; - - const result: DmChannel = { - id: dmChannel.id, - createdAt: dmChannel.createdAt, - members, - lastMessage: lastMsg ? { - id: lastMsg.id, - dmChannelId: lastMsg.dmChannelId, - userId: lastMsg.userId, - content: lastMsg.content, - createdAt: lastMsg.createdAt, - } : null, - }; - - return reply.code(200).send(result); - } - } - } - // Create new DM channel const dmChannelId = generateSnowflake(); const now = Date.now(); @@ -306,8 +307,9 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); } - // Soft close: remove the user's membership row (Discord-style hide) - db.delete(schema.dmMembers) + // Soft close: set closed flag (preserves membership for future message delivery) + db.update(schema.dmMembers) + .set({ closed: 1 }) .where(and( eq(schema.dmMembers.dmChannelId, id), eq(schema.dmMembers.userId, request.userId), @@ -428,18 +430,8 @@ export async function dmRoutes(app: FastifyInstance): Promise { user: sanitizeUser(user), }; - // Broadcast via WebSocket to all DM members - const dmMembers = db.select() - .from(schema.dmMembers) - .where(eq(schema.dmMembers.dmChannelId, id)) - .all(); - - for (const member of dmMembers) { - connectionManager.sendToUser(member.userId, { - type: 'dm_message_created', - message, - }); - } + // Broadcast to all DM members (including those who closed the channel) + broadcastDmMessage(id, message); return reply.code(201).send(message); }); diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 8eb70705..5f06aa0f 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -3,6 +3,7 @@ import { getDb, schema } from '../db/index.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { connectionManager } from './handler.js'; import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js'; +import { broadcastDmMessage } from '../routes/dm.js'; import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared'; function sanitizeUser(row: typeof schema.users.$inferSelect): User { @@ -497,18 +498,8 @@ function handleDmMessageCreate(event: Record, userId: string): user: sanitizeUser(user), }; - // Send to all DM members - const dmMembers = db.select() - .from(schema.dmMembers) - .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) - .all(); - - for (const member of dmMembers) { - connectionManager.sendToUser(member.userId, { - type: 'dm_message_created', - message: dmMessage, - }); - } + // Broadcast to all DM members (including those who closed the channel) + broadcastDmMessage(dmChannelId, dmMessage); } function handleDmTypingStart(event: Record, userId: string, username: string): void { diff --git a/packages/web/src/components/chat/MessageList.js b/packages/web/src/components/chat/MessageList.js index 471c0020..a64689b2 100644 --- a/packages/web/src/components/chat/MessageList.js +++ b/packages/web/src/components/chat/MessageList.js @@ -4,6 +4,7 @@ import { Message } from './Message'; import { useChatStore } from '../../stores/chatStore'; import { useServerStore, isDmChannel } from '../../stores/serverStore'; import { useAuthStore } from '../../stores/authStore'; +import { useSocialStore } from '../../stores/socialStore'; import { Avatar } from '../ui/Avatar'; import { LoadingSpinner } from '../ui/LoadingSpinner'; const EMPTY_MESSAGES = []; @@ -100,13 +101,16 @@ export function MessageList({ channelId }) { function WelcomeHeader({ channelId }) { const dmChannels = useServerStore((s) => s.dmChannels); const authUser = useAuthStore((s) => s.user); + const removeFriend = useSocialStore((s) => s.removeFriend); + const friends = useSocialStore((s) => s.friends); const isDm = isDmChannel(channelId); if (isDm) { const dm = dmChannels.find(d => d.id === channelId); const otherUser = dm?.members.find(m => m.id !== authUser?.id); const displayName = otherUser?.displayName ?? otherUser?.username ?? 'Unknown'; const username = otherUser?.username ?? 'unknown'; - return (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "mb-2", children: _jsx(Avatar, { src: otherUser?.avatar, name: displayName, size: 80 }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: displayName }), _jsxs("p", { className: "text-discord-text-secondary text-[14px] mt-1", children: ["This is the beginning of your direct message history with ", _jsxs("strong", { children: ["@", username] }), "."] }), _jsx("div", { className: "mt-4", children: _jsx("button", { className: "px-4 py-1.5 bg-discord-bg-accent hover:bg-discord-bg-surface-higher text-[14px] font-medium text-discord-text-primary rounded-[3px] transition-colors", children: "Remove Friend" }) }), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] })); + const isFriend = otherUser ? friends.some(f => f.id === otherUser.id) : false; + return (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "mb-2", children: _jsx(Avatar, { src: otherUser?.avatar, name: displayName, size: 80 }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: displayName }), _jsxs("p", { className: "text-discord-text-secondary text-[14px] mt-1", children: ["This is the beginning of your direct message history with ", _jsxs("strong", { children: ["@", username] }), "."] }), isFriend && otherUser && (_jsx("div", { className: "mt-4", children: _jsx("button", { onClick: () => removeFriend(otherUser.id), className: "px-4 py-1.5 bg-discord-bg-accent hover:bg-discord-bg-surface-higher text-[14px] font-medium text-discord-text-primary rounded-[3px] transition-colors", children: "Remove Friend" }) })), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] })); } return (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "w-[68px] h-[68px] rounded-full bg-discord-bg-accent flex items-center justify-center mb-4 text-white", children: _jsx("svg", { width: "42", height: "42", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: "Welcome to the channel!" }), _jsx("p", { className: "text-discord-text-secondary text-[16px] mt-2", children: "This is the start of the conversation." }), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] })); } diff --git a/packages/web/src/components/chat/MessageList.tsx b/packages/web/src/components/chat/MessageList.tsx index dadd0ef1..6e27e1bb 100644 --- a/packages/web/src/components/chat/MessageList.tsx +++ b/packages/web/src/components/chat/MessageList.tsx @@ -3,6 +3,7 @@ import { Message } from './Message'; import { useChatStore } from '../../stores/chatStore'; import { useServerStore, isDmChannel } from '../../stores/serverStore'; import { useAuthStore } from '../../stores/authStore'; +import { useSocialStore } from '../../stores/socialStore'; import { Avatar } from '../ui/Avatar'; import { LoadingSpinner } from '../ui/LoadingSpinner'; import type { MessageWithUser } from '@opencord/shared'; @@ -158,6 +159,8 @@ export function MessageList({ channelId }: MessageListProps) { function WelcomeHeader({ channelId }: { channelId: string }) { const dmChannels = useServerStore((s) => s.dmChannels); const authUser = useAuthStore((s) => s.user); + const removeFriend = useSocialStore((s) => s.removeFriend); + const friends = useSocialStore((s) => s.friends); const isDm = isDmChannel(channelId); if (isDm) { @@ -165,6 +168,7 @@ function WelcomeHeader({ channelId }: { channelId: string }) { const otherUser = dm?.members.find(m => m.id !== authUser?.id); const displayName = otherUser?.displayName ?? otherUser?.username ?? 'Unknown'; const username = otherUser?.username ?? 'unknown'; + const isFriend = otherUser ? friends.some(f => f.id === otherUser.id) : false; return (
@@ -175,11 +179,16 @@ function WelcomeHeader({ channelId }: { channelId: string }) {

This is the beginning of your direct message history with @{username}.

-
- -
+ {isFriend && otherUser && ( +
+ +
+ )}
);