chore: remove 62 tsc emit artifacts from src/, add noEmit to tsconfig
tsc was emitting compiled .js files directly into packages/web/src/ alongside the .tsx source files because noEmit was not set. These artifacts were never used — Vite compiles from .tsx source directly. - Add noEmit: true to packages/web/tsconfig.json (tsc = type-check only) - Delete all 62 orphaned .js files from src/ (-6,129 lines) - Add packages/web/src/**/*.js to .gitignore as safeguard
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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 }) })] }));
|
||||
}
|
||||
@@ -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 }),
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" })] })] })] })] }));
|
||||
}
|
||||
@@ -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" })] })] })] })] }));
|
||||
}
|
||||
@@ -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]" }) }))] }));
|
||||
}
|
||||
@@ -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" }) }) })) })] }));
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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() })] }));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }) }) })] })] })] }));
|
||||
}
|
||||
@@ -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" })] }));
|
||||
}
|
||||
@@ -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 }) })] }) }));
|
||||
}
|
||||
@@ -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))] }))] }))] }) }));
|
||||
}
|
||||
@@ -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 <audio> elements that bypass our Web Audio pipeline.
|
||||
// LiveKit can re-attach hidden <audio> elements after .detach(), causing full-volume playback
|
||||
// that ignores our volume/mute controls. Any <audio> without data-opencord is immediately killed.
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLAudioElement && !node.dataset.opencord) {
|
||||
node.muted = true;
|
||||
node.volume = 0;
|
||||
node.pause();
|
||||
node.srcObject = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
|
||||
useEffect(() => {
|
||||
AudioManager.getInstance().setOutputDevice(outputDeviceId);
|
||||
}, [outputDeviceId]);
|
||||
const { user, isLoading } = useAuth();
|
||||
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
|
||||
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
|
||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||
const loadMessages = useChatStore((s) => s.loadMessages);
|
||||
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
||||
const setShowDms = useUIStore((s) => s.setShowDms);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||
const isMobile = useUIStore((s) => s.isMobile);
|
||||
const userProfilePopout = useUIStore((s) => s.userProfilePopout);
|
||||
const closeUserProfile = useUIStore((s) => s.closeUserProfile);
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||
const setParticipants = useVoiceStore((s) => s.setParticipants);
|
||||
const { connect: connectVoice, connectDm: connectDmVoice, disconnect: disconnectVoice, participants: voiceParticipants, isConnected: isVoiceConnected, isConnecting: isVoiceConnecting, connectedChannelId, } = useLiveKit();
|
||||
// Initialize WebSocket
|
||||
const { isConnected: isWsConnected } = useWebSocket();
|
||||
// Sync participants to store
|
||||
useEffect(() => {
|
||||
setParticipants(voiceParticipants);
|
||||
}, [voiceParticipants, setParticipants]);
|
||||
// Track the last channel we attempted to connect to, to prevent effect loops
|
||||
const lastAttemptedRef = React.useRef(null);
|
||||
// Manage voice connection
|
||||
useEffect(() => {
|
||||
if (isLoading || !user || !isWsConnected)
|
||||
return;
|
||||
const manageConnection = async () => {
|
||||
// Determine what we SHOULD be connected to
|
||||
const targetChannelId = activeDmCall
|
||||
? `dm-${activeDmCall.dmChannelId}`
|
||||
: currentVoiceChannelId;
|
||||
// 1. If we have a target
|
||||
if (targetChannelId) {
|
||||
// If we're not connected to the RIGHT place, trigger connect.
|
||||
// We IGNORE isVoiceConnecting here to allow "interrupting" a connection
|
||||
// or switching rooms immediately.
|
||||
if (connectedChannelId !== targetChannelId) {
|
||||
// Prevent spamming the same connection attempt if React re-renders
|
||||
if (lastAttemptedRef.current === targetChannelId && isVoiceConnecting) {
|
||||
return;
|
||||
}
|
||||
console.log(`[AppLayout] Switching/Connecting to: ${targetChannelId}`);
|
||||
lastAttemptedRef.current = targetChannelId;
|
||||
if (activeDmCall) {
|
||||
await connectDmVoice(activeDmCall.dmChannelId);
|
||||
}
|
||||
else {
|
||||
await connectVoice(targetChannelId);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// We are connected to the right place. Reset ref.
|
||||
lastAttemptedRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 2. No target — ensure disconnected
|
||||
if (connectedChannelId !== null || isVoiceConnected || isVoiceConnecting) {
|
||||
console.log('[AppLayout] Leaving voice (no target)');
|
||||
lastAttemptedRef.current = null;
|
||||
await disconnectVoice();
|
||||
}
|
||||
};
|
||||
manageConnection();
|
||||
}, [
|
||||
currentVoiceChannelId,
|
||||
activeDmCall,
|
||||
connectedChannelId,
|
||||
isVoiceConnected,
|
||||
isVoiceConnecting,
|
||||
isWsConnected,
|
||||
isLoading,
|
||||
user,
|
||||
connectVoice,
|
||||
connectDmVoice,
|
||||
disconnectVoice
|
||||
]);
|
||||
// Responsive detection
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, [setIsMobile]);
|
||||
// Handle route params
|
||||
useEffect(() => {
|
||||
if (serverId === '@me') {
|
||||
setShowDms(true);
|
||||
setCurrentServer(null);
|
||||
}
|
||||
else if (serverId) {
|
||||
setShowDms(false);
|
||||
setCurrentServer(serverId);
|
||||
loadServerDetail(serverId);
|
||||
}
|
||||
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
|
||||
useEffect(() => {
|
||||
if (inviteCode) {
|
||||
openModal('joinServer');
|
||||
}
|
||||
}, [inviteCode, openModal]);
|
||||
useEffect(() => {
|
||||
if (channelId) {
|
||||
setCurrentChannel(channelId);
|
||||
loadMessages(channelId);
|
||||
}
|
||||
else {
|
||||
setCurrentChannel(null);
|
||||
}
|
||||
}, [channelId, setCurrentChannel, loadMessages]);
|
||||
if (isLoading || !user) {
|
||||
return (_jsx("div", { className: "h-screen flex items-center justify-center bg-discord-bg-primary", children: _jsxs("div", { className: "text-center", children: [_jsxs("svg", { className: "animate-spin w-10 h-10 text-discord-blurple mx-auto mb-4", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }), _jsx("p", { className: "text-discord-text-muted", children: "Loading Opencord..." })] }) }));
|
||||
}
|
||||
return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), _jsx(RightPanel, {})] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(IncomingCallModal, {}), _jsx(ImagePreview, {}), _jsx(PictureInPicture, {}), _jsx(SoundController, {}), _jsx(GlobalAudioRenderer, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] }));
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
export function MemberListToggleButton() {
|
||||
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
|
||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||
return (_jsx("button", { onClick: toggleMemberList, className: `w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'}`, title: "Toggle Member List", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }) }));
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useMemo } from 'react';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
const ROLE_ORDER = { owner: 0, admin: 1, member: 2 };
|
||||
const ROLE_LABELS = { owner: 'OWNER', admin: 'ADMIN', member: 'MEMBER' };
|
||||
export function MemberSidebar() {
|
||||
const members = useServerStore((s) => s.members);
|
||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
const { roleGroups, offlineMembers } = useMemo(() => {
|
||||
const online = members.filter(m => m.user.status !== 'offline');
|
||||
const offline = members.filter(m => m.user.status === 'offline');
|
||||
// Group online members by role
|
||||
const groups = new Map();
|
||||
for (const m of online) {
|
||||
const role = m.role || 'member';
|
||||
if (!groups.has(role))
|
||||
groups.set(role, []);
|
||||
groups.get(role).push(m);
|
||||
}
|
||||
// Sort groups by role hierarchy
|
||||
const sorted = [...groups.entries()].sort((a, b) => (ROLE_ORDER[a[0]] ?? 99) - (ROLE_ORDER[b[0]] ?? 99));
|
||||
return { roleGroups: sorted, offlineMembers: offline };
|
||||
}, [members]);
|
||||
if (!memberListOpen)
|
||||
return null;
|
||||
const roleColors = {
|
||||
owner: 'text-discord-red',
|
||||
admin: 'text-discord-blurple',
|
||||
member: 'text-discord-text-primary',
|
||||
};
|
||||
const getMemberColor = (member) => {
|
||||
if (member.roles && member.roles.length > 0) {
|
||||
return { color: member.roles[0].color };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const handleMemberClick = (e, user) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(user, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.left - 316,
|
||||
});
|
||||
};
|
||||
const renderMember = (member, isOffline = false) => {
|
||||
const displayName = member.user.displayName ?? member.user.username;
|
||||
return (_jsxs("div", { onClick: (e) => handleMemberClick(e, member.user), className: "flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: isOffline ? 'offline' : member.user.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' : (!getMemberColor(member) ? (roleColors[member.role] ?? 'text-discord-text-primary') : '')}`, style: isOffline ? undefined : getMemberColor(member), children: displayName }), !isOffline && member.user.customStatus && (_jsx("div", { className: "text-[12px] text-discord-text-muted truncate", children: member.user.customStatus }))] })] }, member.userId));
|
||||
};
|
||||
return (_jsx("div", { className: "w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto select-none no-scrollbar", children: _jsxs("div", { className: "p-3", children: [roleGroups.map(([role, groupMembers]) => (_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: [ROLE_LABELS[role] ?? role.toUpperCase(), " \u2014 ", groupMembers.length] }), groupMembers.map((m) => renderMember(m))] }, role))), offlineMembers.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 ", offlineMembers.length] }), offlineMembers.map((m) => renderMember(m, true))] }))] }) }));
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
export function MobileNav() {
|
||||
const isMobile = useUIStore((s) => s.isMobile);
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||
if (!isMobile)
|
||||
return null;
|
||||
return (_jsxs(_Fragment, { children: [_jsx("button", { onClick: toggleSidebar, className: "fixed top-3 left-3 z-40 p-1.5 rounded bg-discord-bg-secondary text-discord-text-primary md:hidden", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: sidebarOpen ? (_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("path", { d: "M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" })) }) }), sidebarOpen && (_jsx("div", { className: "fixed inset-0 bg-black/50 z-30 md:hidden", onClick: toggleSidebar }))] }));
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { MemberSidebar } from './MemberSidebar';
|
||||
import { ActivityPanel } from './ActivityPanel';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
export function RightPanel() {
|
||||
const showDms = useUIStore((s) => s.showDms);
|
||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||
if (showDms || !currentServerId) {
|
||||
return _jsx(ActivityPanel, {});
|
||||
}
|
||||
return _jsx(MemberSidebar, {});
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
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, hasUnread }) {
|
||||
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';
|
||||
};
|
||||
const getButtonClasses = () => {
|
||||
const base = 'w-12 h-12 flex items-center justify-center transition-all duration-200 overflow-hidden relative group';
|
||||
if (type === 'dm') {
|
||||
return `${base} ${active ? 'bg-discord-blurple rounded-[16px] text-white' : 'bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:bg-discord-blurple hover:text-white'}`;
|
||||
}
|
||||
if (type === 'action') {
|
||||
return `${base} bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-green hover:bg-discord-green hover:text-white`;
|
||||
}
|
||||
return `${base} ${active ? 'bg-discord-blurple rounded-[16px] text-white' : 'bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:bg-discord-blurple hover:text-white'}`;
|
||||
};
|
||||
return (_jsxs("div", { className: "relative flex items-center mb-2 w-full justify-center", onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [(type === 'server' || type === 'dm') && (_jsx("div", { className: "absolute -left-0 w-2 h-12 flex items-center", children: _jsx("div", { className: `bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1` }) })), _jsx(Tooltip, { content: name, position: "right", children: _jsx("button", { onClick: onClick, className: getButtonClasses(), children: type === 'dm' ? (_jsx("svg", { width: "28", height: "20", viewBox: "0 0 28 20", fill: "currentColor", children: _jsx("path", { d: "M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z", transform: "scale(0.85) translate(0, 0)" }) })) : type === 'action' ? (actionType === 'add' ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) })) : actionType === 'explore' ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.486 2 2 6.486 2 12s4.486 10 10 10 10-4.486 10-10S17.514 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm-3.146-5.351l2.78-1.042 1.042-2.78-2.78 1.042-1.042 2.78zM14.5 7.5l-2.5 5-5 2.5 2.5-5 5-2.5z" }) })) : (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" }) }))) : icon ? (_jsx("img", { src: icon.startsWith('http') ? icon : `/api/uploads/${icon}`, alt: name, className: "w-full h-full object-cover" })) : (_jsx("span", { className: "text-[16px] font-medium", children: firstLetter })) }) })] }));
|
||||
}
|
||||
export function ServerSidebar() {
|
||||
const servers = useServerStore((s) => s.servers);
|
||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||
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) => {
|
||||
setCurrentServer(serverId);
|
||||
setShowDms(false);
|
||||
navigate(`/channels/${serverId}`);
|
||||
};
|
||||
const handleDmClick = () => {
|
||||
setShowDms(true);
|
||||
setCurrentServer(null);
|
||||
navigate('/channels/@me');
|
||||
};
|
||||
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" }), _jsx(SidebarItem, { id: "explore", name: "Explore Discoverable Servers", active: false, onClick: () => { }, type: "action", actionType: "explore" })] }));
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
export function CreateChannelModal() {
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState('text');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
const closeModal = useUIStore((s) => s.closeModal);
|
||||
const createChannel = useServerStore((s) => s.createChannel);
|
||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||
const isOpen = activeModal === 'createChannel';
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!name.trim()) {
|
||||
setError('Channel name is required');
|
||||
return;
|
||||
}
|
||||
if (!currentServerId) {
|
||||
setError('No server selected');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await createChannel(currentServerId, name.trim(), type, topic.trim() || undefined);
|
||||
closeModal();
|
||||
setName('');
|
||||
setTopic('');
|
||||
setType('text');
|
||||
}
|
||||
catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create channel');
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Create Channel", children: _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Channel Type" }), _jsx("div", { className: "space-y-2", children: ['text', 'voice', 'video'].map((t) => (_jsxs("label", { className: `flex items-center gap-3 p-3 rounded cursor-pointer border ${type === t
|
||||
? 'border-discord-blurple bg-discord-bg-hover'
|
||||
: 'border-discord-bg-tertiary bg-discord-bg-secondary hover:bg-discord-bg-hover'}`, children: [_jsx("input", { type: "radio", name: "channelType", value: t, checked: type === t, onChange: () => setType(t), className: "hidden" }), _jsxs("div", { className: "w-5 h-5 text-discord-text-muted", children: [t === 'text' && (_jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) })), t === 'voice' && (_jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) })), t === 'video' && (_jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) }))] }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium text-discord-text-primary capitalize", children: t }), _jsxs("div", { className: "text-xs text-discord-text-muted", children: [t === 'text' && 'Send messages, images, and files', t === 'voice' && 'Hang out with voice and video', t === 'video' && 'Share your screen and camera'] })] })] }, t))) })] }), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Channel Name" }), _jsx("input", { type: "text", value: name, onChange: (e) => setName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "new-channel", autoFocus: true })] }), type === 'text' && (_jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Topic (optional)" }), _jsx("input", { type: "text", value: topic, onChange: (e) => setTopic(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "What's this channel about?" })] })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Creating...' : 'Create Channel' })] })] }) }));
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
export function CreateServerModal() {
|
||||
const [name, setName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const createServer = useServerStore((s) => s.createServer);
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
const closeModal = useUIStore((s) => s.closeModal);
|
||||
const navigate = useNavigate();
|
||||
const isOpen = activeModal === 'createServer';
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!name.trim()) {
|
||||
setError('Server name is required');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const server = await createServer(name.trim());
|
||||
closeModal();
|
||||
setName('');
|
||||
navigate(`/channels/${server.id}`);
|
||||
}
|
||||
catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create server');
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Create a Server", children: _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Server Name" }), _jsx("input", { type: "text", value: name, onChange: (e) => setName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "My Awesome Server", autoFocus: true })] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Creating...' : 'Create' })] })] }) }));
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
export function InviteModal() {
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
const closeModal = useUIStore((s) => s.closeModal);
|
||||
const generateInvite = useServerStore((s) => s.generateInvite);
|
||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||
const isOpen = activeModal === 'invite';
|
||||
const inviteUrl = inviteCode ? `${window.location.origin}/join/${inviteCode}` : '';
|
||||
useEffect(() => {
|
||||
if (isOpen && currentServerId) {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
generateInvite(currentServerId)
|
||||
.then(code => {
|
||||
setInviteCode(code);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate invite link');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [isOpen, currentServerId, generateInvite]);
|
||||
const handleCopy = async () => {
|
||||
if (!inviteUrl)
|
||||
return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
catch {
|
||||
const input = document.querySelector('.invite-code-input');
|
||||
if (input) {
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
return (_jsxs(Modal, { isOpen: isOpen, onClose: closeModal, title: "Invite Friends", children: [_jsx("p", { className: "text-discord-text-secondary text-sm mb-4", children: "Share this invite link with friends to let them join your server." }), error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "text", value: isLoading ? 'Generating...' : inviteUrl, readOnly: true, className: "invite-code-input flex-1 px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none font-mono text-xs" }), _jsx("button", { onClick: handleCopy, disabled: isLoading || !inviteUrl, className: `px-4 py-2 text-sm font-medium rounded transition-colors ${copied
|
||||
? 'bg-discord-green text-white'
|
||||
: 'bg-discord-blurple hover:bg-discord-blurple-hover text-white'}`, children: copied ? 'Copied!' : 'Copy' })] })] }));
|
||||
}
|
||||
@@ -1,95 +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 { InviteModal } from './InviteModal';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
// Mock the stores by spying on their getState
|
||||
beforeEach(() => {
|
||||
// Reset stores to default state
|
||||
useUIStore.setState({
|
||||
activeModal: null,
|
||||
modalData: {},
|
||||
});
|
||||
useServerStore.setState({
|
||||
currentServerId: null,
|
||||
servers: [],
|
||||
});
|
||||
});
|
||||
describe('InviteModal', () => {
|
||||
it('does not render when activeModal is not "invite"', () => {
|
||||
useUIStore.setState({ activeModal: null });
|
||||
render(_jsx(InviteModal, {}));
|
||||
expect(screen.queryByText('Invite Friends')).not.toBeInTheDocument();
|
||||
});
|
||||
it('calls generateInvite and displays the invite URL when opened', async () => {
|
||||
const mockGenerateInvite = vi.fn().mockResolvedValue('test-invite-code');
|
||||
useUIStore.setState({ activeModal: 'invite' });
|
||||
useServerStore.setState({
|
||||
currentServerId: 'server-123',
|
||||
generateInvite: mockGenerateInvite,
|
||||
});
|
||||
render(_jsx(InviteModal, {}));
|
||||
// Modal title should be visible
|
||||
expect(screen.getByText('Invite Friends')).toBeInTheDocument();
|
||||
// Should show "Generating..." initially
|
||||
expect(screen.getByDisplayValue('Generating...')).toBeInTheDocument();
|
||||
// Wait for the invite code to load
|
||||
await waitFor(() => {
|
||||
const input = screen.getByDisplayValue(/\/join\/test-invite-code/);
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
// generateInvite should have been called with the server ID
|
||||
expect(mockGenerateInvite).toHaveBeenCalledWith('server-123');
|
||||
});
|
||||
it('displays an error when generateInvite fails', async () => {
|
||||
const mockGenerateInvite = vi.fn().mockRejectedValue(new Error('Not authorized'));
|
||||
useUIStore.setState({ activeModal: 'invite' });
|
||||
useServerStore.setState({
|
||||
currentServerId: 'server-123',
|
||||
generateInvite: mockGenerateInvite,
|
||||
});
|
||||
render(_jsx(InviteModal, {}));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not authorized')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('Copy button is disabled while loading', () => {
|
||||
const mockGenerateInvite = vi.fn().mockReturnValue(new Promise(() => { })); // never resolves
|
||||
useUIStore.setState({ activeModal: 'invite' });
|
||||
useServerStore.setState({
|
||||
currentServerId: 'server-123',
|
||||
generateInvite: mockGenerateInvite,
|
||||
});
|
||||
render(_jsx(InviteModal, {}));
|
||||
const copyButton = screen.getByText('Copy');
|
||||
expect(copyButton).toBeDisabled();
|
||||
});
|
||||
it('Copy button calls clipboard.writeText with the invite URL', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockGenerateInvite = vi.fn().mockResolvedValue('abc123');
|
||||
const mockClipboard = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: mockClipboard },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
useUIStore.setState({ activeModal: 'invite' });
|
||||
useServerStore.setState({
|
||||
currentServerId: 'server-123',
|
||||
generateInvite: mockGenerateInvite,
|
||||
});
|
||||
render(_jsx(InviteModal, {}));
|
||||
// Wait for invite to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue(/\/join\/abc123/)).toBeInTheDocument();
|
||||
});
|
||||
// Click copy
|
||||
const copyButton = screen.getByText('Copy');
|
||||
await user.click(copyButton);
|
||||
expect(mockClipboard).toHaveBeenCalledWith(expect.stringContaining('/join/abc123'));
|
||||
// Button text should change to "Copied!"
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
export function JoinServerModal() {
|
||||
const { inviteCode: urlInviteCode } = useParams();
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
const closeModal = useUIStore((s) => s.closeModal);
|
||||
const joinByCode = useServerStore((s) => s.joinByCode);
|
||||
const navigate = useNavigate();
|
||||
const isOpen = activeModal === 'joinServer';
|
||||
useEffect(() => {
|
||||
if (isOpen && urlInviteCode) {
|
||||
setInviteCode(urlInviteCode);
|
||||
}
|
||||
}, [isOpen, urlInviteCode]);
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
const code = inviteCode.trim();
|
||||
if (!code) {
|
||||
setError('Invite code is required');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const server = await joinByCode(code);
|
||||
closeModal();
|
||||
setInviteCode('');
|
||||
navigate(`/channels/${server.id}`);
|
||||
}
|
||||
catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join server');
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Join a Server", children: _jsxs("form", { onSubmit: handleSubmit, children: [_jsx("p", { className: "text-discord-text-secondary text-sm mb-4", children: "Enter an invite code to join an existing server." }), error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Invite Code" }), _jsx("input", { type: "text", value: inviteCode, onChange: (e) => setInviteCode(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "e.g. abc123", autoFocus: true })] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Joining...' : 'Join Server' })] })] }) }));
|
||||
}
|
||||
@@ -1,86 +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 { JoinServerModal } from './JoinServer';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
beforeEach(() => {
|
||||
mockNavigate.mockClear();
|
||||
useUIStore.setState({ activeModal: null });
|
||||
useServerStore.setState({
|
||||
servers: [],
|
||||
currentServerId: null,
|
||||
});
|
||||
});
|
||||
function renderModal() {
|
||||
return render(_jsx(MemoryRouter, { children: _jsx(JoinServerModal, {}) }));
|
||||
}
|
||||
describe('JoinServerModal', () => {
|
||||
it('does not render when activeModal is not "joinServer"', () => {
|
||||
useUIStore.setState({ activeModal: null });
|
||||
renderModal();
|
||||
expect(screen.queryByText('Join a Server')).not.toBeInTheDocument();
|
||||
});
|
||||
it('renders the form when opened', () => {
|
||||
useUIStore.setState({ activeModal: 'joinServer' });
|
||||
renderModal();
|
||||
expect(screen.getByText('Join a Server')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('e.g. abc123')).toBeInTheDocument();
|
||||
expect(screen.getByText('Join Server')).toBeInTheDocument();
|
||||
});
|
||||
it('shows validation error when submitting empty code', async () => {
|
||||
const user = userEvent.setup();
|
||||
useUIStore.setState({ activeModal: 'joinServer' });
|
||||
renderModal();
|
||||
const submitButton = screen.getByText('Join Server');
|
||||
await user.click(submitButton);
|
||||
expect(screen.getByText('Invite code is required')).toBeInTheDocument();
|
||||
});
|
||||
it('calls joinByCode with the entered invite code and navigates on success', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockJoinByCode = vi.fn().mockResolvedValue({ id: 'new-server-id', name: 'Test Server' });
|
||||
useUIStore.setState({ activeModal: 'joinServer' });
|
||||
useServerStore.setState({ joinByCode: mockJoinByCode });
|
||||
renderModal();
|
||||
// Type invite code
|
||||
const input = screen.getByPlaceholderText('e.g. abc123');
|
||||
await user.type(input, 'my-invite-code');
|
||||
// Click join
|
||||
const submitButton = screen.getByText('Join Server');
|
||||
await user.click(submitButton);
|
||||
// joinByCode should be called with the code
|
||||
await waitFor(() => {
|
||||
expect(mockJoinByCode).toHaveBeenCalledWith('my-invite-code');
|
||||
});
|
||||
// Should navigate to the new server
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/channels/new-server-id');
|
||||
});
|
||||
// Modal should close (activeModal becomes null)
|
||||
expect(useUIStore.getState().activeModal).toBeNull();
|
||||
});
|
||||
it('shows error message when joinByCode fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockJoinByCode = vi.fn().mockRejectedValue(new Error('Invalid invite code'));
|
||||
useUIStore.setState({ activeModal: 'joinServer' });
|
||||
useServerStore.setState({ joinByCode: mockJoinByCode });
|
||||
renderModal();
|
||||
const input = screen.getByPlaceholderText('e.g. abc123');
|
||||
await user.type(input, 'bad-code');
|
||||
const submitButton = screen.getByText('Join Server');
|
||||
await user.click(submitButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid invite code')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { api } from '../../api/client';
|
||||
export function NewDmModal() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
const closeModal = useUIStore((s) => s.closeModal);
|
||||
const addDmChannel = useServerStore((s) => s.addDmChannel);
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef(null);
|
||||
const searchTimer = useRef();
|
||||
const isOpen = activeModal === 'newDm';
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setError('');
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
const handleSearch = (value) => {
|
||||
setQuery(value);
|
||||
setError('');
|
||||
if (searchTimer.current) {
|
||||
clearTimeout(searchTimer.current);
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
searchTimer.current = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const users = await api.social.search(value.trim());
|
||||
setResults(users);
|
||||
}
|
||||
catch {
|
||||
setResults([]);
|
||||
}
|
||||
finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
const handleSelectUser = async (user) => {
|
||||
setError('');
|
||||
try {
|
||||
const channel = await api.dm.create({ userId: user.id });
|
||||
addDmChannel(channel);
|
||||
closeModal();
|
||||
useUIStore.getState().setShowDms(true);
|
||||
navigate(`/channels/@me/${channel.id}`);
|
||||
}
|
||||
catch (err) {
|
||||
setError(err.message || 'Failed to create DM');
|
||||
}
|
||||
};
|
||||
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "New Direct Message", children: _jsxs("div", { className: "space-y-3", children: [_jsx("input", { ref: inputRef, type: "text", value: query, onChange: (e) => handleSearch(e.target.value), placeholder: "Search for a user...", className: "w-full px-3 py-2 bg-discord-bg-tertiary text-discord-text-primary placeholder-discord-text-muted/60 rounded-[4px] text-[14px] outline-none focus:ring-1 focus:ring-discord-blurple" }), error && (_jsx("p", { className: "text-discord-red text-[13px]", children: error })), _jsxs("div", { className: "max-h-[300px] overflow-y-auto space-y-[2px]", children: [isSearching && (_jsx("div", { className: "py-4 text-center text-discord-text-muted text-[14px]", children: "Searching..." })), !isSearching && query.trim().length >= 2 && results.length === 0 && (_jsx("div", { className: "py-4 text-center text-discord-text-muted text-[14px]", children: "No users found" })), results.map((user) => (_jsxs("button", { onClick: () => handleSelectUser(user), className: "w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-discord-modifier-hover transition-colors text-left", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 36, status: user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-medium text-discord-text-primary truncate", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate", children: ["@", user.username] })] })] }, user.id)))] })] }) }));
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { api } from '../../api/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
export function ServerSettingsModal() {
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
const closeModal = useUIStore((s) => s.closeModal);
|
||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||
const servers = useServerStore((s) => s.servers);
|
||||
const members = useServerStore((s) => s.members);
|
||||
const updateServer = useServerStore((s) => s.updateServer);
|
||||
const deleteServer = useServerStore((s) => s.deleteServer);
|
||||
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState('overview');
|
||||
const [serverName, setServerName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const isOpen = activeModal === 'serverSettings';
|
||||
const server = servers.find(s => s.id === currentServerId);
|
||||
const isOwnerUser = server?.ownerId === currentUser?.id;
|
||||
React.useEffect(() => {
|
||||
if (server) {
|
||||
setServerName(server.name);
|
||||
}
|
||||
}, [server]);
|
||||
if (!server || !currentServerId)
|
||||
return null;
|
||||
const handleSave = async () => {
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await updateServer(currentServerId, { name: serverName.trim() });
|
||||
setIsLoading(false);
|
||||
}
|
||||
catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update server');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
const handleDelete = async () => {
|
||||
if (!confirmDelete) {
|
||||
setConfirmDelete(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteServer(currentServerId);
|
||||
closeModal();
|
||||
navigate('/channels/@me');
|
||||
}
|
||||
catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete server');
|
||||
}
|
||||
};
|
||||
const handleRoleChange = async (userId, role) => {
|
||||
try {
|
||||
await api.servers.updateMember(currentServerId, userId, { role });
|
||||
await loadServerDetail(currentServerId);
|
||||
}
|
||||
catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update role');
|
||||
}
|
||||
};
|
||||
const handleKick = async (userId) => {
|
||||
try {
|
||||
await api.servers.removeMember(currentServerId, userId);
|
||||
await loadServerDetail(currentServerId);
|
||||
}
|
||||
catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to kick member');
|
||||
}
|
||||
};
|
||||
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Server Settings", maxWidth: "max-w-xl", children: _jsxs("div", { className: "flex gap-4", children: [_jsxs("div", { className: "w-32 flex-shrink-0 space-y-1", children: [_jsx("button", { onClick: () => setTab('overview'), className: `w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${tab === 'overview' ? 'bg-discord-bg-active text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: "Overview" }), _jsx("button", { onClick: () => setTab('members'), className: `w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${tab === 'members' ? 'bg-discord-bg-active text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: "Members" })] }), _jsxs("div", { className: "flex-1 min-w-0", children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm", children: error })), tab === 'overview' && (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Server Name" }), _jsx("input", { type: "text", value: serverName, onChange: (e) => setServerName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", disabled: !isOwnerUser })] }), isOwnerUser && (_jsxs(_Fragment, { children: [_jsx("button", { onClick: handleSave, disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Saving...' : 'Save Changes' }), _jsxs("div", { className: "pt-4 border-t border-discord-bg-tertiary", children: [_jsx("h3", { className: "text-sm font-bold text-discord-red mb-2", children: "Danger Zone" }), _jsx("button", { onClick: handleDelete, className: "px-4 py-2 bg-discord-red hover:bg-discord-red-hover text-white text-sm font-medium rounded transition-colors", children: confirmDelete ? 'Click again to confirm deletion' : 'Delete Server' })] })] }))] })), tab === 'members' && (_jsx("div", { className: "space-y-2 max-h-[400px] overflow-y-auto scrollbar-thin", children: members.map((member) => {
|
||||
const displayName = member.user.displayName ?? member.user.username;
|
||||
return (_jsxs("div", { className: "flex items-center justify-between p-2 rounded hover:bg-discord-bg-hover", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: member.user.status }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium", children: displayName }), _jsx("div", { className: "text-xs text-discord-text-muted capitalize", children: member.role })] })] }), isOwnerUser && member.userId !== currentUser?.id && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("select", { value: member.role, onChange: (e) => handleRoleChange(member.userId, e.target.value), className: "px-2 py-1 bg-discord-bg-tertiary rounded text-xs text-discord-text-secondary outline-none", children: [_jsx("option", { value: "member", children: "Member" }), _jsx("option", { value: "admin", children: "Admin" })] }), _jsx("button", { onClick: () => handleKick(member.userId), className: "px-2 py-1 text-xs text-discord-red hover:bg-discord-red/10 rounded transition-colors", children: "Kick" })] }))] }, member.userId));
|
||||
}) }))] })] }) }));
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,40 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
const statusColors = {
|
||||
online: 'bg-discord-green',
|
||||
idle: 'bg-discord-yellow',
|
||||
dnd: 'bg-discord-red',
|
||||
offline: 'bg-discord-text-muted',
|
||||
};
|
||||
export function Avatar({ src, name, size = 40, status, className = '', onClick, user }) {
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
const initials = name.charAt(0).toUpperCase();
|
||||
const fontSize = size < 32 ? 'text-xs' : size < 48 ? 'text-sm' : 'text-lg';
|
||||
const handleClick = (e) => {
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
else if (user) {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(user, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.right + 16,
|
||||
});
|
||||
}
|
||||
};
|
||||
return (_jsxs("div", { className: `relative inline-flex flex-shrink-0 ${(onClick || user) ? 'cursor-pointer' : ''} ${className}`, style: { width: size, height: size }, onClick: handleClick, children: [src ? (_jsx("img", { src: src.startsWith('http') ? src : `/api/uploads/${src}`, alt: name, className: "w-full h-full rounded-full object-cover", onError: (e) => {
|
||||
e.target.style.display = 'none';
|
||||
const parent = e.target.parentElement;
|
||||
if (parent) {
|
||||
const fallback = parent.querySelector('.avatar-fallback');
|
||||
if (fallback)
|
||||
fallback.style.display = 'flex';
|
||||
}
|
||||
} })) : null, _jsx("div", { className: `avatar-fallback w-full h-full rounded-full bg-discord-blurple flex items-center justify-center ${fontSize} font-semibold text-white ${src ? 'hidden' : 'flex'}`, style: src ? { display: 'none' } : undefined, children: initials }), status && (_jsx("div", { className: `absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-discord-bg-secondary ${statusColors[status] ?? 'bg-discord-text-muted'}`, style: {
|
||||
width: size * 0.35,
|
||||
height: size * 0.35,
|
||||
minWidth: 12,
|
||||
minHeight: 12,
|
||||
} }))] }));
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
export function ContextMenu({ items, children }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const menuRef = useRef(null);
|
||||
const handleContextMenu = (e) => {
|
||||
e.preventDefault();
|
||||
setPosition({ x: e.clientX, y: e.clientY });
|
||||
setIsOpen(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
const handleClick = () => setIsOpen(false);
|
||||
const handleScroll = () => setIsOpen(false);
|
||||
if (isOpen) {
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('scroll', handleScroll, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('scroll', handleScroll, true);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
// Adjust position to keep menu in viewport
|
||||
useEffect(() => {
|
||||
if (isOpen && menuRef.current) {
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
const newPosition = { ...position };
|
||||
if (rect.right > window.innerWidth) {
|
||||
newPosition.x = window.innerWidth - rect.width - 8;
|
||||
}
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
newPosition.y = window.innerHeight - rect.height - 8;
|
||||
}
|
||||
if (newPosition.x !== position.x || newPosition.y !== position.y) {
|
||||
setPosition(newPosition);
|
||||
}
|
||||
}
|
||||
}, [isOpen, position]);
|
||||
return (_jsxs(_Fragment, { children: [_jsx("div", { onContextMenu: handleContextMenu, children: children }), isOpen && (_jsx("div", { ref: menuRef, className: "fixed z-50 min-w-[180px] py-1.5 bg-discord-bg-floating rounded-md shadow-elevation-high animate-fade-in", style: { left: position.x, top: position.y }, children: items.map((item, i) => (_jsxs("button", { className: `w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 ${item.danger
|
||||
? 'text-discord-red hover:bg-discord-red hover:text-white'
|
||||
: 'text-discord-text-secondary hover:bg-discord-blurple hover:text-white'}`, style: { width: 'calc(100% - 12px)' }, onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
item.onClick();
|
||||
setIsOpen(false);
|
||||
}, children: [item.icon && _jsx("span", { className: "w-4 h-4", children: item.icon }), item.label] }, i))) }))] }));
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
export function LoadingSpinner({ size = 40, className = '' }) {
|
||||
return (_jsx("div", { className: `flex items-center justify-center ${className}`, children: _jsxs("svg", { className: "animate-spin", width: size, height: size, 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }) }));
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useEffect, useCallback } from 'react';
|
||||
export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' }) {
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [isOpen, handleKeyDown]);
|
||||
if (!isOpen)
|
||||
return null;
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center animate-fade-in", children: [_jsx("div", { className: "absolute inset-0 bg-discord-bg-overlay", onClick: onClose }), _jsxs("div", { className: `relative ${maxWidth} w-full mx-4 bg-discord-bg-surface rounded-lg shadow-xl animate-slide-up`, children: [title && (_jsxs("div", { className: "flex items-center justify-between px-4 pt-4", children: [_jsx("h2", { className: "text-xl font-bold text-discord-text-primary", children: title }), _jsx("button", { onClick: onClose, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors p-1", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] })), _jsx("div", { className: "p-4", children: children })] })] }));
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
export function Tooltip({ content, children, position = 'right', delay = 200 }) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const timeoutRef = useRef();
|
||||
const show = () => {
|
||||
timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
|
||||
};
|
||||
const hide = () => {
|
||||
if (timeoutRef.current)
|
||||
clearTimeout(timeoutRef.current);
|
||||
setIsVisible(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current)
|
||||
clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
const positionClasses = {
|
||||
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
||||
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||
};
|
||||
return (_jsxs("div", { className: "relative inline-flex", onMouseEnter: show, onMouseLeave: hide, children: [children, isVisible && (_jsx("div", { className: `absolute z-50 px-3 py-1.5 text-sm font-medium text-discord-text-primary bg-discord-bg-floating rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none ${positionClasses[position]}`, children: content }))] }));
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { api } from '../../api/client';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
export function UserProfilePopout({ user, onClose, position }) {
|
||||
const navigate = useNavigate();
|
||||
const addDmChannel = useServerStore((s) => s.addDmChannel);
|
||||
const displayName = user.displayName ?? user.username;
|
||||
const handleSendMessage = async () => {
|
||||
try {
|
||||
const channel = await api.dm.create({ userId: user.id });
|
||||
addDmChannel(channel);
|
||||
useUIStore.getState().setShowDms(true);
|
||||
onClose();
|
||||
navigate(`/channels/@me/${channel.id}`);
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to create DM channel:', err);
|
||||
}
|
||||
};
|
||||
return (_jsxs("div", { className: "fixed z-50 w-[300px] bg-discord-bg-floating rounded-[8px] shadow-elevation-high overflow-hidden animate-fade-in select-none", style: position ? { top: position.top, left: position.left } : { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }, children: [_jsx("div", { className: "h-[60px] bg-discord-blurple" }), _jsxs("div", { className: "px-4 pb-4 relative", children: [_jsx("div", { className: "absolute -top-8 left-4 rounded-full border-[6px] border-discord-bg-floating bg-discord-bg-floating", children: _jsx(Avatar, { src: user.avatar, name: displayName, size: 80, status: user.status }) }), _jsxs("div", { className: "mt-12 bg-discord-bg-tertiary rounded-[8px] p-3", children: [_jsx("div", { className: "text-[20px] font-bold text-discord-text-header leading-tight mb-1", children: displayName }), _jsxs("div", { className: "text-[14px] text-discord-text-normal font-medium mb-3", children: ["@", user.username] }), _jsx("div", { className: "w-full h-[1px] bg-discord-modifier-accent mb-3" }), _jsxs("div", { className: "mb-3", children: [_jsx("div", { className: "text-[12px] font-bold text-discord-text-header uppercase mb-1", children: "Opencord Member Since" }), _jsx("div", { className: "text-[12px] text-discord-text-normal font-medium", children: new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) })] }), user.customStatus && (_jsxs("div", { className: "mb-3", children: [_jsx("div", { className: "text-[12px] font-bold text-discord-text-header uppercase mb-1", children: "Status" }), _jsx("div", { className: "text-[14px] text-discord-text-normal", children: user.customStatus })] }))] })] }), _jsx("div", { className: "px-4 pb-4", children: _jsx("button", { onClick: handleSendMessage, className: "w-full py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-[14px] font-medium rounded-[4px] transition-colors", children: "Send Message" }) })] }));
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,61 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import React from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
|
||||
/**
|
||||
* Manages a Web Audio pipeline for a single audio track.
|
||||
* Renders a muted <audio> element as a Chrome keep-alive for the WebRTC track.
|
||||
* All actual audio output goes through Web Audio (GainNode -> ctx.destination).
|
||||
*/
|
||||
function AudioTrackElement({ track, globalVolume, perSourceVolume, isDeafened, isMuted, attenuate, someoneIsSpeaking, attenuationEnabled, attenuationStrength, }) {
|
||||
const globalScale = globalVolume / 100;
|
||||
const sourceScale = perSourceVolume / 100;
|
||||
let finalVolume = globalScale * sourceScale;
|
||||
// Stream attenuation: duck when someone is speaking
|
||||
if (attenuate && attenuationEnabled && someoneIsSpeaking) {
|
||||
finalVolume *= 1 - attenuationStrength / 100;
|
||||
}
|
||||
const shouldMute = isDeafened || isMuted;
|
||||
const audioRef = useAudioTrackPlayer({
|
||||
track,
|
||||
volume: finalVolume,
|
||||
muted: shouldMute,
|
||||
});
|
||||
// The <audio> element is always muted — it serves only as a Chrome
|
||||
// keep-alive so Chrome continues processing the WebRTC track.
|
||||
// Real audio output goes through the Web Audio pipeline.
|
||||
return _jsx("audio", { ref: audioRef, autoPlay: true, playsInline: true, "data-opencord": "keepalive" });
|
||||
}
|
||||
/**
|
||||
* Always-mounted component that manages Web Audio pipelines
|
||||
* for every remote participant's mic and screen audio tracks.
|
||||
*
|
||||
* Rendered in AppLayout alongside PictureInPicture and SoundController.
|
||||
* Never unmounts during navigation, so audio persists even when
|
||||
* VoiceGrid / VoiceUser / StreamTile are not rendered.
|
||||
*
|
||||
* All audio is routed through the Web Audio API (GainNodes connected
|
||||
* to a shared AudioContext.destination). Muted <audio> elements serve
|
||||
* as Chrome keep-alives for WebRTC tracks but produce no sound.
|
||||
*/
|
||||
export function GlobalAudioRenderer() {
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
|
||||
const streamMutes = useVoiceStore((s) => s.streamMutes);
|
||||
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
||||
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
|
||||
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
||||
// Determine if someone is currently speaking (for stream attenuation)
|
||||
const someoneIsSpeaking = participants.some((p) => !p.isLocal && p.isSpeaking);
|
||||
// Only render audio for remote participants
|
||||
const remoteParticipants = participants.filter((p) => !p.isLocal);
|
||||
return (_jsx(_Fragment, { children: remoteParticipants.map((p) => {
|
||||
const micVolume = participantVolumes.get(p.userId) ?? 100;
|
||||
const streamVol = streamVolumes.get(p.userId) ?? 100;
|
||||
const isStreamMuted = streamMutes.get(p.userId) ?? false;
|
||||
return (_jsxs(React.Fragment, { children: [p.audioTrack && (_jsx(AudioTrackElement, { track: p.audioTrack, globalVolume: outputVolume, perSourceVolume: micVolume, isDeafened: isDeafened, isMuted: false, attenuate: false, someoneIsSpeaking: false, attenuationEnabled: false, attenuationStrength: 0 })), p.screenAudioTrack && watchingStreams.has(p.userId) && (_jsx(AudioTrackElement, { track: p.screenAudioTrack, globalVolume: outputVolume, perSourceVolume: streamVol, isDeafened: isDeafened, isMuted: isStreamMuted, attenuate: true, someoneIsSpeaking: someoneIsSpeaking, attenuationEnabled: streamAttenuationEnabled, attenuationStrength: streamAttenuationStrength }))] }, p.identity));
|
||||
}) }));
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
export function IncomingCallModal() {
|
||||
const incomingCall = useVoiceStore((s) => s.incomingCall);
|
||||
const setIncomingCall = useVoiceStore((s) => s.setIncomingCall);
|
||||
const timerRef = useRef(null);
|
||||
// Auto-dismiss after 30 seconds
|
||||
useEffect(() => {
|
||||
if (incomingCall) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
// Auto-reject after timeout
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
|
||||
setIncomingCall(null);
|
||||
}, 30000);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [incomingCall, setIncomingCall]);
|
||||
if (!incomingCall)
|
||||
return null;
|
||||
const handleAccept = () => {
|
||||
if (timerRef.current)
|
||||
clearTimeout(timerRef.current);
|
||||
wsSend({ type: 'dm_call_accept', dmChannelId: incomingCall.dmChannelId });
|
||||
};
|
||||
const handleDecline = () => {
|
||||
if (timerRef.current)
|
||||
clearTimeout(timerRef.current);
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
|
||||
setIncomingCall(null);
|
||||
};
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-[100] flex items-center justify-center", children: [_jsx("div", { className: "absolute inset-0 bg-black/60" }), _jsxs("div", { className: "relative bg-[#1e1f22] rounded-lg shadow-2xl w-[340px] overflow-hidden", children: [_jsxs("div", { className: "absolute inset-0 overflow-hidden", children: [_jsx("div", { className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200px] h-[200px] rounded-full bg-discord-green/5 animate-ping", style: { animationDuration: '2s' } }), _jsx("div", { className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150px] h-[150px] rounded-full bg-discord-green/10 animate-ping", style: { animationDuration: '2s', animationDelay: '0.5s' } })] }), _jsxs("div", { className: "relative p-8 flex flex-col items-center gap-4", children: [_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-20 h-20 rounded-full bg-discord-blurple flex items-center justify-center text-white text-3xl font-bold", children: incomingCall.callerName.charAt(0).toUpperCase() }), _jsx("div", { className: "absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-discord-green flex items-center justify-center", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "white", children: _jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }) })] }), _jsxs("div", { className: "text-center", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header", children: incomingCall.callerName }), _jsx("p", { className: "text-[14px] text-discord-text-muted mt-1", children: "Incoming Voice Call..." })] }), _jsxs("div", { className: "flex items-center gap-6 mt-2", children: [_jsx("button", { onClick: handleDecline, className: "w-14 h-14 rounded-full bg-discord-red hover:bg-discord-red/80 flex items-center justify-center transition-colors group", title: "Decline", children: _jsx("svg", { width: "28", height: "28", viewBox: "0 0 24 24", fill: "white", className: "group-hover:scale-110 transition-transform", children: _jsx("path", { d: "M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" }) }) }), _jsx("button", { onClick: handleAccept, className: "w-14 h-14 rounded-full bg-discord-green hover:bg-discord-green/80 flex items-center justify-center transition-colors group", title: "Accept", children: _jsx("svg", { width: "28", height: "28", viewBox: "0 0 24 24", fill: "white", className: "group-hover:scale-110 transition-transform", children: _jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }) })] })] })] })] }));
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
const PIP_WIDTH = 320;
|
||||
const PIP_HEIGHT = 180;
|
||||
const PIP_MARGIN = 16;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
function selectPipStream(participants, focusedId, watchingStreams) {
|
||||
// Priority 1: Screen share from a user we're watching
|
||||
const screenSharer = participants.find(p => p.screenTrack !== null && watchingStreams.has(p.userId));
|
||||
if (screenSharer?.screenTrack) {
|
||||
return { participant: screenSharer, track: screenSharer.screenTrack, type: 'screen' };
|
||||
}
|
||||
// Priority 2: Focused participant with camera
|
||||
if (focusedId) {
|
||||
const focused = participants.find(p => p.identity === focusedId);
|
||||
if (focused?.videoTrack) {
|
||||
return { participant: focused, track: focused.videoTrack, type: 'camera' };
|
||||
}
|
||||
}
|
||||
// Priority 3: Remote participant with camera
|
||||
const remoteWithCamera = participants.find(p => !p.isLocal && p.videoTrack !== null);
|
||||
if (remoteWithCamera?.videoTrack) {
|
||||
return { participant: remoteWithCamera, track: remoteWithCamera.videoTrack, type: 'camera' };
|
||||
}
|
||||
// Priority 4: Local participant with camera
|
||||
const localWithCamera = participants.find(p => p.isLocal && p.videoTrack !== null);
|
||||
if (localWithCamera?.videoTrack) {
|
||||
return { participant: localWithCamera, track: localWithCamera.videoTrack, type: 'camera' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function PictureInPicture() {
|
||||
const navigate = useNavigate();
|
||||
const videoRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
// Store state
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
||||
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
||||
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
||||
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
||||
const pipCollapsed = useUIStore((s) => s.pipCollapsed);
|
||||
const setPipCollapsed = useUIStore((s) => s.setPipCollapsed);
|
||||
const channelToServerMap = useServerStore((s) => s.channelToServerMap);
|
||||
const channels = useServerStore((s) => s.channels);
|
||||
// Drag state
|
||||
const [position, setPosition] = useState({ x: -1, y: -1 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const dragStartPos = useRef({ x: 0, y: 0 });
|
||||
const hasMoved = useRef(false);
|
||||
// Reset pipCollapsed when joining a new call
|
||||
const prevVoiceChannel = useRef(currentVoiceChannelId);
|
||||
const prevDmCall = useRef(activeDmCall?.dmChannelId ?? null);
|
||||
useEffect(() => {
|
||||
const voiceChanged = currentVoiceChannelId !== prevVoiceChannel.current;
|
||||
const dmChanged = (activeDmCall?.dmChannelId ?? null) !== prevDmCall.current;
|
||||
prevVoiceChannel.current = currentVoiceChannelId;
|
||||
prevDmCall.current = activeDmCall?.dmChannelId ?? null;
|
||||
if ((voiceChanged && currentVoiceChannelId) || (dmChanged && activeDmCall)) {
|
||||
setPipCollapsed(false);
|
||||
}
|
||||
}, [currentVoiceChannelId, activeDmCall, setPipCollapsed]);
|
||||
// Visibility
|
||||
const isInServerVoice = currentVoiceChannelId !== null && currentChannelId !== currentVoiceChannelId;
|
||||
const isInDmCall = activeDmCall !== null && currentChannelId !== activeDmCall.dmChannelId;
|
||||
const shouldShow = (isInServerVoice || isInDmCall) && !voiceFullscreen && !pipCollapsed;
|
||||
// Stream selection
|
||||
const selectedStream = useMemo(() => selectPipStream(participants, focusedParticipantId, watchingStreams), [participants, focusedParticipantId, watchingStreams]);
|
||||
// Fallback participant for avatar (most relevant remote, or first participant)
|
||||
const fallbackParticipant = useMemo(() => {
|
||||
const speaking = participants.find(p => !p.isLocal && p.isSpeaking);
|
||||
if (speaking)
|
||||
return speaking;
|
||||
const remote = participants.find(p => !p.isLocal);
|
||||
if (remote)
|
||||
return remote;
|
||||
return participants[0] ?? null;
|
||||
}, [participants]);
|
||||
// Channel name for display
|
||||
const channelName = useMemo(() => {
|
||||
if (currentVoiceChannelId) {
|
||||
const ch = channels.find(c => c.id === currentVoiceChannelId);
|
||||
return ch?.name ?? 'Voice';
|
||||
}
|
||||
return 'Call';
|
||||
}, [currentVoiceChannelId, channels]);
|
||||
// Video track attachment
|
||||
// shouldShow in deps ensures re-run when PiP becomes visible (videoRef was null before)
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl)
|
||||
return;
|
||||
if (selectedStream?.track) {
|
||||
videoEl.srcObject = new MediaStream([selectedStream.track]);
|
||||
}
|
||||
else {
|
||||
videoEl.srcObject = null;
|
||||
}
|
||||
}, [selectedStream?.track, shouldShow]);
|
||||
// Initialize position to bottom-right
|
||||
useEffect(() => {
|
||||
if (shouldShow && position.x === -1) {
|
||||
setPosition({
|
||||
x: window.innerWidth - PIP_WIDTH - PIP_MARGIN,
|
||||
y: window.innerHeight - PIP_HEIGHT - PIP_MARGIN,
|
||||
});
|
||||
}
|
||||
}, [shouldShow, position.x]);
|
||||
// Window resize: keep PiP in bounds
|
||||
useEffect(() => {
|
||||
if (!shouldShow)
|
||||
return;
|
||||
const handleResize = () => {
|
||||
setPosition(prev => ({
|
||||
x: Math.max(PIP_MARGIN, Math.min(window.innerWidth - PIP_WIDTH - PIP_MARGIN, prev.x)),
|
||||
y: Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, prev.y)),
|
||||
}));
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [shouldShow]);
|
||||
// Snap to nearest horizontal edge
|
||||
const snapToEdge = useCallback((currentX, currentY) => {
|
||||
const centerX = currentX + PIP_WIDTH / 2;
|
||||
const screenMidX = window.innerWidth / 2;
|
||||
const targetX = centerX < screenMidX
|
||||
? PIP_MARGIN
|
||||
: window.innerWidth - PIP_WIDTH - PIP_MARGIN;
|
||||
const clampedY = Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, currentY));
|
||||
setPosition({ x: targetX, y: clampedY });
|
||||
}, []);
|
||||
// Drag handlers
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (e.target.closest('[data-pip-action]'))
|
||||
return;
|
||||
setIsDragging(true);
|
||||
hasMoved.current = false;
|
||||
dragOffset.current = { x: e.clientX - position.x, y: e.clientY - position.y };
|
||||
dragStartPos.current = { x: e.clientX, y: e.clientY };
|
||||
containerRef.current?.setPointerCapture(e.pointerId);
|
||||
}, [position]);
|
||||
const handlePointerMove = useCallback((e) => {
|
||||
if (!isDragging)
|
||||
return;
|
||||
const dx = Math.abs(e.clientX - dragStartPos.current.x);
|
||||
const dy = Math.abs(e.clientY - dragStartPos.current.y);
|
||||
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
|
||||
hasMoved.current = true;
|
||||
}
|
||||
const newX = Math.max(PIP_MARGIN, Math.min(window.innerWidth - PIP_WIDTH - PIP_MARGIN, e.clientX - dragOffset.current.x));
|
||||
const newY = Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, e.clientY - dragOffset.current.y));
|
||||
setPosition({ x: newX, y: newY });
|
||||
}, [isDragging]);
|
||||
const handlePointerUp = useCallback((e) => {
|
||||
if (!isDragging)
|
||||
return;
|
||||
setIsDragging(false);
|
||||
containerRef.current?.releasePointerCapture(e.pointerId);
|
||||
if (!hasMoved.current) {
|
||||
// Click — navigate back to voice channel
|
||||
if (activeDmCall) {
|
||||
navigate(`/channels/@me/${activeDmCall.dmChannelId}`);
|
||||
}
|
||||
else if (currentVoiceChannelId) {
|
||||
const serverId = channelToServerMap.get(currentVoiceChannelId);
|
||||
if (serverId) {
|
||||
navigate(`/channels/${serverId}/${currentVoiceChannelId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Drag ended — snap to edge
|
||||
snapToEdge(position.x, position.y);
|
||||
}
|
||||
}, [isDragging, activeDmCall, currentVoiceChannelId, channelToServerMap, navigate, snapToEdge, position]);
|
||||
const handleClose = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
setPipCollapsed(true);
|
||||
}, [setPipCollapsed]);
|
||||
if (!shouldShow)
|
||||
return null;
|
||||
const displayParticipant = selectedStream?.participant ?? fallbackParticipant;
|
||||
const displayName = displayParticipant
|
||||
? (displayParticipant.isLocal ? `${displayParticipant.username} (You)` : displayParticipant.username)
|
||||
: channelName;
|
||||
const hasVideo = selectedStream !== null;
|
||||
const isScreen = selectedStream?.type === 'screen';
|
||||
return (_jsxs("div", { ref: containerRef, className: `fixed z-[40] overflow-hidden rounded-lg shadow-2xl ring-1 ring-white/10 bg-[#080a0b] select-none ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`, style: {
|
||||
width: PIP_WIDTH,
|
||||
height: PIP_HEIGHT,
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
transition: isDragging ? 'none' : 'left 0.2s ease, top 0.2s ease',
|
||||
touchAction: 'none',
|
||||
}, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, children: [hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-cover", style: { imageRendering: 'auto' } })) : (_jsx("div", { className: "w-full h-full flex items-center justify-center bg-[#1e1f22]", children: displayParticipant ? (_jsxs("div", { className: "relative", children: [_jsx(Avatar, { name: displayParticipant.username, size: 64 }), displayParticipant.isSpeaking && (_jsx("div", { className: "absolute -inset-1 rounded-full ring-2 ring-discord-green animate-pulse" }))] })) : (_jsxs("div", { className: "flex items-center gap-2 text-discord-text-muted", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("span", { className: "text-sm font-medium", children: channelName })] })) })), isScreen && (_jsx("div", { className: "absolute top-2 left-2 px-1.5 py-0.5 bg-discord-red rounded text-[11px] font-bold text-white uppercase tracking-wide", children: "LIVE" })), _jsx("button", { "data-pip-action": "close", onClick: handleClose, className: "absolute top-2 right-2 w-6 h-6 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-colors", children: _jsx("svg", { width: "12", height: "12", 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: "absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 to-transparent", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-white text-xs font-semibold truncate", children: displayName }), displayParticipant?.isSpeaking && (_jsx("div", { className: "w-2 h-2 rounded-full bg-discord-green flex-shrink-0 animate-pulse" }))] }), _jsx("div", { className: "text-white/50 text-[10px] truncate", children: channelName })] }), _jsx("div", { className: "absolute bottom-2 right-2 w-5 h-5 flex items-center justify-center text-white/40", children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21 11V3h-8l3.29 3.29-10 10L3 13v8h8l-3.29-3.29 10-10z" }) }) })] }));
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
import { AudioManager } from '../../audio/AudioManager';
|
||||
export function SoundController() {
|
||||
const audioManager = AudioManager.getInstance();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const { isConnected: isWsConnected } = useWebSocket();
|
||||
// Refs to track previous states
|
||||
const isInitialMount = useRef(true);
|
||||
const prevIsWsConnected = useRef(false);
|
||||
const prevIsMuted = useRef(useVoiceStore.getState().isMuted);
|
||||
const prevIsDeafened = useRef(useVoiceStore.getState().isDeafened);
|
||||
const prevIsCameraOn = useRef(useVoiceStore.getState().isCameraOn);
|
||||
const prevIsScreenSharing = useRef(useVoiceStore.getState().isScreenSharing);
|
||||
const prevIsConnected = useRef(useVoiceStore.getState().isLiveKitConnected);
|
||||
const prevParticipantIds = useRef(new Set(useVoiceStore.getState().participants.map(p => p.userId)));
|
||||
const prevScreenShareUserIds = useRef(new Set(useVoiceStore.getState().participants.filter(p => p.isScreenSharing).map(p => p.userId)));
|
||||
const incomingCallLoop = useRef(null);
|
||||
const outgoingCallLoop = useRef(null);
|
||||
// WebSocket Reconnect Sound — suppress during active voice (LiveKit handles its own reconnection)
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current)
|
||||
return;
|
||||
if (isWsConnected && !prevIsWsConnected.current) {
|
||||
const isInActiveVoice = useVoiceStore.getState().isLiveKitConnected;
|
||||
if (!isInActiveVoice) {
|
||||
audioManager.playSound('reconnect');
|
||||
}
|
||||
}
|
||||
prevIsWsConnected.current = isWsConnected;
|
||||
}, [isWsConnected, audioManager]);
|
||||
useEffect(() => {
|
||||
// Set initial mount flag to false after first run
|
||||
const timer = setTimeout(() => {
|
||||
isInitialMount.current = false;
|
||||
prevIsWsConnected.current = isWsConnected;
|
||||
}, 1000);
|
||||
// 1. Listen to Voice State Changes
|
||||
const unsubscribeVoice = useVoiceStore.subscribe((state) => {
|
||||
if (isInitialMount.current)
|
||||
return;
|
||||
// Mute/Unmute
|
||||
if (state.isMuted !== prevIsMuted.current) {
|
||||
audioManager.playSound(state.isMuted ? 'mute' : 'unmute');
|
||||
prevIsMuted.current = state.isMuted;
|
||||
}
|
||||
// Deafen/Undeafen
|
||||
if (state.isDeafened !== prevIsDeafened.current) {
|
||||
audioManager.playSound(state.isDeafened ? 'deafen' : 'undeafen');
|
||||
prevIsDeafened.current = state.isDeafened;
|
||||
}
|
||||
// Camera Toggle
|
||||
if (state.isCameraOn !== prevIsCameraOn.current) {
|
||||
audioManager.playSound(state.isCameraOn ? 'camera_on' : 'camera_off');
|
||||
prevIsCameraOn.current = state.isCameraOn;
|
||||
}
|
||||
// Screen Share Toggle (Self)
|
||||
if (state.isScreenSharing !== prevIsScreenSharing.current) {
|
||||
audioManager.playSound(state.isScreenSharing ? 'stream_started' : 'stream_ended');
|
||||
prevIsScreenSharing.current = state.isScreenSharing;
|
||||
}
|
||||
// Disconnect (Self)
|
||||
if (prevIsConnected.current && !state.isLiveKitConnected) {
|
||||
audioManager.playSound('disconnect');
|
||||
}
|
||||
// Connect (Self)
|
||||
if (!prevIsConnected.current && state.isLiveKitConnected) {
|
||||
audioManager.playSound('user_join');
|
||||
}
|
||||
prevIsConnected.current = state.isLiveKitConnected;
|
||||
// Participant Joins/Leaves & Screen Sharing
|
||||
const currentParticipantIds = new Set(state.participants.map(p => p.userId));
|
||||
const currentScreenShareUserIds = new Set(state.participants.filter(p => p.isScreenSharing).map(p => p.userId));
|
||||
if (state.isLiveKitConnected) {
|
||||
// Someone joined voice (Others only)
|
||||
state.participants.forEach(p => {
|
||||
if (!prevParticipantIds.current.has(p.userId) && p.userId !== currentUser?.id) {
|
||||
audioManager.playSound('user_join');
|
||||
}
|
||||
});
|
||||
// Someone left voice (Others only)
|
||||
prevParticipantIds.current.forEach(userId => {
|
||||
if (!currentParticipantIds.has(userId) && userId !== currentUser?.id) {
|
||||
audioManager.playSound('user_leave');
|
||||
}
|
||||
});
|
||||
// Someone started screen sharing (Others only)
|
||||
state.participants.forEach(p => {
|
||||
if (p.isScreenSharing && !prevScreenShareUserIds.current.has(p.userId) && p.userId !== currentUser?.id) {
|
||||
audioManager.playSound('stream_user_joined');
|
||||
}
|
||||
});
|
||||
// Someone stopped screen sharing (Others only)
|
||||
prevScreenShareUserIds.current.forEach(userId => {
|
||||
if (!currentScreenShareUserIds.has(userId) && userId !== currentUser?.id) {
|
||||
audioManager.playSound('stream_user_left');
|
||||
}
|
||||
});
|
||||
}
|
||||
prevParticipantIds.current = currentParticipantIds;
|
||||
prevScreenShareUserIds.current = currentScreenShareUserIds;
|
||||
// Incoming Call (Ringing)
|
||||
if (state.incomingCall && !incomingCallLoop.current) {
|
||||
audioManager.playSound('call_ringing', { loop: true }).then(source => {
|
||||
incomingCallLoop.current = source;
|
||||
});
|
||||
}
|
||||
else if (!state.incomingCall && incomingCallLoop.current) {
|
||||
incomingCallLoop.current.stop();
|
||||
incomingCallLoop.current = null;
|
||||
}
|
||||
// Outgoing Call (Calling)
|
||||
if (state.outgoingCall && !outgoingCallLoop.current) {
|
||||
audioManager.playSound('call_calling', { loop: true }).then(source => {
|
||||
outgoingCallLoop.current = source;
|
||||
});
|
||||
}
|
||||
else if (!state.outgoingCall && outgoingCallLoop.current) {
|
||||
outgoingCallLoop.current.stop();
|
||||
outgoingCallLoop.current = null;
|
||||
}
|
||||
});
|
||||
// 2. Listen to Chat State Changes (New Real-Time Messages from any channel)
|
||||
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
||||
if (isInitialMount.current)
|
||||
return;
|
||||
// Only trigger on NEW realtimeMessageEvents entries (not API loads)
|
||||
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
|
||||
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
||||
for (const { message } of newEvents) {
|
||||
if (message.userId !== currentUser?.id) {
|
||||
audioManager.playSound('message');
|
||||
break; // one sound per batch
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
unsubscribeVoice();
|
||||
unsubscribeChat();
|
||||
if (incomingCallLoop.current)
|
||||
incomingCallLoop.current.stop();
|
||||
if (outgoingCallLoop.current)
|
||||
outgoingCallLoop.current.stop();
|
||||
};
|
||||
}, [audioManager, currentUser?.id]);
|
||||
return null;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
export function StreamTile({ tile, large }) {
|
||||
const videoRef = useRef(null);
|
||||
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
|
||||
const streamMutes = useVoiceStore((s) => s.streamMutes);
|
||||
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
||||
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
|
||||
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
||||
const { participant } = tile;
|
||||
const isLocal = participant.isLocal;
|
||||
const userId = participant.userId;
|
||||
const isWatching = watchingStreams.has(userId);
|
||||
const streamVolume = streamVolumes.get(userId) ?? 100;
|
||||
const isStreamMuted = streamMutes.get(userId) ?? false;
|
||||
const liveScreenTrack = tile.screenTrack?.readyState === 'live' ? tile.screenTrack : null;
|
||||
// Quality badge state
|
||||
const [qualityBadge, setQualityBadge] = useState('');
|
||||
// Context menu state
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false);
|
||||
// --- VIDEO ---
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl)
|
||||
return;
|
||||
if (liveScreenTrack) {
|
||||
videoEl.srcObject = new MediaStream([liveScreenTrack]);
|
||||
}
|
||||
else {
|
||||
videoEl.srcObject = null;
|
||||
}
|
||||
}, [liveScreenTrack]);
|
||||
// Quality badge (poll every 3s)
|
||||
useEffect(() => {
|
||||
if (!liveScreenTrack) {
|
||||
setQualityBadge('');
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
const settings = liveScreenTrack.getSettings();
|
||||
const h = settings.height ?? 0;
|
||||
const fps = Math.round(settings.frameRate ?? 0);
|
||||
if (h > 0 && fps > 0) {
|
||||
setQualityBadge(`${h}P ${fps}FPS`);
|
||||
}
|
||||
else if (h > 0) {
|
||||
setQualityBadge(`${h}P`);
|
||||
}
|
||||
};
|
||||
update();
|
||||
const interval = setInterval(update, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [liveScreenTrack]);
|
||||
// Force re-render on track end
|
||||
const [, forceUpdate] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!tile.screenTrack)
|
||||
return;
|
||||
const onEnded = () => forceUpdate((n) => n + 1);
|
||||
tile.screenTrack.addEventListener('ended', onEnded);
|
||||
return () => tile.screenTrack?.removeEventListener('ended', onEnded);
|
||||
}, [tile.screenTrack]);
|
||||
// --- CONTEXT MENU ---
|
||||
const handleContextMenu = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!contextMenu)
|
||||
return;
|
||||
const close = () => setContextMenu(null);
|
||||
window.addEventListener('click', close);
|
||||
return () => window.removeEventListener('click', close);
|
||||
}, [contextMenu]);
|
||||
const handleWatch = useCallback(() => {
|
||||
useVoiceStore.getState().watchStream(userId);
|
||||
setStreamSubscription(getActiveRoom(), participant.identity, true);
|
||||
}, [userId, participant.identity]);
|
||||
const handleUnwatch = useCallback(() => {
|
||||
useVoiceStore.getState().unwatchStream(userId);
|
||||
setStreamSubscription(getActiveRoom(), participant.identity, false);
|
||||
}, [userId, participant.identity]);
|
||||
const handleStopStreaming = useCallback(async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
useVoiceStore.getState().toggleScreenShare();
|
||||
}
|
||||
}, []);
|
||||
const handleChangeStream = useCallback(async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
// Small delay then re-start to re-trigger the source picker
|
||||
setTimeout(async () => {
|
||||
await room.localParticipant.setScreenShareEnabled(true, {
|
||||
audio: true,
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
}, []);
|
||||
const setStreamVolumeAction = useVoiceStore((s) => s.setStreamVolume);
|
||||
const setStreamMuteAction = useVoiceStore((s) => s.setStreamMute);
|
||||
const setAttenuationEnabled = useVoiceStore((s) => s.setStreamAttenuationEnabled);
|
||||
const setAttenuationStrength = useVoiceStore((s) => s.setStreamAttenuationStrength);
|
||||
const hasVideo = liveScreenTrack !== null;
|
||||
return (_jsxs("div", { className: `relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ring-1 ring-white/[0.06] hover:ring-white/10 ${large ? 'h-full w-full' : 'h-full aspect-video'}`, onContextMenu: handleContextMenu, children: [hasVideo && isWatching ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-contain bg-black" })) : (_jsxs("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: [_jsx("div", { className: "relative", children: _jsx(Avatar, { src: null, name: participant.username, size: large ? 80 : 48 }) }), _jsxs("div", { className: "text-center px-4", children: [_jsxs("p", { className: "text-discord-text-primary text-sm font-semibold", children: [participant.username, " is streaming"] }), !isLocal && (_jsx("button", { onClick: handleWatch, className: "mt-2 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple/80 rounded text-white text-xs font-semibold transition-colors", children: "Watch Stream" }))] })] })), _jsx("div", { className: "absolute top-2 left-2 px-1.5 py-0.5 bg-discord-red rounded text-[11px] font-bold text-white uppercase tracking-wide", children: "LIVE" }), qualityBadge && hasVideo && (_jsx("div", { className: "absolute top-2 right-2 px-1.5 py-0.5 bg-black/60 rounded text-[10px] font-bold text-white/70 uppercase tracking-wide", children: qualityBadge })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent", children: _jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-white/70 flex-shrink-0", children: _jsx("path", { d: "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" }) }), _jsx("span", { className: `font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/40 font-medium", children: "(you)" }))] }) }), contextMenu && (_jsx("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-2 min-w-[220px] border border-white/[0.06]", style: { left: contextMenu.x, top: contextMenu.y }, onClick: (e) => e.stopPropagation(), children: isLocal ? (
|
||||
/* Streamer context menu (own stream) */
|
||||
_jsxs(_Fragment, { children: [_jsxs("button", { onClick: () => {
|
||||
handleStopStreaming();
|
||||
setContextMenu(null);
|
||||
}, className: "w-full flex items-center gap-2 px-3 py-2 text-discord-red hover:bg-discord-red/10 rounded text-sm transition-colors", children: [_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" }), _jsx("line", { x1: "4", y1: "4", x2: "20", y2: "20", stroke: "currentColor", strokeWidth: "2" })] }), "Stop Streaming"] }), _jsxs("button", { onClick: () => {
|
||||
handleChangeStream();
|
||||
setContextMenu(null);
|
||||
}, className: "w-full flex items-center gap-2 px-3 py-2 text-discord-text-secondary hover:bg-discord-modifier-hover rounded text-sm transition-colors", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" }) }), "Change Stream"] }), _jsx("div", { className: "border-t border-white/[0.06] my-1" }), _jsxs("div", { className: "px-3 py-1", children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-1 font-medium uppercase tracking-wider", children: "Stream Quality" }), _jsxs("div", { className: "relative", children: [_jsxs("button", { onClick: () => setQualityPopoverOpen(!qualityPopoverOpen), className: "w-full flex items-center justify-between px-2 py-1.5 text-sm text-discord-text-secondary hover:bg-discord-modifier-hover rounded transition-colors", children: [_jsx("span", { children: useVoiceStore.getState().videoQuality }), _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M7 10l5 5 5-5z" }) })] }), qualityPopoverOpen && (_jsx(VideoQualityPopover, { open: qualityPopoverOpen, onClose: () => setQualityPopoverOpen(false) }))] })] })] })) : (
|
||||
/* Viewer context menu (remote stream) */
|
||||
_jsxs(_Fragment, { children: [isWatching ? (_jsxs("button", { onClick: () => {
|
||||
handleUnwatch();
|
||||
setContextMenu(null);
|
||||
}, className: "w-full flex items-center gap-2 px-3 py-2 text-discord-text-secondary hover:bg-discord-modifier-hover rounded text-sm transition-colors", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" }) }), "Stop Watching"] })) : (_jsxs("button", { onClick: () => {
|
||||
handleWatch();
|
||||
setContextMenu(null);
|
||||
}, className: "w-full flex items-center gap-2 px-3 py-2 text-discord-text-secondary hover:bg-discord-modifier-hover rounded text-sm transition-colors", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" }) }), "Watch Stream"] })), _jsx("div", { className: "border-t border-white/[0.06] my-1" }), _jsxs("button", { onClick: () => {
|
||||
setStreamMuteAction(userId, !isStreamMuted);
|
||||
}, className: "w-full flex items-center justify-between px-3 py-2 text-discord-text-secondary hover:bg-discord-modifier-hover rounded text-sm transition-colors", children: [_jsx("span", { children: "Mute Stream" }), _jsx("div", { className: `w-4 h-4 rounded border flex items-center justify-center transition-colors ${isStreamMuted
|
||||
? 'bg-discord-blurple border-discord-blurple'
|
||||
: 'border-discord-text-muted'}`, children: isStreamMuted && (_jsx("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "white", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) })) })] }), _jsxs("div", { className: "px-3 py-2", children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "Stream Volume" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9H3z" }) }), _jsx("input", { type: "range", min: "0", max: "200", value: streamVolume, onChange: (e) => setStreamVolumeAction(userId, parseInt(e.target.value)), className: "flex-1 accent-discord-blurple h-1" }), _jsxs("span", { className: "text-xs text-discord-text-secondary min-w-[32px] text-right", children: [streamVolume, "%"] })] })] }), _jsx("div", { className: "border-t border-white/[0.06] my-1" }), _jsxs("button", { onClick: () => setAttenuationEnabled(!streamAttenuationEnabled), className: "w-full flex items-center justify-between px-3 py-2 text-discord-text-secondary hover:bg-discord-modifier-hover rounded text-sm transition-colors", children: [_jsx("span", { children: "Stream Attenuation" }), _jsx("div", { className: `w-4 h-4 rounded border flex items-center justify-center transition-colors ${streamAttenuationEnabled
|
||||
? 'bg-discord-blurple border-discord-blurple'
|
||||
: 'border-discord-text-muted'}`, children: streamAttenuationEnabled && (_jsx("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "white", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) })) })] }), streamAttenuationEnabled && (_jsxs("div", { className: "px-3 py-2", children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "Attenuation Strength" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "range", min: "0", max: "100", value: streamAttenuationStrength, onChange: (e) => setAttenuationStrength(parseInt(e.target.value)), className: "flex-1 accent-discord-blurple h-1" }), _jsxs("span", { className: "text-xs text-discord-text-secondary min-w-[32px] text-right", children: [streamAttenuationStrength, "%"] })] })] }))] })) }))] }));
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { VideoPreset } from 'livekit-client';
|
||||
const PRESETS = [
|
||||
{ value: '1080p60', label: '1080p 60fps', desc: '1920x1080, 10000 kbps' },
|
||||
{ value: '1080p', label: '1080p 30fps', desc: '1920x1080, 5000 kbps' },
|
||||
{ value: '720p60', label: '720p 60fps', desc: '1280x720, 5000 kbps' },
|
||||
{ value: '720p', label: '720p 30fps', desc: '1280x720, 3000 kbps' },
|
||||
{ value: '540p', label: '540p 30fps', desc: '960x540, 1500 kbps' },
|
||||
{ value: '360p', label: '360p 30fps', desc: '640x360, 800 kbps' },
|
||||
];
|
||||
const QUALITY_MAP = {
|
||||
'1080p60': new VideoPreset(1920, 1080, 15_000_000, 60),
|
||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
||||
'720p': new VideoPreset(1280, 720, 5_000_000, 30),
|
||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
||||
};
|
||||
export function VideoQualityPopover({ open, onClose, anchorRect }) {
|
||||
const popoverRef = useRef(null);
|
||||
const videoQuality = useVoiceStore((s) => s.videoQuality);
|
||||
const setVideoQuality = useVoiceStore((s) => s.setVideoQuality);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return;
|
||||
const handleClick = (e) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open, onClose]);
|
||||
if (!open)
|
||||
return null;
|
||||
const handleSelect = async (quality) => {
|
||||
setVideoQuality(quality);
|
||||
onClose();
|
||||
};
|
||||
return (_jsxs("div", { ref: popoverRef, className: "absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[240px] bg-[#1e1f22] rounded-lg shadow-lg border border-[#111214] z-50 overflow-hidden", children: [_jsx("div", { className: "px-3 py-2 border-b border-[#111214]", children: _jsx("span", { className: "text-[14px] font-bold text-discord-text-primary", children: "Video Quality" }) }), _jsx("div", { className: "py-1", children: PRESETS.map((preset) => (_jsxs("button", { onClick: () => handleSelect(preset.value), className: `w-full px-3 py-2 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors ${videoQuality === preset.value ? 'text-discord-text-primary' : 'text-discord-text-secondary'}`, children: [_jsxs("div", { className: "text-left", children: [_jsx("div", { className: "text-[14px] font-medium", children: preset.label }), _jsx("div", { className: "text-[12px] text-discord-text-muted", children: preset.desc })] }), videoQuality === preset.value && (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-blurple flex-shrink-0 ml-2", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) }))] }, preset.value))) })] }));
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
const EMPTY_VOICE_USERS = [];
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
export function VoiceChannel({ channelId, channelName, onClick }) {
|
||||
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
|
||||
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const localIsMuted = useVoiceStore((s) => s.isMuted);
|
||||
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
||||
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||
const members = useServerStore((s) => s.members);
|
||||
const isActive = currentVoiceChannel === channelId;
|
||||
return (_jsxs("div", { children: [_jsxs("button", { onClick: onClick, className: `w-full flex items-center gap-1.5 px-2 h-8 rounded-[4px] group transition-colors ${isActive
|
||||
? 'bg-discord-modifier-selected text-white'
|
||||
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'}`, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" }) }), _jsx("span", { className: "truncate text-[15px] font-medium", children: channelName })] }), voiceUsers.length > 0 && (_jsx("div", { className: "ml-6 mt-0.5 space-y-0.5", children: voiceUsers.map((userId) => {
|
||||
const member = members.find(m => m.userId === userId);
|
||||
const participant = participants.find(p => p.userId === userId);
|
||||
const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId;
|
||||
const avatar = member?.user.avatar ?? null;
|
||||
const status = member?.user.status;
|
||||
// Resolve status: for local user use store directly, for remote users
|
||||
// try LiveKit participant first, then fall back to WebSocket voiceUserStates
|
||||
const wsStatus = voiceUserStates.get(userId);
|
||||
const isParticipantDeafened = userId === currentUserId
|
||||
? localIsDeafened
|
||||
: (participant?.isDeafened ?? wsStatus?.isDeafened ?? false);
|
||||
const isMuted = userId === currentUserId
|
||||
? localIsMuted
|
||||
: (participant?.isMuted ?? wsStatus?.isMuted ?? false);
|
||||
const hasCamera = participant?.isCameraOn ?? false;
|
||||
const isScreenSharing = participant?.isScreenSharing ?? false;
|
||||
return (_jsxs("div", { className: "flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-modifier-hover transition-colors", children: [_jsx(Avatar, { src: avatar, name: displayName, size: 20, status: status }), _jsx("span", { className: "text-[13px] text-discord-text-secondary truncate flex-1 min-w-0", children: displayName }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [isMuted && (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-red", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })), isParticipantDeafened && (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-red", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })), hasCamera && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" }) })), isScreenSharing && (_jsx("span", { className: "bg-discord-red text-white text-[9px] font-bold px-1 rounded leading-[14px]", children: "LIVE" }))] })] }, userId));
|
||||
}) }))] }));
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { MessageList } from '../chat/MessageList';
|
||||
import { MessageInput } from '../chat/MessageInput';
|
||||
import { TypingIndicator } from '../chat/TypingIndicator';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
export function VoiceChatPanel({ channelId, channelName }) {
|
||||
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
||||
return (_jsxs("div", { className: "w-[340px] flex-shrink-0 bg-discord-bg-primary flex flex-col border-l border-[#2b2d31]", children: [_jsxs("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0", children: [_jsx("span", { className: "font-bold text-discord-text-primary text-[16px]", children: "Chat" }), _jsx("button", { onClick: toggleVoiceChat, className: "w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded", title: "Close Chat", children: _jsx("svg", { width: "18", height: "18", 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(MessageList, { channelId: channelId }), _jsx(TypingIndicator, { channelId: channelId }), _jsx(MessageInput, { channelId: channelId, channelName: channelName })] }));
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
import { VideoPreset } from 'livekit-client';
|
||||
const QUALITY_MAP = {
|
||||
'1080p60': new VideoPreset(1920, 1080, 15_000_000, 60),
|
||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
||||
'720p': new VideoPreset(1280, 720, 5_000_000, 30),
|
||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
||||
};
|
||||
const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors';
|
||||
const btnDefault = `${btnBase} bg-[#1e1f22] text-discord-text-secondary hover:bg-[#2b2d31] hover:text-discord-text-primary`;
|
||||
const btnActive = (color) => `${btnBase} bg-${color}/20 text-${color} hover:bg-${color}/30`;
|
||||
const btnGreen = `${btnBase} bg-[#1e1f22] text-discord-green hover:bg-[#2b2d31]`;
|
||||
export function VoiceControlBar() {
|
||||
const isMuted = useVoiceStore((s) => s.isMuted);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
|
||||
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
||||
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
||||
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
|
||||
const [qualityOpen, setQualityOpen] = useState(false);
|
||||
const handleMute = React.useCallback(async () => {
|
||||
toggleMic();
|
||||
// Broadcast via WebSocket so sidebar shows status without joining
|
||||
wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened });
|
||||
}, [isMuted, isDeafened, toggleMic]);
|
||||
const handleDeafen = React.useCallback(async () => {
|
||||
const room = getActiveRoom();
|
||||
const willDeafen = !isDeafened;
|
||||
// Update store FIRST so updateParticipants reads correct state
|
||||
toggleDeafen();
|
||||
if (willDeafen && !isMuted)
|
||||
toggleMic();
|
||||
if (!willDeafen && isMuted)
|
||||
toggleMic();
|
||||
// Broadcast via WebSocket
|
||||
wsSend({ type: 'voice_status', isMuted: willDeafen, isDeafened: willDeafen });
|
||||
if (room) {
|
||||
try {
|
||||
// Broadcast deafen state via LiveKit data channel for in-room users
|
||||
const encoder = new TextEncoder();
|
||||
room.localParticipant.publishData(encoder.encode(JSON.stringify({ type: 'deafen', deafened: willDeafen })), { reliable: true }).catch(() => { });
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle deafen:', err);
|
||||
}
|
||||
}
|
||||
}, [isDeafened, isMuted, toggleDeafen, toggleMic]);
|
||||
const handleCamera = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room)
|
||||
return;
|
||||
try {
|
||||
const willEnable = !isCameraOn;
|
||||
if (willEnable) {
|
||||
const videoQuality = useVoiceStore.getState().videoQuality;
|
||||
const preset = QUALITY_MAP[videoQuality];
|
||||
if (preset) {
|
||||
await room.localParticipant.setCameraEnabled(true, { resolution: preset.resolution }, {
|
||||
videoEncoding: preset.encoding,
|
||||
simulcast: videoQuality === '1080p' || videoQuality === '720p'
|
||||
});
|
||||
}
|
||||
else {
|
||||
await room.localParticipant.setCameraEnabled(true);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await room.localParticipant.setCameraEnabled(false);
|
||||
}
|
||||
toggleCamera();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle camera:', err);
|
||||
}
|
||||
};
|
||||
const handleScreenShare = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room)
|
||||
return;
|
||||
try {
|
||||
if (!isScreenSharing) {
|
||||
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
||||
}
|
||||
else {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
}
|
||||
toggleScreenShare();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle screen share:', err);
|
||||
}
|
||||
};
|
||||
const handleDisconnect = () => {
|
||||
wsSend({ type: 'voice_leave' });
|
||||
useVoiceStore.getState().leaveVoice();
|
||||
if (voiceFullscreen) {
|
||||
useUIStore.getState().setVoiceFullscreen(false);
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => { });
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleFullscreen = () => {
|
||||
toggleVoiceFullscreen();
|
||||
};
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
||||
return;
|
||||
if (e.key === 'm' || e.key === 'M') {
|
||||
e.preventDefault();
|
||||
handleMute();
|
||||
}
|
||||
else if (e.key === 'd' || e.key === 'D') {
|
||||
e.preventDefault();
|
||||
handleDeafen();
|
||||
}
|
||||
else if (e.key === 'Escape' && voiceFullscreen) {
|
||||
useUIStore.getState().setVoiceFullscreen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleMute, handleDeafen, voiceFullscreen]);
|
||||
return (_jsx("div", { className: "absolute bottom-6 left-1/2 -translate-x-1/2 z-20 opacity-0 translate-y-4 group-hover/voice:opacity-100 group-hover/voice:translate-y-0 transition-all duration-300 ease-out", children: _jsxs("div", { className: "flex items-center gap-1.5 rounded-full px-3 py-2 bg-[#111214]/90 backdrop-blur-md ring-1 ring-white/[0.06] shadow-[0_8px_32px_rgba(0,0,0,0.5)]", children: [_jsx("button", { onClick: handleMute, className: isMuted || isDeafened
|
||||
? `${btnBase} bg-discord-red/20 text-discord-red hover:bg-discord-red/30`
|
||||
: btnDefault, title: isMuted ? 'Unmute (M)' : 'Mute (M)', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), (isMuted || isDeafened) && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: handleDeafen, className: isDeafened
|
||||
? `${btnBase} bg-discord-red/20 text-discord-red hover:bg-discord-red/30`
|
||||
: btnDefault, title: isDeafened ? 'Undeafen (D)' : 'Deafen (D)', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: handleCamera, className: isCameraOn ? btnGreen : btnDefault, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: isCameraOn ? (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) })) : (_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }), _jsx("line", { x1: "2", y1: "2", x2: "22", y2: "22", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) }), _jsx("button", { onClick: handleScreenShare, className: isScreenSharing ? btnGreen : btnDefault, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }), _jsx("path", { d: "M15 11L11 14V12H9V10H11V8L15 11Z" })] }) }), _jsxs("div", { className: "relative", children: [_jsx("button", { onClick: () => setQualityOpen(!qualityOpen), className: qualityOpen
|
||||
? `${btnBase} bg-[#1e1f22] text-discord-text-primary`
|
||||
: btnDefault, title: "Video Quality", 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" }) }) }), _jsx(VideoQualityPopover, { open: qualityOpen, onClose: () => setQualityOpen(false) })] }), _jsx("div", { className: "w-[1px] h-6 bg-white/10 mx-0.5" }), _jsx("button", { onClick: toggleVoiceChat, className: voiceChatOpen
|
||||
? `${btnBase} bg-[#1e1f22] text-discord-text-primary`
|
||||
: btnDefault, title: "Toggle Chat", 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-6H6V6h12v2z" }) }) }), _jsx("button", { onClick: handleFullscreen, className: voiceFullscreen
|
||||
? `${btnBase} bg-[#1e1f22] text-discord-text-primary`
|
||||
: btnDefault, title: voiceFullscreen ? 'Exit Fullscreen (Esc)' : 'Fullscreen', children: voiceFullscreen ? (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" }) })) : (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" }) })) }), _jsx("div", { className: "w-[1px] h-6 bg-white/10 mx-0.5" }), _jsx("button", { onClick: handleDisconnect, className: `${btnBase} bg-discord-red hover:bg-discord-red-hover text-white`, title: "Disconnect", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] }) }));
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
/**
|
||||
* VoiceControls renders the voice status + button rows.
|
||||
* It has NO wrapper/card styling — the parent provides the container.
|
||||
*/
|
||||
export function VoiceControls() {
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
||||
const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled);
|
||||
const connectionError = useVoiceStore((s) => s.connectionError);
|
||||
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||
const channels = useServerStore((s) => s.channels);
|
||||
const [showVideoQuality, setShowVideoQuality] = useState(false);
|
||||
if (!currentVoiceChannelId)
|
||||
return null;
|
||||
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||
const channelName = channel?.name ?? 'Voice Channel';
|
||||
const handleCamera = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room)
|
||||
return;
|
||||
try {
|
||||
await room.localParticipant.setCameraEnabled(!isCameraOn);
|
||||
toggleCamera();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControls] Failed to toggle camera:', err);
|
||||
}
|
||||
};
|
||||
const handleScreenShare = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room)
|
||||
return;
|
||||
try {
|
||||
if (!isScreenSharing) {
|
||||
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
||||
}
|
||||
else {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
}
|
||||
toggleScreenShare();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControls] Failed to toggle screen share:', err);
|
||||
}
|
||||
};
|
||||
const handleDisconnect = () => {
|
||||
wsSend({ type: 'voice_leave' });
|
||||
useVoiceStore.getState().leaveVoice();
|
||||
};
|
||||
const statusColor = connectionError
|
||||
? 'text-discord-red'
|
||||
: isLiveKitConnected
|
||||
? 'text-discord-green'
|
||||
: 'text-discord-yellow';
|
||||
const statusBgColor = connectionError
|
||||
? 'bg-discord-red/20'
|
||||
: isLiveKitConnected
|
||||
? 'bg-discord-green/20'
|
||||
: 'bg-discord-yellow/20';
|
||||
const btnBase = 'flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors';
|
||||
const btnDefaultStyle = 'bg-[#111214] text-discord-text-muted hover:bg-[#1a1b1e] hover:text-discord-text-secondary';
|
||||
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center gap-2 px-3 pt-3 pb-1", children: [_jsx("div", { className: `w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0`, children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: statusColor, children: _jsx("path", { d: "M1.5 21.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM3.14 15.75a.75.75 0 01-.09-1.06A8.46 8.46 0 0112 11a8.46 8.46 0 018.95 3.69.75.75 0 01-1.15.97A6.96 6.96 0 0012 12.5a6.96 6.96 0 00-7.8 3.16.75.75 0 01-1.06.09zM6.37 18.3a.75.75 0 01-.08-1.06A5.46 5.46 0 0112 15a5.46 5.46 0 015.71 2.24.75.75 0 01-1.14.97A3.96 3.96 0 0012 16.5a3.96 3.96 0 00-4.57 1.71.75.75 0 01-1.06.09z" }) }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: `text-[13px] font-semibold leading-[18px] ${statusColor}`, children: connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...' }), _jsx("div", { className: "text-[12px] text-discord-channels-default truncate leading-[16px]", children: connectionError ? connectionError : channelName })] }), _jsxs("div", { className: "flex items-center gap-0.5 flex-shrink-0", children: [_jsx("button", { className: "w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded", title: "Connection Info", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M2 20h2V8H2v12zm5 0h2V4H7v16zm5 0h2v-8h-2v8zm5 0h2V12h-2v8z" }) }) }), _jsx("button", { onClick: handleDisconnect, className: "w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded", title: "Disconnect", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] })] }), _jsxs("div", { className: "relative flex items-center gap-1 px-3 pb-2 pt-1", children: [_jsx("button", { onClick: handleCamera, className: `${btnBase} ${isCameraOn
|
||||
? 'bg-[#111214] text-discord-green hover:bg-[#1a1b1e]'
|
||||
: btnDefaultStyle}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: isCameraOn ? (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) })) : (_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }), _jsx("line", { x1: "2", y1: "2", x2: "22", y2: "22", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) }), _jsx("button", { onClick: handleScreenShare, className: `${btnBase} ${isScreenSharing
|
||||
? 'bg-[#111214] text-discord-green hover:bg-[#1a1b1e]'
|
||||
: btnDefaultStyle}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }), _jsx("path", { d: "M15 11L11 14V12H9V10H11V8L15 11Z" })] }) }), _jsx("button", { onClick: () => setShowVideoQuality(!showVideoQuality), className: `${btnBase} ${showVideoQuality
|
||||
? 'bg-[#111214] text-discord-blurple hover:bg-[#1a1b1e]'
|
||||
: btnDefaultStyle}`, title: "Video Quality", children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M3 5v14h18V5H3zm16 12H5V7h14v10z" }), _jsx("path", { d: "M8 15l2.5-3.21L13 15l2-2.5L18 17H6z" })] }) }), _jsx("button", { onClick: () => setRnnoiseEnabled(!rnnoiseEnabled), className: `${btnBase} ${rnnoiseEnabled
|
||||
? 'bg-[#111214] text-discord-green hover:bg-[#1a1b1e]'
|
||||
: btnDefaultStyle}`, title: rnnoiseEnabled ? 'Disable AI Noise Suppression' : 'Enable AI Noise Suppression', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z", opacity: rnnoiseEnabled ? 0.15 : 0.08 }), _jsx("path", { d: "M12 1a2 2 0 012 2v1a2 2 0 01-4 0V3a2 2 0 012-2z" }), _jsx("path", { d: "M12 7c-1.66 0-3 1.34-3 3v2c0 1.66 1.34 3 3 3s3-1.34 3-3v-2c0-1.66-1.34-3-3-3z" }), _jsx("path", { d: "M17 11v1c0 2.76-2.24 5-5 5s-5-2.24-5-5v-1H5v1c0 3.53 2.61 6.43 6 6.92V21h2v-2.08c3.39-.49 6-3.39 6-6.92v-1h-2z" }), rnnoiseEnabled ? (_jsxs(_Fragment, { children: [_jsx("circle", { cx: "18", cy: "5", r: "1.2", fill: "currentColor" }), _jsx("circle", { cx: "20", cy: "8", r: "0.9", fill: "currentColor", opacity: "0.7" }), _jsx("circle", { cx: "6", cy: "5", r: "1.2", fill: "currentColor" }), _jsx("circle", { cx: "4", cy: "8", r: "0.9", fill: "currentColor", opacity: "0.7" })] })) : (_jsx("line", { x1: "4", y1: "4", x2: "20", y2: "20", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", opacity: "0.4" }))] }) }), _jsx(VideoQualityPopover, { open: showVideoQuality, onClose: () => setShowVideoQuality(false) })] })] }));
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { VoiceUser } from './VoiceUser';
|
||||
import { StreamTile } from './StreamTile';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { deriveGridTiles } from '../../hooks/useLiveKit';
|
||||
export function VoiceGrid({ participants }) {
|
||||
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
||||
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
|
||||
const [stripHidden, setStripHidden] = useState(false);
|
||||
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
||||
// Reset strip visibility when focus target changes
|
||||
useEffect(() => {
|
||||
setStripHidden(false);
|
||||
}, [focusedParticipantId]);
|
||||
// Unfocus if the focused stream tile no longer exists
|
||||
useEffect(() => {
|
||||
const currentStreamKeys = new Set(tiles
|
||||
.filter((t) => t.kind === 'stream' && t.screenTrack?.readyState === 'live')
|
||||
.map((t) => t.key));
|
||||
if (focusedParticipantId &&
|
||||
focusedParticipantId.endsWith(':stream') &&
|
||||
!currentStreamKeys.has(focusedParticipantId)) {
|
||||
setFocusedParticipant(null);
|
||||
}
|
||||
}, [tiles, focusedParticipantId, setFocusedParticipant]);
|
||||
if (tiles.length === 0) {
|
||||
return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsxs("div", { className: "text-center", children: [_jsx("svg", { width: "48", height: "48", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted/40 mx-auto mb-3", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("p", { className: "text-discord-text-muted text-sm", children: "Waiting for others to join..." })] }) }));
|
||||
}
|
||||
const focusedTile = focusedParticipantId
|
||||
? tiles.find((t) => t.key === focusedParticipantId)
|
||||
: null;
|
||||
// Render a single tile polymorphically
|
||||
const renderTile = (tile, large) => tile.kind === 'user' ? (_jsx(VoiceUser, { tile: tile, large: large })) : (_jsx(StreamTile, { tile: tile, large: large }));
|
||||
// Focus mode: one large tile + bottom strip
|
||||
if (focusedTile) {
|
||||
const otherTiles = tiles.filter((t) => t.key !== focusedParticipantId);
|
||||
return (_jsxs("div", { className: "flex-1 flex flex-col overflow-hidden relative", children: [_jsxs("div", { className: "flex-1 p-2 min-h-0 cursor-pointer relative", onClick: () => setFocusedParticipant(null), title: "Click to return to grid view", children: [renderTile(focusedTile, true), _jsxs("button", { onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
setFocusedParticipant(null);
|
||||
}, className: "absolute top-4 right-4 z-10 px-3 py-1.5 bg-black/60 hover:bg-black/80 rounded-lg flex items-center gap-2 text-white/70 hover:text-white transition-colors", title: "Back to grid view", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" }) }), _jsx("span", { className: "text-xs font-medium", children: "Grid" })] })] }), otherTiles.length > 0 && (_jsx("div", { className: "flex justify-center flex-shrink-0 py-1", children: _jsxs("button", { onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
setStripHidden(!stripHidden);
|
||||
}, className: "px-4 py-1 bg-black/50 hover:bg-black/70 rounded-full flex items-center gap-2 text-white/60 hover:text-white transition-colors text-xs", title: stripHidden ? 'Show Members' : 'Hide Members', children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", children: stripHidden
|
||||
? _jsx("path", { d: "M7 14l5-5 5 5z" })
|
||||
: _jsx("path", { d: "M7 10l5 5 5-5z" }) }), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }), _jsx("span", { children: stripHidden ? 'Show Members' : 'Hide Members' })] }) })), !stripHidden && otherTiles.length > 0 && (_jsx("div", { className: "h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-[#111214]/50 overflow-x-auto no-scrollbar", children: otherTiles.map((t) => (_jsx("div", { onClick: () => setFocusedParticipant(t.key), className: "h-full aspect-video flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity", children: renderTile(t) }, t.key))) }))] }));
|
||||
}
|
||||
// Default grid mode
|
||||
const gridClass = (() => {
|
||||
if (tiles.length === 1)
|
||||
return 'grid-cols-1 max-w-2xl mx-auto';
|
||||
if (tiles.length === 2)
|
||||
return 'grid-cols-2 max-w-4xl mx-auto';
|
||||
if (tiles.length <= 4)
|
||||
return 'grid-cols-2';
|
||||
if (tiles.length <= 9)
|
||||
return 'grid-cols-3';
|
||||
return 'grid-cols-4';
|
||||
})();
|
||||
return (_jsx("div", { className: "flex-1 p-3 overflow-auto flex items-center min-h-0", children: _jsx("div", { className: `grid ${gridClass} gap-2 w-full max-h-full`, children: tiles.map((t) => (_jsx("div", { onClick: () => setFocusedParticipant(t.key), className: "cursor-pointer hover:opacity-90 transition-opacity h-full", children: renderTile(t) }, t.key))) }) }));
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
export function VoiceUser({ tile, large }) {
|
||||
const videoRef = useRef(null);
|
||||
const { participant } = tile;
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||
const [, forceUpdate] = useState(0);
|
||||
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
||||
const isLocal = participant.isLocal;
|
||||
// --- VIDEO & UI ---
|
||||
const activeVideoTrack = tile.videoTrack;
|
||||
const hasVideo = activeVideoTrack !== null;
|
||||
// Force re-render when tracks end/mute
|
||||
useEffect(() => {
|
||||
if (!tile.videoTrack)
|
||||
return;
|
||||
const onEnded = () => forceUpdate((n) => n + 1);
|
||||
tile.videoTrack.addEventListener('ended', onEnded);
|
||||
return () => tile.videoTrack?.removeEventListener('ended', onEnded);
|
||||
}, [tile.videoTrack]);
|
||||
// Attach Video
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl)
|
||||
return;
|
||||
if (activeVideoTrack) {
|
||||
videoEl.srcObject = new MediaStream([activeVideoTrack]);
|
||||
}
|
||||
else {
|
||||
videoEl.srcObject = null;
|
||||
}
|
||||
}, [activeVideoTrack]);
|
||||
// Context Menu
|
||||
const [volumeMenu, setVolumeMenu] = useState(null);
|
||||
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
|
||||
const handleContextMenu = useCallback((e) => {
|
||||
if (isLocal)
|
||||
return;
|
||||
e.preventDefault();
|
||||
setVolumeMenu({ x: e.clientX, y: e.clientY });
|
||||
}, [isLocal]);
|
||||
useEffect(() => {
|
||||
if (!volumeMenu)
|
||||
return;
|
||||
const close = () => setVolumeMenu(null);
|
||||
window.addEventListener('click', close);
|
||||
return () => window.removeEventListener('click', close);
|
||||
}, [volumeMenu]);
|
||||
return (_jsxs("div", { className: `relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${participant.isSpeaking
|
||||
? 'ring-[3px] ring-discord-green shadow-[0_0_12px_rgba(35,165,90,0.25)]'
|
||||
: 'ring-1 ring-white/[0.06] hover:ring-white/10'} ${large ? 'h-full w-full' : 'h-full aspect-video'}`, onContextMenu: handleContextMenu, children: [hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: `w-full h-full ${large ? 'object-contain bg-black' : 'object-cover'}` })) : (_jsx("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: _jsxs("div", { className: "relative", children: [_jsx(Avatar, { src: null, name: participant.username, size: large ? 100 : 64 }), participant.isSpeaking && (_jsx("div", { className: "absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" }))] }) })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_jsx("span", { className: `font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/40 font-medium", children: "(you)" }))] }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })), (isLocal ? isDeafened : participant.isDeafened) && (_jsx("div", { className: "w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) }))] })] }) }), volumeMenu && !isLocal && (_jsxs("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px] border border-white/[0.06]", style: { left: volumeMenu.x, top: volumeMenu.y }, onClick: (e) => e.stopPropagation(), children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "User Volume" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9H3z" }) }), _jsx("input", { type: "range", min: "0", max: "200", value: perUserVolume, onChange: (e) => setParticipantVolume(participant.userId, parseInt(e.target.value)), className: "flex-1 accent-discord-blurple h-1" }), _jsxs("span", { className: "text-xs text-discord-text-secondary min-w-[32px] text-right", children: [perUserVolume, "%"] })] })] }))] }));
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { AudioManager } from '../audio/AudioManager';
|
||||
/**
|
||||
* Hybrid audio pipeline for a single remote audio track.
|
||||
*
|
||||
* Architecture:
|
||||
* 1. A MUTED <audio> element keeps Chrome's WebRTC audio pipeline alive
|
||||
* for the track. Chrome requires an HTML media element consuming a
|
||||
* WebRTC MediaStreamTrack or it stops processing it. The element is
|
||||
* always muted (volume=0, muted=true) — it never produces audible output.
|
||||
*
|
||||
* 2. A Web Audio pipeline handles ALL actual audio output:
|
||||
* MediaStreamTrack -> MediaStream -> MediaStreamAudioSourceNode -> GainNode -> ctx.destination
|
||||
*
|
||||
* This gives us:
|
||||
* - Chrome compatibility (muted <audio> keep-alive)
|
||||
* - No ducking (all elements are muted, only Web Audio produces sound)
|
||||
* - Clean mixing (single ctx.destination for all tracks)
|
||||
* - Full volume range (0.0 – 4.0+) via GainNode
|
||||
* - Smooth transitions via setTargetAtTime (no clicks/pops)
|
||||
*/
|
||||
export function useAudioTrackPlayer(opts) {
|
||||
const { track, volume, muted } = opts;
|
||||
const audioRef = useRef(null);
|
||||
const sourceRef = useRef(null);
|
||||
const gainRef = useRef(null);
|
||||
// Keep current volume/muted in refs so Effect 1 can read them
|
||||
// for the initial ramp without depending on them
|
||||
const volumeRef = useRef(volume);
|
||||
const mutedRef = useRef(muted);
|
||||
volumeRef.current = volume;
|
||||
mutedRef.current = muted;
|
||||
// Effect 1: Track attachment (keep-alive) + Web Audio pipeline build
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
// Tear down previous Web Audio graph
|
||||
if (sourceRef.current) {
|
||||
sourceRef.current.disconnect();
|
||||
sourceRef.current = null;
|
||||
}
|
||||
if (gainRef.current) {
|
||||
gainRef.current.disconnect();
|
||||
gainRef.current = null;
|
||||
}
|
||||
if (!track) {
|
||||
if (audioEl)
|
||||
audioEl.srcObject = null;
|
||||
return;
|
||||
}
|
||||
// --- Keep-alive: attach track to <audio> element (always muted) ---
|
||||
// Chrome needs an HTML element consuming the WebRTC track or it
|
||||
// stops the audio pipeline for that track entirely.
|
||||
if (audioEl) {
|
||||
audioEl.srcObject = new MediaStream([track]);
|
||||
audioEl.muted = true;
|
||||
audioEl.volume = 0;
|
||||
audioEl.play().catch(() => { });
|
||||
}
|
||||
// --- Web Audio pipeline for actual output ---
|
||||
const ctx = AudioManager.getInstance().ensureContext();
|
||||
const stream = new MediaStream([track]);
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const gain = ctx.createGain();
|
||||
// Start gain at 0 to prevent pop, then ramp to target
|
||||
gain.gain.setValueAtTime(0, ctx.currentTime);
|
||||
const targetGain = mutedRef.current ? 0 : volumeRef.current;
|
||||
gain.gain.setTargetAtTime(targetGain, ctx.currentTime, 0.015);
|
||||
source.connect(gain);
|
||||
gain.connect(AudioManager.getInstance().getMasterOutput());
|
||||
sourceRef.current = source;
|
||||
gainRef.current = gain;
|
||||
return () => {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
sourceRef.current = null;
|
||||
gainRef.current = null;
|
||||
};
|
||||
}, [track]);
|
||||
// Effect 2: Update gain when volume or muted changes (no graph rebuild)
|
||||
useEffect(() => {
|
||||
if (!gainRef.current)
|
||||
return;
|
||||
const ctx = AudioManager.getInstance().ensureContext();
|
||||
const targetGain = muted ? 0 : volume;
|
||||
gainRef.current.gain.setTargetAtTime(targetGain, ctx.currentTime, 0.015);
|
||||
}, [volume, muted]);
|
||||
return audioRef;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
export function useAuth() {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const loadUser = useAuthStore((s) => s.loadUser);
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
if (!user && !isLoading) {
|
||||
loadUser();
|
||||
}
|
||||
}, [token, user, isLoading, loadUser, navigate]);
|
||||
return { user, isLoading, isAuthenticated: !!token };
|
||||
}
|
||||
@@ -1,671 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Room, RoomEvent, Track, ConnectionState, VideoPresets, VideoPreset, } from 'livekit-client';
|
||||
import { api } from '../api/client';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
import { AudioManager } from '../audio/AudioManager';
|
||||
/**
|
||||
* OPENCORD NATIVE OVERDRIVE PIPELINE v32
|
||||
*/
|
||||
const QUALITY_MAP = {
|
||||
'1080p60': new VideoPreset(1920, 1080, 12_000_000, 60),
|
||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
||||
'720p': new VideoPreset(1280, 720, 4_000_000, 30),
|
||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
||||
};
|
||||
const AUTO_PRESET = QUALITY_MAP['720p60'];
|
||||
let _activeRoom = null;
|
||||
export function getActiveRoom() {
|
||||
return _activeRoom;
|
||||
}
|
||||
export function deriveGridTiles(participants) {
|
||||
const tiles = [];
|
||||
for (const p of participants) {
|
||||
tiles.push({
|
||||
kind: 'user',
|
||||
key: p.identity,
|
||||
participant: p,
|
||||
videoTrack: (p.isCameraOn && p.videoTrack?.readyState === 'live') ? p.videoTrack : null,
|
||||
audioTrack: p.audioTrack,
|
||||
});
|
||||
if (p.isScreenSharing) {
|
||||
tiles.push({
|
||||
kind: 'stream',
|
||||
key: `${p.identity}:stream`,
|
||||
participant: p,
|
||||
screenTrack: p.screenTrack,
|
||||
screenAudioTrack: p.screenAudioTrack,
|
||||
});
|
||||
}
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
export function setStreamSubscription(room, targetIdentity, subscribed) {
|
||||
if (!room)
|
||||
return;
|
||||
const rp = room.remoteParticipants.get(targetIdentity);
|
||||
if (!rp)
|
||||
return;
|
||||
rp.trackPublications.forEach((pub) => {
|
||||
if (pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) {
|
||||
pub.setSubscribed(subscribed);
|
||||
}
|
||||
});
|
||||
}
|
||||
function parseIdentity(identity) {
|
||||
const parts = identity.split(':');
|
||||
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
|
||||
}
|
||||
let _connectGeneration = 0;
|
||||
async function applyOverdriveHammer(room, source, preset) {
|
||||
try {
|
||||
const pub = room.localParticipant.getTrackPublications().find(p => p.source === source);
|
||||
if (!pub?.track)
|
||||
return;
|
||||
const engine = room.engine;
|
||||
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc;
|
||||
if (pc) {
|
||||
const senders = pc.getSenders();
|
||||
const sender = senders.find(s => s.track?.id === pub.track.mediaStreamTrack?.id);
|
||||
if (sender) {
|
||||
const params = sender.getParameters();
|
||||
if (params.encodings && params.encodings[0]) {
|
||||
params.encodings[0].maxBitrate = preset.encoding.maxBitrate;
|
||||
params.encodings[0].minBitrate = 2_000_000;
|
||||
params.encodings[0].maxFramerate = preset.encoding.maxFramerate;
|
||||
params.encodings[0].networkPriority = 'high';
|
||||
// @ts-ignore
|
||||
params.degradationPreference = 'maintain-framerate';
|
||||
await sender.setParameters(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) { }
|
||||
}
|
||||
export function useLiveKit() {
|
||||
const [room, setRoom] = useState(null);
|
||||
const [participants, setParticipants] = useState([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectionState, setConnectionState] = useState(ConnectionState.Disconnected);
|
||||
const [connectedChannelId, setConnectedChannelId] = useState(null);
|
||||
const [connectionError, setConnectionError] = useState(null);
|
||||
const roomRef = useRef(null);
|
||||
const connectedChannelRef = useRef(null);
|
||||
const isMuted = useVoiceStore((s) => s.isMuted);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
const videoQuality = useVoiceStore((s) => s.videoQuality);
|
||||
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
||||
const inputVolume = useVoiceStore((s) => s.inputVolume);
|
||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
||||
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
|
||||
const noiseSuppression = useVoiceStore((s) => s.noiseSuppression);
|
||||
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
|
||||
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
||||
const lastMicGenRef = useRef(0);
|
||||
const updateParticipants = useCallback(() => {
|
||||
const r = roomRef.current;
|
||||
if (!r)
|
||||
return;
|
||||
const allParticipants = [];
|
||||
const processParticipant = (p, isLocal) => {
|
||||
if (!p.identity)
|
||||
return;
|
||||
const { userId, username } = parseIdentity(p.identity);
|
||||
let audioTrack = null;
|
||||
let videoTrack = null;
|
||||
let screenTrack = null;
|
||||
let screenAudioTrack = null;
|
||||
let hasScreenSharePublication = false;
|
||||
p.trackPublications.forEach((pub) => {
|
||||
// Detect screen share publication even if unsubscribed
|
||||
if (pub.source === Track.Source.ScreenShare)
|
||||
hasScreenSharePublication = true;
|
||||
const track = pub.track;
|
||||
if (!track)
|
||||
return;
|
||||
// Strict check: Track must be subscribed AND not muted to be considered "active"
|
||||
if (pub.isMuted)
|
||||
return;
|
||||
if (!isLocal && !pub.isSubscribed)
|
||||
return;
|
||||
const mt = track.mediaStreamTrack;
|
||||
if (!mt || mt.readyState !== 'live')
|
||||
return;
|
||||
if (pub.source === Track.Source.Microphone)
|
||||
audioTrack = mt;
|
||||
else if (pub.source === Track.Source.Camera && p.isCameraEnabled)
|
||||
videoTrack = mt;
|
||||
else if (pub.source === Track.Source.ScreenShare)
|
||||
screenTrack = mt;
|
||||
else if (pub.source === Track.Source.ScreenShareAudio)
|
||||
screenAudioTrack = mt;
|
||||
});
|
||||
const userState = useVoiceStore.getState().voiceUserStates.get(userId);
|
||||
let isPartDeafened = false;
|
||||
let isPartMuted = !p.isMicrophoneEnabled;
|
||||
if (isLocal) {
|
||||
isPartDeafened = useVoiceStore.getState().isDeafened;
|
||||
isPartMuted = useVoiceStore.getState().isMuted;
|
||||
}
|
||||
else {
|
||||
isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
|
||||
if (userState)
|
||||
isPartMuted = userState.isMuted;
|
||||
}
|
||||
allParticipants.push({
|
||||
identity: p.identity,
|
||||
userId,
|
||||
username,
|
||||
isSpeaking: p.isSpeaking,
|
||||
isMuted: isPartMuted,
|
||||
isDeafened: isPartDeafened,
|
||||
isCameraOn: !!videoTrack,
|
||||
isScreenSharing: hasScreenSharePublication, // True even when unsubscribed
|
||||
isLocal,
|
||||
audioTrack,
|
||||
videoTrack,
|
||||
screenTrack,
|
||||
screenAudioTrack,
|
||||
});
|
||||
};
|
||||
processParticipant(r.localParticipant, true);
|
||||
r.remoteParticipants.forEach((p) => processParticipant(p, false));
|
||||
setParticipants(allParticipants);
|
||||
}, []);
|
||||
const handleDataReceived = useCallback((payload, participant) => {
|
||||
try {
|
||||
const text = new TextDecoder().decode(payload);
|
||||
const msg = JSON.parse(text);
|
||||
if (msg.type === 'deafen' && participant) {
|
||||
const { userId } = parseIdentity(participant.identity);
|
||||
useVoiceStore.getState().setUserDeafened(userId, msg.deafened === true);
|
||||
updateParticipants();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}, [updateParticipants]);
|
||||
// Handle Input Device & Mute Logic via AudioManager
|
||||
// Mute uses setMicrophoneEnabled(false) to keep the track published (silence frames)
|
||||
// instead of unpublishTrack() which tears down the WebRTC transport.
|
||||
useEffect(() => {
|
||||
const r = roomRef.current;
|
||||
if (!r || !isConnected)
|
||||
return;
|
||||
const syncMic = async () => {
|
||||
try {
|
||||
const audioManager = AudioManager.getInstance();
|
||||
audioManager.setVoiceProcessing({ echoCancellation, noiseSuppression, autoGainControl });
|
||||
await audioManager.setRnnoiseEnabled(rnnoiseEnabled);
|
||||
audioManager.setScreenShareActive(isScreenSharing);
|
||||
const micPub = r.localParticipant.getTrackPublications()
|
||||
.find(p => p.source === Track.Source.Microphone);
|
||||
// If muted or deafened, mute the track in-place (keep it published)
|
||||
if (isMuted || isDeafened) {
|
||||
if (micPub?.track && !micPub.isMuted) {
|
||||
await r.localParticipant.setMicrophoneEnabled(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Not muted — ensure mic is published and live
|
||||
await audioManager.setInputDevice(inputDeviceId);
|
||||
audioManager.setInputVolume(inputVolume);
|
||||
const currentGen = audioManager.getStreamGeneration();
|
||||
if (micPub?.track) {
|
||||
// Track already published — check if it's still current
|
||||
if (micPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) {
|
||||
// Current and live — just unmute if needed
|
||||
if (micPub.isMuted) {
|
||||
await r.localParticipant.setMicrophoneEnabled(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Track is stale (device or constraint change) — replace it
|
||||
await r.localParticipant.unpublishTrack(micPub.track);
|
||||
}
|
||||
// Publish fresh track from AudioManager pipeline
|
||||
const audioTrack = audioManager.getFreshTrack();
|
||||
if (!audioTrack)
|
||||
return;
|
||||
console.log('[LiveKit] Publishing fresh microphone track (gen:', currentGen, ')');
|
||||
await r.localParticipant.publishTrack(audioTrack, {
|
||||
name: 'microphone',
|
||||
source: Track.Source.Microphone,
|
||||
});
|
||||
lastMicGenRef.current = currentGen;
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[LiveKit] Failed to sync mic state:', err);
|
||||
}
|
||||
};
|
||||
syncMic();
|
||||
const unsubscribe = AudioManager.getInstance().onResumed(() => {
|
||||
syncMic();
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled, isScreenSharing]);
|
||||
const connect = useCallback(async (channelId) => {
|
||||
if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected)
|
||||
return;
|
||||
const gen = ++_connectGeneration;
|
||||
// Ensure AudioContext is created and resumed before tracks arrive
|
||||
await AudioManager.getInstance().resumeContext();
|
||||
// 1. Reset state immediately to reflect "Loading/Switching" in UI
|
||||
setRoom(null);
|
||||
setParticipants([]);
|
||||
setIsConnected(false);
|
||||
setIsConnecting(true);
|
||||
setConnectionState(ConnectionState.Connecting);
|
||||
setConnectionError(null);
|
||||
setConnectedChannelId(null); // Clear this so AppLayout knows we are transitioning
|
||||
useVoiceStore.getState().setIsLiveKitConnected(false);
|
||||
// 2. Strictly disconnect previous room (Local Ref OR Global Ref)
|
||||
// This handles cases where AppLayout might have remounted, losing roomRef but leaving _activeRoom alive.
|
||||
const roomToDisconnect = roomRef.current || _activeRoom;
|
||||
if (roomToDisconnect) {
|
||||
try {
|
||||
console.log('[LiveKit] Disconnecting previous room:', roomToDisconnect.name);
|
||||
await roomToDisconnect.disconnect();
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('Error disconnecting from previous room:', err);
|
||||
}
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
}
|
||||
try {
|
||||
const { token, url } = await api.livekit.token(channelId);
|
||||
if (gen !== _connectGeneration)
|
||||
return;
|
||||
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
|
||||
roomRef.current = newRoom;
|
||||
const guardedUpdate = () => { if (roomRef.current === newRoom)
|
||||
updateParticipants(); };
|
||||
// ... existing event listeners ...
|
||||
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||
guardedUpdate();
|
||||
if (useVoiceStore.getState().isDeafened) {
|
||||
const encoder = new TextEncoder();
|
||||
newRoom.localParticipant.publishData(encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })), { reliable: true }).catch(() => { });
|
||||
}
|
||||
});
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||
// LiveKit auto-attaches a hidden <audio> element for subscribed audio tracks.
|
||||
// GlobalAudioRenderer is the sole audio playback path with volume/attenuation/boost.
|
||||
// Detach LiveKit's internal element to prevent double-playback.
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
track.detach();
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (track) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
track.detach();
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||
useVoiceStore.getState().watchStream(userId);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||
useVoiceStore.getState().unwatchStream(userId);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackPublished, (publication, participant) => {
|
||||
if (publication.source === Track.Source.ScreenShare ||
|
||||
publication.source === Track.Source.ScreenShareAudio) {
|
||||
publication.setSubscribed(false);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackUnpublished, (publication, participant) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(participant.identity);
|
||||
const state = useVoiceStore.getState();
|
||||
state.unwatchStream(userId);
|
||||
state.clearStreamVolume(userId);
|
||||
state.clearStreamMute(userId);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.DataReceived, handleDataReceived);
|
||||
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||
if (roomRef.current === newRoom) {
|
||||
setConnectionState(state);
|
||||
const connected = state === ConnectionState.Connected;
|
||||
const connecting = state === ConnectionState.Connecting || state === ConnectionState.Reconnecting;
|
||||
setIsConnected(connected);
|
||||
setIsConnecting(connecting);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(connected);
|
||||
if (connected) {
|
||||
updateParticipants();
|
||||
}
|
||||
}
|
||||
});
|
||||
newRoom.on(RoomEvent.Disconnected, () => {
|
||||
if (roomRef.current !== newRoom)
|
||||
return;
|
||||
setConnectionState(ConnectionState.Disconnected);
|
||||
setConnectedChannelId(null);
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
setIsConnected(false);
|
||||
setRoom(null);
|
||||
setParticipants([]);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(false);
|
||||
});
|
||||
await newRoom.connect(url, token);
|
||||
if (gen !== _connectGeneration) {
|
||||
newRoom.disconnect();
|
||||
return;
|
||||
}
|
||||
_activeRoom = newRoom;
|
||||
connectedChannelRef.current = channelId;
|
||||
setConnectedChannelId(channelId);
|
||||
setRoom(newRoom);
|
||||
setIsConnected(true);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||
updateParticipants();
|
||||
// Unsubscribe from any remote screen share tracks that auto-subscribed during connect
|
||||
newRoom.remoteParticipants.forEach((rp) => {
|
||||
rp.trackPublications.forEach((pub) => {
|
||||
if ((pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) &&
|
||||
pub.isSubscribed) {
|
||||
pub.setSubscribed(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Initial mute state check
|
||||
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
|
||||
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
|
||||
if (wasDeafened) {
|
||||
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
|
||||
}
|
||||
updateParticipants();
|
||||
}
|
||||
catch (err) {
|
||||
if (gen === _connectGeneration)
|
||||
setConnectionError('Failed to connect');
|
||||
}
|
||||
finally {
|
||||
if (gen === _connectGeneration)
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [updateParticipants, handleDataReceived]);
|
||||
const connectDm = useCallback(async (dmChannelId) => {
|
||||
const gen = ++_connectGeneration;
|
||||
// Ensure AudioContext is created and resumed before tracks arrive
|
||||
await AudioManager.getInstance().resumeContext();
|
||||
// 1. Reset state immediately
|
||||
setRoom(null);
|
||||
setParticipants([]);
|
||||
setIsConnected(false);
|
||||
setIsConnecting(true);
|
||||
setConnectionState(ConnectionState.Connecting);
|
||||
setConnectionError(null);
|
||||
setConnectedChannelId(null);
|
||||
// 2. Strictly disconnect previous room (Local Ref OR Global Ref)
|
||||
const roomToDisconnect = roomRef.current || _activeRoom;
|
||||
if (roomToDisconnect) {
|
||||
try {
|
||||
console.log('[LiveKit] Disconnecting previous room (DM):', roomToDisconnect.name);
|
||||
await roomToDisconnect.disconnect();
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('Error disconnecting from previous room:', err);
|
||||
}
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
}
|
||||
try {
|
||||
const { token, url } = await api.livekit.dmToken(dmChannelId);
|
||||
if (gen !== _connectGeneration)
|
||||
return;
|
||||
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
|
||||
roomRef.current = newRoom;
|
||||
const guardedUpdate = () => { if (roomRef.current === newRoom)
|
||||
updateParticipants(); };
|
||||
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
track.detach();
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (track) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
track.detach();
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||
useVoiceStore.getState().watchStream(userId);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||
useVoiceStore.getState().unwatchStream(userId);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackPublished, (publication, participant) => {
|
||||
if (publication.source === Track.Source.ScreenShare ||
|
||||
publication.source === Track.Source.ScreenShareAudio) {
|
||||
publication.setSubscribed(false);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackUnpublished, (publication, participant) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(participant.identity);
|
||||
const state = useVoiceStore.getState();
|
||||
state.unwatchStream(userId);
|
||||
state.clearStreamVolume(userId);
|
||||
state.clearStreamMute(userId);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||
if (roomRef.current === newRoom) {
|
||||
setConnectionState(state);
|
||||
const connected = state === ConnectionState.Connected;
|
||||
const connecting = state === ConnectionState.Connecting || state === ConnectionState.Reconnecting;
|
||||
setIsConnected(connected);
|
||||
setIsConnecting(connecting);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(connected);
|
||||
if (connected) {
|
||||
updateParticipants();
|
||||
}
|
||||
}
|
||||
});
|
||||
await newRoom.connect(url, token);
|
||||
if (gen !== _connectGeneration) {
|
||||
newRoom.disconnect();
|
||||
return;
|
||||
}
|
||||
const fullId = `dm-${dmChannelId}`;
|
||||
_activeRoom = newRoom;
|
||||
connectedChannelRef.current = fullId;
|
||||
setConnectedChannelId(fullId);
|
||||
setRoom(newRoom);
|
||||
setIsConnected(true);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||
updateParticipants();
|
||||
// Unsubscribe from any remote screen share tracks that auto-subscribed during connect
|
||||
newRoom.remoteParticipants.forEach((rp) => {
|
||||
rp.trackPublications.forEach((pub) => {
|
||||
if ((pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) &&
|
||||
pub.isSubscribed) {
|
||||
pub.setSubscribed(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
|
||||
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
|
||||
if (wasDeafened) {
|
||||
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
|
||||
}
|
||||
updateParticipants();
|
||||
}
|
||||
catch (err) {
|
||||
if (gen === _connectGeneration)
|
||||
setConnectionError('Failed to connect');
|
||||
}
|
||||
finally {
|
||||
if (gen === _connectGeneration)
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [updateParticipants, handleDataReceived]);
|
||||
const disconnect = useCallback(async () => {
|
||||
_connectGeneration++;
|
||||
connectedChannelRef.current = null;
|
||||
setConnectedChannelId(null);
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.disconnect();
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
setRoom(null);
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionState(ConnectionState.Disconnected);
|
||||
setParticipants([]);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(false);
|
||||
}
|
||||
}, []);
|
||||
const toggleMic = useCallback(async () => {
|
||||
await AudioManager.getInstance().resumeContext();
|
||||
useVoiceStore.getState().toggleMic();
|
||||
}, []);
|
||||
const toggleCamera = useCallback(async () => {
|
||||
if (roomRef.current) {
|
||||
if (!isCameraOn) {
|
||||
const preset = QUALITY_MAP[videoQuality] || VideoPresets.h720;
|
||||
await roomRef.current.localParticipant.setCameraEnabled(true, { resolution: preset.resolution, frameRate: preset.encoding.maxFramerate }, { videoCodec: 'h264', videoEncoding: preset.encoding, simulcast: false });
|
||||
setTimeout(() => { if (roomRef.current)
|
||||
applyOverdriveHammer(roomRef.current, Track.Source.Camera, preset); }, 2000);
|
||||
}
|
||||
else {
|
||||
await roomRef.current.localParticipant.setCameraEnabled(false);
|
||||
}
|
||||
updateParticipants();
|
||||
}
|
||||
}, [isCameraOn, videoQuality, updateParticipants]);
|
||||
const toggleScreenShare = useCallback(async () => {
|
||||
if (roomRef.current) {
|
||||
if (!isScreenSharing) {
|
||||
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
||||
const track = await roomRef.current.localParticipant.setScreenShareEnabled(true, {
|
||||
audio: true,
|
||||
resolution: VideoPresets.h360.resolution,
|
||||
// @ts-ignore
|
||||
frameRate: 30,
|
||||
}, {
|
||||
videoCodec: 'h264', videoEncoding: VideoPresets.h360.encoding, simulcast: false, priority: 'very-high'
|
||||
});
|
||||
if (track) {
|
||||
setTimeout(async () => {
|
||||
if (roomRef.current && isScreenSharing) {
|
||||
const screenPub = roomRef.current.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
||||
if (screenPub?.track?.mediaStreamTrack) {
|
||||
await screenPub.track.mediaStreamTrack.applyConstraints({
|
||||
width: { ideal: preset.resolution.width },
|
||||
height: { ideal: preset.resolution.height },
|
||||
frameRate: { ideal: preset.encoding.maxFramerate, min: 30 }
|
||||
});
|
||||
await applyOverdriveHammer(roomRef.current, Track.Source.ScreenShare, preset);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
setTimeout(() => applyOverdriveHammer(roomRef.current, Track.Source.ScreenShare, preset), 5000);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await roomRef.current.localParticipant.setScreenShareEnabled(false);
|
||||
}
|
||||
updateParticipants();
|
||||
}
|
||||
}, [isScreenSharing, videoQuality, updateParticipants]);
|
||||
useEffect(() => {
|
||||
updateParticipants();
|
||||
}, [voiceUserStates, isMuted, isDeafened, updateParticipants]);
|
||||
useEffect(() => {
|
||||
if (!room)
|
||||
return;
|
||||
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
||||
const updateActiveTracks = async () => {
|
||||
if (isScreenSharing) {
|
||||
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
||||
if (screenPub?.videoTrack) {
|
||||
const mediaTrack = screenPub.videoTrack.mediaStreamTrack;
|
||||
if (mediaTrack) {
|
||||
await mediaTrack.applyConstraints({ width: { ideal: preset.resolution.width }, height: { ideal: preset.resolution.height }, frameRate: { ideal: preset.encoding.maxFramerate } });
|
||||
}
|
||||
await applyOverdriveHammer(room, Track.Source.ScreenShare, preset);
|
||||
}
|
||||
}
|
||||
if (isCameraOn) {
|
||||
await applyOverdriveHammer(room, Track.Source.Camera, preset);
|
||||
}
|
||||
};
|
||||
updateActiveTracks().catch(() => { });
|
||||
}, [room, videoQuality, isScreenSharing, isCameraOn]);
|
||||
useEffect(() => {
|
||||
if (!room)
|
||||
return;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const engine = room.engine;
|
||||
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc || room.pc;
|
||||
if (!pc)
|
||||
return;
|
||||
const stats = await pc.getStats();
|
||||
stats.forEach((report) => {
|
||||
if (report.type === 'outbound-rtp' && report.kind === 'video' && report.frameWidth > 0) {
|
||||
const fps = Math.round(report.framesPerSecond || 0);
|
||||
const key = `_lastBytes_${report.ssrc}`;
|
||||
const lastBytes = window[key] || report.bytesSent;
|
||||
const bitrate = (((report.bytesSent - lastBytes) * 8) / 5000 / 1000).toFixed(2);
|
||||
window[key] = report.bytesSent;
|
||||
console.log(`[Soft-Launch Diagnostic] ${report.frameWidth}x${report.frameHeight} @ ${fps} FPS (~${bitrate} Mbps) | ${report.qualityLimitationReason}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (err) { }
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [room]);
|
||||
useEffect(() => {
|
||||
return () => { _connectGeneration++; if (roomRef.current) {
|
||||
roomRef.current.disconnect();
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
} };
|
||||
}, []);
|
||||
return { room, participants, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare };
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useServerStore } from '../stores/serverStore';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
import { useSocialStore } from '../stores/socialStore';
|
||||
let globalWs = null;
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimer;
|
||||
let currentToken = null;
|
||||
let isInitialized = false;
|
||||
function handleEvent(event) {
|
||||
const { setUser } = useAuthStore.getState();
|
||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState();
|
||||
const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
||||
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
|
||||
switch (event.type) {
|
||||
case 'ready':
|
||||
setUser(event.user);
|
||||
populateFromReady(event.servers, event.folders, event.dmChannels);
|
||||
if (currentServerId) {
|
||||
loadServerDetail(currentServerId);
|
||||
}
|
||||
// Only force-reload the current channel on reconnect; other channels keep their cache
|
||||
{
|
||||
const { loadMessages: reloadMessages, currentChannelId, setReadStates } = useChatStore.getState();
|
||||
if (currentChannelId) {
|
||||
reloadMessages(currentChannelId, true);
|
||||
}
|
||||
// Initialize unread tracking from ready payload
|
||||
const { channelLastMessageIds } = useServerStore.getState();
|
||||
if (event.readStates) {
|
||||
setReadStates(event.readStates, channelLastMessageIds);
|
||||
}
|
||||
}
|
||||
// Clear stale voice state, then populate from server truth
|
||||
clearAllVoiceUsers();
|
||||
if (event.voiceStates) {
|
||||
for (const [channelId, userIds] of Object.entries(event.voiceStates)) {
|
||||
setVoiceUsers(channelId, userIds);
|
||||
}
|
||||
}
|
||||
// Populate voice user statuses (mute/deafen) from server
|
||||
if (event.voiceUserStates) {
|
||||
for (const [uid, status] of Object.entries(event.voiceUserStates)) {
|
||||
setVoiceUserStatus(uid, status.isMuted, status.isDeafened);
|
||||
}
|
||||
}
|
||||
// Re-register in voice channel if we're still connected to LiveKit
|
||||
// (WebSocket reconnect causes server to drop our voice tracking)
|
||||
{
|
||||
const { currentVoiceChannelId, isMuted: curMuted, isDeafened: curDeafened } = useVoiceStore.getState();
|
||||
if (currentVoiceChannelId) {
|
||||
console.log('[WebSocket] Re-syncing voice status on reconnect:', { currentVoiceChannelId, curMuted, curDeafened });
|
||||
wsSend({ type: 'voice_join', channelId: currentVoiceChannelId });
|
||||
wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'message_created':
|
||||
addRealtimeMessage(event.message.channelId, event.message);
|
||||
{
|
||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||
if (event.message.channelId !== currentChannelId) {
|
||||
markChannelUnread(event.message.channelId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'message_updated':
|
||||
updateMessage(event.message);
|
||||
break;
|
||||
case 'message_deleted':
|
||||
removeMessage(event.messageId, event.channelId);
|
||||
break;
|
||||
case 'typing':
|
||||
setTyping(event.channelId, event.userId, event.username);
|
||||
break;
|
||||
case 'presence_update':
|
||||
updateMemberPresence(event.userId, event.status);
|
||||
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
|
||||
break;
|
||||
case 'voice_state_update':
|
||||
if (event.action === 'join') {
|
||||
addVoiceUser(event.channelId, event.userId);
|
||||
}
|
||||
else {
|
||||
removeVoiceUser(event.channelId, event.userId);
|
||||
}
|
||||
break;
|
||||
case 'voice_status_update':
|
||||
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened);
|
||||
break;
|
||||
case 'member_joined':
|
||||
addMember(event.member);
|
||||
break;
|
||||
case 'member_left':
|
||||
removeMember(event.userId);
|
||||
break;
|
||||
case 'dm_message_created': {
|
||||
addRealtimeMessage(event.message.dmChannelId, event.message);
|
||||
// If DM channel is unknown (first-ever message safety net), add a minimal one
|
||||
const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useServerStore.getState();
|
||||
const knownDm = currentDmChannels.find(dm => dm.id === event.message.dmChannelId);
|
||||
if (!knownDm) {
|
||||
// Construct a minimal DmChannel from the message so the sidebar shows it
|
||||
addDmCh({
|
||||
id: event.message.dmChannelId,
|
||||
createdAt: event.message.createdAt,
|
||||
members: event.message.user ? [event.message.user] : [],
|
||||
lastMessage: event.message,
|
||||
});
|
||||
} else {
|
||||
// Update lastMessage on the DM channel so the sidebar sorts correctly
|
||||
const updatedDms = currentDmChannels.map(dm => dm.id === event.message.dmChannelId
|
||||
? { ...dm, lastMessage: event.message }
|
||||
: dm);
|
||||
// Re-sort by most recent message
|
||||
updatedDms.sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDms(updatedDms);
|
||||
}
|
||||
// Mark DM as unread if not currently viewing it
|
||||
{
|
||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||
if (event.message.dmChannelId !== currentChannelId) {
|
||||
markChannelUnread(event.message.dmChannelId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'dm_message_updated':
|
||||
updateMessage(event.message);
|
||||
break;
|
||||
case 'dm_message_deleted':
|
||||
removeMessage(event.messageId, event.dmChannelId);
|
||||
break;
|
||||
case 'dm_typing':
|
||||
setTyping(event.dmChannelId, event.userId, event.username);
|
||||
break;
|
||||
case 'reaction_added':
|
||||
onReactionAdded(event.messageId, event.reaction);
|
||||
break;
|
||||
case 'reaction_removed':
|
||||
onReactionRemoved(event.messageId, event.userId, event.emoji);
|
||||
break;
|
||||
case 'friend_request_received': {
|
||||
const { addIncomingRequest } = useSocialStore.getState();
|
||||
addIncomingRequest(event.request);
|
||||
break;
|
||||
}
|
||||
case 'friend_request_accepted': {
|
||||
const { addFriendFromAccepted } = useSocialStore.getState();
|
||||
addFriendFromAccepted(event.friend, event.requestId);
|
||||
break;
|
||||
}
|
||||
case 'channel_ack': {
|
||||
const { onChannelAck } = useChatStore.getState();
|
||||
onChannelAck(event.channelId, event.messageId);
|
||||
break;
|
||||
}
|
||||
case 'dm_call_incoming': {
|
||||
const { setIncomingCall } = useVoiceStore.getState();
|
||||
setIncomingCall({
|
||||
dmChannelId: event.dmChannelId,
|
||||
callerId: event.callerId,
|
||||
callerName: event.callerName,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'dm_call_accepted': {
|
||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
|
||||
setIncomingCall(null);
|
||||
setOutgoingCall(null);
|
||||
setActiveDmCall({ dmChannelId: event.dmChannelId });
|
||||
break;
|
||||
}
|
||||
case 'dm_call_rejected': {
|
||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
|
||||
setIncomingCall(null);
|
||||
setOutgoingCall(null);
|
||||
setActiveDmCall(null);
|
||||
break;
|
||||
}
|
||||
case 'dm_call_ended': {
|
||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
|
||||
setIncomingCall(null);
|
||||
setOutgoingCall(null);
|
||||
setActiveDmCall(null);
|
||||
break;
|
||||
}
|
||||
case 'dm_channel_created':
|
||||
addDmChannel(event.dmChannel);
|
||||
break;
|
||||
case 'dm_channel_closed':
|
||||
removeDmChannel(event.dmChannelId);
|
||||
break;
|
||||
case 'friend_removed': {
|
||||
const { removeFriendLocally } = useSocialStore.getState();
|
||||
removeFriendLocally(event.userId);
|
||||
break;
|
||||
}
|
||||
case 'channel_created': {
|
||||
const { currentServerId: curServerId, channels: curChannels, setChannels } = useServerStore.getState();
|
||||
if (event.serverId === curServerId) {
|
||||
if (!curChannels.find(c => c.id === event.channel.id)) {
|
||||
setChannels([...curChannels, event.channel].sort((a, b) => a.position - b.position));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'channel_updated': {
|
||||
const { currentServerId: curServerId2, channels: curChannels2, setChannels: setChannels2 } = useServerStore.getState();
|
||||
if (event.serverId === curServerId2) {
|
||||
setChannels2(curChannels2.map(c => c.id === event.channel.id ? event.channel : c).sort((a, b) => a.position - b.position));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'channel_deleted': {
|
||||
const { currentServerId: curServerId3, channels: curChannels3, setChannels: setChannels3 } = useServerStore.getState();
|
||||
if (event.serverId === curServerId3) {
|
||||
setChannels3(curChannels3.filter(c => c.id !== event.channelId));
|
||||
}
|
||||
{
|
||||
const { currentChannelId } = useChatStore.getState();
|
||||
if (currentChannelId === event.channelId) {
|
||||
const { channels: remainingChannels } = useServerStore.getState();
|
||||
const firstText = remainingChannels.find(c => c.type === 'text');
|
||||
if (firstText) {
|
||||
useChatStore.getState().setCurrentChannel(firstText.id);
|
||||
} else {
|
||||
useChatStore.getState().setCurrentChannel(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'server_updated': {
|
||||
const { servers: currentServers, setServers } = useServerStore.getState();
|
||||
setServers(currentServers.map(s => s.id === event.server.id ? { ...s, ...event.server } : s));
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
console.error('WebSocket error:', event.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
function connect() {
|
||||
if (!currentToken)
|
||||
return;
|
||||
if (globalWs && (globalWs.readyState === WebSocket.OPEN || globalWs.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
globalWs = ws;
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0;
|
||||
ws.send(JSON.stringify({ type: 'auth', token: currentToken }));
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const event = JSON.parse(e.data);
|
||||
handleEvent(event);
|
||||
}
|
||||
catch {
|
||||
console.error('Failed to parse WebSocket message');
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
globalWs = null;
|
||||
if (currentToken) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||
reconnectAttempts++;
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
function disconnect() {
|
||||
currentToken = null;
|
||||
isInitialized = false;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = undefined;
|
||||
}
|
||||
if (globalWs) {
|
||||
globalWs.close();
|
||||
globalWs = null;
|
||||
}
|
||||
}
|
||||
/** Send an event over the WebSocket. Can be used outside of React components. */
|
||||
export function wsSend(event) {
|
||||
if (globalWs && globalWs.readyState === WebSocket.OPEN) {
|
||||
globalWs.send(JSON.stringify(event));
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Hook to initialize the WebSocket connection. Should only be called ONCE
|
||||
* from the top-level layout component (AppLayout). Other components should
|
||||
* use the exported `wsSend` function directly.
|
||||
*/
|
||||
export function useWebSocket() {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const prevToken = useRef(token);
|
||||
const [isConnected, setIsConnected] = React.useState(false);
|
||||
useEffect(() => {
|
||||
if (token && (!isInitialized || token !== prevToken.current)) {
|
||||
currentToken = token;
|
||||
isInitialized = true;
|
||||
connect();
|
||||
}
|
||||
else if (!token && isInitialized) {
|
||||
disconnect();
|
||||
}
|
||||
prevToken.current = token;
|
||||
}, [token]);
|
||||
useEffect(() => {
|
||||
const checkStatus = setInterval(() => {
|
||||
setIsConnected(!!globalWs && globalWs.readyState === WebSocket.OPEN);
|
||||
}, 500);
|
||||
return () => {
|
||||
clearInterval(checkStatus);
|
||||
disconnect();
|
||||
};
|
||||
}, []);
|
||||
return { send: wsSend, isConnected };
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import './styles/globals.css';
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (_jsxs("div", { style: {
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#232428',
|
||||
color: '#ffffff',
|
||||
fontFamily: 'sans-serif',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}, children: [_jsx("h1", { style: { fontSize: '24px', fontWeight: 'bold' }, children: "Something went wrong" }), _jsx("p", { style: { color: '#abacb2' }, children: this.state.error?.message }), _jsx("button", { onClick: () => window.location.reload(), style: {
|
||||
padding: '8px 24px',
|
||||
backgroundColor: '#5865f2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}, children: "Reload" })] }));
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
const root = document.getElementById('root');
|
||||
if (!root)
|
||||
throw new Error('Root element not found');
|
||||
ReactDOM.createRoot(root).render(_jsx(React.StrictMode, { children: _jsx(ErrorBoundary, { children: _jsx(BrowserRouter, { children: _jsx(App, {}) }) }) }));
|
||||
@@ -1,62 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
export const useAuthStore = create((set, get) => ({
|
||||
token: localStorage.getItem('opencord_token'),
|
||||
user: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
login: async (username, password) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await api.auth.login({ username, password });
|
||||
localStorage.setItem('opencord_token', response.token);
|
||||
set({ token: response.token, user: response.user, isLoading: false });
|
||||
}
|
||||
catch (err) {
|
||||
set({ isLoading: false, error: err instanceof Error ? err.message : 'Login failed' });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
register: async (username, password, displayName) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await api.auth.register({ username, password, displayName });
|
||||
localStorage.setItem('opencord_token', response.token);
|
||||
set({ token: response.token, user: response.user, isLoading: false });
|
||||
}
|
||||
catch (err) {
|
||||
set({ isLoading: false, error: err instanceof Error ? err.message : 'Registration failed' });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem('opencord_token');
|
||||
set({ token: null, user: null });
|
||||
},
|
||||
loadUser: async () => {
|
||||
const token = get().token;
|
||||
if (!token)
|
||||
return;
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const user = await api.users.me();
|
||||
set({ user, isLoading: false });
|
||||
}
|
||||
catch {
|
||||
localStorage.removeItem('opencord_token');
|
||||
set({ token: null, user: null, isLoading: false });
|
||||
}
|
||||
},
|
||||
updateProfile: async (data) => {
|
||||
try {
|
||||
const user = await api.users.update(data);
|
||||
set({ user });
|
||||
}
|
||||
catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : 'Update failed' });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
setUser: (user) => set({ user }),
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
@@ -1,351 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
import { wsSend } from '../hooks/useWebSocket';
|
||||
import { isDmChannel, useServerStore } from './serverStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
export const useChatStore = create((set, get) => ({
|
||||
messages: new Map(),
|
||||
currentChannelId: null,
|
||||
typingUsers: new Map(),
|
||||
hasMore: new Map(),
|
||||
isLoading: false,
|
||||
loadError: null,
|
||||
replyTo: null,
|
||||
readStates: new Map(),
|
||||
unreadChannels: new Set(),
|
||||
realtimeMessageEvents: [],
|
||||
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
||||
setReplyTo: (message) => set({ replyTo: message }),
|
||||
clearAllMessages: () => set({ messages: new Map(), hasMore: new Map() }),
|
||||
loadMessages: async (channelId, force) => {
|
||||
if (!force && get().messages.has(channelId))
|
||||
return;
|
||||
set({ isLoading: true, loadError: null });
|
||||
try {
|
||||
const isDm = isDmChannel(channelId);
|
||||
const messages = isDm
|
||||
? await api.dm.messages(channelId)
|
||||
: await api.channels.messages(channelId);
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
newMessages.set(channelId, messages);
|
||||
const newHasMore = new Map(state.hasMore);
|
||||
newHasMore.set(channelId, messages.length >= 50);
|
||||
return { messages: newMessages, hasMore: newHasMore, isLoading: false, loadError: null };
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
set({ isLoading: false, loadError: err.message || 'Failed to load messages' });
|
||||
}
|
||||
},
|
||||
loadMoreMessages: async (channelId) => {
|
||||
const existing = get().messages.get(channelId);
|
||||
if (!existing || existing.length === 0)
|
||||
return false;
|
||||
if (!get().hasMore.get(channelId))
|
||||
return false;
|
||||
const oldestMessage = existing[0];
|
||||
if (!oldestMessage)
|
||||
return false;
|
||||
try {
|
||||
const isDm = isDmChannel(channelId);
|
||||
const olderMessages = isDm
|
||||
? await api.dm.messages(channelId, oldestMessage.id)
|
||||
: await api.channels.messages(channelId, oldestMessage.id);
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
const current = newMessages.get(channelId) ?? [];
|
||||
newMessages.set(channelId, [...olderMessages, ...current]);
|
||||
const newHasMore = new Map(state.hasMore);
|
||||
newHasMore.set(channelId, olderMessages.length >= 50);
|
||||
return { messages: newMessages, hasMore: newHasMore };
|
||||
});
|
||||
return olderMessages.length > 0;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
sendMessage: async (channelId, content, attachmentIds) => {
|
||||
const replyToId = get().replyTo?.id;
|
||||
const isDm = isDmChannel(channelId);
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
|
||||
// Generate optimistic message
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
if (currentUser) {
|
||||
const optimisticMessage = {
|
||||
id: tempId,
|
||||
channelId: isDm ? '' : channelId,
|
||||
userId: currentUser.id,
|
||||
content,
|
||||
replyToId: replyToId ?? null,
|
||||
editedAt: null,
|
||||
createdAt: Date.now(),
|
||||
user: currentUser,
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
};
|
||||
if (isDm) {
|
||||
optimisticMessage.dmChannelId = channelId;
|
||||
}
|
||||
// Add optimistic message immediately
|
||||
get().addMessage(channelId, optimisticMessage);
|
||||
|
||||
// For DMs, update lastMessage on the DM channel so sidebar re-sorts
|
||||
if (isDm) {
|
||||
const { dmChannels, setDmChannels } = useServerStore.getState();
|
||||
const updatedDms = dmChannels.map(dm =>
|
||||
dm.id === channelId
|
||||
? { ...dm, lastMessage: { id: tempId, dmChannelId: channelId, userId: currentUser.id, content, createdAt: Date.now() } }
|
||||
: dm
|
||||
);
|
||||
updatedDms.sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDmChannels(updatedDms);
|
||||
}
|
||||
}
|
||||
|
||||
set({ replyTo: null });
|
||||
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.sendMessage(channelId, { content });
|
||||
} else {
|
||||
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
||||
}
|
||||
} catch {
|
||||
// Rollback: remove the optimistic message on failure
|
||||
get().removeMessage(tempId, channelId);
|
||||
}
|
||||
},
|
||||
editMessage: async (messageId, content, channelId) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
// Optimistic: update content locally first
|
||||
const messages = get().messages.get(channelId);
|
||||
const originalMessage = messages?.find(m => m.id === messageId);
|
||||
if (originalMessage) {
|
||||
get().updateMessage({ ...originalMessage, content, editedAt: Date.now() });
|
||||
}
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
} else {
|
||||
await api.messages.update(messageId, { content });
|
||||
}
|
||||
} catch {
|
||||
// Rollback: restore the original message on failure
|
||||
if (originalMessage) {
|
||||
get().updateMessage(originalMessage);
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteMessage: async (messageId, channelId) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
// Optimistic: remove locally first
|
||||
const messages = get().messages.get(channelId);
|
||||
const savedMessage = messages?.find(m => m.id === messageId);
|
||||
get().removeMessage(messageId, channelId);
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
} else {
|
||||
await api.messages.delete(messageId);
|
||||
}
|
||||
} catch {
|
||||
// Rollback: re-add the message on failure
|
||||
if (savedMessage) {
|
||||
get().addMessage(channelId, savedMessage);
|
||||
}
|
||||
}
|
||||
},
|
||||
addMessage: (channelId, message) => {
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
const current = newMessages.get(channelId) ?? [];
|
||||
// Avoid duplicates
|
||||
if (current.find(m => m.id === message.id))
|
||||
return state;
|
||||
// Remove any optimistic temp message from same user with same content
|
||||
const filtered = current.filter(m => {
|
||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||
return m.content !== message.content;
|
||||
});
|
||||
newMessages.set(channelId, [...filtered, message]);
|
||||
return { messages: newMessages };
|
||||
});
|
||||
},
|
||||
addRealtimeMessage: (channelId, message) => {
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
const current = newMessages.get(channelId) ?? [];
|
||||
// Avoid duplicates
|
||||
if (current.find(m => m.id === message.id))
|
||||
return state;
|
||||
// Remove any optimistic temp message from same user with same content
|
||||
const filtered = current.filter(m => {
|
||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||
return m.content !== message.content;
|
||||
});
|
||||
newMessages.set(channelId, [...filtered, message]);
|
||||
// Append to realtimeMessageEvents (capped at 50)
|
||||
const newEvents = [...state.realtimeMessageEvents, { channelId, message }];
|
||||
if (newEvents.length > 50) newEvents.splice(0, newEvents.length - 50);
|
||||
return { messages: newMessages, realtimeMessageEvents: newEvents };
|
||||
});
|
||||
},
|
||||
updateMessage: (message) => {
|
||||
// DM messages have dmChannelId instead of channelId — check both
|
||||
const channelKey = message.channelId || message.dmChannelId;
|
||||
if (!channelKey)
|
||||
return;
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
const current = newMessages.get(channelKey);
|
||||
if (!current)
|
||||
return state;
|
||||
newMessages.set(channelKey, current.map(m => m.id === message.id ? message : m));
|
||||
return { messages: newMessages };
|
||||
});
|
||||
},
|
||||
removeMessage: (messageId, channelId) => {
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
const current = newMessages.get(channelId);
|
||||
if (!current)
|
||||
return state;
|
||||
newMessages.set(channelId, current.filter(m => m.id !== messageId));
|
||||
return { messages: newMessages };
|
||||
});
|
||||
},
|
||||
addReaction: (messageId, emoji) => {
|
||||
wsSend({ type: 'reaction_add', messageId, emoji });
|
||||
},
|
||||
removeReaction: (messageId, emoji) => {
|
||||
wsSend({ type: 'reaction_remove', messageId, emoji });
|
||||
},
|
||||
onReactionAdded: (messageId, reaction) => {
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
for (const [channelId, msgs] of newMessages.entries()) {
|
||||
const msgIndex = msgs.findIndex(m => m.id === messageId);
|
||||
if (msgIndex !== -1) {
|
||||
const newMsgs = [...msgs];
|
||||
const oldMsg = newMsgs[msgIndex];
|
||||
newMsgs[msgIndex] = {
|
||||
...oldMsg,
|
||||
reactions: [...(oldMsg.reactions || []), reaction],
|
||||
};
|
||||
newMessages.set(channelId, newMsgs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { messages: newMessages };
|
||||
});
|
||||
},
|
||||
onReactionRemoved: (messageId, userId, emoji) => {
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
for (const [channelId, msgs] of newMessages.entries()) {
|
||||
const msgIndex = msgs.findIndex(m => m.id === messageId);
|
||||
if (msgIndex !== -1) {
|
||||
const newMsgs = [...msgs];
|
||||
const oldMsg = newMsgs[msgIndex];
|
||||
newMsgs[msgIndex] = {
|
||||
...oldMsg,
|
||||
reactions: (oldMsg.reactions || []).filter(r => !(r.userId === userId && r.emoji === emoji)),
|
||||
};
|
||||
newMessages.set(channelId, newMsgs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { messages: newMessages };
|
||||
});
|
||||
},
|
||||
setTyping: (channelId, userId, username) => {
|
||||
set((state) => {
|
||||
const newTyping = new Map(state.typingUsers);
|
||||
const current = newTyping.get(channelId) ?? [];
|
||||
const filtered = current.filter(t => t.userId !== userId);
|
||||
filtered.push({ userId, username, timestamp: Date.now() });
|
||||
newTyping.set(channelId, filtered);
|
||||
return { typingUsers: newTyping };
|
||||
});
|
||||
// Auto-clear after 5 seconds
|
||||
setTimeout(() => {
|
||||
get().clearTyping(channelId, userId);
|
||||
}, 5000);
|
||||
},
|
||||
clearTyping: (channelId, userId) => {
|
||||
set((state) => {
|
||||
const newTyping = new Map(state.typingUsers);
|
||||
const current = newTyping.get(channelId);
|
||||
if (!current)
|
||||
return state;
|
||||
newTyping.set(channelId, current.filter(t => t.userId !== userId));
|
||||
return { typingUsers: newTyping };
|
||||
});
|
||||
},
|
||||
getMessages: (channelId) => {
|
||||
return get().messages.get(channelId) ?? [];
|
||||
},
|
||||
getTypingUsers: (channelId) => {
|
||||
const users = get().typingUsers.get(channelId) ?? [];
|
||||
const now = Date.now();
|
||||
return users.filter(t => now - t.timestamp < 5000);
|
||||
},
|
||||
setReadStates: (readStates, channelLastMessageIds) => {
|
||||
const rsMap = new Map();
|
||||
for (const rs of readStates) {
|
||||
rsMap.set(rs.channelId, rs.lastReadMessageId);
|
||||
}
|
||||
const unread = new Set();
|
||||
for (const [channelId, lastMsgId] of channelLastMessageIds) {
|
||||
const lastRead = rsMap.get(channelId);
|
||||
if (!lastRead || BigInt(lastMsgId) > BigInt(lastRead)) {
|
||||
unread.add(channelId);
|
||||
}
|
||||
}
|
||||
set({ readStates: rsMap, unreadChannels: unread });
|
||||
},
|
||||
markChannelUnread: (channelId) => {
|
||||
set((state) => {
|
||||
if (state.unreadChannels.has(channelId))
|
||||
return state;
|
||||
const newUnread = new Set(state.unreadChannels);
|
||||
newUnread.add(channelId);
|
||||
return { unreadChannels: newUnread };
|
||||
});
|
||||
},
|
||||
ackChannel: (channelId) => {
|
||||
const msgs = get().messages.get(channelId);
|
||||
if (!msgs || msgs.length === 0)
|
||||
return;
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
if (!lastMsg)
|
||||
return;
|
||||
const messageId = lastMsg.id;
|
||||
// Update local state immediately
|
||||
set((state) => {
|
||||
const newReadStates = new Map(state.readStates);
|
||||
newReadStates.set(channelId, messageId);
|
||||
const newUnread = new Set(state.unreadChannels);
|
||||
newUnread.delete(channelId);
|
||||
return { readStates: newReadStates, unreadChannels: newUnread };
|
||||
});
|
||||
// Send to server
|
||||
wsSend({ type: 'channel_ack', channelId, messageId });
|
||||
},
|
||||
onChannelAck: (channelId, messageId) => {
|
||||
set((state) => {
|
||||
const newReadStates = new Map(state.readStates);
|
||||
newReadStates.set(channelId, messageId);
|
||||
const newUnread = new Set(state.unreadChannels);
|
||||
newUnread.delete(channelId);
|
||||
return { readStates: newReadStates, unreadChannels: newUnread };
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -1,194 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
export const useServerStore = create((set, get) => ({
|
||||
servers: [],
|
||||
currentServerId: null,
|
||||
channels: [],
|
||||
members: [],
|
||||
roles: [],
|
||||
folders: [],
|
||||
dmChannels: [],
|
||||
channelToServerMap: new Map(),
|
||||
channelLastMessageIds: new Map(),
|
||||
setServers: (servers) => set({ servers }),
|
||||
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
|
||||
setChannels: (channels) => set({ channels }),
|
||||
setMembers: (members) => set({ members }),
|
||||
setRoles: (roles) => set({ roles }),
|
||||
setDmChannels: (dmChannels) => set({ dmChannels }),
|
||||
addDmChannel: (channel) => set((state) => ({
|
||||
dmChannels: [channel, ...state.dmChannels.filter(c => c.id !== channel.id)]
|
||||
})),
|
||||
removeDmChannel: (id) => set((state) => ({
|
||||
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
||||
})),
|
||||
closeDm: async (id) => {
|
||||
await api.dm.close(id);
|
||||
set((state) => ({
|
||||
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
||||
}));
|
||||
},
|
||||
loadServers: async () => {
|
||||
try {
|
||||
const servers = await api.servers.list();
|
||||
set({ servers });
|
||||
}
|
||||
catch {
|
||||
// Silently fail - will be populated from WS ready
|
||||
}
|
||||
},
|
||||
loadServerDetail: async (serverId) => {
|
||||
try {
|
||||
const detail = await api.servers.get(serverId);
|
||||
set({
|
||||
currentServerId: serverId,
|
||||
channels: detail.channels.sort((a, b) => a.position - b.position),
|
||||
members: detail.members,
|
||||
roles: detail.roles.sort((a, b) => b.position - a.position), // Higher position = higher in list
|
||||
});
|
||||
}
|
||||
catch {
|
||||
// Handle error silently
|
||||
}
|
||||
},
|
||||
loadDmChannels: async () => {
|
||||
try {
|
||||
const dmChannels = await api.dm.list();
|
||||
set({ dmChannels });
|
||||
}
|
||||
catch {
|
||||
// Handle error silently
|
||||
}
|
||||
},
|
||||
createServer: async (name, icon) => {
|
||||
const server = await api.servers.create({ name, icon });
|
||||
set((state) => ({ servers: [...state.servers, server] }));
|
||||
return server;
|
||||
},
|
||||
updateServer: async (serverId, data) => {
|
||||
const updated = await api.servers.update(serverId, data);
|
||||
set((state) => ({
|
||||
servers: state.servers.map(s => s.id === serverId ? { ...s, ...updated } : s),
|
||||
}));
|
||||
},
|
||||
deleteServer: async (serverId) => {
|
||||
await api.servers.delete(serverId);
|
||||
set((state) => ({
|
||||
servers: state.servers.filter(s => s.id !== serverId),
|
||||
currentServerId: state.currentServerId === serverId ? null : state.currentServerId,
|
||||
}));
|
||||
},
|
||||
joinServer: async (serverId, inviteCode) => {
|
||||
const server = await api.servers.join(serverId, { inviteCode });
|
||||
set((state) => {
|
||||
if (state.servers.find(s => s.id === server.id))
|
||||
return state;
|
||||
return { servers: [...state.servers, server] };
|
||||
});
|
||||
},
|
||||
joinByCode: async (inviteCode) => {
|
||||
const server = await api.servers.joinByCode(inviteCode);
|
||||
set((state) => {
|
||||
if (state.servers.find(s => s.id === server.id))
|
||||
return state;
|
||||
return { servers: [...state.servers, server] };
|
||||
});
|
||||
return server;
|
||||
},
|
||||
generateInvite: async (serverId) => {
|
||||
const result = await api.servers.invite(serverId);
|
||||
return result.inviteCode;
|
||||
},
|
||||
createChannel: async (serverId, name, type, topic) => {
|
||||
const channel = await api.channels.create(serverId, { name, type, topic });
|
||||
set((state) => ({
|
||||
channels: [...state.channels, channel].sort((a, b) => a.position - b.position),
|
||||
}));
|
||||
return channel;
|
||||
},
|
||||
deleteChannel: async (channelId) => {
|
||||
await api.channels.delete(channelId);
|
||||
set((state) => ({
|
||||
channels: state.channels.filter(c => c.id !== channelId),
|
||||
}));
|
||||
},
|
||||
addServer: (server) => {
|
||||
set((state) => {
|
||||
if (state.servers.find(s => s.id === server.id))
|
||||
return state;
|
||||
return { servers: [...state.servers, server] };
|
||||
});
|
||||
},
|
||||
removeServer: (serverId) => {
|
||||
set((state) => ({
|
||||
servers: state.servers.filter(s => s.id !== serverId),
|
||||
currentServerId: state.currentServerId === serverId ? null : state.currentServerId,
|
||||
}));
|
||||
},
|
||||
updateMemberPresence: (userId, status) => {
|
||||
set((state) => ({
|
||||
members: state.members.map(m => m.userId === userId ? { ...m, user: { ...m.user, status: status } } : m),
|
||||
}));
|
||||
},
|
||||
addMember: (member) => {
|
||||
set((state) => ({
|
||||
members: [...state.members.filter(m => m.userId !== member.userId), member],
|
||||
}));
|
||||
},
|
||||
removeMember: (userId) => {
|
||||
set((state) => ({
|
||||
members: state.members.filter(m => m.userId !== userId),
|
||||
}));
|
||||
},
|
||||
populateFromReady: (servers, folders, dmChannels) => {
|
||||
const simpleServers = servers.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
icon: s.icon,
|
||||
ownerId: s.ownerId,
|
||||
inviteCode: s.inviteCode,
|
||||
createdAt: s.createdAt,
|
||||
}));
|
||||
// Build channel→server map and channel→lastMessageId map
|
||||
const channelToServerMap = new Map();
|
||||
const channelLastMessageIds = new Map();
|
||||
for (const srv of servers) {
|
||||
for (const ch of srv.channels) {
|
||||
channelToServerMap.set(ch.id, srv.id);
|
||||
if (ch.lastMessageId) {
|
||||
channelLastMessageIds.set(ch.id, ch.lastMessageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also map DM channels
|
||||
const dms = dmChannels || [];
|
||||
for (const dm of dms) {
|
||||
if (dm.lastMessage?.id) {
|
||||
channelLastMessageIds.set(dm.id, dm.lastMessage.id);
|
||||
}
|
||||
}
|
||||
set({
|
||||
servers: simpleServers,
|
||||
folders: folders || [],
|
||||
dmChannels: dms,
|
||||
channelToServerMap,
|
||||
channelLastMessageIds,
|
||||
});
|
||||
},
|
||||
}));
|
||||
/**
|
||||
* Data-driven DM channel detection. Returns true if the given channelId
|
||||
* belongs to a DM channel. Authoritative because dmChannels is populated
|
||||
* from the WS ready event and DM/server channel IDs never overlap.
|
||||
*/
|
||||
export function isDmChannel(channelId) {
|
||||
const dmChannels = useServerStore.getState().dmChannels;
|
||||
if (dmChannels.length > 0) {
|
||||
return dmChannels.some(dm => dm.id === channelId);
|
||||
}
|
||||
// Before WS ready populates dmChannels, fall back to URL path
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.pathname.startsWith('/channels/@me/');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
export const useSocialStore = create((set, get) => ({
|
||||
friends: [],
|
||||
requests: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
loadFriends: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const friends = await api.social.friends();
|
||||
set({ friends, isLoading: false });
|
||||
}
|
||||
catch (err) {
|
||||
set({ error: err.message, isLoading: false });
|
||||
}
|
||||
},
|
||||
loadRequests: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const requests = await api.social.requests();
|
||||
set({ requests, isLoading: false });
|
||||
}
|
||||
catch (err) {
|
||||
set({ error: err.message, isLoading: false });
|
||||
}
|
||||
},
|
||||
sendFriendRequest: async (username) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await api.social.sendRequest(username);
|
||||
await get().loadRequests();
|
||||
}
|
||||
catch (err) {
|
||||
set({ error: err.message, isLoading: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
updateFriendRequest: async (id, status) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await api.social.updateRequest(id, status);
|
||||
await get().loadRequests();
|
||||
if (status === 'accepted') {
|
||||
await get().loadFriends();
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
set({ error: err.message, isLoading: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
cancelFriendRequest: async (id) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await api.social.cancelRequest(id);
|
||||
set((state) => ({
|
||||
requests: state.requests.filter(r => r.id !== id),
|
||||
isLoading: false,
|
||||
}));
|
||||
}
|
||||
catch (err) {
|
||||
set({ error: err.message, isLoading: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
removeFriend: async (id) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await api.social.removeFriend(id);
|
||||
set((state) => ({
|
||||
friends: state.friends.filter((f) => f.id !== id),
|
||||
isLoading: false,
|
||||
}));
|
||||
}
|
||||
catch (err) {
|
||||
set({ error: err.message, isLoading: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
searchUsers: async (query) => {
|
||||
try {
|
||||
return await api.social.search(query);
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to search users:', err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
// Called from WS handler when another user sends you a friend request
|
||||
addIncomingRequest: (request) => {
|
||||
set((state) => {
|
||||
if (state.requests.find(r => r.id === request.id))
|
||||
return state;
|
||||
return { requests: [...state.requests, request] };
|
||||
});
|
||||
},
|
||||
// Called from WS handler when someone accepts your friend request
|
||||
addFriendFromAccepted: (friend, requestId) => {
|
||||
set((state) => ({
|
||||
friends: state.friends.find(f => f.id === friend.id) ? state.friends : [...state.friends, friend],
|
||||
requests: state.requests.filter(r => r.id !== requestId),
|
||||
}));
|
||||
},
|
||||
// Called from WS handler when the other user removes us as a friend
|
||||
removeFriendLocally: (userId) => {
|
||||
set((state) => ({
|
||||
friends: state.friends.filter(f => f.id !== userId),
|
||||
}));
|
||||
},
|
||||
// Called from WS handler on presence_update to keep friend status live
|
||||
updateFriendPresence: (userId, status) => {
|
||||
set((state) => ({
|
||||
friends: state.friends.map(f =>
|
||||
f.id === userId ? { ...f, status } : f
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -1,59 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
export const useUIStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sidebarOpen: true,
|
||||
memberListOpen: true,
|
||||
activeModal: null,
|
||||
modalData: {},
|
||||
isMobile: false,
|
||||
showDms: false,
|
||||
imagePreviewUrl: null,
|
||||
userProfilePopout: {
|
||||
user: null,
|
||||
position: null,
|
||||
},
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
||||
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
|
||||
closeModal: () => set({ activeModal: null, modalData: {} }),
|
||||
setIsMobile: (isMobile) => {
|
||||
const prev = get().isMobile;
|
||||
if (prev === isMobile)
|
||||
return;
|
||||
if (isMobile) {
|
||||
set({ isMobile, sidebarOpen: false, memberListOpen: false });
|
||||
}
|
||||
else {
|
||||
// On desktop transition, restore sidebarOpen but leave memberListOpen
|
||||
// at its persisted/toggled value — don't override user preference
|
||||
set({ isMobile, sidebarOpen: true });
|
||||
}
|
||||
},
|
||||
setShowDms: (show) => set({ showDms: show }),
|
||||
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
||||
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
||||
openUserProfile: (user, position) => set({
|
||||
userProfilePopout: { user, position }
|
||||
}),
|
||||
closeUserProfile: () => set({
|
||||
userProfilePopout: { user: null, position: null }
|
||||
}),
|
||||
voiceChatOpen: false,
|
||||
voiceFullscreen: false,
|
||||
pipCollapsed: false,
|
||||
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
|
||||
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
|
||||
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
|
||||
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
|
||||
}),
|
||||
{
|
||||
name: 'opencord-ui-settings',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
memberListOpen: state.memberListOpen,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,242 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { AudioManager } from '../audio/AudioManager';
|
||||
export const useVoiceStore = create()(persist((set, get) => ({
|
||||
voiceUsers: new Map(),
|
||||
currentVoiceChannelId: null,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isCameraOn: false,
|
||||
isScreenSharing: false,
|
||||
participants: [],
|
||||
connectionError: null,
|
||||
isLiveKitConnected: false,
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
inputDeviceId: 'default',
|
||||
outputDeviceId: 'default',
|
||||
focusedParticipantId: null,
|
||||
videoQuality: '720p60',
|
||||
participantVolumes: new Map(),
|
||||
setParticipantVolume: (userId, volume) => {
|
||||
set((state) => {
|
||||
const newMap = new Map(state.participantVolumes);
|
||||
newMap.set(userId, volume);
|
||||
return { participantVolumes: newMap };
|
||||
});
|
||||
},
|
||||
getParticipantVolume: (userId) => get().participantVolumes.get(userId) ?? 100,
|
||||
// Stream widget state
|
||||
streamVolumes: new Map(),
|
||||
streamMutes: new Map(),
|
||||
watchingStreams: new Set(),
|
||||
streamAttenuationEnabled: false,
|
||||
streamAttenuationStrength: 50,
|
||||
setStreamVolume: (userId, volume) => {
|
||||
set((state) => {
|
||||
const newMap = new Map(state.streamVolumes);
|
||||
newMap.set(userId, volume);
|
||||
return { streamVolumes: newMap };
|
||||
});
|
||||
},
|
||||
setStreamMute: (userId, muted) => {
|
||||
set((state) => {
|
||||
const newMap = new Map(state.streamMutes);
|
||||
newMap.set(userId, muted);
|
||||
return { streamMutes: newMap };
|
||||
});
|
||||
},
|
||||
watchStream: (userId) => {
|
||||
set((state) => {
|
||||
const newSet = new Set(state.watchingStreams);
|
||||
newSet.add(userId);
|
||||
return { watchingStreams: newSet };
|
||||
});
|
||||
},
|
||||
unwatchStream: (userId) => {
|
||||
set((state) => {
|
||||
const newSet = new Set(state.watchingStreams);
|
||||
newSet.delete(userId);
|
||||
return { watchingStreams: newSet };
|
||||
});
|
||||
},
|
||||
clearStreamVolume: (userId) => {
|
||||
set((state) => {
|
||||
const newMap = new Map(state.streamVolumes);
|
||||
newMap.delete(userId);
|
||||
return { streamVolumes: newMap };
|
||||
});
|
||||
},
|
||||
clearStreamMute: (userId) => {
|
||||
set((state) => {
|
||||
const newMap = new Map(state.streamMutes);
|
||||
newMap.delete(userId);
|
||||
return { streamMutes: newMap };
|
||||
});
|
||||
},
|
||||
setStreamAttenuationEnabled: (enabled) => set({ streamAttenuationEnabled: enabled }),
|
||||
setStreamAttenuationStrength: (strength) => set({ streamAttenuationStrength: strength }),
|
||||
incomingCall: null,
|
||||
outgoingCall: null,
|
||||
activeDmCall: null,
|
||||
setIncomingCall: (call) => set({ incomingCall: call }),
|
||||
setOutgoingCall: (call) => set({ outgoingCall: call }),
|
||||
setActiveDmCall: (call) => set({ activeDmCall: call }),
|
||||
setVoiceUsers: (channelId, userIds) => {
|
||||
set((state) => {
|
||||
const newMap = new Map(state.voiceUsers);
|
||||
newMap.set(channelId, userIds);
|
||||
return { voiceUsers: newMap };
|
||||
});
|
||||
},
|
||||
addVoiceUser: (channelId, userId) => {
|
||||
set((state) => {
|
||||
const newMap = new Map(state.voiceUsers);
|
||||
const current = newMap.get(channelId) ?? [];
|
||||
if (!current.includes(userId)) {
|
||||
newMap.set(channelId, [...current, userId]);
|
||||
}
|
||||
return { voiceUsers: newMap };
|
||||
});
|
||||
},
|
||||
removeVoiceUser: (channelId, userId) => {
|
||||
set((state) => {
|
||||
const newMap = new Map(state.voiceUsers);
|
||||
const current = newMap.get(channelId) ?? [];
|
||||
newMap.set(channelId, current.filter(id => id !== userId));
|
||||
return { voiceUsers: newMap };
|
||||
});
|
||||
},
|
||||
setCurrentVoiceChannel: (channelId) => set({
|
||||
currentVoiceChannelId: channelId,
|
||||
activeDmCall: null // Clear active DM call when joining a server channel
|
||||
}),
|
||||
setParticipants: (participants) => set({ participants }),
|
||||
setConnectionError: (error) => set({ connectionError: error }),
|
||||
setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }),
|
||||
setInputVolume: (volume) => {
|
||||
set({ inputVolume: volume });
|
||||
AudioManager.getInstance().setInputVolume(volume);
|
||||
},
|
||||
setOutputVolume: (volume) => set({ outputVolume: volume }),
|
||||
setInputDevice: (deviceId) => set({ inputDeviceId: deviceId }),
|
||||
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
|
||||
toggleMic: () => set((state) => ({ isMuted: !state.isMuted })),
|
||||
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
|
||||
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
|
||||
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
|
||||
setFocusedParticipant: (id) => set({ focusedParticipantId: id }),
|
||||
setVideoQuality: (quality) => set({ videoQuality: quality }),
|
||||
noiseSuppression: true,
|
||||
echoCancellation: true,
|
||||
autoGainControl: false,
|
||||
rnnoiseEnabled: true,
|
||||
setEchoCancellation: (enabled) => set({ echoCancellation: enabled }),
|
||||
setAutoGainControl: (enabled) => set({ autoGainControl: enabled }),
|
||||
setRnnoiseEnabled: (enabled) => set({ rnnoiseEnabled: enabled }),
|
||||
deafenedUserIds: new Set(),
|
||||
setUserDeafened: (userId, deafened) => {
|
||||
set((state) => {
|
||||
const newSet = new Set(state.deafenedUserIds);
|
||||
if (deafened)
|
||||
newSet.add(userId);
|
||||
else
|
||||
newSet.delete(userId);
|
||||
return { deafenedUserIds: newSet };
|
||||
});
|
||||
},
|
||||
voiceUserStates: new Map(),
|
||||
setVoiceUserStatus: (userId, isMuted, isDeafened) => {
|
||||
set((state) => {
|
||||
const newMap = new Map(state.voiceUserStates);
|
||||
newMap.set(userId, { isMuted, isDeafened });
|
||||
return { voiceUserStates: newMap };
|
||||
});
|
||||
},
|
||||
clearVoiceUserStatus: (userId) => {
|
||||
set((state) => {
|
||||
const newMap = new Map(state.voiceUserStates);
|
||||
newMap.delete(userId);
|
||||
return { voiceUserStates: newMap };
|
||||
});
|
||||
},
|
||||
getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
|
||||
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
|
||||
// Leave voice without wiping the voiceUsers map (so sidebar still shows others)
|
||||
leaveVoice: () => set({
|
||||
currentVoiceChannelId: null,
|
||||
isCameraOn: false,
|
||||
isScreenSharing: false,
|
||||
participants: [],
|
||||
connectionError: null,
|
||||
isLiveKitConnected: false,
|
||||
focusedParticipantId: null,
|
||||
activeDmCall: null,
|
||||
outgoingCall: null,
|
||||
deafenedUserIds: new Set(),
|
||||
streamVolumes: new Map(),
|
||||
streamMutes: new Map(),
|
||||
watchingStreams: new Set(),
|
||||
}),
|
||||
reset: () => set({
|
||||
voiceUsers: new Map(),
|
||||
currentVoiceChannelId: null,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isCameraOn: false,
|
||||
isScreenSharing: false,
|
||||
participants: [],
|
||||
connectionError: null,
|
||||
isLiveKitConnected: false,
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
inputDeviceId: 'default',
|
||||
outputDeviceId: 'default',
|
||||
focusedParticipantId: null,
|
||||
participantVolumes: new Map(),
|
||||
incomingCall: null,
|
||||
outgoingCall: null,
|
||||
activeDmCall: null,
|
||||
deafenedUserIds: new Set(),
|
||||
voiceUserStates: new Map(),
|
||||
streamVolumes: new Map(),
|
||||
streamMutes: new Map(),
|
||||
watchingStreams: new Set(),
|
||||
}),
|
||||
}), {
|
||||
name: 'opencord-voice-settings',
|
||||
version: 4,
|
||||
migrate: (persistedState, version) => {
|
||||
if (version === 0) {
|
||||
persistedState.streamAttenuationEnabled = false;
|
||||
}
|
||||
if (version < 2) {
|
||||
persistedState.echoCancellation = true;
|
||||
persistedState.autoGainControl = false;
|
||||
}
|
||||
if (version < 4) {
|
||||
// v4: RNNoise on by default, browser NS is no longer user-configurable
|
||||
persistedState.rnnoiseEnabled = true;
|
||||
persistedState.noiseSuppression = true;
|
||||
}
|
||||
return persistedState;
|
||||
},
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
// Only persist these keys. Maps and Sets are complex to serialize.
|
||||
// noiseSuppression intentionally excluded — always true, managed by AudioManager.
|
||||
partialize: (state) => ({
|
||||
currentVoiceChannelId: state.currentVoiceChannelId,
|
||||
isMuted: state.isMuted,
|
||||
isDeafened: state.isDeafened,
|
||||
inputVolume: state.inputVolume,
|
||||
outputVolume: state.outputVolume,
|
||||
inputDeviceId: state.inputDeviceId,
|
||||
outputDeviceId: state.outputDeviceId,
|
||||
videoQuality: state.videoQuality,
|
||||
echoCancellation: state.echoCancellation,
|
||||
autoGainControl: state.autoGainControl,
|
||||
rnnoiseEnabled: state.rnnoiseEnabled,
|
||||
streamAttenuationEnabled: state.streamAttenuationEnabled,
|
||||
streamAttenuationStrength: state.streamAttenuationStrength,
|
||||
}),
|
||||
}));
|
||||
@@ -1 +0,0 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -11,6 +11,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
|
||||
Reference in New Issue
Block a user