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
All checks were successful
Build and Release / build-and-release (push) Successful in 10m9s
This commit is contained in:
@@ -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",
|
||||
|
||||
1
Frontend/Electron/src/assets/icons/friends.svg
Normal file
1
Frontend/Electron/src/assets/icons/friends.svg
Normal 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 |
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user