diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index c117e271..35117deb 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -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, diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 7bb20aa8..f1b57a68 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -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' }), diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 0ef93717..a81e4a6d 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -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, userId: string): v }); } } + +function handleChannelAck(event: Record, 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, + }); +} diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index d076d490..5e9df05d 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -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; + 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 { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 444a904e..f490d150 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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 } + | { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record; 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 }; diff --git a/packages/web/src/components/auth/LoginPage.js b/packages/web/src/components/auth/LoginPage.js index c2aa5a3a..2da9b0e7 100644 --- a/packages/web/src/components/auth/LoginPage.js +++ b/packages/web/src/components/auth/LoginPage.js @@ -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" })] })] })] }) })); } diff --git a/packages/web/src/components/auth/LoginPage.tsx b/packages/web/src/components/auth/LoginPage.tsx index d34e8473..da0c2ef4 100644 --- a/packages/web/src/components/auth/LoginPage.tsx +++ b/packages/web/src/components/auth/LoginPage.tsx @@ -33,7 +33,7 @@ export function LoginPage() { return (
-
+

Welcome back!

We're so excited to see you again!

@@ -41,7 +41,7 @@ export function LoginPage() {
{error && ( -
+
{error}
)} @@ -83,7 +83,7 @@ export function LoginPage() {

Need an account?{' '} - + Register

diff --git a/packages/web/src/components/auth/RegisterPage.js b/packages/web/src/components/auth/RegisterPage.js index 8af8b929..3e048236 100644 --- a/packages/web/src/components/auth/RegisterPage.js +++ b/packages/web/src/components/auth/RegisterPage.js @@ -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" })] })] })] }) })); } diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx index 52747099..167ed647 100644 --- a/packages/web/src/components/auth/RegisterPage.tsx +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -46,14 +46,14 @@ export function RegisterPage() { return (
-
+

Create an account

{error && ( -
+
{error}
)} @@ -108,7 +108,7 @@ export function RegisterPage() {

Already have an account?{' '} - + Log In

diff --git a/packages/web/src/components/chat/FriendsPage.js b/packages/web/src/components/chat/FriendsPage.js index aa636dc6..dc127679 100644 --- a/packages/web/src/components/chat/FriendsPage.js +++ b/packages/web/src/components/chat/FriendsPage.js @@ -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()] })); diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 4477cf44..4a673f22 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -160,7 +160,7 @@ export function FriendsPage() { {addStatus && ( -
+
{addStatus.message}
)} diff --git a/packages/web/src/components/chat/ImagePreview.js b/packages/web/src/components/chat/ImagePreview.js index 2d2c752a..8eb10530 100644 --- a/packages/web/src/components/chat/ImagePreview.js +++ b/packages/web/src/components/chat/ImagePreview.js @@ -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() })] })); } diff --git a/packages/web/src/components/chat/ImagePreview.tsx b/packages/web/src/components/chat/ImagePreview.tsx index f11fcfd5..92bcb384 100644 --- a/packages/web/src/components/chat/ImagePreview.tsx +++ b/packages/web/src/components/chat/ImagePreview.tsx @@ -10,7 +10,7 @@ export function ImagePreview() { return (
diff --git a/packages/web/src/components/chat/Message.js b/packages/web/src/components/chat/Message.js index 5f08b2ac..33c5d363 100644 --- a/packages/web/src/components/chat/Message.js +++ b/packages/web/src/components/chat/Message.js @@ -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 }); } diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index 727f9868..a807cb14 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -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 {' '}• enter to @@ -365,7 +366,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { )} {canDelete && ( - ))} + {textChannels.map((channel) => { + const isUnread = unreadChannels.has(channel.id) && currentChannelId !== channel.id; + return ( + + ); + })}
diff --git a/packages/web/src/components/layout/ServerSidebar.js b/packages/web/src/components/layout/ServerSidebar.js index 6a04e059..6d4ef302 100644 --- a/packages/web/src/components/layout/ServerSidebar.js +++ b/packages/web/src/components/layout/ServerSidebar.js @@ -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" })] })); } diff --git a/packages/web/src/components/layout/ServerSidebar.tsx b/packages/web/src/components/layout/ServerSidebar.tsx index 399cf1a2..8c3d5e56 100644 --- a/packages/web/src/components/layout/ServerSidebar.tsx +++ b/packages/web/src/components/layout/ServerSidebar.tsx @@ -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(); + 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 ( -