feat: Implement Direct Messaging with new frontend components, user search, and backend read state management.
All checks were successful
Build and Release / build-and-release (push) Successful in 10m9s

This commit is contained in:
Bryan1029384756
2026-02-11 08:01:51 -06:00
parent 44814011fe
commit e773ab41ae
10 changed files with 154 additions and 30 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "discord",
"private": true,
"version": "1.0.3",
"version": "1.0.4",
"description": "A Discord clone built with Convex, React, and Electron",
"author": "Moyettes",
"type": "module",

View File

@@ -0,0 +1 @@
<svg class="linkButtonIcon__972a0" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M13 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" class=""></path><path fill="currentColor" d="M3 5v-.75C3 3.56 3.56 3 4.25 3s1.24.56 1.33 1.25C6.12 8.65 9.46 12 13 12h1a8 8 0 0 1 8 8 2 2 0 0 1-2 2 .21.21 0 0 1-.2-.15 7.65 7.65 0 0 0-1.32-2.3c-.15-.2-.42-.06-.39.17l.25 2c.02.15-.1.28-.25.28H9a2 2 0 0 1-2-2v-2.22c0-1.57-.67-3.05-1.53-4.37A15.85 15.85 0 0 1 3 5Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 557 B

View File

@@ -24,6 +24,7 @@ import TypingIcon from './typing.svg';
import DMIcon from './dm.svg';
import SpoilerIcon from './spoiler.svg';
import CrownIcon from './crown.svg';
import FriendsIcon from './friends.svg';
export {
AddIcon,
@@ -51,7 +52,8 @@ export {
TypingIcon,
DMIcon,
SpoilerIcon,
CrownIcon
CrownIcon,
FriendsIcon
};
export const Icons = {
@@ -80,5 +82,6 @@ export const Icons = {
Typing: TypingIcon,
DM: DMIcon,
Spoiler: SpoilerIcon,
Crown: CrownIcon
Crown: CrownIcon,
Friends: FriendsIcon
};

View File

@@ -692,10 +692,13 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
if (!currentUserId || !channelId || !decryptedMessages.length) return;
const lastMsg = decryptedMessages[decryptedMessages.length - 1];
if (!lastMsg?.created_at) return;
markReadMutation({ userId: currentUserId, channelId, lastReadTimestamp: lastMsg.created_at }).catch(() => {});
markReadMutation({ userId: currentUserId, channelId, lastReadTimestamp: new Date(lastMsg.created_at).getTime() }).catch(() => {});
setUnreadDividerTimestamp(null);
}, [currentUserId, channelId, decryptedMessages, markReadMutation]);
const markChannelAsReadRef = useRef(markChannelAsRead);
markChannelAsReadRef.current = markChannelAsRead;
const typingUsers = typingData.filter(t => t.username !== username);
const filteredMentionMembers = mentionQuery !== null ? filterMembersForMention(members, mentionQuery) : [];
@@ -775,7 +778,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
// Mark as read on initial load (already scrolled to bottom)
useEffect(() => {
if (!isInitialLoadRef.current && decryptedMessages.length > 0) {
if (decryptedMessages.length > 0) {
const container = messagesContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
@@ -785,6 +788,13 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
}
}, [decryptedMessages.length, markChannelAsRead]);
// Mark as read when component unmounts (e.g., switching to voice channel)
useEffect(() => {
return () => {
markChannelAsReadRef.current();
};
}, []);
const saveSelection = () => {
const sel = window.getSelection();
if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange();

View File

@@ -4,6 +4,7 @@ import { api } from '../../../../convex/_generated/api';
import Tooltip from './Tooltip';
import Avatar from './Avatar';
import { useOnlineUsers } from '../contexts/PresenceContext';
import friendsIcon from '../assets/icons/friends.svg';
const STATUS_COLORS = {
online: '#3ba55c',
@@ -86,7 +87,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
};
return (
<div style={{ padding: '8px', flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Search Input */}
<div className="dm-search-wrapper">
<input
@@ -179,23 +180,33 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
onClick={() => onSelectDM('friends')}
>
<div style={{ marginRight: '12px' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M13.5 2C13.5 2.82843 12.8284 3.5 12 3.5C11.1716 3.5 10.5 2.82843 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2Z" fill="currentColor"/>
<path d="M7 13C7 11.8954 7.89543 11 9 11H15C16.1046 11 17 11.8954 17 13V15H7V13Z" fill="#fff"/>
</svg>
<div style={{
width: 24,
height: 24,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
<img
src={friendsIcon}
alt=""
style={{
width: 24,
height: 24,
transform: 'translateX(-1000px)',
filter: `drop-shadow(1000px 0 0 var(--interactive-normal))`
}}
/>
</div>
</div>
<span style={{ fontWeight: 500 }}>Friends</span>
</div>
{/* DM List Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 8px 8px', color: '#96989d', fontSize: '11px', fontWeight: 'bold', textTransform: 'uppercase' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 8px 8px', color: '#96989d', fontSize: '11px', fontWeight: 'bold', borderTop: 'solid 1px var(--app-frame-border)'}}>
<span>Direct Messages</span>
<Tooltip text="New DM" position="top">
<span
style={{ cursor: 'pointer', fontSize: '16px' }}
onClick={handleOpenUserPicker}
>+</span>
</Tooltip>
</div>
{/* DM Channel List */}
@@ -211,7 +222,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
>
<div style={{ display: 'flex', alignItems: 'center', flex: 1, minWidth: 0 }}>
<div style={{ position: 'relative', marginRight: '12px', flexShrink: 0 }}>
<Avatar username={dm.other_username} size={32} />
<Avatar username={dm.other_username} avatarUrl={dm.other_user_avatar_url} size={32} />
<div style={{
position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%',
@@ -223,9 +234,6 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
<div style={{ color: isActive ? 'var(--header-primary)' : 'var(--text-normal)', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
{dm.other_username}
</div>
<div className="dm-item-status">
{STATUS_LABELS[effectiveStatus] || 'Offline'}
</div>
</div>
</div>
<div className="dm-close-btn" onClick={(e) => handleCloseDM(e, dm)}>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useConvex, useMutation, useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
@@ -21,6 +21,7 @@ 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 PingSound from '../assets/sounds/ping.mp3';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -348,7 +349,10 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const convex = useConvex();
// Unread tracking
const channelIds = React.useMemo(() => channels.map(c => c._id), [channels]);
const channelIds = React.useMemo(() => [
...channels.map(c => c._id),
...dmChannels.map(dm => dm.channel_id)
], [channels, dmChannels]);
const allReadStates = useQuery(
api.readState.getAllReadStates,
userId ? { userId } : "skip"
@@ -373,6 +377,38 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
return set;
}, [allReadStates, latestTimestamps]);
const unreadDMs = React.useMemo(() =>
dmChannels.filter(dm =>
unreadChannels.has(dm.channel_id) &&
!(view === 'me' && activeDMChannel?.channel_id === dm.channel_id)
),
[dmChannels, unreadChannels, view, activeDMChannel]
);
const prevUnreadDMsRef = useRef(null);
useEffect(() => {
const currentIds = new Set(
dmChannels.filter(dm => unreadChannels.has(dm.channel_id)).map(dm => dm.channel_id)
);
if (prevUnreadDMsRef.current === null) {
prevUnreadDMsRef.current = currentIds;
return;
}
for (const id of currentIds) {
if (!prevUnreadDMsRef.current.has(id)) {
const audio = new Audio(PingSound);
audio.volume = 0.5;
audio.play().catch(() => {});
break;
}
}
prevUnreadDMsRef.current = currentIds;
}, [dmChannels, unreadChannels]);
const onRenameChannel = () => {};
const onDeleteChannel = (id) => {
@@ -621,7 +657,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
{Object.entries(groupedChannels).map(([category, catChannels]) => (
<div key={category}>
<div className="channel-category-header" onClick={() => toggleCategory(category)}>
<span className={`category-chevron ${collapsedCategories[category] ? 'collapsed' : ''}`}></span>
<span className="category-label">{category}</span>
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); handleStartCreate(); }} title="Create Channel">
+
@@ -712,6 +747,32 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</Tooltip>
</div>
{unreadDMs.map(dm => (
<div key={dm.channel_id} className="server-item-wrapper">
<div className={`server-pill ${
view === 'me' && activeDMChannel?.channel_id === dm.channel_id ? 'active' : ''
}`} />
<Tooltip text={dm.other_username} position="right">
<div
className={`server-icon dm-server-icon ${
view === 'me' && activeDMChannel?.channel_id === dm.channel_id ? 'active' : ''
}`}
onClick={() => {
setActiveDMChannel(dm);
onViewChange('me');
}}
>
<Avatar
username={dm.other_username}
avatarUrl={dm.other_user_avatar_url}
size={48}
/>
<div className="dm-notification-dot" />
</div>
</Tooltip>
</div>
))}
<div className="server-separator" />
<div className="server-item-wrapper">

View File

@@ -148,7 +148,6 @@ body {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 12px;
flex-shrink: 0;
}
@@ -411,7 +410,7 @@ body {
.chat-input-form {
padding: 0 8px 8px;
margin-top: 8px;
margin-top: 24px;
background-color: var(--bg-primary);
}
@@ -1863,7 +1862,7 @@ body {
display: flex;
align-items: center;
padding: 8px;
border-radius: 4px;
border-radius: 8px;
cursor: pointer;
color: var(--text-muted);
transition: background-color 0.1s;
@@ -2086,7 +2085,7 @@ body {
============================================ */
.titlebar-nav {
position: absolute;
left: 80px;
left: 8px;
top: 0;
height: 100%;
display: flex;
@@ -2575,4 +2574,32 @@ body {
height: 8px;
border-radius: 0 4px 4px 0;
background: var(--header-primary);
}
/* ============================================
DM SERVER STRIP NOTIFICATIONS
============================================ */
.dm-server-icon {
position: relative;
padding: 0;
overflow: visible;
}
.dm-server-icon img,
.dm-server-icon > div:first-child {
width: 48px;
height: 48px;
border-radius: inherit;
}
.dm-notification-dot {
position: absolute;
bottom: -2px;
right: -2px;
width: 14px;
height: 14px;
background-color: #f23f42;
border: 3px solid var(--bg-tertiary);
border-radius: 50%;
z-index: 1;
}