feat: real-time sync, notification & social system overhaul

- Add WS events: dm_channel_created, dm_channel_closed, friend_removed,
  channel_created/updated/deleted, server_updated
- Fix first-ever DM: broadcast dm_channel_created to recipient
- Add DELETE /api/dm/:id for closing DMs with re-open support
- Wire Close DM button in sidebar
- Fix dm_message_created for unknown channels (safety net)
- Broadcast friend_removed on friend deletion
- Sound system: track realtimeMessageEvents separately from API loads,
  play notification for messages in all channels, not just current
- Optimistic updates: send/edit/delete messages appear instantly with
  rollback on failure, temp message deduplication on WS echo
- Channel CRUD broadcasts to all server members
- Server update broadcast on PATCH
- Server join registers user in connectionManager immediately
- Reconnect: only reload current channel, preserve other channel caches
- Activity panel, right panel, member list toggle components
This commit is contained in:
Jannis Braun
2026-02-22 22:15:11 +01:00
parent 708bdf5468
commit 6b8fd44a3a
35 changed files with 1002 additions and 262 deletions
+28 -2
View File
@@ -4,6 +4,7 @@ import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isAdmin, getChannelServerId } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
CreateChannelRequest,
UpdateChannelRequest,
@@ -106,7 +107,16 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(500).send({ error: 'Failed to create channel', statusCode: 500 });
}
return reply.code(201).send(rowToChannel(channel));
const channelData = rowToChannel(channel);
// Broadcast channel_created to all server members
connectionManager.sendToServer(id, {
type: 'channel_created',
channel: channelData,
serverId: id,
});
return reply.code(201).send(channelData);
});
// PATCH /api/channels/:id - Update a channel (admin+)
@@ -159,7 +169,16 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(500).send({ error: 'Failed to update channel', statusCode: 500 });
}
return reply.code(200).send(rowToChannel(updated));
const channelData = rowToChannel(updated);
// Broadcast channel_updated to all server members
connectionManager.sendToServer(serverId, {
type: 'channel_updated',
channel: channelData,
serverId,
});
return reply.code(200).send(channelData);
});
// DELETE /api/channels/:id - Delete a channel (admin+)
@@ -183,6 +202,13 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run();
db.delete(schema.channels).where(eq(schema.channels.id, id)).run();
// Broadcast channel_deleted to all server members
connectionManager.sendToServer(serverId, {
type: 'channel_deleted',
channelId: id,
serverId,
});
return reply.code(200).send({ success: true });
});
}
+118 -2
View File
@@ -115,7 +115,7 @@ 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
// Check if DM channel already exists between these two users (both are members)
const myDms = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.userId, request.userId))
@@ -131,7 +131,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
.get();
if (otherMember) {
// DM channel already exists
// DM channel already exists, both users are members
const dmChannel = db.select()
.from(schema.dmChannels)
.where(eq(schema.dmChannels.id, myDm.dmChannelId))
@@ -173,6 +173,79 @@ 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();
@@ -204,9 +277,52 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
lastMessage: null,
};
// Broadcast dm_channel_created to the other user so their sidebar updates
connectionManager.sendToUser(userId, {
type: 'dm_channel_created',
dmChannel: result,
});
return reply.code(201).send(result);
});
// DELETE /api/dm/:id - Close (hide) a DM channel for the requesting user
app.delete<{ Params: { id: string } }>('/api/dm/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
// Verify the user is a member of this DM channel
const membership = db.select()
.from(schema.dmMembers)
.where(and(
eq(schema.dmMembers.dmChannelId, id),
eq(schema.dmMembers.userId, request.userId),
))
.get();
if (!membership) {
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)
.where(and(
eq(schema.dmMembers.dmChannelId, id),
eq(schema.dmMembers.userId, request.userId),
))
.run();
// Broadcast dm_channel_closed to self for multi-tab sync
connectionManager.sendToUser(request.userId, {
type: 'dm_channel_closed',
dmChannelId: id,
});
return reply.code(200).send({ success: true });
});
// GET /api/dm/:id/messages - Get DM messages with pagination
app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/dm/:id/messages', {
preHandler: authenticate,
+15 -1
View File
@@ -275,7 +275,15 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(500).send({ error: 'Failed to update server', statusCode: 500 });
}
return reply.code(200).send(rowToServer(updated));
const serverData = rowToServer(updated);
// Broadcast server_updated to all server members
connectionManager.sendToServer(id, {
type: 'server_updated',
server: serverData,
});
return reply.code(200).send(serverData);
});
// DELETE /api/servers/:id - Delete server (owner only)
@@ -363,6 +371,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
joinedAt: now,
}).run();
// Register the user in connectionManager so they receive WS broadcasts for this server
connectionManager.addUserServer(request.userId, id);
return reply.code(200).send(rowToServer(server));
});
@@ -395,6 +406,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
joinedAt: now,
}).run();
// Register the user in connectionManager so they receive WS broadcasts for this server
connectionManager.addUserServer(request.userId, server.id);
return reply.code(200).send(rowToServer(server));
});
+6
View File
@@ -279,6 +279,12 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
and(eq(schema.friends.userId, id), eq(schema.friends.friendId, request.userId))
)).run();
// Broadcast friend_removed to the other user so their friends list updates
connectionManager.sendToUser(id, {
type: 'friend_removed',
userId: request.userId,
});
return reply.code(200).send({ success: true });
});