From 5ef502f2e398cacb085ba1e0bd8a0040375bb162 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 18 Feb 2026 05:34:45 +0100 Subject: [PATCH] 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 --- Makefile | 30 + deploy.sh | 28 + docker-compose.yml | 4 +- packages/server/package.json | 3 +- packages/server/src/db/index.ts | 59 ++ packages/server/src/db/migrate.ts | 45 + packages/server/src/db/schema.ts | 68 +- packages/server/src/index.ts | 9 +- packages/server/src/routes/livekit.ts | 5 +- packages/server/src/routes/messages.ts | 147 ++- packages/server/src/routes/servers.ts | 153 +++ packages/server/src/routes/social.ts | 313 ++++++ packages/server/src/routes/users.ts | 31 +- packages/server/src/routes/utils.ts | 42 + packages/server/src/ws/events.ts | 119 ++- packages/server/src/ws/handler.ts | 65 +- packages/shared/src/types.ts | 93 +- packages/web/package.json | 15 +- packages/web/src/App.js | 2 +- packages/web/src/App.tsx | 16 + packages/web/src/api/client.js | 8 + packages/web/src/api/client.ts | 14 + packages/web/src/components/chat/Embed.js | 30 + packages/web/src/components/chat/Embed.tsx | 79 ++ .../web/src/components/chat/FriendsPage.js | 59 ++ .../src/components/chat/FriendsPage.test.tsx | 319 +++++++ .../web/src/components/chat/FriendsPage.tsx | 320 +++++++ packages/web/src/components/chat/Message.js | 81 +- packages/web/src/components/chat/Message.tsx | 219 ++++- .../web/src/components/chat/MessageInput.js | 16 +- .../web/src/components/chat/MessageInput.tsx | 72 +- .../web/src/components/chat/MessageList.js | 4 +- .../web/src/components/chat/MessageList.tsx | 21 +- .../src/components/chat/TypingIndicator.js | 2 +- .../src/components/chat/TypingIndicator.tsx | 17 +- .../web/src/components/layout/AppLayout.js | 34 +- .../web/src/components/layout/AppLayout.tsx | 88 +- .../src/components/layout/ChannelSidebar.js | 35 +- .../src/components/layout/ChannelSidebar.tsx | 300 ++++-- .../web/src/components/layout/MainContent.js | 12 +- .../web/src/components/layout/MainContent.tsx | 43 +- .../src/components/layout/MemberSidebar.js | 22 +- .../src/components/layout/MemberSidebar.tsx | 37 +- .../src/components/layout/ServerSidebar.js | 33 +- .../src/components/layout/ServerSidebar.tsx | 192 ++-- .../web/src/components/modals/InviteModal.js | 7 +- .../components/modals/InviteModal.test.tsx | 115 +++ .../web/src/components/modals/InviteModal.tsx | 25 +- .../web/src/components/modals/JoinServer.js | 10 +- .../src/components/modals/JoinServer.test.tsx | 109 +++ .../web/src/components/modals/JoinServer.tsx | 36 +- .../web/src/components/modals/UserSettings.js | 4 +- .../src/components/modals/UserSettings.tsx | 19 +- packages/web/src/components/ui/Avatar.js | 25 +- packages/web/src/components/ui/Avatar.tsx | 33 +- .../web/src/components/ui/ContextMenu.tsx | 2 +- packages/web/src/components/ui/Tooltip.tsx | 2 +- .../src/components/ui/UserProfilePopout.js | 25 + .../src/components/ui/UserProfilePopout.tsx | 90 ++ .../web/src/components/voice/VoiceControls.js | 4 +- .../src/components/voice/VoiceControls.tsx | 4 +- .../web/src/components/voice/VoiceUser.js | 11 +- .../web/src/components/voice/VoiceUser.tsx | 15 +- packages/web/src/hooks/useLiveKit.js | 12 +- packages/web/src/hooks/useLiveKit.ts | 13 +- packages/web/src/hooks/useWebSocket.js | 11 +- packages/web/src/hooks/useWebSocket.ts | 26 +- packages/web/src/stores/chatStore.js | 68 +- packages/web/src/stores/chatStore.ts | 97 +- packages/web/src/stores/serverStore.js | 26 +- packages/web/src/stores/serverStore.ts | 48 +- packages/web/src/stores/socialStore.js | 76 ++ packages/web/src/stores/socialStore.ts | 124 +++ packages/web/src/stores/uiStore.js | 10 + packages/web/src/stores/uiStore.ts | 18 + packages/web/src/stores/voiceStore.js | 4 +- packages/web/src/stores/voiceStore.ts | 12 +- packages/web/src/styles/globals.css | 145 ++- packages/web/src/test/setup.ts | 1 + packages/web/tailwind.config.js | 23 +- packages/web/vite.config.ts | 12 +- pnpm-lock.yaml | 892 ++++++++++++++++++ 82 files changed, 4906 insertions(+), 552 deletions(-) create mode 100644 Makefile create mode 100755 deploy.sh create mode 100644 packages/server/src/db/migrate.ts create mode 100644 packages/server/src/routes/social.ts create mode 100644 packages/server/src/routes/utils.ts create mode 100644 packages/web/src/components/chat/Embed.js create mode 100644 packages/web/src/components/chat/Embed.tsx create mode 100644 packages/web/src/components/chat/FriendsPage.js create mode 100644 packages/web/src/components/chat/FriendsPage.test.tsx create mode 100644 packages/web/src/components/chat/FriendsPage.tsx create mode 100644 packages/web/src/components/modals/InviteModal.test.tsx create mode 100644 packages/web/src/components/modals/JoinServer.test.tsx create mode 100644 packages/web/src/components/ui/UserProfilePopout.js create mode 100644 packages/web/src/components/ui/UserProfilePopout.tsx create mode 100644 packages/web/src/stores/socialStore.js create mode 100644 packages/web/src/stores/socialStore.ts create mode 100644 packages/web/src/test/setup.ts diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..db87e29f --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +# Raspberry Pi Deployment Makefile (Direct SSH) + +PI_IP = 192.168.1.10 +PI_USER = youruser +REMOTE_PATH = ~/opencord + +.PHONY: deploy logs shell env status + +# Full deployment: sync code and rebuild/restart containers ON the Pi +deploy: + @chmod +x deploy.sh + ./deploy.sh + +# Watch real-time logs FROM the Pi server +logs: + ssh $(PI_USER)@$(PI_IP) "cd $(REMOTE_PATH) && docker compose logs -f" + +# Drop into a shell inside the running container ON the Pi +shell: + ssh -t $(PI_USER)@$(PI_IP) "docker exec -it opencord /bin/bash" + +# Push local .env to the Pi (do this once) +env: + @echo "📤 Pushing .env to Pi..." + ssh $(PI_USER)@$(PI_IP) "mkdir -p $(REMOTE_PATH)" + scp .env $(PI_USER)@$(PI_IP):$(REMOTE_PATH)/.env + +# Check container status ON the Pi +status: + ssh $(PI_USER)@$(PI_IP) "cd $(REMOTE_PATH) && docker compose ps" diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 00000000..3114f056 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# Configuration +PI_IP="192.168.1.10" +PI_USER="youruser" +REMOTE_PATH="~/opencord" + +# 0. Ensure remote directory exists +echo "📁 Preparing remote directory on Pi..." +ssh "$PI_USER@$PI_IP" "mkdir -p $REMOTE_PATH" + +# 1. Sync files via rsync +echo "🚀 Syncing files to Pi..." +rsync -avz --delete \ + --exclude='node_modules' \ + --exclude='.git' \ + --exclude='.env.local' \ + --exclude='packages/*/node_modules' \ + --exclude='packages/web/dist' \ + --exclude='data' \ + --exclude='.DS_Store' \ + ./ "$PI_USER@$PI_IP:$REMOTE_PATH" + +# 2. Trigger Docker Compose directly on the Pi via SSH +echo "🚢 Triggering build and deploy on Pi hardware..." +ssh "$PI_USER@$PI_IP" "cd $REMOTE_PATH && docker compose up -d --build" + +echo "✅ Deployment command sent to Pi!" diff --git a/docker-compose.yml b/docker-compose.yml index b86af35f..7bab48b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: container_name: opencord restart: unless-stopped ports: - - "${PORT:-3000}:3000" + - "${PORT:-3000}:${PORT:-3000}" volumes: - opencord-data:/app/data env_file: @@ -16,7 +16,7 @@ services: - DB_PATH=/app/data/opencord.db - UPLOAD_DIR=/app/data/uploads healthcheck: - test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"] + test: ["CMD", "node", "-e", "fetch('http://localhost:${PORT:-3000}/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"] interval: 30s timeout: 5s retries: 3 diff --git a/packages/server/package.json b/packages/server/package.json index d8e2c13d..c0d34d5c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -21,7 +21,8 @@ "drizzle-orm": "^0.33.0", "fastify": "^4.28.1", "jsonwebtoken": "^9.0.2", - "livekit-server-sdk": "^2.6.1" + "livekit-server-sdk": "^2.6.1", + "cheerio": "^1.0.0" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index ba2fd546..84487e31 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -2,6 +2,7 @@ import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import { config } from '../config.js'; import * as schema from './schema.js'; +import { runMigrations } from './migrate.js'; import { mkdirSync } from 'fs'; import { dirname } from 'path'; @@ -57,6 +58,7 @@ function createTables(db: Database.Database): void { id TEXT PRIMARY KEY, channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id), + reply_to_id TEXT REFERENCES messages(id) ON DELETE SET NULL, content TEXT, edited_at INTEGER, created_at INTEGER NOT NULL @@ -90,6 +92,62 @@ function createTables(db: Database.Database): void { content TEXT, created_at INTEGER NOT NULL ); + + CREATE TABLE IF NOT EXISTS friends ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + friend_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, friend_id) + ); + + CREATE TABLE IF NOT EXISTS friend_requests ( + id TEXT PRIMARY KEY, + from_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT DEFAULT 'pending', + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS reactions ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(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, + name TEXT NOT NULL, + color TEXT DEFAULT '#b9bbbe', + position INTEGER DEFAULT 0, + permissions TEXT, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS member_roles ( + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + PRIMARY KEY (server_id, user_id, role_id) + ); + + CREATE TABLE IF NOT EXISTS server_folders ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT, + color TEXT, + position INTEGER DEFAULT 0, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS server_folder_members ( + folder_id TEXT NOT NULL REFERENCES server_folders(id) ON DELETE CASCADE, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + PRIMARY KEY (folder_id, server_id) + ); `); } @@ -99,6 +157,7 @@ export function initDatabase() { sqlite.pragma('journal_mode = WAL'); sqlite.pragma('foreign_keys = ON'); createTables(sqlite); + runMigrations(sqlite); console.log(`Database initialized at ${config.dbPath}`); return drizzle(sqlite, { schema }); } diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts new file mode 100644 index 00000000..b13c3902 --- /dev/null +++ b/packages/server/src/db/migrate.ts @@ -0,0 +1,45 @@ +import Database from 'better-sqlite3'; + +export function runMigrations(db: Database.Database): void { + console.log('Checking for database migrations...'); + + const tables = [ + { + name: 'messages', + columns: [ + { name: 'reply_to_id', type: 'TEXT REFERENCES messages(id) ON DELETE SET NULL' } + ] + }, + { + name: 'users', + columns: [ + { name: 'status', type: "TEXT DEFAULT 'offline'" }, + { name: 'custom_status', type: 'TEXT' } + ] + }, + { + name: 'roles', + columns: [ + { name: 'permissions', type: 'TEXT' } + ] + } + ]; + + for (const table of tables) { + const tableInfo = db.pragma(`table_info(${table.name})`) as { name: string }[]; + const existingColumns = new Set(tableInfo.map(c => c.name)); + + for (const column of table.columns) { + if (!existingColumns.has(column.name)) { + console.log(`Migrating: Adding column ${column.name} to ${table.name}`); + try { + db.exec(`ALTER TABLE ${table.name} ADD COLUMN ${column.name} ${column.type}`); + } catch (error) { + console.error(`Failed to add column ${column.name} to ${table.name}:`, error); + } + } + } + } + + console.log('Migrations complete.'); +} diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 9b7431ec..a90c3ebd 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core'; +import { sqliteTable, text, integer, primaryKey, foreignKey } from 'drizzle-orm/sqlite-core'; export const users = sqliteTable('users', { id: text('id').primaryKey(), @@ -44,10 +44,16 @@ export const messages = sqliteTable('messages', { id: text('id').primaryKey(), channelId: text('channel_id').notNull().references(() => channels.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(), -}); +}, (table) => ({ + replyToFk: foreignKey({ + columns: [table.replyToId], + foreignColumns: [table.id], + }).onDelete('set null'), +})); export const attachments = sqliteTable('attachments', { id: text('id').primaryKey(), @@ -78,3 +84,61 @@ export const dmMessages = sqliteTable('dm_messages', { content: text('content'), createdAt: integer('created_at').notNull(), }); + +export const friends = sqliteTable('friends', { + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + friendId: text('friend_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + createdAt: integer('created_at').notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.userId, table.friendId] }), +})); + +export const friendRequests = sqliteTable('friend_requests', { + id: text('id').primaryKey(), + fromId: text('from_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + toId: text('to_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + status: text('status').default('pending'), // 'pending', 'accepted', 'declined' + createdAt: integer('created_at').notNull(), +}); + +export const reactions = sqliteTable('reactions', { + id: text('id').primaryKey(), + messageId: text('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }), + 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' }), + name: text('name').notNull(), + color: text('color').default('#b9bbbe'), + position: integer('position').default(0), + permissions: text('permissions'), // JSON string of permission keys + createdAt: integer('created_at').notNull(), +}); + +export const memberRoles = sqliteTable('member_roles', { + serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + roleId: text('role_id').notNull().references(() => roles.id, { onDelete: 'cascade' }), +}, (table) => ({ + pk: primaryKey({ columns: [table.serverId, table.userId, table.roleId] }), +})); + +export const serverFolders = sqliteTable('server_folders', { + id: text('id').primaryKey(), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + name: text('name'), + color: text('color'), + position: integer('position').default(0), + createdAt: integer('created_at').notNull(), +}); + +export const serverFolderMembers = sqliteTable('server_folder_members', { + folderId: text('folder_id').notNull().references(() => serverFolders.id, { onDelete: 'cascade' }), + serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }), +}, (table) => ({ + pk: primaryKey({ columns: [table.folderId, table.serverId] }), +})); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 796c4e59..412e8cd0 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -4,7 +4,7 @@ import websocket from '@fastify/websocket'; import multipart from '@fastify/multipart'; import fastifyStatic from '@fastify/static'; import { config } from './config.js'; -import { initDatabase } from './db/index.js'; +import { getDb } from './db/index.js'; import { seedDatabase } from './db/seed.js'; import { authRoutes } from './routes/auth.js'; import { userRoutes } from './routes/users.js'; @@ -14,6 +14,8 @@ import { messageRoutes } from './routes/messages.js'; import { uploadRoutes } from './routes/uploads.js'; import { dmRoutes } from './routes/dm.js'; import { livekitRoutes } from './routes/livekit.js'; +import { socialRoutes } from './routes/social.js'; +import { utilRoutes } from './routes/utils.js'; import { registerWebSocket } from './ws/handler.js'; import path from 'path'; import fs from 'fs'; @@ -50,7 +52,8 @@ async function main(): Promise { }); } - initDatabase(); + // Initialize database + getDb(); await seedDatabase(); await app.register(authRoutes); @@ -61,6 +64,8 @@ async function main(): Promise { await app.register(uploadRoutes); await app.register(dmRoutes); await app.register(livekitRoutes); + await app.register(socialRoutes); + await app.register(utilRoutes); await app.register(registerWebSocket); app.get('/api/health', async () => { diff --git a/packages/server/src/routes/livekit.ts b/packages/server/src/routes/livekit.ts index 59a75df2..d8755dab 100644 --- a/packages/server/src/routes/livekit.ts +++ b/packages/server/src/routes/livekit.ts @@ -41,7 +41,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise { const jwt = await token.toJwt(); - const response: LiveKitTokenResponse = { token: jwt }; + const response: LiveKitTokenResponse = { + token: jwt, + url: config.livekit.url + }; return reply.code(200).send(response); }); } diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index eae4e708..c4e7ab6b 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify'; -import { eq, lt, desc, inArray } from 'drizzle-orm'; +import { eq, desc, inArray } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; @@ -11,7 +11,7 @@ import type { PaginatedQuery, User, MessageWithUser, - Attachment, + Reaction, } from '@opencord/shared'; function sanitizeUser(row: typeof schema.users.$inferSelect): User { @@ -26,15 +26,123 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User { }; } +/** + * Fetch reactions for a set of message IDs. + * Returns a map from messageId to Reaction[]. + */ +function fetchReactionsForMessages(messageIds: string[]): Map { + if (messageIds.length === 0) return new Map(); + const db = getDb(); + const reactionRows = db.select() + .from(schema.reactions) + .where(inArray(schema.reactions.messageId, messageIds)) + .all(); + + // Batch fetch users for reactions + const reactionUserIds = [...new Set(reactionRows.map(r => r.userId))]; + const reactionUsers = reactionUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, reactionUserIds)).all() + : []; + const reactionUserMap = new Map(reactionUsers.map(u => [u.id, u])); + + const map = new Map(); + for (const r of reactionRows) { + const user = reactionUserMap.get(r.userId); + const reaction: Reaction = { + id: r.id, + messageId: r.messageId, + userId: r.userId, + emoji: r.emoji, + createdAt: r.createdAt, + user: user ? sanitizeUser(user) : undefined, + }; + if (!map.has(r.messageId)) { + map.set(r.messageId, []); + } + map.get(r.messageId)!.push(reaction); + } + return map; +} + +/** + * Fetch reply-to messages for a set of message IDs. + * Returns a map from messageId to its reply parent MessageWithUser. + */ +function fetchReplyToMessages(messages: (typeof schema.messages.$inferSelect)[]): Map { + const replyToIds = messages + .map(m => m.replyToId) + .filter((id): id is string => id !== null && id !== undefined); + + if (replyToIds.length === 0) return new Map(); + + const db = getDb(); + const uniqueReplyIds = [...new Set(replyToIds)]; + const replyMessages = db.select() + .from(schema.messages) + .where(inArray(schema.messages.id, uniqueReplyIds)) + .all(); + + // Fetch users for reply messages + const replyUserIds = [...new Set(replyMessages.map(m => m.userId))]; + const replyUsers = replyUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, replyUserIds)).all() + : []; + const replyUserMap = new Map(replyUsers.map(u => [u.id, u])); + + // Fetch attachments for reply messages + const replyMsgIds = replyMessages.map(m => m.id); + const replyAttachments = replyMsgIds.length > 0 + ? db.select().from(schema.attachments).where(inArray(schema.attachments.messageId, replyMsgIds)).all() + : []; + const replyAttMap = new Map(); + for (const att of replyAttachments) { + const mid = att.messageId ?? ''; + if (!replyAttMap.has(mid)) replyAttMap.set(mid, []); + replyAttMap.get(mid)!.push(att); + } + + const map = new Map(); + for (const rm of replyMessages) { + const user = replyUserMap.get(rm.userId); + if (!user) continue; + const atts = replyAttMap.get(rm.id) ?? []; + map.set(rm.id, { + id: rm.id, + channelId: rm.channelId, + userId: rm.userId, + replyToId: rm.replyToId, + content: rm.content, + editedAt: rm.editedAt, + createdAt: rm.createdAt, + user: sanitizeUser(user), + attachments: atts.map(a => ({ + id: a.id, + messageId: a.messageId ?? rm.id, + filename: a.filename, + originalName: a.originalName, + mimetype: a.mimetype, + size: a.size, + createdAt: a.createdAt, + })), + reactions: [], + replyTo: null, + }); + } + return map; +} + function buildMessageWithUser( message: typeof schema.messages.$inferSelect, user: typeof schema.users.$inferSelect, attachmentRows: (typeof schema.attachments.$inferSelect)[], + reactions: Reaction[] = [], + replyTo: MessageWithUser | null = null, ): MessageWithUser { return { id: message.id, channelId: message.channelId, userId: message.userId, + replyToId: message.replyToId, content: message.content, editedAt: message.editedAt, createdAt: message.createdAt, @@ -48,6 +156,8 @@ function buildMessageWithUser( size: a.size, createdAt: a.createdAt, })), + reactions, + replyTo, }; } @@ -118,11 +228,19 @@ export async function messageRoutes(app: FastifyInstance): Promise { attachmentMap.get(mid)!.push(att); } + // Batch fetch reactions for all messages + const reactionsMap = fetchReactionsForMessages(messageIds); + + // Batch fetch reply-to messages + const replyToMap = fetchReplyToMessages(messageRows); + const messages: MessageWithUser[] = messageRows .map(m => { const user = userMap.get(m.userId); if (!user) return null; - return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? []); + const reactions = reactionsMap.get(m.id) ?? []; + const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; + return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo); }) .filter((m): m is MessageWithUser => m !== null); @@ -134,7 +252,7 @@ export async function messageRoutes(app: FastifyInstance): Promise { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; - const { content, attachments: attachmentIds } = request.body; + const { content, attachments: attachmentIds, replyToId } = request.body; const serverId = getChannelServerId(id); if (!serverId) { @@ -158,6 +276,7 @@ export async function messageRoutes(app: FastifyInstance): Promise { id: messageId, channelId: id, userId: request.userId, + replyToId: replyToId || null, content: content?.trim() || null, createdAt: now, }).run(); @@ -187,7 +306,14 @@ export async function messageRoutes(app: FastifyInstance): Promise { return reply.code(500).send({ error: 'Failed to create message', statusCode: 500 }); } - const messageWithUser = buildMessageWithUser(message, user, attachmentRows); + // Hydrate the reply-to message if present + let replyTo: MessageWithUser | null = null; + if (message.replyToId) { + const replyToMap = fetchReplyToMessages([message]); + replyTo = replyToMap.get(message.replyToId) ?? null; + } + + const messageWithUser = buildMessageWithUser(message, user, attachmentRows, [], replyTo); // Broadcast via WebSocket connectionManager.sendToServer(serverId, { @@ -240,7 +366,16 @@ export async function messageRoutes(app: FastifyInstance): Promise { .where(eq(schema.attachments.messageId, id)) .all(); - const messageWithUser = buildMessageWithUser(updatedMessage, user, attachmentRows); + // Hydrate reactions and reply-to + const reactionsMap = fetchReactionsForMessages([id]); + const reactions = reactionsMap.get(id) ?? []; + let replyTo: MessageWithUser | null = null; + if (updatedMessage.replyToId) { + const replyToMap = fetchReplyToMessages([updatedMessage]); + replyTo = replyToMap.get(updatedMessage.replyToId) ?? null; + } + + const messageWithUser = buildMessageWithUser(updatedMessage, user, attachmentRows, reactions, replyTo); // Broadcast edit const serverId = getChannelServerId(message.channelId); diff --git a/packages/server/src/routes/servers.ts b/packages/server/src/routes/servers.ts index 6d08ec5e..b2d57e8d 100644 --- a/packages/server/src/routes/servers.ts +++ b/packages/server/src/routes/servers.ts @@ -5,6 +5,7 @@ import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { isMember, isOwner, isAdmin } from '../utils/permissions.js'; import crypto from 'crypto'; +import { connectionManager } from '../ws/handler.js'; import type { CreateServerRequest, UpdateServerRequest, @@ -15,6 +16,7 @@ import type { Channel, MemberWithUser, ServerWithChannelsAndMembers, + Role, } from '@opencord/shared'; function sanitizeUser(row: typeof schema.users.$inferSelect): User { @@ -159,6 +161,12 @@ export async function serverRoutes(app: FastifyInstance): Promise { .where(eq(schema.channels.serverId, id)) .all(); + const roles = db.select() + .from(schema.roles) + .where(eq(schema.roles.serverId, id)) + .orderBy(schema.roles.position) + .all(); + const memberRows = db.select() .from(schema.serverMembers) .where(eq(schema.serverMembers.serverId, id)) @@ -171,10 +179,31 @@ export async function serverRoutes(app: FastifyInstance): Promise { const userMap = new Map(users.map(u => [u.id, u])); + const memberRoleRows = db.select() + .from(schema.memberRoles) + .where(eq(schema.memberRoles.serverId, id)) + .all(); + const members: MemberWithUser[] = memberRows .map(m => { const user = userMap.get(m.userId); if (!user) 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, @@ -182,6 +211,7 @@ export async function serverRoutes(app: FastifyInstance): Promise { nickname: m.nickname, joinedAt: m.joinedAt, user: sanitizeUser(user), + roles: memberRoles, }; }) .filter((m): m is MemberWithUser => m !== null); @@ -190,6 +220,14 @@ export async function serverRoutes(app: FastifyInstance): Promise { ...rowToServer(server), channels: channels.map(rowToChannel), 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, + })), }; return reply.code(200).send(result); @@ -280,6 +318,11 @@ export async function serverRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'Only admins can generate invite codes', statusCode: 403 }); } + // Return existing invite code if one exists, otherwise generate a new one + if (server.inviteCode) { + return reply.code(200).send({ inviteCode: server.inviteCode }); + } + const inviteCode = generateInviteCode(); db.update(schema.servers).set({ inviteCode }).where(eq(schema.servers.id, id)).run(); @@ -394,6 +437,7 @@ export async function serverRoutes(app: FastifyInstance): Promise { nickname: m.nickname, joinedAt: m.joinedAt, user: sanitizeUser(user), + roles: [] as Role[], // TODO: Fetch member roles }; }) .filter((m): m is MemberWithUser => m !== null); @@ -470,6 +514,7 @@ export async function serverRoutes(app: FastifyInstance): Promise { nickname: updatedMember.nickname, joinedAt: updatedMember.joinedAt, user: sanitizeUser(user), + roles: [] as Role[], // TODO: Fetch member roles }; return reply.code(200).send(result); @@ -523,6 +568,114 @@ export async function serverRoutes(app: FastifyInstance): Promise { )) .run(); + // Broadcast member_left event + connectionManager.sendToServer(id, { + type: 'member_left', + serverId: id, + userId: uid, + }); + + return reply.code(200).send({ success: true }); + }); + + // Role Management + + // POST /api/servers/:id/roles - Create a new role + app.post<{ Params: { id: string }; Body: { name: string; color?: string } }>('/api/servers/:id/roles', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { name, color } = request.body; + const db = getDb(); + + if (!isAdmin(id, request.userId)) { + return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); + } + + const roleId = generateSnowflake(); + db.insert(schema.roles).values({ + id: roleId, + serverId: id, + name: name || 'new role', + color: color || '#b9bbbe', + position: 0, + createdAt: Date.now(), + }).run(); + + const role = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get(); + return reply.code(201).send(role); + }); + + // PATCH /api/servers/:id/roles/:roleId - Update a role + app.patch<{ Params: { id: string; roleId: string }; Body: { name?: string; color?: string; position?: number } }>('/api/servers/:id/roles/:roleId', { + preHandler: authenticate, + }, async (request, reply) => { + const { id, roleId } = request.params; + const updates = request.body; + const db = getDb(); + + if (!isAdmin(id, request.userId)) { + return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); + } + + db.update(schema.roles).set(updates).where(and(eq(schema.roles.id, roleId), eq(schema.roles.serverId, id))).run(); + const updated = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get(); + return reply.code(200).send(updated); + }); + + // DELETE /api/servers/:id/roles/:roleId - Delete a role + app.delete<{ Params: { id: string; roleId: string } }>('/api/servers/:id/roles/:roleId', { + preHandler: authenticate, + }, async (request, reply) => { + const { id, roleId } = request.params; + const db = getDb(); + + if (!isAdmin(id, request.userId)) { + return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); + } + + db.delete(schema.roles).where(and(eq(schema.roles.id, roleId), eq(schema.roles.serverId, id))).run(); + return reply.code(200).send({ success: true }); + }); + + // POST /api/servers/:id/members/:uid/roles - Add role to member + app.post<{ Params: { id: string; uid: string }; Body: { roleId: string } }>('/api/servers/:id/members/:uid/roles', { + preHandler: authenticate, + }, async (request, reply) => { + const { id, uid } = request.params; + const { roleId } = request.body; + const db = getDb(); + + if (!isAdmin(id, request.userId)) { + return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); + } + + db.insert(schema.memberRoles).values({ + serverId: id, + userId: uid, + roleId, + }).run(); + + return reply.code(200).send({ success: true }); + }); + + // DELETE /api/servers/:id/members/:uid/roles/:roleId - Remove role from member + app.delete<{ Params: { id: string; uid: string; roleId: string } }>('/api/servers/:id/members/:uid/roles/:roleId', { + preHandler: authenticate, + }, async (request, reply) => { + const { id, uid, roleId } = request.params; + const db = getDb(); + + if (!isAdmin(id, request.userId)) { + return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 }); + } + + db.delete(schema.memberRoles).where(and( + eq(schema.memberRoles.serverId, id), + eq(schema.memberRoles.userId, uid), + eq(schema.memberRoles.roleId, roleId) + )).run(); + return reply.code(200).send({ success: true }); }); } diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts new file mode 100644 index 00000000..01b5fb09 --- /dev/null +++ b/packages/server/src/routes/social.ts @@ -0,0 +1,313 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, and, or, ne, like } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { authenticate } from '../utils/auth.js'; +import { generateSnowflake } from '../utils/snowflake.js'; +import { connectionManager } from '../ws/handler.js'; +import type { + User, + Friend, + FriendRequest, + SendFriendRequest, + UpdateFriendRequest, +} from '@opencord/shared'; + +function sanitizeUser(row: typeof schema.users.$inferSelect): User { + return { + id: row.id, + username: row.username, + displayName: row.displayName, + avatar: row.avatar, + status: (row.status ?? 'offline') as User['status'], + customStatus: row.customStatus, + createdAt: row.createdAt, + }; +} + +export async function socialRoutes(app: FastifyInstance): Promise { + // GET /api/social/friends - List all friends + app.get('/api/social/friends', { + preHandler: authenticate, + }, async (request, reply) => { + const db = getDb(); + + // Get all friends where current user is either userId or friendId + const friendRows = db.select() + .from(schema.friends) + .where(or( + eq(schema.friends.userId, request.userId), + eq(schema.friends.friendId, request.userId) + )) + .all(); + + if (friendRows.length === 0) { + return reply.code(200).send([]); + } + + // Get the IDs of the actual friends (not the current user) + const friendIds = friendRows.map(f => f.userId === request.userId ? f.friendId : f.userId); + + const friendUsers = db.select() + .from(schema.users) + .where(or(...friendIds.map(id => eq(schema.users.id, id)))) + .all(); + + const friends: Friend[] = friendUsers.map(u => { + const relationship = friendRows.find(f => f.userId === u.id || f.friendId === u.id); + return { + ...sanitizeUser(u), + addedAt: relationship?.createdAt ?? Date.now(), + }; + }); + + return reply.code(200).send(friends); + }); + + // GET /api/social/requests - List pending friend requests + app.get('/api/social/requests', { + preHandler: authenticate, + }, async (request, reply) => { + const db = getDb(); + + const requests = db.select() + .from(schema.friendRequests) + .where(and( + or( + eq(schema.friendRequests.fromId, request.userId), + eq(schema.friendRequests.toId, request.userId) + ), + eq(schema.friendRequests.status, 'pending') + )) + .all(); + + if (requests.length === 0) { + return reply.code(200).send([]); + } + + // Enhance with user data + const userIds = requests.map(r => r.fromId === request.userId ? r.toId : r.fromId); + const users = db.select() + .from(schema.users) + .where(or(...userIds.map(id => eq(schema.users.id, id)))) + .all(); + + const userMap = new Map(users.map(u => [u.id, u])); + + const result: FriendRequest[] = requests.map(r => { + const otherId = r.fromId === request.userId ? r.toId : r.fromId; + const otherUser = userMap.get(otherId); + return { + id: r.id, + fromId: r.fromId, + toId: r.toId, + status: (r.status ?? 'pending') as any, + createdAt: r.createdAt, + user: otherUser ? sanitizeUser(otherUser) : undefined, + }; + }); + + return reply.code(200).send(result); + }); + + // POST /api/social/requests - Send a friend request + app.post<{ Body: SendFriendRequest }>('/api/social/requests', { + preHandler: authenticate, + }, async (request, reply) => { + const { username } = request.body; + const db = getDb(); + + if (!username) { + return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); + } + + // Find the target user + const targetUser = db.select().from(schema.users).where(eq(schema.users.username, username)).get(); + if (!targetUser) { + return reply.code(404).send({ error: 'User not found', statusCode: 404 }); + } + + if (targetUser.id === request.userId) { + return reply.code(400).send({ error: 'You cannot add yourself as a friend', statusCode: 400 }); + } + + // Check if already friends + const existingFriend = db.select().from(schema.friends).where(or( + and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUser.id)), + and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, request.userId)) + )).get(); + + if (existingFriend) { + return reply.code(400).send({ error: 'You are already friends with this user', statusCode: 400 }); + } + + // Check for existing pending request + const existingRequest = db.select().from(schema.friendRequests).where(and( + or( + and(eq(schema.friendRequests.fromId, request.userId), eq(schema.friendRequests.toId, targetUser.id)), + and(eq(schema.friendRequests.fromId, targetUser.id), eq(schema.friendRequests.toId, request.userId)) + ), + eq(schema.friendRequests.status, 'pending') + )).get(); + + if (existingRequest) { + return reply.code(400).send({ error: 'A friend request is already pending', statusCode: 400 }); + } + + // Create the request + const id = generateSnowflake(); + const now = Date.now(); + db.insert(schema.friendRequests).values({ + id, + fromId: request.userId, + toId: targetUser.id, + status: 'pending', + createdAt: now, + }).run(); + + // Get the sender user for the WS event + const senderUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + + // Broadcast friend_request_received to the target user + const friendRequestPayload: FriendRequest = { + id, + fromId: request.userId, + toId: targetUser.id, + status: 'pending', + createdAt: now, + user: senderUser ? sanitizeUser(senderUser) : undefined, + }; + + connectionManager.sendToUser(targetUser.id, { + type: 'friend_request_received', + request: friendRequestPayload, + }); + + return reply.code(201).send({ success: true }); + }); + + // PATCH /api/social/requests/:id - Accept/Decline a friend request + app.patch<{ Params: { id: string }; Body: UpdateFriendRequest }>('/api/social/requests/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { status } = request.body; + const db = getDb(); + + if (!['accepted', 'declined'].includes(status)) { + return reply.code(400).send({ error: 'Invalid status', statusCode: 400 }); + } + + const friendRequest = db.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, id)).get(); + if (!friendRequest) { + return reply.code(404).send({ error: 'Friend request not found', statusCode: 404 }); + } + + if (friendRequest.toId !== request.userId) { + return reply.code(403).send({ error: 'You can only manage requests sent to you', statusCode: 403 }); + } + + if (status === 'accepted') { + // Add to friends table + const now = Date.now(); + db.insert(schema.friends).values({ + userId: friendRequest.fromId, + friendId: friendRequest.toId, + createdAt: now, + }).run(); + + // Get the accepting user's data for the WS event + const acceptingUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (acceptingUser) { + const friend: Friend = { + ...sanitizeUser(acceptingUser), + addedAt: now, + }; + connectionManager.sendToUser(friendRequest.fromId, { + type: 'friend_request_accepted', + friend, + requestId: id, + }); + } + } + + // Update request status + db.update(schema.friendRequests) + .set({ status }) + .where(eq(schema.friendRequests.id, id)) + .run(); + + return reply.code(200).send({ success: true }); + }); + + // DELETE /api/social/requests/:id - Cancel an outgoing friend request + app.delete<{ Params: { id: string } }>('/api/social/requests/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const friendRequest = db.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, id)).get(); + if (!friendRequest) { + return reply.code(404).send({ error: 'Friend request not found', statusCode: 404 }); + } + + // Only the sender can cancel an outgoing request + if (friendRequest.fromId !== request.userId) { + return reply.code(403).send({ error: 'You can only cancel requests you sent', statusCode: 403 }); + } + + if (friendRequest.status !== 'pending') { + return reply.code(400).send({ error: 'Can only cancel pending requests', statusCode: 400 }); + } + + db.delete(schema.friendRequests) + .where(eq(schema.friendRequests.id, id)) + .run(); + + return reply.code(200).send({ success: true }); + }); + + // DELETE /api/social/friends/:id - Remove a friend + app.delete<{ Params: { id: string } }>('/api/social/friends/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + db.delete(schema.friends).where(or( + and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, id)), + and(eq(schema.friends.userId, id), eq(schema.friends.friendId, request.userId)) + )).run(); + + return reply.code(200).send({ success: true }); + }); + + // GET /api/social/search?q=... - Search for users to add as friends + app.get<{ Querystring: { q: string } }>('/api/social/search', { + preHandler: authenticate, + }, async (request, reply) => { + const { q } = request.query; + const db = getDb(); + + if (!q || q.length < 2) { + return reply.code(200).send([]); + } + + const pattern = `%${q}%`; + + // Search by username or display name with partial matching, excluding current user + const users = db.select() + .from(schema.users) + .where(and( + or( + like(schema.users.username, pattern), + like(schema.users.displayName, pattern) + ), + ne(schema.users.id, request.userId) + )) + .limit(10) + .all(); + + return reply.code(200).send(users.map(sanitizeUser)); + }); +} diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 549337e6..b5beab49 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'; import { eq } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; +import { connectionManager } from '../ws/handler.js'; import type { User, UpdateUserRequest } from '@opencord/shared'; function sanitizeUser(row: typeof schema.users.$inferSelect): User { @@ -29,7 +30,7 @@ export async function userRoutes(app: FastifyInstance): Promise { }); app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => { - const { displayName, avatar, customStatus } = request.body; + const { displayName, avatar, customStatus, status } = request.body; const db = getDb(); const updateData: Record = {}; @@ -62,6 +63,13 @@ export async function userRoutes(app: FastifyInstance): Promise { } } + if (status !== undefined) { + if (!['online', 'idle', 'dnd', 'offline'].includes(status)) { + return reply.code(400).send({ error: 'Invalid status', statusCode: 400 }); + } + updateData.status = status; + } + if (Object.keys(updateData).length === 0) { return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); } @@ -73,7 +81,26 @@ export async function userRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'User not found', statusCode: 404 }); } - return reply.code(200).send(sanitizeUser(updatedUser)); + const sanitized = sanitizeUser(updatedUser); + + // Broadcast presence update if status changed + if (status !== undefined) { + const userServers = connectionManager.getUserServers(sanitized.id); + for (const serverId of userServers) { + connectionManager.sendToServer(serverId, { + type: 'presence_update', + userId: sanitized.id, + status: status, + }, sanitized.id); + } + connectionManager.sendToUser(sanitized.id, { + type: 'presence_update', + userId: sanitized.id, + status: status, + }); + } + + return reply.code(200).send(sanitized); }); app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => { diff --git a/packages/server/src/routes/utils.ts b/packages/server/src/routes/utils.ts new file mode 100644 index 00000000..39f96657 --- /dev/null +++ b/packages/server/src/routes/utils.ts @@ -0,0 +1,42 @@ +import type { FastifyInstance } from 'fastify'; +import { authenticate } from '../utils/auth.js'; +import * as cheerio from 'cheerio'; + +export async function utilRoutes(app: FastifyInstance): Promise { + app.get<{ Querystring: { url: string } }>('/api/utils/metadata', { + preHandler: authenticate, + }, async (request, reply) => { + const { url } = request.query; + + if (!url) { + return reply.code(400).send({ error: 'URL is required' }); + } + + try { + const response = await fetch(url, { + headers: { + 'User-Agent': 'OpencordBot/1.0', + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch URL'); + } + + const html = await response.text(); + const $ = cheerio.load(html); + + const metadata = { + title: $('meta[property="og:title"]').attr('content') || $('title').text(), + description: $('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr('content'), + image: $('meta[property="og:image"]').attr('content'), + siteName: $('meta[property="og:site_name"]').attr('content'), + url: url, + }; + + return reply.code(200).send(metadata); + } catch (err) { + return reply.code(200).send({}); // Fail silently with empty object + } + }); +} diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 15c4b9e9..3b25e6a1 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -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, 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, userId: string): vo id: messageId, channelId, userId, + replyToId: replyToId || null, content: content.trim(), createdAt: now, }).run(); @@ -407,3 +454,73 @@ function handleDmMessageCreate(event: Record, userId: string): }); } } + +function handleReactionAdd(event: Record, 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, 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, + }); + } +} diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index b8157a74..a104fc6b 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -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 { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index be0e86bc..1fe0942b 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -30,6 +30,7 @@ export interface Server { export interface ServerWithChannelsAndMembers extends Server { channels: Channel[]; members: MemberWithUser[]; + roles: Role[]; } // ─── Member Types ─────────────────────────────────────────────────────────── @@ -46,6 +47,30 @@ export interface Member { export interface MemberWithUser extends Member { user: User; + roles: Role[]; +} + +// ─── Role Types ───────────────────────────────────────────────────────────── + +export interface Role { + id: string; + serverId: string; + name: string; + color: string; + position: number; + permissions?: string[]; + createdAt: number; +} + +// ─── Folder Types ─────────────────────────────────────────────────────────── + +export interface ServerFolder { + id: string; + userId: string; + name: string | null; + color: string | null; + position: number; + serverIds: string[]; } // ─── Channel Types ────────────────────────────────────────────────────────── @@ -68,6 +93,7 @@ export interface Message { id: string; channelId: string; userId: string; + replyToId: string | null; content: string | null; editedAt: number | null; createdAt: number; @@ -76,6 +102,19 @@ export interface Message { export interface MessageWithUser extends Message { user: User; attachments: Attachment[]; + reactions: Reaction[]; + replyTo?: MessageWithUser | null; +} + +// ─── Reaction Types ──────────────────────────────────────────────────────── + +export interface Reaction { + id: string; + messageId: string; + userId: string; + emoji: string; + createdAt: number; + user?: User; } // ─── Attachment Types ─────────────────────────────────────────────────────── @@ -105,10 +144,17 @@ export interface DmMessage { userId: string; content: string | null; createdAt: number; + // Compatibility fields + channelId?: string; + replyToId?: string | null; + editedAt?: number | null; } export interface DmMessageWithUser extends DmMessage { user: User; + attachments?: Attachment[]; + reactions?: Reaction[]; + replyTo?: MessageWithUser | null; } // ─── WebSocket Event Types ────────────────────────────────────────────────── @@ -116,18 +162,20 @@ export interface DmMessageWithUser extends DmMessage { // Client → Server Events export type ClientEvent = | { type: 'auth'; token: string } - | { type: 'message_create'; channelId: string; content: string } + | { type: 'message_create'; channelId: string; content: string; replyToId?: string } | { type: 'message_edit'; messageId: string; content: string } | { type: 'message_delete'; messageId: string } | { type: 'typing_start'; channelId: string } | { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' } | { type: 'voice_join'; channelId: string } | { type: 'voice_leave' } - | { type: 'dm_message_create'; dmChannelId: string; content: string }; + | { type: 'dm_message_create'; dmChannelId: string; content: string } + | { type: 'reaction_add'; messageId: string; emoji: string } + | { type: 'reaction_remove'; messageId: string; emoji: string }; // Server → Client Events export type ServerEvent = - | { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[] } + | { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[] } | { type: 'message_created'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser } | { type: 'message_deleted'; messageId: string; channelId: string } @@ -137,6 +185,10 @@ export type ServerEvent = | { type: 'member_joined'; serverId: string; member: MemberWithUser } | { type: 'member_left'; serverId: string; userId: string } | { type: 'dm_message_created'; message: DmMessageWithUser } + | { type: 'reaction_added'; messageId: string; reaction: Reaction } + | { type: 'reaction_removed'; messageId: string; userId: string; emoji: string } + | { type: 'friend_request_received'; request: FriendRequest } + | { type: 'friend_request_accepted'; friend: Friend; requestId: string } | { type: 'error'; message: string }; // ─── API Request/Response Types ───────────────────────────────────────────── @@ -183,6 +235,7 @@ export interface UpdateUserRequest { displayName?: string; avatar?: string; customStatus?: string; + status?: UserStatus; } export interface UpdateMemberRequest { @@ -192,6 +245,7 @@ export interface UpdateMemberRequest { export interface CreateMessageRequest { content: string; attachments?: string[]; + replyToId?: string; } export interface UpdateMessageRequest { @@ -208,6 +262,7 @@ export interface LiveKitTokenRequest { export interface LiveKitTokenResponse { token: string; + url: string; } export interface CreateDmRequest { @@ -227,3 +282,35 @@ export interface ApiError { error: string; statusCode: number; } + +// ─── Social Types ──────────────────────────────────────────────────────────── + +export interface Friend { + id: string; + username: string; + displayName: string | null; + avatar: string | null; + status: UserStatus; + customStatus: string | null; + createdAt: number; + addedAt: number; +} + +export type FriendRequestStatus = 'pending' | 'accepted' | 'declined'; + +export interface FriendRequest { + id: string; + fromId: string; + toId: string; + status: FriendRequestStatus; + createdAt: number; + user?: User; // The other user (if it's an incoming request, the sender; if outgoing, the recipient) +} + +export interface SendFriendRequest { + username: string; +} + +export interface UpdateFriendRequest { + status: 'accepted' | 'declined'; +} diff --git a/packages/web/package.json b/packages/web/package.json index f7cd4da5..011d8897 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -9,23 +9,28 @@ "preview": "vite preview" }, "dependencies": { + "@livekit/components-react": "^2.7.4", "@opencord/shared": "workspace:*", + "livekit-client": "^2.9.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0", "react-markdown": "^9.0.1", - "zustand": "^5.0.2", - "livekit-client": "^2.9.0", - "@livekit/components-react": "^2.7.4" + "react-router-dom": "^6.28.0", + "zustand": "^5.0.2" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "jsdom": "^28.1.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.15", "typescript": "^5.7.2", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vitest": "^4.0.18" } } diff --git a/packages/web/src/App.js b/packages/web/src/App.js index 01153023..8b4785a8 100644 --- a/packages/web/src/App.js +++ b/packages/web/src/App.js @@ -17,5 +17,5 @@ function AuthRedirect({ children }) { return _jsx(_Fragment, { children: children }); } export function App() { - return (_jsxs(Routes, { children: [_jsx(Route, { path: "/login", element: _jsx(AuthRedirect, { children: _jsx(LoginPage, {}) }) }), _jsx(Route, { path: "/register", element: _jsx(AuthRedirect, { children: _jsx(RegisterPage, {}) }) }), _jsx(Route, { path: "/channels/:serverId/:channelId?", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) })] })); + return (_jsxs(Routes, { children: [_jsx(Route, { path: "/login", element: _jsx(AuthRedirect, { children: _jsx(LoginPage, {}) }) }), _jsx(Route, { path: "/register", element: _jsx(AuthRedirect, { children: _jsx(RegisterPage, {}) }) }), _jsx(Route, { path: "/channels/:serverId/:channelId?", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/channels/@me/:channelId?", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/join/:inviteCode", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) })] })); } diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 02bad588..90952185 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -44,6 +44,22 @@ export function App() { } /> + + + + } + /> + + + + } + /> } /> } /> diff --git a/packages/web/src/api/client.js b/packages/web/src/api/client.js index 46f03b4d..0dff53b7 100644 --- a/packages/web/src/api/client.js +++ b/packages/web/src/api/client.js @@ -99,6 +99,14 @@ export const api = { }, sendMessage: (id, data) => request('POST', `/dm/${id}/messages`, data), }, + social: { + friends: () => request('GET', '/social/friends'), + requests: () => request('GET', '/social/requests'), + sendRequest: (username) => request('POST', '/social/requests', { username }), + updateRequest: (id, status) => request('PATCH', `/social/requests/${id}`, { status }), + removeFriend: (id) => request('DELETE', `/social/friends/${id}`), + search: (q) => request('GET', `/social/search?q=${encodeURIComponent(q)}`), + }, livekit: { token: (channelId) => request('POST', '/livekit/token', { channelId }), }, diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 49f7d2e5..85ae1e9f 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -23,6 +23,8 @@ import type { LiveKitTokenResponse, CreateDmRequest, CreateDmMessageRequest, + Friend, + FriendRequest, } from '@opencord/shared'; const BASE_URL = '/api'; @@ -110,6 +112,7 @@ export const api = { delete: (id: string) => request<{ success: boolean }>('DELETE', `/servers/${id}`), invite: (id: string) => request<{ inviteCode: string }>('POST', `/servers/${id}/invite`), join: (id: string, data: JoinServerRequest) => request('POST', `/servers/${id}/join`, data), + joinByCode: (inviteCode: string) => request('POST', '/servers/join', { inviteCode }), members: (id: string) => request('GET', `/servers/${id}/members`), updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) => request('PATCH', `/servers/${serverId}/members/${userId}`, data), @@ -156,6 +159,17 @@ export const api = { request('POST', `/dm/${id}/messages`, data), }, + social: { + friends: () => request('GET', '/social/friends'), + requests: () => request('GET', '/social/requests'), + sendRequest: (username: string) => request<{ success: boolean }>('POST', '/social/requests', { username }), + updateRequest: (id: string, status: 'accepted' | 'declined') => + request<{ success: boolean }>('PATCH', `/social/requests/${id}`, { status }), + removeFriend: (id: string) => request<{ success: boolean }>('DELETE', `/social/friends/${id}`), + cancelRequest: (id: string) => request<{ success: boolean }>('DELETE', `/social/requests/${id}`), + search: (q: string) => request('GET', `/social/search?q=${encodeURIComponent(q)}`), + }, + livekit: { token: (channelId: string) => request('POST', '/livekit/token', { channelId }), diff --git a/packages/web/src/components/chat/Embed.js b/packages/web/src/components/chat/Embed.js new file mode 100644 index 00000000..506d9194 --- /dev/null +++ b/packages/web/src/components/chat/Embed.js @@ -0,0 +1,30 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useState, useEffect } from 'react'; +export function Embed({ url }) { + const [metadata, setMetadata] = useState(null); + const [isLoading, setIsLoading] = useState(true); + useEffect(() => { + let isMounted = true; + // Simple fetch from our new API + fetch(`/api/utils/metadata?url=${encodeURIComponent(url)}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('opencord_token')}` + } + }) + .then(res => res.json()) + .then(data => { + if (isMounted && data.title) { + setMetadata(data); + } + setIsLoading(false); + }) + .catch(() => { + if (isMounted) + setIsLoading(false); + }); + return () => { isMounted = false; }; + }, [url]); + if (isLoading || !metadata) + return null; + return (_jsxs("div", { className: "mt-2 max-w-[520px] bg-discord-bg-secondary rounded-[4px] border-l-4 border-discord-bg-tertiary flex overflow-hidden", children: [_jsxs("div", { className: "flex-1 p-3 min-w-0", children: [metadata.siteName && (_jsx("div", { className: "text-[12px] text-discord-text-normal font-medium mb-1 truncate", children: metadata.siteName })), metadata.title && (_jsx("a", { href: url, target: "_blank", rel: "noopener noreferrer", className: "text-[16px] text-discord-text-link font-semibold hover:underline block mb-2", children: metadata.title })), metadata.description && (_jsx("div", { className: "text-[14px] text-discord-text-normal leading-[1.125rem]", children: metadata.description }))] }), metadata.image && (_jsx("div", { className: "w-[80px] h-[80px] m-3 flex-shrink-0", children: _jsx("img", { src: metadata.image, alt: "", className: "w-full h-full object-cover rounded-[4px]" }) }))] })); +} diff --git a/packages/web/src/components/chat/Embed.tsx b/packages/web/src/components/chat/Embed.tsx new file mode 100644 index 00000000..350198ea --- /dev/null +++ b/packages/web/src/components/chat/Embed.tsx @@ -0,0 +1,79 @@ +import React, { useState, useEffect } from 'react'; +import { api } from '../../api/client'; + +interface EmbedProps { + url: string; +} + +interface Metadata { + title?: string; + description?: string; + image?: string; + siteName?: string; +} + +export function Embed({ url }: EmbedProps) { + const [metadata, setMetadata] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + + // Simple fetch from our new API + fetch(`/api/utils/metadata?url=${encodeURIComponent(url)}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('opencord_token')}` + } + }) + .then(res => res.json()) + .then(data => { + if (isMounted && data.title) { + setMetadata(data); + } + setIsLoading(false); + }) + .catch(() => { + if (isMounted) setIsLoading(false); + }); + + return () => { isMounted = false; }; + }, [url]); + + if (isLoading || !metadata) return null; + + return ( +
+
+ {metadata.siteName && ( +
+ {metadata.siteName} +
+ )} + {metadata.title && ( + + {metadata.title} + + )} + {metadata.description && ( +
+ {metadata.description} +
+ )} +
+ {metadata.image && ( +
+ +
+ )} +
+ ); +} diff --git a/packages/web/src/components/chat/FriendsPage.js b/packages/web/src/components/chat/FriendsPage.js new file mode 100644 index 00000000..3d8f7f7e --- /dev/null +++ b/packages/web/src/components/chat/FriendsPage.js @@ -0,0 +1,59 @@ +import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; +import { useEffect, useState } from 'react'; +import { useSocialStore } from '../../stores/socialStore'; +import { Avatar } from '../ui/Avatar'; +import { LoadingSpinner } from '../ui/LoadingSpinner'; +export function FriendsPage() { + const [activeTab, setActiveTab] = useState('online'); + const [addUsername, setAddUsername] = useState(''); + const [addStatus, setAddStatus] = useState(null); + const { friends, requests, isLoading, loadFriends, loadRequests, sendFriendRequest, updateFriendRequest, removeFriend } = useSocialStore(); + useEffect(() => { + loadFriends(); + loadRequests(); + }, [loadFriends, loadRequests]); + const onlineFriends = friends.filter(f => f.status !== 'offline'); + const pendingIncoming = requests.filter(r => r.status === 'pending' && r.toId !== r.fromId && r.user?.id === r.fromId); + const pendingOutgoing = requests.filter(r => r.status === 'pending' && r.fromId !== r.toId && r.user?.id === r.toId); + const handleAddFriend = async (e) => { + e.preventDefault(); + if (!addUsername.trim()) + return; + try { + await sendFriendRequest(addUsername.trim()); + setAddStatus({ type: 'success', message: `Success! Your friend request to ${addUsername} has been sent.` }); + setAddUsername(''); + } + catch (err) { + setAddStatus({ type: 'error', message: err.message }); + } + }; + const renderTabContent = () => { + if (isLoading && friends.length === 0 && requests.length === 0) { + return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsx(LoadingSpinner, {}) })); + } + switch (activeTab) { + case 'online': + return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["Online \u2014 ", onlineFriends.length] }), onlineFriends.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: [_jsx("img", { src: "/friends-empty.svg", alt: "", className: "w-64 h-64 mb-4", onError: (e) => e.target.style.display = 'none' }), _jsx("p", { className: "text-discord-text-muted", children: "No one's around to play with Wumpus." })] })) : (onlineFriends.map(friend => (_jsx(FriendItem, { friend: friend, onRemove: () => removeFriend(friend.id) }, friend.id))))] })); + case 'all': + return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["All Friends \u2014 ", friends.length] }), friends.length === 0 ? (_jsx("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: _jsx("p", { className: "text-discord-text-muted", children: "Wumpus is waiting on friends. You can add them!" }) })) : (friends.map(friend => (_jsx(FriendItem, { friend: friend, onRemove: () => removeFriend(friend.id) }, friend.id))))] })); + case 'pending': + return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["Pending \u2014 ", pendingIncoming.length + pendingOutgoing.length] }), [...pendingIncoming, ...pendingOutgoing].length === 0 ? (_jsx("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: _jsx("p", { className: "text-discord-text-muted", children: "There are no pending friend requests. Here's Wumpus for now!" }) })) : (_jsxs(_Fragment, { children: [pendingIncoming.map(req => (_jsx(RequestItem, { request: req, type: "incoming", onAction: (status) => updateFriendRequest(req.id, status) }, req.id))), pendingOutgoing.map(req => (_jsx(RequestItem, { request: req, type: "outgoing", onAction: () => { } }, req.id)))] }))] })); + case 'add': + return (_jsxs("div", { className: "flex-1 p-8", children: [_jsx("h2", { className: "text-base font-bold text-discord-text-primary uppercase mb-2", children: "Add Friend" }), _jsx("p", { className: "text-sm text-discord-text-muted mb-4", children: "You can add friends with their Opencord username." }), _jsxs("form", { onSubmit: handleAddFriend, className: "relative mb-8", children: [_jsx("input", { type: "text", placeholder: "You can add a friend with their username", value: addUsername, onChange: (e) => setAddUsername(e.target.value), className: "w-full bg-discord-bg-tertiary text-discord-text-primary px-4 py-3 rounded-lg border border-transparent focus:border-discord-text-link outline-none transition-all placeholder:text-discord-text-muted/50" }), _jsx("button", { type: "submit", disabled: !addUsername.trim() || isLoading, className: "absolute right-2 top-1.5 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple-hover disabled:opacity-50 disabled:bg-discord-blurple text-white text-sm font-medium rounded transition-colors", children: "Send Friend Request" })] }), addStatus && (_jsx("div", { className: `text-sm p-3 rounded-lg border ${addStatus.type === 'success' ? 'text-discord-green border-discord-green/20 bg-discord-green/5' : 'text-discord-red border-discord-red/20 bg-discord-red/5'}`, children: addStatus.message }))] })); + } + }; + return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary h-full", children: [_jsxs("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary/50 shadow-sm flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 mr-4", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: "Friends" })] }), _jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsxs("div", { className: "flex items-center gap-4 ml-2", children: [_jsx(TabButton, { active: activeTab === 'online', onClick: () => setActiveTab('online'), children: "Online" }), _jsx(TabButton, { active: activeTab === 'all', onClick: () => setActiveTab('all'), children: "All" }), _jsxs(TabButton, { active: activeTab === 'pending', onClick: () => setActiveTab('pending'), children: ["Pending", (pendingIncoming.length > 0) && (_jsx("span", { className: "ml-2 px-1.5 py-0.5 bg-discord-red text-white text-[10px] rounded-full leading-none", children: pendingIncoming.length }))] }), _jsx("button", { onClick: () => setActiveTab('add'), className: `px-2 py-0.5 rounded text-[14px] font-medium transition-all ${activeTab === 'add' ? 'text-discord-green bg-transparent' : 'bg-discord-green text-white hover:bg-discord-green/90'}`, children: "Add Friend" })] })] }), renderTabContent()] })); +} +function TabButton({ children, active, onClick }) { + return (_jsx("button", { onClick: onClick, className: `px-2 py-0.5 rounded-[4px] text-[16px] font-medium transition-colors ${active ? 'bg-discord-modifier-selected text-white' : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: children })); +} +function FriendItem({ friend, onRemove }) { + return (_jsxs("div", { className: "flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-discord-modifier-hover group transition-colors border-t border-discord-bg-modifier-accent mx-2", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Avatar, { src: friend.avatar, name: friend.displayName ?? friend.username, size: 32, status: friend.status }), _jsxs("div", { className: "flex flex-col leading-tight", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-discord-text-header font-semibold text-[15px]", children: friend.displayName ?? friend.username }), _jsxs("span", { className: "text-discord-text-muted text-[13px] opacity-0 group-hover:opacity-100 transition-opacity font-medium", children: ["@", friend.username] })] }), _jsx("span", { className: "text-[12px] text-discord-text-muted font-medium uppercase", children: friend.status })] })] }), _jsxs("div", { className: "flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity pr-2", children: [_jsx("button", { className: "w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-text-primary transition-colors", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-5H6V7h12v2z" }) }) }), _jsx("button", { onClick: (e) => { e.stopPropagation(); onRemove(); }, className: "w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-colors", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" }) }) })] })] })); +} +function RequestItem({ request, type, onAction }) { + const user = request.user; + if (!user) + return null; + return (_jsxs("div", { className: "flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-discord-bg-hover/50 group transition-colors border-t border-transparent hover:border-discord-bg-tertiary/30", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status }), _jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-discord-text-primary font-bold text-sm", children: user.displayName ?? user.username }), _jsxs("span", { className: "text-discord-text-muted text-xs", children: ["@", user.username] })] }), _jsx("span", { className: "text-xs text-discord-text-muted", children: type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request' })] })] }), _jsx("div", { className: "flex items-center gap-2", children: type === 'incoming' ? (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => onAction('accepted'), className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-green hover:bg-discord-green hover:text-white transition-all shadow-sm", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) }) }), _jsx("button", { onClick: () => onAction('declined'), className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-red hover:bg-discord-red hover:text-white transition-all shadow-sm", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" }) }) })] })) : (_jsx("button", { className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-all shadow-sm", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" }) }) })) })] })); +} diff --git a/packages/web/src/components/chat/FriendsPage.test.tsx b/packages/web/src/components/chat/FriendsPage.test.tsx new file mode 100644 index 00000000..57495ff7 --- /dev/null +++ b/packages/web/src/components/chat/FriendsPage.test.tsx @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { FriendsPage } from './FriendsPage'; +import { useSocialStore } from '../../stores/socialStore'; +import { useServerStore } from '../../stores/serverStore'; +import type { Friend, FriendRequest } from '@opencord/shared'; + +// Mock the api module +vi.mock('../../api/client', () => ({ + api: { + dm: { + create: vi.fn(), + }, + social: { + friends: vi.fn().mockResolvedValue([]), + requests: vi.fn().mockResolvedValue([]), + sendRequest: vi.fn().mockResolvedValue({ success: true }), + updateRequest: vi.fn().mockResolvedValue({ success: true }), + cancelRequest: vi.fn().mockResolvedValue({ success: true }), + removeFriend: vi.fn().mockResolvedValue({ success: true }), + search: vi.fn().mockResolvedValue([]), + }, + }, +})); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +const makeFriend = (overrides: Partial = {}): Friend => ({ + id: 'friend-1', + username: 'testfriend', + displayName: 'Test Friend', + avatar: null, + status: 'online', + customStatus: null, + createdAt: Date.now(), + addedAt: Date.now(), + ...overrides, +}); + +const makeRequest = (overrides: Partial = {}): FriendRequest => ({ + id: 'req-1', + fromId: 'other-user', + toId: 'current-user', + status: 'pending', + createdAt: Date.now(), + user: { + id: 'other-user', + username: 'otheruser', + displayName: 'Other User', + avatar: null, + status: 'online', + customStatus: null, + createdAt: Date.now(), + }, + ...overrides, +}); + +function renderFriendsPage() { + return render( + + + + ); +} + +beforeEach(() => { + mockNavigate.mockClear(); + // Reset the social store with no-op loaders (we set state directly) + useSocialStore.setState({ + friends: [], + requests: [], + isLoading: false, + error: null, + loadFriends: vi.fn(), + loadRequests: vi.fn(), + }); + useServerStore.setState({ + dmChannels: [], + }); +}); + +describe('FriendsPage', () => { + describe('Add Friend tab', () => { + it('renders the Add Friend form when tab is clicked', async () => { + const user = userEvent.setup(); + renderFriendsPage(); + + const addFriendTab = screen.getByText('Add Friend'); + await user.click(addFriendTab); + + expect(screen.getByPlaceholderText('You can add a friend with their username')).toBeInTheDocument(); + expect(screen.getByText('Send Friend Request')).toBeInTheDocument(); + }); + + it('calls sendFriendRequest with the username when form is submitted', async () => { + const user = userEvent.setup(); + const mockSendFriendRequest = vi.fn().mockResolvedValue(undefined); + useSocialStore.setState({ + sendFriendRequest: mockSendFriendRequest, + }); + + renderFriendsPage(); + + // Switch to Add Friend tab + await user.click(screen.getByText('Add Friend')); + + // Type username + const input = screen.getByPlaceholderText('You can add a friend with their username'); + await user.type(input, 'newbuddy'); + + // Click send + await user.click(screen.getByText('Send Friend Request')); + + await waitFor(() => { + expect(mockSendFriendRequest).toHaveBeenCalledWith('newbuddy'); + }); + + // Should show success message + await waitFor(() => { + expect(screen.getByText(/Success! Your friend request to newbuddy has been sent/)).toBeInTheDocument(); + }); + }); + + it('shows error when sendFriendRequest fails', async () => { + const user = userEvent.setup(); + const mockSendFriendRequest = vi.fn().mockRejectedValue(new Error('User not found')); + useSocialStore.setState({ + sendFriendRequest: mockSendFriendRequest, + }); + + renderFriendsPage(); + await user.click(screen.getByText('Add Friend')); + + const input = screen.getByPlaceholderText('You can add a friend with their username'); + await user.type(input, 'ghost'); + await user.click(screen.getByText('Send Friend Request')); + + await waitFor(() => { + expect(screen.getByText('User not found')).toBeInTheDocument(); + }); + }); + }); + + describe('DM button on friend item', () => { + it('calls api.dm.create and navigates when clicking the Message button', async () => { + const user = userEvent.setup(); + const friend = makeFriend({ id: 'friend-42', username: 'dmpal', displayName: 'DM Pal' }); + const mockAddDmChannel = vi.fn(); + + useSocialStore.setState({ + friends: [friend], + requests: [], + }); + useServerStore.setState({ + addDmChannel: mockAddDmChannel, + }); + + // Mock the dm.create API + const { api } = await import('../../api/client'); + (api.dm.create as ReturnType).mockResolvedValue({ + id: 'dm-channel-99', + createdAt: Date.now(), + members: [], + }); + + renderFriendsPage(); + + // Switch to "All" tab to see the friend + await user.click(screen.getByText('All')); + + // Find the Message button by title + const dmButton = screen.getByTitle('Message'); + await user.click(dmButton); + + await waitFor(() => { + expect(api.dm.create).toHaveBeenCalledWith({ userId: 'friend-42' }); + }); + + await waitFor(() => { + expect(mockAddDmChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'dm-channel-99' })); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/channels/@me/dm-channel-99'); + }); + }); + }); + + describe('Cancel outgoing friend request', () => { + it('calls cancelFriendRequest when clicking cancel on an outgoing request', async () => { + const user = userEvent.setup(); + const mockCancel = vi.fn().mockResolvedValue(undefined); + + // Outgoing request: user.id === toId means current user sent it (fromId is current user, user is the recipient) + const outgoingRequest = makeRequest({ + id: 'req-out-1', + fromId: 'current-user', + toId: 'other-user', + user: { + id: 'other-user', + username: 'recipient', + displayName: 'Recipient', + avatar: null, + status: 'online', + customStatus: null, + createdAt: Date.now(), + }, + }); + + useSocialStore.setState({ + friends: [], + requests: [outgoingRequest], + cancelFriendRequest: mockCancel, + }); + + renderFriendsPage(); + + // Switch to Pending tab + await user.click(screen.getByText('Pending')); + + // Should see the outgoing request + expect(screen.getByText('Outgoing Friend Request')).toBeInTheDocument(); + + // Click the cancel button (the X icon button with title "Cancel Request") + const cancelButton = screen.getByTitle('Cancel Request'); + await user.click(cancelButton); + + await waitFor(() => { + expect(mockCancel).toHaveBeenCalledWith('req-out-1'); + }); + }); + }); + + describe('Accept/Decline incoming friend request', () => { + it('calls updateFriendRequest with "accepted" when clicking accept', async () => { + const user = userEvent.setup(); + const mockUpdate = vi.fn().mockResolvedValue(undefined); + + const incomingRequest = makeRequest({ + id: 'req-in-1', + fromId: 'sender-id', + toId: 'current-user', + user: { + id: 'sender-id', + username: 'sender', + displayName: 'Sender', + avatar: null, + status: 'online', + customStatus: null, + createdAt: Date.now(), + }, + }); + + useSocialStore.setState({ + friends: [], + requests: [incomingRequest], + updateFriendRequest: mockUpdate, + }); + + renderFriendsPage(); + await user.click(screen.getByText('Pending')); + + expect(screen.getByText('Incoming Friend Request')).toBeInTheDocument(); + + // Click accept button (title "Accept") + const acceptButton = screen.getByTitle('Accept'); + await user.click(acceptButton); + + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith('req-in-1', 'accepted'); + }); + }); + + it('calls updateFriendRequest with "declined" when clicking decline', async () => { + const user = userEvent.setup(); + const mockUpdate = vi.fn().mockResolvedValue(undefined); + + const incomingRequest = makeRequest({ + id: 'req-in-2', + fromId: 'sender-id', + toId: 'current-user', + user: { + id: 'sender-id', + username: 'sender2', + displayName: 'Sender 2', + avatar: null, + status: 'online', + customStatus: null, + createdAt: Date.now(), + }, + }); + + useSocialStore.setState({ + friends: [], + requests: [incomingRequest], + updateFriendRequest: mockUpdate, + }); + + renderFriendsPage(); + await user.click(screen.getByText('Pending')); + + const declineButton = screen.getByTitle('Decline'); + await user.click(declineButton); + + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith('req-in-2', 'declined'); + }); + }); + }); +}); diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx new file mode 100644 index 00000000..4477cf44 --- /dev/null +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -0,0 +1,320 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useSocialStore } from '../../stores/socialStore'; +import { useServerStore } from '../../stores/serverStore'; +import { Avatar } from '../ui/Avatar'; +import { LoadingSpinner } from '../ui/LoadingSpinner'; +import { api } from '../../api/client'; +import type { Friend, FriendRequest } from '@opencord/shared'; + +type Tab = 'online' | 'all' | 'pending' | 'add'; + +export function FriendsPage() { + const [activeTab, setActiveTab] = useState('online'); + const [addUsername, setAddUsername] = useState(''); + const [addStatus, setAddStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null); + const navigate = useNavigate(); + const addDmChannel = useServerStore((s) => s.addDmChannel); + + const { + friends, + requests, + isLoading, + loadFriends, + loadRequests, + sendFriendRequest, + updateFriendRequest, + cancelFriendRequest, + removeFriend + } = useSocialStore(); + + useEffect(() => { + loadFriends(); + loadRequests(); + }, [loadFriends, loadRequests]); + + const onlineFriends = friends.filter(f => f.status !== 'offline'); + const pendingIncoming = requests.filter(r => r.status === 'pending' && r.user?.id === r.fromId); + const pendingOutgoing = requests.filter(r => r.status === 'pending' && r.user?.id === r.toId); + + const handleAddFriend = async (e: React.FormEvent) => { + e.preventDefault(); + if (!addUsername.trim()) return; + + try { + await sendFriendRequest(addUsername.trim()); + setAddStatus({ type: 'success', message: `Success! Your friend request to ${addUsername} has been sent.` }); + setAddUsername(''); + } catch (err) { + setAddStatus({ type: 'error', message: (err as Error).message }); + } + }; + + const handleOpenDm = async (friendId: string) => { + try { + const dmChannel = await api.dm.create({ userId: friendId }); + addDmChannel(dmChannel); + navigate(`/channels/@me/${dmChannel.id}`); + } catch (err) { + console.error('Failed to open DM:', err); + } + }; + + const renderTabContent = () => { + if (isLoading && friends.length === 0 && requests.length === 0) { + return ( +
+ +
+ ); + } + + switch (activeTab) { + case 'online': + return ( +
+

+ Online — {onlineFriends.length} +

+ {onlineFriends.length === 0 ? ( +
+ (e.target as any).style.display='none'} /> +

No one's around to play with Wumpus.

+
+ ) : ( + onlineFriends.map(friend => ( + removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id)} /> + )) + )} +
+ ); + case 'all': + return ( +
+

+ All Friends — {friends.length} +

+ {friends.length === 0 ? ( +
+

Wumpus is waiting on friends. You can add them!

+
+ ) : ( + friends.map(friend => ( + removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id)} /> + )) + )} +
+ ); + case 'pending': + return ( +
+

+ Pending — {pendingIncoming.length + pendingOutgoing.length} +

+ {[...pendingIncoming, ...pendingOutgoing].length === 0 ? ( +
+

There are no pending friend requests. Here's Wumpus for now!

+
+ ) : ( + <> + {pendingIncoming.map(req => ( + updateFriendRequest(req.id, 'accepted')} + onDecline={() => updateFriendRequest(req.id, 'declined')} + /> + ))} + {pendingOutgoing.map(req => ( + cancelFriendRequest(req.id)} + /> + ))} + + )} +
+ ); + case 'add': + return ( +
+

Add Friend

+

You can add friends with their Opencord username.

+
+ setAddUsername(e.target.value)} + className="w-full bg-discord-bg-tertiary text-discord-text-primary px-4 py-3 rounded-lg border border-transparent focus:border-discord-text-link outline-none transition-all placeholder:text-discord-text-muted/50" + /> + +
+ {addStatus && ( +
+ {addStatus.message} +
+ )} +
+ ); + } + }; + + return ( +
+ {/* Header */} +
+
+ + + + Friends +
+ +
+ +
+ setActiveTab('online')}>Online + setActiveTab('all')}>All + setActiveTab('pending')}> + Pending + {(pendingIncoming.length > 0) && ( + + {pendingIncoming.length} + + )} + + +
+
+ + {renderTabContent()} +
+ ); +} + +function TabButton({ children, active, onClick }: { children: React.ReactNode, active: boolean, onClick: () => void }) { + return ( + + ); +} + +function FriendItem({ friend, onRemove, onDm }: { friend: Friend, onRemove: () => void, onDm: () => void }) { + return ( +
+
+ +
+
+ {friend.displayName ?? friend.username} + @{friend.username} +
+ {friend.status} +
+
+
+ + +
+
+ ); +} + +function RequestItem({ request, type, onAccept, onDecline, onCancel }: { + request: FriendRequest; + type: 'incoming' | 'outgoing'; + onAccept?: () => void; + onDecline?: () => void; + onCancel?: () => void; +}) { + const user = request.user; + if (!user) return null; + + return ( +
+
+ +
+
+ {user.displayName ?? user.username} + @{user.username} +
+ {type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'} +
+
+
+ {type === 'incoming' ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} diff --git a/packages/web/src/components/chat/Message.js b/packages/web/src/components/chat/Message.js index deb1dadc..7a9b0da8 100644 --- a/packages/web/src/components/chat/Message.js +++ b/packages/web/src/components/chat/Message.js @@ -1,4 +1,4 @@ -import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { useState } from 'react'; import ReactMarkdown from 'react-markdown'; import { Avatar } from '../ui/Avatar'; @@ -7,6 +7,7 @@ import { useAuthStore } from '../../stores/authStore'; import { useChatStore } from '../../stores/chatStore'; import { useServerStore } from '../../stores/serverStore'; import { useUIStore } from '../../stores/uiStore'; +import { Embed } from './Embed'; function formatTime(timestamp) { const date = new Date(timestamp); const now = new Date(); @@ -33,10 +34,44 @@ export function Message({ message, isCompact, isFirstInGroup }) { const deleteMessage = useChatStore((s) => s.deleteMessage); const members = useServerStore((s) => s.members); const openImagePreview = useUIStore((s) => s.openImagePreview); + const openUserProfile = useUIStore((s) => s.openUserProfile); const isAuthor = currentUser?.id === message.userId; const memberRole = members.find(m => m.userId === currentUser?.id)?.role; const isAdminUser = memberRole === 'admin' || memberRole === 'owner'; const canDelete = isAuthor || isAdminUser; + const addReaction = useChatStore((s) => s.addReaction); + const removeReaction = useChatStore((s) => s.removeReaction); + const setReplyTo = useChatStore((s) => s.setReplyTo); + const toggleReaction = (emoji) => { + const hasReacted = message.reactions?.some(r => r.userId === currentUser?.id && r.emoji === emoji); + if (hasReacted) { + removeReaction(message.id, emoji); + } + else { + addReaction(message.id, emoji); + } + }; + const reactionGroups = (message.reactions || []).reduce((acc, r) => { + const group = acc[r.emoji] || { count: 0, me: false }; + group.count++; + if (r.userId === currentUser?.id) { + group.me = true; + } + acc[r.emoji] = group; + return acc; + }, {}); + const urlRegex = /(https?:\/\/[^\s]+)/g; + const firstUrl = message.content?.match(urlRegex)?.[0]; + const handleUsernameClick = (e) => { + if (!message.user) + return; + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + openUserProfile(message.user, { + top: Math.min(rect.top, window.innerHeight - 450), + left: rect.right + 16, + }); + }; const contextMenuItems = []; if (isAuthor) { contextMenuItems.push({ @@ -70,36 +105,52 @@ export function Message({ message, isCompact, isFirstInGroup }) { const displayName = message.user.displayName ?? message.user.username; const roleColor = (() => { const member = members.find(m => m.userId === message.userId); + if (member?.roles && member.roles.length > 0) { + return { color: member.roles[0].color }; + } if (member?.role === 'owner') - return 'text-discord-red'; + return { color: '#da373c' }; if (member?.role === 'admin') - return 'text-discord-blurple'; - return 'text-white'; + return { color: '#5865f2' }; + return { color: '#dbdee1' }; })(); - const content = (_jsxs("div", { className: `group relative flex px-4 py-0.5 hover:bg-discord-bg-hover/30 ${isFirstInGroup ? 'mt-4' : ''}`, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [_jsx("div", { className: "w-[72px] flex-shrink-0 flex items-start justify-center", children: isFirstInGroup ? (_jsx(Avatar, { src: message.user.avatar, name: displayName, size: 40, className: "mt-0.5 cursor-pointer" })) : (_jsx("span", { className: `text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-1 select-none`, children: formatHoverTime(message.createdAt) })) }), _jsxs("div", { className: "flex-1 min-w-0", children: [isFirstInGroup && (_jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: `font-medium cursor-pointer hover:underline ${roleColor}`, children: displayName }), _jsx("span", { className: "text-xs text-discord-text-muted", children: formatTime(message.createdAt) })] })), isEditing ? (_jsxs("div", { className: "mt-1", children: [_jsx("textarea", { value: editContent, onChange: (e) => setEditContent(e.target.value), onKeyDown: handleEditSubmit, className: "w-full p-2 bg-discord-bg-input rounded text-discord-text-primary outline-none resize-none text-sm", rows: 2, autoFocus: true }), _jsxs("p", { className: "text-xs text-discord-text-muted mt-1", children: ["escape to ", _jsx("button", { onClick: () => setIsEditing(false), className: "text-[#00aff4] hover:underline", children: "cancel" }), ' ', "\u2022 enter to ", _jsx("button", { onClick: () => { + const replyRoleColor = (msg) => { + const member = members.find(m => m.userId === msg.userId); + if (member?.roles && member.roles.length > 0) { + return { color: member.roles[0].color }; + } + if (member?.role === 'owner') + return { color: '#da373c' }; + if (member?.role === 'admin') + return { color: '#5865f2' }; + return { color: '#dbdee1' }; + }; + const content = (_jsxs("div", { className: `group relative flex px-4 py-0.5 hover:bg-[#2e3035]/30 transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''}`, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [message.replyTo && (_jsx("div", { className: "absolute left-[36px] top-[-14px] w-[33px] h-[22px] border-l-2 border-t-2 border-[#4e5058] rounded-tl-[6px] opacity-60" })), _jsx("div", { className: "w-[72px] flex-shrink-0 flex items-start justify-start pl-0.5", children: isFirstInGroup || message.replyTo ? (_jsx("div", { className: "mt-1", children: _jsx(Avatar, { src: message.user.avatar, name: displayName, size: 40, user: message.user, className: "hover:drop-shadow-md transition-all active:translate-y-[1px]" }) })) : (_jsx("span", { className: `text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-2 select-none w-full text-center leading-[1.375rem] font-medium`, children: formatHoverTime(message.createdAt) })) }), _jsxs("div", { className: "flex-1 min-w-0 pr-4", children: [message.replyTo && (_jsxs("div", { className: "flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply", children: [_jsx(Avatar, { src: message.replyTo.user.avatar, name: message.replyTo.user.username, size: 16 }), _jsx("span", { className: "text-[14px] font-bold text-discord-text-header hover:underline", style: message.replyTo ? replyRoleColor(message.replyTo) : undefined, children: message.replyTo.user.displayName ?? message.replyTo.user.username }), _jsx("span", { className: "text-[14px] text-discord-text-normal truncate max-w-[400px] hover:text-white", children: message.replyTo.content })] })), (isFirstInGroup || message.replyTo) && (_jsxs("div", { className: "flex items-baseline gap-2 mb-0.5", children: [_jsx("span", { onClick: handleUsernameClick, className: "font-bold cursor-pointer hover:underline text-[16px] leading-tight", style: roleColor, children: displayName }), _jsx("span", { className: "text-[12px] text-discord-text-muted leading-tight font-medium hover:cursor-default", children: formatTime(message.createdAt) })] })), isEditing ? (_jsxs("div", { className: "mt-1 w-full", children: [_jsx("textarea", { value: editContent, onChange: (e) => setEditContent(e.target.value), onKeyDown: handleEditSubmit, className: "w-full p-3 bg-discord-bg-input rounded-lg text-discord-text-primary outline-none resize-none text-[16px] leading-[1.375rem] shadow-inner", rows: 2, autoFocus: true }), _jsxs("p", { className: "text-[12px] text-discord-text-muted mt-1.5 ml-1", children: ["escape to ", _jsx("button", { onClick: () => setIsEditing(false), className: "text-discord-text-link hover:underline", children: "cancel" }), ' ', "\u2022 enter to ", _jsx("button", { onClick: () => { if (editContent.trim()) { editMessage(message.id, editContent.trim()); setIsEditing(false); } - }, className: "text-[#00aff4] hover:underline", children: "save" })] })] })) : (_jsxs(_Fragment, { children: [message.content && (_jsxs("div", { className: "text-discord-text-primary text-sm leading-[1.375rem] break-words", children: [_jsx(ReactMarkdown, { components: { + }, className: "text-discord-text-link hover:underline", children: "save" })] })] })) : (_jsxs("div", { className: "flex flex-col gap-1", children: [message.content && (_jsxs("div", { className: "text-discord-text-normal text-[16px] leading-[1.375rem] break-words whitespace-pre-wrap selection:bg-discord-blurple/30", children: [_jsx(ReactMarkdown, { components: { p: ({ children }) => _jsx("span", { children: children }), - a: ({ href, children }) => (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-[#00aff4] hover:underline", children: children })), - code: ({ children }) => (_jsx("code", { className: "px-1 py-0.5 bg-discord-bg-tertiary rounded text-sm font-mono", children: children })), - pre: ({ children }) => (_jsx("pre", { className: "mt-1 p-3 bg-discord-bg-tertiary rounded text-sm font-mono overflow-x-auto", children: children })), - strong: ({ children }) => _jsx("strong", { className: "font-bold", children: children }), + a: ({ href, children }) => (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-discord-text-link hover:underline", children: children })), + code: ({ children }) => (_jsx("code", { className: "px-1 py-0.5 bg-discord-bg-tertiary rounded text-[14px] font-mono", children: children })), + pre: ({ children }) => (_jsx("pre", { className: "mt-1 p-3 bg-discord-bg-tertiary border border-discord-bg-tertiary/50 rounded-md text-[14px] font-mono overflow-x-auto", children: children })), + strong: ({ children }) => _jsx("strong", { className: "font-bold text-discord-text-primary", children: children }), em: ({ children }) => _jsx("em", { className: "italic", children: children }), - }, children: message.content }), message.editedAt && (_jsx("span", { className: "text-[10px] text-discord-text-muted ml-1", children: "(edited)" }))] })), message.attachments.length > 0 && (_jsx("div", { className: "mt-1 space-y-1", children: message.attachments.map((att) => { + }, children: message.content }), message.editedAt && (_jsx("span", { className: "text-[10px] text-discord-text-muted ml-1 select-none font-medium", children: "(edited)" }))] })), Object.keys(reactionGroups).length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1 mt-1", children: Object.entries(reactionGroups).map(([emoji, { count, me }]) => (_jsxs("button", { onClick: () => toggleReaction(emoji), className: `flex items-center gap-1.5 px-1.5 py-0.5 rounded-[8px] text-[14px] font-medium border transition-colors ${me + ? 'bg-discord-blurple/15 border-discord-blurple text-discord-blurple' + : 'bg-discord-bg-secondary border-transparent text-discord-text-muted hover:border-discord-text-muted/30'}`, children: [_jsx("span", { children: emoji }), _jsx("span", { className: me ? 'text-discord-blurple' : 'text-discord-text-normal', children: count })] }, emoji))) })), !isEditing && firstUrl && _jsx(Embed, { url: firstUrl }), message.attachments.length > 0 && (_jsx("div", { className: "mt-1 grid gap-2", children: message.attachments.map((att) => { const isImage = att.mimetype.startsWith('image/'); if (isImage) { - return (_jsx("div", { className: "max-w-[400px]", children: _jsx("img", { src: `/api/uploads/${att.filename}`, alt: att.originalName, className: "max-w-full max-h-[300px] rounded cursor-pointer hover:shadow-lg transition-shadow", onClick: () => openImagePreview(`/api/uploads/${att.filename}`), loading: "lazy" }) }, att.id)); + return (_jsx("div", { className: "max-w-fit mt-1 rounded-lg overflow-hidden border border-discord-bg-tertiary/50 bg-discord-bg-tertiary/20", children: _jsx("img", { src: `/api/uploads/${att.filename}`, alt: att.originalName, className: "max-w-full max-h-[350px] object-contain cursor-pointer hover:brightness-95 transition-all", onClick: () => openImagePreview(`/api/uploads/${att.filename}`), loading: "lazy" }) }, att.id)); } - return (_jsxs("a", { href: `/api/uploads/${att.filename}`, download: att.originalName, className: "flex items-center gap-2 p-3 bg-discord-bg-secondary rounded border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors max-w-[400px]", children: [_jsx("svg", { className: "w-6 h-6 text-discord-text-muted flex-shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-[#00aff4] text-sm truncate hover:underline", children: att.originalName }), _jsx("p", { className: "text-xs text-discord-text-muted", children: att.size < 1024 ? `${att.size} B` : + return (_jsxs("a", { href: `/api/uploads/${att.filename}`, download: att.originalName, className: "flex items-center gap-3 p-4 bg-discord-bg-secondary/50 rounded-lg border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-all max-w-[400px] mt-1 group/att", children: [_jsx("div", { className: "p-2 bg-discord-bg-tertiary rounded text-discord-text-muted group-hover/att:text-discord-text-primary transition-colors", children: _jsx("svg", { className: "w-8 h-8", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 1.5, d: "M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" }) }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-discord-text-link text-[15px] font-medium truncate hover:underline", children: att.originalName }), _jsx("p", { className: "text-[12px] text-discord-text-muted font-medium", children: att.size < 1024 ? `${att.size} B` : att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` : `${(att.size / 1048576).toFixed(1)} MB` })] })] }, att.id)); - }) }))] }))] }), isHovered && !isEditing && contextMenuItems.length > 0 && (_jsxs("div", { className: "absolute -top-3 right-4 flex items-center bg-discord-bg-secondary border border-discord-bg-tertiary rounded shadow-md", children: [isAuthor && (_jsx("button", { onClick: () => { + }) }))] }))] }), isHovered && !isEditing && (_jsxs("div", { className: "absolute -top-[18px] right-4 flex items-center bg-discord-bg-primary border border-discord-bg-tertiary/50 rounded-[4px] shadow-elevation-low overflow-hidden z-10 h-8", children: [_jsx("div", { className: "flex items-center px-1 border-r border-discord-bg-tertiary/50 h-full", children: ['👍', '❤️', '😂', '😮'].map(emoji => (_jsx("button", { onClick: () => toggleReaction(emoji), className: "p-1 hover:bg-discord-modifier-hover rounded transition-colors text-[16px] leading-none", children: emoji }, emoji))) }), _jsx("button", { onClick: () => setReplyTo(message), className: "px-2 h-full text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover transition-all flex items-center justify-center", title: "Reply", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" }) }) }), isAuthor && (_jsx("button", { onClick: () => { setEditContent(message.content ?? ''); setIsEditing(true); - }, className: "p-1.5 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Edit", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M13.293 1.293a1 1 0 011.414 1.414l-9 9a1 1 0 01-.39.242l-3 1a1 1 0 01-1.266-1.265l1-3a1 1 0 01.242-.391l9-9zM12 3l1 1-8 8-1.5.5.5-1.5L12 3z" }) }) })), canDelete && (_jsx("button", { onClick: () => deleteMessage(message.id), className: "p-1.5 text-discord-text-muted hover:text-discord-red transition-colors", title: "Delete", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" }) }) }))] }))] })); + }, className: "px-2 h-full text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover transition-all flex items-center justify-center", title: "Edit", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })), canDelete && (_jsx("button", { onClick: () => deleteMessage(message.id), className: "px-2 h-full text-discord-text-muted hover:text-discord-red hover:bg-discord-modifier-hover transition-all flex items-center justify-center", title: "Delete", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" }) }) }))] }))] })); if (contextMenuItems.length > 0) { return _jsx(ContextMenu, { items: contextMenuItems, children: content }); } diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index c798ff13..23a19485 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -7,6 +7,7 @@ import { useAuthStore } from '../../stores/authStore'; import { useChatStore } from '../../stores/chatStore'; import { useServerStore } from '../../stores/serverStore'; import { useUIStore } from '../../stores/uiStore'; +import { Embed } from './Embed'; interface MessageProps { message: MessageWithUser; @@ -42,12 +43,49 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { const deleteMessage = useChatStore((s) => s.deleteMessage); const members = useServerStore((s) => s.members); const openImagePreview = useUIStore((s) => s.openImagePreview); + const openUserProfile = useUIStore((s) => s.openUserProfile); const isAuthor = currentUser?.id === message.userId; const memberRole = members.find(m => m.userId === currentUser?.id)?.role; const isAdminUser = memberRole === 'admin' || memberRole === 'owner'; const canDelete = isAuthor || isAdminUser; + const addReaction = useChatStore((s) => s.addReaction); + const removeReaction = useChatStore((s) => s.removeReaction); + const setReplyTo = useChatStore((s) => s.setReplyTo); + + const toggleReaction = (emoji: string) => { + const hasReacted = message.reactions?.some(r => r.userId === currentUser?.id && r.emoji === emoji); + if (hasReacted) { + removeReaction(message.id, emoji); + } else { + addReaction(message.id, emoji); + } + }; + + const reactionGroups = (message.reactions || []).reduce((acc, r) => { + const group = acc[r.emoji] || { count: 0, me: false }; + group.count++; + if (r.userId === currentUser?.id) { + group.me = true; + } + acc[r.emoji] = group; + return acc; + }, {} as Record); + + const urlRegex = /(https?:\/\/[^\s]+)/g; + const firstUrl = message.content?.match(urlRegex)?.[0]; + + const handleUsernameClick = (e: React.MouseEvent) => { + if (!message.user) return; + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + openUserProfile(message.user, { + top: Math.min(rect.top, window.innerHeight - 450), + left: rect.right + 16, + }); + }; + const contextMenuItems = []; if (isAuthor) { contextMenuItems.push({ @@ -84,112 +122,175 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { const roleColor = (() => { const member = members.find(m => m.userId === message.userId); - if (member?.role === 'owner') return 'text-discord-red'; - if (member?.role === 'admin') return 'text-discord-blurple'; - return 'text-white'; + if (member?.roles && member.roles.length > 0) { + return { color: member.roles[0]!.color }; + } + if (member?.role === 'owner') return { color: '#da373c' }; + if (member?.role === 'admin') return { color: '#5865f2' }; + return { color: '#dbdee1' }; })(); + const replyRoleColor = (msg: any) => { + const member = members.find(m => m.userId === msg.userId); + if (member?.roles && member.roles.length > 0) { + return { color: member.roles[0]!.color }; + } + if (member?.role === 'owner') return { color: '#da373c' }; + if (member?.role === 'admin') return { color: '#5865f2' }; + return { color: '#dbdee1' }; + }; + const content = (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > + {/* Reply Line */} + {message.replyTo && ( +
+ )} + {/* Avatar or timestamp column */} -
- {isFirstInGroup ? ( - +
+ {isFirstInGroup || message.replyTo ? ( +
+ +
) : ( - + {formatHoverTime(message.createdAt)} )}
{/* Content */} -
- {isFirstInGroup && ( -
- +
+ {message.replyTo && ( +
+ + + {message.replyTo.user.displayName ?? message.replyTo.user.username} + + + {message.replyTo.content} + +
+ )} + + {(isFirstInGroup || message.replyTo) && ( +
+ {displayName} - + {formatTime(message.createdAt)}
)} {isEditing ? ( -
+