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

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

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

Testing:
- Set up vitest + @testing-library/react + jsdom
- Add 17 tests across InviteModal, JoinServer, FriendsPage (all passing)
- Fix vite resolve.extensions to prefer .tsx over stale .js files
This commit is contained in:
Jannis Braun
2026-02-18 05:34:45 +01:00
parent 4fd17084a5
commit 5ef502f2e3
82 changed files with 4906 additions and 552 deletions
+2 -1
View File
@@ -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",
+59
View File
@@ -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 });
}
+45
View File
@@ -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.');
}
+66 -2
View File
@@ -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] }),
}));
+7 -2
View File
@@ -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<void> {
});
}
initDatabase();
// Initialize database
getDb();
await seedDatabase();
await app.register(authRoutes);
@@ -61,6 +64,8 @@ async function main(): Promise<void> {
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 () => {
+4 -1
View File
@@ -41,7 +41,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
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);
});
}
+141 -6
View File
@@ -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<string, Reaction[]> {
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<string, Reaction[]>();
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<string, MessageWithUser> {
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<string, (typeof schema.attachments.$inferSelect)[]>();
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<string, MessageWithUser>();
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
.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);
+153
View File
@@ -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<void> {
.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<void> {
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<void> {
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<void> {
...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<void> {
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<void> {
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<void> {
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<void> {
))
.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 });
});
}
+313
View File
@@ -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<void> {
// 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));
});
}
+29 -2
View File
@@ -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<void> {
});
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<string, string | null | undefined> = {};
@@ -62,6 +63,13 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
}
}
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<void> {
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) => {
+42
View File
@@ -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<void> {
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
}
});
}
+118 -1
View File
@@ -1,4 +1,4 @@
import { eq, inArray } from 'drizzle-orm';
import { eq, inArray, and } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from './handler.js';
@@ -40,15 +40,54 @@ function getMessageWithUser(messageId: string): MessageWithUser | null {
createdAt: a.createdAt,
}));
const reactionRows = db.select()
.from(schema.reactions)
.where(eq(schema.reactions.messageId, messageId))
.all();
const reactions = reactionRows.map(r => ({
id: r.id,
messageId: r.messageId,
userId: r.userId,
emoji: r.emoji,
createdAt: r.createdAt,
}));
let replyTo: MessageWithUser | null = null;
if (message.replyToId) {
// Simple fetch for replyTo (one level deep to avoid recursion loops)
const replyMsg = db.select().from(schema.messages).where(eq(schema.messages.id, message.replyToId)).get();
if (replyMsg) {
const replyUser = db.select().from(schema.users).where(eq(schema.users.id, replyMsg.userId)).get();
if (replyUser) {
replyTo = {
id: replyMsg.id,
channelId: replyMsg.channelId,
userId: replyMsg.userId,
replyToId: replyMsg.replyToId,
content: replyMsg.content,
editedAt: replyMsg.editedAt,
createdAt: replyMsg.createdAt,
user: sanitizeUser(replyUser),
attachments: [], // Don't fetch attachments for replies to save bandwidth
reactions: [], // Don't fetch reactions for replies
};
}
}
}
return {
id: message.id,
channelId: message.channelId,
userId: message.userId,
replyToId: message.replyToId,
content: message.content,
editedAt: message.editedAt,
createdAt: message.createdAt,
user: sanitizeUser(user),
attachments,
reactions,
replyTo,
};
}
@@ -87,6 +126,12 @@ export function handleClientEvent(
case 'dm_message_create':
handleDmMessageCreate(event, userId);
break;
case 'reaction_add':
handleReactionAdd(event, userId);
break;
case 'reaction_remove':
handleReactionRemove(event, userId);
break;
default:
connectionManager.sendToUser(userId, {
type: 'error',
@@ -98,6 +143,7 @@ export function handleClientEvent(
function handleMessageCreate(event: Record<string, unknown>, userId: string): void {
const channelId = event.channelId as string;
const content = event.content as string;
const replyToId = event.replyToId as string | undefined;
if (!channelId || typeof channelId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' });
@@ -128,6 +174,7 @@ function handleMessageCreate(event: Record<string, unknown>, userId: string): vo
id: messageId,
channelId,
userId,
replyToId: replyToId || null,
content: content.trim(),
createdAt: now,
}).run();
@@ -407,3 +454,73 @@ function handleDmMessageCreate(event: Record<string, unknown>, userId: string):
});
}
}
function handleReactionAdd(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string;
const emoji = event.emoji as string;
if (!messageId || !emoji) return;
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) return;
const serverId = getChannelServerId(message.channelId);
if (!serverId || !isMember(serverId, userId)) return;
const reactionId = generateSnowflake();
try {
db.insert(schema.reactions).values({
id: reactionId,
messageId,
userId,
emoji,
createdAt: Date.now(),
}).run();
connectionManager.sendToServer(serverId, {
type: 'reaction_added',
messageId,
reaction: {
id: reactionId,
messageId,
userId,
emoji,
createdAt: Date.now(),
},
});
} catch (err) {
// Unique constraint violation (already reacted)
}
}
function handleReactionRemove(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string;
const emoji = event.emoji as string;
if (!messageId || !emoji) return;
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (!message) return;
const serverId = getChannelServerId(message.channelId);
if (!serverId || !isMember(serverId, userId)) return;
const result = db.delete(schema.reactions)
.where(and(
eq(schema.reactions.messageId, messageId),
eq(schema.reactions.userId, userId),
eq(schema.reactions.emoji, emoji)
))
.run();
if (result.changes > 0) {
connectionManager.sendToServer(serverId, {
type: 'reaction_removed',
messageId,
userId,
emoji,
});
}
}
+64 -1
View File
@@ -11,6 +11,7 @@ import type {
Channel,
DmChannel,
ServerEvent,
ServerFolder,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
@@ -185,6 +186,7 @@ function buildReadyPayload(userId: string): {
user: User;
servers: ServerWithChannelsAndMembers[];
dmChannels: DmChannel[];
folders: ServerFolder[];
} {
const db = getDb();
@@ -217,6 +219,12 @@ function buildReadyPayload(userId: string): {
.where(eq(schema.channels.serverId, serverRow.id))
.all();
const roles = db.select()
.from(schema.roles)
.where(eq(schema.roles.serverId, serverRow.id))
.orderBy(schema.roles.position)
.all();
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, serverRow.id))
@@ -228,10 +236,31 @@ function buildReadyPayload(userId: string): {
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const memberRoleRows = db.select()
.from(schema.memberRoles)
.where(eq(schema.memberRoles.serverId, serverRow.id))
.all();
const members: MemberWithUser[] = memberRows
.map(m => {
const u = userMap.get(m.userId);
if (!u) return null;
const assignedRoleIds = memberRoleRows
.filter(mr => mr.userId === m.userId)
.map(mr => mr.roleId);
const memberRoles = roles
.filter(r => assignedRoleIds.includes(r.id))
.map(r => ({
id: r.id,
serverId: r.serverId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
createdAt: r.createdAt,
}));
return {
serverId: m.serverId,
userId: m.userId,
@@ -239,6 +268,7 @@ function buildReadyPayload(userId: string): {
nickname: m.nickname,
joinedAt: m.joinedAt,
user: sanitizeUser(u),
roles: memberRoles,
};
})
.filter((m): m is MemberWithUser => m !== null);
@@ -260,6 +290,14 @@ function buildReadyPayload(userId: string): {
createdAt: ch.createdAt,
})),
members,
roles: roles.map(r => ({
id: r.id,
serverId: r.serverId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
createdAt: r.createdAt,
})),
});
}
}
@@ -316,7 +354,32 @@ function buildReadyPayload(userId: string): {
});
}
return { user, servers, dmChannels };
// Get Server Folders
const folderRows = db.select()
.from(schema.serverFolders)
.where(eq(schema.serverFolders.userId, userId))
.orderBy(schema.serverFolders.position)
.all();
const folders: any[] = [];
for (const folder of folderRows) {
const serverIds = db.select()
.from(schema.serverFolderMembers)
.where(eq(schema.serverFolderMembers.folderId, folder.id))
.all()
.map(m => m.serverId);
folders.push({
id: folder.id,
userId: folder.userId,
name: folder.name,
color: folder.color,
position: folder.position,
serverIds,
});
}
return { user, servers, dmChannels, folders };
}
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
+90 -3
View File
@@ -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';
}
+10 -5
View File
@@ -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"
}
}
+1 -1
View File
@@ -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 }) })] }));
}
+16
View File
@@ -44,6 +44,22 @@ export function App() {
</ProtectedRoute>
}
/>
<Route
path="/channels/@me/:channelId?"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
/>
<Route
path="/join/:inviteCode"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/channels/@me" replace />} />
<Route path="*" element={<Navigate to="/channels/@me" replace />} />
</Routes>
+8
View File
@@ -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 }),
},
+14
View File
@@ -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<Server>('POST', `/servers/${id}/join`, data),
joinByCode: (inviteCode: string) => request<Server>('POST', '/servers/join', { inviteCode }),
members: (id: string) => request<MemberWithUser[]>('GET', `/servers/${id}/members`),
updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) =>
request<MemberWithUser>('PATCH', `/servers/${serverId}/members/${userId}`, data),
@@ -156,6 +159,17 @@ export const api = {
request<DmMessageWithUser>('POST', `/dm/${id}/messages`, data),
},
social: {
friends: () => request<Friend[]>('GET', '/social/friends'),
requests: () => request<FriendRequest[]>('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<User[]>('GET', `/social/search?q=${encodeURIComponent(q)}`),
},
livekit: {
token: (channelId: string) =>
request<LiveKitTokenResponse>('POST', '/livekit/token', { channelId }),
+30
View File
@@ -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]" }) }))] }));
}
@@ -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<Metadata | null>(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 (
<div className="mt-2 max-w-[520px] bg-discord-bg-secondary rounded-[4px] border-l-4 border-discord-bg-tertiary flex overflow-hidden">
<div className="flex-1 p-3 min-w-0">
{metadata.siteName && (
<div className="text-[12px] text-discord-text-normal font-medium mb-1 truncate">
{metadata.siteName}
</div>
)}
{metadata.title && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-[16px] text-discord-text-link font-semibold hover:underline block mb-2"
>
{metadata.title}
</a>
)}
{metadata.description && (
<div className="text-[14px] text-discord-text-normal leading-[1.125rem]">
{metadata.description}
</div>
)}
</div>
{metadata.image && (
<div className="w-[80px] h-[80px] m-3 flex-shrink-0">
<img
src={metadata.image}
alt=""
className="w-full h-full object-cover rounded-[4px]"
/>
</div>
)}
</div>
);
}
@@ -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" }) }) })) })] }));
}
@@ -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> = {}): 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> = {}): 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(
<MemoryRouter>
<FriendsPage />
</MemoryRouter>
);
}
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<typeof vi.fn>).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');
});
});
});
});
@@ -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<Tab>('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 (
<div className="flex-1 flex items-center justify-center">
<LoadingSpinner />
</div>
);
}
switch (activeTab) {
case 'online':
return (
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2">
Online {onlineFriends.length}
</h2>
{onlineFriends.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-60">
<img src="/friends-empty.svg" alt="" className="w-64 h-64 mb-4" onError={(e) => (e.target as any).style.display='none'} />
<p className="text-discord-text-muted">No one's around to play with Wumpus.</p>
</div>
) : (
onlineFriends.map(friend => (
<FriendItem key={friend.id} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id)} />
))
)}
</div>
);
case 'all':
return (
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2">
All Friends — {friends.length}
</h2>
{friends.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-60">
<p className="text-discord-text-muted">Wumpus is waiting on friends. You can add them!</p>
</div>
) : (
friends.map(friend => (
<FriendItem key={friend.id} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id)} />
))
)}
</div>
);
case 'pending':
return (
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2">
Pending — {pendingIncoming.length + pendingOutgoing.length}
</h2>
{[...pendingIncoming, ...pendingOutgoing].length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-60">
<p className="text-discord-text-muted">There are no pending friend requests. Here's Wumpus for now!</p>
</div>
) : (
<>
{pendingIncoming.map(req => (
<RequestItem
key={req.id}
request={req}
type="incoming"
onAccept={() => updateFriendRequest(req.id, 'accepted')}
onDecline={() => updateFriendRequest(req.id, 'declined')}
/>
))}
{pendingOutgoing.map(req => (
<RequestItem
key={req.id}
request={req}
type="outgoing"
onCancel={() => cancelFriendRequest(req.id)}
/>
))}
</>
)}
</div>
);
case 'add':
return (
<div className="flex-1 p-8">
<h2 className="text-base font-bold text-discord-text-primary uppercase mb-2">Add Friend</h2>
<p className="text-sm text-discord-text-muted mb-4">You can add friends with their Opencord username.</p>
<form onSubmit={handleAddFriend} className="relative mb-8">
<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"
/>
<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"
>
Send Friend Request
</button>
</form>
{addStatus && (
<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'}`}>
{addStatus.message}
</div>
)}
</div>
);
}
};
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary h-full">
{/* Header */}
<div className="h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10 bg-discord-bg-primary">
<div className="flex items-center gap-2 mr-4">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<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" />
</svg>
<span className="font-bold text-discord-text-primary">Friends</span>
</div>
<div className="w-[1px] h-6 bg-discord-bg-accent mx-2" />
<div className="flex items-center gap-4 ml-2">
<TabButton active={activeTab === 'online'} onClick={() => setActiveTab('online')}>Online</TabButton>
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')}>All</TabButton>
<TabButton active={activeTab === 'pending'} onClick={() => setActiveTab('pending')}>
Pending
{(pendingIncoming.length > 0) && (
<span className="ml-2 px-1.5 py-0.5 bg-discord-red text-white text-[10px] rounded-full leading-none">
{pendingIncoming.length}
</span>
)}
</TabButton>
<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'
}`}
>
Add Friend
</button>
</div>
</div>
{renderTabContent()}
</div>
);
}
function TabButton({ children, active, onClick }: { children: React.ReactNode, active: boolean, onClick: () => void }) {
return (
<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}
</button>
);
}
function FriendItem({ friend, onRemove, onDm }: { friend: Friend, onRemove: () => void, onDm: () => void }) {
return (
<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-modifier-accent mx-2">
<div className="flex items-center gap-3">
<Avatar src={friend.avatar} name={friend.displayName ?? friend.username} size={32} status={friend.status} />
<div className="flex flex-col leading-tight">
<div className="flex items-center gap-1.5">
<span className="text-discord-text-primary font-semibold text-[15px]">{friend.displayName ?? friend.username}</span>
<span className="text-discord-text-muted text-[13px] opacity-0 group-hover:opacity-100 transition-opacity font-medium">@{friend.username}</span>
</div>
<span className="text-[12px] text-discord-text-muted font-medium uppercase">{friend.status}</span>
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity pr-2">
<button
onClick={(e) => { e.stopPropagation(); onDm(); }}
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"
title="Message"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
<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"
title="Remove Friend"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
</div>
</div>
);
}
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 (
<div className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-discord-modifier-hover group transition-colors border-t border-discord-modifier-accent mx-2">
<div className="flex items-center gap-3">
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} />
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="text-discord-text-primary font-bold text-sm">{user.displayName ?? user.username}</span>
<span className="text-discord-text-muted text-xs">@{user.username}</span>
</div>
<span className="text-xs text-discord-text-muted">{type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'}</span>
</div>
</div>
<div className="flex items-center gap-2">
{type === 'incoming' ? (
<>
<button
onClick={() => onAccept?.()}
className="p-2 bg-discord-bg-tertiary rounded-full text-discord-green hover:bg-discord-green hover:text-white transition-all"
title="Accept"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</button>
<button
onClick={() => onDecline?.()}
className="p-2 bg-discord-bg-tertiary rounded-full text-discord-red hover:bg-discord-red hover:text-white transition-all"
title="Decline"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
</>
) : (
<button
onClick={() => onCancel?.()}
className="p-2 bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-all"
title="Cancel Request"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
)}
</div>
</div>
);
}
+66 -15
View File
@@ -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 });
}
+171 -48
View File
@@ -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<string, { count: number; me: boolean }>);
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 = (
<div
className={`group relative flex px-4 py-0.5 hover:bg-discord-bg-hover/30 ${isFirstInGroup ? 'mt-4' : ''}`}
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)}
>
{/* Reply Line */}
{message.replyTo && (
<div className="absolute left-[36px] top-[-14px] w-[33px] h-[22px] border-l-2 border-t-2 border-[#4e5058] rounded-tl-[6px] opacity-60" />
)}
{/* Avatar or timestamp column */}
<div className="w-[72px] flex-shrink-0 flex items-start justify-center">
{isFirstInGroup ? (
<Avatar
src={message.user.avatar}
name={displayName}
size={40}
className="mt-0.5 cursor-pointer"
/>
<div className="w-[72px] flex-shrink-0 flex items-start justify-start pl-0.5">
{isFirstInGroup || message.replyTo ? (
<div className="mt-1">
<Avatar
src={message.user.avatar}
name={displayName}
size={40}
user={message.user}
className="hover:drop-shadow-md transition-all active:translate-y-[1px]"
/>
</div>
) : (
<span className={`text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-1 select-none`}>
<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`}>
{formatHoverTime(message.createdAt)}
</span>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{isFirstInGroup && (
<div className="flex items-baseline gap-2">
<span className={`font-medium cursor-pointer hover:underline ${roleColor}`}>
<div className="flex-1 min-w-0 pr-4">
{message.replyTo && (
<div className="flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply">
<Avatar src={message.replyTo.user.avatar} name={message.replyTo.user.username} size={16} />
<span
className="text-[14px] font-bold text-discord-text-header hover:underline"
style={message.replyTo ? replyRoleColor(message.replyTo) : undefined}
>
{message.replyTo.user.displayName ?? message.replyTo.user.username}
</span>
<span className="text-[14px] text-discord-text-normal truncate max-w-[400px] hover:text-white">
{message.replyTo.content}
</span>
</div>
)}
{(isFirstInGroup || message.replyTo) && (
<div className="flex items-baseline gap-2 mb-0.5">
<span
onClick={handleUsernameClick}
className="font-bold cursor-pointer hover:underline text-[16px] leading-tight"
style={roleColor}
>
{displayName}
</span>
<span className="text-xs text-discord-text-muted">
<span className="text-[12px] text-discord-text-muted leading-tight font-medium hover:cursor-default">
{formatTime(message.createdAt)}
</span>
</div>
)}
{isEditing ? (
<div className="mt-1">
<div className="mt-1 w-full">
<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"
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
/>
<p className="text-xs text-discord-text-muted mt-1">
escape to <button onClick={() => setIsEditing(false)} className="text-[#00aff4] hover:underline">cancel</button>
<p className="text-[12px] text-discord-text-muted mt-1.5 ml-1">
escape to <button onClick={() => setIsEditing(false)} className="text-discord-text-link hover:underline">cancel</button>
{' '}&bull; enter to <button onClick={() => {
if (editContent.trim()) {
editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}} className="text-[#00aff4] hover:underline">save</button>
}} className="text-discord-text-link hover:underline">save</button>
</p>
</div>
) : (
<>
<div className="flex flex-col gap-1">
{message.content && (
<div className="text-discord-text-primary text-sm leading-[1.375rem] break-words">
<div className="text-discord-text-normal text-[16px] leading-[1.375rem] break-words whitespace-pre-wrap selection:bg-discord-blurple/30">
<ReactMarkdown
components={{
p: ({ children }) => <span>{children}</span>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-[#00aff4] hover:underline">
<a href={href} target="_blank" rel="noopener noreferrer" className="text-discord-text-link hover:underline">
{children}
</a>
),
code: ({ children }) => (
<code className="px-1 py-0.5 bg-discord-bg-tertiary rounded text-sm font-mono">
<code className="px-1 py-0.5 bg-discord-bg-tertiary rounded text-[14px] font-mono">
{children}
</code>
),
pre: ({ children }) => (
<pre className="mt-1 p-3 bg-discord-bg-tertiary rounded text-sm font-mono overflow-x-auto">
<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}
</pre>
),
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
strong: ({ children }) => <strong className="font-bold text-discord-text-primary">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
}}
>
{message.content}
</ReactMarkdown>
{message.editedAt && (
<span className="text-[10px] text-discord-text-muted ml-1">(edited)</span>
<span className="text-[10px] text-discord-text-muted ml-1 select-none font-medium">(edited)</span>
)}
</div>
)}
{/* Reactions */}
{Object.keys(reactionGroups).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{Object.entries(reactionGroups).map(([emoji, { count, me }]) => (
<button
key={emoji}
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'
}`}
>
<span>{emoji}</span>
<span className={me ? 'text-discord-blurple' : 'text-discord-text-normal'}>{count}</span>
</button>
))}
</div>
)}
{/* Embeds */}
{!isEditing && firstUrl && <Embed url={firstUrl} />}
{/* Attachments */}
{message.attachments.length > 0 && (
<div className="mt-1 space-y-1">
<div className="mt-1 grid gap-2">
{message.attachments.map((att) => {
const isImage = att.mimetype.startsWith('image/');
if (isImage) {
return (
<div key={att.id} className="max-w-[400px]">
<div key={att.id} className="max-w-fit mt-1 rounded-lg overflow-hidden border border-discord-bg-tertiary/50 bg-discord-bg-tertiary/20">
<img
src={`/api/uploads/${att.filename}`}
alt={att.originalName}
className="max-w-full max-h-[300px] rounded cursor-pointer hover:shadow-lg transition-shadow"
className="max-w-full max-h-[350px] object-contain cursor-pointer hover:brightness-95 transition-all"
onClick={() => openImagePreview(`/api/uploads/${att.filename}`)}
loading="lazy"
/>
@@ -201,14 +302,16 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
key={att.id}
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]"
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"
>
<svg className="w-6 h-6 text-discord-text-muted flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
<div className="p-2 bg-discord-bg-tertiary rounded text-discord-text-muted group-hover/att:text-discord-text-primary transition-colors">
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
</div>
<div className="min-w-0">
<p className="text-[#00aff4] text-sm truncate hover:underline">{att.originalName}</p>
<p className="text-xs text-discord-text-muted">
<p className="text-discord-text-link text-[15px] font-medium truncate hover:underline">{att.originalName}</p>
<p className="text-[12px] text-discord-text-muted font-medium">
{att.size < 1024 ? `${att.size} B` :
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
`${(att.size / 1048576).toFixed(1)} MB`}
@@ -219,35 +322,55 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
})}
</div>
)}
</>
</div>
)}
</div>
{/* Action buttons on hover */}
{isHovered && !isEditing && contextMenuItems.length > 0 && (
<div className="absolute -top-3 right-4 flex items-center bg-discord-bg-secondary border border-discord-bg-tertiary rounded shadow-md">
{isHovered && !isEditing && (
<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">
<div className="flex items-center px-1 border-r border-discord-bg-tertiary/50 h-full">
{['👍', '❤️', '😂', '😮'].map(emoji => (
<button
key={emoji}
onClick={() => toggleReaction(emoji)}
className="p-1 hover:bg-discord-modifier-hover rounded transition-colors text-[16px] leading-none"
>
{emoji}
</button>
))}
</div>
<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"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" />
</svg>
</button>
{isAuthor && (
<button
onClick={() => {
setEditContent(message.content ?? '');
setIsEditing(true);
}}
className="p-1.5 text-discord-text-muted hover:text-discord-text-primary transition-colors"
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"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<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" />
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
)}
{canDelete && (
<button
onClick={() => deleteMessage(message.id)}
className="p-1.5 text-discord-text-muted hover:text-discord-red transition-colors"
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"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<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" />
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
)}
@@ -10,6 +10,8 @@ export function MessageInput({ channelId, channelName }) {
const fileInputRef = useRef(null);
const textareaRef = useRef(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const replyTo = useChatStore((s) => s.replyTo);
const setReplyTo = useChatStore((s) => s.setReplyTo);
const typingTimeoutRef = useRef();
const handleTyping = useCallback(() => {
if (typingTimeoutRef.current)
@@ -89,11 +91,11 @@ export function MessageInput({ channelId, channelName }) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
};
return (_jsx("div", { className: "px-4 pb-6", children: _jsxs("div", { className: "bg-discord-bg-input rounded-lg", onDrop: handleDrop, onDragOver: handleDragOver, children: [files.length > 0 && (_jsx("div", { className: "p-2 border-b border-discord-bg-tertiary flex flex-wrap gap-2", children: files.map((file, i) => (_jsxs("div", { className: "relative group bg-discord-bg-secondary rounded p-2 max-w-[200px]", children: [file.type.startsWith('image/') ? (_jsx("img", { src: URL.createObjectURL(file), alt: file.name, className: "max-h-[100px] rounded object-cover" })) : (_jsxs("div", { className: "flex items-center gap-2 text-sm text-discord-text-secondary", children: [_jsx("svg", { className: "w-5 h-5", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" }) }), _jsx("span", { className: "truncate", children: file.name })] })), _jsx("button", { onClick: () => removeFile(i), className: "absolute -top-1 -right-1 w-5 h-5 bg-discord-red rounded-full flex items-center justify-center text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity", children: "\u2715" })] }, i))) })), _jsxs("div", { className: "flex items-end", children: [_jsx("button", { onClick: () => fileInputRef.current?.click(), className: "p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Attach file", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) }) }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, className: "hidden", onChange: (e) => {
const selected = Array.from(e.target.files ?? []);
if (selected.length > 0) {
setFiles((prev) => [...prev, ...selected]);
}
e.target.value = '';
} }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message #${channelName}`, className: "flex-1 py-3 bg-transparent text-discord-text-primary placeholder-discord-text-muted outline-none resize-none text-sm leading-[1.375rem] max-h-[300px]", rows: 1, disabled: isUploading }), isUploading && (_jsx("div", { className: "p-3 text-discord-text-muted", children: _jsxs("svg", { className: "w-5 h-5 animate-spin", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }) }))] })] }) }));
return (_jsxs("div", { className: "px-4 pb-6 flex-shrink-0", children: [replyTo && (_jsxs("div", { className: "bg-[#2e3035] rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-discord-bg-tertiary/50", children: [_jsxs("div", { className: "flex items-center gap-1 text-[14px] text-discord-text-normal truncate", children: [_jsx("span", { className: "opacity-60", children: "Replying to" }), _jsx("span", { className: "font-bold", children: replyTo.user.displayName ?? replyTo.user.username })] }), _jsx("button", { onClick: () => setReplyTo(null), className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] })), _jsxs("div", { className: `bg-discord-bg-input ${replyTo ? 'rounded-b-lg' : 'rounded-lg'} overflow-hidden`, onDrop: handleDrop, onDragOver: handleDragOver, children: [files.length > 0 && (_jsx("div", { className: "p-4 flex flex-wrap gap-4 bg-discord-bg-secondary/30", children: files.map((file, i) => (_jsxs("div", { className: "relative group bg-discord-bg-secondary rounded-lg p-2 max-w-[200px] shadow-sm border border-discord-bg-tertiary", children: [file.type.startsWith('image/') ? (_jsx("img", { src: URL.createObjectURL(file), alt: file.name, className: "max-h-[150px] rounded object-cover" })) : (_jsxs("div", { className: "flex items-center gap-2 text-sm text-discord-text-secondary py-4 px-2", children: [_jsx("svg", { className: "w-8 h-8 opacity-60", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" }) }), _jsx("span", { className: "truncate max-w-[120px] font-medium", children: file.name })] })), _jsx("button", { onClick: () => removeFile(i), className: "absolute -top-2 -right-2 w-7 h-7 bg-discord-red hover:bg-discord-red-hover shadow-lg rounded-lg flex items-center justify-center text-white transition-colors z-10", children: _jsx("svg", { width: "14", height: "14", 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" }) }) })] }, i))) })), _jsxs("div", { className: "flex items-start px-1", children: [_jsx("button", { onClick: () => fileInputRef.current?.click(), className: "p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors sticky top-0", title: "Attach file", children: _jsx("div", { className: "bg-discord-text-muted/20 hover:bg-discord-text-muted/40 rounded-full p-0.5 transition-colors", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) }) }) }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, className: "hidden", onChange: (e) => {
const selected = Array.from(e.target.files ?? []);
if (selected.length > 0) {
setFiles((prev) => [...prev, ...selected]);
}
e.target.value = '';
} }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message #${channelName}`, className: "flex-1 py-[11px] px-1 bg-transparent text-discord-text-primary placeholder-discord-text-muted/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin", rows: 1, disabled: isUploading }), isUploading && (_jsx("div", { className: "p-3 text-discord-text-muted", children: _jsxs("svg", { className: "w-5 h-5 animate-spin", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }) })), _jsx("button", { className: "p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" }) }) }), _jsx("button", { onClick: handleSubmit, disabled: !content.trim() && files.length === 0, className: "p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors disabled:opacity-30 disabled:hover:text-discord-text-muted", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" }) }) })] })] })] }));
}
@@ -15,6 +15,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const replyTo = useChatStore((s) => s.replyTo);
const setReplyTo = useChatStore((s) => s.setReplyTo);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const handleTyping = useCallback(() => {
@@ -103,52 +105,72 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
};
return (
<div className="px-4 pb-6">
<div className="px-4 pb-6 flex-shrink-0">
{replyTo && (
<div className="bg-[#2e3035] rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-discord-bg-tertiary/50">
<div className="flex items-center gap-1 text-[14px] text-discord-text-normal truncate">
<span className="opacity-60">Replying to</span>
<span className="font-bold">{replyTo.user.displayName ?? replyTo.user.username}</span>
</div>
<button
onClick={() => setReplyTo(null)}
className="text-discord-text-muted hover:text-discord-text-primary transition-colors"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
)}
<div
className="bg-discord-bg-input rounded-lg"
className={`bg-discord-bg-input ${replyTo ? 'rounded-b-lg' : 'rounded-lg'} overflow-hidden`}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* File previews */}
{files.length > 0 && (
<div className="p-2 border-b border-discord-bg-tertiary flex flex-wrap gap-2">
<div className="p-4 flex flex-wrap gap-4 bg-discord-bg-secondary/30">
{files.map((file, i) => (
<div key={i} className="relative group bg-discord-bg-secondary rounded p-2 max-w-[200px]">
<div key={i} className="relative group bg-discord-bg-secondary rounded-lg p-2 max-w-[200px] shadow-sm border border-discord-bg-tertiary">
{file.type.startsWith('image/') ? (
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="max-h-[100px] rounded object-cover"
className="max-h-[150px] rounded object-cover"
/>
) : (
<div className="flex items-center gap-2 text-sm text-discord-text-secondary">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div className="flex items-center gap-2 text-sm text-discord-text-secondary py-4 px-2">
<svg className="w-8 h-8 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="truncate">{file.name}</span>
<span className="truncate max-w-[120px] font-medium">{file.name}</span>
</div>
)}
<button
onClick={() => removeFile(i)}
className="absolute -top-1 -right-1 w-5 h-5 bg-discord-red rounded-full flex items-center justify-center text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity"
className="absolute -top-2 -right-2 w-7 h-7 bg-discord-red hover:bg-discord-red-hover shadow-lg rounded-lg flex items-center justify-center text-white transition-colors z-10"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<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" />
</svg>
</button>
</div>
))}
</div>
)}
<div className="flex items-end">
<div className="flex items-start px-1">
{/* File attach button */}
<button
onClick={() => fileInputRef.current?.click()}
className="p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors"
className="p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors sticky top-0"
title="Attach file"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
<div className="bg-discord-text-muted/20 hover:bg-discord-text-muted/40 rounded-full p-0.5 transition-colors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
</div>
</button>
<input
ref={fileInputRef}
@@ -172,7 +194,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={`Message #${channelName}`}
className="flex-1 py-3 bg-transparent text-discord-text-primary placeholder-discord-text-muted outline-none resize-none text-sm leading-[1.375rem] max-h-[300px]"
className="flex-1 py-[11px] px-1 bg-transparent text-discord-text-primary placeholder-discord-text-muted/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
rows={1}
disabled={isUploading}
/>
@@ -186,6 +208,24 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
</svg>
</div>
)}
{/* Emoji button placeholder */}
<button className="p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
</svg>
</button>
{/* Send Button */}
<button
onClick={handleSubmit}
disabled={!content.trim() && files.length === 0}
className="p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors disabled:opacity-30 disabled:hover:text-discord-text-muted"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</button>
</div>
</div>
</div>
@@ -77,10 +77,10 @@ export function MessageList({ channelId }) {
if (isLoading && messages.length === 0) {
return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsx(LoadingSpinner, {}) }));
}
return (_jsxs("div", { ref: containerRef, className: "flex-1 overflow-y-auto overflow-x-hidden", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && (_jsxs("div", { className: "px-4 pt-6 pb-4", children: [_jsx("h3", { className: "text-2xl font-bold text-discord-text-primary", children: "Welcome to the channel!" }), _jsx("p", { className: "text-discord-text-muted text-sm mt-1", children: "This is the start of the conversation." }), _jsx("div", { className: "mt-4 border-b border-discord-bg-hover" })] })), _jsx("div", { className: "pb-6", children: messages.map((msg, i) => {
return (_jsxs("div", { ref: containerRef, className: "flex-1 overflow-y-auto overflow-x-hidden", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "w-[68px] h-[68px] rounded-full bg-discord-bg-accent flex items-center justify-center mb-4 text-white", children: _jsx("svg", { width: "42", height: "42", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: "Welcome to the channel!" }), _jsx("p", { className: "text-discord-text-secondary text-[16px] mt-2", children: "This is the start of the conversation." }), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] })), _jsx("div", { className: "pb-6", children: messages.map((msg, i) => {
const prevMsg = messages[i - 1];
const showDate = shouldShowDateDivider(prevMsg, msg);
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
return (_jsxs(React.Fragment, { children: [showDate && (_jsxs("div", { className: "flex items-center px-4 my-4", children: [_jsx("div", { className: "flex-1 border-t border-discord-bg-hover" }), _jsx("span", { className: "px-2 text-xs font-semibold text-discord-text-muted", children: formatDateDivider(msg.createdAt) }), _jsx("div", { className: "flex-1 border-t border-discord-bg-hover" })] })), _jsx(Message, { message: msg, isCompact: !isFirstInGroup, isFirstInGroup: isFirstInGroup })] }, msg.id));
return (_jsxs(React.Fragment, { children: [showDate && (_jsxs("div", { className: "flex items-center px-4 my-6 select-none pointer-events-none", children: [_jsx("div", { className: "flex-1 h-[1px] bg-discord-modifier-accent" }), _jsx("span", { className: "px-2 text-[12px] font-bold text-discord-text-muted leading-tight", children: formatDateDivider(msg.createdAt) }), _jsx("div", { className: "flex-1 h-[1px] bg-discord-modifier-accent" })] })), _jsx(Message, { message: msg, isCompact: !isFirstInGroup, isFirstInGroup: isFirstInGroup })] }, msg.id));
}) }), _jsx("div", { ref: bottomRef })] }));
}
@@ -108,10 +108,15 @@ export function MessageList({ channelId }: MessageListProps) {
)}
{!hasMore && (
<div className="px-4 pt-6 pb-4">
<h3 className="text-2xl font-bold text-discord-text-primary">Welcome to the channel!</h3>
<p className="text-discord-text-muted text-sm mt-1">This is the start of the conversation.</p>
<div className="mt-4 border-b border-discord-bg-hover" />
<div className="px-4 pt-8 pb-4">
<div className="w-[68px] h-[68px] rounded-full bg-discord-bg-accent flex items-center justify-center mb-4 text-white">
<svg width="42" height="42" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
</svg>
</div>
<h3 className="text-[32px] leading-10 font-bold text-discord-text-primary">Welcome to the channel!</h3>
<p className="text-discord-text-secondary text-[16px] mt-2">This is the start of the conversation.</p>
<div className="mt-6 border-b border-discord-modifier-accent" />
</div>
)}
@@ -124,12 +129,12 @@ export function MessageList({ channelId }: MessageListProps) {
return (
<React.Fragment key={msg.id}>
{showDate && (
<div className="flex items-center px-4 my-4">
<div className="flex-1 border-t border-discord-bg-hover" />
<span className="px-2 text-xs font-semibold text-discord-text-muted">
<div className="flex items-center px-4 my-6 select-none pointer-events-none">
<div className="flex-1 h-[1px] bg-discord-modifier-accent" />
<span className="px-2 text-[12px] font-bold text-discord-text-muted leading-tight">
{formatDateDivider(msg.createdAt)}
</span>
<div className="flex-1 border-t border-discord-bg-hover" />
<div className="flex-1 h-[1px] bg-discord-modifier-accent" />
</div>
)}
<Message
@@ -25,5 +25,5 @@ export function TypingIndicator({ channelId }) {
else {
text = 'Several people are typing';
}
return (_jsx("div", { className: "h-6 px-4 flex items-center text-xs text-discord-text-muted", children: _jsxs("div", { className: "flex items-center gap-1", children: [_jsxs("span", { className: "flex gap-0.5", children: [_jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '0ms' } }), _jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '150ms' } }), _jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '300ms' } })] }), _jsx("span", { className: "font-medium", children: text }), _jsx("span", { children: "..." })] }) }));
return (_jsx("div", { className: "h-[24px] px-4 flex items-center text-[12px] text-discord-text-header font-medium select-none pointer-events-none", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex gap-[2px] bg-discord-bg-accent/20 rounded-full px-2 py-1", children: [_jsx("div", { className: "w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce", style: { animationDelay: '0ms', animationDuration: '0.8s' } }), _jsx("div", { className: "w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce", style: { animationDelay: '150ms', animationDuration: '0.8s' } }), _jsx("div", { className: "w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce", style: { animationDelay: '300ms', animationDuration: '0.8s' } })] }), _jsx("span", { className: "truncate max-w-[400px]", children: _jsx("span", { className: "font-bold", children: text }) })] }) }));
}
@@ -30,15 +30,16 @@ export function TypingIndicator({ channelId }: TypingIndicatorProps) {
}
return (
<div className="h-6 px-4 flex items-center text-xs text-discord-text-muted">
<div className="flex items-center gap-1">
<span className="flex gap-0.5">
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
<div className="h-[24px] px-4 flex items-center text-[12px] text-discord-text-header font-medium select-none pointer-events-none">
<div className="flex items-center gap-2">
<div className="flex gap-[2px] bg-discord-bg-accent/20 rounded-full px-2 py-1">
<div className="w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce" style={{ animationDelay: '0ms', animationDuration: '0.8s' }} />
<div className="w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce" style={{ animationDelay: '150ms', animationDuration: '0.8s' }} />
<div className="w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce" style={{ animationDelay: '300ms', animationDuration: '0.8s' }} />
</div>
<span className="truncate max-w-[400px]">
<span className="font-bold">{text}</span>
</span>
<span className="font-medium">{text}</span>
<span>...</span>
</div>
</div>
);
@@ -1,11 +1,10 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { ServerSidebar } from './ServerSidebar';
import { ChannelSidebar } from './ChannelSidebar';
import { MainContent } from './MainContent';
import { MemberSidebar } from './MemberSidebar';
import { MobileNav } from './MobileNav';
import { ImagePreview } from '../chat/ImagePreview';
import { CreateServerModal } from '../modals/CreateServer';
import { JoinServerModal } from '../modals/JoinServer';
@@ -13,13 +12,16 @@ import { CreateChannelModal } from '../modals/CreateChannel';
import { InviteModal } from '../modals/InviteModal';
import { UserSettingsModal } from '../modals/UserSettings';
import { ServerSettingsModal } from '../modals/ServerSettings';
import { UserProfilePopout } from '../ui/UserProfilePopout';
import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useLiveKit } from '../../hooks/useLiveKit';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useVoiceStore } from '../../stores/voiceStore';
export function AppLayout() {
const { serverId, channelId } = useParams();
const { serverId, channelId, inviteCode } = useParams();
const { user, isLoading } = useAuth();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
@@ -27,10 +29,29 @@ export function AppLayout() {
const loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const isMobile = useUIStore((s) => s.isMobile);
const userProfilePopout = useUIStore((s) => s.userProfilePopout);
const closeUserProfile = useUIStore((s) => s.closeUserProfile);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const setParticipants = useVoiceStore((s) => s.setParticipants);
const { connect: connectVoice, disconnect: disconnectVoice, participants: voiceParticipants, toggleMic, toggleCamera, toggleScreenShare } = useLiveKit();
// Initialize WebSocket
useWebSocket();
// Sync participants to store
useEffect(() => {
setParticipants(voiceParticipants);
}, [voiceParticipants, setParticipants]);
// Manage voice connection
useEffect(() => {
if (currentVoiceChannelId) {
connectVoice(currentVoiceChannelId);
}
else {
disconnectVoice();
}
}, [currentVoiceChannelId, connectVoice, disconnectVoice]);
// Responsive detection
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
@@ -50,6 +71,11 @@ export function AppLayout() {
loadServerDetail(serverId);
}
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
useEffect(() => {
if (inviteCode) {
openModal('joinServer');
}
}, [inviteCode, openModal]);
useEffect(() => {
if (channelId) {
setCurrentChannel(channelId);
@@ -62,5 +88,5 @@ export function AppLayout() {
if (isLoading || !user) {
return (_jsx("div", { className: "h-screen flex items-center justify-center bg-discord-bg-primary", children: _jsxs("div", { className: "text-center", children: [_jsxs("svg", { className: "animate-spin w-10 h-10 text-discord-blurple mx-auto mb-4", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }), _jsx("p", { className: "text-discord-text-muted", children: "Loading Opencord..." })] }) }));
}
return (_jsxs("div", { className: "h-screen flex overflow-hidden", children: [_jsx(MobileNav, {}), _jsx("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : ''}`, children: _jsxs("div", { className: "flex h-full", children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }) }), _jsxs("div", { className: "flex-1 flex min-w-0", children: [_jsx(MainContent, {}), _jsx(MemberSidebar, {})] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(ImagePreview, {})] }));
return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, { onToggleMic: toggleMic, onToggleCamera: toggleCamera, onToggleScreenShare: toggleScreenShare })] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), serverId === '@me' ? (_jsx("div", { className: "w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col border-l border-discord-modifier-accent", children: _jsxs("div", { className: "p-4", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4", children: "Active Now" }), _jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1 text-center", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we\u2019ll show it here!" })] })] }) })) : (_jsx(MemberSidebar, {}))] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(ImagePreview, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] }));
}
@@ -12,14 +12,17 @@ import { CreateChannelModal } from '../modals/CreateChannel';
import { InviteModal } from '../modals/InviteModal';
import { UserSettingsModal } from '../modals/UserSettings';
import { ServerSettingsModal } from '../modals/ServerSettings';
import { UserProfilePopout } from '../ui/UserProfilePopout';
import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useLiveKit } from '../../hooks/useLiveKit';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useVoiceStore } from '../../stores/voiceStore';
export function AppLayout() {
const { serverId, channelId } = useParams<{ serverId?: string; channelId?: string }>();
const { serverId, channelId, inviteCode } = useParams<{ serverId?: string; channelId?: string; inviteCode?: string }>();
const { user, isLoading } = useAuth();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
@@ -27,12 +30,40 @@ export function AppLayout() {
const loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const isMobile = useUIStore((s) => s.isMobile);
const userProfilePopout = useUIStore((s) => s.userProfilePopout);
const closeUserProfile = useUIStore((s) => s.closeUserProfile);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const setParticipants = useVoiceStore((s) => s.setParticipants);
const {
connect: connectVoice,
disconnect: disconnectVoice,
participants: voiceParticipants,
toggleMic,
toggleCamera,
toggleScreenShare
} = useLiveKit();
// Initialize WebSocket
useWebSocket();
// Sync participants to store
useEffect(() => {
setParticipants(voiceParticipants);
}, [voiceParticipants, setParticipants]);
// Manage voice connection
useEffect(() => {
if (currentVoiceChannelId) {
connectVoice(currentVoiceChannelId);
} else {
disconnectVoice();
}
}, [currentVoiceChannelId, connectVoice, disconnectVoice]);
// Responsive detection
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
@@ -53,6 +84,12 @@ export function AppLayout() {
}
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
useEffect(() => {
if (inviteCode) {
openModal('joinServer');
}
}, [inviteCode, openModal]);
useEffect(() => {
if (channelId) {
setCurrentChannel(channelId);
@@ -77,21 +114,35 @@ export function AppLayout() {
}
return (
<div className="h-screen flex overflow-hidden">
<MobileNav />
<div className="h-screen flex bg-discord-bg-tertiary overflow-hidden">
{/* Server sidebar - always visible on desktop, toggled on mobile */}
<div className={`${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : ''}`}>
<div className="flex h-full">
<ServerSidebar />
<ChannelSidebar />
</div>
<div className={`${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`}>
<ServerSidebar />
<ChannelSidebar
onToggleMic={toggleMic}
onToggleCamera={toggleCamera}
onToggleScreenShare={toggleScreenShare}
/>
</div>
{/* Main content area */}
<div className="flex-1 flex min-w-0">
<div className="flex-1 flex min-w-0 bg-discord-bg-primary relative">
<MainContent />
<MemberSidebar />
{serverId === '@me' ? (
<div className="w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col">
<div className="p-4">
<h3 className="text-[20px] font-bold text-discord-text-header mb-4">Active Now</h3>
<div className="text-center py-8">
<div className="text-[16px] font-bold text-discord-text-header mb-1 text-center">It's quiet for now...</div>
<div className="text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto">
When a friend starts an activitylike playing a game or hanging out on voicewell show it here!
</div>
</div>
</div>
</div>
) : (
<MemberSidebar />
)}
</div>
{/* Modals */}
@@ -102,6 +153,21 @@ export function AppLayout() {
<UserSettingsModal />
<ServerSettingsModal />
<ImagePreview />
{/* User Profile Popout */}
{userProfilePopout.user && userProfilePopout.position && (
<>
<div
className="fixed inset-0 z-[45]"
onClick={closeUserProfile}
/>
<UserProfilePopout
user={userProfilePopout.user}
onClose={closeUserProfile}
position={userProfilePopout.position}
/>
</>
)}
</div>
);
}
@@ -9,10 +9,11 @@ import { VoiceControls } from '../voice/VoiceControls';
import { useVoiceStore } from '../../stores/voiceStore';
import { Avatar } from '../ui/Avatar';
import { wsSend } from '../../hooks/useWebSocket';
export function ChannelSidebar() {
export function ChannelSidebar({ onToggleMic, onToggleCamera, onToggleScreenShare }) {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const channels = useServerStore((s) => s.channels);
const dmChannels = useServerStore((s) => s.dmChannels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const openModal = useUIStore((s) => s.openModal);
@@ -28,7 +29,11 @@ export function ChannelSidebar() {
const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video');
const handleChannelClick = (channelId) => {
setCurrentChannel(channelId);
navigate(`/channels/${currentServerId}/${channelId}`);
navigate(`/channels/${currentServerId || '@me'}/${channelId}`);
};
const handleHomeClick = () => {
setCurrentChannel(null);
navigate('/channels/@me');
};
const handleVoiceJoin = (channelId) => {
setCurrentVoiceChannel(channelId);
@@ -39,9 +44,27 @@ export function ChannelSidebar() {
wsSend({ type: 'voice_leave' });
};
if (!server) {
return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0", children: [_jsx("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary", children: _jsx("span", { className: "font-semibold text-discord-text-primary", children: "Direct Messages" }) }), _jsx("div", { className: "flex-1 p-2 text-discord-text-muted text-sm", children: _jsx("p", { className: "px-2 py-4", children: "Select or create a DM conversation" }) }), user && (_jsxs("div", { className: "h-[52px] px-2 bg-discord-bg-members flex items-center gap-2", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-sm font-medium truncate", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[10px] text-discord-text-muted truncate", children: ["@", user.username] })] }), _jsx("button", { onClick: () => openModal('userSettings'), className: "p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "User Settings", 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" }) }) })] }))] }));
return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none", children: [_jsx("div", { className: "h-12 px-4 flex items-center shadow-header z-10", children: _jsx("button", { className: "flex-1 bg-discord-bg-tertiary text-discord-text-muted text-[14px] font-medium py-1 px-2 rounded-[4px] text-left hover:bg-discord-bg-tertiary/80 transition-colors", children: "Find or start a conversation" }) }), _jsxs("div", { className: "flex-1 overflow-y-auto pt-4 px-2 no-scrollbar", children: [_jsxs("div", { onClick: handleHomeClick, className: `flex items-center gap-3 px-2 h-10 rounded-[4px] cursor-pointer mb-0.5 transition-colors group ${!currentChannelId
? 'bg-discord-modifier-selected text-white'
: 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: `${!currentChannelId ? 'text-white' : 'opacity-70 group-hover:opacity-100'}`, 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-medium text-[16px]", children: "Friends" })] }), _jsxs("div", { className: "mt-[18px] px-2 mb-1 flex items-center justify-between group", children: [_jsx("span", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider", children: "Direct Messages" }), _jsx("button", { className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) })] }), _jsxs("div", { className: "space-y-[2px]", children: [dmChannels.map((dm) => {
const otherUser = dm.members.find(m => m.id !== user?.id);
if (!otherUser)
return null;
return (_jsxs("div", { onClick: () => handleChannelClick(dm.id), className: `flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer transition-colors group ${currentChannelId === dm.id
? 'bg-discord-modifier-selected text-white'
: 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: [_jsx(Avatar, { src: otherUser.avatar, name: otherUser.displayName ?? otherUser.username, size: 32, status: otherUser.status }), _jsx("div", { className: "flex-1 min-w-0", children: _jsx("div", { className: `text-[16px] font-medium truncate ${currentChannelId === dm.id ? 'text-white' : 'text-discord-text-muted group-hover:text-discord-text-secondary'}`, children: otherUser.displayName ?? otherUser.username }) })] }, dm.id));
}), dmChannels.length === 0 && (_jsx("p", { className: "px-2 py-4 text-[13px] text-discord-text-muted italic opacity-60", children: "No DM conversations yet." }))] })] }), user && (_jsxs("div", { className: "h-[52px] px-2 bg-[#232428] flex items-center gap-2 select-none", children: [_jsxs("div", { className: "p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status, user: user }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-bold text-discord-text-primary truncate leading-tight", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary", children: ["@", user.username] })] })] }), _jsxs("div", { className: "flex items-center", children: [_jsx(UserAreaButton, { title: "Mute", children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" })] }) }), _jsx(UserAreaButton, { title: "Deafen", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }) }) }), _jsx(UserAreaButton, { title: "User Settings", onClick: () => openModal('userSettings'), 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" }) }) })] })] }))] }));
}
return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0", children: [_jsxs("button", { onClick: () => openModal('serverSettings'), className: "h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors", children: [_jsx("span", { className: "font-bold text-discord-text-primary truncate", children: server.name }), _jsx("svg", { width: "18", height: "18", viewBox: "0 0 18 18", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" }) })] }), _jsxs("div", { className: "flex-1 overflow-y-auto p-2 space-y-4", children: [textChannels.length > 0 && (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1", children: [_jsx("span", { className: "text-xs font-bold text-discord-text-muted uppercase tracking-wide", children: "Text Channels" }), isAdminUser && (_jsx("button", { onClick: () => openModal('createChannel'), className: "text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), textChannels.map((channel) => (_jsxs("button", { onClick: () => handleChannelClick(channel.id), className: `w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm group ${currentChannelId === channel.id
? 'bg-discord-bg-active text-white'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: "truncate", children: channel.name })] }, channel.id)))] })), voiceChannels.length > 0 && (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1", children: [_jsx("span", { className: "text-xs font-bold text-discord-text-muted uppercase tracking-wide", children: "Voice Channels" }), isAdminUser && (_jsx("button", { onClick: () => openModal('createChannel'), className: "text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), voiceChannels.map((channel) => (_jsx(VoiceChannel, { channelId: channel.id, channelName: channel.name, onClick: () => handleVoiceJoin(channel.id) }, channel.id)))] })), _jsxs("button", { onClick: () => openModal('invite'), className: "w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M14 2.5a.5.5 0 00-.5-.5h-6a.5.5 0 000 1h4.793L2.146 13.146a.5.5 0 00.708.708L13 3.707V8.5a.5.5 0 001 0v-6z" }) }), "Invite People"] })] }), currentVoiceChannelId && (_jsx(VoiceControls, { onDisconnect: handleVoiceDisconnect, onToggleMic: () => { }, onToggleCamera: () => { }, onToggleScreenShare: () => { } })), user && (_jsxs("div", { className: "h-[52px] px-2 bg-discord-bg-members flex items-center gap-2", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-sm font-medium truncate", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[10px] text-discord-text-muted truncate", children: ["@", user.username] })] }), _jsx("button", { onClick: () => openModal('userSettings'), className: "p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "User Settings", 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" }) }) })] }))] }));
return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none", children: [_jsxs("button", { onClick: () => openModal('serverSettings'), className: "h-12 px-4 flex items-center justify-between shadow-header z-10 hover:bg-discord-modifier-hover transition-colors group", children: [_jsx("span", { className: "font-bold text-[16px] text-discord-text-primary truncate leading-tight", children: server.name }), _jsx("svg", { width: "18", height: "18", viewBox: "0 0 18 18", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0 group-hover:text-discord-text-secondary", children: _jsx("path", { d: "M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" }) })] }), _jsxs("div", { className: "flex-1 overflow-y-auto pt-3 px-2 space-y-[21px] no-scrollbar", children: [_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1 group cursor-pointer", children: [_jsxs("div", { className: "flex items-center gap-0.5 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-70", children: _jsx("path", { d: "M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" }) }), _jsx("span", { className: "text-[12px] font-bold uppercase tracking-wider", children: "Text Channels" })] }), isAdminUser && (_jsx("button", { onClick: (e) => {
e.stopPropagation();
openModal('createChannel');
}, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), _jsx("div", { className: "space-y-[2px]", children: textChannels.map((channel) => (_jsxs("button", { onClick: () => handleChannelClick(channel.id), className: `w-full flex items-center gap-1.5 px-2 h-8 rounded-[4px] group transition-colors ${currentChannelId === channel.id
? 'bg-discord-modifier-selected text-white'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'}`, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: "truncate font-medium text-[16px]", children: channel.name })] }, channel.id))) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1 group cursor-pointer", children: [_jsxs("div", { className: "flex items-center gap-0.5 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-70", children: _jsx("path", { d: "M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" }) }), _jsx("span", { className: "text-[12px] font-bold uppercase tracking-wider", children: "Voice Channels" })] }), isAdminUser && (_jsx("button", { onClick: (e) => {
e.stopPropagation();
openModal('createChannel');
}, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), _jsx("div", { className: "space-y-[2px]", children: voiceChannels.map((channel) => (_jsx(VoiceChannel, { channelId: channel.id, channelName: channel.name, onClick: () => handleVoiceJoin(channel.id) }, channel.id))) })] }), _jsx("div", { className: "pt-2", children: _jsxs("button", { onClick: () => openModal('invite'), className: "w-full flex items-center gap-2 px-2 h-8 rounded-[4px] text-[15px] font-medium text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover transition-colors", children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-60", children: _jsx("path", { d: "M13 13v5h-2v-5H6v-2h5V6h2v5h5v2h-5z" }) }), "Invite People"] }) })] }), currentVoiceChannelId && (_jsx(VoiceControls, { onDisconnect: handleVoiceDisconnect, onToggleMic: onToggleMic, onToggleCamera: onToggleCamera, onToggleScreenShare: onToggleScreenShare })), user && (_jsxs("div", { className: "h-[52px] px-2 bg-[#232428] flex items-center gap-2 select-none", children: [_jsxs("div", { className: "p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status, user: user }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-bold text-discord-text-primary truncate leading-tight", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary", children: ["@", user.username] })] })] }), _jsxs("div", { className: "flex items-center", children: [_jsx(UserAreaButton, { title: "Mute", children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" })] }) }), _jsx(UserAreaButton, { title: "Deafen", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }) }) }), _jsx(UserAreaButton, { title: "User Settings", onClick: () => openModal('userSettings'), 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" }) }) })] })] }))] }));
}
function UserAreaButton({ children, title, onClick }) {
return (_jsx("button", { onClick: onClick, className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover rounded-[4px] transition-all", title: title, children: children }));
}
@@ -10,10 +10,17 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { Avatar } from '../ui/Avatar';
import { wsSend } from '../../hooks/useWebSocket';
export function ChannelSidebar() {
interface ChannelSidebarProps {
onToggleMic: () => void;
onToggleCamera: () => void;
onToggleScreenShare: () => void;
}
export function ChannelSidebar({ onToggleMic, onToggleCamera, onToggleScreenShare }: ChannelSidebarProps) {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const channels = useServerStore((s) => s.channels);
const dmChannels = useServerStore((s) => s.dmChannels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const openModal = useUIStore((s) => s.openModal);
@@ -32,7 +39,12 @@ export function ChannelSidebar() {
const handleChannelClick = (channelId: string) => {
setCurrentChannel(channelId);
navigate(`/channels/${currentServerId}/${channelId}`);
navigate(`/channels/${currentServerId || '@me'}/${channelId}`);
};
const handleHomeClick = () => {
setCurrentChannel(null);
navigate('/channels/@me');
};
const handleVoiceJoin = (channelId: string) => {
@@ -47,104 +59,184 @@ export function ChannelSidebar() {
if (!server) {
return (
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0">
<div className="h-12 px-4 flex items-center border-b border-discord-bg-tertiary">
<span className="font-semibold text-discord-text-primary">Direct Messages</span>
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none">
<div className="h-12 px-4 flex items-center shadow-header z-10">
<button className="flex-1 bg-discord-bg-tertiary text-discord-text-muted text-[14px] font-medium py-1 px-2 rounded-[4px] text-left hover:bg-discord-bg-tertiary/80 transition-colors">
Find or start a conversation
</button>
</div>
<div className="flex-1 p-2 text-discord-text-muted text-sm">
<p className="px-2 py-4">Select or create a DM conversation</p>
</div>
{/* User area at bottom */}
{user && (
<div className="h-[52px] px-2 bg-discord-bg-members flex items-center gap-2">
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.displayName ?? user.username}</div>
<div className="text-[10px] text-discord-text-muted truncate">@{user.username}</div>
</div>
<button
onClick={() => openModal('userSettings')}
className="p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="User Settings"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<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" />
<div className="flex-1 overflow-y-auto pt-4 px-2 no-scrollbar">
<div
onClick={handleHomeClick}
className={`flex items-center gap-3 px-2 h-10 rounded-[4px] cursor-pointer mb-0.5 transition-colors group ${
!currentChannelId
? 'bg-discord-modifier-selected text-white'
: 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'
}`}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className={`${!currentChannelId ? 'text-white' : 'opacity-70 group-hover:opacity-100'}`}>
<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" />
</svg>
<span className="font-medium text-[16px]">Friends</span>
</div>
<div className="mt-[18px] px-2 mb-1 flex items-center justify-between group">
<span className="text-[12px] font-bold text-discord-text-muted uppercase tracking-wider">Direct Messages</span>
<button className="text-discord-text-muted hover:text-discord-text-primary transition-colors">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
</button>
</div>
<div className="space-y-[2px]">
{dmChannels.map((dm) => {
const otherUser = dm.members.find(m => m.id !== user?.id);
if (!otherUser) return null;
return (
<div
key={dm.id}
onClick={() => handleChannelClick(dm.id)}
className={`flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer transition-colors group ${
currentChannelId === dm.id
? 'bg-discord-modifier-selected text-white'
: 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'
}`}
>
<Avatar src={otherUser.avatar} name={otherUser.displayName ?? otherUser.username} size={32} status={otherUser.status as any} />
<div className="flex-1 min-w-0">
<div className={`text-[16px] font-medium truncate ${currentChannelId === dm.id ? 'text-white' : 'text-discord-text-muted group-hover:text-discord-text-secondary'}`}>
{otherUser.displayName ?? otherUser.username}
</div>
</div>
</div>
);
})}
{dmChannels.length === 0 && (
<p className="px-2 py-4 text-[13px] text-discord-text-muted italic opacity-60">No DM conversations yet.</p>
)}
</div>
</div>
{/* User area at bottom */}
{user && (
<div className="h-[52px] px-2 bg-[#232428] flex items-center gap-2 select-none">
<div className="p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group">
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} user={user} />
<div className="flex-1 min-w-0">
<div className="text-[14px] font-bold text-discord-text-primary truncate leading-tight">{user.displayName ?? user.username}</div>
<div className="text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary">@{user.username}</div>
</div>
</div>
<div className="flex items-center">
<UserAreaButton title="Mute">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg>
</UserAreaButton>
<UserAreaButton title="Deafen">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
</svg>
</UserAreaButton>
<UserAreaButton title="User Settings" onClick={() => openModal('userSettings')}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</UserAreaButton>
</div>
</div>
)}
</div>
);
}
return (
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0">
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none">
{/* Server header */}
<button
onClick={() => openModal('serverSettings')}
className="h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors"
className="h-12 px-4 flex items-center justify-between shadow-header z-10 hover:bg-discord-modifier-hover transition-colors group"
>
<span className="font-bold text-discord-text-primary truncate">{server.name}</span>
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor" className="text-discord-text-muted flex-shrink-0">
<span className="font-bold text-[16px] text-discord-text-primary truncate leading-tight">{server.name}</span>
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor" className="text-discord-text-muted flex-shrink-0 group-hover:text-discord-text-secondary">
<path d="M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" />
</svg>
</button>
{/* Channels */}
<div className="flex-1 overflow-y-auto p-2 space-y-4">
<div className="flex-1 overflow-y-auto pt-3 px-2 space-y-[21px] no-scrollbar">
{/* Text Channels */}
{textChannels.length > 0 && (
<div>
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-xs font-bold text-discord-text-muted uppercase tracking-wide">Text Channels</span>
{isAdminUser && (
<button
onClick={() => openModal('createChannel')}
className="text-discord-text-muted hover:text-discord-text-secondary transition-colors"
title="Create Channel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
</button>
)}
<div>
<div className="flex items-center justify-between px-1 mb-1 group cursor-pointer">
<div className="flex items-center gap-0.5 text-discord-text-muted hover:text-discord-text-secondary transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="opacity-70">
<path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" />
</svg>
<span className="text-[12px] font-bold uppercase tracking-wider">Text Channels</span>
</div>
{isAdminUser && (
<button
onClick={(e) => {
e.stopPropagation();
openModal('createChannel');
}}
className="text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="Create Channel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
</button>
)}
</div>
<div className="space-y-[2px]">
{textChannels.map((channel) => (
<button
key={channel.id}
onClick={() => handleChannelClick(channel.id)}
className={`w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm group ${
className={`w-full flex items-center gap-1.5 px-2 h-8 rounded-[4px] group transition-colors ${
currentChannelId === channel.id
? 'bg-discord-bg-active text-white'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
? 'bg-discord-modifier-selected text-white'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'
}`}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-60">
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
</svg>
<span className="truncate">{channel.name}</span>
<span className="truncate font-medium text-[16px]">{channel.name}</span>
</button>
))}
</div>
)}
</div>
{/* Voice Channels */}
{voiceChannels.length > 0 && (
<div>
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-xs font-bold text-discord-text-muted uppercase tracking-wide">Voice Channels</span>
{isAdminUser && (
<button
onClick={() => openModal('createChannel')}
className="text-discord-text-muted hover:text-discord-text-secondary transition-colors"
title="Create Channel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
</button>
)}
<div>
<div className="flex items-center justify-between px-1 mb-1 group cursor-pointer">
<div className="flex items-center gap-0.5 text-discord-text-muted hover:text-discord-text-secondary transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="opacity-70">
<path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" />
</svg>
<span className="text-[12px] font-bold uppercase tracking-wider">Voice Channels</span>
</div>
{isAdminUser && (
<button
onClick={(e) => {
e.stopPropagation();
openModal('createChannel');
}}
className="text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="Create Channel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
</button>
)}
</div>
<div className="space-y-[2px]">
{voiceChannels.map((channel) => (
<VoiceChannel
key={channel.id}
@@ -154,49 +246,75 @@ export function ChannelSidebar() {
/>
))}
</div>
)}
</div>
{/* Invite button */}
<button
onClick={() => openModal('invite')}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 2.5a.5.5 0 00-.5-.5h-6a.5.5 0 000 1h4.793L2.146 13.146a.5.5 0 00.708.708L13 3.707V8.5a.5.5 0 001 0v-6z" />
</svg>
Invite People
</button>
{/* Restore Invite Button */}
<div className="pt-2">
<button
onClick={() => openModal('invite')}
className="w-full flex items-center gap-2 px-2 h-8 rounded-[4px] text-[15px] font-medium text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover transition-colors"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="opacity-60">
<path d="M13 13v5h-2v-5H6v-2h5V6h2v5h5v2h-5z" />
</svg>
Invite People
</button>
</div>
</div>
{/* Voice controls */}
{currentVoiceChannelId && (
<VoiceControls
onDisconnect={handleVoiceDisconnect}
onToggleMic={() => {}}
onToggleCamera={() => {}}
onToggleScreenShare={() => {}}
onToggleMic={onToggleMic}
onToggleCamera={onToggleCamera}
onToggleScreenShare={onToggleScreenShare}
/>
)}
{/* User area */}
{user && (
<div className="h-[52px] px-2 bg-discord-bg-members flex items-center gap-2">
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.displayName ?? user.username}</div>
<div className="text-[10px] text-discord-text-muted truncate">@{user.username}</div>
<div className="h-[52px] px-2 bg-[#232428] flex items-center gap-2 select-none">
<div className="p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group">
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} user={user} />
<div className="flex-1 min-w-0">
<div className="text-[14px] font-bold text-discord-text-primary truncate leading-tight">{user.displayName ?? user.username}</div>
<div className="text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary">@{user.username}</div>
</div>
</div>
<div className="flex items-center">
<UserAreaButton title="Mute">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg>
</UserAreaButton>
<UserAreaButton title="Deafen">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
</svg>
</UserAreaButton>
<UserAreaButton title="User Settings" onClick={() => openModal('userSettings')}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</UserAreaButton>
</div>
<button
onClick={() => openModal('userSettings')}
className="p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="User Settings"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
</div>
)}
</div>
);
}
function UserAreaButton({ children, title, onClick }: { children: React.ReactNode, title: string, onClick?: () => void }) {
return (
<button
onClick={onClick}
className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover rounded-[4px] transition-all"
title={title}
>
{children}
</button>
);
}
@@ -6,6 +6,7 @@ import { MessageList } from '../chat/MessageList';
import { MessageInput } from '../chat/MessageInput';
import { TypingIndicator } from '../chat/TypingIndicator';
import { VoiceGrid } from '../voice/VoiceGrid';
import { FriendsPage } from '../chat/FriendsPage';
import { useVoiceStore } from '../../stores/voiceStore';
export function MainContent() {
const channels = useServerStore((s) => s.channels);
@@ -13,13 +14,16 @@ export function MainContent() {
const currentServerId = useServerStore((s) => s.currentServerId);
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants);
const showDms = useUIStore((s) => s.showDms);
const channel = channels.find(c => c.id === currentChannelId);
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
// DM view or no server selected
if (showDms || !currentServerId) {
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary shadow-sm", children: _jsx("span", { className: "font-bold text-discord-text-primary", children: "Home" }) }), _jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsxs("div", { className: "text-center", children: [_jsx("h2", { className: "text-2xl font-bold text-discord-text-primary mb-2", children: "Welcome to Opencord!" }), _jsx("p", { children: "Select a server from the sidebar or start a direct message." })] }) })] }));
if (!currentChannelId) {
return _jsx(FriendsPage, {});
}
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [_jsx("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary/50 shadow-sm flex-shrink-0 z-10", children: _jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("span", { className: "text-discord-text-muted font-bold text-lg", children: "@" }), _jsx("span", { className: "font-bold text-discord-text-primary truncate", children: "Direct Message" })] }) }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: "Direct Message" })] }));
}
// No channel selected
if (!currentChannelId || !channel) {
@@ -27,8 +31,8 @@ export function MainContent() {
}
// Voice/Video channel view
if (isVoiceChannel) {
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: channel.name })] }) }), _jsx(VoiceGrid, { participants: [] })] }));
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: channel.name })] }) }), _jsx(VoiceGrid, { participants: participants })] }));
}
// Text channel view
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0", children: [_jsxs("div", { className: "h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm flex-shrink-0", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary truncate", children: channel.name }), channel.topic && (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-px h-6 bg-discord-bg-hover mx-1" }), _jsx("span", { className: "text-xs text-discord-text-muted truncate", children: channel.topic })] }))] }), _jsx("div", { className: "flex items-center gap-2 flex-shrink-0", children: _jsx("button", { onClick: toggleMemberList, className: `p-1 transition-colors ${memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: "Toggle Member List", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }) }) })] }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: channel.name })] }));
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [_jsxs("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary truncate leading-tight", children: channel.name }), channel.topic && (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsx("span", { className: "text-xs text-discord-text-muted truncate leading-tight", children: channel.topic })] }))] }), _jsx("div", { className: "flex items-center gap-4 flex-shrink-0", children: _jsx("button", { onClick: toggleMemberList, className: `p-1 transition-colors ${memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'}`, title: "Toggle Member List", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }) }) })] }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: channel.name })] }));
}
@@ -6,6 +6,7 @@ import { MessageList } from '../chat/MessageList';
import { MessageInput } from '../chat/MessageInput';
import { TypingIndicator } from '../chat/TypingIndicator';
import { VoiceGrid } from '../voice/VoiceGrid';
import { FriendsPage } from '../chat/FriendsPage';
import { useVoiceStore } from '../../stores/voiceStore';
export function MainContent() {
@@ -14,7 +15,7 @@ export function MainContent() {
const currentServerId = useServerStore((s) => s.currentServerId);
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants);
const showDms = useUIStore((s) => s.showDms);
const channel = channels.find(c => c.id === currentChannelId);
@@ -22,17 +23,21 @@ export function MainContent() {
// DM view or no server selected
if (showDms || !currentServerId) {
if (!currentChannelId) {
return <FriendsPage />;
}
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center border-b border-discord-bg-tertiary shadow-sm">
<span className="font-bold text-discord-text-primary">Home</span>
</div>
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
<div className="text-center">
<h2 className="text-2xl font-bold text-discord-text-primary mb-2">Welcome to Opencord!</h2>
<p>Select a server from the sidebar or start a direct message.</p>
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative">
<div className="h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10">
<div className="flex items-center gap-2 min-w-0">
<span className="text-discord-text-muted font-bold text-lg">@</span>
<span className="font-bold text-discord-text-primary truncate">Direct Message</span>
</div>
</div>
<MessageList channelId={currentChannelId} />
<TypingIndicator channelId={currentChannelId} />
<MessageInput channelId={currentChannelId} channelName="Direct Message" />
</div>
);
}
@@ -41,7 +46,7 @@ export function MainContent() {
if (!currentChannelId || !channel) {
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center border-b border-discord-bg-tertiary shadow-sm">
<div className="h-12 px-4 flex items-center shadow-header">
<span className="text-discord-text-muted">Select a channel</span>
</div>
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
@@ -55,7 +60,7 @@ export function MainContent() {
if (isVoiceChannel) {
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm">
<div className="h-12 px-4 flex items-center justify-between shadow-header">
<div className="flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" />
@@ -63,33 +68,33 @@ export function MainContent() {
<span className="font-bold text-discord-text-primary">{channel.name}</span>
</div>
</div>
<VoiceGrid participants={[]} />
<VoiceGrid participants={participants} />
</div>
);
}
// Text channel view
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0">
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative">
{/* Channel header */}
<div className="h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm flex-shrink-0">
<div className="h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 z-10 bg-discord-bg-primary">
<div className="flex items-center gap-2 min-w-0">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0">
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
</svg>
<span className="font-bold text-discord-text-primary truncate">{channel.name}</span>
<span className="font-bold text-discord-text-primary truncate leading-tight">{channel.name}</span>
{channel.topic && (
<>
<div className="w-px h-6 bg-discord-bg-hover mx-1" />
<span className="text-xs text-discord-text-muted truncate">{channel.topic}</span>
<div className="w-[1px] h-6 bg-discord-bg-accent mx-2" />
<span className="text-xs text-discord-text-muted truncate leading-tight">{channel.topic}</span>
</>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-4 flex-shrink-0">
<button
onClick={toggleMemberList}
className={`p-1 transition-colors ${
memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-primary'
memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'
}`}
title="Toggle Member List"
>
@@ -5,6 +5,7 @@ import { Avatar } from '../ui/Avatar';
export function MemberSidebar() {
const members = useServerStore((s) => s.members);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const openUserProfile = useUIStore((s) => s.openUserProfile);
if (!memberListOpen)
return null;
const onlineMembers = members.filter(m => m.user.status !== 'offline');
@@ -14,11 +15,26 @@ export function MemberSidebar() {
admin: 'text-discord-blurple',
member: 'text-discord-text-primary',
};
return (_jsx("div", { className: "w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto", children: _jsxs("div", { className: "p-3", children: [onlineMembers.length > 0 && (_jsxs("div", { className: "mb-4", children: [_jsxs("h3", { className: "text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1", children: ["Online \u2014 ", onlineMembers.length] }), onlineMembers.map((member) => {
const getMemberColor = (member) => {
if (member.roles && member.roles.length > 0) {
// Return the color of the first role (already sorted by position)
return { color: member.roles[0].color };
}
return undefined;
};
const handleMemberClick = (e, user) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
openUserProfile(user, {
top: Math.min(rect.top, window.innerHeight - 450),
left: rect.left - 316, // Open to the left of member sidebar
});
};
return (_jsx("div", { className: "w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto select-none no-scrollbar", children: _jsxs("div", { className: "p-3", children: [onlineMembers.length > 0 && (_jsxs("div", { className: "mb-4", children: [_jsxs("h3", { className: "text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1", children: ["Online \u2014 ", onlineMembers.length] }), onlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { className: "flex items-center gap-3 px-2 py-1.5 rounded hover:bg-discord-bg-hover cursor-pointer group", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: member.user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: `text-sm font-medium truncate ${roleColors[member.role] ?? 'text-discord-text-primary'}`, children: displayName }), member.user.customStatus && (_jsx("div", { className: "text-xs text-discord-text-muted truncate", children: member.user.customStatus }))] })] }, member.userId));
return (_jsxs("div", { onClick: (e) => handleMemberClick(e, member.user), className: "flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: member.user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: `text-[15px] font-medium truncate ${!getMemberColor(member) ? (roleColors[member.role] ?? 'text-discord-text-primary') : ''}`, style: getMemberColor(member), children: displayName }), member.user.customStatus && (_jsx("div", { className: "text-[12px] text-discord-text-muted truncate", children: member.user.customStatus }))] })] }, member.userId));
})] })), offlineMembers.length > 0 && (_jsxs("div", { children: [_jsxs("h3", { className: "text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1", children: ["Offline \u2014 ", offlineMembers.length] }), offlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { className: "flex items-center gap-3 px-2 py-1.5 rounded hover:bg-discord-bg-hover cursor-pointer group opacity-50", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: "offline" }), _jsx("div", { className: "flex-1 min-w-0", children: _jsx("div", { className: "text-sm font-medium truncate text-discord-text-muted", children: displayName }) })] }, member.userId));
return (_jsxs("div", { onClick: (e) => handleMemberClick(e, member.user), className: "flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: "offline", className: "opacity-60" }), _jsx("div", { className: "flex-1 min-w-0", children: _jsx("div", { className: "text-[15px] font-medium truncate text-discord-text-muted", children: displayName }) })] }, member.userId));
})] }))] }) }));
}
@@ -1,4 +1,5 @@
import React from 'react';
import type { MemberWithUser } from '@opencord/shared';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
@@ -6,6 +7,7 @@ import { Avatar } from '../ui/Avatar';
export function MemberSidebar() {
const members = useServerStore((s) => s.members);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const openUserProfile = useUIStore((s) => s.openUserProfile);
if (!memberListOpen) return null;
@@ -18,8 +20,25 @@ export function MemberSidebar() {
member: 'text-discord-text-primary',
};
const getMemberColor = (member: MemberWithUser) => {
if (member.roles && member.roles.length > 0) {
// Return the color of the first role (already sorted by position)
return { color: member.roles[0]!.color };
}
return undefined;
};
const handleMemberClick = (e: React.MouseEvent, user: any) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
openUserProfile(user, {
top: Math.min(rect.top, window.innerHeight - 450),
left: rect.left - 316, // Open to the left of member sidebar
});
};
return (
<div className="w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto">
<div className="w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto select-none no-scrollbar">
<div className="p-3">
{/* Online */}
{onlineMembers.length > 0 && (
@@ -32,7 +51,8 @@ export function MemberSidebar() {
return (
<div
key={member.userId}
className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-discord-bg-hover cursor-pointer group"
onClick={(e) => handleMemberClick(e, member.user)}
className="flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors"
>
<Avatar
src={member.user.avatar}
@@ -41,11 +61,14 @@ export function MemberSidebar() {
status={member.user.status}
/>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${roleColors[member.role] ?? 'text-discord-text-primary'}`}>
<div
className={`text-[15px] font-medium truncate ${!getMemberColor(member) ? (roleColors[member.role] ?? 'text-discord-text-primary') : ''}`}
style={getMemberColor(member)}
>
{displayName}
</div>
{member.user.customStatus && (
<div className="text-xs text-discord-text-muted truncate">{member.user.customStatus}</div>
<div className="text-[12px] text-discord-text-muted truncate">{member.user.customStatus}</div>
)}
</div>
</div>
@@ -65,16 +88,18 @@ export function MemberSidebar() {
return (
<div
key={member.userId}
className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-discord-bg-hover cursor-pointer group opacity-50"
onClick={(e) => handleMemberClick(e, member.user)}
className="flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors"
>
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status="offline"
className="opacity-60"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate text-discord-text-muted">
<div className="text-[15px] font-medium truncate text-discord-text-muted">
{displayName}
</div>
</div>
@@ -1,8 +1,31 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Tooltip } from '../ui/Tooltip';
function SidebarItem({ name, icon, active, onClick, type = 'server', actionType }) {
const [isHovered, setIsHovered] = useState(false);
const firstLetter = name.charAt(0).toUpperCase();
const getPillHeight = () => {
if (active)
return 'h-10';
if (isHovered)
return 'h-5';
return 'h-2 scale-0';
};
const getButtonClasses = () => {
const base = 'w-12 h-12 flex items-center justify-center transition-all duration-200 overflow-hidden relative group';
if (type === 'dm') {
return `${base} ${active ? 'bg-discord-blurple rounded-[16px] text-white' : 'bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:bg-discord-blurple hover:text-white'}`;
}
if (type === 'action') {
return `${base} bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-green hover:bg-discord-green hover:text-white`;
}
return `${base} ${active ? 'bg-discord-blurple rounded-[16px] text-white' : 'bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:bg-discord-blurple hover:text-white'}`;
};
return (_jsxs("div", { className: "relative flex items-center mb-2 w-full justify-center", onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [(type === 'server' || type === 'dm') && (_jsx("div", { className: "absolute -left-0 w-2 h-12 flex items-center", children: _jsx("div", { className: `bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1` }) })), _jsx(Tooltip, { content: name, position: "right", children: _jsx("button", { onClick: onClick, className: getButtonClasses(), children: type === 'dm' ? (_jsx("svg", { width: "28", height: "20", viewBox: "0 0 28 20", fill: "currentColor", children: _jsx("path", { d: "M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z", transform: "scale(0.85) translate(0, 0)" }) })) : type === 'action' ? (actionType === 'add' ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) })) : (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" }) }))) : icon ? (_jsx("img", { src: icon.startsWith('http') ? icon : `/api/uploads/${icon}`, alt: name, className: "w-full h-full object-cover" })) : (_jsx("span", { className: "text-[16px] font-medium", children: firstLetter })) }) })] }));
}
export function ServerSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
@@ -21,13 +44,5 @@ export function ServerSidebar() {
setCurrentServer(null);
navigate('/channels/@me');
};
return (_jsxs("div", { className: "w-[72px] bg-discord-bg-tertiary flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 gap-2", children: [_jsx(Tooltip, { content: "Direct Messages", position: "right", children: _jsx("button", { onClick: handleDmClick, className: `w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 flex items-center justify-center ${showDms
? 'bg-discord-blurple rounded-[16px]'
: 'bg-discord-bg-primary hover:bg-discord-blurple'}`, children: _jsx("svg", { width: "28", height: "20", viewBox: "0 0 28 20", fill: "white", children: _jsx("path", { d: "M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z", transform: "scale(0.85) translate(0, 0)" }) }) }) }), _jsx("div", { className: "w-8 h-0.5 bg-discord-bg-primary rounded-full" }), servers.map((server) => {
const isActive = currentServerId === server.id;
const firstLetter = server.name.charAt(0).toUpperCase();
return (_jsxs("div", { className: "relative", children: [isActive && (_jsx("div", { className: "absolute -left-1 top-1/2 -translate-y-1/2 w-1 h-10 bg-white rounded-r-full" })), _jsx(Tooltip, { content: server.name, position: "right", children: _jsx("button", { onClick: () => handleServerClick(server.id), className: `w-12 h-12 transition-all duration-200 flex items-center justify-center text-lg font-semibold ${isActive
? 'bg-discord-blurple rounded-[16px] text-white'
: 'bg-discord-bg-primary hover:bg-discord-blurple rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:text-white'}`, children: server.icon ? (_jsx("img", { src: server.icon.startsWith('http') ? server.icon : `/api/uploads/${server.icon}`, alt: server.name, className: "w-full h-full rounded-inherit object-cover" })) : (firstLetter) }) })] }, server.id));
}), _jsx(Tooltip, { content: "Add a Server", position: "right", children: _jsx("button", { onClick: () => openModal('createServer'), className: "w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 bg-discord-bg-primary hover:bg-discord-green text-discord-green hover:text-white flex items-center justify-center", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) }) }) }), _jsx(Tooltip, { content: "Join a Server", position: "right", children: _jsx("button", { onClick: () => openModal('joinServer'), className: "w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 bg-discord-bg-primary hover:bg-discord-green text-discord-green hover:text-white flex items-center justify-center", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" }) }) }) })] }));
return (_jsxs("nav", { className: "w-[72px] bg-discord-bg-tertiary flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none", children: [_jsx(SidebarItem, { id: "@me", name: "Direct Messages", active: showDms, onClick: handleDmClick, type: "dm" }), _jsx("div", { className: "w-8 h-[2px] bg-discord-modifier-accent rounded-full mb-2" }), servers.map((server) => (_jsx(SidebarItem, { id: server.id, name: server.name, icon: server.icon, active: currentServerId === server.id, onClick: () => handleServerClick(server.id) }, server.id))), _jsx(SidebarItem, { id: "add-server", name: "Add a Server", active: false, onClick: () => openModal('createServer'), type: "action", actionType: "add" }), _jsx(SidebarItem, { id: "join-server", name: "Join a Server", active: false, onClick: () => openModal('joinServer'), type: "action", actionType: "join" })] }));
}
@@ -1,9 +1,89 @@
import React from 'react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Tooltip } from '../ui/Tooltip';
interface SidebarItemProps {
id: string;
name: string;
icon?: string | null;
active: boolean;
onClick: () => void;
type?: 'server' | 'dm' | 'action';
actionType?: 'add' | 'join';
}
function SidebarItem({ name, icon, active, onClick, type = 'server', actionType }: SidebarItemProps) {
const [isHovered, setIsHovered] = useState(false);
const firstLetter = name.charAt(0).toUpperCase();
const getPillHeight = () => {
if (active) return 'h-10';
if (isHovered) return 'h-5';
return 'h-2 scale-0';
};
const getButtonClasses = () => {
const base = 'w-12 h-12 flex items-center justify-center transition-all duration-200 overflow-hidden relative group';
if (type === 'dm') {
return `${base} ${active ? 'bg-discord-blurple rounded-[16px] text-white' : 'bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:bg-discord-blurple hover:text-white'}`;
}
if (type === 'action') {
return `${base} bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-green hover:bg-discord-green hover:text-white`;
}
return `${base} ${active ? 'bg-discord-blurple rounded-[16px] text-white' : 'bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:bg-discord-blurple hover:text-white'}`;
};
return (
<div
className="relative flex items-center mb-2 w-full justify-center"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Pill Indicator */}
{(type === 'server' || type === 'dm') && (
<div className="absolute -left-0 w-2 h-12 flex items-center">
<div
className={`bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1`}
/>
</div>
)}
<Tooltip content={name} position="right">
<button onClick={onClick} className={getButtonClasses()}>
{type === 'dm' ? (
<svg width="28" height="20" viewBox="0 0 28 20" fill="currentColor">
<path d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z" transform="scale(0.85) translate(0, 0)" />
</svg>
) : type === 'action' ? (
actionType === 'add' ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" />
</svg>
)
) : icon ? (
<img
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
alt={name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-[16px] font-medium">{firstLetter}</span>
)}
</button>
</Tooltip>
</div>
);
}
export function ServerSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
@@ -26,83 +106,45 @@ export function ServerSidebar() {
};
return (
<div className="w-[72px] bg-discord-bg-tertiary flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 gap-2">
{/* DM Button */}
<Tooltip content="Direct Messages" position="right">
<button
onClick={handleDmClick}
className={`w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 flex items-center justify-center ${
showDms
? 'bg-discord-blurple rounded-[16px]'
: 'bg-discord-bg-primary hover:bg-discord-blurple'
}`}
>
<svg width="28" height="20" viewBox="0 0 28 20" fill="white">
<path d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z" transform="scale(0.85) translate(0, 0)" />
</svg>
</button>
</Tooltip>
<nav className="w-[72px] bg-discord-bg-tertiary flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none">
<SidebarItem
id="@me"
name="Direct Messages"
active={showDms}
onClick={handleDmClick}
type="dm"
/>
{/* Divider */}
<div className="w-8 h-0.5 bg-discord-bg-primary rounded-full" />
<div className="w-8 h-[2px] bg-discord-modifier-accent rounded-full mb-2" />
{/* Server Icons */}
{servers.map((server) => {
const isActive = currentServerId === server.id;
const firstLetter = server.name.charAt(0).toUpperCase();
return (
<div key={server.id} className="relative">
{/* Active indicator */}
{isActive && (
<div className="absolute -left-1 top-1/2 -translate-y-1/2 w-1 h-10 bg-white rounded-r-full" />
)}
<Tooltip content={server.name} position="right">
<button
onClick={() => handleServerClick(server.id)}
className={`w-12 h-12 transition-all duration-200 flex items-center justify-center text-lg font-semibold ${
isActive
? 'bg-discord-blurple rounded-[16px] text-white'
: 'bg-discord-bg-primary hover:bg-discord-blurple rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:text-white'
}`}
>
{server.icon ? (
<img
src={server.icon.startsWith('http') ? server.icon : `/api/uploads/${server.icon}`}
alt={server.name}
className="w-full h-full rounded-inherit object-cover"
/>
) : (
firstLetter
)}
</button>
</Tooltip>
</div>
);
})}
{servers.map((server) => (
<SidebarItem
key={server.id}
id={server.id}
name={server.name}
icon={server.icon}
active={currentServerId === server.id}
onClick={() => handleServerClick(server.id)}
/>
))}
{/* Add Server Button */}
<Tooltip content="Add a Server" position="right">
<button
onClick={() => openModal('createServer')}
className="w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 bg-discord-bg-primary hover:bg-discord-green text-discord-green hover:text-white flex items-center justify-center"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
</button>
</Tooltip>
<SidebarItem
id="add-server"
name="Add a Server"
active={false}
onClick={() => openModal('createServer')}
type="action"
actionType="add"
/>
{/* Join Server Button */}
<Tooltip content="Join a Server" position="right">
<button
onClick={() => openModal('joinServer')}
className="w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 bg-discord-bg-primary hover:bg-discord-green text-discord-green hover:text-white flex items-center justify-center"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" />
</svg>
</button>
</Tooltip>
</div>
<SidebarItem
id="join-server"
name="Join a Server"
active={false}
onClick={() => openModal('joinServer')}
type="action"
actionType="join"
/>
</nav>
);
}
@@ -12,6 +12,7 @@ export function InviteModal() {
const generateInvite = useServerStore((s) => s.generateInvite);
const currentServerId = useServerStore((s) => s.currentServerId);
const isOpen = activeModal === 'invite';
const inviteUrl = inviteCode ? `${window.location.origin}/join/${inviteCode}` : '';
useEffect(() => {
if (isOpen && currentServerId) {
setIsLoading(true);
@@ -24,8 +25,10 @@ export function InviteModal() {
}
}, [isOpen, currentServerId, generateInvite]);
const handleCopy = async () => {
if (!inviteUrl)
return;
try {
await navigator.clipboard.writeText(inviteCode);
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
@@ -40,7 +43,7 @@ export function InviteModal() {
}
}
};
return (_jsxs(Modal, { isOpen: isOpen, onClose: closeModal, title: "Invite Friends", children: [_jsx("p", { className: "text-discord-text-secondary text-sm mb-4", children: "Share this invite code with friends to let them join your server." }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "text", value: isLoading ? 'Generating...' : inviteCode, readOnly: true, className: "invite-code-input flex-1 px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none font-mono text-sm" }), _jsx("button", { onClick: handleCopy, disabled: isLoading, className: `px-4 py-2 text-sm font-medium rounded transition-colors ${copied
return (_jsxs(Modal, { isOpen: isOpen, onClose: closeModal, title: "Invite Friends", children: [_jsx("p", { className: "text-discord-text-secondary text-sm mb-4", children: "Share this invite link with friends to let them join your server." }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "text", value: isLoading ? 'Generating...' : inviteUrl, readOnly: true, className: "invite-code-input flex-1 px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none font-mono text-xs" }), _jsx("button", { onClick: handleCopy, disabled: isLoading || !inviteUrl, className: `px-4 py-2 text-sm font-medium rounded transition-colors ${copied
? 'bg-discord-green text-white'
: 'bg-discord-blurple hover:bg-discord-blurple-hover text-white'}`, children: copied ? 'Copied!' : 'Copy' })] })] }));
}
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InviteModal } from './InviteModal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
// Mock the stores by spying on their getState
beforeEach(() => {
// Reset stores to default state
useUIStore.setState({
activeModal: null,
modalData: {},
});
useServerStore.setState({
currentServerId: null,
servers: [],
});
});
describe('InviteModal', () => {
it('does not render when activeModal is not "invite"', () => {
useUIStore.setState({ activeModal: null });
render(<InviteModal />);
expect(screen.queryByText('Invite Friends')).not.toBeInTheDocument();
});
it('calls generateInvite and displays the invite URL when opened', async () => {
const mockGenerateInvite = vi.fn().mockResolvedValue('test-invite-code');
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
generateInvite: mockGenerateInvite,
});
render(<InviteModal />);
// Modal title should be visible
expect(screen.getByText('Invite Friends')).toBeInTheDocument();
// Should show "Generating..." initially
expect(screen.getByDisplayValue('Generating...')).toBeInTheDocument();
// Wait for the invite code to load
await waitFor(() => {
const input = screen.getByDisplayValue(/\/join\/test-invite-code/);
expect(input).toBeInTheDocument();
});
// generateInvite should have been called with the server ID
expect(mockGenerateInvite).toHaveBeenCalledWith('server-123');
});
it('displays an error when generateInvite fails', async () => {
const mockGenerateInvite = vi.fn().mockRejectedValue(new Error('Not authorized'));
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
generateInvite: mockGenerateInvite,
});
render(<InviteModal />);
await waitFor(() => {
expect(screen.getByText('Not authorized')).toBeInTheDocument();
});
});
it('Copy button is disabled while loading', () => {
const mockGenerateInvite = vi.fn().mockReturnValue(new Promise(() => {})); // never resolves
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
generateInvite: mockGenerateInvite,
});
render(<InviteModal />);
const copyButton = screen.getByText('Copy');
expect(copyButton).toBeDisabled();
});
it('Copy button calls clipboard.writeText with the invite URL', async () => {
const user = userEvent.setup();
const mockGenerateInvite = vi.fn().mockResolvedValue('abc123');
const mockClipboard = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: mockClipboard },
writable: true,
configurable: true,
});
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
generateInvite: mockGenerateInvite,
});
render(<InviteModal />);
// Wait for invite to load
await waitFor(() => {
expect(screen.getByDisplayValue(/\/join\/abc123/)).toBeInTheDocument();
});
// Click copy
const copyButton = screen.getByText('Copy');
await user.click(copyButton);
expect(mockClipboard).toHaveBeenCalledWith(expect.stringContaining('/join/abc123'));
// Button text should change to "Copied!"
expect(screen.getByText('Copied!')).toBeInTheDocument();
});
});
@@ -7,32 +7,38 @@ export function InviteModal() {
const [inviteCode, setInviteCode] = useState('');
const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const generateInvite = useServerStore((s) => s.generateInvite);
const currentServerId = useServerStore((s) => s.currentServerId);
const isOpen = activeModal === 'invite';
const inviteUrl = inviteCode ? `${window.location.origin}/join/${inviteCode}` : '';
useEffect(() => {
if (isOpen && currentServerId) {
setIsLoading(true);
setError('');
generateInvite(currentServerId)
.then(code => {
setInviteCode(code);
setIsLoading(false);
})
.catch(() => setIsLoading(false));
.catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to generate invite link');
setIsLoading(false);
});
}
}, [isOpen, currentServerId, generateInvite]);
const handleCopy = async () => {
if (!inviteUrl) return;
try {
await navigator.clipboard.writeText(inviteCode);
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: select the text
const input = document.querySelector<HTMLInputElement>('.invite-code-input');
if (input) {
input.select();
@@ -46,18 +52,23 @@ export function InviteModal() {
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends">
<p className="text-discord-text-secondary text-sm mb-4">
Share this invite code with friends to let them join your server.
Share this invite link with friends to let them join your server.
</p>
{error && (
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm">
{error}
</div>
)}
<div className="flex items-center gap-2">
<input
type="text"
value={isLoading ? 'Generating...' : inviteCode}
value={isLoading ? 'Generating...' : inviteUrl}
readOnly
className="invite-code-input flex-1 px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none font-mono text-sm"
className="invite-code-input flex-1 px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none font-mono text-xs"
/>
<button
onClick={handleCopy}
disabled={isLoading}
disabled={isLoading || !inviteUrl}
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
copied
? 'bg-discord-green text-white'
@@ -1,10 +1,11 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
export function JoinServerModal() {
const { inviteCode: urlInviteCode } = useParams();
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
@@ -13,6 +14,11 @@ export function JoinServerModal() {
const loadServers = useServerStore((s) => s.loadServers);
const navigate = useNavigate();
const isOpen = activeModal === 'joinServer';
useEffect(() => {
if (isOpen && urlInviteCode) {
setInviteCode(urlInviteCode);
}
}, [isOpen, urlInviteCode]);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
@@ -0,0 +1,109 @@
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 { JoinServerModal } from './JoinServer';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
beforeEach(() => {
mockNavigate.mockClear();
useUIStore.setState({ activeModal: null });
useServerStore.setState({
servers: [],
currentServerId: null,
});
});
function renderModal() {
return render(
<MemoryRouter>
<JoinServerModal />
</MemoryRouter>
);
}
describe('JoinServerModal', () => {
it('does not render when activeModal is not "joinServer"', () => {
useUIStore.setState({ activeModal: null });
renderModal();
expect(screen.queryByText('Join a Server')).not.toBeInTheDocument();
});
it('renders the form when opened', () => {
useUIStore.setState({ activeModal: 'joinServer' });
renderModal();
expect(screen.getByText('Join a Server')).toBeInTheDocument();
expect(screen.getByPlaceholderText('e.g. abc123')).toBeInTheDocument();
expect(screen.getByText('Join Server')).toBeInTheDocument();
});
it('shows validation error when submitting empty code', async () => {
const user = userEvent.setup();
useUIStore.setState({ activeModal: 'joinServer' });
renderModal();
const submitButton = screen.getByText('Join Server');
await user.click(submitButton);
expect(screen.getByText('Invite code is required')).toBeInTheDocument();
});
it('calls joinByCode with the entered invite code and navigates on success', async () => {
const user = userEvent.setup();
const mockJoinByCode = vi.fn().mockResolvedValue({ id: 'new-server-id', name: 'Test Server' });
useUIStore.setState({ activeModal: 'joinServer' });
useServerStore.setState({ joinByCode: mockJoinByCode });
renderModal();
// Type invite code
const input = screen.getByPlaceholderText('e.g. abc123');
await user.type(input, 'my-invite-code');
// Click join
const submitButton = screen.getByText('Join Server');
await user.click(submitButton);
// joinByCode should be called with the code
await waitFor(() => {
expect(mockJoinByCode).toHaveBeenCalledWith('my-invite-code');
});
// Should navigate to the new server
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/channels/new-server-id');
});
// Modal should close (activeModal becomes null)
expect(useUIStore.getState().activeModal).toBeNull();
});
it('shows error message when joinByCode fails', async () => {
const user = userEvent.setup();
const mockJoinByCode = vi.fn().mockRejectedValue(new Error('Invalid invite code'));
useUIStore.setState({ activeModal: 'joinServer' });
useServerStore.setState({ joinByCode: mockJoinByCode });
renderModal();
const input = screen.getByPlaceholderText('e.g. abc123');
await user.type(input, 'bad-code');
const submitButton = screen.getByText('Join Server');
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Invalid invite code')).toBeInTheDocument();
});
});
});
@@ -1,48 +1,40 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
export function JoinServerModal() {
const { inviteCode: urlInviteCode } = useParams<{ inviteCode?: string }>();
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const loadServers = useServerStore((s) => s.loadServers);
const joinByCode = useServerStore((s) => s.joinByCode);
const navigate = useNavigate();
const isOpen = activeModal === 'joinServer';
useEffect(() => {
if (isOpen && urlInviteCode) {
setInviteCode(urlInviteCode);
}
}, [isOpen, urlInviteCode]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!inviteCode.trim()) {
const code = inviteCode.trim();
if (!code) {
setError('Invite code is required');
return;
}
setIsLoading(true);
try {
const token = localStorage.getItem('opencord_token');
const response = await fetch('/api/servers/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ inviteCode: inviteCode.trim() }),
});
if (!response.ok) {
const data = await response.json() as { error: string };
throw new Error(data.error || 'Failed to join server');
}
const server = await response.json() as { id: string };
await loadServers();
const server = await joinByCode(code);
closeModal();
setInviteCode('');
navigate(`/channels/${server.id}`);
@@ -60,7 +52,7 @@ export function JoinServerModal() {
Enter an invite code to join an existing server.
</p>
{error && (
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm">
{error}
</div>
)}
@@ -12,6 +12,7 @@ export function UserSettingsModal() {
const logout = useAuthStore((s) => s.logout);
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
const [status, setStatus] = useState(user?.status ?? 'online');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
@@ -24,6 +25,7 @@ export function UserSettingsModal() {
await updateProfile({
displayName: displayName.trim() || undefined,
customStatus: customStatus.trim() || undefined,
status: status,
});
setSuccess('Profile updated!');
setTimeout(() => setSuccess(''), 2000);
@@ -41,5 +43,5 @@ export function UserSettingsModal() {
};
if (!user)
return null;
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "User Settings", maxWidth: "max-w-lg", children: _jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center gap-4 p-4 bg-discord-bg-secondary rounded-lg", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 64, status: user.status }), _jsxs("div", { children: [_jsx("div", { className: "font-bold text-lg", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-discord-text-muted text-sm", children: ["@", user.username] }), user.customStatus && (_jsx("div", { className: "text-discord-text-secondary text-sm mt-1", children: user.customStatus }))] })] }), error && (_jsx("div", { className: "p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), success && (_jsx("div", { className: "p-2 bg-discord-green/10 border border-discord-green/30 rounded text-discord-green text-sm", children: success })), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Display Name" }), _jsx("input", { type: "text", value: displayName, onChange: (e) => setDisplayName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple" })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Custom Status" }), _jsx("input", { type: "text", value: customStatus, onChange: (e) => setCustomStatus(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "What are you up to?" })] }), _jsxs("div", { className: "flex items-center justify-between pt-2", children: [_jsx("button", { onClick: handleLogout, className: "px-4 py-2 text-sm text-discord-red hover:bg-discord-red/10 rounded transition-colors", children: "Log Out" }), _jsx("button", { onClick: handleSave, disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Saving...' : 'Save Changes' })] })] }) }));
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "User Settings", maxWidth: "max-w-lg", children: _jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center gap-4 p-4 bg-discord-bg-secondary rounded-lg", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 64, status: user.status }), _jsxs("div", { children: [_jsx("div", { className: "font-bold text-lg", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-discord-text-muted text-sm", children: ["@", user.username] }), user.customStatus && (_jsx("div", { className: "text-discord-text-secondary text-sm mt-1", children: user.customStatus }))] })] }), error && (_jsx("div", { className: "p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), success && (_jsx("div", { className: "p-2 bg-discord-green/10 border border-discord-green/30 rounded text-discord-green text-sm", children: success })), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Status" }), _jsxs("select", { value: status, onChange: (e) => setStatus(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple appearance-none", children: [_jsx("option", { value: "online", children: "Online" }), _jsx("option", { value: "idle", children: "Idle" }), _jsx("option", { value: "dnd", children: "Do Not Disturb" })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Display Name" }), _jsx("input", { type: "text", value: displayName, onChange: (e) => setDisplayName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple" })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Custom Status" }), _jsx("input", { type: "text", value: customStatus, onChange: (e) => setCustomStatus(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "What are you up to?" })] }), _jsxs("div", { className: "flex items-center justify-between pt-2", children: [_jsx("button", { onClick: handleLogout, className: "px-4 py-2 text-sm text-discord-red hover:bg-discord-red/10 rounded transition-colors", children: "Log Out" }), _jsx("button", { onClick: handleSave, disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Saving...' : 'Save Changes' })] })] }) }));
}
@@ -13,6 +13,7 @@ export function UserSettingsModal() {
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
const [status, setStatus] = useState(user?.status ?? 'online');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
@@ -27,7 +28,8 @@ export function UserSettingsModal() {
await updateProfile({
displayName: displayName.trim() || undefined,
customStatus: customStatus.trim() || undefined,
});
status: status as any,
} as any);
setSuccess('Profile updated!');
setTimeout(() => setSuccess(''), 2000);
} catch (err) {
@@ -71,6 +73,21 @@ export function UserSettingsModal() {
<div className="p-2 bg-discord-green/10 border border-discord-green/30 rounded text-discord-green text-sm">{success}</div>
)}
<div>
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as any)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple appearance-none"
>
<option value="online">Online</option>
<option value="idle">Idle</option>
<option value="dnd">Do Not Disturb</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Display Name
+20 -5
View File
@@ -1,14 +1,29 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useUIStore } from '../../stores/uiStore';
const statusColors = {
online: 'bg-discord-green',
idle: 'bg-discord-yellow',
dnd: 'bg-discord-red',
offline: 'bg-gray-500',
};
export function Avatar({ src, name, size = 40, status, className = '', onClick }) {
export function Avatar({ src, name, size = 40, status, className = '', onClick, user }) {
const openUserProfile = useUIStore((s) => s.openUserProfile);
const initials = name.charAt(0).toUpperCase();
const fontSize = size < 32 ? 'text-xs' : size < 48 ? 'text-sm' : 'text-lg';
return (_jsxs("div", { className: `relative inline-flex flex-shrink-0 ${onClick ? 'cursor-pointer' : ''} ${className}`, style: { width: size, height: size }, onClick: onClick, children: [src ? (_jsx("img", { src: src.startsWith('http') ? src : `/api/uploads/${src}`, alt: name, className: "w-full h-full rounded-full object-cover", onError: (e) => {
const handleClick = (e) => {
if (onClick) {
onClick(e);
}
else if (user) {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
openUserProfile(user, {
top: Math.min(rect.top, window.innerHeight - 450),
left: rect.right + 16,
});
}
};
return (_jsxs("div", { className: `relative inline-flex flex-shrink-0 ${(onClick || user) ? 'cursor-pointer' : ''} ${className}`, style: { width: size, height: size }, onClick: handleClick, children: [src ? (_jsx("img", { src: src.startsWith('http') ? src : `/api/uploads/${src}`, alt: name, className: "w-full h-full rounded-full object-cover", onError: (e) => {
e.target.style.display = 'none';
const parent = e.target.parentElement;
if (parent) {
@@ -16,10 +31,10 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick }
if (fallback)
fallback.style.display = 'flex';
}
} })) : null, _jsx("div", { className: `avatar-fallback w-full h-full rounded-full bg-discord-blurple flex items-center justify-center ${fontSize} font-semibold text-white ${src ? 'hidden' : 'flex'}`, style: src ? { display: 'none' } : undefined, children: initials }), status && (_jsx("div", { className: `absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-discord-bg-secondary ${statusColors[status] ?? 'bg-gray-500'}`, style: {
} })) : null, _jsx("div", { className: `avatar-fallback w-full h-full rounded-full bg-discord-blurple flex items-center justify-center ${fontSize} font-semibold text-white ${src ? 'hidden' : 'flex'}`, style: src ? { display: 'none' } : undefined, children: initials }), status && (_jsx("div", { className: `absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-[#2b2d31] ${statusColors[status] ?? 'bg-gray-500'}`, style: {
width: size * 0.35,
height: size * 0.35,
minWidth: 10,
minHeight: 10,
minWidth: 12,
minHeight: 12,
} }))] }));
}
+25 -8
View File
@@ -1,4 +1,6 @@
import React from 'react';
import type { User } from '@opencord/shared';
import { useUIStore } from '../../stores/uiStore';
interface AvatarProps {
src?: string | null;
@@ -6,25 +8,40 @@ interface AvatarProps {
size?: number;
status?: 'online' | 'idle' | 'dnd' | 'offline' | null;
className?: string;
onClick?: () => void;
onClick?: (e: React.MouseEvent) => void;
user?: User;
}
const statusColors: Record<string, string> = {
online: 'bg-discord-green',
idle: 'bg-discord-yellow',
dnd: 'bg-discord-red',
offline: 'bg-gray-500',
offline: 'bg-discord-text-muted',
};
export function Avatar({ src, name, size = 40, status, className = '', onClick }: AvatarProps) {
export function Avatar({ src, name, size = 40, status, className = '', onClick, user }: AvatarProps) {
const openUserProfile = useUIStore((s) => s.openUserProfile);
const initials = name.charAt(0).toUpperCase();
const fontSize = size < 32 ? 'text-xs' : size < 48 ? 'text-sm' : 'text-lg';
const handleClick = (e: React.MouseEvent) => {
if (onClick) {
onClick(e);
} else if (user) {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
openUserProfile(user, {
top: Math.min(rect.top, window.innerHeight - 450),
left: rect.right + 16,
});
}
};
return (
<div
className={`relative inline-flex flex-shrink-0 ${onClick ? 'cursor-pointer' : ''} ${className}`}
className={`relative inline-flex flex-shrink-0 ${(onClick || user) ? 'cursor-pointer' : ''} ${className}`}
style={{ width: size, height: size }}
onClick={onClick}
onClick={handleClick}
>
{src ? (
<img
@@ -49,12 +66,12 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick }
</div>
{status && (
<div
className={`absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-discord-bg-secondary ${statusColors[status] ?? 'bg-gray-500'}`}
className={`absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-[#2b2d31] ${statusColors[status] ?? 'bg-discord-text-muted'}`}
style={{
width: size * 0.35,
height: size * 0.35,
minWidth: 10,
minHeight: 10,
minWidth: 12,
minHeight: 12,
}}
/>
)}
@@ -62,7 +62,7 @@ export function ContextMenu({ items, children }: ContextMenuProps) {
{isOpen && (
<div
ref={menuRef}
className="fixed z-50 min-w-[180px] py-1.5 bg-[#111214] rounded-md shadow-xl border border-gray-800 animate-fade-in"
className="fixed z-50 min-w-[180px] py-1.5 bg-discord-bg-floating rounded-md shadow-elevation-high animate-fade-in"
style={{ left: position.x, top: position.y }}
>
{items.map((item, i) => (
+1 -1
View File
@@ -38,7 +38,7 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
{children}
{isVisible && (
<div
className={`absolute z-50 px-3 py-1.5 text-sm font-medium text-white bg-gray-900 rounded-md shadow-lg whitespace-nowrap pointer-events-none ${positionClasses[position]}`}
className={`absolute z-50 px-3 py-1.5 text-sm font-medium text-white bg-discord-bg-floating rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none ${positionClasses[position]}`}
>
{content}
</div>
@@ -0,0 +1,25 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useNavigate } from 'react-router-dom';
import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
export function UserProfilePopout({ user, onClose, position }) {
const navigate = useNavigate();
const addDmChannel = useServerStore((s) => s.addDmChannel);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const displayName = user.displayName ?? user.username;
const handleSendMessage = async () => {
try {
const channel = await api.dm.create({ userId: user.id });
addDmChannel(channel);
setCurrentChannel(channel.id);
onClose();
navigate(`/channels/@me/${channel.id}`);
}
catch (err) {
console.error('Failed to create DM channel:', err);
}
};
return (_jsxs("div", { className: "fixed z-50 w-[300px] bg-discord-bg-floating rounded-[8px] shadow-elevation-high overflow-hidden animate-fade-in select-none", style: position ? { top: position.top, left: position.left } : { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }, children: [_jsx("div", { className: "h-[60px] bg-discord-blurple" }), _jsxs("div", { className: "px-4 pb-4 relative", children: [_jsx("div", { className: "absolute -top-8 left-4 rounded-full border-[6px] border-discord-bg-floating bg-discord-bg-floating", children: _jsx(Avatar, { src: user.avatar, name: displayName, size: 80, status: user.status }) }), _jsxs("div", { className: "mt-12 bg-discord-bg-tertiary rounded-[8px] p-3", children: [_jsx("div", { className: "text-[20px] font-bold text-discord-text-header leading-tight mb-1", children: displayName }), _jsxs("div", { className: "text-[14px] text-discord-text-normal font-medium mb-3", children: ["@", user.username] }), _jsx("div", { className: "w-full h-[1px] bg-discord-modifier-accent mb-3" }), _jsxs("div", { className: "mb-3", children: [_jsx("div", { className: "text-[12px] font-bold text-discord-text-header uppercase mb-1", children: "Opencord Member Since" }), _jsx("div", { className: "text-[12px] text-discord-text-normal font-medium", children: new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) })] }), user.customStatus && (_jsxs("div", { className: "mb-3", children: [_jsx("div", { className: "text-[12px] font-bold text-discord-text-header uppercase mb-1", children: "Status" }), _jsx("div", { className: "text-[14px] text-discord-text-normal", children: user.customStatus })] }))] })] }), _jsx("div", { className: "px-4 pb-4", children: _jsx("button", { onClick: handleSendMessage, className: "w-full py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-[14px] font-medium rounded-[4px] transition-colors", children: "Send Message" }) })] }));
}
@@ -0,0 +1,90 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import type { User } from '@opencord/shared';
import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
interface UserProfilePopoutProps {
user: User;
onClose: () => void;
position?: { top: number; left: number };
}
export function UserProfilePopout({ user, onClose, position }: UserProfilePopoutProps) {
const navigate = useNavigate();
const addDmChannel = useServerStore((s) => s.addDmChannel);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const displayName = user.displayName ?? user.username;
const handleSendMessage = async () => {
try {
const channel = await api.dm.create({ userId: user.id });
addDmChannel(channel);
setCurrentChannel(channel.id);
onClose();
navigate(`/channels/@me/${channel.id}`);
} catch (err) {
console.error('Failed to create DM channel:', err);
}
};
return (
<div
className="fixed z-50 w-[300px] bg-discord-bg-floating rounded-[8px] shadow-elevation-high overflow-hidden animate-fade-in select-none"
style={position ? { top: position.top, left: position.left } : { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}
>
{/* Banner */}
<div className="h-[60px] bg-discord-blurple" />
{/* Avatar Container */}
<div className="px-4 pb-4 relative">
<div className="absolute -top-8 left-4 rounded-full border-[6px] border-discord-bg-floating bg-discord-bg-floating">
<Avatar
src={user.avatar}
name={displayName}
size={80}
status={user.status as any}
/>
</div>
{/* Content */}
<div className="mt-12 bg-discord-bg-tertiary rounded-[8px] p-3">
<div className="text-[20px] font-bold text-discord-text-header leading-tight mb-1">
{displayName}
</div>
<div className="text-[14px] text-discord-text-normal font-medium mb-3">
@{user.username}
</div>
<div className="w-full h-[1px] bg-discord-modifier-accent mb-3" />
<div className="mb-3">
<div className="text-[12px] font-bold text-discord-text-header uppercase mb-1">Opencord Member Since</div>
<div className="text-[12px] text-discord-text-normal font-medium">
{new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</div>
</div>
{user.customStatus && (
<div className="mb-3">
<div className="text-[12px] font-bold text-discord-text-header uppercase mb-1">Status</div>
<div className="text-[14px] text-discord-text-normal">{user.customStatus}</div>
</div>
)}
</div>
</div>
{/* Footer / Actions */}
<div className="px-4 pb-4">
<button
onClick={handleSendMessage}
className="w-full py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-[14px] font-medium rounded-[4px] transition-colors"
>
Send Message
</button>
</div>
</div>
);
}
@@ -7,7 +7,7 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog
const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleMute = useVoiceStore((s) => s.toggleMute);
const toggleMic = useVoiceStore((s) => s.toggleMic);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
@@ -17,7 +17,7 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog
const channel = channels.find(c => c.id === currentVoiceChannelId);
const channelName = channel?.name ?? 'Voice Channel';
const handleMic = () => {
toggleMute();
toggleMic();
onToggleMic();
};
const handleDeafen = () => {
@@ -15,7 +15,7 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog
const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleMute = useVoiceStore((s) => s.toggleMute);
const toggleMic = useVoiceStore((s) => s.toggleMic);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
@@ -27,7 +27,7 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog
const channelName = channel?.name ?? 'Voice Channel';
const handleMic = () => {
toggleMute();
toggleMic();
onToggleMic();
};
+10 -1
View File
@@ -3,6 +3,7 @@ import { useRef, useEffect } from 'react';
import { Avatar } from '../ui/Avatar';
export function VoiceUser({ participant }) {
const videoRef = useRef(null);
const audioRef = useRef(null);
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl)
@@ -16,6 +17,14 @@ export function VoiceUser({ participant }) {
videoEl.srcObject = null;
}
}, [participant.videoTrack, participant.screenTrack]);
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl || !participant.audioTrack)
return;
const stream = new MediaStream([participant.audioTrack]);
audioEl.srcObject = stream;
}, [participant.audioTrack]);
const hasVideo = participant.isCameraOn || participant.isScreenSharing;
return (_jsxs("div", { className: `relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center ${participant.isSpeaking ? 'ring-2 ring-discord-green' : ''}`, style: { aspectRatio: '16/9', minHeight: '200px' }, children: [hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: participant.userId === 'local', className: "w-full h-full object-cover" })) : (_jsx(Avatar, { src: null, name: participant.username, size: 80 })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm font-medium text-white", children: participant.username }), _jsx("div", { className: "flex items-center gap-1", children: participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })) })] }) }), participant.isSpeaking && (_jsx("div", { className: "absolute inset-0 rounded-xl ring-2 ring-discord-green animate-pulse pointer-events-none" }))] }));
const isLocal = participant.isLocal;
return (_jsxs("div", { className: `relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center ${participant.isSpeaking ? 'ring-2 ring-discord-green' : ''}`, style: { aspectRatio: '16/9', minHeight: '200px' }, children: [!isLocal && _jsx("audio", { ref: audioRef, autoPlay: true }), hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: "w-full h-full object-cover" })) : (_jsx(Avatar, { src: null, name: participant.username, size: 80 })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm font-medium text-white", children: participant.username }), _jsx("div", { className: "flex items-center gap-1", children: participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })) })] }) }), participant.isSpeaking && (_jsx("div", { className: "absolute inset-0 rounded-xl ring-2 ring-discord-green animate-pulse pointer-events-none" }))] }));
}
@@ -8,6 +8,7 @@ interface VoiceUserProps {
export function VoiceUser({ participant }: VoiceUserProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
const videoEl = videoRef.current;
@@ -22,7 +23,16 @@ export function VoiceUser({ participant }: VoiceUserProps) {
}
}, [participant.videoTrack, participant.screenTrack]);
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl || !participant.audioTrack) return;
const stream = new MediaStream([participant.audioTrack]);
audioEl.srcObject = stream;
}, [participant.audioTrack]);
const hasVideo = participant.isCameraOn || participant.isScreenSharing;
const isLocal = participant.isLocal;
return (
<div
@@ -31,12 +41,15 @@ export function VoiceUser({ participant }: VoiceUserProps) {
}`}
style={{ aspectRatio: '16/9', minHeight: '200px' }}
>
{/* Audio element for remote participants */}
{!isLocal && <audio ref={audioRef} autoPlay />}
{hasVideo ? (
<video
ref={videoRef}
autoPlay
playsInline
muted={participant.userId === 'local'}
muted={isLocal}
className="w-full h-full object-cover"
/>
) : (
+6 -6
View File
@@ -23,7 +23,7 @@ export function useLiveKit() {
if (!r)
return;
const allParticipants = [];
const processParticipant = (p) => {
const processParticipant = (p, isLocal) => {
const { userId, username } = parseIdentity(p.identity);
let audioTrack = null;
let videoTrack = null;
@@ -50,13 +50,14 @@ export function useLiveKit() {
isMuted: !p.isMicrophoneEnabled,
isCameraOn: p.isCameraEnabled,
isScreenSharing: p.isScreenShareEnabled,
isLocal,
audioTrack,
videoTrack,
screenTrack,
});
};
processParticipant(r.localParticipant);
r.remoteParticipants.forEach(processParticipant);
processParticipant(r.localParticipant, true);
r.remoteParticipants.forEach((p) => processParticipant(p, false));
setParticipants(allParticipants);
}, []);
const connect = useCallback(async (channelId) => {
@@ -65,8 +66,7 @@ export function useLiveKit() {
}
setIsConnecting(true);
try {
const { token } = await api.livekit.token(channelId);
const livekitUrl = 'wss://nova.ddns.net/livekit';
const { token, url } = await api.livekit.token(channelId);
const newRoom = new Room({
adaptiveStream: true,
dynacast: true,
@@ -85,7 +85,7 @@ export function useLiveKit() {
setIsConnected(false);
setParticipants([]);
});
await newRoom.connect(livekitUrl, token);
await newRoom.connect(url, token);
await newRoom.localParticipant.enableCameraAndMicrophone();
roomRef.current = newRoom;
setRoom(newRoom);
+7 -6
View File
@@ -21,6 +21,7 @@ export interface ParticipantInfo {
isMuted: boolean;
isCameraOn: boolean;
isScreenSharing: boolean;
isLocal: boolean;
audioTrack: MediaStreamTrack | null;
videoTrack: MediaStreamTrack | null;
screenTrack: MediaStreamTrack | null;
@@ -50,7 +51,7 @@ export function useLiveKit() {
const allParticipants: ParticipantInfo[] = [];
const processParticipant = (p: Participant) => {
const processParticipant = (p: Participant, isLocal: boolean) => {
const { userId, username } = parseIdentity(p.identity);
let audioTrack: MediaStreamTrack | null = null;
let videoTrack: MediaStreamTrack | null = null;
@@ -76,14 +77,15 @@ export function useLiveKit() {
isMuted: !p.isMicrophoneEnabled,
isCameraOn: p.isCameraEnabled,
isScreenSharing: p.isScreenShareEnabled,
isLocal,
audioTrack,
videoTrack,
screenTrack,
});
};
processParticipant(r.localParticipant);
r.remoteParticipants.forEach(processParticipant);
processParticipant(r.localParticipant, true);
r.remoteParticipants.forEach((p) => processParticipant(p, false));
setParticipants(allParticipants);
}, []);
@@ -95,8 +97,7 @@ export function useLiveKit() {
setIsConnecting(true);
try {
const { token } = await api.livekit.token(channelId);
const livekitUrl = 'wss://nova.ddns.net/livekit';
const { token, url } = await api.livekit.token(channelId);
const newRoom = new Room({
adaptiveStream: true,
@@ -118,7 +119,7 @@ export function useLiveKit() {
setParticipants([]);
});
await newRoom.connect(livekitUrl, token);
await newRoom.connect(url, token);
await newRoom.localParticipant.enableCameraAndMicrophone();
roomRef.current = newRoom;
+9 -2
View File
@@ -11,12 +11,12 @@ let isInitialized = false;
function handleEvent(event) {
const { setUser } = useAuthStore.getState();
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
const { addMessage, updateMessage, removeMessage, setTyping } = useChatStore.getState();
const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
const { addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
switch (event.type) {
case 'ready':
setUser(event.user);
populateFromReady(event.servers);
populateFromReady(event.servers, event.folders, event.dmChannels);
if (currentServerId) {
loadServerDetail(currentServerId);
}
@@ -51,6 +51,13 @@ function handleEvent(event) {
removeMember(event.userId);
break;
case 'dm_message_created':
addMessage(event.message.dmChannelId, event.message);
break;
case 'reaction_added':
onReactionAdded(event.messageId, event.reaction);
break;
case 'reaction_removed':
onReactionRemoved(event.messageId, event.userId, event.emoji);
break;
case 'error':
console.error('WebSocket error:', event.message);
+24 -2
View File
@@ -3,6 +3,7 @@ import { useAuthStore } from '../stores/authStore';
import { useServerStore } from '../stores/serverStore';
import { useChatStore } from '../stores/chatStore';
import { useVoiceStore } from '../stores/voiceStore';
import { useSocialStore } from '../stores/socialStore';
import type { ServerEvent, ClientEvent } from '@opencord/shared';
let globalWs: WebSocket | null = null;
@@ -14,13 +15,13 @@ let isInitialized = false;
function handleEvent(event: ServerEvent): void {
const { setUser } = useAuthStore.getState();
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
const { addMessage, updateMessage, removeMessage, setTyping } = useChatStore.getState();
const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
const { addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
switch (event.type) {
case 'ready':
setUser(event.user);
populateFromReady(event.servers);
populateFromReady(event.servers, event.folders, event.dmChannels);
if (currentServerId) {
loadServerDetail(currentServerId);
}
@@ -63,8 +64,29 @@ function handleEvent(event: ServerEvent): void {
break;
case 'dm_message_created':
addMessage(event.message.dmChannelId, event.message as any);
break;
case 'reaction_added':
onReactionAdded(event.messageId, event.reaction);
break;
case 'reaction_removed':
onReactionRemoved(event.messageId, event.userId, event.emoji);
break;
case 'friend_request_received': {
const { addIncomingRequest } = useSocialStore.getState();
addIncomingRequest(event.request);
break;
}
case 'friend_request_accepted': {
const { addFriendFromAccepted } = useSocialStore.getState();
addFriendFromAccepted(event.friend, event.requestId);
break;
}
case 'error':
console.error('WebSocket error:', event.message);
break;
+65 -3
View File
@@ -1,18 +1,25 @@
import { create } from 'zustand';
import { api } from '../api/client';
import { wsSend } from '../hooks/useWebSocket';
import { useUIStore } from './uiStore';
export const useChatStore = create((set, get) => ({
messages: new Map(),
currentChannelId: null,
typingUsers: new Map(),
hasMore: new Map(),
isLoading: false,
replyTo: null,
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
setReplyTo: (message) => set({ replyTo: message }),
loadMessages: async (channelId) => {
if (get().messages.has(channelId))
return;
set({ isLoading: true });
try {
const messages = await api.channels.messages(channelId);
const isDm = useUIStore.getState().showDms;
const messages = isDm
? await api.dm.messages(channelId)
: await api.channels.messages(channelId);
set((state) => {
const newMessages = new Map(state.messages);
newMessages.set(channelId, messages);
@@ -35,7 +42,10 @@ export const useChatStore = create((set, get) => ({
if (!oldestMessage)
return false;
try {
const olderMessages = await api.channels.messages(channelId, oldestMessage.id);
const isDm = useUIStore.getState().showDms;
const olderMessages = isDm
? await api.dm.messages(channelId, oldestMessage.id)
: await api.channels.messages(channelId, oldestMessage.id);
set((state) => {
const newMessages = new Map(state.messages);
const current = newMessages.get(channelId) ?? [];
@@ -51,7 +61,15 @@ export const useChatStore = create((set, get) => ({
}
},
sendMessage: async (channelId, content, attachmentIds) => {
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds });
const replyToId = get().replyTo?.id;
const isDm = useUIStore.getState().showDms;
if (isDm) {
await api.dm.sendMessage(channelId, { content });
}
else {
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
}
set({ replyTo: null });
// Message will arrive via WebSocket
},
editMessage: async (messageId, content) => {
@@ -93,6 +111,50 @@ export const useChatStore = create((set, get) => ({
return { messages: newMessages };
});
},
addReaction: (messageId, emoji) => {
wsSend({ type: 'reaction_add', messageId, emoji });
},
removeReaction: (messageId, emoji) => {
wsSend({ type: 'reaction_remove', messageId, emoji });
},
onReactionAdded: (messageId, reaction) => {
set((state) => {
const newMessages = new Map(state.messages);
for (const [channelId, msgs] of newMessages.entries()) {
const msgIndex = msgs.findIndex(m => m.id === messageId);
if (msgIndex !== -1) {
const newMsgs = [...msgs];
const oldMsg = newMsgs[msgIndex];
newMsgs[msgIndex] = {
...oldMsg,
reactions: [...(oldMsg.reactions || []), reaction],
};
newMessages.set(channelId, newMsgs);
break;
}
}
return { messages: newMessages };
});
},
onReactionRemoved: (messageId, userId, emoji) => {
set((state) => {
const newMessages = new Map(state.messages);
for (const [channelId, msgs] of newMessages.entries()) {
const msgIndex = msgs.findIndex(m => m.id === messageId);
if (msgIndex !== -1) {
const newMsgs = [...msgs];
const oldMsg = newMsgs[msgIndex];
newMsgs[msgIndex] = {
...oldMsg,
reactions: (oldMsg.reactions || []).filter(r => !(r.userId === userId && r.emoji === emoji)),
};
newMessages.set(channelId, newMsgs);
break;
}
}
return { messages: newMessages };
});
},
setTyping: (channelId, userId, username) => {
set((state) => {
const newTyping = new Map(state.typingUsers);
+87 -10
View File
@@ -1,6 +1,8 @@
import { create } from 'zustand';
import type { MessageWithUser } from '@opencord/shared';
import type { MessageWithUser, Reaction } from '@opencord/shared';
import { api } from '../api/client';
import { wsSend } from '../hooks/useWebSocket';
import { useUIStore } from './uiStore';
interface TypingUser {
userId: string;
@@ -14,7 +16,10 @@ interface ChatState {
typingUsers: Map<string, TypingUser[]>;
hasMore: Map<string, boolean>;
isLoading: boolean;
loadError: string | null;
replyTo: MessageWithUser | null;
setCurrentChannel: (channelId: string | null) => void;
setReplyTo: (message: MessageWithUser | null) => void;
loadMessages: (channelId: string) => Promise<void>;
loadMoreMessages: (channelId: string) => Promise<boolean>;
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
@@ -23,6 +28,10 @@ interface ChatState {
addMessage: (channelId: string, message: MessageWithUser) => void;
updateMessage: (message: MessageWithUser) => void;
removeMessage: (messageId: string, channelId: string) => void;
addReaction: (messageId: string, emoji: string) => void;
removeReaction: (messageId: string, emoji: string) => void;
onReactionAdded: (messageId: string, reaction: any) => void;
onReactionRemoved: (messageId: string, userId: string, emoji: string) => void;
setTyping: (channelId: string, userId: string, username: string) => void;
clearTyping: (channelId: string, userId: string) => void;
getMessages: (channelId: string) => MessageWithUser[];
@@ -35,23 +44,30 @@ export const useChatStore = create<ChatState>((set, get) => ({
typingUsers: new Map(),
hasMore: new Map(),
isLoading: false,
loadError: null,
replyTo: null,
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
setReplyTo: (message) => set({ replyTo: message }),
loadMessages: async (channelId: string) => {
if (get().messages.has(channelId)) return;
set({ isLoading: true });
set({ isLoading: true, loadError: null });
try {
const messages = await api.channels.messages(channelId);
const isDm = useUIStore.getState().showDms;
const messages = isDm
? await api.dm.messages(channelId)
: await api.channels.messages(channelId);
set((state) => {
const newMessages = new Map(state.messages);
newMessages.set(channelId, messages);
newMessages.set(channelId, messages as MessageWithUser[]);
const newHasMore = new Map(state.hasMore);
newHasMore.set(channelId, messages.length >= 50);
return { messages: newMessages, hasMore: newHasMore, isLoading: false };
return { messages: newMessages, hasMore: newHasMore, isLoading: false, loadError: null };
});
} catch {
set({ isLoading: false });
} catch (err) {
set({ isLoading: false, loadError: (err as Error).message || 'Failed to load messages' });
}
},
@@ -64,11 +80,15 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (!oldestMessage) return false;
try {
const olderMessages = await api.channels.messages(channelId, oldestMessage.id);
const isDm = useUIStore.getState().showDms;
const olderMessages = isDm
? await api.dm.messages(channelId, oldestMessage.id)
: await api.channels.messages(channelId, oldestMessage.id);
set((state) => {
const newMessages = new Map(state.messages);
const current = newMessages.get(channelId) ?? [];
newMessages.set(channelId, [...olderMessages, ...current]);
newMessages.set(channelId, [...(olderMessages as MessageWithUser[]), ...current]);
const newHasMore = new Map(state.hasMore);
newHasMore.set(channelId, olderMessages.length >= 50);
return { messages: newMessages, hasMore: newHasMore };
@@ -80,7 +100,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
},
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => {
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds });
const replyToId = get().replyTo?.id;
const isDm = useUIStore.getState().showDms;
if (isDm) {
await api.dm.sendMessage(channelId, { content });
} else {
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
}
set({ replyTo: null });
// Message will arrive via WebSocket
},
@@ -128,6 +157,54 @@ export const useChatStore = create<ChatState>((set, get) => ({
});
},
addReaction: (messageId: string, emoji: string) => {
wsSend({ type: 'reaction_add', messageId, emoji });
},
removeReaction: (messageId: string, emoji: string) => {
wsSend({ type: 'reaction_remove', messageId, emoji });
},
onReactionAdded: (messageId: string, reaction: Reaction) => {
set((state) => {
const newMessages = new Map(state.messages);
for (const [channelId, msgs] of newMessages.entries()) {
const msgIndex = msgs.findIndex(m => m.id === messageId);
if (msgIndex !== -1) {
const newMsgs = [...msgs];
const oldMsg = newMsgs[msgIndex]!;
newMsgs[msgIndex] = {
...oldMsg,
reactions: [...(oldMsg.reactions || []), reaction],
};
newMessages.set(channelId, newMsgs);
break;
}
}
return { messages: newMessages };
});
},
onReactionRemoved: (messageId: string, userId: string, emoji: string) => {
set((state) => {
const newMessages = new Map(state.messages);
for (const [channelId, msgs] of newMessages.entries()) {
const msgIndex = msgs.findIndex(m => m.id === messageId);
if (msgIndex !== -1) {
const newMsgs = [...msgs];
const oldMsg = newMsgs[msgIndex]!;
newMsgs[msgIndex] = {
...oldMsg,
reactions: (oldMsg.reactions || []).filter(r => !(r.userId === userId && r.emoji === emoji)),
};
newMessages.set(channelId, newMsgs);
break;
}
}
return { messages: newMessages };
});
},
setTyping: (channelId: string, userId: string, username: string) => {
set((state) => {
const newTyping = new Map(state.typingUsers);
+24 -2
View File
@@ -5,10 +5,18 @@ export const useServerStore = create((set, get) => ({
currentServerId: null,
channels: [],
members: [],
roles: [],
folders: [],
dmChannels: [],
setServers: (servers) => set({ servers }),
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
setChannels: (channels) => set({ channels }),
setMembers: (members) => set({ members }),
setRoles: (roles) => set({ roles }),
setDmChannels: (dmChannels) => set({ dmChannels }),
addDmChannel: (channel) => set((state) => ({
dmChannels: [channel, ...state.dmChannels.filter(c => c.id !== channel.id)]
})),
loadServers: async () => {
try {
const servers = await api.servers.list();
@@ -25,12 +33,22 @@ export const useServerStore = create((set, get) => ({
currentServerId: serverId,
channels: detail.channels.sort((a, b) => a.position - b.position),
members: detail.members,
roles: detail.roles.sort((a, b) => b.position - a.position), // Higher position = higher in list
});
}
catch {
// Handle error silently
}
},
loadDmChannels: async () => {
try {
const dmChannels = await api.dm.list();
set({ dmChannels });
}
catch {
// Handle error silently
}
},
createServer: async (name, icon) => {
const server = await api.servers.create({ name, icon });
set((state) => ({ servers: [...state.servers, server] }));
@@ -102,7 +120,7 @@ export const useServerStore = create((set, get) => ({
members: state.members.filter(m => m.userId !== userId),
}));
},
populateFromReady: (servers) => {
populateFromReady: (servers, folders, dmChannels) => {
const simpleServers = servers.map(s => ({
id: s.id,
name: s.name,
@@ -111,6 +129,10 @@ export const useServerStore = create((set, get) => ({
inviteCode: s.inviteCode,
createdAt: s.createdAt,
}));
set({ servers: simpleServers });
set({
servers: simpleServers,
folders: folders || [],
dmChannels: dmChannels || []
});
},
}));
+44 -4
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers } from '@opencord/shared';
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel } from '@opencord/shared';
import { api } from '../api/client';
interface ServerState {
@@ -7,16 +7,24 @@ interface ServerState {
currentServerId: string | null;
channels: Channel[];
members: MemberWithUser[];
roles: Role[];
folders: ServerFolder[];
dmChannels: DmChannel[];
setServers: (servers: Server[]) => void;
setCurrentServer: (serverId: string | null) => void;
setChannels: (channels: Channel[]) => void;
setMembers: (members: MemberWithUser[]) => void;
setRoles: (roles: Role[]) => void;
setDmChannels: (channels: DmChannel[]) => void;
addDmChannel: (channel: DmChannel) => void;
loadServers: () => Promise<void>;
loadServerDetail: (serverId: string) => Promise<void>;
loadDmChannels: () => Promise<void>;
createServer: (name: string, icon?: string) => Promise<Server>;
updateServer: (serverId: string, data: { name?: string; icon?: string }) => Promise<void>;
deleteServer: (serverId: string) => Promise<void>;
joinServer: (serverId: string, inviteCode: string) => Promise<void>;
joinByCode: (inviteCode: string) => Promise<Server>;
generateInvite: (serverId: string) => Promise<string>;
createChannel: (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
deleteChannel: (channelId: string) => Promise<void>;
@@ -25,7 +33,7 @@ interface ServerState {
updateMemberPresence: (userId: string, status: string) => void;
addMember: (member: MemberWithUser) => void;
removeMember: (userId: string) => void;
populateFromReady: (servers: ServerWithChannelsAndMembers[]) => void;
populateFromReady: (servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => void;
}
export const useServerStore = create<ServerState>((set, get) => ({
@@ -33,11 +41,20 @@ export const useServerStore = create<ServerState>((set, get) => ({
currentServerId: null,
channels: [],
members: [],
roles: [],
folders: [],
dmChannels: [],
setServers: (servers) => set({ servers }),
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
setChannels: (channels) => set({ channels }),
setMembers: (members) => set({ members }),
setRoles: (roles) => set({ roles }),
setDmChannels: (dmChannels) => set({ dmChannels }),
addDmChannel: (channel) => set((state) => ({
dmChannels: [channel, ...state.dmChannels.filter(c => c.id !== channel.id)]
})),
loadServers: async () => {
try {
@@ -55,12 +72,22 @@ export const useServerStore = create<ServerState>((set, get) => ({
currentServerId: serverId,
channels: detail.channels.sort((a, b) => a.position - b.position),
members: detail.members,
roles: detail.roles.sort((a, b) => b.position - a.position), // Higher position = higher in list
});
} catch {
// Handle error silently
}
},
loadDmChannels: async () => {
try {
const dmChannels = await api.dm.list();
set({ dmChannels });
} catch {
// Handle error silently
}
},
createServer: async (name: string, icon?: string) => {
const server = await api.servers.create({ name, icon });
set((state) => ({ servers: [...state.servers, server] }));
@@ -90,6 +117,15 @@ export const useServerStore = create<ServerState>((set, get) => ({
});
},
joinByCode: async (inviteCode: string) => {
const server = await api.servers.joinByCode(inviteCode);
set((state) => {
if (state.servers.find(s => s.id === server.id)) return state;
return { servers: [...state.servers, server] };
});
return server;
},
generateInvite: async (serverId: string) => {
const result = await api.servers.invite(serverId);
return result.inviteCode;
@@ -144,7 +180,7 @@ export const useServerStore = create<ServerState>((set, get) => ({
}));
},
populateFromReady: (servers: ServerWithChannelsAndMembers[]) => {
populateFromReady: (servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => {
const simpleServers: Server[] = servers.map(s => ({
id: s.id,
name: s.name,
@@ -153,6 +189,10 @@ export const useServerStore = create<ServerState>((set, get) => ({
inviteCode: s.inviteCode,
createdAt: s.createdAt,
}));
set({ servers: simpleServers });
set({
servers: simpleServers,
folders: folders || [],
dmChannels: dmChannels || []
});
},
}));
+76
View File
@@ -0,0 +1,76 @@
import { create } from 'zustand';
import { api } from '../api/client';
export const useSocialStore = create((set, get) => ({
friends: [],
requests: [],
isLoading: false,
error: null,
loadFriends: async () => {
set({ isLoading: true, error: null });
try {
const friends = await api.social.friends();
set({ friends, isLoading: false });
}
catch (err) {
set({ error: err.message, isLoading: false });
}
},
loadRequests: async () => {
set({ isLoading: true, error: null });
try {
const requests = await api.social.requests();
set({ requests, isLoading: false });
}
catch (err) {
set({ error: err.message, isLoading: false });
}
},
sendFriendRequest: async (username) => {
set({ isLoading: true, error: null });
try {
await api.social.sendRequest(username);
await get().loadRequests();
}
catch (err) {
set({ error: err.message, isLoading: false });
throw err;
}
},
updateFriendRequest: async (id, status) => {
set({ isLoading: true, error: null });
try {
await api.social.updateRequest(id, status);
await get().loadRequests();
if (status === 'accepted') {
await get().loadFriends();
}
}
catch (err) {
set({ error: err.message, isLoading: false });
throw err;
}
},
removeFriend: async (id) => {
set({ isLoading: true, error: null });
try {
await api.social.removeFriend(id);
set((state) => ({
friends: state.friends.filter((f) => f.id !== id),
isLoading: false,
}));
}
catch (err) {
set({ error: err.message, isLoading: false });
throw err;
}
},
searchUsers: async (query) => {
try {
return await api.social.search(query);
}
catch (err) {
console.error('Failed to search users:', err);
return [];
}
},
}));
+124
View File
@@ -0,0 +1,124 @@
import { create } from 'zustand';
import type { Friend, FriendRequest, User } from '@opencord/shared';
import { api } from '../api/client';
interface SocialState {
friends: Friend[];
requests: FriendRequest[];
isLoading: boolean;
error: string | null;
loadFriends: () => Promise<void>;
loadRequests: () => Promise<void>;
sendFriendRequest: (username: string) => Promise<void>;
updateFriendRequest: (id: string, status: 'accepted' | 'declined') => Promise<void>;
cancelFriendRequest: (id: string) => Promise<void>;
removeFriend: (id: string) => Promise<void>;
searchUsers: (query: string) => Promise<User[]>;
addIncomingRequest: (request: FriendRequest) => void;
addFriendFromAccepted: (friend: Friend, requestId: string) => void;
}
export const useSocialStore = create<SocialState>((set, get) => ({
friends: [],
requests: [],
isLoading: false,
error: null,
loadFriends: async () => {
set({ isLoading: true, error: null });
try {
const friends = await api.social.friends();
set({ friends, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
}
},
loadRequests: async () => {
set({ isLoading: true, error: null });
try {
const requests = await api.social.requests();
set({ requests, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
}
},
sendFriendRequest: async (username: string) => {
set({ isLoading: true, error: null });
try {
await api.social.sendRequest(username);
await get().loadRequests();
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
throw err;
}
},
updateFriendRequest: async (id: string, status: 'accepted' | 'declined') => {
set({ isLoading: true, error: null });
try {
await api.social.updateRequest(id, status);
await get().loadRequests();
if (status === 'accepted') {
await get().loadFriends();
}
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
throw err;
}
},
cancelFriendRequest: async (id: string) => {
set({ isLoading: true, error: null });
try {
await api.social.cancelRequest(id);
set((state) => ({
requests: state.requests.filter(r => r.id !== id),
isLoading: false,
}));
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
throw err;
}
},
removeFriend: async (id: string) => {
set({ isLoading: true, error: null });
try {
await api.social.removeFriend(id);
set((state) => ({
friends: state.friends.filter((f) => f.id !== id),
isLoading: false,
}));
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
throw err;
}
},
searchUsers: async (query: string) => {
try {
return await api.social.search(query);
} catch (err) {
console.error('Failed to search users:', err);
return [];
}
},
// Called from WS handler when another user sends you a friend request
addIncomingRequest: (request: FriendRequest) => {
set((state) => {
if (state.requests.find(r => r.id === request.id)) return state;
return { requests: [...state.requests, request] };
});
},
// Called from WS handler when someone accepts your friend request
addFriendFromAccepted: (friend: Friend, requestId: string) => {
set((state) => ({
friends: state.friends.find(f => f.id === friend.id) ? state.friends : [...state.friends, friend],
requests: state.requests.filter(r => r.id !== requestId),
}));
},
}));
+10
View File
@@ -7,6 +7,10 @@ export const useUIStore = create((set) => ({
isMobile: false,
showDms: false,
imagePreviewUrl: null,
userProfilePopout: {
user: null,
position: null,
},
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
@@ -19,4 +23,10 @@ export const useUIStore = create((set) => ({
setShowDms: (show) => set({ showDms: show }),
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
openUserProfile: (user, position) => set({
userProfilePopout: { user, position }
}),
closeUserProfile: () => set({
userProfilePopout: { user: null, position: null }
}),
}));
+18
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import type { User } from '@opencord/shared';
type ModalType =
| 'createServer'
@@ -18,6 +19,10 @@ interface UIState {
isMobile: boolean;
showDms: boolean;
imagePreviewUrl: string | null;
userProfilePopout: {
user: User | null;
position: { top: number; left: number } | null;
};
toggleSidebar: () => void;
toggleMemberList: () => void;
openModal: (modal: ModalType, data?: Record<string, unknown>) => void;
@@ -26,6 +31,8 @@ interface UIState {
setShowDms: (show: boolean) => void;
openImagePreview: (url: string) => void;
closeImagePreview: () => void;
openUserProfile: (user: User, position: { top: number; left: number }) => void;
closeUserProfile: () => void;
}
export const useUIStore = create<UIState>((set) => ({
@@ -36,6 +43,10 @@ export const useUIStore = create<UIState>((set) => ({
isMobile: false,
showDms: false,
imagePreviewUrl: null,
userProfilePopout: {
user: null,
position: null,
},
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
@@ -53,4 +64,11 @@ export const useUIStore = create<UIState>((set) => ({
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
openUserProfile: (user, position) => set({
userProfilePopout: { user, position }
}),
closeUserProfile: () => set({
userProfilePopout: { user: null, position: null }
}),
}));
+3 -1
View File
@@ -6,6 +6,7 @@ export const useVoiceStore = create((set, get) => ({
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
participants: [],
setVoiceUsers: (channelId, userIds) => {
set((state) => {
const newMap = new Map(state.voiceUsers);
@@ -32,7 +33,8 @@ export const useVoiceStore = create((set, get) => ({
});
},
setCurrentVoiceChannel: (channelId) => set({ currentVoiceChannelId: channelId }),
toggleMute: () => set((state) => ({ isMuted: !state.isMuted })),
setParticipants: (participants) => set({ participants }),
toggleMic: () => set((state) => ({ isMuted: !state.isMuted })),
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
+9 -3
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import type { ParticipantInfo } from '../hooks/useLiveKit';
interface VoiceState {
voiceUsers: Map<string, string[]>; // channelId → userIds
@@ -7,14 +8,16 @@ interface VoiceState {
isDeafened: boolean;
isCameraOn: boolean;
isScreenSharing: boolean;
participants: ParticipantInfo[];
setVoiceUsers: (channelId: string, userIds: string[]) => void;
addVoiceUser: (channelId: string, userId: string) => void;
removeVoiceUser: (channelId: string, userId: string) => void;
setCurrentVoiceChannel: (channelId: string | null) => void;
toggleMute: () => void;
toggleDeafen: () => void;
setParticipants: (participants: ParticipantInfo[]) => void;
toggleMic: () => void;
toggleCamera: () => void;
toggleScreenShare: () => void;
toggleDeafen: () => void;
getVoiceUsers: (channelId: string) => string[];
reset: () => void;
}
@@ -26,6 +29,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
participants: [],
setVoiceUsers: (channelId, userIds) => {
set((state) => {
@@ -57,7 +61,9 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
setCurrentVoiceChannel: (channelId) => set({ currentVoiceChannelId: channelId }),
toggleMute: () => set((state) => ({ isMuted: !state.isMuted })),
setParticipants: (participants) => set({ participants }),
toggleMic: () => set((state) => ({ isMuted: !state.isMuted })),
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
+54 -91
View File
@@ -2,82 +2,60 @@
@tailwind components;
@tailwind utilities;
:root {
--bg-primary: #313338;
--bg-secondary: #2b2d31;
--bg-tertiary: #1e1f22;
--bg-members: #232428;
--bg-hover: #35373c;
--bg-active: #404249;
--bg-input: #383a40;
--text-primary: #f2f3f5;
--text-secondary: #b5bac1;
--text-muted: #949ba4;
--blurple: #5865f2;
--blurple-hover: #4752c4;
--green: #23a559;
--yellow: #f0b232;
--red: #da373c;
@layer base {
* {
@apply border-none;
outline: none !important;
}
html, body, #root {
@apply h-full w-full overflow-hidden bg-discord-bg-tertiary text-discord-text-normal;
font-family: 'Inter', 'gg sans', 'Noto Sans', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Discord-style thin scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-[#1a1b1e] rounded-full border-2 border-transparent bg-clip-padding;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-[#27282c];
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* For dark theme auto-fill */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: #f2f3f5;
-webkit-box-shadow: 0 0 0px 1000px #1e1f22 inset;
transition: background-color 5000s ease-in-out 0s;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: 'gg sans', 'Noto Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Discord-style scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #1a1b1e;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #27282c;
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: #1a1b1e transparent;
}
/* Selection color */
::selection {
background-color: rgba(88, 101, 242, 0.3);
}
/* Link styles */
a {
color: #00aff4;
text-decoration: none;
}
a:hover {
text-decoration: underline;
@layer utilities {
.rounded-inherit {
border-radius: inherit;
}
.scrollbar-thin::-webkit-scrollbar {
width: 4px;
}
}
/* Animations */
@@ -91,25 +69,10 @@ a:hover {
to { transform: translateY(0); opacity: 1; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
.animate-fade-in {
animation: fadeIn 0.15s ease-in;
animation: fadeIn 0.15s ease-out;
}
.animate-slide-up {
animation: slideUp 0.2s ease-out;
animation: slideUp 0.15s ease-out;
}
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
+19 -4
View File
@@ -11,23 +11,38 @@ export default {
'bg-primary': '#313338',
'bg-secondary': '#2b2d31',
'bg-tertiary': '#1e1f22',
'bg-members': '#232428',
'bg-hover': '#35373c',
'bg-active': '#404249',
'bg-user-area': '#232428',
'bg-floating': '#111214',
'bg-overlay': 'rgba(0, 0, 0, 0.85)',
'bg-input': '#383a40',
'bg-accent': '#404249',
'text-primary': '#f2f3f5',
'text-normal': '#dbdee1',
'text-secondary': '#b5bac1',
'text-muted': '#949ba4',
'text-link': '#00a8fc',
'text-positive': '#23a559',
'text-warning': '#f0b232',
'text-danger': '#fa777c',
'blurple': '#5865f2',
'blurple-hover': '#4752c4',
'green': '#23a559',
'yellow': '#f0b232',
'red': '#da373c',
'red-hover': '#a12d31',
'modifier-hover': '#35373c',
'modifier-active': '#3b3d42',
'modifier-selected': '#404249',
'modifier-accent': 'hsla(0, 0%, 100%, 0.06)',
},
},
boxShadow: {
'header': '0 1px 0 rgba(4, 4, 5, 0.2), 0 1.5px 0 rgba(6, 6, 7, 0.05), 0 2px 0 rgba(4, 4, 5, 0.05)',
'elevation-low': '0 1px 0 rgba(4, 4, 5, 0.2), 0 1.5px 0 rgba(6, 6, 7, 0.05), 0 2px 0 rgba(4, 4, 5, 0.05)',
'elevation-high': '0 8px 16px rgba(0, 0, 0, 0.24)',
},
fontFamily: {
sans: ['gg sans', 'Noto Sans', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
sans: ['"gg sans"', 'Inter', 'Noto Sans', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
},
},
},
+10 -2
View File
@@ -1,10 +1,18 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: false,
},
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.mts', '.json'],
alias: {
'@': path.resolve(__dirname, './src'),
},
@@ -13,11 +21,11 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
target: 'http://localhost:3005',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3000',
target: 'ws://localhost:3005',
ws: true,
},
},