diff --git a/.gitignore b/.gitignore index bd3e2957..bd6c04da 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ pnpm-debug.log* # TypeScript *.tsbuildinfo +# tsc emit artifacts — Vite handles compilation, tsc is type-check only +packages/web/src/**/*.js # Misc .cache/ diff --git a/packages/web/src/App.js b/packages/web/src/App.js deleted file mode 100644 index b7e3302f..00000000 --- a/packages/web/src/App.js +++ /dev/null @@ -1,21 +0,0 @@ -import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; -import { Routes, Route, Navigate } from 'react-router-dom'; -import { LoginPage } from './components/auth/LoginPage'; -import { RegisterPage } from './components/auth/RegisterPage'; -import { AppLayout } from './components/layout/AppLayout'; -import { useAuthStore } from './stores/authStore'; -function ProtectedRoute({ children }) { - const token = useAuthStore((s) => s.token); - if (!token) - return _jsx(Navigate, { to: "/login", replace: true }); - return _jsx(_Fragment, { children: children }); -} -function AuthRedirect({ children }) { - const token = useAuthStore((s) => s.token); - if (token) - return _jsx(Navigate, { to: "/channels/@me", replace: true }); - return _jsx(_Fragment, { children: children }); -} -export function App() { - return (_jsxs(Routes, { children: [_jsx(Route, { path: "/login", element: _jsx(AuthRedirect, { children: _jsx(LoginPage, {}) }) }), _jsx(Route, { path: "/register", element: _jsx(AuthRedirect, { children: _jsx(RegisterPage, {}) }) }), _jsx(Route, { path: "/channels/:serverId/:channelId?", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/join/:inviteCode", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) })] })); -} diff --git a/packages/web/src/api/client.js b/packages/web/src/api/client.js deleted file mode 100644 index db927908..00000000 --- a/packages/web/src/api/client.js +++ /dev/null @@ -1,119 +0,0 @@ -const BASE_URL = '/api'; -function getToken() { - return localStorage.getItem('opencord_token'); -} -async function request(method, path, body, requireAuth = true) { - const headers = {}; - if (body) { - headers['Content-Type'] = 'application/json'; - } - if (requireAuth) { - const token = getToken(); - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - } - const response = await fetch(`${BASE_URL}${path}`, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }); - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Request failed' })); - throw new Error(error.error || `HTTP ${response.status}`); - } - return response.json(); -} -async function uploadFile(file) { - const formData = new FormData(); - formData.append('file', file); - const token = getToken(); - const headers = {}; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - const response = await fetch(`${BASE_URL}/uploads`, { - method: 'POST', - headers, - body: formData, - }); - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Upload failed' })); - throw new Error(error.error || `HTTP ${response.status}`); - } - return response.json(); -} -export const api = { - auth: { - register: (data) => request('POST', '/auth/register', data, false), - login: (data) => request('POST', '/auth/login', data, false), - }, - users: { - me: () => request('GET', '/users/@me'), - update: (data) => request('PATCH', '/users/@me', data), - get: (id) => request('GET', `/users/${id}`), - }, - servers: { - list: () => request('GET', '/servers'), - get: (id) => request('GET', `/servers/${id}`), - create: (data) => request('POST', '/servers', data), - update: (id, data) => request('PATCH', `/servers/${id}`, data), - delete: (id) => request('DELETE', `/servers/${id}`), - invite: (id) => request('POST', `/servers/${id}/invite`), - join: (id, data) => request('POST', `/servers/${id}/join`, data), - joinByCode: (inviteCode) => request('POST', '/servers/join', { inviteCode }), - members: (id) => request('GET', `/servers/${id}/members`), - updateMember: (serverId, userId, data) => request('PATCH', `/servers/${serverId}/members/${userId}`, data), - removeMember: (serverId, userId) => request('DELETE', `/servers/${serverId}/members/${userId}`), - }, - channels: { - list: (serverId) => request('GET', `/servers/${serverId}/channels`), - create: (serverId, data) => request('POST', `/servers/${serverId}/channels`, data), - update: (id, data) => request('PATCH', `/channels/${id}`, data), - delete: (id) => request('DELETE', `/channels/${id}`), - messages: (id, before, limit = 50) => { - const params = new URLSearchParams(); - if (before) - params.set('before', before); - params.set('limit', String(limit)); - return request('GET', `/channels/${id}/messages?${params}`); - }, - sendMessage: (channelId, data) => request('POST', `/channels/${channelId}/messages`, data), - }, - messages: { - update: (id, data) => request('PATCH', `/messages/${id}`, data), - delete: (id) => request('DELETE', `/messages/${id}`), - }, - uploads: { - upload: uploadFile, - url: (filename) => `${BASE_URL}/uploads/${filename}`, - }, - dm: { - list: () => request('GET', '/dm'), - create: (data) => request('POST', '/dm', data), - close: (id) => request('DELETE', `/dm/${id}`), - messages: (id, before, limit = 50) => { - const params = new URLSearchParams(); - if (before) - params.set('before', before); - params.set('limit', String(limit)); - return request('GET', `/dm/${id}/messages?${params}`); - }, - sendMessage: (id, data) => request('POST', `/dm/${id}/messages`, data), - updateMessage: (id, data) => request('PATCH', `/dm/messages/${id}`, data), - deleteMessage: (id) => request('DELETE', `/dm/messages/${id}`), - }, - social: { - friends: () => request('GET', '/social/friends'), - requests: () => request('GET', '/social/requests'), - sendRequest: (username) => request('POST', '/social/requests', { username }), - updateRequest: (id, status) => request('PATCH', `/social/requests/${id}`, { status }), - removeFriend: (id) => request('DELETE', `/social/friends/${id}`), - cancelRequest: (id) => request('DELETE', `/social/requests/${id}`), - search: (q) => request('GET', `/social/search?q=${encodeURIComponent(q)}`), - }, - livekit: { - token: (channelId) => request('POST', '/livekit/token', { channelId }), - dmToken: (dmChannelId) => request('POST', '/livekit/token', { dmChannelId }), - }, -}; diff --git a/packages/web/src/audio/AudioManager.js b/packages/web/src/audio/AudioManager.js deleted file mode 100644 index 35cbeb16..00000000 --- a/packages/web/src/audio/AudioManager.js +++ /dev/null @@ -1,328 +0,0 @@ -import { RnnoiseWorkletNode, loadRnnoise } from '@sapphi-red/web-noise-suppressor'; -import rnnoiseWorkletPath from '@sapphi-red/web-noise-suppressor/rnnoiseWorklet.js?url'; -import rnnoiseWasmPath from '@sapphi-red/web-noise-suppressor/rnnoise.wasm?url'; -import rnnoiseWasmSimdPath from '@sapphi-red/web-noise-suppressor/rnnoise_simd.wasm?url'; - -export class AudioManager { - static instance = null; - ctx = null; - inputGain = null; - inputSource = null; - inputDestination = null; - silentGain = null; - analyser = null; - masterCompressor = null; - currentInputDeviceId = 'default'; - desiredOutputDeviceId = 'default'; - currentStream = null; - isInitialized = false; - listeners = new Set(); - soundBuffers = new Map(); - voiceEchoCancellation = true; - voiceNoiseSuppression = true; - voiceAutoGainControl = false; - screenShareActive = false; - streamGeneration = 0; - inputSwitchChain = Promise.resolve(null); - rnnoiseNode = null; - stereoMerger = null; - rnnoiseEnabled = false; - rnnoiseReady = false; - constructor() { } - static getInstance() { - if (!AudioManager.instance) { - AudioManager.instance = new AudioManager(); - } - return AudioManager.instance; - } - initContext() { - if (this.ctx) - return; - const AudioContextClass = window.AudioContext || window.webkitAudioContext; - this.ctx = new AudioContextClass({ sampleRate: 48000 }); - this.inputGain = this.ctx.createGain(); - this.inputDestination = this.ctx.createMediaStreamDestination(); - this.analyser = this.ctx.createAnalyser(); - this.analyser.fftSize = 256; - this.silentGain = this.ctx.createGain(); - this.silentGain.gain.value = 0; - // Master compressor/limiter — prevents clipping when multiple - // audio sources (voice + stream) sum at the output. - this.masterCompressor = this.ctx.createDynamicsCompressor(); - this.masterCompressor.threshold.value = -1; // only engage near digital clipping - this.masterCompressor.knee.value = 0.5; // hard knee — transparent below threshold - this.masterCompressor.ratio.value = 4; // gentle limiting, no ducking - this.masterCompressor.attack.value = 0.0005; // 0.5ms — catch transient peaks - this.masterCompressor.release.value = 0.01; // 10ms — recover quickly - this.masterCompressor.connect(this.ctx.destination); - this.inputGain.connect(this.inputDestination); - this.inputGain.connect(this.analyser); - this.inputGain.connect(this.silentGain); - this.silentGain.connect(this.ctx.destination); - this.inputGain.gain.setValueAtTime(1, this.ctx.currentTime); - this.ctx.onstatechange = () => { - console.log(`[AudioManager] Context state: ${this.ctx?.state}`); - if (this.ctx?.state === 'running') { - this.notifyResumed(); - } - }; - this.isInitialized = true; - this.applyOutputDevice(); - } - async setOutputDevice(deviceId) { - this.desiredOutputDeviceId = deviceId; - await this.applyOutputDevice(); - } - async applyOutputDevice() { - if (!this.ctx || !('setSinkId' in this.ctx)) return; - try { - const sinkId = this.desiredOutputDeviceId === 'default' ? '' : this.desiredOutputDeviceId; - await this.ctx.setSinkId(sinkId); - console.log(`[AudioManager] Output device set to: ${this.desiredOutputDeviceId}`); - } - catch (err) { - console.error('[AudioManager] Failed to set output device:', err); - } - } - onResumed(cb) { - this.listeners.add(cb); - return () => this.listeners.delete(cb); - } - notifyResumed() { - this.listeners.forEach(cb => cb()); - } - async resumeContext() { - if (!this.ctx) - this.initContext(); - if (this.ctx && this.ctx.state === 'suspended') { - try { - await this.ctx.resume(); - console.log('[AudioManager] AudioContext resumed.'); - } - catch (err) { - console.error('[AudioManager] Failed to resume context:', err); - } - } - } - async loadSound(name) { - if (this.soundBuffers.has(name)) { - return this.soundBuffers.get(name); - } - if (!this.ctx) - this.initContext(); - try { - const response = await fetch(`/sounds/${name}.mp3`); - if (!response.ok) - throw new Error(`Failed to load sound: ${name}`); - const arrayBuffer = await response.arrayBuffer(); - const audioBuffer = await this.ctx.decodeAudioData(arrayBuffer); - this.soundBuffers.set(name, audioBuffer); - return audioBuffer; - } - catch (err) { - console.error(`[AudioManager] Error loading sound ${name}:`, err); - return null; - } - } - async playSound(name, options = {}) { - await this.resumeContext(); - const buffer = await this.loadSound(name); - if (!buffer || !this.ctx) - return null; - const source = this.ctx.createBufferSource(); - source.buffer = buffer; - source.loop = options.loop || false; - const gainNode = this.ctx.createGain(); - gainNode.gain.value = options.volume ?? 0.5; - source.connect(gainNode); - gainNode.connect(this.masterCompressor); - source.start(0); - return source; - } - async setInputDevice(deviceId) { - const job = this.inputSwitchChain.then(() => this._setInputDeviceImpl(deviceId)); - this.inputSwitchChain = job.catch(() => null); - return job; - } - async _setInputDeviceImpl(deviceId) { - if (!this.isInitialized) - this.initContext(); - // Skip if already set and stream is active - if (this.currentInputDeviceId === deviceId && this.currentStream?.active) { - return this.currentStream; - } - try { - if (this.currentStream) { - this.currentStream.getTracks().forEach(t => t.stop()); - } - const effectiveEchoCancellation = this.screenShareActive ? false : this.voiceEchoCancellation; - // When RNNoise is active, force browser NS off — running both degrades quality. - const effectiveNoiseSuppression = this.rnnoiseEnabled ? false : this.voiceNoiseSuppression; - const constraints = { - audio: { - deviceId: deviceId === 'default' ? undefined : { exact: deviceId }, - echoCancellation: effectiveEchoCancellation, - noiseSuppression: effectiveNoiseSuppression, - autoGainControl: this.voiceAutoGainControl, - googEchoCancellation: effectiveEchoCancellation, - googAutoGainControl: this.voiceAutoGainControl, - googNoiseSuppression: effectiveNoiseSuppression, - googHighpassFilter: false, - googTypingNoiseDetection: false, - } - }; - this.currentStream = await navigator.mediaDevices.getUserMedia(constraints); - this.currentInputDeviceId = deviceId; - this.streamGeneration++; - if (this.ctx && this.inputGain) { - if (this.inputSource) { - this.inputSource.disconnect(); - } - this.inputSource = this.ctx.createMediaStreamSource(this.currentStream); - this.inputSource.connect(this.getInputTarget()); - } - return this.currentStream; - } - catch (err) { - console.error('[AudioManager] Failed to set input device:', err); - throw err; - } - } - getInputTarget() { - return (this.rnnoiseEnabled && this.rnnoiseNode) ? this.rnnoiseNode : this.inputGain; - } - async setRnnoiseEnabled(enabled) { - if (enabled === this.rnnoiseEnabled && this.rnnoiseReady) - return; - if (!this.isInitialized) - this.initContext(); - if (enabled && !this.rnnoiseReady) { - try { - console.log('[AudioManager] Loading RNNoise worklet...'); - await this.ctx.audioWorklet.addModule(rnnoiseWorkletPath); - // loadRnnoise handles SIMD feature detection and returns the right binary - const wasmBinary = await loadRnnoise({ url: rnnoiseWasmPath, simdUrl: rnnoiseWasmSimdPath }); - this.rnnoiseNode = new RnnoiseWorkletNode(this.ctx, { - wasmBinary, - maxChannels: 1, - }); - - // Explicit mono→stereo: RNNoise outputs 1 channel, so duplicate it - // to both L and R via a ChannelMergerNode. This is spec-guaranteed - // stereo, unlike relying on automatic up-mixing which fails in some - // browsers when the source is an AudioWorkletNode. - this.stereoMerger = this.ctx.createChannelMerger(2); - this.rnnoiseNode.connect(this.stereoMerger, 0, 0); // mono → left - this.rnnoiseNode.connect(this.stereoMerger, 0, 1); // mono → right - this.stereoMerger.connect(this.inputGain); - - this.rnnoiseReady = true; - console.log('[AudioManager] RNNoise worklet loaded and connected (mono→stereo via ChannelMerger)'); - } - catch (err) { - console.error('[AudioManager] Failed to load RNNoise worklet:', err); - this.rnnoiseReady = false; - this.rnnoiseEnabled = false; - if (this.inputSource && this.inputGain) { - this.inputSource.disconnect(); - this.inputSource.connect(this.inputGain); - } - return; - } - } - this.rnnoiseEnabled = enabled; - // Rewire the graph - if (this.inputSource) { - this.inputSource.disconnect(); - this.inputSource.connect(this.getInputTarget()); - console.log(`[AudioManager] RNNoise ${enabled ? 'enabled' : 'bypassed'} — inputSource → ${enabled ? 'rnnoiseNode' : 'inputGain'}`); - } - // Force track re-publish so LiveKit picks up the new pipeline - this.streamGeneration++; - if (this.currentStream) { - this.currentStream.getTracks().forEach(t => t.stop()); - this.currentStream = null; - } - } - isRnnoiseEnabled() { - return this.rnnoiseEnabled; - } - setInputVolume(volume) { - if (!this.isInitialized) - this.initContext(); - if (this.inputGain && this.ctx) { - const gainValue = volume / 100; - this.inputGain.gain.setTargetAtTime(gainValue, this.ctx.currentTime, 0.1); - } - } - setVoiceProcessing(opts) { - let changed = false; - if (opts.echoCancellation !== undefined && opts.echoCancellation !== this.voiceEchoCancellation) { - this.voiceEchoCancellation = opts.echoCancellation; - changed = true; - } - if (opts.noiseSuppression !== undefined && opts.noiseSuppression !== this.voiceNoiseSuppression) { - this.voiceNoiseSuppression = opts.noiseSuppression; - changed = true; - } - if (opts.autoGainControl !== undefined && opts.autoGainControl !== this.voiceAutoGainControl) { - this.voiceAutoGainControl = opts.autoGainControl; - changed = true; - } - if (changed && this.currentStream) { - this.currentStream.getTracks().forEach(t => t.stop()); - this.currentStream = null; - } - } - setScreenShareActive(active) { - if (this.screenShareActive === active) return; - this.screenShareActive = active; - console.log(`[AudioManager] Screen share active: ${active} — ${active ? 'forcing AEC off' : 'restoring user AEC preference'}`); - if (this.currentStream) { - this.currentStream.getTracks().forEach(t => t.stop()); - this.currentStream = null; - } - } - getStreamGeneration() { - return this.streamGeneration; - } - /** - * CRITICAL: Always returns a CLONE of the destination track. - * This prevents LiveKit's cleanup from killing the main singleton track - * when switching rooms. - */ - getFreshTrack() { - if (!this.isInitialized) - this.initContext(); - const track = this.inputDestination.stream.getAudioTracks()[0]; - if (!track) - return null; - return track.clone(); - } - getAnalyserNode() { - if (!this.isInitialized) - this.initContext(); - return this.analyser; - } - /** - * Ensures the AudioContext exists and returns it. - * Unlike getContext(), this will never return null — it lazily - * creates the context if it hasn't been initialised yet. - * The context may be in 'suspended' state but Web Audio nodes - * can be created and connected regardless; audio will flow - * once the context resumes. - */ - ensureContext() { - if (!this.ctx) - this.initContext(); - return this.ctx; - } - getMasterOutput() { - if (!this.ctx) - this.initContext(); - return this.masterCompressor; - } - getContext() { - return this.ctx; - } -} diff --git a/packages/web/src/components/auth/LoginPage.js b/packages/web/src/components/auth/LoginPage.js deleted file mode 100644 index 9a9fbf53..00000000 --- a/packages/web/src/components/auth/LoginPage.js +++ /dev/null @@ -1,32 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { useAuthStore } from '../../stores/authStore'; -export function LoginPage() { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const login = useAuthStore((s) => s.login); - const isLoading = useAuthStore((s) => s.isLoading); - const navigate = useNavigate(); - const handleSubmit = async (e) => { - e.preventDefault(); - setError(''); - if (!username.trim()) { - setError('Username is required'); - return; - } - if (!password) { - setError('Password is required'); - return; - } - try { - await login(username.trim(), password); - navigate('/channels/@me'); - } - catch (err) { - setError(err instanceof Error ? err.message : 'Login failed'); - } - }; - return (_jsxs("div", { className: "min-h-screen flex items-center justify-center bg-[#080a0b] relative", children: [_jsx("div", { className: "absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(88,101,242,0.06)_0%,transparent_50%)]" }), _jsxs("div", { className: "w-full max-w-[480px] bg-discord-bg-surface rounded-md p-8 shadow-elevation-high relative z-10", 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/RegisterPage.js b/packages/web/src/components/auth/RegisterPage.js deleted file mode 100644 index 011b6aae..00000000 --- a/packages/web/src/components/auth/RegisterPage.js +++ /dev/null @@ -1,45 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { useAuthStore } from '../../stores/authStore'; -export function RegisterPage() { - const [username, setUsername] = useState(''); - const [displayName, setDisplayName] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const register = useAuthStore((s) => s.register); - const isLoading = useAuthStore((s) => s.isLoading); - const navigate = useNavigate(); - const handleSubmit = async (e) => { - e.preventDefault(); - setError(''); - if (!username.trim()) { - setError('Username is required'); - return; - } - if (username.trim().length < 3 || username.trim().length > 32) { - setError('Username must be between 3 and 32 characters'); - return; - } - if (!/^[a-zA-Z0-9_]+$/.test(username.trim())) { - setError('Username can only contain letters, numbers, and underscores'); - return; - } - if (!password) { - setError('Password is required'); - return; - } - if (password.length < 6) { - setError('Password must be at least 6 characters'); - return; - } - try { - await register(username.trim(), password, displayName.trim() || undefined); - navigate('/channels/@me'); - } - catch (err) { - setError(err instanceof Error ? err.message : 'Registration failed'); - } - }; - return (_jsxs("div", { className: "min-h-screen flex items-center justify-center bg-[#080a0b] relative", children: [_jsx("div", { className: "absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(88,101,242,0.06)_0%,transparent_50%)]" }), _jsxs("div", { className: "w-full max-w-[480px] bg-discord-bg-surface rounded-md p-8 shadow-elevation-high relative z-10", 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/chat/Embed.js b/packages/web/src/components/chat/Embed.js deleted file mode 100644 index 506d9194..00000000 --- a/packages/web/src/components/chat/Embed.js +++ /dev/null @@ -1,30 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useState, useEffect } from 'react'; -export function Embed({ url }) { - const [metadata, setMetadata] = useState(null); - const [isLoading, setIsLoading] = useState(true); - useEffect(() => { - let isMounted = true; - // Simple fetch from our new API - fetch(`/api/utils/metadata?url=${encodeURIComponent(url)}`, { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('opencord_token')}` - } - }) - .then(res => res.json()) - .then(data => { - if (isMounted && data.title) { - setMetadata(data); - } - setIsLoading(false); - }) - .catch(() => { - if (isMounted) - setIsLoading(false); - }); - return () => { isMounted = false; }; - }, [url]); - if (isLoading || !metadata) - return null; - return (_jsxs("div", { className: "mt-2 max-w-[520px] bg-discord-bg-secondary rounded-[4px] border-l-4 border-discord-bg-tertiary flex overflow-hidden", children: [_jsxs("div", { className: "flex-1 p-3 min-w-0", children: [metadata.siteName && (_jsx("div", { className: "text-[12px] text-discord-text-normal font-medium mb-1 truncate", children: metadata.siteName })), metadata.title && (_jsx("a", { href: url, target: "_blank", rel: "noopener noreferrer", className: "text-[16px] text-discord-text-link font-semibold hover:underline block mb-2", children: metadata.title })), metadata.description && (_jsx("div", { className: "text-[14px] text-discord-text-normal leading-[1.125rem]", children: metadata.description }))] }), metadata.image && (_jsx("div", { className: "w-[80px] h-[80px] m-3 flex-shrink-0", children: _jsx("img", { src: metadata.image, alt: "", className: "w-full h-full object-cover rounded-[4px]" }) }))] })); -} diff --git a/packages/web/src/components/chat/FriendsPage.js b/packages/web/src/components/chat/FriendsPage.js deleted file mode 100644 index 8a151d26..00000000 --- a/packages/web/src/components/chat/FriendsPage.js +++ /dev/null @@ -1,75 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useSocialStore } from '../../stores/socialStore'; -import { useServerStore } from '../../stores/serverStore'; -import { Avatar } from '../ui/Avatar'; -import { MemberListToggleButton } from '../layout/MemberListToggleButton'; -import { LoadingSpinner } from '../ui/LoadingSpinner'; -import { api } from '../../api/client'; -export function FriendsPage() { - const [activeTab, setActiveTab] = useState('online'); - const [addUsername, setAddUsername] = useState(''); - const [addStatus, setAddStatus] = useState(null); - const navigate = useNavigate(); - const addDmChannel = useServerStore((s) => s.addDmChannel); - const { friends, requests, isLoading, loadFriends, loadRequests, sendFriendRequest, updateFriendRequest, cancelFriendRequest, removeFriend } = useSocialStore(); - useEffect(() => { - loadFriends(); - loadRequests(); - }, [loadFriends, loadRequests]); - const onlineFriends = friends.filter(f => f.status !== 'offline'); - const pendingIncoming = requests.filter(r => r.status === 'pending' && r.user?.id === r.fromId); - const pendingOutgoing = requests.filter(r => r.status === 'pending' && r.user?.id === r.toId); - const handleAddFriend = async (e) => { - e.preventDefault(); - if (!addUsername.trim()) - return; - try { - await sendFriendRequest(addUsername.trim()); - setAddStatus({ type: 'success', message: `Success! Your friend request to ${addUsername} has been sent.` }); - setAddUsername(''); - } - catch (err) { - setAddStatus({ type: 'error', message: err.message }); - } - }; - const handleOpenDm = async (friendId) => { - try { - const dmChannel = await api.dm.create({ userId: friendId }); - addDmChannel(dmChannel); - navigate(`/channels/@me/${dmChannel.id}`); - } - catch (err) { - console.error('Failed to open DM:', err); - } - }; - const renderTabContent = () => { - if (isLoading && friends.length === 0 && requests.length === 0) { - return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsx(LoadingSpinner, {}) })); - } - switch (activeTab) { - case 'online': - return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["Online \u2014 ", onlineFriends.length] }), onlineFriends.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: [_jsx("img", { src: "/friends-empty.svg", alt: "", className: "w-64 h-64 mb-4", onError: (e) => e.target.style.display = 'none' }), _jsx("p", { className: "text-discord-text-muted", children: "No one's around to play with Wumpus." })] })) : (onlineFriends.map(friend => (_jsx(FriendItem, { friend: friend, onRemove: () => removeFriend(friend.id), onDm: () => handleOpenDm(friend.id) }, friend.id))))] })); - case 'all': - return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["All Friends \u2014 ", friends.length] }), friends.length === 0 ? (_jsx("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: _jsx("p", { className: "text-discord-text-muted", children: "Wumpus is waiting on friends. You can add them!" }) })) : (friends.map(friend => (_jsx(FriendItem, { friend: friend, onRemove: () => removeFriend(friend.id), onDm: () => handleOpenDm(friend.id) }, friend.id))))] })); - case 'pending': - return (_jsxs("div", { className: "flex-1 overflow-y-auto p-4", children: [_jsxs("h2", { className: "text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2", children: ["Pending \u2014 ", pendingIncoming.length + pendingOutgoing.length] }), [...pendingIncoming, ...pendingOutgoing].length === 0 ? (_jsx("div", { className: "flex flex-col items-center justify-center h-full opacity-60", children: _jsx("p", { className: "text-discord-text-muted", children: "There are no pending friend requests. Here's Wumpus for now!" }) })) : (_jsxs(_Fragment, { children: [pendingIncoming.map(req => (_jsx(RequestItem, { request: req, type: "incoming", 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-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" })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsx(MemberListToggleButton, {}) })] }), renderTabContent()] })); -} -function TabButton({ children, active, onClick }) { - return (_jsx("button", { onClick: onClick, className: `px-2 py-0.5 rounded-[4px] text-[16px] font-medium transition-colors ${active ? 'bg-discord-modifier-selected text-white' : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: children })); -} -function FriendItem({ friend, onRemove, onDm }) { - return (_jsxs("div", { className: "flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-discord-modifier-hover group transition-colors border-t border-discord-modifier-accent mx-2", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Avatar, { src: friend.avatar, name: friend.displayName ?? friend.username, size: 32, status: friend.status }), _jsxs("div", { className: "flex flex-col leading-tight", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-discord-text-primary font-semibold text-[15px]", children: friend.displayName ?? friend.username }), _jsxs("span", { className: "text-discord-text-muted text-[13px] opacity-0 group-hover:opacity-100 transition-opacity font-medium", children: ["@", friend.username] })] }), _jsx("span", { className: "text-[12px] text-discord-text-muted font-medium uppercase", children: friend.status })] })] }), _jsxs("div", { className: "flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity pr-2", children: [_jsx("button", { onClick: (e) => { e.stopPropagation(); onDm(); }, className: "w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Message", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-5H6V7h12v2z" }) }) }), _jsx("button", { onClick: (e) => { e.stopPropagation(); onRemove(); }, className: "w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-colors", title: "Remove Friend", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" }) }) })] })] })); -} -function RequestItem({ request, type, onAccept, onDecline, onCancel }) { - const user = request.user; - if (!user) - return null; - return (_jsxs("div", { className: "flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-discord-modifier-hover group transition-colors border-t border-discord-modifier-accent mx-2", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status }), _jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-discord-text-primary font-bold text-sm", children: user.displayName ?? user.username }), _jsxs("span", { className: "text-discord-text-muted text-xs", children: ["@", user.username] })] }), _jsx("span", { className: "text-xs text-discord-text-muted", children: type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request' })] })] }), _jsx("div", { className: "flex items-center gap-2", children: type === 'incoming' ? (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => onAccept?.(), className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-green hover:bg-discord-green hover:text-white transition-all", title: "Accept", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) }) }), _jsx("button", { onClick: () => onDecline?.(), className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-red hover:bg-discord-red hover:text-white transition-all", title: "Decline", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" }) }) })] })) : (_jsx("button", { onClick: () => onCancel?.(), className: "p-2 bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-all", title: "Cancel Request", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" }) }) })) })] })); -} diff --git a/packages/web/src/components/chat/FriendsPage.test.js b/packages/web/src/components/chat/FriendsPage.test.js deleted file mode 100644 index 257d656e..00000000 --- a/packages/web/src/components/chat/FriendsPage.test.js +++ /dev/null @@ -1,265 +0,0 @@ -import { jsx as _jsx } from "react/jsx-runtime"; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; -import { FriendsPage } from './FriendsPage'; -import { useSocialStore } from '../../stores/socialStore'; -import { useServerStore } from '../../stores/serverStore'; -// Mock the api module -vi.mock('../../api/client', () => ({ - api: { - dm: { - create: vi.fn(), - }, - social: { - friends: vi.fn().mockResolvedValue([]), - requests: vi.fn().mockResolvedValue([]), - sendRequest: vi.fn().mockResolvedValue({ success: true }), - updateRequest: vi.fn().mockResolvedValue({ success: true }), - cancelRequest: vi.fn().mockResolvedValue({ success: true }), - removeFriend: vi.fn().mockResolvedValue({ success: true }), - search: vi.fn().mockResolvedValue([]), - }, - }, -})); -const mockNavigate = vi.fn(); -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useNavigate: () => mockNavigate, - }; -}); -const makeFriend = (overrides = {}) => ({ - id: 'friend-1', - username: 'testfriend', - displayName: 'Test Friend', - avatar: null, - status: 'online', - customStatus: null, - createdAt: Date.now(), - addedAt: Date.now(), - ...overrides, -}); -const makeRequest = (overrides = {}) => ({ - id: 'req-1', - fromId: 'other-user', - toId: 'current-user', - status: 'pending', - createdAt: Date.now(), - user: { - id: 'other-user', - username: 'otheruser', - displayName: 'Other User', - avatar: null, - status: 'online', - customStatus: null, - createdAt: Date.now(), - }, - ...overrides, -}); -function renderFriendsPage() { - return render(_jsx(MemoryRouter, { children: _jsx(FriendsPage, {}) })); -} -beforeEach(() => { - mockNavigate.mockClear(); - // Reset the social store with no-op loaders (we set state directly) - useSocialStore.setState({ - friends: [], - requests: [], - isLoading: false, - error: null, - loadFriends: vi.fn(), - loadRequests: vi.fn(), - }); - useServerStore.setState({ - dmChannels: [], - }); -}); -describe('FriendsPage', () => { - describe('Add Friend tab', () => { - it('renders the Add Friend form when tab is clicked', async () => { - const user = userEvent.setup(); - renderFriendsPage(); - const addFriendTab = screen.getByText('Add Friend'); - await user.click(addFriendTab); - expect(screen.getByPlaceholderText('You can add a friend with their username')).toBeInTheDocument(); - expect(screen.getByText('Send Friend Request')).toBeInTheDocument(); - }); - it('calls sendFriendRequest with the username when form is submitted', async () => { - const user = userEvent.setup(); - const mockSendFriendRequest = vi.fn().mockResolvedValue(undefined); - useSocialStore.setState({ - sendFriendRequest: mockSendFriendRequest, - }); - renderFriendsPage(); - // Switch to Add Friend tab - await user.click(screen.getByText('Add Friend')); - // Type username - const input = screen.getByPlaceholderText('You can add a friend with their username'); - await user.type(input, 'newbuddy'); - // Click send - await user.click(screen.getByText('Send Friend Request')); - await waitFor(() => { - expect(mockSendFriendRequest).toHaveBeenCalledWith('newbuddy'); - }); - // Should show success message - await waitFor(() => { - expect(screen.getByText(/Success! Your friend request to newbuddy has been sent/)).toBeInTheDocument(); - }); - }); - it('shows error when sendFriendRequest fails', async () => { - const user = userEvent.setup(); - const mockSendFriendRequest = vi.fn().mockRejectedValue(new Error('User not found')); - useSocialStore.setState({ - sendFriendRequest: mockSendFriendRequest, - }); - renderFriendsPage(); - await user.click(screen.getByText('Add Friend')); - const input = screen.getByPlaceholderText('You can add a friend with their username'); - await user.type(input, 'ghost'); - await user.click(screen.getByText('Send Friend Request')); - await waitFor(() => { - expect(screen.getByText('User not found')).toBeInTheDocument(); - }); - }); - }); - describe('DM button on friend item', () => { - it('calls api.dm.create and navigates when clicking the Message button', async () => { - const user = userEvent.setup(); - const friend = makeFriend({ id: 'friend-42', username: 'dmpal', displayName: 'DM Pal' }); - const mockAddDmChannel = vi.fn(); - useSocialStore.setState({ - friends: [friend], - requests: [], - }); - useServerStore.setState({ - addDmChannel: mockAddDmChannel, - }); - // Mock the dm.create API - const { api } = await import('../../api/client'); - api.dm.create.mockResolvedValue({ - id: 'dm-channel-99', - createdAt: Date.now(), - members: [], - }); - renderFriendsPage(); - // Switch to "All" tab to see the friend - await user.click(screen.getByText('All')); - // Find the Message button by title - const dmButton = screen.getByTitle('Message'); - await user.click(dmButton); - await waitFor(() => { - expect(api.dm.create).toHaveBeenCalledWith({ userId: 'friend-42' }); - }); - await waitFor(() => { - expect(mockAddDmChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'dm-channel-99' })); - }); - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/channels/@me/dm-channel-99'); - }); - }); - }); - describe('Cancel outgoing friend request', () => { - it('calls cancelFriendRequest when clicking cancel on an outgoing request', async () => { - const user = userEvent.setup(); - const mockCancel = vi.fn().mockResolvedValue(undefined); - // Outgoing request: user.id === toId means current user sent it (fromId is current user, user is the recipient) - const outgoingRequest = makeRequest({ - id: 'req-out-1', - fromId: 'current-user', - toId: 'other-user', - user: { - id: 'other-user', - username: 'recipient', - displayName: 'Recipient', - avatar: null, - status: 'online', - customStatus: null, - createdAt: Date.now(), - }, - }); - useSocialStore.setState({ - friends: [], - requests: [outgoingRequest], - cancelFriendRequest: mockCancel, - }); - renderFriendsPage(); - // Switch to Pending tab - await user.click(screen.getByText('Pending')); - // Should see the outgoing request - expect(screen.getByText('Outgoing Friend Request')).toBeInTheDocument(); - // Click the cancel button (the X icon button with title "Cancel Request") - const cancelButton = screen.getByTitle('Cancel Request'); - await user.click(cancelButton); - await waitFor(() => { - expect(mockCancel).toHaveBeenCalledWith('req-out-1'); - }); - }); - }); - describe('Accept/Decline incoming friend request', () => { - it('calls updateFriendRequest with "accepted" when clicking accept', async () => { - const user = userEvent.setup(); - const mockUpdate = vi.fn().mockResolvedValue(undefined); - const incomingRequest = makeRequest({ - id: 'req-in-1', - fromId: 'sender-id', - toId: 'current-user', - user: { - id: 'sender-id', - username: 'sender', - displayName: 'Sender', - avatar: null, - status: 'online', - customStatus: null, - createdAt: Date.now(), - }, - }); - useSocialStore.setState({ - friends: [], - requests: [incomingRequest], - updateFriendRequest: mockUpdate, - }); - renderFriendsPage(); - await user.click(screen.getByText('Pending')); - expect(screen.getByText('Incoming Friend Request')).toBeInTheDocument(); - // Click accept button (title "Accept") - const acceptButton = screen.getByTitle('Accept'); - await user.click(acceptButton); - await waitFor(() => { - expect(mockUpdate).toHaveBeenCalledWith('req-in-1', 'accepted'); - }); - }); - it('calls updateFriendRequest with "declined" when clicking decline', async () => { - const user = userEvent.setup(); - const mockUpdate = vi.fn().mockResolvedValue(undefined); - const incomingRequest = makeRequest({ - id: 'req-in-2', - fromId: 'sender-id', - toId: 'current-user', - user: { - id: 'sender-id', - username: 'sender2', - displayName: 'Sender 2', - avatar: null, - status: 'online', - customStatus: null, - createdAt: Date.now(), - }, - }); - useSocialStore.setState({ - friends: [], - requests: [incomingRequest], - updateFriendRequest: mockUpdate, - }); - renderFriendsPage(); - await user.click(screen.getByText('Pending')); - const declineButton = screen.getByTitle('Decline'); - await user.click(declineButton); - await waitFor(() => { - expect(mockUpdate).toHaveBeenCalledWith('req-in-2', 'declined'); - }); - }); - }); -}); diff --git a/packages/web/src/components/chat/ImagePreview.js b/packages/web/src/components/chat/ImagePreview.js deleted file mode 100644 index 8eb10530..00000000 --- a/packages/web/src/components/chat/ImagePreview.js +++ /dev/null @@ -1,10 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useUIStore } from '../../stores/uiStore'; -export function ImagePreview() { - const imageUrl = useUIStore((s) => s.imagePreviewUrl); - const closeImagePreview = useUIStore((s) => s.closeImagePreview); - 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-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/Message.js b/packages/web/src/components/chat/Message.js deleted file mode 100644 index 33c5d363..00000000 --- a/packages/web/src/components/chat/Message.js +++ /dev/null @@ -1,159 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useState } from 'react'; -import ReactMarkdown from 'react-markdown'; -import { Avatar } from '../ui/Avatar'; -import { ContextMenu } from '../ui/ContextMenu'; -import { useAuthStore } from '../../stores/authStore'; -import { useChatStore } from '../../stores/chatStore'; -import { useServerStore } from '../../stores/serverStore'; -import { useUIStore } from '../../stores/uiStore'; -import { Embed } from './Embed'; -function formatTime(timestamp) { - const date = new Date(timestamp); - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - const isYesterday = date.toDateString() === yesterday.toDateString(); - const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - if (isToday) - return `Today at ${time}`; - if (isYesterday) - return `Yesterday at ${time}`; - return `${date.toLocaleDateString()} ${time}`; -} -function formatHoverTime(timestamp) { - return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} -export function Message({ message, isCompact, isFirstInGroup }) { - const [isEditing, setIsEditing] = useState(false); - const [editContent, setEditContent] = useState(message.content ?? ''); - const [isHovered, setIsHovered] = useState(false); - const currentUser = useAuthStore((s) => s.user); - const editMessage = useChatStore((s) => s.editMessage); - const deleteMessage = useChatStore((s) => s.deleteMessage); - const members = useServerStore((s) => s.members); - const openImagePreview = useUIStore((s) => s.openImagePreview); - const openUserProfile = useUIStore((s) => s.openUserProfile); - const 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'; - const canDelete = isAuthor || isAdminUser; - const addReaction = useChatStore((s) => s.addReaction); - const removeReaction = useChatStore((s) => s.removeReaction); - const setReplyTo = useChatStore((s) => s.setReplyTo); - const toggleReaction = (emoji) => { - const hasReacted = message.reactions?.some(r => r.userId === currentUser?.id && r.emoji === emoji); - if (hasReacted) { - removeReaction(message.id, emoji); - } - else { - addReaction(message.id, emoji); - } - }; - const reactionGroups = (message.reactions || []).reduce((acc, r) => { - const group = acc[r.emoji] || { count: 0, me: false }; - group.count++; - if (r.userId === currentUser?.id) { - group.me = true; - } - acc[r.emoji] = group; - return acc; - }, {}); - const urlRegex = /(https?:\/\/[^\s]+)/g; - const firstUrl = message.content?.match(urlRegex)?.[0]; - const handleUsernameClick = (e) => { - if (!message.user) - return; - e.stopPropagation(); - const rect = e.currentTarget.getBoundingClientRect(); - openUserProfile(message.user, { - top: Math.min(rect.top, window.innerHeight - 450), - left: rect.right + 16, - }); - }; - const contextMenuItems = []; - if (isAuthor) { - contextMenuItems.push({ - label: 'Edit Message', - onClick: () => { - setEditContent(message.content ?? ''); - setIsEditing(true); - }, - }); - } - if (canDelete) { - contextMenuItems.push({ - label: 'Delete Message', - onClick: () => deleteMessage(message.id, channelKey), - danger: true, - }); - } - const handleEditSubmit = async (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - if (editContent.trim()) { - await editMessage(message.id, editContent.trim(), channelKey); - setIsEditing(false); - } - } - if (e.key === 'Escape') { - setIsEditing(false); - setEditContent(message.content ?? ''); - } - }; - const displayName = message.user.displayName ?? message.user.username; - const roleColor = (() => { - const member = members.find(m => m.userId === message.userId); - if (member?.roles && member.roles.length > 0) { - return { color: member.roles[0].color }; - } - if (member?.role === 'owner') - return { color: '#f23f43' }; - if (member?.role === 'admin') - return { color: '#5865f2' }; - return { color: '#dcdcdf' }; - })(); - const replyRoleColor = (msg) => { - const member = members.find(m => m.userId === msg.userId); - if (member?.roles && member.roles.length > 0) { - return { color: member.roles[0].color }; - } - if (member?.role === 'owner') - return { color: '#f23f43' }; - if (member?.role === 'admin') - return { color: '#5865f2' }; - return { color: '#dcdcdf' }; - }; - 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(), 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: { - p: ({ children }) => _jsx("span", { children: children }), - a: ({ href, children }) => (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-discord-text-link hover:underline", children: children })), - code: ({ children }) => (_jsx("code", { className: "px-1 py-0.5 bg-discord-bg-tertiary rounded text-[14px] font-mono", children: children })), - pre: ({ children }) => (_jsx("pre", { className: "mt-1 p-3 bg-discord-bg-tertiary border border-discord-bg-tertiary/50 rounded-md text-[14px] font-mono overflow-x-auto", children: children })), - strong: ({ children }) => _jsx("strong", { className: "font-bold text-discord-text-primary", children: children }), - em: ({ children }) => _jsx("em", { className: "italic", children: children }), - }, children: message.content }), message.editedAt && (_jsx("span", { className: "text-[10px] text-discord-text-muted ml-1 select-none font-medium", children: "(edited)" }))] })), Object.keys(reactionGroups).length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1 mt-1", children: Object.entries(reactionGroups).map(([emoji, { count, me }]) => (_jsxs("button", { onClick: () => toggleReaction(emoji), className: `flex items-center gap-1.5 px-1.5 py-0.5 rounded-[8px] text-[14px] font-medium border transition-colors ${me - ? 'bg-discord-blurple/15 border-discord-blurple text-discord-blurple' - : 'bg-discord-bg-secondary border-transparent text-discord-text-muted hover:border-discord-text-muted/30'}`, children: [_jsx("span", { children: emoji }), _jsx("span", { className: me ? 'text-discord-blurple' : 'text-discord-text-normal', children: count })] }, emoji))) })), !isEditing && firstUrl && _jsx(Embed, { url: firstUrl }), message.attachments && message.attachments.length > 0 && (_jsx("div", { className: "mt-1 grid gap-2", children: message.attachments.map((att) => { - const isImage = att.mimetype.startsWith('image/'); - if (isImage) { - return (_jsx("div", { className: "max-w-fit mt-1 rounded-lg overflow-hidden border border-discord-bg-tertiary/50 bg-discord-bg-tertiary/20", children: _jsx("img", { src: `/api/uploads/${att.filename}`, alt: att.originalName, className: "max-w-full max-h-[350px] object-contain cursor-pointer hover:brightness-95 transition-all", onClick: () => openImagePreview(`/api/uploads/${att.filename}`), loading: "lazy" }) }, att.id)); - } - return (_jsxs("a", { href: `/api/uploads/${att.filename}`, download: att.originalName, className: "flex items-center gap-3 p-4 bg-discord-bg-secondary/50 rounded-lg border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-all max-w-[400px] mt-1 group/att", children: [_jsx("div", { className: "p-2 bg-discord-bg-tertiary rounded text-discord-text-muted group-hover/att:text-discord-text-primary transition-colors", children: _jsx("svg", { className: "w-8 h-8", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 1.5, d: "M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" }) }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-discord-text-link text-[15px] font-medium truncate hover:underline", children: att.originalName }), _jsx("p", { className: "text-[12px] text-discord-text-muted font-medium", children: att.size < 1024 ? `${att.size} B` : - att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` : - `${(att.size / 1048576).toFixed(1)} MB` })] })] }, att.id)); - }) }))] }))] }), isHovered && !isEditing && (_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, 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 }); - } - return content; -} diff --git a/packages/web/src/components/chat/MessageInput.js b/packages/web/src/components/chat/MessageInput.js deleted file mode 100644 index 38cd3483..00000000 --- a/packages/web/src/components/chat/MessageInput.js +++ /dev/null @@ -1,108 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useState, useRef, useCallback } from 'react'; -import { useChatStore } from '../../stores/chatStore'; -import { isDmChannel } from '../../stores/serverStore'; -import { wsSend } from '../../hooks/useWebSocket'; -import { api } from '../../api/client'; -export function MessageInput({ channelId, channelName }) { - const [content, setContent] = useState(''); - const [files, setFiles] = useState([]); - const [isUploading, setIsUploading] = useState(false); - const fileInputRef = useRef(null); - const textareaRef = useRef(null); - const sendMessage = useChatStore((s) => s.sendMessage); - const replyTo = useChatStore((s) => s.replyTo); - const setReplyTo = useChatStore((s) => s.setReplyTo); - const typingTimeoutRef = useRef(); - const handleTyping = useCallback(() => { - if (typingTimeoutRef.current) - return; - const isDm = isDmChannel(channelId); - if (isDm) { - wsSend({ type: 'dm_typing_start', dmChannelId: channelId }); - } - else { - wsSend({ type: 'typing_start', channelId }); - } - typingTimeoutRef.current = setTimeout(() => { - typingTimeoutRef.current = undefined; - }, 3000); - }, [channelId]); - const handleSubmit = async () => { - const trimmed = content.trim(); - if (!trimmed && files.length === 0) - return; - setIsUploading(true); - try { - // Upload files first - const attachmentIds = []; - for (const file of files) { - const attachment = await api.uploads.upload(file); - attachmentIds.push(attachment.id); - } - await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined); - setContent(''); - setFiles([]); - // Clear typing timeout - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - typingTimeoutRef.current = undefined; - } - } - catch (err) { - console.error('Failed to send message:', err); - } - finally { - setIsUploading(false); - } - }; - const handleKeyDown = (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }; - const handlePaste = (e) => { - const items = e.clipboardData.items; - const pastedFiles = []; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (item && item.type.startsWith('image/')) { - const file = item.getAsFile(); - if (file) - pastedFiles.push(file); - } - } - if (pastedFiles.length > 0) { - setFiles((prev) => [...prev, ...pastedFiles]); - } - }; - const handleDrop = (e) => { - e.preventDefault(); - const droppedFiles = Array.from(e.dataTransfer.files); - if (droppedFiles.length > 0) { - setFiles((prev) => [...prev, ...droppedFiles]); - } - }; - const handleDragOver = (e) => { - e.preventDefault(); - }; - const removeFile = (index) => { - setFiles((prev) => prev.filter((_, i) => i !== index)); - }; - const handleChange = (e) => { - setContent(e.target.value); - handleTyping(); - // Auto-resize textarea - const textarea = e.target; - textarea.style.height = 'auto'; - textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; - }; - return (_jsxs("div", { className: "px-4 pb-6 flex-shrink-0", children: [replyTo && (_jsxs("div", { className: "bg-discord-bg-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-discord-bg-tertiary/50", children: [_jsxs("div", { className: "flex items-center gap-1 text-[14px] text-discord-text-normal truncate", children: [_jsx("span", { className: "opacity-60", children: "Replying to" }), _jsx("span", { className: "font-bold", children: replyTo.user.displayName ?? replyTo.user.username })] }), _jsx("button", { onClick: () => setReplyTo(null), className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] })), _jsxs("div", { className: `bg-discord-bg-input ${replyTo ? 'rounded-b-lg' : 'rounded-lg'} overflow-hidden`, onDrop: handleDrop, onDragOver: handleDragOver, children: [files.length > 0 && (_jsx("div", { className: "p-4 flex flex-wrap gap-4 bg-discord-bg-secondary/30", children: files.map((file, i) => (_jsxs("div", { className: "relative group bg-discord-bg-secondary rounded-lg p-2 max-w-[200px] shadow-elevation-low border border-discord-bg-tertiary", children: [file.type.startsWith('image/') ? (_jsx("img", { src: URL.createObjectURL(file), alt: file.name, className: "max-h-[150px] rounded object-cover" })) : (_jsxs("div", { className: "flex items-center gap-2 text-sm text-discord-text-secondary py-4 px-2", children: [_jsx("svg", { className: "w-8 h-8 opacity-60", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" }) }), _jsx("span", { className: "truncate max-w-[120px] font-medium", children: file.name })] })), _jsx("button", { onClick: () => removeFile(i), className: "absolute -top-2 -right-2 w-7 h-7 bg-discord-red hover:bg-discord-red-hover shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10", children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" }) }) })] }, i))) })), _jsxs("div", { className: "flex items-start px-1", children: [_jsx("button", { onClick: () => fileInputRef.current?.click(), className: "p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors sticky top-0", title: "Attach file", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) }) }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, className: "hidden", onChange: (e) => { - const selected = Array.from(e.target.files ?? []); - if (selected.length > 0) { - setFiles((prev) => [...prev, ...selected]); - } - e.target.value = ''; - } }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`, className: "flex-1 py-[11px] px-1 bg-transparent text-discord-text-primary placeholder-discord-text-muted/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin", rows: 1, disabled: isUploading }), isUploading && (_jsx("div", { className: "p-3 text-discord-text-muted", children: _jsxs("svg", { className: "w-5 h-5 animate-spin", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }) })), _jsx("button", { className: "p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "GIF", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" }) }) }), _jsx("button", { className: "p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "Stickers", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12.5 2C6.81 2 2 6.81 2 12.5S6.81 23 12.5 23c1.31 0 2.56-.25 3.73-.7l5.07-5.07c.45-1.17.7-2.42.7-3.73C22 7.81 17.19 2 12.5 2Zm0 19c-4.69 0-8.5-3.81-8.5-8.5S7.81 4 12.5 4 21 7.81 21 12.5c0 .89-.14 1.74-.4 2.54l-3.56 3.56c-.8.26-1.65.4-2.54.4ZM8 11.5c.83 0 1.5-.67 1.5-1.5S8.83 8.5 8 8.5 6.5 9.17 6.5 10s.67 1.5 1.5 1.5Zm6 0c.83 0 1.5-.67 1.5-1.5s-.67-1.5-1.5-1.5-1.5.67-1.5 1.5.67 1.5 1.5 1.5Zm-1 3.5c-2.33 0-4.31-1.46-5.11-3.5h10.22c-.8 2.04-2.78 3.5-5.11 3.5Z" }) }) }), _jsx("button", { className: "p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "Emoji", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" }) }) })] })] })] })); -} diff --git a/packages/web/src/components/chat/MessageList.js b/packages/web/src/components/chat/MessageList.js deleted file mode 100644 index a64689b2..00000000 --- a/packages/web/src/components/chat/MessageList.js +++ /dev/null @@ -1,116 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import React, { useEffect, useRef, useCallback, useState } from 'react'; -import { Message } from './Message'; -import { useChatStore } from '../../stores/chatStore'; -import { useServerStore, isDmChannel } from '../../stores/serverStore'; -import { useAuthStore } from '../../stores/authStore'; -import { useSocialStore } from '../../stores/socialStore'; -import { Avatar } from '../ui/Avatar'; -import { LoadingSpinner } from '../ui/LoadingSpinner'; -const EMPTY_MESSAGES = []; -function isSameGroup(prev, curr) { - if (prev.userId !== curr.userId) - return false; - const timeDiff = curr.createdAt - prev.createdAt; - return timeDiff < 5 * 60 * 1000; // 5 minutes -} -function formatDateDivider(timestamp) { - const date = new Date(timestamp); - return date.toLocaleDateString(undefined, { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }); -} -function shouldShowDateDivider(prev, curr) { - if (!prev) - return true; - const prevDate = new Date(prev.createdAt).toDateString(); - const currDate = new Date(curr.createdAt).toDateString(); - return prevDate !== currDate; -} -export function MessageList({ channelId }) { - const messages = useChatStore((s) => s.messages.get(channelId)) ?? EMPTY_MESSAGES; - const loadMessages = useChatStore((s) => s.loadMessages); - 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]); - // 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) { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); - } - prevMessagesLength.current = messages.length; - }, [messages.length, isNearBottom]); - // Scroll to bottom on initial load - useEffect(() => { - if (messages.length > 0 && prevMessagesLength.current === 0) { - bottomRef.current?.scrollIntoView(); - } - }, [messages.length]); - const handleScroll = useCallback(async () => { - const container = containerRef.current; - if (!container) - return; - // Check if near bottom - const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - setIsNearBottom(distanceFromBottom < 100); - // Load more when scrolled to top - if (container.scrollTop < 50 && hasMore && !isLoadingMore) { - setIsLoadingMore(true); - const prevScrollHeight = container.scrollHeight; - const loaded = await loadMoreMessages(channelId); - if (loaded) { - // Maintain scroll position - requestAnimationFrame(() => { - container.scrollTop = container.scrollHeight - prevScrollHeight; - }); - } - setIsLoadingMore(false); - } - }, [channelId, hasMore, isLoadingMore, loadMoreMessages]); - 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 scrollbar-thin", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && _jsx(WelcomeHeader, { channelId: channelId }), _jsx("div", { className: "pb-6", children: messages.map((msg, i) => { - const prevMsg = messages[i - 1]; - const showDate = shouldShowDateDivider(prevMsg, msg); - const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg); - return (_jsxs(React.Fragment, { children: [showDate && (_jsxs("div", { className: "flex items-center px-4 my-6 select-none pointer-events-none", children: [_jsx("div", { className: "flex-1 h-[1px] bg-discord-modifier-accent" }), _jsx("span", { className: "px-2 text-[12px] font-bold text-discord-text-muted leading-tight", children: formatDateDivider(msg.createdAt) }), _jsx("div", { className: "flex-1 h-[1px] bg-discord-modifier-accent" })] })), _jsx(Message, { message: msg, isCompact: !isFirstInGroup, isFirstInGroup: isFirstInGroup })] }, msg.id)); - }) }), _jsx("div", { ref: bottomRef })] })); -} -function WelcomeHeader({ channelId }) { - const dmChannels = useServerStore((s) => s.dmChannels); - const authUser = useAuthStore((s) => s.user); - const removeFriend = useSocialStore((s) => s.removeFriend); - const friends = useSocialStore((s) => s.friends); - const isDm = isDmChannel(channelId); - if (isDm) { - const dm = dmChannels.find(d => d.id === channelId); - const otherUser = dm?.members.find(m => m.id !== authUser?.id); - const displayName = otherUser?.displayName ?? otherUser?.username ?? 'Unknown'; - const username = otherUser?.username ?? 'unknown'; - const isFriend = otherUser ? friends.some(f => f.id === otherUser.id) : false; - return (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "mb-2", children: _jsx(Avatar, { src: otherUser?.avatar, name: displayName, size: 80 }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: displayName }), _jsxs("p", { className: "text-discord-text-secondary text-[14px] mt-1", children: ["This is the beginning of your direct message history with ", _jsxs("strong", { children: ["@", username] }), "."] }), isFriend && otherUser && (_jsx("div", { className: "mt-4", children: _jsx("button", { onClick: () => removeFriend(otherUser.id), className: "px-4 py-1.5 bg-discord-bg-accent hover:bg-discord-bg-surface-higher text-[14px] font-medium text-discord-text-primary rounded-[3px] transition-colors", children: "Remove Friend" }) })), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] })); - } - return (_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" })] })); -} diff --git a/packages/web/src/components/chat/TypingIndicator.js b/packages/web/src/components/chat/TypingIndicator.js deleted file mode 100644 index c3dfb732..00000000 --- a/packages/web/src/components/chat/TypingIndicator.js +++ /dev/null @@ -1,29 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useMemo } from 'react'; -import { useChatStore } from '../../stores/chatStore'; -import { useAuthStore } from '../../stores/authStore'; -export function TypingIndicator({ channelId }) { - const typingUsersRaw = useChatStore((s) => s.typingUsers.get(channelId)); - const currentUserId = useAuthStore((s) => s.user?.id); - // Filter out current user and expired entries - const others = useMemo(() => { - if (!typingUsersRaw || typingUsersRaw.length === 0) - return []; - const now = Date.now(); - return typingUsersRaw - .filter(t => now - t.timestamp < 5000 && t.userId !== currentUserId); - }, [typingUsersRaw, currentUserId]); - if (others.length === 0) - return null; - let text = ''; - if (others.length === 1) { - text = `${others[0].username} is typing`; - } - else if (others.length === 2) { - text = `${others[0].username} and ${others[1].username} are typing`; - } - else { - text = 'Several people are typing'; - } - return (_jsx("div", { className: "h-[24px] px-4 flex items-center text-[12px] text-discord-text-header font-medium select-none pointer-events-none", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex gap-[2px] bg-discord-bg-accent/20 rounded-full px-2 py-1", children: [_jsx("div", { className: "w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce", style: { animationDelay: '0ms', animationDuration: '0.8s' } }), _jsx("div", { className: "w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce", style: { animationDelay: '150ms', animationDuration: '0.8s' } }), _jsx("div", { className: "w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce", style: { animationDelay: '300ms', animationDuration: '0.8s' } })] }), _jsx("span", { className: "truncate max-w-[400px]", children: _jsx("span", { className: "font-bold", children: text }) })] }) })); -} diff --git a/packages/web/src/components/layout/ActivityPanel.js b/packages/web/src/components/layout/ActivityPanel.js deleted file mode 100644 index 549359c8..00000000 --- a/packages/web/src/components/layout/ActivityPanel.js +++ /dev/null @@ -1,39 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; -import { useEffect, useMemo } from 'react'; -import { useSocialStore } from '../../stores/socialStore'; -import { useUIStore } from '../../stores/uiStore'; -import { Avatar } from '../ui/Avatar'; -export function ActivityPanel() { - const friends = useSocialStore((s) => s.friends); - const loadFriends = useSocialStore((s) => s.loadFriends); - const memberListOpen = useUIStore((s) => s.memberListOpen); - const openUserProfile = useUIStore((s) => s.openUserProfile); - useEffect(() => { - loadFriends(); - }, [loadFriends]); - const { onlineFriends, offlineFriends } = useMemo(() => { - const online = friends.filter(f => f.status !== 'offline'); - const offline = friends.filter(f => f.status === 'offline'); - return { onlineFriends: online, offlineFriends: offline }; - }, [friends]); - if (!memberListOpen) - return null; - const handleFriendClick = (e, friend) => { - e.stopPropagation(); - const rect = e.currentTarget.getBoundingClientRect(); - openUserProfile({ - id: friend.id, - username: friend.username, - displayName: friend.displayName, - avatar: friend.avatar, - status: friend.status, - customStatus: friend.customStatus, - createdAt: friend.createdAt, - }, { - top: Math.min(rect.top, window.innerHeight - 450), - left: rect.left - 316, - }); - }; - const renderFriend = (friend, isOffline = false) => (_jsxs("div", { onClick: (e) => handleFriendClick(e, friend), className: "flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors", children: [_jsx(Avatar, { src: friend.avatar, name: friend.displayName ?? friend.username, size: 32, status: isOffline ? 'offline' : friend.status, className: isOffline ? 'opacity-60' : undefined }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: `text-[15px] font-medium truncate ${isOffline ? 'text-discord-text-muted' : 'text-discord-text-primary'}`, children: friend.displayName ?? friend.username }), !isOffline && friend.customStatus && (_jsx("div", { className: "text-[12px] text-discord-text-muted truncate", children: friend.customStatus }))] })] }, friend.id)); - return (_jsx("div", { className: "w-60 bg-discord-bg-secondary flex-shrink-0 overflow-y-auto select-none no-scrollbar", children: _jsxs("div", { className: "p-3", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4 px-2", children: "Active Now" }), onlineFriends.length === 0 && offlineFriends.length === 0 ? (_jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we'll show it here!" })] })) : (_jsxs(_Fragment, { children: [onlineFriends.length > 0 && (_jsxs("div", { className: "mb-4", children: [_jsxs("h3", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1", children: ["ONLINE \u2014 ", onlineFriends.length] }), onlineFriends.map(f => renderFriend(f))] })), offlineFriends.length > 0 && (_jsxs("div", { children: [_jsxs("h3", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1", children: ["OFFLINE \u2014 ", offlineFriends.length] }), offlineFriends.map(f => renderFriend(f, true))] }))] }))] }) })); -} diff --git a/packages/web/src/components/layout/AppLayout.js b/packages/web/src/components/layout/AppLayout.js deleted file mode 100644 index 7e7d7361..00000000 --- a/packages/web/src/components/layout/AppLayout.js +++ /dev/null @@ -1,188 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; -import React, { useEffect } from 'react'; -import { useParams } from 'react-router-dom'; -import { ServerSidebar } from './ServerSidebar'; -import { ChannelSidebar } from './ChannelSidebar'; -import { MainContent } from './MainContent'; -import { RightPanel } from './RightPanel'; -import { ImagePreview } from '../chat/ImagePreview'; -import { CreateServerModal } from '../modals/CreateServer'; -import { JoinServerModal } from '../modals/JoinServer'; -import { CreateChannelModal } from '../modals/CreateChannel'; -import { InviteModal } from '../modals/InviteModal'; -import { UserSettingsModal } from '../modals/UserSettings'; -import { ServerSettingsModal } from '../modals/ServerSettings'; -import { NewDmModal } from '../modals/NewDmModal'; -import { IncomingCallModal } from '../voice/IncomingCallModal'; -import { PictureInPicture } from '../voice/PictureInPicture'; -import { SoundController } from '../voice/SoundController'; -import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer'; -import { UserProfilePopout } from '../ui/UserProfilePopout'; -import { useAuth } from '../../hooks/useAuth'; -import { useWebSocket } from '../../hooks/useWebSocket'; -import { useLiveKit } from '../../hooks/useLiveKit'; -import { useServerStore } from '../../stores/serverStore'; -import { useChatStore } from '../../stores/chatStore'; -import { useUIStore } from '../../stores/uiStore'; -import { useVoiceStore } from '../../stores/voiceStore'; -import { AudioManager } from '../../audio/AudioManager'; -export function AppLayout() { - const { serverId, channelId, inviteCode } = useParams(); - // Global interaction handler to resume AudioContext - useEffect(() => { - const resume = () => { - AudioManager.getInstance().resumeContext().then(() => { - window.removeEventListener('click', resume); - window.removeEventListener('keydown', resume); - window.removeEventListener('touchstart', resume); - }); - }; - window.addEventListener('click', resume); - window.addEventListener('keydown', resume); - window.addEventListener('touchstart', resume); - return () => { - window.removeEventListener('click', resume); - window.removeEventListener('keydown', resume); - window.removeEventListener('touchstart', resume); - }; - }, []); - // MutationObserver: neutralize rogue LiveKit