feat: DM infrastructure overhaul + volume sliders + LiveKit dynamic URL

- Add DM edit/delete endpoints (REST + WebSocket)
- Add DM typing indicators with real-time broadcast
- Fix volume sliders to control LiveKit mic/speaker
- Fix Send Message button on user profile popout
- Add New DM modal with user search
- Fix message pagination (SQL cursor instead of in-memory)
- Guard optional attachments on DM messages
- Dynamic LiveKit URL from request Host header
- DM sidebar auto-sorts by most recent message
This commit is contained in:
Jannis Braun
2026-02-18 17:29:39 +01:00
parent 4f65ea9c86
commit 7168149d98
31 changed files with 1189 additions and 136 deletions
+9
View File
@@ -117,6 +117,15 @@ function createTables(db: Database.Database): void {
UNIQUE(message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS dm_reactions (
id TEXT PRIMARY KEY,
dm_message_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(dm_message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS roles (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+13
View File
@@ -22,6 +22,19 @@ export function runMigrations(db: Database.Database): void {
columns: [
{ name: 'permissions', type: 'TEXT' }
]
},
{
name: 'dm_messages',
columns: [
{ name: 'edited_at', type: 'INTEGER' },
{ name: 'reply_to_id', type: 'TEXT' }
]
},
{
name: 'attachments',
columns: [
{ name: 'dm_message_id', type: 'TEXT' }
]
}
];
+11
View File
@@ -58,6 +58,7 @@ export const messages = sqliteTable('messages', {
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
messageId: text('message_id').references(() => messages.id, { onDelete: 'cascade' }),
dmMessageId: text('dm_message_id'),
filename: text('filename').notNull(),
originalName: text('original_name').notNull(),
mimetype: text('mimetype').notNull(),
@@ -81,7 +82,9 @@ export const dmMessages = sqliteTable('dm_messages', {
id: text('id').primaryKey(),
dmChannelId: text('dm_channel_id').notNull().references(() => dmChannels.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id),
replyToId: text('reply_to_id'),
content: text('content'),
editedAt: integer('edited_at'),
createdAt: integer('created_at').notNull(),
});
@@ -109,6 +112,14 @@ export const reactions = sqliteTable('reactions', {
createdAt: integer('created_at').notNull(),
});
export const dmReactions = sqliteTable('dm_reactions', {
id: text('id').primaryKey(),
dmMessageId: text('dm_message_id').notNull(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
emoji: text('emoji').notNull(),
createdAt: integer('created_at').notNull(),
});
export const roles = sqliteTable('roles', {
id: text('id').primaryKey(),
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
+130 -6
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, desc, inArray } from 'drizzle-orm';
import { eq, and, desc, lt, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
@@ -147,11 +147,26 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
const memberUserIds = dmMemberRows.map(m => m.userId);
const users = db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all();
// Fetch actual last message
const lastMsgRows = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, myDm.dmChannelId))
.orderBy(desc(schema.dmMessages.createdAt))
.limit(1)
.all();
const lastMsg = lastMsgRows[0] ?? null;
const result: DmChannel = {
id: dmChannel.id,
createdAt: dmChannel.createdAt,
members: users.map(sanitizeUser),
lastMessage: null,
lastMessage: lastMsg ? {
id: lastMsg.id,
dmChannelId: lastMsg.dmChannelId,
userId: lastMsg.userId,
content: lastMsg.content,
createdAt: lastMsg.createdAt,
} : null,
};
return reply.code(200).send(result);
@@ -211,11 +226,13 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
if (before) {
messageRows = db.select()
.from(schema.dmMessages)
.where(eq(schema.dmMessages.dmChannelId, id))
.where(and(
eq(schema.dmMessages.dmChannelId, id),
lt(schema.dmMessages.id, before)
))
.orderBy(desc(schema.dmMessages.createdAt))
.all()
.filter(m => m.id < before)
.slice(0, limit);
.limit(limit)
.all();
} else {
messageRows = db.select()
.from(schema.dmMessages)
@@ -310,4 +327,111 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
return reply.code(201).send(message);
});
// PATCH /api/dm/messages/:id - Edit a DM message
app.patch<{ Params: { id: string }; Body: { content: string } }>('/api/dm/messages/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { content } = request.body;
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return reply.code(400).send({ error: 'Message content is required', statusCode: 400 });
}
const db = getDb();
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, id)).get();
if (!msg) {
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
}
if (msg.userId !== request.userId) {
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
}
const now = Date.now();
db.update(schema.dmMessages)
.set({ content: content.trim(), editedAt: now })
.where(eq(schema.dmMessages.id, id))
.run();
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!user) {
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
}
const updated: DmMessageWithUser = {
id: msg.id,
dmChannelId: msg.dmChannelId,
userId: msg.userId,
content: content.trim(),
editedAt: now,
createdAt: msg.createdAt,
user: sanitizeUser(user),
};
// Broadcast to all DM members
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_message_updated',
message: updated,
});
}
return reply.code(200).send(updated);
});
// DELETE /api/dm/messages/:id - Delete a DM message
app.delete<{ Params: { id: string } }>('/api/dm/messages/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, id)).get();
if (!msg) {
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
}
if (msg.userId !== request.userId) {
return reply.code(403).send({ error: 'You can only delete your own messages', statusCode: 403 });
}
// Delete attachments linked to this DM message
db.delete(schema.attachments)
.where(eq(schema.attachments.dmMessageId, id))
.run();
// Delete reactions
db.delete(schema.dmReactions)
.where(eq(schema.dmReactions.dmMessageId, id))
.run();
// Delete message
db.delete(schema.dmMessages)
.where(eq(schema.dmMessages.id, id))
.run();
// Broadcast to all DM members
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_message_deleted',
messageId: id,
dmChannelId: msg.dmChannelId,
});
}
return reply.code(200).send({ success: true });
});
}
+9 -2
View File
@@ -41,9 +41,16 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
const jwt = await token.toJwt();
const response: LiveKitTokenResponse = {
// Use the request's Host header so the LiveKit WSS URL matches however
// the client reached us (domain or direct IP).
const requestHost = request.headers.host?.replace(/:\d+$/, '') || '';
const livekitUrl = requestHost
? `wss://${requestHost}/livekit`
: config.livekit.url;
const response: LiveKitTokenResponse = {
token: jwt,
url: config.livekit.url
url: livekitUrl
};
return reply.code(200).send(response);
});
+7 -5
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify';
import { eq, desc, inArray } from 'drizzle-orm';
import { eq, and, desc, lt, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
@@ -186,11 +186,13 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
if (before) {
messageRows = db.select()
.from(schema.messages)
.where(eq(schema.messages.channelId, id))
.where(and(
eq(schema.messages.channelId, id),
lt(schema.messages.id, before)
))
.orderBy(desc(schema.messages.createdAt))
.all()
.filter(m => m.id < before)
.slice(0, limit);
.limit(limit)
.all();
} else {
messageRows = db.select()
.from(schema.messages)
+153
View File
@@ -126,6 +126,15 @@ export function handleClientEvent(
case 'dm_message_create':
handleDmMessageCreate(event, userId);
break;
case 'dm_typing_start':
handleDmTypingStart(event, userId, username);
break;
case 'dm_message_edit':
handleDmMessageEdit(event, userId);
break;
case 'dm_message_delete':
handleDmMessageDelete(event, userId);
break;
case 'reaction_add':
handleReactionAdd(event, userId);
break;
@@ -455,6 +464,150 @@ function handleDmMessageCreate(event: Record<string, unknown>, userId: string):
}
}
function handleDmTypingStart(event: Record<string, unknown>, userId: string, username: string): void {
const dmChannelId = event.dmChannelId as string;
if (!dmChannelId || typeof dmChannelId !== 'string') return;
if (!isDmMember(dmChannelId, userId)) return;
const key = `dm:${userId}:${dmChannelId}`;
const existing = typingTimeouts.get(key);
if (existing) {
clearTimeout(existing);
}
// Send to all other DM members
const db = getDb();
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
.all();
for (const member of dmMembers) {
if (member.userId !== userId) {
connectionManager.sendToUser(member.userId, {
type: 'dm_typing',
dmChannelId,
userId,
username,
});
}
}
const timeout = setTimeout(() => {
typingTimeouts.delete(key);
}, 5000);
typingTimeouts.set(key, timeout);
}
function handleDmMessageEdit(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string;
const content = event.content as string;
if (!messageId || typeof messageId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' });
return;
}
if (!content || typeof content !== 'string' || content.trim().length === 0) {
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' });
return;
}
const db = getDb();
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, messageId)).get();
if (!msg) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' });
return;
}
if (msg.userId !== userId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'You can only edit your own messages' });
return;
}
const now = Date.now();
db.update(schema.dmMessages)
.set({ content: content.trim(), editedAt: now })
.where(eq(schema.dmMessages.id, messageId))
.run();
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!user) return;
const updated: DmMessageWithUser = {
id: msg.id,
dmChannelId: msg.dmChannelId,
userId: msg.userId,
content: content.trim(),
editedAt: now,
createdAt: msg.createdAt,
user: sanitizeUser(user),
};
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_message_updated',
message: updated,
});
}
}
function handleDmMessageDelete(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string;
if (!messageId || typeof messageId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' });
return;
}
const db = getDb();
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, messageId)).get();
if (!msg) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' });
return;
}
if (msg.userId !== userId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'You can only delete your own messages' });
return;
}
// Delete attachments linked to this DM message
db.delete(schema.attachments)
.where(eq(schema.attachments.dmMessageId, messageId))
.run();
// Delete reactions
db.delete(schema.dmReactions)
.where(eq(schema.dmReactions.dmMessageId, messageId))
.run();
// Delete message
db.delete(schema.dmMessages)
.where(eq(schema.dmMessages.id, messageId))
.run();
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_message_deleted',
messageId,
dmChannelId: msg.dmChannelId,
});
}
}
function handleReactionAdd(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string;
const emoji = event.emoji as string;
+1
View File
@@ -187,6 +187,7 @@ function buildReadyPayload(userId: string): {
servers: ServerWithChannelsAndMembers[];
dmChannels: DmChannel[];
folders: ServerFolder[];
voiceStates: Record<string, string[]>;
} {
const db = getDb();