feat: Implement core Discord clone functionality including Convex backend services for authentication, channels, messages, roles, and voice state, alongside new Electron frontend components for chat, voice, server settings, and user interface.
All checks were successful
Build and Release / build-and-release (push) Successful in 14m19s
All checks were successful
Build and Release / build-and-release (push) Successful in 14m19s
This commit is contained in:
@@ -11,7 +11,7 @@ import DMList from './DMList';
|
||||
import Avatar from './Avatar';
|
||||
import UserSettings from './UserSettings';
|
||||
import { Track } from 'livekit-client';
|
||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
|
||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay, useDraggable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import muteIcon from '../assets/icons/mute.svg';
|
||||
@@ -24,13 +24,18 @@ import disconnectIcon from '../assets/icons/disconnect.svg';
|
||||
import cameraIcon from '../assets/icons/camera.svg';
|
||||
import screenIcon from '../assets/icons/screen.svg';
|
||||
import inviteUserIcon from '../assets/icons/invite_user.svg';
|
||||
import personalMuteIcon from '../assets/icons/personal_mute.svg';
|
||||
import serverMuteIcon from '../assets/icons/server_mute.svg';
|
||||
import categoryCollapsedIcon from '../assets/icons/category_collapsed_icon.svg';
|
||||
import PingSound from '../assets/sounds/ping.mp3';
|
||||
import screenShareStartSound from '../assets/sounds/screenshare_start.mp3';
|
||||
import screenShareStopSound from '../assets/sounds/screenshare_stop.mp3';
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
|
||||
const ICON_COLOR_ACTIVE = 'hsl(357.692 67.826% 54.902% / 1)';
|
||||
const SERVER_MUTE_RED = 'color-mix(in oklab, hsl(1.343 calc(1*84.81%) 69.02% /1) 100%, #000 0%)';
|
||||
|
||||
const controlButtonStyle = {
|
||||
background: 'transparent',
|
||||
@@ -114,6 +119,8 @@ const UserControlPanel = ({ username, userId }) => {
|
||||
const [currentStatus, setCurrentStatus] = useState('online');
|
||||
const updateStatusMutation = useMutation(api.auth.updateStatus);
|
||||
const navigate = useNavigate();
|
||||
const manualStatusRef = useRef(false);
|
||||
const preIdleStatusRef = useRef('online');
|
||||
|
||||
// Fetch stored status preference from server and sync local state
|
||||
const allUsers = useQuery(api.auth.getPublicKeys) || [];
|
||||
@@ -122,9 +129,12 @@ const UserControlPanel = ({ username, userId }) => {
|
||||
if (myUser) {
|
||||
if (myUser.status && myUser.status !== 'offline') {
|
||||
setCurrentStatus(myUser.status);
|
||||
// dnd/invisible are manual overrides; idle is auto-set so don't count it
|
||||
manualStatusRef.current = (myUser.status === 'dnd' || myUser.status === 'invisible');
|
||||
} else if (!myUser.status || myUser.status === 'offline') {
|
||||
// First login or no preference set yet — default to "online"
|
||||
setCurrentStatus('online');
|
||||
manualStatusRef.current = false;
|
||||
if (userId) {
|
||||
updateStatusMutation({ userId, status: 'online' }).catch(() => {});
|
||||
}
|
||||
@@ -153,6 +163,7 @@ const UserControlPanel = ({ username, userId }) => {
|
||||
const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c';
|
||||
|
||||
const handleStatusChange = async (status) => {
|
||||
manualStatusRef.current = (status !== 'online');
|
||||
setCurrentStatus(status);
|
||||
setShowStatusMenu(false);
|
||||
if (userId) {
|
||||
@@ -164,6 +175,25 @@ const UserControlPanel = ({ username, userId }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-idle detection via Electron powerMonitor
|
||||
useEffect(() => {
|
||||
if (!window.idleAPI || !userId) return;
|
||||
const handleIdleChange = (data) => {
|
||||
if (manualStatusRef.current) return;
|
||||
if (data.isIdle) {
|
||||
preIdleStatusRef.current = currentStatus;
|
||||
setCurrentStatus('idle');
|
||||
updateStatusMutation({ userId, status: 'idle' }).catch(() => {});
|
||||
} else {
|
||||
const restoreTo = preIdleStatusRef.current || 'online';
|
||||
setCurrentStatus(restoreTo);
|
||||
updateStatusMutation({ userId, status: restoreTo }).catch(() => {});
|
||||
}
|
||||
};
|
||||
window.idleAPI.onIdleStateChanged(handleIdleChange);
|
||||
return () => window.idleAPI.removeIdleStateListener();
|
||||
}, [userId]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '64px',
|
||||
@@ -331,7 +361,12 @@ function getScreenCaptureConstraints(selection) {
|
||||
return { video: { deviceId: { exact: selection.deviceId } }, audio: false };
|
||||
}
|
||||
return {
|
||||
audio: false,
|
||||
audio: selection.shareAudio ? {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: selection.sourceId
|
||||
}
|
||||
} : false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
@@ -341,6 +376,82 @@ function getScreenCaptureConstraints(selection) {
|
||||
};
|
||||
}
|
||||
|
||||
const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onMessage }) => {
|
||||
const menuRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: y, left: x });
|
||||
|
||||
useEffect(() => {
|
||||
const h = () => onClose();
|
||||
window.addEventListener('click', h);
|
||||
return () => window.removeEventListener('click', h);
|
||||
}, [onClose]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!menuRef.current) return;
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
let newTop = y, newLeft = x;
|
||||
if (x + rect.width > window.innerWidth) newLeft = x - rect.width;
|
||||
if (y + rect.height > window.innerHeight) newTop = y - rect.height;
|
||||
if (newLeft < 0) newLeft = 10;
|
||||
if (newTop < 0) newTop = 10;
|
||||
setPos({ top: newTop, left: newLeft });
|
||||
}, [x, y]);
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="context-menu-item context-menu-checkbox-item"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={isMuted}
|
||||
onClick={(e) => { e.stopPropagation(); onMute(); }}
|
||||
>
|
||||
<span>Mute</span>
|
||||
<div className="context-menu-checkbox">
|
||||
<div className={`context-menu-checkbox-indicator ${isMuted ? 'checked' : ''}`}>
|
||||
{isMuted ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24">
|
||||
<path fill="white" d="M8.99991 16.17L4.82991 12L3.40991 13.41L8.99991 19L20.9999 7L19.5899 5.59L8.99991 16.17Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasPermission && (
|
||||
<div
|
||||
className="context-menu-item context-menu-checkbox-item"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={isServerMuted}
|
||||
onClick={(e) => { e.stopPropagation(); onServerMute(); }}
|
||||
>
|
||||
<span style={{ color: SERVER_MUTE_RED }}>Server Mute</span>
|
||||
<div className="context-menu-checkbox">
|
||||
<div
|
||||
className={`context-menu-checkbox-indicator ${isServerMuted ? 'checked' : ''}`}
|
||||
style={isServerMuted ? { backgroundColor: SERVER_MUTE_RED, borderColor: SERVER_MUTE_RED } : {}}
|
||||
>
|
||||
{isServerMuted ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24">
|
||||
<path fill="white" d="M8.99991 16.17L4.82991 12L3.40991 13.41L8.99991 19L20.9999 7L19.5899 5.59L8.99991 16.17Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-separator" />
|
||||
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onMessage(); onClose(); }}>
|
||||
<span>Message</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChannelListContextMenu = ({ x, y, onClose, onCreateChannel, onCreateCategory }) => {
|
||||
const menuRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: y, left: x });
|
||||
@@ -580,7 +691,29 @@ const SortableChannel = ({ id, children }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
{typeof children === 'function' ? children(listeners) : children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `voice-user-${userId}`,
|
||||
data: { type: 'voice-user', userId, channelId },
|
||||
disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
cursor: disabled ? 'default' : 'grab',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -595,13 +728,21 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||
const [collapsedCategories, setCollapsedCategories] = useState({});
|
||||
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
|
||||
const [voiceUserMenu, setVoiceUserMenu] = useState(null);
|
||||
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
|
||||
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
|
||||
const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null);
|
||||
const [activeDragItem, setActiveDragItem] = useState(null);
|
||||
const [dragOverChannelId, setDragOverChannelId] = useState(null);
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
// Permissions for move_members gating
|
||||
const myPermissions = useQuery(
|
||||
api.roles.getMyPermissions,
|
||||
userId ? { userId } : "skip"
|
||||
) || {};
|
||||
|
||||
// DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||
@@ -674,7 +815,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
if (activeChannel === id) onSelectChannel(null);
|
||||
};
|
||||
|
||||
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing } = useVoice();
|
||||
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing, isPersonallyMuted, togglePersonalMute, isMuted: selfMuted, toggleMute, serverMute, isServerMuted, serverSettings } = useVoice();
|
||||
|
||||
const handleStartCreate = () => {
|
||||
setIsCreating(true);
|
||||
@@ -772,7 +913,19 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection));
|
||||
let stream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection));
|
||||
} catch (audioErr) {
|
||||
// Audio capture may fail (e.g. macOS/Linux) — retry video-only
|
||||
if (selection.shareAudio) {
|
||||
console.warn("Audio capture failed, falling back to video-only:", audioErr.message);
|
||||
stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints({ ...selection, shareAudio: false }));
|
||||
} else {
|
||||
throw audioErr;
|
||||
}
|
||||
}
|
||||
|
||||
const track = stream.getVideoTracks()[0];
|
||||
if (!track) return;
|
||||
|
||||
@@ -781,9 +934,24 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
source: Track.Source.ScreenShare
|
||||
});
|
||||
|
||||
// Publish audio track if present (system audio from desktop capture)
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
await room.localParticipant.publishTrack(audioTrack, {
|
||||
name: 'screen_share_audio',
|
||||
source: Track.Source.ScreenShareAudio
|
||||
});
|
||||
}
|
||||
|
||||
new Audio(screenShareStartSound).play();
|
||||
setScreenSharing(true);
|
||||
|
||||
track.onended = () => {
|
||||
// Clean up audio track when video track ends
|
||||
if (audioTrack) {
|
||||
audioTrack.stop();
|
||||
room.localParticipant.unpublishTrack(audioTrack);
|
||||
}
|
||||
setScreenSharing(false);
|
||||
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
|
||||
};
|
||||
@@ -795,7 +963,17 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
|
||||
const handleScreenShareClick = () => {
|
||||
if (room?.localParticipant.isScreenShareEnabled) {
|
||||
// Clean up any screen share audio tracks before stopping
|
||||
for (const pub of room.localParticipant.trackPublications.values()) {
|
||||
const source = pub.source ? pub.source.toString().toLowerCase() : '';
|
||||
const name = pub.trackName ? pub.trackName.toLowerCase() : '';
|
||||
if (source === 'screen_share_audio' || name === 'screen_share_audio') {
|
||||
if (pub.track) pub.track.stop();
|
||||
room.localParticipant.unpublishTrack(pub.track);
|
||||
}
|
||||
}
|
||||
room.localParticipant.setScreenShareEnabled(false);
|
||||
new Audio(screenShareStopSound).play();
|
||||
setScreenSharing(false);
|
||||
} else {
|
||||
setIsScreenShareModalOpen(true);
|
||||
@@ -828,31 +1006,83 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
return (
|
||||
<div style={{ marginLeft: 32, marginBottom: 8 }}>
|
||||
{users.map(user => (
|
||||
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Avatar
|
||||
username={user.username}
|
||||
size={24}
|
||||
style={{
|
||||
marginRight: 8,
|
||||
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
|
||||
<DraggableVoiceUser
|
||||
key={user.userId}
|
||||
userId={user.userId}
|
||||
channelId={channel._id}
|
||||
disabled={!myPermissions.move_members}
|
||||
>
|
||||
<div
|
||||
className="voice-user-item"
|
||||
style={{ display: 'flex', alignItems: 'center', marginBottom: 2 }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setVoiceUserMenu({ x: e.clientX, y: e.clientY, user });
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span>
|
||||
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center', marginRight: "16px" }}>
|
||||
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
|
||||
{(user.isMuted || user.isDeafened) && (
|
||||
<ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />
|
||||
)}
|
||||
{user.isDeafened && (
|
||||
<ColoredIcon src={defeanedIcon} color="var(--header-secondary)" size="14px" />
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={24}
|
||||
style={{
|
||||
marginRight: 8,
|
||||
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span>
|
||||
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center', marginRight: "16px" }}>
|
||||
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
|
||||
{user.isServerMuted ? (
|
||||
<ColoredIcon src={serverMuteIcon} color={SERVER_MUTE_RED} size="14px" />
|
||||
) : isPersonallyMuted(user.userId) ? (
|
||||
<ColoredIcon src={personalMuteIcon} color="var(--header-secondary)" size="14px" />
|
||||
) : (user.isMuted || user.isDeafened) ? (
|
||||
<ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />
|
||||
) : null}
|
||||
{user.isDeafened && (
|
||||
<ColoredIcon src={defeanedIcon} color="var(--header-secondary)" size="14px" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableVoiceUser>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCollapsedVoiceUsers = (channel) => {
|
||||
const users = voiceStates[channel._id];
|
||||
if (channel.type !== 'voice' || !users?.length) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`channel-item ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
||||
onClick={() => handleChannelClick(channel)}
|
||||
style={{ position: 'relative', display: 'flex', alignItems: 'center', paddingRight: '8px' }}
|
||||
>
|
||||
<div style={{ marginRight: 6 }}>
|
||||
<ColoredIcon src={voiceIcon} size="16px" color={VOICE_ACTIVE_COLOR} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{users.map(user => (
|
||||
<div key={user.userId} style={{ marginRight: -6, position: 'relative', zIndex: 1 }}>
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={24}
|
||||
style={{
|
||||
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCategory = (cat) => {
|
||||
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
|
||||
};
|
||||
@@ -901,17 +1131,65 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
const chId = active.id.replace('channel-', '');
|
||||
const ch = channels.find(c => c._id === chId);
|
||||
setActiveDragItem({ type: 'channel', channel: ch });
|
||||
} else if (activeType === 'voice-user') {
|
||||
const targetUserId = active.data.current.userId;
|
||||
const sourceChannelId = active.data.current.channelId;
|
||||
const users = voiceStates[sourceChannelId];
|
||||
const user = users?.find(u => u.userId === targetUserId);
|
||||
setActiveDragItem({ type: 'voice-user', user, sourceChannelId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event) => {
|
||||
const { active, over } = event;
|
||||
if (!active?.data.current || active.data.current.type !== 'voice-user') {
|
||||
setDragOverChannelId(null);
|
||||
return;
|
||||
}
|
||||
if (over) {
|
||||
// Check if hovering over a voice channel (channel item or its DnD wrapper)
|
||||
const overType = over.data.current?.type;
|
||||
if (overType === 'channel') {
|
||||
const chId = over.id.replace('channel-', '');
|
||||
const ch = channels.find(c => c._id === chId);
|
||||
if (ch?.type === 'voice') {
|
||||
setDragOverChannelId(ch._id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setDragOverChannelId(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event) => {
|
||||
setActiveDragItem(null);
|
||||
setDragOverChannelId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const activeType = active.data.current?.type;
|
||||
const overType = over.data.current?.type;
|
||||
|
||||
// Handle voice-user drag
|
||||
if (activeType === 'voice-user') {
|
||||
if (overType !== 'channel') return;
|
||||
const targetChId = over.id.replace('channel-', '');
|
||||
const targetChannel = channels.find(c => c._id === targetChId);
|
||||
if (!targetChannel || targetChannel.type !== 'voice') return;
|
||||
const sourceChannelId = active.data.current.channelId;
|
||||
if (sourceChannelId === targetChId) return;
|
||||
try {
|
||||
await convex.mutation(api.voiceState.moveUser, {
|
||||
actorUserId: userId,
|
||||
targetUserId: active.data.current.userId,
|
||||
targetChannelId: targetChId,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to move voice user:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeType === 'category' && overType === 'category') {
|
||||
// Reorder categories
|
||||
const oldIndex = groupedChannels.findIndex(g => `category-${g.id}` === active.id);
|
||||
@@ -1043,6 +1321,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={categoryDndIds} strategy={verticalListSortingStrategy}>
|
||||
@@ -1060,8 +1339,12 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const visibleChannels = collapsedCategories[group.id]
|
||||
? group.channels.filter(ch => ch._id === activeChannel)
|
||||
const isCollapsed = collapsedCategories[group.id];
|
||||
const visibleChannels = isCollapsed
|
||||
? group.channels.filter(ch =>
|
||||
ch._id === activeChannel ||
|
||||
(ch.type === 'voice' && voiceStates[ch._id]?.length > 0)
|
||||
)
|
||||
: group.channels;
|
||||
if (visibleChannels.length === 0) return null;
|
||||
const visibleDndIds = visibleChannels.map(ch => `channel-${ch._id}`);
|
||||
@@ -1071,10 +1354,12 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id);
|
||||
return (
|
||||
<SortableChannel key={channel._id} id={`channel-${channel._id}`}>
|
||||
{(channelDragListeners) => (
|
||||
<React.Fragment>
|
||||
<div
|
||||
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
||||
{!(isCollapsed && channel.type === 'voice' && voiceStates[channel._id]?.length > 0) && <div
|
||||
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''} ${dragOverChannelId === channel._id ? 'voice-drop-target' : ''}`}
|
||||
onClick={() => handleChannelClick(channel)}
|
||||
{...channelDragListeners}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
@@ -1096,7 +1381,9 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
) : (
|
||||
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px', flexShrink: 0 }}>#</span>
|
||||
)}
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', ...(isUnread ? { color: 'var(--header-primary)', fontWeight: 600 } : {}) }}>{channel.name}</span>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', ...(isUnread ? { color: 'var(--header-primary)', fontWeight: 600 } : {}) }}>
|
||||
{channel.name}{serverSettings?.afkChannelId === channel._id ? ' (AFK)' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -1115,9 +1402,12 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
>
|
||||
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
|
||||
</button>
|
||||
</div>
|
||||
{renderVoiceUsers(channel)}
|
||||
</div>}
|
||||
{isCollapsed
|
||||
? renderCollapsedVoiceUsers(channel)
|
||||
: renderVoiceUsers(channel)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</SortableChannel>
|
||||
);
|
||||
})}
|
||||
@@ -1145,6 +1435,17 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
{activeDragItem.name}
|
||||
</div>
|
||||
)}
|
||||
{activeDragItem?.type === 'voice-user' && activeDragItem.user && (
|
||||
<div className="drag-overlay-voice-user">
|
||||
<Avatar
|
||||
username={activeDragItem.user.username}
|
||||
avatarUrl={activeDragItem.user.avatarUrl}
|
||||
size={24}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span>{activeDragItem.user.username}</span>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
@@ -1283,6 +1584,23 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
onCreateCategory={() => setShowCreateCategoryModal(true)}
|
||||
/>
|
||||
)}
|
||||
{voiceUserMenu && (
|
||||
<VoiceUserContextMenu
|
||||
x={voiceUserMenu.x}
|
||||
y={voiceUserMenu.y}
|
||||
user={voiceUserMenu.user}
|
||||
onClose={() => setVoiceUserMenu(null)}
|
||||
isMuted={voiceUserMenu.user.userId === userId ? selfMuted : isPersonallyMuted(voiceUserMenu.user.userId)}
|
||||
onMute={() => voiceUserMenu.user.userId === userId ? toggleMute() : togglePersonalMute(voiceUserMenu.user.userId)}
|
||||
isServerMuted={isServerMuted(voiceUserMenu.user.userId)}
|
||||
onServerMute={() => serverMute(voiceUserMenu.user.userId, !isServerMuted(voiceUserMenu.user.userId))}
|
||||
hasPermission={!!myPermissions.mute_members}
|
||||
onMessage={() => {
|
||||
onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.username);
|
||||
onViewChange('me');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showCreateChannelModal && (
|
||||
<CreateChannelModal
|
||||
categoryId={createChannelCategoryId}
|
||||
|
||||
Reference in New Issue
Block a user