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
+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;