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
This commit is contained in:
Jannis Braun
2026-02-22 22:29:40 +01:00
parent 6b8fd44a3a
commit 5fe43bc0c8
7 changed files with 112 additions and 108 deletions
+6
View File
@@ -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' }
]
}
];
+1
View File
@@ -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] }),
}));
+82 -90
View File
@@ -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<void> {
// GET /api/dm - List user's DM channels
app.get('/api/dm', {
@@ -36,7 +95,10 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
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<void> {
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<void> {
.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<void> {
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<void> {
}
}
// 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<typeof u> => 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<void> {
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<void> {
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);
});
+3 -12
View File
@@ -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<string, unknown>, 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<string, unknown>, userId: string, username: string): void {