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:
@@ -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,
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -187,6 +187,7 @@ function buildReadyPayload(userId: string): {
|
||||
servers: ServerWithChannelsAndMembers[];
|
||||
dmChannels: DmChannel[];
|
||||
folders: ServerFolder[];
|
||||
voiceStates: Record<string, string[]>;
|
||||
} {
|
||||
const db = getDb();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user