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:
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user