feat: unread indicators + DM bug fixes + data-driven isDmChannel
- Fix stale message cache: add force param to loadMessages, clearAllMessages action - Fix reload race condition: URL-based isDmChannel fallback before WS ready - Add read_states DB table for persistent unread tracking - Add channel_ack WS event (client→server→echo) with BigInt comparison - Wire up unread state in chatStore (readStates, unreadChannels, ackChannel) - Auto-ack channels on MessageList view (200ms debounced) - Unread pill indicators on server icons in ServerSidebar - Bold text + white dot on unread channels/DMs in ChannelSidebar - Replace all showDms reads with data-driven isDmChannel() across 8 files - Design system, UI polish, and component fixes from previous sessions
This commit is contained in:
@@ -143,6 +143,14 @@ function createTables(db: Database.Database): void {
|
||||
PRIMARY KEY (server_id, user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS read_states (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
channel_id TEXT NOT NULL,
|
||||
last_read_message_id TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, channel_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -138,6 +138,15 @@ export const memberRoles = sqliteTable('member_roles', {
|
||||
pk: primaryKey({ columns: [table.serverId, table.userId, table.roleId] }),
|
||||
}));
|
||||
|
||||
export const readStates = sqliteTable('read_states', {
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
channelId: text('channel_id').notNull(),
|
||||
lastReadMessageId: text('last_read_message_id').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.userId, table.channelId] }),
|
||||
}));
|
||||
|
||||
export const serverFolders = sqliteTable('server_folders', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -141,6 +141,9 @@ export function handleClientEvent(
|
||||
case 'reaction_remove':
|
||||
handleReactionRemove(event, userId);
|
||||
break;
|
||||
case 'channel_ack':
|
||||
handleChannelAck(event, userId);
|
||||
break;
|
||||
default:
|
||||
connectionManager.sendToUser(userId, {
|
||||
type: 'error',
|
||||
@@ -677,3 +680,48 @@ function handleReactionRemove(event: Record<string, unknown>, userId: string): v
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleChannelAck(event: Record<string, unknown>, userId: string): void {
|
||||
const channelId = event.channelId as string;
|
||||
const messageId = event.messageId as string;
|
||||
if (!channelId || !messageId) return;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.select()
|
||||
.from(schema.readStates)
|
||||
.where(and(
|
||||
eq(schema.readStates.userId, userId),
|
||||
eq(schema.readStates.channelId, channelId),
|
||||
))
|
||||
.get();
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (existing) {
|
||||
// Only update if the new messageId is newer (larger snowflake)
|
||||
if (BigInt(messageId) > BigInt(existing.lastReadMessageId)) {
|
||||
db.update(schema.readStates)
|
||||
.set({ lastReadMessageId: messageId, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.readStates.userId, userId),
|
||||
eq(schema.readStates.channelId, channelId),
|
||||
))
|
||||
.run();
|
||||
}
|
||||
} else {
|
||||
db.insert(schema.readStates).values({
|
||||
userId,
|
||||
channelId,
|
||||
lastReadMessageId: messageId,
|
||||
updatedAt: now,
|
||||
}).run();
|
||||
}
|
||||
|
||||
// Echo ack back to all of this user's connections (multi-tab sync)
|
||||
connectionManager.sendToUser(userId, {
|
||||
type: 'channel_ack',
|
||||
channelId,
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { verifyJwt } from '../utils/auth.js';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { eq, inArray, desc } from 'drizzle-orm';
|
||||
import { handleClientEvent } from './events.js';
|
||||
import type {
|
||||
User,
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
DmChannel,
|
||||
ServerEvent,
|
||||
ServerFolder,
|
||||
ReadState,
|
||||
} from '@opencord/shared';
|
||||
|
||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
@@ -188,6 +189,7 @@ function buildReadyPayload(userId: string): {
|
||||
dmChannels: DmChannel[];
|
||||
folders: ServerFolder[];
|
||||
voiceStates: Record<string, string[]>;
|
||||
readStates: ReadState[];
|
||||
} {
|
||||
const db = getDb();
|
||||
|
||||
@@ -281,15 +283,24 @@ function buildReadyPayload(userId: string): {
|
||||
ownerId: serverRow.ownerId,
|
||||
inviteCode: serverRow.inviteCode,
|
||||
createdAt: serverRow.createdAt,
|
||||
channels: channels.map(ch => ({
|
||||
id: ch.id,
|
||||
serverId: ch.serverId,
|
||||
name: ch.name,
|
||||
type: ch.type as Channel['type'],
|
||||
topic: ch.topic,
|
||||
position: ch.position ?? 0,
|
||||
createdAt: ch.createdAt,
|
||||
})),
|
||||
channels: channels.map(ch => {
|
||||
const lastMsg = db.select({ id: schema.messages.id })
|
||||
.from(schema.messages)
|
||||
.where(eq(schema.messages.channelId, ch.id))
|
||||
.orderBy(desc(schema.messages.createdAt))
|
||||
.limit(1)
|
||||
.get();
|
||||
return {
|
||||
id: ch.id,
|
||||
serverId: ch.serverId,
|
||||
name: ch.name,
|
||||
type: ch.type as Channel['type'],
|
||||
topic: ch.topic,
|
||||
position: ch.position ?? 0,
|
||||
createdAt: ch.createdAt,
|
||||
lastMessageId: lastMsg?.id ?? null,
|
||||
};
|
||||
}),
|
||||
members,
|
||||
roles: roles.map(r => ({
|
||||
id: r.id,
|
||||
@@ -394,7 +405,18 @@ function buildReadyPayload(userId: string): {
|
||||
}
|
||||
}
|
||||
|
||||
return { user, servers, dmChannels, folders, voiceStates };
|
||||
// Fetch read states for unread tracking
|
||||
const readStateRows = db.select()
|
||||
.from(schema.readStates)
|
||||
.where(eq(schema.readStates.userId, userId))
|
||||
.all();
|
||||
|
||||
const readStates: ReadState[] = readStateRows.map(rs => ({
|
||||
channelId: rs.channelId,
|
||||
lastReadMessageId: rs.lastReadMessageId,
|
||||
}));
|
||||
|
||||
return { user, servers, dmChannels, folders, voiceStates, readStates };
|
||||
}
|
||||
|
||||
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
|
||||
@@ -85,6 +85,12 @@ export interface Channel {
|
||||
topic: string | null;
|
||||
position: number;
|
||||
createdAt: number;
|
||||
lastMessageId?: string | null;
|
||||
}
|
||||
|
||||
export interface ReadState {
|
||||
channelId: string;
|
||||
lastReadMessageId: string;
|
||||
}
|
||||
|
||||
// ─── Message Types ──────────────────────────────────────────────────────────
|
||||
@@ -174,11 +180,12 @@ export type ClientEvent =
|
||||
| { type: 'dm_message_edit'; messageId: string; content: string }
|
||||
| { type: 'dm_message_delete'; messageId: string }
|
||||
| { type: 'reaction_add'; messageId: string; emoji: string }
|
||||
| { type: 'reaction_remove'; messageId: string; emoji: string };
|
||||
| { type: 'reaction_remove'; messageId: string; emoji: string }
|
||||
| { type: 'channel_ack'; channelId: string; messageId: string };
|
||||
|
||||
// Server → Client Events
|
||||
export type ServerEvent =
|
||||
| { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record<string, string[]> }
|
||||
| { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record<string, string[]>; readStates?: ReadState[] }
|
||||
| { type: 'message_created'; message: MessageWithUser }
|
||||
| { type: 'message_updated'; message: MessageWithUser }
|
||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||
@@ -193,6 +200,7 @@ export type ServerEvent =
|
||||
| { type: 'dm_typing'; dmChannelId: string; userId: string; username: string }
|
||||
| { type: 'reaction_added'; messageId: string; reaction: Reaction }
|
||||
| { type: 'reaction_removed'; messageId: string; userId: string; emoji: string }
|
||||
| { type: 'channel_ack'; channelId: string; messageId: string }
|
||||
| { type: 'friend_request_received'; request: FriendRequest }
|
||||
| { type: 'friend_request_accepted'; friend: Friend; requestId: string }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
@@ -28,5 +28,5 @@ export function LoginPage() {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
}
|
||||
};
|
||||
return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-discord-bg-tertiary", children: _jsxs("div", { className: "w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl", children: [_jsxs("div", { className: "text-center mb-6", children: [_jsx("h1", { className: "text-2xl font-bold text-discord-text-primary", children: "Welcome back!" }), _jsx("p", { className: "text-discord-text-muted mt-1", children: "We're so excited to see you again!" })] }), _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Username ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "text", value: username, onChange: (e) => setUsername(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoFocus: true, autoComplete: "username" })] }), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Password ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "current-password" })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: isLoading ? 'Logging in...' : 'Log In' }), _jsxs("p", { className: "mt-3 text-sm text-discord-text-muted", children: ["Need an account?", ' ', _jsx(Link, { to: "/register", className: "text-[#00aff4] hover:underline", children: "Register" })] })] })] }) }));
|
||||
return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-discord-bg-tertiary", children: _jsxs("div", { className: "w-full max-w-[480px] bg-discord-bg-surface rounded-md p-8 shadow-elevation-high", children: [_jsxs("div", { className: "text-center mb-6", children: [_jsx("h1", { className: "text-2xl font-bold text-discord-text-primary", children: "Welcome back!" }), _jsx("p", { className: "text-discord-text-muted mt-1", children: "We're so excited to see you again!" })] }), _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Username ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "text", value: username, onChange: (e) => setUsername(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoFocus: true, autoComplete: "username" })] }), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Password ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "current-password" })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: isLoading ? 'Logging in...' : 'Log In' }), _jsxs("p", { className: "mt-3 text-sm text-discord-text-muted", children: ["Need an account?", ' ', _jsx(Link, { to: "/register", className: "text-discord-text-link hover:underline", children: "Register" })] })] })] }) }));
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-discord-bg-tertiary">
|
||||
<div className="w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl">
|
||||
<div className="w-full max-w-[480px] bg-discord-bg-surface rounded-md p-8 shadow-elevation-high">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-discord-text-primary">Welcome back!</h1>
|
||||
<p className="text-discord-text-muted mt-1">We're so excited to see you again!</p>
|
||||
@@ -41,7 +41,7 @@ export function LoginPage() {
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
|
||||
<div className="mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -83,7 +83,7 @@ export function LoginPage() {
|
||||
|
||||
<p className="mt-3 text-sm text-discord-text-muted">
|
||||
Need an account?{' '}
|
||||
<Link to="/register" className="text-[#00aff4] hover:underline">
|
||||
<Link to="/register" className="text-discord-text-link hover:underline">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -41,5 +41,5 @@ export function RegisterPage() {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
}
|
||||
};
|
||||
return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-discord-bg-tertiary", children: _jsxs("div", { className: "w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl", children: [_jsx("div", { className: "text-center mb-6", children: _jsx("h1", { className: "text-2xl font-bold text-discord-text-primary", children: "Create an account" }) }), _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Username ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "text", value: username, onChange: (e) => setUsername(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoFocus: true, autoComplete: "username" })] }), _jsxs("div", { className: "mb-5", 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.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "name" })] }), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Password ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "new-password" })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: isLoading ? 'Creating account...' : 'Continue' }), _jsxs("p", { className: "mt-3 text-sm text-discord-text-muted", children: ["Already have an account?", ' ', _jsx(Link, { to: "/login", className: "text-[#00aff4] hover:underline", children: "Log In" })] })] })] }) }));
|
||||
return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-discord-bg-tertiary", children: _jsxs("div", { className: "w-full max-w-[480px] bg-discord-bg-surface rounded-md p-8 shadow-elevation-high", children: [_jsx("div", { className: "text-center mb-6", children: _jsx("h1", { className: "text-2xl font-bold text-discord-text-primary", children: "Create an account" }) }), _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Username ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "text", value: username, onChange: (e) => setUsername(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoFocus: true, autoComplete: "username" })] }), _jsxs("div", { className: "mb-5", 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.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "name" })] }), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Password ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "new-password" })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: isLoading ? 'Creating account...' : 'Continue' }), _jsxs("p", { className: "mt-3 text-sm text-discord-text-muted", children: ["Already have an account?", ' ', _jsx(Link, { to: "/login", className: "text-discord-text-link hover:underline", children: "Log In" })] })] })] }) }));
|
||||
}
|
||||
|
||||
@@ -46,14 +46,14 @@ export function RegisterPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-discord-bg-tertiary">
|
||||
<div className="w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl">
|
||||
<div className="w-full max-w-[480px] bg-discord-bg-surface rounded-md p-8 shadow-elevation-high">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-discord-text-primary">Create an account</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
|
||||
<div className="mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -108,7 +108,7 @@ export function RegisterPage() {
|
||||
|
||||
<p className="mt-3 text-sm text-discord-text-muted">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-[#00aff4] hover:underline">
|
||||
<Link to="/login" className="text-discord-text-link hover:underline">
|
||||
Log In
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function FriendsPage() {
|
||||
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", onAccept: () => updateFriendRequest(req.id, 'accepted'), onDecline: () => updateFriendRequest(req.id, 'declined') }, req.id))), pendingOutgoing.map(req => (_jsx(RequestItem, { request: req, type: "outgoing", onCancel: () => cancelFriendRequest(req.id) }, 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 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-text-positive border-discord-green/20 bg-discord-green/5' : 'text-discord-text-danger 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 shadow-header 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()] }));
|
||||
|
||||
@@ -160,7 +160,7 @@ export function FriendsPage() {
|
||||
</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'}`}>
|
||||
<div className={`text-sm p-3 rounded-lg border ${addStatus.type === 'success' ? 'text-discord-text-positive border-discord-green/20 bg-discord-green/5' : 'text-discord-text-danger border-discord-red/20 bg-discord-red/5'}`}>
|
||||
{addStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,5 +6,5 @@ export function ImagePreview() {
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
if (activeModal !== 'imagePreview' || !imageUrl)
|
||||
return null;
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in cursor-pointer", onClick: closeImagePreview, children: [_jsx("button", { className: "absolute top-4 right-4 text-white/70 hover:text-white transition-colors z-10", onClick: closeImagePreview, children: _jsx("svg", { width: "24", height: "24", 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" }) }) }), _jsx("img", { src: imageUrl, alt: "Preview", className: "max-w-[90vw] max-h-[90vh] object-contain rounded shadow-2xl", onClick: (e) => e.stopPropagation() })] }));
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-[60] flex items-center justify-center bg-discord-bg-overlay animate-fade-in cursor-pointer", onClick: closeImagePreview, children: [_jsx("button", { className: "absolute top-4 right-4 text-white/70 hover:text-white transition-colors z-10", onClick: closeImagePreview, children: _jsx("svg", { width: "24", height: "24", 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" }) }) }), _jsx("img", { src: imageUrl, alt: "Preview", className: "max-w-[90vw] max-h-[90vh] object-contain rounded shadow-elevation-high", onClick: (e) => e.stopPropagation() })] }));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ImagePreview() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in cursor-pointer"
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-discord-bg-overlay animate-fade-in cursor-pointer"
|
||||
onClick={closeImagePreview}
|
||||
>
|
||||
<button
|
||||
@@ -24,7 +24,7 @@ export function ImagePreview() {
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Preview"
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain rounded shadow-2xl"
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain rounded shadow-elevation-high"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ export function Message({ message, isCompact, isFirstInGroup }) {
|
||||
const members = useServerStore((s) => s.members);
|
||||
const openImagePreview = useUIStore((s) => s.openImagePreview);
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
const channelKey = message.channelId || message.dmChannelId;
|
||||
const isAuthor = currentUser?.id === message.userId;
|
||||
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
|
||||
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
|
||||
@@ -85,7 +86,7 @@ export function Message({ message, isCompact, isFirstInGroup }) {
|
||||
if (canDelete) {
|
||||
contextMenuItems.push({
|
||||
label: 'Delete Message',
|
||||
onClick: () => deleteMessage(message.id),
|
||||
onClick: () => deleteMessage(message.id, channelKey),
|
||||
danger: true,
|
||||
});
|
||||
}
|
||||
@@ -93,7 +94,7 @@ export function Message({ message, isCompact, isFirstInGroup }) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (editContent.trim()) {
|
||||
await editMessage(message.id, editContent.trim());
|
||||
await editMessage(message.id, editContent.trim(), channelKey);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}
|
||||
@@ -127,7 +128,7 @@ export function Message({ message, isCompact, isFirstInGroup }) {
|
||||
};
|
||||
const content = (_jsxs("div", { className: `group relative flex px-4 py-0.5 hover:bg-discord-modifier-hover 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-discord-interactive-muted 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-discord-text-primary", children: message.replyTo.content })] })), (isFirstInGroup || message.replyTo) && (_jsxs("div", { className: "flex items-baseline gap-2 mb-0.5", children: [_jsx("span", { onClick: handleUsernameClick, className: "font-bold cursor-pointer hover:underline text-[16px] leading-tight", style: roleColor, children: displayName }), _jsx("span", { className: "text-[12px] text-discord-text-muted leading-tight font-medium hover:cursor-default", children: formatTime(message.createdAt) })] })), isEditing ? (_jsxs("div", { className: "mt-1 w-full", children: [_jsx("textarea", { value: editContent, onChange: (e) => setEditContent(e.target.value), onKeyDown: handleEditSubmit, className: "w-full p-3 bg-discord-bg-input rounded-lg text-discord-text-primary outline-none resize-none text-[16px] leading-[1.375rem] shadow-inner", rows: 2, autoFocus: true }), _jsxs("p", { className: "text-[12px] text-discord-text-muted mt-1.5 ml-1", children: ["escape to ", _jsx("button", { onClick: () => setIsEditing(false), className: "text-discord-text-link hover:underline", children: "cancel" }), ' ', "\u2022 enter to ", _jsx("button", { onClick: () => {
|
||||
if (editContent.trim()) {
|
||||
editMessage(message.id, editContent.trim());
|
||||
editMessage(message.id, editContent.trim(), channelKey);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, 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: {
|
||||
@@ -150,7 +151,7 @@ export function Message({ message, isCompact, isFirstInGroup }) {
|
||||
}) }))] }))] }), isHovered && !isEditing && (_jsxs("div", { className: "absolute -top-[18px] right-4 flex items-center bg-discord-bg-primary border border-discord-bg-tertiary/50 rounded-[4px] shadow-elevation-low overflow-hidden z-10 h-8", children: [_jsx("div", { className: "flex items-center px-1 border-r border-discord-bg-tertiary/50 h-full", children: ['👍', '❤️', '😂', '😮'].map(emoji => (_jsx("button", { onClick: () => toggleReaction(emoji), className: "p-1 hover:bg-discord-modifier-hover rounded transition-colors text-[16px] leading-none", children: emoji }, emoji))) }), _jsx("button", { onClick: () => setReplyTo(message), className: "px-2 h-full text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover transition-all flex items-center justify-center", title: "Reply", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" }) }) }), isAuthor && (_jsx("button", { onClick: () => {
|
||||
setEditContent(message.content ?? '');
|
||||
setIsEditing(true);
|
||||
}, className: "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" }) }) }))] }))] }));
|
||||
}, 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, channelKey), className: "px-2 h-full text-discord-text-muted hover:text-discord-red hover:bg-discord-modifier-hover transition-all flex items-center justify-center", title: "Delete", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" }) }) }))] }))] }));
|
||||
if (contextMenuItems.length > 0) {
|
||||
return _jsx(ContextMenu, { items: contextMenuItems, children: content });
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
const openImagePreview = useUIStore((s) => s.openImagePreview);
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
|
||||
const channelKey = message.channelId || (message as any).dmChannelId;
|
||||
const isAuthor = currentUser?.id === message.userId;
|
||||
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
|
||||
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
|
||||
@@ -99,7 +100,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
if (canDelete) {
|
||||
contextMenuItems.push({
|
||||
label: 'Delete Message',
|
||||
onClick: () => deleteMessage(message.id),
|
||||
onClick: () => deleteMessage(message.id, channelKey),
|
||||
danger: true,
|
||||
});
|
||||
}
|
||||
@@ -108,7 +109,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (editContent.trim()) {
|
||||
await editMessage(message.id, editContent.trim());
|
||||
await editMessage(message.id, editContent.trim(), channelKey);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}
|
||||
@@ -216,7 +217,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
escape to <button onClick={() => setIsEditing(false)} className="text-discord-text-link hover:underline">cancel</button>
|
||||
{' '}• enter to <button onClick={() => {
|
||||
if (editContent.trim()) {
|
||||
editMessage(message.id, editContent.trim());
|
||||
editMessage(message.id, editContent.trim(), channelKey);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}} className="text-discord-text-link hover:underline">save</button>
|
||||
@@ -365,7 +366,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => deleteMessage(message.id)}
|
||||
onClick={() => deleteMessage(message.id, channelKey)}
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { isDmChannel } from '../../stores/serverStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { api } from '../../api/client';
|
||||
export function MessageInput({ channelId, channelName }) {
|
||||
@@ -17,7 +17,7 @@ export function MessageInput({ channelId, channelName }) {
|
||||
const handleTyping = useCallback(() => {
|
||||
if (typingTimeoutRef.current)
|
||||
return;
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
wsSend({ type: 'dm_typing_start', dmChannelId: channelId });
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { isDmChannel } from '../../stores/serverStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
@@ -22,7 +22,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
|
||||
const handleTyping = useCallback(() => {
|
||||
if (typingTimeoutRef.current) return;
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
wsSend({ type: 'dm_typing_start', dmChannelId: channelId });
|
||||
} else {
|
||||
|
||||
@@ -32,14 +32,23 @@ export function MessageList({ channelId }) {
|
||||
const loadMoreMessages = useChatStore((s) => s.loadMoreMessages);
|
||||
const isLoading = useChatStore((s) => s.isLoading);
|
||||
const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true);
|
||||
const ackChannel = useChatStore((s) => s.ackChannel);
|
||||
const bottomRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const prevMessagesLength = useRef(0);
|
||||
const ackTimerRef = useRef();
|
||||
useEffect(() => {
|
||||
loadMessages(channelId);
|
||||
}, [channelId, loadMessages]);
|
||||
useEffect(() => {
|
||||
if (messages.length > 0 && isNearBottom) {
|
||||
clearTimeout(ackTimerRef.current);
|
||||
ackTimerRef.current = setTimeout(() => ackChannel(channelId), 200);
|
||||
}
|
||||
return () => clearTimeout(ackTimerRef.current);
|
||||
}, [channelId, messages.length, isNearBottom, ackChannel]);
|
||||
// Auto-scroll to bottom on new messages (if near bottom)
|
||||
useEffect(() => {
|
||||
if (messages.length > prevMessagesLength.current && isNearBottom) {
|
||||
@@ -77,7 +86,7 @@ export function MessageList({ channelId }) {
|
||||
if (isLoading && messages.length === 0) {
|
||||
return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsx(LoadingSpinner, {}) }));
|
||||
}
|
||||
return (_jsxs("div", { ref: containerRef, className: "flex-1 overflow-y-auto overflow-x-hidden", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && (_jsxs("div", { className: "px-4 pt-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) => {
|
||||
return (_jsxs("div", { ref: containerRef, className: "flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "w-[68px] h-[68px] rounded-full bg-discord-bg-accent flex items-center justify-center mb-4 text-white", children: _jsx("svg", { width: "42", height: "42", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: "Welcome to the channel!" }), _jsx("p", { className: "text-discord-text-secondary text-[16px] mt-2", children: "This is the start of the conversation." }), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] })), _jsx("div", { className: "pb-6", children: messages.map((msg, i) => {
|
||||
const prevMsg = messages[i - 1];
|
||||
const showDate = shouldShowDateDivider(prevMsg, msg);
|
||||
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
|
||||
|
||||
@@ -39,16 +39,27 @@ export function MessageList({ channelId }: MessageListProps) {
|
||||
const loadMoreMessages = useChatStore((s) => s.loadMoreMessages);
|
||||
const isLoading = useChatStore((s) => s.isLoading);
|
||||
const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true);
|
||||
const ackChannel = useChatStore((s) => s.ackChannel);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const prevMessagesLength = useRef(0);
|
||||
const ackTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
loadMessages(channelId);
|
||||
}, [channelId, loadMessages]);
|
||||
|
||||
// Ack channel when messages load or when new messages arrive while near bottom
|
||||
useEffect(() => {
|
||||
if (messages.length > 0 && isNearBottom) {
|
||||
clearTimeout(ackTimerRef.current);
|
||||
ackTimerRef.current = setTimeout(() => ackChannel(channelId), 200);
|
||||
}
|
||||
return () => clearTimeout(ackTimerRef.current);
|
||||
}, [channelId, messages.length, isNearBottom, ackChannel]);
|
||||
|
||||
// Auto-scroll to bottom on new messages (if near bottom)
|
||||
useEffect(() => {
|
||||
if (messages.length > prevMessagesLength.current && isNearBottom) {
|
||||
@@ -98,7 +109,7 @@ export function MessageList({ channelId }: MessageListProps) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden"
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{isLoadingMore && (
|
||||
|
||||
@@ -17,6 +17,7 @@ export function ChannelSidebar() {
|
||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||
const unreadChannels = useChatStore((s) => s.unreadChannels);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const members = useServerStore((s) => s.members);
|
||||
@@ -71,17 +72,24 @@ export function ChannelSidebar() {
|
||||
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
|
||||
const isDmUnread = unreadChannels.has(dm.id) && currentChannelId !== dm.id;
|
||||
return (_jsxs("div", { onClick: () => handleChannelClick(dm.id), className: `relative 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));
|
||||
: isDmUnread
|
||||
? 'text-white hover:bg-discord-modifier-hover'
|
||||
: 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: [isDmUnread && _jsx("div", { className: "absolute -left-1 w-1 h-2 bg-white rounded-r-full" }), _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] truncate ${currentChannelId === dm.id ? 'text-white font-medium' : isDmUnread ? 'text-white font-bold' : 'text-discord-text-muted group-hover:text-discord-text-secondary font-medium'}`, 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-discord-bg-user-area 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: isMuted ? 'Unmute' : 'Mute', onClick: handleMicToggle, active: isMuted, 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" }), isMuted && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2" })] }) }), _jsx(UserAreaButton, { title: isDeafened ? 'Undeafen' : 'Deafen', onClick: handleDeafenToggle, active: isDeafened, children: _jsxs("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" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2" })] }) }), _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 select-none", children: [_jsxs("button", { onClick: () => openModal('serverSettings'), className: "h-12 px-4 flex items-center justify-between shadow-header z-10 hover:bg-discord-modifier-hover transition-colors group", children: [_jsx("span", { className: "font-bold text-[16px] text-discord-text-primary truncate leading-tight", children: server.name }), _jsx("svg", { width: "18", height: "18", viewBox: "0 0 18 18", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0 group-hover:text-discord-text-secondary", children: _jsx("path", { d: "M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" }) })] }), _jsxs("div", { className: "flex-1 overflow-y-auto pt-3 px-2 space-y-[21px] no-scrollbar", children: [_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1 group cursor-pointer", children: [_jsxs("div", { className: "flex items-center gap-0.5 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-70", children: _jsx("path", { d: "M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" }) }), _jsx("span", { className: "text-[12px] font-bold uppercase tracking-wider", children: "Text Channels" })] }), isAdminUser && (_jsx("button", { onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
openModal('createChannel');
|
||||
}, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), _jsx("div", { className: "space-y-[2px]", children: textChannels.map((channel) => (_jsxs("button", { onClick: () => handleChannelClick(channel.id), className: `w-full flex items-center gap-1.5 px-2 h-8 rounded-[4px] group transition-colors ${currentChannelId === channel.id
|
||||
}, 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) => {
|
||||
const isUnread = unreadChannels.has(channel.id) && currentChannelId !== channel.id;
|
||||
return _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) => {
|
||||
: isUnread
|
||||
? 'text-white hover:text-white hover:bg-discord-modifier-hover'
|
||||
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'}`, children: [isUnread && _jsx("div", { className: "absolute -left-0.5 w-1 h-2 bg-white rounded-r-full" }), _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 text-[16px] ${isUnread ? 'font-bold' : 'font-medium'}`, 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, {}), user && (_jsxs("div", { className: "h-[52px] px-2 bg-discord-bg-user-area 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: isMuted ? 'Unmute' : 'Mute', onClick: handleMicToggle, active: isMuted, 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" }), isMuted && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2" })] }) }), _jsx(UserAreaButton, { title: isDeafened ? 'Undeafen' : 'Deafen', onClick: handleDeafenToggle, active: isDeafened, children: _jsxs("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" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2" })] }) }), _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" }) }) })] })] }))] }));
|
||||
|
||||
@@ -18,6 +18,7 @@ export function ChannelSidebar() {
|
||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||
const unreadChannels = useChatStore((s) => s.unreadChannels);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const members = useServerStore((s) => s.members);
|
||||
@@ -113,20 +114,30 @@ export function ChannelSidebar() {
|
||||
{dmChannels.map((dm) => {
|
||||
const otherUser = dm.members.find(m => m.id !== user?.id);
|
||||
if (!otherUser) return null;
|
||||
|
||||
const isDmUnread = unreadChannels.has(dm.id) && currentChannelId !== dm.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
<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'
|
||||
className={`relative 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'
|
||||
: isDmUnread
|
||||
? 'text-white hover:bg-discord-modifier-hover'
|
||||
: 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{isDmUnread && (
|
||||
<div className="absolute -left-1 w-1 h-2 bg-white rounded-r-full" />
|
||||
)}
|
||||
<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'}`}>
|
||||
<div className={`text-[16px] truncate ${
|
||||
currentChannelId === dm.id ? 'text-white font-medium'
|
||||
: isDmUnread ? 'text-white font-bold'
|
||||
: 'text-discord-text-muted group-hover:text-discord-text-secondary font-medium'
|
||||
}`}>
|
||||
{otherUser.displayName ?? otherUser.username}
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,22 +205,30 @@ export function ChannelSidebar() {
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-[2px]">
|
||||
{textChannels.map((channel) => (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => handleChannelClick(channel.id)}
|
||||
className={`w-full flex items-center gap-1.5 px-2 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'
|
||||
}`}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-60">
|
||||
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
|
||||
</svg>
|
||||
<span className="truncate font-medium text-[16px]">{channel.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{textChannels.map((channel) => {
|
||||
const isUnread = unreadChannels.has(channel.id) && currentChannelId !== channel.id;
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
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'
|
||||
: isUnread
|
||||
? 'text-white hover:text-white hover:bg-discord-modifier-hover'
|
||||
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'
|
||||
}`}
|
||||
>
|
||||
{isUnread && (
|
||||
<div className="absolute -left-0.5 w-1 h-2 bg-white rounded-r-full" />
|
||||
)}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-60">
|
||||
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
|
||||
</svg>
|
||||
<span className={`truncate text-[16px] ${isUnread ? 'font-bold' : 'font-medium'}`}>{channel.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Tooltip } from '../ui/Tooltip';
|
||||
function SidebarItem({ name, icon, active, onClick, type = 'server', actionType }) {
|
||||
function SidebarItem({ name, icon, active, onClick, type = 'server', actionType, hasUnread }) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const firstLetter = name.charAt(0).toUpperCase();
|
||||
const getPillHeight = () => {
|
||||
@@ -12,6 +13,8 @@ function SidebarItem({ name, icon, active, onClick, type = 'server', actionType
|
||||
return 'h-10';
|
||||
if (isHovered)
|
||||
return 'h-5';
|
||||
if (hasUnread && !active)
|
||||
return 'h-2';
|
||||
return 'h-2 scale-0';
|
||||
};
|
||||
const getButtonClasses = () => {
|
||||
@@ -30,10 +33,27 @@ export function ServerSidebar() {
|
||||
const servers = useServerStore((s) => s.servers);
|
||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
|
||||
const channelToServerMap = useServerStore((s) => s.channelToServerMap);
|
||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||
const showDms = useUIStore((s) => s.showDms);
|
||||
const setShowDms = useUIStore((s) => s.setShowDms);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
const unreadChannels = useChatStore((s) => s.unreadChannels);
|
||||
const navigate = useNavigate();
|
||||
const unreadServerIds = useMemo(() => {
|
||||
const ids = new Set();
|
||||
for (const channelId of unreadChannels) {
|
||||
const serverId = channelToServerMap.get(channelId);
|
||||
if (serverId) ids.add(serverId);
|
||||
}
|
||||
return ids;
|
||||
}, [unreadChannels, channelToServerMap]);
|
||||
const hasDmUnread = useMemo(() => {
|
||||
for (const dm of dmChannels) {
|
||||
if (unreadChannels.has(dm.id)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [unreadChannels, dmChannels]);
|
||||
const handleServerClick = (serverId) => {
|
||||
setCurrentServer(serverId);
|
||||
setShowDms(false);
|
||||
@@ -44,5 +64,5 @@ export function ServerSidebar() {
|
||||
setCurrentServer(null);
|
||||
navigate('/channels/@me');
|
||||
};
|
||||
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" })] }));
|
||||
return (_jsxs("nav", { className: "w-[72px] bg-discord-bg-server 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", hasUnread: hasDmUnread }), _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), hasUnread: unreadServerIds.has(server.id) }, server.id))), _jsx(SidebarItem, { id: "add-server", name: "Add a Server", active: false, onClick: () => openModal('createServer'), type: "action", actionType: "add" }), _jsx(SidebarItem, { id: "join-server", name: "Join a Server", active: false, onClick: () => openModal('joinServer'), type: "action", actionType: "join" })] }));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Tooltip } from '../ui/Tooltip';
|
||||
|
||||
@@ -12,15 +13,17 @@ interface SidebarItemProps {
|
||||
onClick: () => void;
|
||||
type?: 'server' | 'dm' | 'action';
|
||||
actionType?: 'add' | 'join';
|
||||
hasUnread?: boolean;
|
||||
}
|
||||
|
||||
function SidebarItem({ name, icon, active, onClick, type = 'server', actionType }: SidebarItemProps) {
|
||||
function SidebarItem({ name, icon, active, onClick, type = 'server', actionType, hasUnread }: SidebarItemProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const firstLetter = name.charAt(0).toUpperCase();
|
||||
|
||||
const getPillHeight = () => {
|
||||
if (active) return 'h-10';
|
||||
if (isHovered) return 'h-5';
|
||||
if (hasUnread && !active) return 'h-2';
|
||||
return 'h-2 scale-0';
|
||||
};
|
||||
|
||||
@@ -88,11 +91,32 @@ export function ServerSidebar() {
|
||||
const servers = useServerStore((s) => s.servers);
|
||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
|
||||
const channelToServerMap = useServerStore((s) => s.channelToServerMap);
|
||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||
const showDms = useUIStore((s) => s.showDms);
|
||||
const setShowDms = useUIStore((s) => s.setShowDms);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
const unreadChannels = useChatStore((s) => s.unreadChannels);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Compute which servers have unread channels
|
||||
const unreadServerIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const channelId of unreadChannels) {
|
||||
const serverId = channelToServerMap.get(channelId);
|
||||
if (serverId) ids.add(serverId);
|
||||
}
|
||||
return ids;
|
||||
}, [unreadChannels, channelToServerMap]);
|
||||
|
||||
// Check if any DM channels are unread
|
||||
const hasDmUnread = useMemo(() => {
|
||||
for (const dm of dmChannels) {
|
||||
if (unreadChannels.has(dm.id)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [unreadChannels, dmChannels]);
|
||||
|
||||
const handleServerClick = (serverId: string) => {
|
||||
setCurrentServer(serverId);
|
||||
setShowDms(false);
|
||||
@@ -106,13 +130,14 @@ export function ServerSidebar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<nav className="w-[72px] bg-discord-bg-server flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none">
|
||||
<SidebarItem
|
||||
id="@me"
|
||||
name="Direct Messages"
|
||||
active={showDms}
|
||||
onClick={handleDmClick}
|
||||
type="dm"
|
||||
hasUnread={hasDmUnread}
|
||||
/>
|
||||
|
||||
<div className="w-8 h-[2px] bg-discord-modifier-accent rounded-full mb-2" />
|
||||
@@ -125,6 +150,7 @@ export function ServerSidebar() {
|
||||
icon={server.icon}
|
||||
active={currentServerId === server.id}
|
||||
onClick={() => handleServerClick(server.id)}
|
||||
hasUnread={unreadServerIds.has(server.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CreateChannelModal() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Create Channel", children: _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Channel Type" }), _jsx("div", { className: "space-y-2", children: ['text', 'voice', 'video'].map((t) => (_jsxs("label", { className: `flex items-center gap-3 p-3 rounded cursor-pointer border ${type === t
|
||||
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Create Channel", children: _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Channel Type" }), _jsx("div", { className: "space-y-2", children: ['text', 'voice', 'video'].map((t) => (_jsxs("label", { className: `flex items-center gap-3 p-3 rounded cursor-pointer border ${type === t
|
||||
? 'border-discord-blurple bg-discord-bg-hover'
|
||||
: 'border-discord-bg-tertiary bg-discord-bg-secondary hover:bg-discord-bg-hover'}`, children: [_jsx("input", { type: "radio", name: "channelType", value: t, checked: type === t, onChange: () => setType(t), className: "hidden" }), _jsxs("div", { className: "w-5 h-5 text-discord-text-muted", children: [t === 'text' && (_jsx("svg", { 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" }) })), t === 'voice' && (_jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", 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" }) })), t === 'video' && (_jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) }))] }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium text-discord-text-primary capitalize", children: t }), _jsxs("div", { className: "text-xs text-discord-text-muted", children: [t === 'text' && 'Send messages, images, and files', t === 'voice' && 'Hang out with voice and video', t === 'video' && 'Share your screen and camera'] })] })] }, t))) })] }), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Channel Name" }), _jsx("input", { type: "text", value: name, onChange: (e) => setName(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: "new-channel", autoFocus: true })] }), type === 'text' && (_jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Topic (optional)" }), _jsx("input", { type: "text", value: topic, onChange: (e) => setTopic(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's this channel about?" })] })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", 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 ? 'Creating...' : 'Create Channel' })] })] }) }));
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function CreateChannelModal() {
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="Create Channel">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
|
||||
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -34,5 +34,5 @@ export function CreateServerModal() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Create a Server", children: _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Server Name" }), _jsx("input", { type: "text", value: name, onChange: (e) => setName(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: "My Awesome Server", autoFocus: true })] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", 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 ? 'Creating...' : 'Create' })] })] }) }));
|
||||
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Create a Server", children: _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Server Name" }), _jsx("input", { type: "text", value: name, onChange: (e) => setName(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: "My Awesome Server", autoFocus: true })] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", 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 ? 'Creating...' : 'Create' })] })] }) }));
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function CreateServerModal() {
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="Create a Server">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
|
||||
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function ServerSettingsModal() {
|
||||
setError(err instanceof Error ? err.message : 'Failed to kick member');
|
||||
}
|
||||
};
|
||||
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Server Settings", maxWidth: "max-w-xl", children: _jsxs("div", { className: "flex gap-4", children: [_jsxs("div", { className: "w-32 flex-shrink-0 space-y-1", children: [_jsx("button", { onClick: () => setTab('overview'), className: `w-full text-left px-3 py-1.5 rounded text-sm ${tab === 'overview' ? 'bg-discord-bg-active text-white' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: "Overview" }), _jsx("button", { onClick: () => setTab('members'), className: `w-full text-left px-3 py-1.5 rounded text-sm ${tab === 'members' ? 'bg-discord-bg-active text-white' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: "Members" })] }), _jsxs("div", { className: "flex-1 min-w-0", children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), tab === 'overview' && (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Server Name" }), _jsx("input", { type: "text", value: serverName, onChange: (e) => setServerName(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", disabled: !isOwnerUser })] }), isOwnerUser && (_jsxs(_Fragment, { children: [_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' }), _jsxs("div", { className: "pt-4 border-t border-discord-bg-tertiary", children: [_jsx("h3", { className: "text-sm font-bold text-discord-red mb-2", children: "Danger Zone" }), _jsx("button", { onClick: handleDelete, className: "px-4 py-2 bg-discord-red hover:bg-discord-red-hover text-white text-sm font-medium rounded transition-colors", children: confirmDelete ? 'Click again to confirm deletion' : 'Delete Server' })] })] }))] })), tab === 'members' && (_jsx("div", { className: "space-y-2 max-h-[400px] overflow-y-auto", children: members.map((member) => {
|
||||
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Server Settings", maxWidth: "max-w-xl", children: _jsxs("div", { className: "flex gap-4", children: [_jsxs("div", { className: "w-32 flex-shrink-0 space-y-1", children: [_jsx("button", { onClick: () => setTab('overview'), className: `w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${tab === 'overview' ? 'bg-discord-bg-active text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: "Overview" }), _jsx("button", { onClick: () => setTab('members'), className: `w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${tab === 'members' ? 'bg-discord-bg-active text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: "Members" })] }), _jsxs("div", { className: "flex-1 min-w-0", children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), tab === 'overview' && (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Server Name" }), _jsx("input", { type: "text", value: serverName, onChange: (e) => setServerName(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", disabled: !isOwnerUser })] }), isOwnerUser && (_jsxs(_Fragment, { children: [_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' }), _jsxs("div", { className: "pt-4 border-t border-discord-bg-tertiary", children: [_jsx("h3", { className: "text-sm font-bold text-discord-red mb-2", children: "Danger Zone" }), _jsx("button", { onClick: handleDelete, className: "px-4 py-2 bg-discord-red hover:bg-discord-red-hover text-white text-sm font-medium rounded transition-colors", children: confirmDelete ? 'Click again to confirm deletion' : 'Delete Server' })] })] }))] })), tab === 'members' && (_jsx("div", { className: "space-y-2 max-h-[400px] overflow-y-auto scrollbar-thin", children: members.map((member) => {
|
||||
const displayName = member.user.displayName ?? member.user.username;
|
||||
return (_jsxs("div", { className: "flex items-center justify-between p-2 rounded hover:bg-discord-bg-hover", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: member.user.status }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium", children: displayName }), _jsx("div", { className: "text-xs text-discord-text-muted capitalize", children: member.role })] })] }), isOwnerUser && member.userId !== currentUser?.id && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("select", { value: member.role, onChange: (e) => handleRoleChange(member.userId, e.target.value), className: "px-2 py-1 bg-discord-bg-tertiary rounded text-xs text-discord-text-secondary outline-none", children: [_jsx("option", { value: "member", children: "Member" }), _jsx("option", { value: "admin", children: "Admin" })] }), _jsx("button", { onClick: () => handleKick(member.userId), className: "px-2 py-1 text-xs text-discord-red hover:bg-discord-red/10 rounded transition-colors", children: "Kick" })] }))] }, member.userId));
|
||||
}) }))] })] }) }));
|
||||
|
||||
@@ -89,16 +89,16 @@ export function ServerSettingsModal() {
|
||||
<div className="w-32 flex-shrink-0 space-y-1">
|
||||
<button
|
||||
onClick={() => setTab('overview')}
|
||||
className={`w-full text-left px-3 py-1.5 rounded text-sm ${
|
||||
tab === 'overview' ? 'bg-discord-bg-active text-white' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
|
||||
className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
|
||||
tab === 'overview' ? 'bg-discord-bg-active text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
|
||||
}`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('members')}
|
||||
className={`w-full text-left px-3 py-1.5 rounded text-sm ${
|
||||
tab === 'members' ? 'bg-discord-bg-active text-white' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
|
||||
className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
|
||||
tab === 'members' ? 'bg-discord-bg-active text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
|
||||
}`}
|
||||
>
|
||||
Members
|
||||
@@ -108,7 +108,7 @@ export function ServerSettingsModal() {
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{error && (
|
||||
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">{error}</div>
|
||||
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{tab === 'overview' && (
|
||||
@@ -151,7 +151,7 @@ export function ServerSettingsModal() {
|
||||
)}
|
||||
|
||||
{tab === 'members' && (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto scrollbar-thin">
|
||||
{members.map((member) => {
|
||||
const displayName = member.user.displayName ?? member.user.username;
|
||||
return (
|
||||
|
||||
@@ -43,5 +43,5 @@ export function UserSettingsModal() {
|
||||
};
|
||||
if (!user)
|
||||
return null;
|
||||
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "User Settings", maxWidth: "max-w-lg", children: _jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center gap-4 p-4 bg-discord-bg-secondary rounded-lg", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 64, status: user.status }), _jsxs("div", { children: [_jsx("div", { className: "font-bold text-lg", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-discord-text-muted text-sm", children: ["@", user.username] }), user.customStatus && (_jsx("div", { className: "text-discord-text-secondary text-sm mt-1", children: user.customStatus }))] })] }), error && (_jsx("div", { className: "p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), success && (_jsx("div", { className: "p-2 bg-discord-green/10 border border-discord-green/30 rounded text-discord-green text-sm", children: success })), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "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' })] })] }) }));
|
||||
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-text-danger text-sm", children: error })), success && (_jsx("div", { className: "p-2 bg-discord-green/10 border border-discord-green/30 rounded text-discord-text-positive 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' })] })] }) }));
|
||||
}
|
||||
|
||||
@@ -67,10 +67,10 @@ export function UserSettingsModal() {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">{error}</div>
|
||||
<div className="p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm">{error}</div>
|
||||
)}
|
||||
{success && (
|
||||
<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-text-positive text-sm">{success}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
|
||||
if (fallback)
|
||||
fallback.style.display = 'flex';
|
||||
}
|
||||
} })) : null, _jsx("div", { className: `avatar-fallback w-full h-full rounded-full bg-discord-blurple flex items-center justify-center ${fontSize} font-semibold text-white ${src ? 'hidden' : 'flex'}`, style: src ? { display: 'none' } : undefined, children: initials }), status && (_jsx("div", { className: `absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-[#2b2d31] ${statusColors[status] ?? 'bg-discord-text-muted'}`, 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-discord-bg-secondary ${statusColors[status] ?? 'bg-discord-text-muted'}`, style: {
|
||||
width: size * 0.35,
|
||||
height: size * 0.35,
|
||||
minWidth: 12,
|
||||
|
||||
@@ -66,7 +66,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
|
||||
</div>
|
||||
{status && (
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-[#2b2d31] ${statusColors[status] ?? 'bg-discord-text-muted'}`}
|
||||
className={`absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-discord-bg-secondary ${statusColors[status] ?? 'bg-discord-text-muted'}`}
|
||||
style={{
|
||||
width: size * 0.35,
|
||||
height: size * 0.35,
|
||||
|
||||
@@ -14,5 +14,5 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md'
|
||||
}, [isOpen, handleKeyDown]);
|
||||
if (!isOpen)
|
||||
return null;
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center animate-fade-in", children: [_jsx("div", { className: "absolute inset-0 bg-black/70", onClick: onClose }), _jsxs("div", { className: `relative ${maxWidth} w-full mx-4 bg-discord-bg-primary rounded-lg shadow-xl animate-slide-up`, children: [title && (_jsxs("div", { className: "flex items-center justify-between px-4 pt-4", children: [_jsx("h2", { className: "text-xl font-bold text-discord-text-primary", children: title }), _jsx("button", { onClick: onClose, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors p-1", children: _jsx("svg", { width: "24", height: "24", 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" }) }) })] })), _jsx("div", { className: "p-4", children: children })] })] }));
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center animate-fade-in", children: [_jsx("div", { className: "absolute inset-0 bg-discord-bg-overlay", onClick: onClose }), _jsxs("div", { className: `relative ${maxWidth} w-full mx-4 bg-discord-bg-surface rounded-lg shadow-xl animate-slide-up`, children: [title && (_jsxs("div", { className: "flex items-center justify-between px-4 pt-4", children: [_jsx("h2", { className: "text-xl font-bold text-discord-text-primary", children: title }), _jsx("button", { onClick: onClose, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors p-1", children: _jsx("svg", { width: "24", height: "24", 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" }) }) })] })), _jsx("div", { className: "p-4", children: children })] })] }));
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md'
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70"
|
||||
className="absolute inset-0 bg-discord-bg-overlay"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className={`relative ${maxWidth} w-full mx-4 bg-discord-bg-primary rounded-lg shadow-xl animate-slide-up`}>
|
||||
<div className={`relative ${maxWidth} w-full mx-4 bg-discord-bg-surface rounded-lg shadow-xl animate-slide-up`}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-4 pt-4">
|
||||
<h2 className="text-xl font-bold text-discord-text-primary">{title}</h2>
|
||||
|
||||
@@ -23,5 +23,5 @@ export function Tooltip({ content, children, position = 'right', delay = 200 })
|
||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||
};
|
||||
return (_jsxs("div", { className: "relative inline-flex", onMouseEnter: show, onMouseLeave: hide, children: [children, isVisible && (_jsx("div", { 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]}`, children: content }))] }));
|
||||
return (_jsxs("div", { className: "relative inline-flex", onMouseEnter: show, onMouseLeave: hide, children: [children, isVisible && (_jsx("div", { className: `absolute z-50 px-3 py-1.5 text-sm font-medium text-discord-text-primary bg-discord-bg-floating rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none ${positionClasses[position]}`, children: content }))] }));
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
|
||||
{children}
|
||||
{isVisible && (
|
||||
<div
|
||||
className={`absolute z-50 px-3 py-1.5 text-sm font-medium text-white bg-discord-bg-floating rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none ${positionClasses[position]}`}
|
||||
className={`absolute z-50 px-3 py-1.5 text-sm font-medium text-discord-text-primary bg-discord-bg-floating rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none ${positionClasses[position]}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,6 @@ export function VoiceChannel({ channelId, channelName, onClick }) {
|
||||
if (!member)
|
||||
return null;
|
||||
const displayName = member.user.displayName ?? member.user.username;
|
||||
return (_jsxs("div", { className: "flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-bg-hover", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 20, status: member.user.status }), _jsx("span", { className: "text-xs text-discord-text-secondary truncate", children: displayName })] }, userId));
|
||||
return (_jsxs("div", { className: "flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-bg-hover transition-colors", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 20, status: member.user.status }), _jsx("span", { className: "text-xs text-discord-text-secondary truncate", children: displayName })] }, userId));
|
||||
}) }))] }));
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
|
||||
if (!member) return null;
|
||||
const displayName = member.user.displayName ?? member.user.username;
|
||||
return (
|
||||
<div key={userId} className="flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-bg-hover">
|
||||
<div key={userId} className="flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-bg-hover transition-colors">
|
||||
<Avatar
|
||||
src={member.user.avatar}
|
||||
name={displayName}
|
||||
|
||||
@@ -5,12 +5,8 @@ import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
export function VoiceControls() {
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const isMuted = useVoiceStore((s) => s.isMuted);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||
const connectionError = useVoiceStore((s) => s.connectionError);
|
||||
@@ -20,24 +16,6 @@ export function VoiceControls() {
|
||||
return null;
|
||||
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||
const channelName = channel?.name ?? 'Voice Channel';
|
||||
const handleMic = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room) {
|
||||
console.warn('[VoiceControls] handleMic: no active room');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// isMuted is the pre-toggle value: if true → we want to unmute → enable mic
|
||||
await room.localParticipant.setMicrophoneEnabled(isMuted);
|
||||
toggleMic();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControls] Failed to toggle mic:', err);
|
||||
}
|
||||
};
|
||||
const handleDeafen = () => {
|
||||
toggleDeafen();
|
||||
};
|
||||
const handleCamera = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room) {
|
||||
@@ -45,7 +23,6 @@ export function VoiceControls() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// isCameraOn is pre-toggle: if false → enable camera
|
||||
await room.localParticipant.setCameraEnabled(!isCameraOn);
|
||||
toggleCamera();
|
||||
}
|
||||
@@ -68,19 +45,8 @@ export function VoiceControls() {
|
||||
}
|
||||
};
|
||||
const handleDisconnect = () => {
|
||||
// Send WS leave event
|
||||
wsSend({ type: 'voice_leave' });
|
||||
// Reset the entire voice store (sets currentVoiceChannelId to null,
|
||||
// which triggers AppLayout's useEffect to call useLiveKit.disconnect)
|
||||
useVoiceStore.getState().reset();
|
||||
useVoiceStore.getState().leaveVoice();
|
||||
};
|
||||
return (_jsxs("div", { className: "bg-discord-bg-secondary border-t border-discord-bg-tertiary p-2", children: [_jsx("div", { className: "flex items-center justify-between px-1 mb-2", children: _jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: `text-xs font-medium flex items-center gap-1 ${connectionError ? 'text-discord-red' : isLiveKitConnected ? 'text-discord-green' : 'text-yellow-500'}`, children: [_jsx("svg", { width: "12", height: "12", 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 2z" }) }), connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'] }), _jsx("div", { className: "text-xs text-discord-text-muted truncate", children: connectionError ? connectionError : channelName })] }) }), _jsxs("div", { className: "flex items-center justify-center gap-2", children: [_jsx("button", { onClick: handleMic, className: `p-2 rounded-full transition-colors ${isMuted
|
||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isMuted ? 'Unmute' : 'Mute', children: isMuted ? (_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", 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("path", { d: "M2.1 2.1L1.4 2.8L7.6 9L7 12C7 14.8 9.2 17 12 17C12.9 17 13.7 16.7 14.4 16.3L16.2 18.1C15 18.9 13.6 19.4 12 19.5V22H14V24H10V22H12V19.5C8.4 19.1 5.6 16.1 5 12.5H7C7.5 14.8 9.5 16.5 12 16.5C12.5 16.5 13 16.4 13.5 16.2L14.7 17.4C13.9 17.8 13 18 12 18C8.7 18 6 15.3 6 12H4C4 15.7 7 18.8 11 19.4V22H10V24H14V22H13V19.4C14 19.3 14.9 18.9 15.7 18.4L21.9 24.6L22.6 23.9L2.1 2.1Z" })] })) : (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", 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 2ZM17 12C17 14.76 14.76 17 12 17S7 14.76 7 12H5C5 15.53 7.61 18.43 11 18.92V22H13V18.92C16.39 18.43 19 15.53 19 12H17Z" }) })) }), _jsx("button", { onClick: handleDeafen, className: `p-2 rounded-full transition-colors ${isDeafened
|
||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isDeafened ? 'Undeafen' : 'Deafen', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12V20C2 21.1 2.9 22 4 22H8V12H4.04C4.28 7.57 7.77 4 12 4S19.72 7.57 19.96 12H16V22H20C21.1 22 22 21.1 22 20V12C22 6.48 17.52 2 12 2Z" }) }) }), _jsx("button", { onClick: handleCamera, className: `p-2 rounded-full transition-colors ${isCameraOn
|
||||
? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) }) }), _jsx("button", { onClick: handleScreenShare, className: `p-2 rounded-full transition-colors ${isScreenSharing
|
||||
? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }) }) }), _jsx("button", { onClick: handleDisconnect, className: "p-2 rounded-full bg-discord-red/20 text-discord-red hover:bg-discord-red/30 transition-colors", title: "Disconnect", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] })] }));
|
||||
return (_jsxs("div", { className: "bg-discord-bg-secondary border-t border-discord-bg-tertiary px-2 py-[10px]", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: `text-[13px] font-semibold leading-[18px] ${connectionError ? 'text-discord-red' : isLiveKitConnected ? 'text-discord-green' : 'text-discord-yellow'}`, children: connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...' }), _jsx("div", { className: "text-[13px] text-discord-text-muted truncate leading-[18px]", children: connectionError ? connectionError : channelName })] }), _jsx("button", { onClick: handleDisconnect, 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-colors flex-shrink-0", title: "Disconnect", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] }), _jsxs("div", { className: "flex items-center justify-center gap-1", children: [_jsx("button", { onClick: handleCamera, className: `w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors ${isCameraOn ? 'text-discord-green bg-discord-green/10 hover:bg-discord-green/20' : 'text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover'}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) }) }), _jsx("button", { onClick: handleScreenShare, className: `w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors ${isScreenSharing ? 'text-discord-green bg-discord-green/10 hover:bg-discord-green/20' : 'text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }) }) })] })] }));
|
||||
}
|
||||
|
||||
@@ -6,12 +6,8 @@ import { wsSend } from '../../hooks/useWebSocket';
|
||||
|
||||
export function VoiceControls() {
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const isMuted = useVoiceStore((s) => s.isMuted);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||
const connectionError = useVoiceStore((s) => s.connectionError);
|
||||
@@ -23,33 +19,10 @@ export function VoiceControls() {
|
||||
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||
const channelName = channel?.name ?? 'Voice Channel';
|
||||
|
||||
const handleMic = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room) {
|
||||
console.warn('[VoiceControls] handleMic: no active room');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// isMuted is the pre-toggle value: if true → we want to unmute → enable mic
|
||||
await room.localParticipant.setMicrophoneEnabled(isMuted);
|
||||
toggleMic();
|
||||
} catch (err) {
|
||||
console.error('[VoiceControls] Failed to toggle mic:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeafen = () => {
|
||||
toggleDeafen();
|
||||
};
|
||||
|
||||
const handleCamera = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room) {
|
||||
console.warn('[VoiceControls] handleCamera: no active room');
|
||||
return;
|
||||
}
|
||||
if (!room) return;
|
||||
try {
|
||||
// isCameraOn is pre-toggle: if false → enable camera
|
||||
await room.localParticipant.setCameraEnabled(!isCameraOn);
|
||||
toggleCamera();
|
||||
} catch (err) {
|
||||
@@ -59,10 +32,7 @@ export function VoiceControls() {
|
||||
|
||||
const handleScreenShare = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room) {
|
||||
console.warn('[VoiceControls] handleScreenShare: no active room');
|
||||
return;
|
||||
}
|
||||
if (!room) return;
|
||||
try {
|
||||
await room.localParticipant.setScreenShareEnabled(!isScreenSharing);
|
||||
toggleScreenShare();
|
||||
@@ -72,105 +42,121 @@ export function VoiceControls() {
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
// Send WS leave event
|
||||
wsSend({ type: 'voice_leave' });
|
||||
// Reset the entire voice store (sets currentVoiceChannelId to null,
|
||||
// which triggers AppLayout's useEffect to call useLiveKit.disconnect)
|
||||
useVoiceStore.getState().reset();
|
||||
useVoiceStore.getState().leaveVoice();
|
||||
};
|
||||
|
||||
const statusColor = connectionError
|
||||
? 'text-discord-red'
|
||||
: isLiveKitConnected
|
||||
? 'text-discord-green'
|
||||
: 'text-discord-yellow';
|
||||
|
||||
const statusBgColor = connectionError
|
||||
? 'bg-discord-red/20'
|
||||
: isLiveKitConnected
|
||||
? 'bg-discord-green/20'
|
||||
: 'bg-discord-yellow/20';
|
||||
|
||||
return (
|
||||
<div className="bg-discord-bg-secondary border-t border-discord-bg-tertiary p-2">
|
||||
<div className="flex items-center justify-between px-1 mb-2">
|
||||
<div className="min-w-0">
|
||||
<div className={`text-xs font-medium flex items-center gap-1 ${connectionError ? 'text-discord-red' : isLiveKitConnected ? 'text-discord-green' : 'text-yellow-500'}`}>
|
||||
<svg width="12" height="12" 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 2z" />
|
||||
</svg>
|
||||
<div className="bg-[#232428] border-t border-discord-bg-tertiary">
|
||||
{/* Row 1: Signal icon + status text + right icons */}
|
||||
<div className="flex items-center gap-2 px-2 pt-[10px] pb-1">
|
||||
{/* Signal icon */}
|
||||
<div className={`w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0`}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className={statusColor}>
|
||||
<path d="M1.5 21.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM3.14 15.75a.75.75 0 01-.09-1.06A8.46 8.46 0 0112 11a8.46 8.46 0 018.95 3.69.75.75 0 01-1.15.97A6.96 6.96 0 0012 12.5a6.96 6.96 0 00-7.8 3.16.75.75 0 01-1.06.09zM6.37 18.3a.75.75 0 01-.08-1.06A5.46 5.46 0 0112 15a5.46 5.46 0 015.71 2.24.75.75 0 01-1.14.97A3.96 3.96 0 0012 16.5a3.96 3.96 0 00-4.57 1.71.75.75 0 01-1.06.09z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Status text */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
|
||||
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
|
||||
</div>
|
||||
<div className="text-xs text-discord-text-muted truncate">
|
||||
<div className="text-[12px] text-discord-channels-default truncate leading-[16px]">
|
||||
{connectionError ? connectionError : channelName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right icons */}
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{/* Signal quality */}
|
||||
<button className="w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded" title="Connection Info">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 20h2V8H2v12zm5 0h2V4H7v16zm5 0h2v-8h-2v8zm5 0h2V12h-2v8z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Disconnect */}
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className="w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded"
|
||||
title="Disconnect"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{/* Mic */}
|
||||
<button
|
||||
onClick={handleMic}
|
||||
className={`p-2 rounded-full transition-colors ${
|
||||
isMuted
|
||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'
|
||||
}`}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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" />
|
||||
<path d="M2.1 2.1L1.4 2.8L7.6 9L7 12C7 14.8 9.2 17 12 17C12.9 17 13.7 16.7 14.4 16.3L16.2 18.1C15 18.9 13.6 19.4 12 19.5V22H14V24H10V22H12V19.5C8.4 19.1 5.6 16.1 5 12.5H7C7.5 14.8 9.5 16.5 12 16.5C12.5 16.5 13 16.4 13.5 16.2L14.7 17.4C13.9 17.8 13 18 12 18C8.7 18 6 15.3 6 12H4C4 15.7 7 18.8 11 19.4V22H10V24H14V22H13V19.4C14 19.3 14.9 18.9 15.7 18.4L21.9 24.6L22.6 23.9L2.1 2.1Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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 2ZM17 12C17 14.76 14.76 17 12 17S7 14.76 7 12H5C5 15.53 7.61 18.43 11 18.92V22H13V18.92C16.39 18.43 19 15.53 19 12H17Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Deafen */}
|
||||
<button
|
||||
onClick={handleDeafen}
|
||||
className={`p-2 rounded-full transition-colors ${
|
||||
isDeafened
|
||||
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'
|
||||
}`}
|
||||
title={isDeafened ? 'Undeafen' : 'Deafen'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12V20C2 21.1 2.9 22 4 22H8V12H4.04C4.28 7.57 7.77 4 12 4S19.72 7.57 19.96 12H16V22H20C21.1 22 22 21.1 22 20V12C22 6.48 17.52 2 12 2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Row 2: Media control buttons */}
|
||||
<div className="flex items-center gap-1 px-2 pb-[10px] pt-1">
|
||||
{/* Camera */}
|
||||
<button
|
||||
onClick={handleCamera}
|
||||
className={`p-2 rounded-full transition-colors ${
|
||||
className={`flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${
|
||||
isCameraOn
|
||||
? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'
|
||||
? 'bg-discord-bg-tertiary text-discord-green hover:bg-discord-bg-tertiary/80'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'
|
||||
}`}
|
||||
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
|
||||
</svg>
|
||||
{isCameraOn ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
|
||||
<line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Screen Share */}
|
||||
<button
|
||||
onClick={handleScreenShare}
|
||||
className={`p-2 rounded-full transition-colors ${
|
||||
className={`flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${
|
||||
isScreenSharing
|
||||
? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'
|
||||
? 'bg-discord-bg-tertiary text-discord-green hover:bg-discord-bg-tertiary/80'
|
||||
: 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'
|
||||
}`}
|
||||
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" />
|
||||
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Disconnect */}
|
||||
{/* Noise Suppression */}
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className="p-2 rounded-full bg-discord-red/20 text-discord-red hover:bg-discord-red/30 transition-colors"
|
||||
title="Disconnect"
|
||||
className="flex-1 h-[34px] flex items-center justify-center rounded-[4px] bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary transition-colors"
|
||||
title="Noise Suppression"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" />
|
||||
<path d="M12 2L9.19 8.63L2 9.24L7.46 13.97L5.82 21L12 17.27L18.18 21L16.54 13.97L22 9.24L14.81 8.63L12 2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Activities */}
|
||||
<button
|
||||
className="flex-1 h-[34px] flex items-center justify-center rounded-[4px] bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary transition-colors"
|
||||
title="Activities"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7.5 2C5.01 2 3 4.01 3 6.5C3 8.99 5.01 11 7.5 11S12 8.99 12 6.5C12 4.01 9.99 2 7.5 2ZM16.5 2C14.01 2 12 4.01 12 6.5C12 8.99 14.01 11 16.5 11S21 8.99 21 6.5C21 4.01 18.99 2 16.5 2ZM7.5 13C5.01 13 3 15.01 3 17.5S5.01 22 7.5 22 12 19.99 12 17.5 9.99 13 7.5 13ZM16.5 13C14.01 13 12 15.01 12 17.5S14.01 22 16.5 22 21 19.99 21 17.5 18.99 13 16.5 13Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,18 @@ function handleEvent(event) {
|
||||
if (currentServerId) {
|
||||
loadServerDetail(currentServerId);
|
||||
}
|
||||
// Clear stale message cache on reconnect, then re-fetch current channel
|
||||
{
|
||||
const { clearAllMessages, loadMessages: reloadMessages, currentChannelId, setReadStates } = useChatStore.getState();
|
||||
clearAllMessages();
|
||||
if (currentChannelId) {
|
||||
reloadMessages(currentChannelId, true);
|
||||
}
|
||||
const { channelLastMessageIds } = useServerStore.getState();
|
||||
if (event.readStates) {
|
||||
setReadStates(event.readStates, channelLastMessageIds);
|
||||
}
|
||||
}
|
||||
// Clear stale voice state, then populate from server truth
|
||||
clearAllVoiceUsers();
|
||||
if (event.voiceStates) {
|
||||
@@ -31,6 +43,12 @@ function handleEvent(event) {
|
||||
break;
|
||||
case 'message_created':
|
||||
addMessage(event.message.channelId, event.message);
|
||||
{
|
||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||
if (event.message.channelId !== currentChannelId) {
|
||||
markChannelUnread(event.message.channelId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'message_updated':
|
||||
updateMessage(event.message);
|
||||
@@ -73,6 +91,12 @@ function handleEvent(event) {
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDmChannels(updatedDms);
|
||||
{
|
||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||
if (event.message.dmChannelId !== currentChannelId) {
|
||||
markChannelUnread(event.message.dmChannelId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'dm_message_updated':
|
||||
@@ -100,6 +124,11 @@ function handleEvent(event) {
|
||||
addFriendFromAccepted(event.friend, event.requestId);
|
||||
break;
|
||||
}
|
||||
case 'channel_ack': {
|
||||
const { onChannelAck } = useChatStore.getState();
|
||||
onChannelAck(event.channelId, event.messageId);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
console.error('WebSocket error:', event.message);
|
||||
break;
|
||||
|
||||
@@ -25,6 +25,19 @@ function handleEvent(event: ServerEvent): void {
|
||||
if (currentServerId) {
|
||||
loadServerDetail(currentServerId);
|
||||
}
|
||||
// Clear stale message cache on reconnect, then re-fetch current channel
|
||||
{
|
||||
const { clearAllMessages, loadMessages: reloadMessages, currentChannelId, setReadStates } = useChatStore.getState();
|
||||
clearAllMessages();
|
||||
if (currentChannelId) {
|
||||
reloadMessages(currentChannelId, true);
|
||||
}
|
||||
// Initialize unread tracking from ready payload
|
||||
const { channelLastMessageIds } = useServerStore.getState();
|
||||
if (event.readStates) {
|
||||
setReadStates(event.readStates, channelLastMessageIds);
|
||||
}
|
||||
}
|
||||
// Clear stale voice state, then populate from server truth
|
||||
clearAllVoiceUsers();
|
||||
if (event.voiceStates) {
|
||||
@@ -36,6 +49,12 @@ function handleEvent(event: ServerEvent): void {
|
||||
|
||||
case 'message_created':
|
||||
addMessage(event.message.channelId, event.message);
|
||||
{
|
||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||
if (event.message.channelId !== currentChannelId) {
|
||||
markChannelUnread(event.message.channelId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message_updated':
|
||||
@@ -86,6 +105,13 @@ function handleEvent(event: ServerEvent): void {
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDmChannels(updatedDms);
|
||||
// Mark DM as unread if not currently viewing it
|
||||
{
|
||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||
if (event.message.dmChannelId !== currentChannelId) {
|
||||
markChannelUnread(event.message.dmChannelId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -121,6 +147,12 @@ function handleEvent(event: ServerEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'channel_ack': {
|
||||
const { onChannelAck } = useChatStore.getState();
|
||||
onChannelAck(event.channelId, event.messageId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
console.error('WebSocket error:', event.message);
|
||||
break;
|
||||
|
||||
@@ -26,13 +26,13 @@ class ErrorBoundary extends React.Component<
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#313338',
|
||||
color: '#f2f3f5',
|
||||
color: '#ffffff',
|
||||
fontFamily: 'sans-serif',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 'bold' }}>Something went wrong</h1>
|
||||
<p style={{ color: '#949ba4' }}>{this.state.error?.message}</p>
|
||||
<p style={{ color: '#abacb2' }}>{this.state.error?.message}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
import { wsSend } from '../hooks/useWebSocket';
|
||||
import { useUIStore } from './uiStore';
|
||||
import { isDmChannel } from './serverStore';
|
||||
export const useChatStore = create((set, get) => ({
|
||||
messages: new Map(),
|
||||
currentChannelId: null,
|
||||
@@ -10,14 +10,17 @@ export const useChatStore = create((set, get) => ({
|
||||
isLoading: false,
|
||||
loadError: null,
|
||||
replyTo: null,
|
||||
readStates: new Map(),
|
||||
unreadChannels: new Set(),
|
||||
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
||||
setReplyTo: (message) => set({ replyTo: message }),
|
||||
loadMessages: async (channelId) => {
|
||||
if (get().messages.has(channelId))
|
||||
clearAllMessages: () => set({ messages: new Map(), hasMore: new Map() }),
|
||||
loadMessages: async (channelId, force) => {
|
||||
if (!force && get().messages.has(channelId))
|
||||
return;
|
||||
set({ isLoading: true, loadError: null });
|
||||
try {
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
const isDm = isDmChannel(channelId);
|
||||
const messages = isDm
|
||||
? await api.dm.messages(channelId)
|
||||
: await api.channels.messages(channelId);
|
||||
@@ -43,7 +46,7 @@ export const useChatStore = create((set, get) => ({
|
||||
if (!oldestMessage)
|
||||
return false;
|
||||
try {
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
const isDm = isDmChannel(channelId);
|
||||
const olderMessages = isDm
|
||||
? await api.dm.messages(channelId, oldestMessage.id)
|
||||
: await api.channels.messages(channelId, oldestMessage.id);
|
||||
@@ -63,7 +66,7 @@ export const useChatStore = create((set, get) => ({
|
||||
},
|
||||
sendMessage: async (channelId, content, attachmentIds) => {
|
||||
const replyToId = get().replyTo?.id;
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.sendMessage(channelId, { content });
|
||||
}
|
||||
@@ -73,8 +76,8 @@ export const useChatStore = create((set, get) => ({
|
||||
set({ replyTo: null });
|
||||
// Message will arrive via WebSocket
|
||||
},
|
||||
editMessage: async (messageId, content) => {
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
editMessage: async (messageId, content, channelId) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
} else {
|
||||
@@ -82,8 +85,8 @@ export const useChatStore = create((set, get) => ({
|
||||
}
|
||||
// Update will arrive via WebSocket
|
||||
},
|
||||
deleteMessage: async (messageId) => {
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
deleteMessage: async (messageId, channelId) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
} else {
|
||||
@@ -200,4 +203,50 @@ export const useChatStore = create((set, get) => ({
|
||||
const now = Date.now();
|
||||
return users.filter(t => now - t.timestamp < 5000);
|
||||
},
|
||||
setReadStates: (readStates, channelLastMessageIds) => {
|
||||
const rsMap = new Map();
|
||||
for (const rs of readStates) {
|
||||
rsMap.set(rs.channelId, rs.lastReadMessageId);
|
||||
}
|
||||
const unread = new Set();
|
||||
for (const [channelId, lastMsgId] of channelLastMessageIds) {
|
||||
const lastRead = rsMap.get(channelId);
|
||||
if (!lastRead || BigInt(lastMsgId) > BigInt(lastRead)) {
|
||||
unread.add(channelId);
|
||||
}
|
||||
}
|
||||
set({ readStates: rsMap, unreadChannels: unread });
|
||||
},
|
||||
markChannelUnread: (channelId) => {
|
||||
set((state) => {
|
||||
if (state.unreadChannels.has(channelId)) return state;
|
||||
const newUnread = new Set(state.unreadChannels);
|
||||
newUnread.add(channelId);
|
||||
return { unreadChannels: newUnread };
|
||||
});
|
||||
},
|
||||
ackChannel: (channelId) => {
|
||||
const msgs = get().messages.get(channelId);
|
||||
if (!msgs || msgs.length === 0) return;
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
if (!lastMsg) return;
|
||||
const messageId = lastMsg.id;
|
||||
set((state) => {
|
||||
const newReadStates = new Map(state.readStates);
|
||||
newReadStates.set(channelId, messageId);
|
||||
const newUnread = new Set(state.unreadChannels);
|
||||
newUnread.delete(channelId);
|
||||
return { readStates: newReadStates, unreadChannels: newUnread };
|
||||
});
|
||||
wsSend({ type: 'channel_ack', channelId, messageId });
|
||||
},
|
||||
onChannelAck: (channelId, messageId) => {
|
||||
set((state) => {
|
||||
const newReadStates = new Map(state.readStates);
|
||||
newReadStates.set(channelId, messageId);
|
||||
const newUnread = new Set(state.unreadChannels);
|
||||
newUnread.delete(channelId);
|
||||
return { readStates: newReadStates, unreadChannels: newUnread };
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import type { MessageWithUser, Reaction } from '@opencord/shared';
|
||||
import type { MessageWithUser, Reaction, ReadState } from '@opencord/shared';
|
||||
import { api } from '../api/client';
|
||||
import { wsSend } from '../hooks/useWebSocket';
|
||||
import { useUIStore } from './uiStore';
|
||||
import { isDmChannel } from './serverStore';
|
||||
|
||||
interface TypingUser {
|
||||
userId: string;
|
||||
@@ -18,13 +18,16 @@ interface ChatState {
|
||||
isLoading: boolean;
|
||||
loadError: string | null;
|
||||
replyTo: MessageWithUser | null;
|
||||
readStates: Map<string, string>;
|
||||
unreadChannels: Set<string>;
|
||||
setCurrentChannel: (channelId: string | null) => void;
|
||||
setReplyTo: (message: MessageWithUser | null) => void;
|
||||
loadMessages: (channelId: string) => Promise<void>;
|
||||
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
|
||||
clearAllMessages: () => void;
|
||||
loadMoreMessages: (channelId: string) => Promise<boolean>;
|
||||
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
editMessage: (messageId: string, content: string) => Promise<void>;
|
||||
deleteMessage: (messageId: string) => Promise<void>;
|
||||
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
|
||||
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
|
||||
addMessage: (channelId: string, message: MessageWithUser) => void;
|
||||
updateMessage: (message: MessageWithUser) => void;
|
||||
removeMessage: (messageId: string, channelId: string) => void;
|
||||
@@ -36,6 +39,10 @@ interface ChatState {
|
||||
clearTyping: (channelId: string, userId: string) => void;
|
||||
getMessages: (channelId: string) => MessageWithUser[];
|
||||
getTypingUsers: (channelId: string) => TypingUser[];
|
||||
setReadStates: (readStates: ReadState[], channelLastMessageIds: Map<string, string>) => void;
|
||||
markChannelUnread: (channelId: string) => void;
|
||||
ackChannel: (channelId: string) => void;
|
||||
onChannelAck: (channelId: string, messageId: string) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
@@ -46,15 +53,19 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
isLoading: false,
|
||||
loadError: null,
|
||||
replyTo: null,
|
||||
readStates: new Map(),
|
||||
unreadChannels: new Set(),
|
||||
|
||||
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
||||
setReplyTo: (message) => set({ replyTo: message }),
|
||||
|
||||
loadMessages: async (channelId: string) => {
|
||||
if (get().messages.has(channelId)) return;
|
||||
clearAllMessages: () => set({ messages: new Map(), hasMore: new Map() }),
|
||||
|
||||
loadMessages: async (channelId: string, force?: boolean) => {
|
||||
if (!force && get().messages.has(channelId)) return;
|
||||
set({ isLoading: true, loadError: null });
|
||||
try {
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
const isDm = isDmChannel(channelId);
|
||||
const messages = isDm
|
||||
? await api.dm.messages(channelId)
|
||||
: await api.channels.messages(channelId);
|
||||
@@ -80,7 +91,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
if (!oldestMessage) return false;
|
||||
|
||||
try {
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
const isDm = isDmChannel(channelId);
|
||||
const olderMessages = isDm
|
||||
? await api.dm.messages(channelId, oldestMessage.id)
|
||||
: await api.channels.messages(channelId, oldestMessage.id);
|
||||
@@ -101,7 +112,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => {
|
||||
const replyToId = get().replyTo?.id;
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
const isDm = isDmChannel(channelId);
|
||||
|
||||
if (isDm) {
|
||||
await api.dm.sendMessage(channelId, { content });
|
||||
@@ -113,8 +124,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// Message will arrive via WebSocket
|
||||
},
|
||||
|
||||
editMessage: async (messageId: string, content: string) => {
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
editMessage: async (messageId: string, content: string, channelId: string) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
} else {
|
||||
@@ -123,8 +134,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// Update will arrive via WebSocket
|
||||
},
|
||||
|
||||
deleteMessage: async (messageId: string) => {
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
deleteMessage: async (messageId: string, channelId: string) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
} else {
|
||||
@@ -253,4 +264,58 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const now = Date.now();
|
||||
return users.filter(t => now - t.timestamp < 5000);
|
||||
},
|
||||
|
||||
setReadStates: (readStates: ReadState[], channelLastMessageIds: Map<string, string>) => {
|
||||
const rsMap = new Map<string, string>();
|
||||
for (const rs of readStates) {
|
||||
rsMap.set(rs.channelId, rs.lastReadMessageId);
|
||||
}
|
||||
const unread = new Set<string>();
|
||||
for (const [channelId, lastMsgId] of channelLastMessageIds) {
|
||||
const lastRead = rsMap.get(channelId);
|
||||
if (!lastRead || BigInt(lastMsgId) > BigInt(lastRead)) {
|
||||
unread.add(channelId);
|
||||
}
|
||||
}
|
||||
set({ readStates: rsMap, unreadChannels: unread });
|
||||
},
|
||||
|
||||
markChannelUnread: (channelId: string) => {
|
||||
set((state) => {
|
||||
if (state.unreadChannels.has(channelId)) return state;
|
||||
const newUnread = new Set(state.unreadChannels);
|
||||
newUnread.add(channelId);
|
||||
return { unreadChannels: newUnread };
|
||||
});
|
||||
},
|
||||
|
||||
ackChannel: (channelId: string) => {
|
||||
const msgs = get().messages.get(channelId);
|
||||
if (!msgs || msgs.length === 0) return;
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
if (!lastMsg) return;
|
||||
const messageId = lastMsg.id;
|
||||
|
||||
// Update local state immediately
|
||||
set((state) => {
|
||||
const newReadStates = new Map(state.readStates);
|
||||
newReadStates.set(channelId, messageId);
|
||||
const newUnread = new Set(state.unreadChannels);
|
||||
newUnread.delete(channelId);
|
||||
return { readStates: newReadStates, unreadChannels: newUnread };
|
||||
});
|
||||
|
||||
// Send to server
|
||||
wsSend({ type: 'channel_ack', channelId, messageId });
|
||||
},
|
||||
|
||||
onChannelAck: (channelId: string, messageId: string) => {
|
||||
set((state) => {
|
||||
const newReadStates = new Map(state.readStates);
|
||||
newReadStates.set(channelId, messageId);
|
||||
const newUnread = new Set(state.unreadChannels);
|
||||
newUnread.delete(channelId);
|
||||
return { readStates: newReadStates, unreadChannels: newUnread };
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -8,6 +8,8 @@ export const useServerStore = create((set, get) => ({
|
||||
roles: [],
|
||||
folders: [],
|
||||
dmChannels: [],
|
||||
channelToServerMap: new Map(),
|
||||
channelLastMessageIds: new Map(),
|
||||
setServers: (servers) => set({ servers }),
|
||||
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
|
||||
setChannels: (channels) => set({ channels }),
|
||||
@@ -138,10 +140,40 @@ export const useServerStore = create((set, get) => ({
|
||||
inviteCode: s.inviteCode,
|
||||
createdAt: s.createdAt,
|
||||
}));
|
||||
const channelToServerMap = new Map();
|
||||
const channelLastMessageIds = new Map();
|
||||
for (const srv of servers) {
|
||||
for (const ch of srv.channels) {
|
||||
channelToServerMap.set(ch.id, srv.id);
|
||||
if (ch.lastMessageId) {
|
||||
channelLastMessageIds.set(ch.id, ch.lastMessageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
const dms = dmChannels || [];
|
||||
for (const dm of dms) {
|
||||
if (dm.lastMessage?.id) {
|
||||
channelLastMessageIds.set(dm.id, dm.lastMessage.id);
|
||||
}
|
||||
}
|
||||
set({
|
||||
servers: simpleServers,
|
||||
folders: folders || [],
|
||||
dmChannels: dmChannels || []
|
||||
dmChannels: dms,
|
||||
channelToServerMap,
|
||||
channelLastMessageIds,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export function isDmChannel(channelId) {
|
||||
const dmChannels = useServerStore.getState().dmChannels;
|
||||
if (dmChannels.length > 0) {
|
||||
return dmChannels.some(dm => dm.id === channelId);
|
||||
}
|
||||
// Before WS ready populates dmChannels, fall back to URL path
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.pathname.startsWith('/channels/@me/');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ interface ServerState {
|
||||
roles: Role[];
|
||||
folders: ServerFolder[];
|
||||
dmChannels: DmChannel[];
|
||||
channelToServerMap: Map<string, string>;
|
||||
channelLastMessageIds: Map<string, string>;
|
||||
setServers: (servers: Server[]) => void;
|
||||
setCurrentServer: (serverId: string | null) => void;
|
||||
setChannels: (channels: Channel[]) => void;
|
||||
@@ -44,6 +46,8 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
||||
roles: [],
|
||||
folders: [],
|
||||
dmChannels: [],
|
||||
channelToServerMap: new Map(),
|
||||
channelLastMessageIds: new Map(),
|
||||
|
||||
setServers: (servers) => set({ servers }),
|
||||
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
|
||||
@@ -189,10 +193,49 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
||||
inviteCode: s.inviteCode,
|
||||
createdAt: s.createdAt,
|
||||
}));
|
||||
set({
|
||||
servers: simpleServers,
|
||||
|
||||
// Build channel→server map and channel→lastMessageId map
|
||||
const channelToServerMap = new Map<string, string>();
|
||||
const channelLastMessageIds = new Map<string, string>();
|
||||
for (const srv of servers) {
|
||||
for (const ch of srv.channels) {
|
||||
channelToServerMap.set(ch.id, srv.id);
|
||||
if (ch.lastMessageId) {
|
||||
channelLastMessageIds.set(ch.id, ch.lastMessageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also map DM channels
|
||||
const dms = dmChannels || [];
|
||||
for (const dm of dms) {
|
||||
if (dm.lastMessage?.id) {
|
||||
channelLastMessageIds.set(dm.id, dm.lastMessage.id);
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
servers: simpleServers,
|
||||
folders: folders || [],
|
||||
dmChannels: dmChannels || []
|
||||
dmChannels: dms,
|
||||
channelToServerMap,
|
||||
channelLastMessageIds,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Data-driven DM channel detection. Returns true if the given channelId
|
||||
* belongs to a DM channel. Authoritative because dmChannels is populated
|
||||
* from the WS ready event and DM/server channel IDs never overlap.
|
||||
*/
|
||||
export function isDmChannel(channelId: string): boolean {
|
||||
const dmChannels = useServerStore.getState().dmChannels;
|
||||
if (dmChannels.length > 0) {
|
||||
return dmChannels.some(dm => dm.id === channelId);
|
||||
}
|
||||
// Before WS ready populates dmChannels, fall back to URL path
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.pathname.startsWith('/channels/@me/');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: #f2f3f5;
|
||||
-webkit-text-fill-color: #ffffff;
|
||||
-webkit-box-shadow: 0 0 0px 1000px #1e1f22 inset;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
@@ -56,6 +56,15 @@
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
|
||||
@@ -8,32 +8,57 @@ export default {
|
||||
extend: {
|
||||
colors: {
|
||||
discord: {
|
||||
'bg-primary': '#313338',
|
||||
'bg-secondary': '#2b2d31',
|
||||
'bg-tertiary': '#1e1f22',
|
||||
'bg-user-area': '#232428',
|
||||
'bg-floating': '#111214',
|
||||
'bg-overlay': 'rgba(0, 0, 0, 0.85)',
|
||||
'bg-input': '#383a40',
|
||||
'bg-accent': '#404249',
|
||||
'text-primary': '#f2f3f5',
|
||||
'text-normal': '#dbdee1',
|
||||
'text-secondary': '#b5bac1',
|
||||
'text-muted': '#949ba4',
|
||||
'text-link': '#00a8fc',
|
||||
'text-positive': '#23a559',
|
||||
'text-warning': '#f0b232',
|
||||
'text-danger': '#fa777c',
|
||||
'blurple': '#5865f2',
|
||||
'blurple-hover': '#4752c4',
|
||||
'green': '#23a559',
|
||||
'yellow': '#f0b232',
|
||||
'red': '#da373c',
|
||||
'red-hover': '#a12d31',
|
||||
'modifier-hover': '#35373c',
|
||||
'modifier-active': '#3b3d42',
|
||||
'modifier-selected': '#404249',
|
||||
'modifier-accent': 'hsla(0, 0%, 100%, 0.06)',
|
||||
// Backgrounds (darkest → lightest)
|
||||
'bg-tertiary': '#1e1f22',
|
||||
'bg-server': '#25262a',
|
||||
'bg-secondary': '#2b2d31',
|
||||
'bg-primary': '#313338',
|
||||
'bg-input': '#383a40',
|
||||
'bg-surface': '#393a41',
|
||||
'bg-surface-higher': '#3f4048',
|
||||
'bg-floating': '#111214',
|
||||
'bg-overlay': 'rgba(0, 0, 0, 0.85)',
|
||||
'bg-user-area': '#232428',
|
||||
'bg-accent': '#41434a',
|
||||
'bg-hover': '#2e3035',
|
||||
'bg-active': '#404249',
|
||||
'bg-members': '#2b2d31',
|
||||
'bg-home': '#282a2e',
|
||||
|
||||
// Text
|
||||
'text-primary': '#ffffff',
|
||||
'text-normal': '#dcdcdf',
|
||||
'text-secondary': '#c5c6ca',
|
||||
'text-muted': '#abacb2',
|
||||
'text-header': '#ffffff',
|
||||
'text-link': '#76aff6',
|
||||
'text-positive': '#73c48b',
|
||||
'text-warning': '#faa900',
|
||||
'text-danger': '#ff938e',
|
||||
|
||||
// Channel/Interactive
|
||||
'channels-default': '#999aa1',
|
||||
'interactive-muted': '#4e5058',
|
||||
|
||||
// Brand
|
||||
'blurple': '#5865f2',
|
||||
'blurple-hover': '#4452bb',
|
||||
'blurple-active': '#3a48a3',
|
||||
|
||||
// Status
|
||||
'green': '#23a55a',
|
||||
'yellow': '#f0b232',
|
||||
'red': '#f23f43',
|
||||
'red-hover': '#a9232e',
|
||||
|
||||
// Notification badge
|
||||
'notification': '#da3e44',
|
||||
|
||||
// Modifiers (semi-transparent — works on any background)
|
||||
'modifier-hover': 'rgba(255,255,255,0.08)',
|
||||
'modifier-active': 'rgba(255,255,255,0.16)',
|
||||
'modifier-selected': 'rgba(255,255,255,0.20)',
|
||||
'modifier-accent': 'rgba(255,255,255,0.12)',
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
|
||||
Reference in New Issue
Block a user