fix: repair invite links, social features, messaging + Discord UI overhaul

Phase 1 - Feature Repair:
- Fix member kick/leave: add missing db.delete() call in servers.ts
- Stabilize invite codes: return existing code instead of regenerating
- Fix user search: use LIKE instead of exact match in social.ts
- Wire DM button on FriendsPage to create/navigate to DM channels
- Add cancel outgoing friend request (DELETE endpoint + frontend)
- Add accept/decline friend request actions with WS real-time events
- Fix replyToId persistence in message creation
- Hydrate reactions and replyTo in message queries
- Add joinByCode to API client and serverStore
- Add friend_request_received/accepted WebSocket events

Phase 2 - Discord UI Overhaul:
- Remove stray borders between layout columns
- Replace shadow-sm with shadow-header on content headers
- Replace all bg-gray-*/text-gray-* with Discord color tokens
- Ensure flat color contrast (#1E1F22, #2B2D31, #313338)

Testing:
- Set up vitest + @testing-library/react + jsdom
- Add 17 tests across InviteModal, JoinServer, FriendsPage (all passing)
- Fix vite resolve.extensions to prefer .tsx over stale .js files
This commit is contained in:
Jannis Braun
2026-02-18 05:34:45 +01:00
parent 4fd17084a5
commit 5ef502f2e3
82 changed files with 4906 additions and 552 deletions
+118 -1
View File
@@ -1,4 +1,4 @@
import { eq, inArray } from 'drizzle-orm';
import { eq, inArray, and } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from './handler.js';
@@ -40,15 +40,54 @@ function getMessageWithUser(messageId: string): MessageWithUser | null {
createdAt: a.createdAt,
}));
const reactionRows = db.select()
.from(schema.reactions)
.where(eq(schema.reactions.messageId, messageId))
.all();
const reactions = reactionRows.map(r => ({
id: r.id,
messageId: r.messageId,
userId: r.userId,
emoji: r.emoji,
createdAt: r.createdAt,
}));
let replyTo: MessageWithUser | null = null;
if (message.replyToId) {
// Simple fetch for replyTo (one level deep to avoid recursion loops)
const replyMsg = db.select().from(schema.messages).where(eq(schema.messages.id, message.replyToId)).get();
if (replyMsg) {
const replyUser = db.select().from(schema.users).where(eq(schema.users.id, replyMsg.userId)).get();
if (replyUser) {
replyTo = {
id: replyMsg.id,
channelId: replyMsg.channelId,
userId: replyMsg.userId,
replyToId: replyMsg.replyToId,
content: replyMsg.content,
editedAt: replyMsg.editedAt,
createdAt: replyMsg.createdAt,
user: sanitizeUser(replyUser),
attachments: [], // Don't fetch attachments for replies to save bandwidth
reactions: [], // Don't fetch reactions for replies
};
}
}
}
return {
id: message.id,
channelId: message.channelId,
userId: message.userId,
replyToId: message.replyToId,
content: message.content,
editedAt: message.editedAt,
createdAt: message.createdAt,
user: sanitizeUser(user),
attachments,
reactions,
replyTo,
};
}
@@ -87,6 +126,12 @@ export function handleClientEvent(
case 'dm_message_create':
handleDmMessageCreate(event, userId);
break;
case 'reaction_add':
handleReactionAdd(event, userId);
break;
case 'reaction_remove':
handleReactionRemove(event, userId);
break;
default:
connectionManager.sendToUser(userId, {
type: 'error',
@@ -98,6 +143,7 @@ export function handleClientEvent(
function handleMessageCreate(event: Record<string, unknown>, userId: string): void {
const channelId = event.channelId as string;
const content = event.content as string;
const replyToId = event.replyToId as string | undefined;
if (!channelId || typeof channelId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' });
@@ -128,6 +174,7 @@ function handleMessageCreate(event: Record<string, unknown>, userId: string): vo
id: messageId,
channelId,
userId,
replyToId: replyToId || null,
content: content.trim(),
createdAt: now,
}).run();
@@ -407,3 +454,73 @@ function handleDmMessageCreate(event: Record<string, unknown>, userId: string):
});
}
}
function handleReactionAdd(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string;
const emoji = event.emoji as string;
if (!messageId || !emoji) return;
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) return;
const serverId = getChannelServerId(message.channelId);
if (!serverId || !isMember(serverId, userId)) return;
const reactionId = generateSnowflake();
try {
db.insert(schema.reactions).values({
id: reactionId,
messageId,
userId,
emoji,
createdAt: Date.now(),
}).run();
connectionManager.sendToServer(serverId, {
type: 'reaction_added',
messageId,
reaction: {
id: reactionId,
messageId,
userId,
emoji,
createdAt: Date.now(),
},
});
} catch (err) {
// Unique constraint violation (already reacted)
}
}
function handleReactionRemove(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string;
const emoji = event.emoji as string;
if (!messageId || !emoji) return;
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) return;
const serverId = getChannelServerId(message.channelId);
if (!serverId || !isMember(serverId, userId)) return;
const result = db.delete(schema.reactions)
.where(and(
eq(schema.reactions.messageId, messageId),
eq(schema.reactions.userId, userId),
eq(schema.reactions.emoji, emoji)
))
.run();
if (result.changes > 0) {
connectionManager.sendToServer(serverId, {
type: 'reaction_removed',
messageId,
userId,
emoji,
});
}
}
+64 -1
View File
@@ -11,6 +11,7 @@ import type {
Channel,
DmChannel,
ServerEvent,
ServerFolder,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
@@ -185,6 +186,7 @@ function buildReadyPayload(userId: string): {
user: User;
servers: ServerWithChannelsAndMembers[];
dmChannels: DmChannel[];
folders: ServerFolder[];
} {
const db = getDb();
@@ -217,6 +219,12 @@ function buildReadyPayload(userId: string): {
.where(eq(schema.channels.serverId, serverRow.id))
.all();
const roles = db.select()
.from(schema.roles)
.where(eq(schema.roles.serverId, serverRow.id))
.orderBy(schema.roles.position)
.all();
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, serverRow.id))
@@ -228,10 +236,31 @@ function buildReadyPayload(userId: string): {
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const memberRoleRows = db.select()
.from(schema.memberRoles)
.where(eq(schema.memberRoles.serverId, serverRow.id))
.all();
const members: MemberWithUser[] = memberRows
.map(m => {
const u = userMap.get(m.userId);
if (!u) return null;
const assignedRoleIds = memberRoleRows
.filter(mr => mr.userId === m.userId)
.map(mr => mr.roleId);
const memberRoles = roles
.filter(r => assignedRoleIds.includes(r.id))
.map(r => ({
id: r.id,
serverId: r.serverId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
createdAt: r.createdAt,
}));
return {
serverId: m.serverId,
userId: m.userId,
@@ -239,6 +268,7 @@ function buildReadyPayload(userId: string): {
nickname: m.nickname,
joinedAt: m.joinedAt,
user: sanitizeUser(u),
roles: memberRoles,
};
})
.filter((m): m is MemberWithUser => m !== null);
@@ -260,6 +290,14 @@ function buildReadyPayload(userId: string): {
createdAt: ch.createdAt,
})),
members,
roles: roles.map(r => ({
id: r.id,
serverId: r.serverId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
createdAt: r.createdAt,
})),
});
}
}
@@ -316,7 +354,32 @@ function buildReadyPayload(userId: string): {
});
}
return { user, servers, dmChannels };
// Get Server Folders
const folderRows = db.select()
.from(schema.serverFolders)
.where(eq(schema.serverFolders.userId, userId))
.orderBy(schema.serverFolders.position)
.all();
const folders: any[] = [];
for (const folder of folderRows) {
const serverIds = db.select()
.from(schema.serverFolderMembers)
.where(eq(schema.serverFolderMembers.folderId, folder.id))
.all()
.map(m => m.serverId);
folders.push({
id: folder.id,
userId: folder.userId,
name: folder.name,
color: folder.color,
position: folder.position,
serverIds,
});
}
return { user, servers, dmChannels, folders };
}
export async function registerWebSocket(app: FastifyInstance): Promise<void> {