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:
@@ -0,0 +1,30 @@
|
|||||||
|
# Raspberry Pi Deployment Makefile (Direct SSH)
|
||||||
|
|
||||||
|
PI_IP = 192.168.1.10
|
||||||
|
PI_USER = youruser
|
||||||
|
REMOTE_PATH = ~/opencord
|
||||||
|
|
||||||
|
.PHONY: deploy logs shell env status
|
||||||
|
|
||||||
|
# Full deployment: sync code and rebuild/restart containers ON the Pi
|
||||||
|
deploy:
|
||||||
|
@chmod +x deploy.sh
|
||||||
|
./deploy.sh
|
||||||
|
|
||||||
|
# Watch real-time logs FROM the Pi server
|
||||||
|
logs:
|
||||||
|
ssh $(PI_USER)@$(PI_IP) "cd $(REMOTE_PATH) && docker compose logs -f"
|
||||||
|
|
||||||
|
# Drop into a shell inside the running container ON the Pi
|
||||||
|
shell:
|
||||||
|
ssh -t $(PI_USER)@$(PI_IP) "docker exec -it opencord /bin/bash"
|
||||||
|
|
||||||
|
# Push local .env to the Pi (do this once)
|
||||||
|
env:
|
||||||
|
@echo "📤 Pushing .env to Pi..."
|
||||||
|
ssh $(PI_USER)@$(PI_IP) "mkdir -p $(REMOTE_PATH)"
|
||||||
|
scp .env $(PI_USER)@$(PI_IP):$(REMOTE_PATH)/.env
|
||||||
|
|
||||||
|
# Check container status ON the Pi
|
||||||
|
status:
|
||||||
|
ssh $(PI_USER)@$(PI_IP) "cd $(REMOTE_PATH) && docker compose ps"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
PI_IP="192.168.1.10"
|
||||||
|
PI_USER="youruser"
|
||||||
|
REMOTE_PATH="~/opencord"
|
||||||
|
|
||||||
|
# 0. Ensure remote directory exists
|
||||||
|
echo "📁 Preparing remote directory on Pi..."
|
||||||
|
ssh "$PI_USER@$PI_IP" "mkdir -p $REMOTE_PATH"
|
||||||
|
|
||||||
|
# 1. Sync files via rsync
|
||||||
|
echo "🚀 Syncing files to Pi..."
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.env.local' \
|
||||||
|
--exclude='packages/*/node_modules' \
|
||||||
|
--exclude='packages/web/dist' \
|
||||||
|
--exclude='data' \
|
||||||
|
--exclude='.DS_Store' \
|
||||||
|
./ "$PI_USER@$PI_IP:$REMOTE_PATH"
|
||||||
|
|
||||||
|
# 2. Trigger Docker Compose directly on the Pi via SSH
|
||||||
|
echo "🚢 Triggering build and deploy on Pi hardware..."
|
||||||
|
ssh "$PI_USER@$PI_IP" "cd $REMOTE_PATH && docker compose up -d --build"
|
||||||
|
|
||||||
|
echo "✅ Deployment command sent to Pi!"
|
||||||
+2
-2
@@ -6,7 +6,7 @@ services:
|
|||||||
container_name: opencord
|
container_name: opencord
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3000}:3000"
|
- "${PORT:-3000}:${PORT:-3000}"
|
||||||
volumes:
|
volumes:
|
||||||
- opencord-data:/app/data
|
- opencord-data:/app/data
|
||||||
env_file:
|
env_file:
|
||||||
@@ -16,7 +16,7 @@ services:
|
|||||||
- DB_PATH=/app/data/opencord.db
|
- DB_PATH=/app/data/opencord.db
|
||||||
- UPLOAD_DIR=/app/data/uploads
|
- UPLOAD_DIR=/app/data/uploads
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://localhost:${PORT:-3000}/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"livekit-server-sdk": "^2.6.1"
|
"livekit-server-sdk": "^2.6.1",
|
||||||
|
"cheerio": "^1.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
|||||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
|
import { runMigrations } from './migrate.js';
|
||||||
import { mkdirSync } from 'fs';
|
import { mkdirSync } from 'fs';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ function createTables(db: Database.Database): void {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
reply_to_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||||
content TEXT,
|
content TEXT,
|
||||||
edited_at INTEGER,
|
edited_at INTEGER,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
@@ -90,6 +92,62 @@ function createTables(db: Database.Database): void {
|
|||||||
content TEXT,
|
content TEXT,
|
||||||
created_at INTEGER NOT NULL
|
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('journal_mode = WAL');
|
||||||
sqlite.pragma('foreign_keys = ON');
|
sqlite.pragma('foreign_keys = ON');
|
||||||
createTables(sqlite);
|
createTables(sqlite);
|
||||||
|
runMigrations(sqlite);
|
||||||
console.log(`Database initialized at ${config.dbPath}`);
|
console.log(`Database initialized at ${config.dbPath}`);
|
||||||
return drizzle(sqlite, { schema });
|
return drizzle(sqlite, { schema });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
@@ -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', {
|
export const users = sqliteTable('users', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -44,10 +44,16 @@ export const messages = sqliteTable('messages', {
|
|||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
channelId: text('channel_id').notNull().references(() => channels.id, { onDelete: 'cascade' }),
|
channelId: text('channel_id').notNull().references(() => channels.id, { onDelete: 'cascade' }),
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
|
replyToId: text('reply_to_id'),
|
||||||
content: text('content'),
|
content: text('content'),
|
||||||
editedAt: integer('edited_at'),
|
editedAt: integer('edited_at'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
});
|
}, (table) => ({
|
||||||
|
replyToFk: foreignKey({
|
||||||
|
columns: [table.replyToId],
|
||||||
|
foreignColumns: [table.id],
|
||||||
|
}).onDelete('set null'),
|
||||||
|
}));
|
||||||
|
|
||||||
export const attachments = sqliteTable('attachments', {
|
export const attachments = sqliteTable('attachments', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -78,3 +84,61 @@ export const dmMessages = sqliteTable('dm_messages', {
|
|||||||
content: text('content'),
|
content: text('content'),
|
||||||
createdAt: integer('created_at').notNull(),
|
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] }),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import websocket from '@fastify/websocket';
|
|||||||
import multipart from '@fastify/multipart';
|
import multipart from '@fastify/multipart';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { initDatabase } from './db/index.js';
|
import { getDb } from './db/index.js';
|
||||||
import { seedDatabase } from './db/seed.js';
|
import { seedDatabase } from './db/seed.js';
|
||||||
import { authRoutes } from './routes/auth.js';
|
import { authRoutes } from './routes/auth.js';
|
||||||
import { userRoutes } from './routes/users.js';
|
import { userRoutes } from './routes/users.js';
|
||||||
@@ -14,6 +14,8 @@ import { messageRoutes } from './routes/messages.js';
|
|||||||
import { uploadRoutes } from './routes/uploads.js';
|
import { uploadRoutes } from './routes/uploads.js';
|
||||||
import { dmRoutes } from './routes/dm.js';
|
import { dmRoutes } from './routes/dm.js';
|
||||||
import { livekitRoutes } from './routes/livekit.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 { registerWebSocket } from './ws/handler.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -50,7 +52,8 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
initDatabase();
|
// Initialize database
|
||||||
|
getDb();
|
||||||
await seedDatabase();
|
await seedDatabase();
|
||||||
|
|
||||||
await app.register(authRoutes);
|
await app.register(authRoutes);
|
||||||
@@ -61,6 +64,8 @@ async function main(): Promise<void> {
|
|||||||
await app.register(uploadRoutes);
|
await app.register(uploadRoutes);
|
||||||
await app.register(dmRoutes);
|
await app.register(dmRoutes);
|
||||||
await app.register(livekitRoutes);
|
await app.register(livekitRoutes);
|
||||||
|
await app.register(socialRoutes);
|
||||||
|
await app.register(utilRoutes);
|
||||||
await app.register(registerWebSocket);
|
await app.register(registerWebSocket);
|
||||||
|
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const jwt = await token.toJwt();
|
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);
|
return reply.code(200).send(response);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
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 { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
PaginatedQuery,
|
PaginatedQuery,
|
||||||
User,
|
User,
|
||||||
MessageWithUser,
|
MessageWithUser,
|
||||||
Attachment,
|
Reaction,
|
||||||
} from '@opencord/shared';
|
} from '@opencord/shared';
|
||||||
|
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
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(
|
function buildMessageWithUser(
|
||||||
message: typeof schema.messages.$inferSelect,
|
message: typeof schema.messages.$inferSelect,
|
||||||
user: typeof schema.users.$inferSelect,
|
user: typeof schema.users.$inferSelect,
|
||||||
attachmentRows: (typeof schema.attachments.$inferSelect)[],
|
attachmentRows: (typeof schema.attachments.$inferSelect)[],
|
||||||
|
reactions: Reaction[] = [],
|
||||||
|
replyTo: MessageWithUser | null = null,
|
||||||
): MessageWithUser {
|
): MessageWithUser {
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
channelId: message.channelId,
|
channelId: message.channelId,
|
||||||
userId: message.userId,
|
userId: message.userId,
|
||||||
|
replyToId: message.replyToId,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
editedAt: message.editedAt,
|
editedAt: message.editedAt,
|
||||||
createdAt: message.createdAt,
|
createdAt: message.createdAt,
|
||||||
@@ -48,6 +156,8 @@ function buildMessageWithUser(
|
|||||||
size: a.size,
|
size: a.size,
|
||||||
createdAt: a.createdAt,
|
createdAt: a.createdAt,
|
||||||
})),
|
})),
|
||||||
|
reactions,
|
||||||
|
replyTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,11 +228,19 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
attachmentMap.get(mid)!.push(att);
|
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
|
const messages: MessageWithUser[] = messageRows
|
||||||
.map(m => {
|
.map(m => {
|
||||||
const user = userMap.get(m.userId);
|
const user = userMap.get(m.userId);
|
||||||
if (!user) return null;
|
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);
|
.filter((m): m is MessageWithUser => m !== null);
|
||||||
|
|
||||||
@@ -134,7 +252,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
preHandler: authenticate,
|
preHandler: authenticate,
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const { content, attachments: attachmentIds } = request.body;
|
const { content, attachments: attachmentIds, replyToId } = request.body;
|
||||||
|
|
||||||
const serverId = getChannelServerId(id);
|
const serverId = getChannelServerId(id);
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
@@ -158,6 +276,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
id: messageId,
|
id: messageId,
|
||||||
channelId: id,
|
channelId: id,
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
|
replyToId: replyToId || null,
|
||||||
content: content?.trim() || null,
|
content: content?.trim() || null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).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 });
|
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
|
// Broadcast via WebSocket
|
||||||
connectionManager.sendToServer(serverId, {
|
connectionManager.sendToServer(serverId, {
|
||||||
@@ -240,7 +366,16 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.where(eq(schema.attachments.messageId, id))
|
.where(eq(schema.attachments.messageId, id))
|
||||||
.all();
|
.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
|
// Broadcast edit
|
||||||
const serverId = getChannelServerId(message.channelId);
|
const serverId = getChannelServerId(message.channelId);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { authenticate } from '../utils/auth.js';
|
|||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
import { isMember, isOwner, isAdmin } from '../utils/permissions.js';
|
import { isMember, isOwner, isAdmin } from '../utils/permissions.js';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { connectionManager } from '../ws/handler.js';
|
||||||
import type {
|
import type {
|
||||||
CreateServerRequest,
|
CreateServerRequest,
|
||||||
UpdateServerRequest,
|
UpdateServerRequest,
|
||||||
@@ -15,6 +16,7 @@ import type {
|
|||||||
Channel,
|
Channel,
|
||||||
MemberWithUser,
|
MemberWithUser,
|
||||||
ServerWithChannelsAndMembers,
|
ServerWithChannelsAndMembers,
|
||||||
|
Role,
|
||||||
} from '@opencord/shared';
|
} from '@opencord/shared';
|
||||||
|
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
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))
|
.where(eq(schema.channels.serverId, id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
const roles = db.select()
|
||||||
|
.from(schema.roles)
|
||||||
|
.where(eq(schema.roles.serverId, id))
|
||||||
|
.orderBy(schema.roles.position)
|
||||||
|
.all();
|
||||||
|
|
||||||
const memberRows = db.select()
|
const memberRows = db.select()
|
||||||
.from(schema.serverMembers)
|
.from(schema.serverMembers)
|
||||||
.where(eq(schema.serverMembers.serverId, id))
|
.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 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
|
const members: MemberWithUser[] = memberRows
|
||||||
.map(m => {
|
.map(m => {
|
||||||
const user = userMap.get(m.userId);
|
const user = userMap.get(m.userId);
|
||||||
if (!user) return null;
|
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 {
|
return {
|
||||||
serverId: m.serverId,
|
serverId: m.serverId,
|
||||||
userId: m.userId,
|
userId: m.userId,
|
||||||
@@ -182,6 +211,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
nickname: m.nickname,
|
nickname: m.nickname,
|
||||||
joinedAt: m.joinedAt,
|
joinedAt: m.joinedAt,
|
||||||
user: sanitizeUser(user),
|
user: sanitizeUser(user),
|
||||||
|
roles: memberRoles,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((m): m is MemberWithUser => m !== null);
|
.filter((m): m is MemberWithUser => m !== null);
|
||||||
@@ -190,6 +220,14 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
...rowToServer(server),
|
...rowToServer(server),
|
||||||
channels: channels.map(rowToChannel),
|
channels: channels.map(rowToChannel),
|
||||||
members,
|
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);
|
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 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();
|
const inviteCode = generateInviteCode();
|
||||||
db.update(schema.servers).set({ inviteCode }).where(eq(schema.servers.id, id)).run();
|
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,
|
nickname: m.nickname,
|
||||||
joinedAt: m.joinedAt,
|
joinedAt: m.joinedAt,
|
||||||
user: sanitizeUser(user),
|
user: sanitizeUser(user),
|
||||||
|
roles: [] as Role[], // TODO: Fetch member roles
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((m): m is MemberWithUser => m !== null);
|
.filter((m): m is MemberWithUser => m !== null);
|
||||||
@@ -470,6 +514,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
nickname: updatedMember.nickname,
|
nickname: updatedMember.nickname,
|
||||||
joinedAt: updatedMember.joinedAt,
|
joinedAt: updatedMember.joinedAt,
|
||||||
user: sanitizeUser(user),
|
user: sanitizeUser(user),
|
||||||
|
roles: [] as Role[], // TODO: Fetch member roles
|
||||||
};
|
};
|
||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
@@ -523,6 +568,114 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
))
|
))
|
||||||
.run();
|
.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 });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { connectionManager } from '../ws/handler.js';
|
||||||
import type { User, UpdateUserRequest } from '@opencord/shared';
|
import type { User, UpdateUserRequest } from '@opencord/shared';
|
||||||
|
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
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) => {
|
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 db = getDb();
|
||||||
|
|
||||||
const updateData: Record<string, string | null | undefined> = {};
|
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) {
|
if (Object.keys(updateData).length === 0) {
|
||||||
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
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(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) => {
|
app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 { getDb, schema } from '../db/index.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
import { connectionManager } from './handler.js';
|
import { connectionManager } from './handler.js';
|
||||||
@@ -40,15 +40,54 @@ function getMessageWithUser(messageId: string): MessageWithUser | null {
|
|||||||
createdAt: a.createdAt,
|
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 {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
channelId: message.channelId,
|
channelId: message.channelId,
|
||||||
userId: message.userId,
|
userId: message.userId,
|
||||||
|
replyToId: message.replyToId,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
editedAt: message.editedAt,
|
editedAt: message.editedAt,
|
||||||
createdAt: message.createdAt,
|
createdAt: message.createdAt,
|
||||||
user: sanitizeUser(user),
|
user: sanitizeUser(user),
|
||||||
attachments,
|
attachments,
|
||||||
|
reactions,
|
||||||
|
replyTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +126,12 @@ export function handleClientEvent(
|
|||||||
case 'dm_message_create':
|
case 'dm_message_create':
|
||||||
handleDmMessageCreate(event, userId);
|
handleDmMessageCreate(event, userId);
|
||||||
break;
|
break;
|
||||||
|
case 'reaction_add':
|
||||||
|
handleReactionAdd(event, userId);
|
||||||
|
break;
|
||||||
|
case 'reaction_remove':
|
||||||
|
handleReactionRemove(event, userId);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
connectionManager.sendToUser(userId, {
|
connectionManager.sendToUser(userId, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@@ -98,6 +143,7 @@ export function handleClientEvent(
|
|||||||
function handleMessageCreate(event: Record<string, unknown>, userId: string): void {
|
function handleMessageCreate(event: Record<string, unknown>, userId: string): void {
|
||||||
const channelId = event.channelId as string;
|
const channelId = event.channelId as string;
|
||||||
const content = event.content as string;
|
const content = event.content as string;
|
||||||
|
const replyToId = event.replyToId as string | undefined;
|
||||||
|
|
||||||
if (!channelId || typeof channelId !== 'string') {
|
if (!channelId || typeof channelId !== 'string') {
|
||||||
connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' });
|
connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' });
|
||||||
@@ -128,6 +174,7 @@ function handleMessageCreate(event: Record<string, unknown>, userId: string): vo
|
|||||||
id: messageId,
|
id: messageId,
|
||||||
channelId,
|
channelId,
|
||||||
userId,
|
userId,
|
||||||
|
replyToId: replyToId || null,
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
Channel,
|
Channel,
|
||||||
DmChannel,
|
DmChannel,
|
||||||
ServerEvent,
|
ServerEvent,
|
||||||
|
ServerFolder,
|
||||||
} from '@opencord/shared';
|
} from '@opencord/shared';
|
||||||
|
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||||
@@ -185,6 +186,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
user: User;
|
user: User;
|
||||||
servers: ServerWithChannelsAndMembers[];
|
servers: ServerWithChannelsAndMembers[];
|
||||||
dmChannels: DmChannel[];
|
dmChannels: DmChannel[];
|
||||||
|
folders: ServerFolder[];
|
||||||
} {
|
} {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
@@ -217,6 +219,12 @@ function buildReadyPayload(userId: string): {
|
|||||||
.where(eq(schema.channels.serverId, serverRow.id))
|
.where(eq(schema.channels.serverId, serverRow.id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
const roles = db.select()
|
||||||
|
.from(schema.roles)
|
||||||
|
.where(eq(schema.roles.serverId, serverRow.id))
|
||||||
|
.orderBy(schema.roles.position)
|
||||||
|
.all();
|
||||||
|
|
||||||
const memberRows = db.select()
|
const memberRows = db.select()
|
||||||
.from(schema.serverMembers)
|
.from(schema.serverMembers)
|
||||||
.where(eq(schema.serverMembers.serverId, serverRow.id))
|
.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 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
|
const members: MemberWithUser[] = memberRows
|
||||||
.map(m => {
|
.map(m => {
|
||||||
const u = userMap.get(m.userId);
|
const u = userMap.get(m.userId);
|
||||||
if (!u) return null;
|
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 {
|
return {
|
||||||
serverId: m.serverId,
|
serverId: m.serverId,
|
||||||
userId: m.userId,
|
userId: m.userId,
|
||||||
@@ -239,6 +268,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
nickname: m.nickname,
|
nickname: m.nickname,
|
||||||
joinedAt: m.joinedAt,
|
joinedAt: m.joinedAt,
|
||||||
user: sanitizeUser(u),
|
user: sanitizeUser(u),
|
||||||
|
roles: memberRoles,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((m): m is MemberWithUser => m !== null);
|
.filter((m): m is MemberWithUser => m !== null);
|
||||||
@@ -260,6 +290,14 @@ function buildReadyPayload(userId: string): {
|
|||||||
createdAt: ch.createdAt,
|
createdAt: ch.createdAt,
|
||||||
})),
|
})),
|
||||||
members,
|
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> {
|
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface Server {
|
|||||||
export interface ServerWithChannelsAndMembers extends Server {
|
export interface ServerWithChannelsAndMembers extends Server {
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
members: MemberWithUser[];
|
members: MemberWithUser[];
|
||||||
|
roles: Role[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Member Types ───────────────────────────────────────────────────────────
|
// ─── Member Types ───────────────────────────────────────────────────────────
|
||||||
@@ -46,6 +47,30 @@ export interface Member {
|
|||||||
|
|
||||||
export interface MemberWithUser extends Member {
|
export interface MemberWithUser extends Member {
|
||||||
user: User;
|
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 ──────────────────────────────────────────────────────────
|
// ─── Channel Types ──────────────────────────────────────────────────────────
|
||||||
@@ -68,6 +93,7 @@ export interface Message {
|
|||||||
id: string;
|
id: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
replyToId: string | null;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
editedAt: number | null;
|
editedAt: number | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
@@ -76,6 +102,19 @@ export interface Message {
|
|||||||
export interface MessageWithUser extends Message {
|
export interface MessageWithUser extends Message {
|
||||||
user: User;
|
user: User;
|
||||||
attachments: Attachment[];
|
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 ───────────────────────────────────────────────────────
|
// ─── Attachment Types ───────────────────────────────────────────────────────
|
||||||
@@ -105,10 +144,17 @@ export interface DmMessage {
|
|||||||
userId: string;
|
userId: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
// Compatibility fields
|
||||||
|
channelId?: string;
|
||||||
|
replyToId?: string | null;
|
||||||
|
editedAt?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DmMessageWithUser extends DmMessage {
|
export interface DmMessageWithUser extends DmMessage {
|
||||||
user: User;
|
user: User;
|
||||||
|
attachments?: Attachment[];
|
||||||
|
reactions?: Reaction[];
|
||||||
|
replyTo?: MessageWithUser | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── WebSocket Event Types ──────────────────────────────────────────────────
|
// ─── WebSocket Event Types ──────────────────────────────────────────────────
|
||||||
@@ -116,18 +162,20 @@ export interface DmMessageWithUser extends DmMessage {
|
|||||||
// Client → Server Events
|
// Client → Server Events
|
||||||
export type ClientEvent =
|
export type ClientEvent =
|
||||||
| { type: 'auth'; token: string }
|
| { 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_edit'; messageId: string; content: string }
|
||||||
| { type: 'message_delete'; messageId: string }
|
| { type: 'message_delete'; messageId: string }
|
||||||
| { type: 'typing_start'; channelId: string }
|
| { type: 'typing_start'; channelId: string }
|
||||||
| { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' }
|
| { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' }
|
||||||
| { type: 'voice_join'; channelId: string }
|
| { type: 'voice_join'; channelId: string }
|
||||||
| { type: 'voice_leave' }
|
| { 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
|
// Server → Client Events
|
||||||
export type ServerEvent =
|
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_created'; message: MessageWithUser }
|
||||||
| { type: 'message_updated'; message: MessageWithUser }
|
| { type: 'message_updated'; message: MessageWithUser }
|
||||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||||
@@ -137,6 +185,10 @@ export type ServerEvent =
|
|||||||
| { type: 'member_joined'; serverId: string; member: MemberWithUser }
|
| { type: 'member_joined'; serverId: string; member: MemberWithUser }
|
||||||
| { type: 'member_left'; serverId: string; userId: string }
|
| { type: 'member_left'; serverId: string; userId: string }
|
||||||
| { type: 'dm_message_created'; message: DmMessageWithUser }
|
| { 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 };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
// ─── API Request/Response Types ─────────────────────────────────────────────
|
// ─── API Request/Response Types ─────────────────────────────────────────────
|
||||||
@@ -183,6 +235,7 @@ export interface UpdateUserRequest {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
customStatus?: string;
|
customStatus?: string;
|
||||||
|
status?: UserStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMemberRequest {
|
export interface UpdateMemberRequest {
|
||||||
@@ -192,6 +245,7 @@ export interface UpdateMemberRequest {
|
|||||||
export interface CreateMessageRequest {
|
export interface CreateMessageRequest {
|
||||||
content: string;
|
content: string;
|
||||||
attachments?: string[];
|
attachments?: string[];
|
||||||
|
replyToId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMessageRequest {
|
export interface UpdateMessageRequest {
|
||||||
@@ -208,6 +262,7 @@ export interface LiveKitTokenRequest {
|
|||||||
|
|
||||||
export interface LiveKitTokenResponse {
|
export interface LiveKitTokenResponse {
|
||||||
token: string;
|
token: string;
|
||||||
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateDmRequest {
|
export interface CreateDmRequest {
|
||||||
@@ -227,3 +282,35 @@ export interface ApiError {
|
|||||||
error: string;
|
error: string;
|
||||||
statusCode: number;
|
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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,23 +9,28 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@livekit/components-react": "^2.7.4",
|
||||||
"@opencord/shared": "workspace:*",
|
"@opencord/shared": "workspace:*",
|
||||||
|
"livekit-client": "^2.9.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0",
|
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"zustand": "^5.0.2",
|
"react-router-dom": "^6.28.0",
|
||||||
"livekit-client": "^2.9.0",
|
"zustand": "^5.0.2"
|
||||||
"@livekit/components-react": "^2.7.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.15",
|
"tailwindcss": "^3.4.15",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.3"
|
"vite": "^6.0.3",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,5 @@ function AuthRedirect({ children }) {
|
|||||||
return _jsx(_Fragment, { children: children });
|
return _jsx(_Fragment, { children: children });
|
||||||
}
|
}
|
||||||
export function App() {
|
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 }) })] }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ export function App() {
|
|||||||
</ProtectedRoute>
|
</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 />} />
|
||||||
<Route path="*" element={<Navigate to="/channels/@me" replace />} />
|
<Route path="*" element={<Navigate to="/channels/@me" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ export const api = {
|
|||||||
},
|
},
|
||||||
sendMessage: (id, data) => request('POST', `/dm/${id}/messages`, data),
|
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: {
|
livekit: {
|
||||||
token: (channelId) => request('POST', '/livekit/token', { channelId }),
|
token: (channelId) => request('POST', '/livekit/token', { channelId }),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import type {
|
|||||||
LiveKitTokenResponse,
|
LiveKitTokenResponse,
|
||||||
CreateDmRequest,
|
CreateDmRequest,
|
||||||
CreateDmMessageRequest,
|
CreateDmMessageRequest,
|
||||||
|
Friend,
|
||||||
|
FriendRequest,
|
||||||
} from '@opencord/shared';
|
} from '@opencord/shared';
|
||||||
|
|
||||||
const BASE_URL = '/api';
|
const BASE_URL = '/api';
|
||||||
@@ -110,6 +112,7 @@ export const api = {
|
|||||||
delete: (id: string) => request<{ success: boolean }>('DELETE', `/servers/${id}`),
|
delete: (id: string) => request<{ success: boolean }>('DELETE', `/servers/${id}`),
|
||||||
invite: (id: string) => request<{ inviteCode: string }>('POST', `/servers/${id}/invite`),
|
invite: (id: string) => request<{ inviteCode: string }>('POST', `/servers/${id}/invite`),
|
||||||
join: (id: string, data: JoinServerRequest) => request<Server>('POST', `/servers/${id}/join`, data),
|
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`),
|
members: (id: string) => request<MemberWithUser[]>('GET', `/servers/${id}/members`),
|
||||||
updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) =>
|
updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) =>
|
||||||
request<MemberWithUser>('PATCH', `/servers/${serverId}/members/${userId}`, data),
|
request<MemberWithUser>('PATCH', `/servers/${serverId}/members/${userId}`, data),
|
||||||
@@ -156,6 +159,17 @@ export const api = {
|
|||||||
request<DmMessageWithUser>('POST', `/dm/${id}/messages`, data),
|
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: {
|
livekit: {
|
||||||
token: (channelId: string) =>
|
token: (channelId: string) =>
|
||||||
request<LiveKitTokenResponse>('POST', '/livekit/token', { channelId }),
|
request<LiveKitTokenResponse>('POST', '/livekit/token', { channelId }),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../stores/authStore';
|
|||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import { Embed } from './Embed';
|
||||||
function formatTime(timestamp) {
|
function formatTime(timestamp) {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -33,10 +34,44 @@ export function Message({ message, isCompact, isFirstInGroup }) {
|
|||||||
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
||||||
const members = useServerStore((s) => s.members);
|
const members = useServerStore((s) => s.members);
|
||||||
const openImagePreview = useUIStore((s) => s.openImagePreview);
|
const openImagePreview = useUIStore((s) => s.openImagePreview);
|
||||||
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
const isAuthor = currentUser?.id === message.userId;
|
const isAuthor = currentUser?.id === message.userId;
|
||||||
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
|
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
|
||||||
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
|
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
|
||||||
const canDelete = isAuthor || isAdminUser;
|
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 = [];
|
const contextMenuItems = [];
|
||||||
if (isAuthor) {
|
if (isAuthor) {
|
||||||
contextMenuItems.push({
|
contextMenuItems.push({
|
||||||
@@ -70,36 +105,52 @@ export function Message({ message, isCompact, isFirstInGroup }) {
|
|||||||
const displayName = message.user.displayName ?? message.user.username;
|
const displayName = message.user.displayName ?? message.user.username;
|
||||||
const roleColor = (() => {
|
const roleColor = (() => {
|
||||||
const member = members.find(m => m.userId === message.userId);
|
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')
|
if (member?.role === 'owner')
|
||||||
return 'text-discord-red';
|
return { color: '#da373c' };
|
||||||
if (member?.role === 'admin')
|
if (member?.role === 'admin')
|
||||||
return 'text-discord-blurple';
|
return { color: '#5865f2' };
|
||||||
return 'text-white';
|
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()) {
|
if (editContent.trim()) {
|
||||||
editMessage(message.id, editContent.trim());
|
editMessage(message.id, editContent.trim());
|
||||||
setIsEditing(false);
|
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 }),
|
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 })),
|
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-sm font-mono", 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 rounded text-sm font-mono overflow-x-auto", 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", children: children }),
|
strong: ({ children }) => _jsx("strong", { className: "font-bold text-discord-text-primary", children: children }),
|
||||||
em: ({ children }) => _jsx("em", { className: "italic", 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/');
|
const isImage = att.mimetype.startsWith('image/');
|
||||||
if (isImage) {
|
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 ? `${(att.size / 1024).toFixed(1)} KB` :
|
||||||
`${(att.size / 1048576).toFixed(1)} MB` })] })] }, att.id));
|
`${(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 ?? '');
|
setEditContent(message.content ?? '');
|
||||||
setIsEditing(true);
|
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) {
|
if (contextMenuItems.length > 0) {
|
||||||
return _jsx(ContextMenu, { items: contextMenuItems, children: content });
|
return _jsx(ContextMenu, { items: contextMenuItems, children: content });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../stores/authStore';
|
|||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import { Embed } from './Embed';
|
||||||
|
|
||||||
interface MessageProps {
|
interface MessageProps {
|
||||||
message: MessageWithUser;
|
message: MessageWithUser;
|
||||||
@@ -42,12 +43,49 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
|||||||
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
||||||
const members = useServerStore((s) => s.members);
|
const members = useServerStore((s) => s.members);
|
||||||
const openImagePreview = useUIStore((s) => s.openImagePreview);
|
const openImagePreview = useUIStore((s) => s.openImagePreview);
|
||||||
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
|
|
||||||
const isAuthor = currentUser?.id === message.userId;
|
const isAuthor = currentUser?.id === message.userId;
|
||||||
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
|
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
|
||||||
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
|
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
|
||||||
const canDelete = isAuthor || isAdminUser;
|
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 = [];
|
const contextMenuItems = [];
|
||||||
if (isAuthor) {
|
if (isAuthor) {
|
||||||
contextMenuItems.push({
|
contextMenuItems.push({
|
||||||
@@ -84,112 +122,175 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
|||||||
|
|
||||||
const roleColor = (() => {
|
const roleColor = (() => {
|
||||||
const member = members.find(m => m.userId === message.userId);
|
const member = members.find(m => m.userId === message.userId);
|
||||||
if (member?.role === 'owner') return 'text-discord-red';
|
if (member?.roles && member.roles.length > 0) {
|
||||||
if (member?.role === 'admin') return 'text-discord-blurple';
|
return { color: member.roles[0]!.color };
|
||||||
return 'text-white';
|
}
|
||||||
|
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 = (
|
const content = (
|
||||||
<div
|
<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)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
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 */}
|
{/* Avatar or timestamp column */}
|
||||||
<div className="w-[72px] flex-shrink-0 flex items-start justify-center">
|
<div className="w-[72px] flex-shrink-0 flex items-start justify-start pl-0.5">
|
||||||
{isFirstInGroup ? (
|
{isFirstInGroup || message.replyTo ? (
|
||||||
|
<div className="mt-1">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={message.user.avatar}
|
src={message.user.avatar}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
size={40}
|
size={40}
|
||||||
className="mt-0.5 cursor-pointer"
|
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)}
|
{formatHoverTime(message.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 pr-4">
|
||||||
{isFirstInGroup && (
|
{message.replyTo && (
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply">
|
||||||
<span className={`font-medium cursor-pointer hover:underline ${roleColor}`}>
|
<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}
|
{displayName}
|
||||||
</span>
|
</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)}
|
{formatTime(message.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="mt-1">
|
<div className="mt-1 w-full">
|
||||||
<textarea
|
<textarea
|
||||||
value={editContent}
|
value={editContent}
|
||||||
onChange={(e) => setEditContent(e.target.value)}
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
onKeyDown={handleEditSubmit}
|
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}
|
rows={2}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-discord-text-muted mt-1">
|
<p className="text-[12px] text-discord-text-muted mt-1.5 ml-1">
|
||||||
escape to <button onClick={() => setIsEditing(false)} className="text-[#00aff4] hover:underline">cancel</button>
|
escape to <button onClick={() => setIsEditing(false)} className="text-discord-text-link hover:underline">cancel</button>
|
||||||
{' '}• enter to <button onClick={() => {
|
{' '}• enter to <button onClick={() => {
|
||||||
if (editContent.trim()) {
|
if (editContent.trim()) {
|
||||||
editMessage(message.id, editContent.trim());
|
editMessage(message.id, editContent.trim());
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
}
|
}
|
||||||
}} className="text-[#00aff4] hover:underline">save</button>
|
}} className="text-discord-text-link hover:underline">save</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex flex-col gap-1">
|
||||||
{message.content && (
|
{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
|
<ReactMarkdown
|
||||||
components={{
|
components={{
|
||||||
p: ({ children }) => <span>{children}</span>,
|
p: ({ children }) => <span>{children}</span>,
|
||||||
a: ({ href, children }) => (
|
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}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
code: ({ children }) => (
|
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}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
),
|
),
|
||||||
pre: ({ children }) => (
|
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}
|
{children}
|
||||||
</pre>
|
</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>,
|
em: ({ children }) => <em className="italic">{children}</em>,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{message.content}
|
{message.content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
{message.editedAt && (
|
{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>
|
</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 */}
|
{/* Attachments */}
|
||||||
{message.attachments.length > 0 && (
|
{message.attachments.length > 0 && (
|
||||||
<div className="mt-1 space-y-1">
|
<div className="mt-1 grid gap-2">
|
||||||
{message.attachments.map((att) => {
|
{message.attachments.map((att) => {
|
||||||
const isImage = att.mimetype.startsWith('image/');
|
const isImage = att.mimetype.startsWith('image/');
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
return (
|
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
|
<img
|
||||||
src={`/api/uploads/${att.filename}`}
|
src={`/api/uploads/${att.filename}`}
|
||||||
alt={att.originalName}
|
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}`)}
|
onClick={() => openImagePreview(`/api/uploads/${att.filename}`)}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
@@ -201,14 +302,16 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
|||||||
key={att.id}
|
key={att.id}
|
||||||
href={`/api/uploads/${att.filename}`}
|
href={`/api/uploads/${att.filename}`}
|
||||||
download={att.originalName}
|
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">
|
<div className="p-2 bg-discord-bg-tertiary rounded text-discord-text-muted group-hover/att:text-discord-text-primary transition-colors">
|
||||||
<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 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>
|
</svg>
|
||||||
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[#00aff4] text-sm truncate hover:underline">{att.originalName}</p>
|
<p className="text-discord-text-link text-[15px] font-medium truncate hover:underline">{att.originalName}</p>
|
||||||
<p className="text-xs text-discord-text-muted">
|
<p className="text-[12px] text-discord-text-muted font-medium">
|
||||||
{att.size < 1024 ? `${att.size} B` :
|
{att.size < 1024 ? `${att.size} B` :
|
||||||
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
|
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
|
||||||
`${(att.size / 1048576).toFixed(1)} MB`}
|
`${(att.size / 1048576).toFixed(1)} MB`}
|
||||||
@@ -219,35 +322,55 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons on hover */}
|
{/* Action buttons on hover */}
|
||||||
{isHovered && !isEditing && contextMenuItems.length > 0 && (
|
{isHovered && !isEditing && (
|
||||||
<div className="absolute -top-3 right-4 flex items-center bg-discord-bg-secondary border border-discord-bg-tertiary rounded shadow-md">
|
<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 && (
|
{isAuthor && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditContent(message.content ?? '');
|
setEditContent(message.content ?? '');
|
||||||
setIsEditing(true);
|
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"
|
title="Edit"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" 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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteMessage(message.id)}
|
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"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" 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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export function MessageInput({ channelId, channelName }) {
|
|||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||||
|
const replyTo = useChatStore((s) => s.replyTo);
|
||||||
|
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
||||||
const typingTimeoutRef = useRef();
|
const typingTimeoutRef = useRef();
|
||||||
const handleTyping = useCallback(() => {
|
const handleTyping = useCallback(() => {
|
||||||
if (typingTimeoutRef.current)
|
if (typingTimeoutRef.current)
|
||||||
@@ -89,11 +91,11 @@ export function MessageInput({ channelId, channelName }) {
|
|||||||
textarea.style.height = 'auto';
|
textarea.style.height = 'auto';
|
||||||
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
|
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) => {
|
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 ?? []);
|
const selected = Array.from(e.target.files ?? []);
|
||||||
if (selected.length > 0) {
|
if (selected.length > 0) {
|
||||||
setFiles((prev) => [...prev, ...selected]);
|
setFiles((prev) => [...prev, ...selected]);
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
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" })] }) }))] })] }) }));
|
} }), _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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
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 typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const handleTyping = useCallback(() => {
|
const handleTyping = useCallback(() => {
|
||||||
@@ -103,52 +105,72 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className="bg-discord-bg-input rounded-lg"
|
className={`bg-discord-bg-input ${replyTo ? 'rounded-b-lg' : 'rounded-lg'} overflow-hidden`}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
>
|
>
|
||||||
{/* File previews */}
|
{/* File previews */}
|
||||||
{files.length > 0 && (
|
{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) => (
|
{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/') ? (
|
{file.type.startsWith('image/') ? (
|
||||||
<img
|
<img
|
||||||
src={URL.createObjectURL(file)}
|
src={URL.createObjectURL(file)}
|
||||||
alt={file.name}
|
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">
|
<div className="flex items-center gap-2 text-sm text-discord-text-secondary py-4 px-2">
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="truncate">{file.name}</span>
|
<span className="truncate max-w-[120px] font-medium">{file.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeFile(i)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-end">
|
<div className="flex items-start px-1">
|
||||||
{/* File attach button */}
|
{/* File attach button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
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"
|
title="Attach file"
|
||||||
>
|
>
|
||||||
|
<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">
|
<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" />
|
<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>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -172,7 +194,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder={`Message #${channelName}`}
|
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}
|
rows={1}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
/>
|
/>
|
||||||
@@ -186,6 +208,24 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ export function MessageList({ channelId }) {
|
|||||||
if (isLoading && messages.length === 0) {
|
if (isLoading && messages.length === 0) {
|
||||||
return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsx(LoadingSpinner, {}) }));
|
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 prevMsg = messages[i - 1];
|
||||||
const showDate = shouldShowDateDivider(prevMsg, msg);
|
const showDate = shouldShowDateDivider(prevMsg, msg);
|
||||||
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(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 })] }));
|
}) }), _jsx("div", { ref: bottomRef })] }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,10 +108,15 @@ export function MessageList({ channelId }: MessageListProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasMore && (
|
{!hasMore && (
|
||||||
<div className="px-4 pt-6 pb-4">
|
<div className="px-4 pt-8 pb-4">
|
||||||
<h3 className="text-2xl font-bold text-discord-text-primary">Welcome to the channel!</h3>
|
<div className="w-[68px] h-[68px] rounded-full bg-discord-bg-accent flex items-center justify-center mb-4 text-white">
|
||||||
<p className="text-discord-text-muted text-sm mt-1">This is the start of the conversation.</p>
|
<svg width="42" height="42" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<div className="mt-4 border-b border-discord-bg-hover" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -124,12 +129,12 @@ export function MessageList({ channelId }: MessageListProps) {
|
|||||||
return (
|
return (
|
||||||
<React.Fragment key={msg.id}>
|
<React.Fragment key={msg.id}>
|
||||||
{showDate && (
|
{showDate && (
|
||||||
<div className="flex items-center px-4 my-4">
|
<div className="flex items-center px-4 my-6 select-none pointer-events-none">
|
||||||
<div className="flex-1 border-t border-discord-bg-hover" />
|
<div className="flex-1 h-[1px] bg-discord-modifier-accent" />
|
||||||
<span className="px-2 text-xs font-semibold text-discord-text-muted">
|
<span className="px-2 text-[12px] font-bold text-discord-text-muted leading-tight">
|
||||||
{formatDateDivider(msg.createdAt)}
|
{formatDateDivider(msg.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 border-t border-discord-bg-hover" />
|
<div className="flex-1 h-[1px] bg-discord-modifier-accent" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Message
|
<Message
|
||||||
|
|||||||
@@ -25,5 +25,5 @@ export function TypingIndicator({ channelId }) {
|
|||||||
else {
|
else {
|
||||||
text = 'Several people are typing';
|
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 (
|
return (
|
||||||
<div className="h-6 px-4 flex items-center text-xs text-discord-text-muted">
|
<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-1">
|
<div className="flex items-center gap-2">
|
||||||
<span className="flex gap-0.5">
|
<div className="flex gap-[2px] bg-discord-bg-accent/20 rounded-full px-2 py-1">
|
||||||
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
<div className="w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce" style={{ animationDelay: '0ms', animationDuration: '0.8s' }} />
|
||||||
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
<div className="w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce" style={{ animationDelay: '150ms', animationDuration: '0.8s' }} />
|
||||||
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
<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>
|
||||||
<span className="font-medium">{text}</span>
|
|
||||||
<span>...</span>
|
|
||||||
</div>
|
</div>
|
||||||
</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 { useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { ServerSidebar } from './ServerSidebar';
|
import { ServerSidebar } from './ServerSidebar';
|
||||||
import { ChannelSidebar } from './ChannelSidebar';
|
import { ChannelSidebar } from './ChannelSidebar';
|
||||||
import { MainContent } from './MainContent';
|
import { MainContent } from './MainContent';
|
||||||
import { MemberSidebar } from './MemberSidebar';
|
import { MemberSidebar } from './MemberSidebar';
|
||||||
import { MobileNav } from './MobileNav';
|
|
||||||
import { ImagePreview } from '../chat/ImagePreview';
|
import { ImagePreview } from '../chat/ImagePreview';
|
||||||
import { CreateServerModal } from '../modals/CreateServer';
|
import { CreateServerModal } from '../modals/CreateServer';
|
||||||
import { JoinServerModal } from '../modals/JoinServer';
|
import { JoinServerModal } from '../modals/JoinServer';
|
||||||
@@ -13,13 +12,16 @@ import { CreateChannelModal } from '../modals/CreateChannel';
|
|||||||
import { InviteModal } from '../modals/InviteModal';
|
import { InviteModal } from '../modals/InviteModal';
|
||||||
import { UserSettingsModal } from '../modals/UserSettings';
|
import { UserSettingsModal } from '../modals/UserSettings';
|
||||||
import { ServerSettingsModal } from '../modals/ServerSettings';
|
import { ServerSettingsModal } from '../modals/ServerSettings';
|
||||||
|
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||||
|
import { useLiveKit } from '../../hooks/useLiveKit';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { serverId, channelId } = useParams();
|
const { serverId, channelId, inviteCode } = useParams();
|
||||||
const { user, isLoading } = useAuth();
|
const { user, isLoading } = useAuth();
|
||||||
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
|
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
|
||||||
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
|
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
|
||||||
@@ -27,10 +29,29 @@ export function AppLayout() {
|
|||||||
const loadMessages = useChatStore((s) => s.loadMessages);
|
const loadMessages = useChatStore((s) => s.loadMessages);
|
||||||
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
||||||
const setShowDms = useUIStore((s) => s.setShowDms);
|
const setShowDms = useUIStore((s) => s.setShowDms);
|
||||||
|
const openModal = useUIStore((s) => s.openModal);
|
||||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||||
const isMobile = useUIStore((s) => s.isMobile);
|
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
|
// Initialize WebSocket
|
||||||
useWebSocket();
|
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
|
// Responsive detection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||||
@@ -50,6 +71,11 @@ export function AppLayout() {
|
|||||||
loadServerDetail(serverId);
|
loadServerDetail(serverId);
|
||||||
}
|
}
|
||||||
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
|
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (inviteCode) {
|
||||||
|
openModal('joinServer');
|
||||||
|
}
|
||||||
|
}, [inviteCode, openModal]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (channelId) {
|
if (channelId) {
|
||||||
setCurrentChannel(channelId);
|
setCurrentChannel(channelId);
|
||||||
@@ -62,5 +88,5 @@ export function AppLayout() {
|
|||||||
if (isLoading || !user) {
|
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 (_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 { InviteModal } from '../modals/InviteModal';
|
||||||
import { UserSettingsModal } from '../modals/UserSettings';
|
import { UserSettingsModal } from '../modals/UserSettings';
|
||||||
import { ServerSettingsModal } from '../modals/ServerSettings';
|
import { ServerSettingsModal } from '../modals/ServerSettings';
|
||||||
|
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||||
|
import { useLiveKit } from '../../hooks/useLiveKit';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
|
|
||||||
export function AppLayout() {
|
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 { user, isLoading } = useAuth();
|
||||||
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
|
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
|
||||||
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
|
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
|
||||||
@@ -27,12 +30,40 @@ export function AppLayout() {
|
|||||||
const loadMessages = useChatStore((s) => s.loadMessages);
|
const loadMessages = useChatStore((s) => s.loadMessages);
|
||||||
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
||||||
const setShowDms = useUIStore((s) => s.setShowDms);
|
const setShowDms = useUIStore((s) => s.setShowDms);
|
||||||
|
const openModal = useUIStore((s) => s.openModal);
|
||||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||||
const isMobile = useUIStore((s) => s.isMobile);
|
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
|
// Initialize WebSocket
|
||||||
useWebSocket();
|
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
|
// Responsive detection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||||
@@ -53,6 +84,12 @@ export function AppLayout() {
|
|||||||
}
|
}
|
||||||
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
|
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inviteCode) {
|
||||||
|
openModal('joinServer');
|
||||||
|
}
|
||||||
|
}, [inviteCode, openModal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (channelId) {
|
if (channelId) {
|
||||||
setCurrentChannel(channelId);
|
setCurrentChannel(channelId);
|
||||||
@@ -77,21 +114,35 @@ export function AppLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex bg-discord-bg-tertiary overflow-hidden">
|
||||||
<MobileNav />
|
|
||||||
|
|
||||||
{/* Server sidebar - always visible on desktop, toggled on mobile */}
|
{/* 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={`${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`}>
|
||||||
<div className="flex h-full">
|
|
||||||
<ServerSidebar />
|
<ServerSidebar />
|
||||||
<ChannelSidebar />
|
<ChannelSidebar
|
||||||
</div>
|
onToggleMic={toggleMic}
|
||||||
|
onToggleCamera={toggleCamera}
|
||||||
|
onToggleScreenShare={toggleScreenShare}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* 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 />
|
<MainContent />
|
||||||
|
{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 activity—like playing a game or hanging out on voice—we’ll show it here!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<MemberSidebar />
|
<MemberSidebar />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
@@ -102,6 +153,21 @@ export function AppLayout() {
|
|||||||
<UserSettingsModal />
|
<UserSettingsModal />
|
||||||
<ServerSettingsModal />
|
<ServerSettingsModal />
|
||||||
<ImagePreview />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import { VoiceControls } from '../voice/VoiceControls';
|
|||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
export function ChannelSidebar() {
|
export function ChannelSidebar({ onToggleMic, onToggleCamera, onToggleScreenShare }) {
|
||||||
const servers = useServerStore((s) => s.servers);
|
const servers = useServerStore((s) => s.servers);
|
||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
const channels = useServerStore((s) => s.channels);
|
const channels = useServerStore((s) => s.channels);
|
||||||
|
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||||
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
||||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||||
const openModal = useUIStore((s) => s.openModal);
|
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 voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video');
|
||||||
const handleChannelClick = (channelId) => {
|
const handleChannelClick = (channelId) => {
|
||||||
setCurrentChannel(channelId);
|
setCurrentChannel(channelId);
|
||||||
navigate(`/channels/${currentServerId}/${channelId}`);
|
navigate(`/channels/${currentServerId || '@me'}/${channelId}`);
|
||||||
|
};
|
||||||
|
const handleHomeClick = () => {
|
||||||
|
setCurrentChannel(null);
|
||||||
|
navigate('/channels/@me');
|
||||||
};
|
};
|
||||||
const handleVoiceJoin = (channelId) => {
|
const handleVoiceJoin = (channelId) => {
|
||||||
setCurrentVoiceChannel(channelId);
|
setCurrentVoiceChannel(channelId);
|
||||||
@@ -39,9 +44,27 @@ export function ChannelSidebar() {
|
|||||||
wsSend({ type: 'voice_leave' });
|
wsSend({ type: 'voice_leave' });
|
||||||
};
|
};
|
||||||
if (!server) {
|
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
|
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) => {
|
||||||
? 'bg-discord-bg-active text-white'
|
e.stopPropagation();
|
||||||
: '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" }) }) })] }))] }));
|
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 { Avatar } from '../ui/Avatar';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
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 servers = useServerStore((s) => s.servers);
|
||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
const channels = useServerStore((s) => s.channels);
|
const channels = useServerStore((s) => s.channels);
|
||||||
|
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||||
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
||||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||||
const openModal = useUIStore((s) => s.openModal);
|
const openModal = useUIStore((s) => s.openModal);
|
||||||
@@ -32,7 +39,12 @@ export function ChannelSidebar() {
|
|||||||
|
|
||||||
const handleChannelClick = (channelId: string) => {
|
const handleChannelClick = (channelId: string) => {
|
||||||
setCurrentChannel(channelId);
|
setCurrentChannel(channelId);
|
||||||
navigate(`/channels/${currentServerId}/${channelId}`);
|
navigate(`/channels/${currentServerId || '@me'}/${channelId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHomeClick = () => {
|
||||||
|
setCurrentChannel(null);
|
||||||
|
navigate('/channels/@me');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVoiceJoin = (channelId: string) => {
|
const handleVoiceJoin = (channelId: string) => {
|
||||||
@@ -47,30 +59,94 @@ export function ChannelSidebar() {
|
|||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return (
|
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">
|
||||||
<div className="h-12 px-4 flex items-center border-b border-discord-bg-tertiary">
|
<div className="h-12 px-4 flex items-center shadow-header z-10">
|
||||||
<span className="font-semibold text-discord-text-primary">Direct Messages</span>
|
<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>
|
||||||
<div className="flex-1 p-2 text-discord-text-muted text-sm">
|
<div className="flex-1 overflow-y-auto pt-4 px-2 no-scrollbar">
|
||||||
<p className="px-2 py-4">Select or create a DM conversation</p>
|
<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>
|
||||||
|
|
||||||
|
<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 area at bottom */}
|
||||||
{user && (
|
{user && (
|
||||||
<div className="h-[52px] px-2 bg-discord-bg-members flex items-center gap-2">
|
<div className="h-[52px] px-2 bg-[#232428] flex items-center gap-2 select-none">
|
||||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status} />
|
<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="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">{user.displayName ?? user.username}</div>
|
<div className="text-[14px] font-bold text-discord-text-primary truncate leading-tight">{user.displayName ?? user.username}</div>
|
||||||
<div className="text-[10px] text-discord-text-muted truncate">@{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>
|
||||||
<button
|
</div>
|
||||||
onClick={() => openModal('userSettings')}
|
<div className="flex items-center">
|
||||||
className="p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors"
|
<UserAreaButton title="Mute">
|
||||||
title="User Settings"
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</UserAreaButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -78,29 +154,36 @@ export function ChannelSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Server header */}
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal('serverSettings')}
|
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>
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Channels */}
|
{/* 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 */}
|
{/* Text Channels */}
|
||||||
{textChannels.length > 0 && (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between px-1 mb-1">
|
<div className="flex items-center justify-between px-1 mb-1 group cursor-pointer">
|
||||||
<span className="text-xs font-bold text-discord-text-muted uppercase tracking-wide">Text Channels</span>
|
<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 && (
|
{isAdminUser && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal('createChannel')}
|
onClick={(e) => {
|
||||||
className="text-discord-text-muted hover:text-discord-text-secondary transition-colors"
|
e.stopPropagation();
|
||||||
|
openModal('createChannel');
|
||||||
|
}}
|
||||||
|
className="text-discord-text-muted hover:text-discord-text-primary transition-colors"
|
||||||
title="Create Channel"
|
title="Create Channel"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
@@ -109,34 +192,42 @@ export function ChannelSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-[2px]">
|
||||||
{textChannels.map((channel) => (
|
{textChannels.map((channel) => (
|
||||||
<button
|
<button
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
onClick={() => handleChannelClick(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
|
currentChannelId === channel.id
|
||||||
? 'bg-discord-bg-active text-white'
|
? 'bg-discord-modifier-selected text-white'
|
||||||
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
|
: '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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="truncate">{channel.name}</span>
|
<span className="truncate font-medium text-[16px]">{channel.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Voice Channels */}
|
{/* Voice Channels */}
|
||||||
{voiceChannels.length > 0 && (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between px-1 mb-1">
|
<div className="flex items-center justify-between px-1 mb-1 group cursor-pointer">
|
||||||
<span className="text-xs font-bold text-discord-text-muted uppercase tracking-wide">Voice Channels</span>
|
<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 && (
|
{isAdminUser && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal('createChannel')}
|
onClick={(e) => {
|
||||||
className="text-discord-text-muted hover:text-discord-text-secondary transition-colors"
|
e.stopPropagation();
|
||||||
|
openModal('createChannel');
|
||||||
|
}}
|
||||||
|
className="text-discord-text-muted hover:text-discord-text-primary transition-colors"
|
||||||
title="Create Channel"
|
title="Create Channel"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
@@ -145,6 +236,7 @@ export function ChannelSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-[2px]">
|
||||||
{voiceChannels.map((channel) => (
|
{voiceChannels.map((channel) => (
|
||||||
<VoiceChannel
|
<VoiceChannel
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
@@ -154,49 +246,75 @@ export function ChannelSidebar() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Invite button */}
|
{/* Restore Invite Button */}
|
||||||
|
<div className="pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal('invite')}
|
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"
|
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="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="opacity-60">
|
||||||
<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" />
|
<path d="M13 13v5h-2v-5H6v-2h5V6h2v5h5v2h-5z" />
|
||||||
</svg>
|
</svg>
|
||||||
Invite People
|
Invite People
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Voice controls */}
|
{/* Voice controls */}
|
||||||
{currentVoiceChannelId && (
|
{currentVoiceChannelId && (
|
||||||
<VoiceControls
|
<VoiceControls
|
||||||
onDisconnect={handleVoiceDisconnect}
|
onDisconnect={handleVoiceDisconnect}
|
||||||
onToggleMic={() => {}}
|
onToggleMic={onToggleMic}
|
||||||
onToggleCamera={() => {}}
|
onToggleCamera={onToggleCamera}
|
||||||
onToggleScreenShare={() => {}}
|
onToggleScreenShare={onToggleScreenShare}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User area */}
|
{/* User area */}
|
||||||
{user && (
|
{user && (
|
||||||
<div className="h-[52px] px-2 bg-discord-bg-members flex items-center gap-2">
|
<div className="h-[52px] px-2 bg-[#232428] flex items-center gap-2 select-none">
|
||||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status} />
|
<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="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">{user.displayName ?? user.username}</div>
|
<div className="text-[14px] font-bold text-discord-text-primary truncate leading-tight">{user.displayName ?? user.username}</div>
|
||||||
<div className="text-[10px] text-discord-text-muted truncate">@{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>
|
||||||
<button
|
</div>
|
||||||
onClick={() => openModal('userSettings')}
|
|
||||||
className="p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors"
|
<div className="flex items-center">
|
||||||
title="User Settings"
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</UserAreaButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 { MessageInput } from '../chat/MessageInput';
|
||||||
import { TypingIndicator } from '../chat/TypingIndicator';
|
import { TypingIndicator } from '../chat/TypingIndicator';
|
||||||
import { VoiceGrid } from '../voice/VoiceGrid';
|
import { VoiceGrid } from '../voice/VoiceGrid';
|
||||||
|
import { FriendsPage } from '../chat/FriendsPage';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
export function MainContent() {
|
export function MainContent() {
|
||||||
const channels = useServerStore((s) => s.channels);
|
const channels = useServerStore((s) => s.channels);
|
||||||
@@ -13,13 +14,16 @@ export function MainContent() {
|
|||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
|
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
|
||||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
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 showDms = useUIStore((s) => s.showDms);
|
||||||
const channel = channels.find(c => c.id === currentChannelId);
|
const channel = channels.find(c => c.id === currentChannelId);
|
||||||
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
|
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
|
||||||
// DM view or no server selected
|
// DM view or no server selected
|
||||||
if (showDms || !currentServerId) {
|
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
|
// No channel selected
|
||||||
if (!currentChannelId || !channel) {
|
if (!currentChannelId || !channel) {
|
||||||
@@ -27,8 +31,8 @@ export function MainContent() {
|
|||||||
}
|
}
|
||||||
// Voice/Video channel view
|
// Voice/Video channel view
|
||||||
if (isVoiceChannel) {
|
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
|
// 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 { MessageInput } from '../chat/MessageInput';
|
||||||
import { TypingIndicator } from '../chat/TypingIndicator';
|
import { TypingIndicator } from '../chat/TypingIndicator';
|
||||||
import { VoiceGrid } from '../voice/VoiceGrid';
|
import { VoiceGrid } from '../voice/VoiceGrid';
|
||||||
|
import { FriendsPage } from '../chat/FriendsPage';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
|
|
||||||
export function MainContent() {
|
export function MainContent() {
|
||||||
@@ -14,7 +15,7 @@ export function MainContent() {
|
|||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
|
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
|
||||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
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 showDms = useUIStore((s) => s.showDms);
|
||||||
|
|
||||||
const channel = channels.find(c => c.id === currentChannelId);
|
const channel = channels.find(c => c.id === currentChannelId);
|
||||||
@@ -22,17 +23,21 @@ export function MainContent() {
|
|||||||
|
|
||||||
// DM view or no server selected
|
// DM view or no server selected
|
||||||
if (showDms || !currentServerId) {
|
if (showDms || !currentServerId) {
|
||||||
|
if (!currentChannelId) {
|
||||||
|
return <FriendsPage />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-discord-bg-primary">
|
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative">
|
||||||
<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 flex-shrink-0 z-10">
|
||||||
<span className="font-bold text-discord-text-primary">Home</span>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
</div>
|
<span className="text-discord-text-muted font-bold text-lg">@</span>
|
||||||
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
|
<span className="font-bold text-discord-text-primary truncate">Direct Message</span>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<MessageList channelId={currentChannelId} />
|
||||||
|
<TypingIndicator channelId={currentChannelId} />
|
||||||
|
<MessageInput channelId={currentChannelId} channelName="Direct Message" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,7 +46,7 @@ export function MainContent() {
|
|||||||
if (!currentChannelId || !channel) {
|
if (!currentChannelId || !channel) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-discord-bg-primary">
|
<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>
|
<span className="text-discord-text-muted">Select a channel</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
|
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
|
||||||
@@ -55,7 +60,7 @@ export function MainContent() {
|
|||||||
if (isVoiceChannel) {
|
if (isVoiceChannel) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-discord-bg-primary">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
|
<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" />
|
<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>
|
<span className="font-bold text-discord-text-primary">{channel.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VoiceGrid participants={[]} />
|
<VoiceGrid participants={participants} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text channel view
|
// Text channel view
|
||||||
return (
|
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 */}
|
{/* 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">
|
<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">
|
<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" />
|
<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>
|
</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 && (
|
{channel.topic && (
|
||||||
<>
|
<>
|
||||||
<div className="w-px h-6 bg-discord-bg-hover mx-1" />
|
<div className="w-[1px] h-6 bg-discord-bg-accent mx-2" />
|
||||||
<span className="text-xs text-discord-text-muted truncate">{channel.topic}</span>
|
<span className="text-xs text-discord-text-muted truncate leading-tight">{channel.topic}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-4 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={toggleMemberList}
|
onClick={toggleMemberList}
|
||||||
className={`p-1 transition-colors ${
|
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"
|
title="Toggle Member List"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Avatar } from '../ui/Avatar';
|
|||||||
export function MemberSidebar() {
|
export function MemberSidebar() {
|
||||||
const members = useServerStore((s) => s.members);
|
const members = useServerStore((s) => s.members);
|
||||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||||
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
if (!memberListOpen)
|
if (!memberListOpen)
|
||||||
return null;
|
return null;
|
||||||
const onlineMembers = members.filter(m => m.user.status !== 'offline');
|
const onlineMembers = members.filter(m => m.user.status !== 'offline');
|
||||||
@@ -14,11 +15,26 @@ export function MemberSidebar() {
|
|||||||
admin: 'text-discord-blurple',
|
admin: 'text-discord-blurple',
|
||||||
member: 'text-discord-text-primary',
|
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;
|
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) => {
|
})] })), 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;
|
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 React from 'react';
|
||||||
|
import type { MemberWithUser } from '@opencord/shared';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
@@ -6,6 +7,7 @@ import { Avatar } from '../ui/Avatar';
|
|||||||
export function MemberSidebar() {
|
export function MemberSidebar() {
|
||||||
const members = useServerStore((s) => s.members);
|
const members = useServerStore((s) => s.members);
|
||||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||||
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
|
|
||||||
if (!memberListOpen) return null;
|
if (!memberListOpen) return null;
|
||||||
|
|
||||||
@@ -18,8 +20,25 @@ export function MemberSidebar() {
|
|||||||
member: 'text-discord-text-primary',
|
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 (
|
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">
|
<div className="p-3">
|
||||||
{/* Online */}
|
{/* Online */}
|
||||||
{onlineMembers.length > 0 && (
|
{onlineMembers.length > 0 && (
|
||||||
@@ -32,7 +51,8 @@ export function MemberSidebar() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={member.userId}
|
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
|
<Avatar
|
||||||
src={member.user.avatar}
|
src={member.user.avatar}
|
||||||
@@ -41,11 +61,14 @@ export function MemberSidebar() {
|
|||||||
status={member.user.status}
|
status={member.user.status}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<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}
|
{displayName}
|
||||||
</div>
|
</div>
|
||||||
{member.user.customStatus && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,16 +88,18 @@ export function MemberSidebar() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={member.userId}
|
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
|
<Avatar
|
||||||
src={member.user.avatar}
|
src={member.user.avatar}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
size={32}
|
size={32}
|
||||||
status="offline"
|
status="offline"
|
||||||
|
className="opacity-60"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<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}
|
{displayName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { Tooltip } from '../ui/Tooltip';
|
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() {
|
export function ServerSidebar() {
|
||||||
const servers = useServerStore((s) => s.servers);
|
const servers = useServerStore((s) => s.servers);
|
||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
@@ -21,13 +44,5 @@ export function ServerSidebar() {
|
|||||||
setCurrentServer(null);
|
setCurrentServer(null);
|
||||||
navigate('/channels/@me');
|
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
|
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" })] }));
|
||||||
? '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" }) }) }) })] }));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,89 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { Tooltip } from '../ui/Tooltip';
|
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() {
|
export function ServerSidebar() {
|
||||||
const servers = useServerStore((s) => s.servers);
|
const servers = useServerStore((s) => s.servers);
|
||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
@@ -26,83 +106,45 @@ export function ServerSidebar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[72px] bg-discord-bg-tertiary flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 gap-2">
|
<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">
|
||||||
{/* DM Button */}
|
<SidebarItem
|
||||||
<Tooltip content="Direct Messages" position="right">
|
id="@me"
|
||||||
<button
|
name="Direct Messages"
|
||||||
|
active={showDms}
|
||||||
onClick={handleDmClick}
|
onClick={handleDmClick}
|
||||||
className={`w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 flex items-center justify-center ${
|
type="dm"
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-8 h-0.5 bg-discord-bg-primary rounded-full" />
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Add Server Button */}
|
<div className="w-8 h-[2px] bg-discord-modifier-accent rounded-full mb-2" />
|
||||||
<Tooltip content="Add a Server" position="right">
|
|
||||||
<button
|
{servers.map((server) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={server.id}
|
||||||
|
id={server.id}
|
||||||
|
name={server.name}
|
||||||
|
icon={server.icon}
|
||||||
|
active={currentServerId === server.id}
|
||||||
|
onClick={() => handleServerClick(server.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<SidebarItem
|
||||||
|
id="add-server"
|
||||||
|
name="Add a Server"
|
||||||
|
active={false}
|
||||||
onClick={() => openModal('createServer')}
|
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"
|
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>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Join Server Button */}
|
<SidebarItem
|
||||||
<Tooltip content="Join a Server" position="right">
|
id="join-server"
|
||||||
<button
|
name="Join a Server"
|
||||||
|
active={false}
|
||||||
onClick={() => openModal('joinServer')}
|
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"
|
type="action"
|
||||||
>
|
actionType="join"
|
||||||
<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" />
|
</nav>
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function InviteModal() {
|
|||||||
const generateInvite = useServerStore((s) => s.generateInvite);
|
const generateInvite = useServerStore((s) => s.generateInvite);
|
||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
const isOpen = activeModal === 'invite';
|
const isOpen = activeModal === 'invite';
|
||||||
|
const inviteUrl = inviteCode ? `${window.location.origin}/join/${inviteCode}` : '';
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && currentServerId) {
|
if (isOpen && currentServerId) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -24,8 +25,10 @@ export function InviteModal() {
|
|||||||
}
|
}
|
||||||
}, [isOpen, currentServerId, generateInvite]);
|
}, [isOpen, currentServerId, generateInvite]);
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
|
if (!inviteUrl)
|
||||||
|
return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(inviteCode);
|
await navigator.clipboard.writeText(inviteUrl);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
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-green text-white'
|
||||||
: 'bg-discord-blurple hover:bg-discord-blurple-hover text-white'}`, children: copied ? 'Copied!' : 'Copy' })] })] }));
|
: '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 [inviteCode, setInviteCode] = useState('');
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
const activeModal = useUIStore((s) => s.activeModal);
|
const activeModal = useUIStore((s) => s.activeModal);
|
||||||
const closeModal = useUIStore((s) => s.closeModal);
|
const closeModal = useUIStore((s) => s.closeModal);
|
||||||
const generateInvite = useServerStore((s) => s.generateInvite);
|
const generateInvite = useServerStore((s) => s.generateInvite);
|
||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
|
|
||||||
const isOpen = activeModal === 'invite';
|
const isOpen = activeModal === 'invite';
|
||||||
|
const inviteUrl = inviteCode ? `${window.location.origin}/join/${inviteCode}` : '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && currentServerId) {
|
if (isOpen && currentServerId) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
generateInvite(currentServerId)
|
generateInvite(currentServerId)
|
||||||
.then(code => {
|
.then(code => {
|
||||||
setInviteCode(code);
|
setInviteCode(code);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => setIsLoading(false));
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to generate invite link');
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [isOpen, currentServerId, generateInvite]);
|
}, [isOpen, currentServerId, generateInvite]);
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
|
if (!inviteUrl) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(inviteCode);
|
await navigator.clipboard.writeText(inviteUrl);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: select the text
|
|
||||||
const input = document.querySelector<HTMLInputElement>('.invite-code-input');
|
const input = document.querySelector<HTMLInputElement>('.invite-code-input');
|
||||||
if (input) {
|
if (input) {
|
||||||
input.select();
|
input.select();
|
||||||
@@ -46,18 +52,23 @@ export function InviteModal() {
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends">
|
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends">
|
||||||
<p className="text-discord-text-secondary text-sm mb-4">
|
<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>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={isLoading ? 'Generating...' : inviteCode}
|
value={isLoading ? 'Generating...' : inviteUrl}
|
||||||
readOnly
|
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
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
disabled={isLoading}
|
disabled={isLoading || !inviteUrl}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
|
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
|
||||||
copied
|
copied
|
||||||
? 'bg-discord-green text-white'
|
? 'bg-discord-green text-white'
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
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 { Modal } from '../ui/Modal';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
export function JoinServerModal() {
|
export function JoinServerModal() {
|
||||||
|
const { inviteCode: urlInviteCode } = useParams();
|
||||||
const [inviteCode, setInviteCode] = useState('');
|
const [inviteCode, setInviteCode] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -13,6 +14,11 @@ export function JoinServerModal() {
|
|||||||
const loadServers = useServerStore((s) => s.loadServers);
|
const loadServers = useServerStore((s) => s.loadServers);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isOpen = activeModal === 'joinServer';
|
const isOpen = activeModal === 'joinServer';
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && urlInviteCode) {
|
||||||
|
setInviteCode(urlInviteCode);
|
||||||
|
}
|
||||||
|
}, [isOpen, urlInviteCode]);
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
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 { Modal } from '../ui/Modal';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
export function JoinServerModal() {
|
export function JoinServerModal() {
|
||||||
|
const { inviteCode: urlInviteCode } = useParams<{ inviteCode?: string }>();
|
||||||
const [inviteCode, setInviteCode] = useState('');
|
const [inviteCode, setInviteCode] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const activeModal = useUIStore((s) => s.activeModal);
|
const activeModal = useUIStore((s) => s.activeModal);
|
||||||
const closeModal = useUIStore((s) => s.closeModal);
|
const closeModal = useUIStore((s) => s.closeModal);
|
||||||
const loadServers = useServerStore((s) => s.loadServers);
|
const joinByCode = useServerStore((s) => s.joinByCode);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const isOpen = activeModal === 'joinServer';
|
const isOpen = activeModal === 'joinServer';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && urlInviteCode) {
|
||||||
|
setInviteCode(urlInviteCode);
|
||||||
|
}
|
||||||
|
}, [isOpen, urlInviteCode]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
if (!inviteCode.trim()) {
|
const code = inviteCode.trim();
|
||||||
|
if (!code) {
|
||||||
setError('Invite code is required');
|
setError('Invite code is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('opencord_token');
|
const server = await joinByCode(code);
|
||||||
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();
|
|
||||||
closeModal();
|
closeModal();
|
||||||
setInviteCode('');
|
setInviteCode('');
|
||||||
navigate(`/channels/${server.id}`);
|
navigate(`/channels/${server.id}`);
|
||||||
@@ -60,7 +52,7 @@ export function JoinServerModal() {
|
|||||||
Enter an invite code to join an existing server.
|
Enter an invite code to join an existing server.
|
||||||
</p>
|
</p>
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function UserSettingsModal() {
|
|||||||
const logout = useAuthStore((s) => s.logout);
|
const logout = useAuthStore((s) => s.logout);
|
||||||
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
||||||
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
|
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
|
||||||
|
const [status, setStatus] = useState(user?.status ?? 'online');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -24,6 +25,7 @@ export function UserSettingsModal() {
|
|||||||
await updateProfile({
|
await updateProfile({
|
||||||
displayName: displayName.trim() || undefined,
|
displayName: displayName.trim() || undefined,
|
||||||
customStatus: customStatus.trim() || undefined,
|
customStatus: customStatus.trim() || undefined,
|
||||||
|
status: status,
|
||||||
});
|
});
|
||||||
setSuccess('Profile updated!');
|
setSuccess('Profile updated!');
|
||||||
setTimeout(() => setSuccess(''), 2000);
|
setTimeout(() => setSuccess(''), 2000);
|
||||||
@@ -41,5 +43,5 @@ export function UserSettingsModal() {
|
|||||||
};
|
};
|
||||||
if (!user)
|
if (!user)
|
||||||
return null;
|
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 [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
||||||
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
|
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
|
||||||
|
const [status, setStatus] = useState(user?.status ?? 'online');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -27,7 +28,8 @@ export function UserSettingsModal() {
|
|||||||
await updateProfile({
|
await updateProfile({
|
||||||
displayName: displayName.trim() || undefined,
|
displayName: displayName.trim() || undefined,
|
||||||
customStatus: customStatus.trim() || undefined,
|
customStatus: customStatus.trim() || undefined,
|
||||||
});
|
status: status as any,
|
||||||
|
} as any);
|
||||||
setSuccess('Profile updated!');
|
setSuccess('Profile updated!');
|
||||||
setTimeout(() => setSuccess(''), 2000);
|
setTimeout(() => setSuccess(''), 2000);
|
||||||
} catch (err) {
|
} 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 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>
|
<div>
|
||||||
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
|
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
|
||||||
Display Name
|
Display Name
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
const statusColors = {
|
const statusColors = {
|
||||||
online: 'bg-discord-green',
|
online: 'bg-discord-green',
|
||||||
idle: 'bg-discord-yellow',
|
idle: 'bg-discord-yellow',
|
||||||
dnd: 'bg-discord-red',
|
dnd: 'bg-discord-red',
|
||||||
offline: 'bg-gray-500',
|
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 initials = name.charAt(0).toUpperCase();
|
||||||
const fontSize = size < 32 ? 'text-xs' : size < 48 ? 'text-sm' : 'text-lg';
|
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';
|
e.target.style.display = 'none';
|
||||||
const parent = e.target.parentElement;
|
const parent = e.target.parentElement;
|
||||||
if (parent) {
|
if (parent) {
|
||||||
@@ -16,10 +31,10 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick }
|
|||||||
if (fallback)
|
if (fallback)
|
||||||
fallback.style.display = 'flex';
|
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,
|
width: size * 0.35,
|
||||||
height: size * 0.35,
|
height: size * 0.35,
|
||||||
minWidth: 10,
|
minWidth: 12,
|
||||||
minHeight: 10,
|
minHeight: 12,
|
||||||
} }))] }));
|
} }))] }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import type { User } from '@opencord/shared';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
@@ -6,25 +8,40 @@ interface AvatarProps {
|
|||||||
size?: number;
|
size?: number;
|
||||||
status?: 'online' | 'idle' | 'dnd' | 'offline' | null;
|
status?: 'online' | 'idle' | 'dnd' | 'offline' | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
user?: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
online: 'bg-discord-green',
|
online: 'bg-discord-green',
|
||||||
idle: 'bg-discord-yellow',
|
idle: 'bg-discord-yellow',
|
||||||
dnd: 'bg-discord-red',
|
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 initials = name.charAt(0).toUpperCase();
|
||||||
const fontSize = size < 32 ? 'text-xs' : size < 48 ? 'text-sm' : 'text-lg';
|
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 (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ width: size, height: size }}
|
||||||
onClick={onClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{src ? (
|
{src ? (
|
||||||
<img
|
<img
|
||||||
@@ -49,12 +66,12 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick }
|
|||||||
</div>
|
</div>
|
||||||
{status && (
|
{status && (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
width: size * 0.35,
|
width: size * 0.35,
|
||||||
height: size * 0.35,
|
height: size * 0.35,
|
||||||
minWidth: 10,
|
minWidth: 12,
|
||||||
minHeight: 10,
|
minHeight: 12,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function ContextMenu({ items, children }: ContextMenuProps) {
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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 }}
|
style={{ left: position.x, top: position.y }}
|
||||||
>
|
>
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
|
|||||||
{children}
|
{children}
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<div
|
<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}
|
{content}
|
||||||
</div>
|
</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 isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
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 toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||||
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
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 channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||||
const channelName = channel?.name ?? 'Voice Channel';
|
const channelName = channel?.name ?? 'Voice Channel';
|
||||||
const handleMic = () => {
|
const handleMic = () => {
|
||||||
toggleMute();
|
toggleMic();
|
||||||
onToggleMic();
|
onToggleMic();
|
||||||
};
|
};
|
||||||
const handleDeafen = () => {
|
const handleDeafen = () => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog
|
|||||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
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 toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||||
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||||
@@ -27,7 +27,7 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog
|
|||||||
const channelName = channel?.name ?? 'Voice Channel';
|
const channelName = channel?.name ?? 'Voice Channel';
|
||||||
|
|
||||||
const handleMic = () => {
|
const handleMic = () => {
|
||||||
toggleMute();
|
toggleMic();
|
||||||
onToggleMic();
|
onToggleMic();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useRef, useEffect } from 'react';
|
|||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
export function VoiceUser({ participant }) {
|
export function VoiceUser({ participant }) {
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
|
const audioRef = useRef(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const videoEl = videoRef.current;
|
const videoEl = videoRef.current;
|
||||||
if (!videoEl)
|
if (!videoEl)
|
||||||
@@ -16,6 +17,14 @@ export function VoiceUser({ participant }) {
|
|||||||
videoEl.srcObject = null;
|
videoEl.srcObject = null;
|
||||||
}
|
}
|
||||||
}, [participant.videoTrack, participant.screenTrack]);
|
}, [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 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) {
|
export function VoiceUser({ participant }: VoiceUserProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const videoEl = videoRef.current;
|
const videoEl = videoRef.current;
|
||||||
@@ -22,7 +23,16 @@ export function VoiceUser({ participant }: VoiceUserProps) {
|
|||||||
}
|
}
|
||||||
}, [participant.videoTrack, participant.screenTrack]);
|
}, [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 hasVideo = participant.isCameraOn || participant.isScreenSharing;
|
||||||
|
const isLocal = participant.isLocal;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -31,12 +41,15 @@ export function VoiceUser({ participant }: VoiceUserProps) {
|
|||||||
}`}
|
}`}
|
||||||
style={{ aspectRatio: '16/9', minHeight: '200px' }}
|
style={{ aspectRatio: '16/9', minHeight: '200px' }}
|
||||||
>
|
>
|
||||||
|
{/* Audio element for remote participants */}
|
||||||
|
{!isLocal && <audio ref={audioRef} autoPlay />}
|
||||||
|
|
||||||
{hasVideo ? (
|
{hasVideo ? (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
muted={participant.userId === 'local'}
|
muted={isLocal}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function useLiveKit() {
|
|||||||
if (!r)
|
if (!r)
|
||||||
return;
|
return;
|
||||||
const allParticipants = [];
|
const allParticipants = [];
|
||||||
const processParticipant = (p) => {
|
const processParticipant = (p, isLocal) => {
|
||||||
const { userId, username } = parseIdentity(p.identity);
|
const { userId, username } = parseIdentity(p.identity);
|
||||||
let audioTrack = null;
|
let audioTrack = null;
|
||||||
let videoTrack = null;
|
let videoTrack = null;
|
||||||
@@ -50,13 +50,14 @@ export function useLiveKit() {
|
|||||||
isMuted: !p.isMicrophoneEnabled,
|
isMuted: !p.isMicrophoneEnabled,
|
||||||
isCameraOn: p.isCameraEnabled,
|
isCameraOn: p.isCameraEnabled,
|
||||||
isScreenSharing: p.isScreenShareEnabled,
|
isScreenSharing: p.isScreenShareEnabled,
|
||||||
|
isLocal,
|
||||||
audioTrack,
|
audioTrack,
|
||||||
videoTrack,
|
videoTrack,
|
||||||
screenTrack,
|
screenTrack,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
processParticipant(r.localParticipant);
|
processParticipant(r.localParticipant, true);
|
||||||
r.remoteParticipants.forEach(processParticipant);
|
r.remoteParticipants.forEach((p) => processParticipant(p, false));
|
||||||
setParticipants(allParticipants);
|
setParticipants(allParticipants);
|
||||||
}, []);
|
}, []);
|
||||||
const connect = useCallback(async (channelId) => {
|
const connect = useCallback(async (channelId) => {
|
||||||
@@ -65,8 +66,7 @@ export function useLiveKit() {
|
|||||||
}
|
}
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
try {
|
try {
|
||||||
const { token } = await api.livekit.token(channelId);
|
const { token, url } = await api.livekit.token(channelId);
|
||||||
const livekitUrl = 'wss://nova.ddns.net/livekit';
|
|
||||||
const newRoom = new Room({
|
const newRoom = new Room({
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
@@ -85,7 +85,7 @@ export function useLiveKit() {
|
|||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setParticipants([]);
|
setParticipants([]);
|
||||||
});
|
});
|
||||||
await newRoom.connect(livekitUrl, token);
|
await newRoom.connect(url, token);
|
||||||
await newRoom.localParticipant.enableCameraAndMicrophone();
|
await newRoom.localParticipant.enableCameraAndMicrophone();
|
||||||
roomRef.current = newRoom;
|
roomRef.current = newRoom;
|
||||||
setRoom(newRoom);
|
setRoom(newRoom);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface ParticipantInfo {
|
|||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isCameraOn: boolean;
|
isCameraOn: boolean;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
|
isLocal: boolean;
|
||||||
audioTrack: MediaStreamTrack | null;
|
audioTrack: MediaStreamTrack | null;
|
||||||
videoTrack: MediaStreamTrack | null;
|
videoTrack: MediaStreamTrack | null;
|
||||||
screenTrack: MediaStreamTrack | null;
|
screenTrack: MediaStreamTrack | null;
|
||||||
@@ -50,7 +51,7 @@ export function useLiveKit() {
|
|||||||
|
|
||||||
const allParticipants: ParticipantInfo[] = [];
|
const allParticipants: ParticipantInfo[] = [];
|
||||||
|
|
||||||
const processParticipant = (p: Participant) => {
|
const processParticipant = (p: Participant, isLocal: boolean) => {
|
||||||
const { userId, username } = parseIdentity(p.identity);
|
const { userId, username } = parseIdentity(p.identity);
|
||||||
let audioTrack: MediaStreamTrack | null = null;
|
let audioTrack: MediaStreamTrack | null = null;
|
||||||
let videoTrack: MediaStreamTrack | null = null;
|
let videoTrack: MediaStreamTrack | null = null;
|
||||||
@@ -76,14 +77,15 @@ export function useLiveKit() {
|
|||||||
isMuted: !p.isMicrophoneEnabled,
|
isMuted: !p.isMicrophoneEnabled,
|
||||||
isCameraOn: p.isCameraEnabled,
|
isCameraOn: p.isCameraEnabled,
|
||||||
isScreenSharing: p.isScreenShareEnabled,
|
isScreenSharing: p.isScreenShareEnabled,
|
||||||
|
isLocal,
|
||||||
audioTrack,
|
audioTrack,
|
||||||
videoTrack,
|
videoTrack,
|
||||||
screenTrack,
|
screenTrack,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
processParticipant(r.localParticipant);
|
processParticipant(r.localParticipant, true);
|
||||||
r.remoteParticipants.forEach(processParticipant);
|
r.remoteParticipants.forEach((p) => processParticipant(p, false));
|
||||||
|
|
||||||
setParticipants(allParticipants);
|
setParticipants(allParticipants);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -95,8 +97,7 @@ export function useLiveKit() {
|
|||||||
|
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
try {
|
try {
|
||||||
const { token } = await api.livekit.token(channelId);
|
const { token, url } = await api.livekit.token(channelId);
|
||||||
const livekitUrl = 'wss://nova.ddns.net/livekit';
|
|
||||||
|
|
||||||
const newRoom = new Room({
|
const newRoom = new Room({
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
@@ -118,7 +119,7 @@ export function useLiveKit() {
|
|||||||
setParticipants([]);
|
setParticipants([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
await newRoom.connect(livekitUrl, token);
|
await newRoom.connect(url, token);
|
||||||
await newRoom.localParticipant.enableCameraAndMicrophone();
|
await newRoom.localParticipant.enableCameraAndMicrophone();
|
||||||
|
|
||||||
roomRef.current = newRoom;
|
roomRef.current = newRoom;
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ let isInitialized = false;
|
|||||||
function handleEvent(event) {
|
function handleEvent(event) {
|
||||||
const { setUser } = useAuthStore.getState();
|
const { setUser } = useAuthStore.getState();
|
||||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.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();
|
const { addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'ready':
|
case 'ready':
|
||||||
setUser(event.user);
|
setUser(event.user);
|
||||||
populateFromReady(event.servers);
|
populateFromReady(event.servers, event.folders, event.dmChannels);
|
||||||
if (currentServerId) {
|
if (currentServerId) {
|
||||||
loadServerDetail(currentServerId);
|
loadServerDetail(currentServerId);
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,13 @@ function handleEvent(event) {
|
|||||||
removeMember(event.userId);
|
removeMember(event.userId);
|
||||||
break;
|
break;
|
||||||
case 'dm_message_created':
|
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;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
console.error('WebSocket error:', event.message);
|
console.error('WebSocket error:', event.message);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useAuthStore } from '../stores/authStore';
|
|||||||
import { useServerStore } from '../stores/serverStore';
|
import { useServerStore } from '../stores/serverStore';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../stores/chatStore';
|
||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
|
import { useSocialStore } from '../stores/socialStore';
|
||||||
import type { ServerEvent, ClientEvent } from '@opencord/shared';
|
import type { ServerEvent, ClientEvent } from '@opencord/shared';
|
||||||
|
|
||||||
let globalWs: WebSocket | null = null;
|
let globalWs: WebSocket | null = null;
|
||||||
@@ -14,13 +15,13 @@ let isInitialized = false;
|
|||||||
function handleEvent(event: ServerEvent): void {
|
function handleEvent(event: ServerEvent): void {
|
||||||
const { setUser } = useAuthStore.getState();
|
const { setUser } = useAuthStore.getState();
|
||||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.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();
|
const { addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'ready':
|
case 'ready':
|
||||||
setUser(event.user);
|
setUser(event.user);
|
||||||
populateFromReady(event.servers);
|
populateFromReady(event.servers, event.folders, event.dmChannels);
|
||||||
if (currentServerId) {
|
if (currentServerId) {
|
||||||
loadServerDetail(currentServerId);
|
loadServerDetail(currentServerId);
|
||||||
}
|
}
|
||||||
@@ -63,8 +64,29 @@ function handleEvent(event: ServerEvent): void {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'dm_message_created':
|
case 'dm_message_created':
|
||||||
|
addMessage(event.message.dmChannelId, event.message as any);
|
||||||
break;
|
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':
|
case 'error':
|
||||||
console.error('WebSocket error:', event.message);
|
console.error('WebSocket error:', event.message);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
import { wsSend } from '../hooks/useWebSocket';
|
||||||
|
import { useUIStore } from './uiStore';
|
||||||
export const useChatStore = create((set, get) => ({
|
export const useChatStore = create((set, get) => ({
|
||||||
messages: new Map(),
|
messages: new Map(),
|
||||||
currentChannelId: null,
|
currentChannelId: null,
|
||||||
typingUsers: new Map(),
|
typingUsers: new Map(),
|
||||||
hasMore: new Map(),
|
hasMore: new Map(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
replyTo: null,
|
||||||
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
||||||
|
setReplyTo: (message) => set({ replyTo: message }),
|
||||||
loadMessages: async (channelId) => {
|
loadMessages: async (channelId) => {
|
||||||
if (get().messages.has(channelId))
|
if (get().messages.has(channelId))
|
||||||
return;
|
return;
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
try {
|
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) => {
|
set((state) => {
|
||||||
const newMessages = new Map(state.messages);
|
const newMessages = new Map(state.messages);
|
||||||
newMessages.set(channelId, messages);
|
newMessages.set(channelId, messages);
|
||||||
@@ -35,7 +42,10 @@ export const useChatStore = create((set, get) => ({
|
|||||||
if (!oldestMessage)
|
if (!oldestMessage)
|
||||||
return false;
|
return false;
|
||||||
try {
|
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) => {
|
set((state) => {
|
||||||
const newMessages = new Map(state.messages);
|
const newMessages = new Map(state.messages);
|
||||||
const current = newMessages.get(channelId) ?? [];
|
const current = newMessages.get(channelId) ?? [];
|
||||||
@@ -51,7 +61,15 @@ export const useChatStore = create((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
sendMessage: async (channelId, content, attachmentIds) => {
|
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
|
// Message will arrive via WebSocket
|
||||||
},
|
},
|
||||||
editMessage: async (messageId, content) => {
|
editMessage: async (messageId, content) => {
|
||||||
@@ -93,6 +111,50 @@ export const useChatStore = create((set, get) => ({
|
|||||||
return { messages: newMessages };
|
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) => {
|
setTyping: (channelId, userId, username) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newTyping = new Map(state.typingUsers);
|
const newTyping = new Map(state.typingUsers);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { MessageWithUser } from '@opencord/shared';
|
import type { MessageWithUser, Reaction } from '@opencord/shared';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
import { wsSend } from '../hooks/useWebSocket';
|
||||||
|
import { useUIStore } from './uiStore';
|
||||||
|
|
||||||
interface TypingUser {
|
interface TypingUser {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -14,7 +16,10 @@ interface ChatState {
|
|||||||
typingUsers: Map<string, TypingUser[]>;
|
typingUsers: Map<string, TypingUser[]>;
|
||||||
hasMore: Map<string, boolean>;
|
hasMore: Map<string, boolean>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
loadError: string | null;
|
||||||
|
replyTo: MessageWithUser | null;
|
||||||
setCurrentChannel: (channelId: string | null) => void;
|
setCurrentChannel: (channelId: string | null) => void;
|
||||||
|
setReplyTo: (message: MessageWithUser | null) => void;
|
||||||
loadMessages: (channelId: string) => Promise<void>;
|
loadMessages: (channelId: string) => Promise<void>;
|
||||||
loadMoreMessages: (channelId: string) => Promise<boolean>;
|
loadMoreMessages: (channelId: string) => Promise<boolean>;
|
||||||
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
||||||
@@ -23,6 +28,10 @@ interface ChatState {
|
|||||||
addMessage: (channelId: string, message: MessageWithUser) => void;
|
addMessage: (channelId: string, message: MessageWithUser) => void;
|
||||||
updateMessage: (message: MessageWithUser) => void;
|
updateMessage: (message: MessageWithUser) => void;
|
||||||
removeMessage: (messageId: string, channelId: string) => 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;
|
setTyping: (channelId: string, userId: string, username: string) => void;
|
||||||
clearTyping: (channelId: string, userId: string) => void;
|
clearTyping: (channelId: string, userId: string) => void;
|
||||||
getMessages: (channelId: string) => MessageWithUser[];
|
getMessages: (channelId: string) => MessageWithUser[];
|
||||||
@@ -35,23 +44,30 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
typingUsers: new Map(),
|
typingUsers: new Map(),
|
||||||
hasMore: new Map(),
|
hasMore: new Map(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
loadError: null,
|
||||||
|
replyTo: null,
|
||||||
|
|
||||||
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
||||||
|
setReplyTo: (message) => set({ replyTo: message }),
|
||||||
|
|
||||||
loadMessages: async (channelId: string) => {
|
loadMessages: async (channelId: string) => {
|
||||||
if (get().messages.has(channelId)) return;
|
if (get().messages.has(channelId)) return;
|
||||||
set({ isLoading: true });
|
set({ isLoading: true, loadError: null });
|
||||||
try {
|
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) => {
|
set((state) => {
|
||||||
const newMessages = new Map(state.messages);
|
const newMessages = new Map(state.messages);
|
||||||
newMessages.set(channelId, messages);
|
newMessages.set(channelId, messages as MessageWithUser[]);
|
||||||
const newHasMore = new Map(state.hasMore);
|
const newHasMore = new Map(state.hasMore);
|
||||||
newHasMore.set(channelId, messages.length >= 50);
|
newHasMore.set(channelId, messages.length >= 50);
|
||||||
return { messages: newMessages, hasMore: newHasMore, isLoading: false };
|
return { messages: newMessages, hasMore: newHasMore, isLoading: false, loadError: null };
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
set({ isLoading: false });
|
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;
|
if (!oldestMessage) return false;
|
||||||
|
|
||||||
try {
|
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) => {
|
set((state) => {
|
||||||
const newMessages = new Map(state.messages);
|
const newMessages = new Map(state.messages);
|
||||||
const current = newMessages.get(channelId) ?? [];
|
const current = newMessages.get(channelId) ?? [];
|
||||||
newMessages.set(channelId, [...olderMessages, ...current]);
|
newMessages.set(channelId, [...(olderMessages as MessageWithUser[]), ...current]);
|
||||||
const newHasMore = new Map(state.hasMore);
|
const newHasMore = new Map(state.hasMore);
|
||||||
newHasMore.set(channelId, olderMessages.length >= 50);
|
newHasMore.set(channelId, olderMessages.length >= 50);
|
||||||
return { messages: newMessages, hasMore: newHasMore };
|
return { messages: newMessages, hasMore: newHasMore };
|
||||||
@@ -80,7 +100,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => {
|
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
|
// 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) => {
|
setTyping: (channelId: string, userId: string, username: string) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newTyping = new Map(state.typingUsers);
|
const newTyping = new Map(state.typingUsers);
|
||||||
|
|||||||
@@ -5,10 +5,18 @@ export const useServerStore = create((set, get) => ({
|
|||||||
currentServerId: null,
|
currentServerId: null,
|
||||||
channels: [],
|
channels: [],
|
||||||
members: [],
|
members: [],
|
||||||
|
roles: [],
|
||||||
|
folders: [],
|
||||||
|
dmChannels: [],
|
||||||
setServers: (servers) => set({ servers }),
|
setServers: (servers) => set({ servers }),
|
||||||
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
|
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
|
||||||
setChannels: (channels) => set({ channels }),
|
setChannels: (channels) => set({ channels }),
|
||||||
setMembers: (members) => set({ members }),
|
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 () => {
|
loadServers: async () => {
|
||||||
try {
|
try {
|
||||||
const servers = await api.servers.list();
|
const servers = await api.servers.list();
|
||||||
@@ -25,12 +33,22 @@ export const useServerStore = create((set, get) => ({
|
|||||||
currentServerId: serverId,
|
currentServerId: serverId,
|
||||||
channels: detail.channels.sort((a, b) => a.position - b.position),
|
channels: detail.channels.sort((a, b) => a.position - b.position),
|
||||||
members: detail.members,
|
members: detail.members,
|
||||||
|
roles: detail.roles.sort((a, b) => b.position - a.position), // Higher position = higher in list
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
// Handle error silently
|
// Handle error silently
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
loadDmChannels: async () => {
|
||||||
|
try {
|
||||||
|
const dmChannels = await api.dm.list();
|
||||||
|
set({ dmChannels });
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Handle error silently
|
||||||
|
}
|
||||||
|
},
|
||||||
createServer: async (name, icon) => {
|
createServer: async (name, icon) => {
|
||||||
const server = await api.servers.create({ name, icon });
|
const server = await api.servers.create({ name, icon });
|
||||||
set((state) => ({ servers: [...state.servers, server] }));
|
set((state) => ({ servers: [...state.servers, server] }));
|
||||||
@@ -102,7 +120,7 @@ export const useServerStore = create((set, get) => ({
|
|||||||
members: state.members.filter(m => m.userId !== userId),
|
members: state.members.filter(m => m.userId !== userId),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
populateFromReady: (servers) => {
|
populateFromReady: (servers, folders, dmChannels) => {
|
||||||
const simpleServers = servers.map(s => ({
|
const simpleServers = servers.map(s => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
@@ -111,6 +129,10 @@ export const useServerStore = create((set, get) => ({
|
|||||||
inviteCode: s.inviteCode,
|
inviteCode: s.inviteCode,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
}));
|
}));
|
||||||
set({ servers: simpleServers });
|
set({
|
||||||
|
servers: simpleServers,
|
||||||
|
folders: folders || [],
|
||||||
|
dmChannels: dmChannels || []
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
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';
|
import { api } from '../api/client';
|
||||||
|
|
||||||
interface ServerState {
|
interface ServerState {
|
||||||
@@ -7,16 +7,24 @@ interface ServerState {
|
|||||||
currentServerId: string | null;
|
currentServerId: string | null;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
members: MemberWithUser[];
|
members: MemberWithUser[];
|
||||||
|
roles: Role[];
|
||||||
|
folders: ServerFolder[];
|
||||||
|
dmChannels: DmChannel[];
|
||||||
setServers: (servers: Server[]) => void;
|
setServers: (servers: Server[]) => void;
|
||||||
setCurrentServer: (serverId: string | null) => void;
|
setCurrentServer: (serverId: string | null) => void;
|
||||||
setChannels: (channels: Channel[]) => void;
|
setChannels: (channels: Channel[]) => void;
|
||||||
setMembers: (members: MemberWithUser[]) => void;
|
setMembers: (members: MemberWithUser[]) => void;
|
||||||
|
setRoles: (roles: Role[]) => void;
|
||||||
|
setDmChannels: (channels: DmChannel[]) => void;
|
||||||
|
addDmChannel: (channel: DmChannel) => void;
|
||||||
loadServers: () => Promise<void>;
|
loadServers: () => Promise<void>;
|
||||||
loadServerDetail: (serverId: string) => Promise<void>;
|
loadServerDetail: (serverId: string) => Promise<void>;
|
||||||
|
loadDmChannels: () => Promise<void>;
|
||||||
createServer: (name: string, icon?: string) => Promise<Server>;
|
createServer: (name: string, icon?: string) => Promise<Server>;
|
||||||
updateServer: (serverId: string, data: { name?: string; icon?: string }) => Promise<void>;
|
updateServer: (serverId: string, data: { name?: string; icon?: string }) => Promise<void>;
|
||||||
deleteServer: (serverId: string) => Promise<void>;
|
deleteServer: (serverId: string) => Promise<void>;
|
||||||
joinServer: (serverId: string, inviteCode: string) => Promise<void>;
|
joinServer: (serverId: string, inviteCode: string) => Promise<void>;
|
||||||
|
joinByCode: (inviteCode: string) => Promise<Server>;
|
||||||
generateInvite: (serverId: string) => Promise<string>;
|
generateInvite: (serverId: string) => Promise<string>;
|
||||||
createChannel: (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
|
createChannel: (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
|
||||||
deleteChannel: (channelId: string) => Promise<void>;
|
deleteChannel: (channelId: string) => Promise<void>;
|
||||||
@@ -25,7 +33,7 @@ interface ServerState {
|
|||||||
updateMemberPresence: (userId: string, status: string) => void;
|
updateMemberPresence: (userId: string, status: string) => void;
|
||||||
addMember: (member: MemberWithUser) => void;
|
addMember: (member: MemberWithUser) => void;
|
||||||
removeMember: (userId: string) => void;
|
removeMember: (userId: string) => void;
|
||||||
populateFromReady: (servers: ServerWithChannelsAndMembers[]) => void;
|
populateFromReady: (servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useServerStore = create<ServerState>((set, get) => ({
|
export const useServerStore = create<ServerState>((set, get) => ({
|
||||||
@@ -33,11 +41,20 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
|||||||
currentServerId: null,
|
currentServerId: null,
|
||||||
channels: [],
|
channels: [],
|
||||||
members: [],
|
members: [],
|
||||||
|
roles: [],
|
||||||
|
folders: [],
|
||||||
|
dmChannels: [],
|
||||||
|
|
||||||
setServers: (servers) => set({ servers }),
|
setServers: (servers) => set({ servers }),
|
||||||
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
|
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
|
||||||
setChannels: (channels) => set({ channels }),
|
setChannels: (channels) => set({ channels }),
|
||||||
setMembers: (members) => set({ members }),
|
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 () => {
|
loadServers: async () => {
|
||||||
try {
|
try {
|
||||||
@@ -55,12 +72,22 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
|||||||
currentServerId: serverId,
|
currentServerId: serverId,
|
||||||
channels: detail.channels.sort((a, b) => a.position - b.position),
|
channels: detail.channels.sort((a, b) => a.position - b.position),
|
||||||
members: detail.members,
|
members: detail.members,
|
||||||
|
roles: detail.roles.sort((a, b) => b.position - a.position), // Higher position = higher in list
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Handle error silently
|
// Handle error silently
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadDmChannels: async () => {
|
||||||
|
try {
|
||||||
|
const dmChannels = await api.dm.list();
|
||||||
|
set({ dmChannels });
|
||||||
|
} catch {
|
||||||
|
// Handle error silently
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
createServer: async (name: string, icon?: string) => {
|
createServer: async (name: string, icon?: string) => {
|
||||||
const server = await api.servers.create({ name, icon });
|
const server = await api.servers.create({ name, icon });
|
||||||
set((state) => ({ servers: [...state.servers, server] }));
|
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) => {
|
generateInvite: async (serverId: string) => {
|
||||||
const result = await api.servers.invite(serverId);
|
const result = await api.servers.invite(serverId);
|
||||||
return result.inviteCode;
|
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 => ({
|
const simpleServers: Server[] = servers.map(s => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
@@ -153,6 +189,10 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
|||||||
inviteCode: s.inviteCode,
|
inviteCode: s.inviteCode,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
}));
|
}));
|
||||||
set({ servers: simpleServers });
|
set({
|
||||||
|
servers: simpleServers,
|
||||||
|
folders: folders || [],
|
||||||
|
dmChannels: dmChannels || []
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -7,6 +7,10 @@ export const useUIStore = create((set) => ({
|
|||||||
isMobile: false,
|
isMobile: false,
|
||||||
showDms: false,
|
showDms: false,
|
||||||
imagePreviewUrl: null,
|
imagePreviewUrl: null,
|
||||||
|
userProfilePopout: {
|
||||||
|
user: null,
|
||||||
|
position: null,
|
||||||
|
},
|
||||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||||
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
||||||
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
|
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
|
||||||
@@ -19,4 +23,10 @@ export const useUIStore = create((set) => ({
|
|||||||
setShowDms: (show) => set({ showDms: show }),
|
setShowDms: (show) => set({ showDms: show }),
|
||||||
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
||||||
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
||||||
|
openUserProfile: (user, position) => set({
|
||||||
|
userProfilePopout: { user, position }
|
||||||
|
}),
|
||||||
|
closeUserProfile: () => set({
|
||||||
|
userProfilePopout: { user: null, position: null }
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import type { User } from '@opencord/shared';
|
||||||
|
|
||||||
type ModalType =
|
type ModalType =
|
||||||
| 'createServer'
|
| 'createServer'
|
||||||
@@ -18,6 +19,10 @@ interface UIState {
|
|||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
showDms: boolean;
|
showDms: boolean;
|
||||||
imagePreviewUrl: string | null;
|
imagePreviewUrl: string | null;
|
||||||
|
userProfilePopout: {
|
||||||
|
user: User | null;
|
||||||
|
position: { top: number; left: number } | null;
|
||||||
|
};
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
toggleMemberList: () => void;
|
toggleMemberList: () => void;
|
||||||
openModal: (modal: ModalType, data?: Record<string, unknown>) => void;
|
openModal: (modal: ModalType, data?: Record<string, unknown>) => void;
|
||||||
@@ -26,6 +31,8 @@ interface UIState {
|
|||||||
setShowDms: (show: boolean) => void;
|
setShowDms: (show: boolean) => void;
|
||||||
openImagePreview: (url: string) => void;
|
openImagePreview: (url: string) => void;
|
||||||
closeImagePreview: () => void;
|
closeImagePreview: () => void;
|
||||||
|
openUserProfile: (user: User, position: { top: number; left: number }) => void;
|
||||||
|
closeUserProfile: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>((set) => ({
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
@@ -36,6 +43,10 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
isMobile: false,
|
isMobile: false,
|
||||||
showDms: false,
|
showDms: false,
|
||||||
imagePreviewUrl: null,
|
imagePreviewUrl: null,
|
||||||
|
userProfilePopout: {
|
||||||
|
user: null,
|
||||||
|
position: null,
|
||||||
|
},
|
||||||
|
|
||||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||||
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
||||||
@@ -53,4 +64,11 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
|
|
||||||
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
||||||
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
||||||
|
|
||||||
|
openUserProfile: (user, position) => set({
|
||||||
|
userProfilePopout: { user, position }
|
||||||
|
}),
|
||||||
|
closeUserProfile: () => set({
|
||||||
|
userProfilePopout: { user: null, position: null }
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const useVoiceStore = create((set, get) => ({
|
|||||||
isDeafened: false,
|
isDeafened: false,
|
||||||
isCameraOn: false,
|
isCameraOn: false,
|
||||||
isScreenSharing: false,
|
isScreenSharing: false,
|
||||||
|
participants: [],
|
||||||
setVoiceUsers: (channelId, userIds) => {
|
setVoiceUsers: (channelId, userIds) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newMap = new Map(state.voiceUsers);
|
const newMap = new Map(state.voiceUsers);
|
||||||
@@ -32,7 +33,8 @@ export const useVoiceStore = create((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
setCurrentVoiceChannel: (channelId) => set({ currentVoiceChannelId: channelId }),
|
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 })),
|
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
|
||||||
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
|
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
|
||||||
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
|
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import type { ParticipantInfo } from '../hooks/useLiveKit';
|
||||||
|
|
||||||
interface VoiceState {
|
interface VoiceState {
|
||||||
voiceUsers: Map<string, string[]>; // channelId → userIds
|
voiceUsers: Map<string, string[]>; // channelId → userIds
|
||||||
@@ -7,14 +8,16 @@ interface VoiceState {
|
|||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
isCameraOn: boolean;
|
isCameraOn: boolean;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
|
participants: ParticipantInfo[];
|
||||||
setVoiceUsers: (channelId: string, userIds: string[]) => void;
|
setVoiceUsers: (channelId: string, userIds: string[]) => void;
|
||||||
addVoiceUser: (channelId: string, userId: string) => void;
|
addVoiceUser: (channelId: string, userId: string) => void;
|
||||||
removeVoiceUser: (channelId: string, userId: string) => void;
|
removeVoiceUser: (channelId: string, userId: string) => void;
|
||||||
setCurrentVoiceChannel: (channelId: string | null) => void;
|
setCurrentVoiceChannel: (channelId: string | null) => void;
|
||||||
toggleMute: () => void;
|
setParticipants: (participants: ParticipantInfo[]) => void;
|
||||||
toggleDeafen: () => void;
|
toggleMic: () => void;
|
||||||
toggleCamera: () => void;
|
toggleCamera: () => void;
|
||||||
toggleScreenShare: () => void;
|
toggleScreenShare: () => void;
|
||||||
|
toggleDeafen: () => void;
|
||||||
getVoiceUsers: (channelId: string) => string[];
|
getVoiceUsers: (channelId: string) => string[];
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -26,6 +29,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
isDeafened: false,
|
isDeafened: false,
|
||||||
isCameraOn: false,
|
isCameraOn: false,
|
||||||
isScreenSharing: false,
|
isScreenSharing: false,
|
||||||
|
participants: [],
|
||||||
|
|
||||||
setVoiceUsers: (channelId, userIds) => {
|
setVoiceUsers: (channelId, userIds) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -57,7 +61,9 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
|
|
||||||
setCurrentVoiceChannel: (channelId) => set({ currentVoiceChannelId: channelId }),
|
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 })),
|
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
|
||||||
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
|
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
|
||||||
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
|
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
|
||||||
|
|||||||
@@ -2,82 +2,60 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
@layer base {
|
||||||
--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;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
@apply border-none;
|
||||||
padding: 0;
|
outline: none !important;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
height: 100%;
|
@apply h-full w-full overflow-hidden bg-discord-bg-tertiary text-discord-text-normal;
|
||||||
width: 100%;
|
font-family: 'Inter', 'gg sans', 'Noto Sans', sans-serif;
|
||||||
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;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Discord-style scrollbar */
|
/* Discord-style thin scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
@apply bg-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #1a1b1e;
|
@apply bg-[#1a1b1e] rounded-full border-2 border-transparent bg-clip-padding;
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #27282c;
|
@apply bg-[#27282c];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Firefox scrollbar */
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
* {
|
display: none;
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #1a1b1e transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Selection color */
|
/* For dark theme auto-fill */
|
||||||
::selection {
|
input:-webkit-autofill,
|
||||||
background-color: rgba(88, 101, 242, 0.3);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Link styles */
|
@layer utilities {
|
||||||
a {
|
.rounded-inherit {
|
||||||
color: #00aff4;
|
border-radius: inherit;
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
text-decoration: underline;
|
width: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
@@ -91,25 +69,10 @@ a:hover {
|
|||||||
to { transform: translateY(0); opacity: 1; }
|
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 {
|
.animate-fade-in {
|
||||||
animation: fadeIn 0.15s ease-in;
|
animation: fadeIn 0.15s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-slide-up {
|
.animate-slide-up {
|
||||||
animation: slideUp 0.2s ease-out;
|
animation: slideUp 0.15s ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
@@ -11,23 +11,38 @@ export default {
|
|||||||
'bg-primary': '#313338',
|
'bg-primary': '#313338',
|
||||||
'bg-secondary': '#2b2d31',
|
'bg-secondary': '#2b2d31',
|
||||||
'bg-tertiary': '#1e1f22',
|
'bg-tertiary': '#1e1f22',
|
||||||
'bg-members': '#232428',
|
'bg-user-area': '#232428',
|
||||||
'bg-hover': '#35373c',
|
'bg-floating': '#111214',
|
||||||
'bg-active': '#404249',
|
'bg-overlay': 'rgba(0, 0, 0, 0.85)',
|
||||||
'bg-input': '#383a40',
|
'bg-input': '#383a40',
|
||||||
|
'bg-accent': '#404249',
|
||||||
'text-primary': '#f2f3f5',
|
'text-primary': '#f2f3f5',
|
||||||
|
'text-normal': '#dbdee1',
|
||||||
'text-secondary': '#b5bac1',
|
'text-secondary': '#b5bac1',
|
||||||
'text-muted': '#949ba4',
|
'text-muted': '#949ba4',
|
||||||
|
'text-link': '#00a8fc',
|
||||||
|
'text-positive': '#23a559',
|
||||||
|
'text-warning': '#f0b232',
|
||||||
|
'text-danger': '#fa777c',
|
||||||
'blurple': '#5865f2',
|
'blurple': '#5865f2',
|
||||||
'blurple-hover': '#4752c4',
|
'blurple-hover': '#4752c4',
|
||||||
'green': '#23a559',
|
'green': '#23a559',
|
||||||
'yellow': '#f0b232',
|
'yellow': '#f0b232',
|
||||||
'red': '#da373c',
|
'red': '#da373c',
|
||||||
'red-hover': '#a12d31',
|
'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: {
|
fontFamily: {
|
||||||
sans: ['gg sans', 'Noto Sans', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
|
sans: ['"gg sans"', 'Inter', 'Noto Sans', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
css: false,
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.mts', '.json'],
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
@@ -13,11 +21,11 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: 'http://localhost:3005',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: 'ws://localhost:3000',
|
target: 'ws://localhost:3005',
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+892
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user