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", "name": "discord",
"private": true, "private": true,
"version": "1.0.3", "version": "1.0.4",
"description": "A Discord clone built with Convex, React, and Electron", "description": "A Discord clone built with Convex, React, and Electron",
"author": "Moyettes", "author": "Moyettes",
"type": "module", "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 DMIcon from './dm.svg';
import SpoilerIcon from './spoiler.svg'; import SpoilerIcon from './spoiler.svg';
import CrownIcon from './crown.svg'; import CrownIcon from './crown.svg';
import FriendsIcon from './friends.svg';
export { export {
AddIcon, AddIcon,
@@ -51,7 +52,8 @@ export {
TypingIcon, TypingIcon,
DMIcon, DMIcon,
SpoilerIcon, SpoilerIcon,
CrownIcon CrownIcon,
FriendsIcon
}; };
export const Icons = { export const Icons = {
@@ -80,5 +82,6 @@ export const Icons = {
Typing: TypingIcon, Typing: TypingIcon,
DM: DMIcon, DM: DMIcon,
Spoiler: SpoilerIcon, 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; if (!currentUserId || !channelId || !decryptedMessages.length) return;
const lastMsg = decryptedMessages[decryptedMessages.length - 1]; const lastMsg = decryptedMessages[decryptedMessages.length - 1];
if (!lastMsg?.created_at) return; 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); setUnreadDividerTimestamp(null);
}, [currentUserId, channelId, decryptedMessages, markReadMutation]); }, [currentUserId, channelId, decryptedMessages, markReadMutation]);
const markChannelAsReadRef = useRef(markChannelAsRead);
markChannelAsReadRef.current = markChannelAsRead;
const typingUsers = typingData.filter(t => t.username !== username); const typingUsers = typingData.filter(t => t.username !== username);
const filteredMentionMembers = mentionQuery !== null ? filterMembersForMention(members, mentionQuery) : []; 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) // Mark as read on initial load (already scrolled to bottom)
useEffect(() => { useEffect(() => {
if (!isInitialLoadRef.current && decryptedMessages.length > 0) { if (decryptedMessages.length > 0) {
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
if (!container) return; if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container; const { scrollTop, scrollHeight, clientHeight } = container;
@@ -785,6 +788,13 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
} }
}, [decryptedMessages.length, markChannelAsRead]); }, [decryptedMessages.length, markChannelAsRead]);
// Mark as read when component unmounts (e.g., switching to voice channel)
useEffect(() => {
return () => {
markChannelAsReadRef.current();
};
}, []);
const saveSelection = () => { const saveSelection = () => {
const sel = window.getSelection(); const sel = window.getSelection();
if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange(); 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 Tooltip from './Tooltip';
import Avatar from './Avatar'; import Avatar from './Avatar';
import { useOnlineUsers } from '../contexts/PresenceContext'; import { useOnlineUsers } from '../contexts/PresenceContext';
import friendsIcon from '../assets/icons/friends.svg';
const STATUS_COLORS = { const STATUS_COLORS = {
online: '#3ba55c', online: '#3ba55c',
@@ -86,7 +87,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
}; };
return ( return (
<div style={{ padding: '8px', flex: 1, display: 'flex', flexDirection: 'column' }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Search Input */} {/* Search Input */}
<div className="dm-search-wrapper"> <div className="dm-search-wrapper">
<input <input
@@ -179,23 +180,33 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
onClick={() => onSelectDM('friends')} onClick={() => onSelectDM('friends')}
> >
<div style={{ marginRight: '12px' }}> <div style={{ marginRight: '12px' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"> <div style={{
<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"/> width: 24,
<path d="M7 13C7 11.8954 7.89543 11 9 11H15C16.1046 11 17 11.8954 17 13V15H7V13Z" fill="#fff"/> height: 24,
</svg> 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> </div>
<span style={{ fontWeight: 500 }}>Friends</span> <span style={{ fontWeight: 500 }}>Friends</span>
</div> </div>
{/* DM List Header */} {/* 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> <span>Direct Messages</span>
<Tooltip text="New DM" position="top">
<span
style={{ cursor: 'pointer', fontSize: '16px' }}
onClick={handleOpenUserPicker}
>+</span>
</Tooltip>
</div> </div>
{/* DM Channel List */} {/* 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={{ display: 'flex', alignItems: 'center', flex: 1, minWidth: 0 }}>
<div style={{ position: 'relative', marginRight: '12px', flexShrink: 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={{ <div style={{
position: 'absolute', bottom: -2, right: -2, position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%', 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' }}> <div style={{ color: isActive ? 'var(--header-primary)' : 'var(--text-normal)', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
{dm.other_username} {dm.other_username}
</div> </div>
<div className="dm-item-status">
{STATUS_LABELS[effectiveStatus] || 'Offline'}
</div>
</div> </div>
</div> </div>
<div className="dm-close-btn" onClick={(e) => handleCloseDM(e, dm)}> <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 { useNavigate } from 'react-router-dom';
import { useConvex, useMutation, useQuery } from 'convex/react'; import { useConvex, useMutation, useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; 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 cameraIcon from '../assets/icons/camera.svg';
import screenIcon from '../assets/icons/screen.svg'; import screenIcon from '../assets/icons/screen.svg';
import inviteUserIcon from '../assets/icons/invite_user.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']; const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -348,7 +349,10 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const convex = useConvex(); const convex = useConvex();
// Unread tracking // 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( const allReadStates = useQuery(
api.readState.getAllReadStates, api.readState.getAllReadStates,
userId ? { userId } : "skip" userId ? { userId } : "skip"
@@ -373,6 +377,38 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
return set; return set;
}, [allReadStates, latestTimestamps]); }, [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 onRenameChannel = () => {};
const onDeleteChannel = (id) => { const onDeleteChannel = (id) => {
@@ -621,7 +657,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
{Object.entries(groupedChannels).map(([category, catChannels]) => ( {Object.entries(groupedChannels).map(([category, catChannels]) => (
<div key={category}> <div key={category}>
<div className="channel-category-header" onClick={() => toggleCategory(category)}> <div className="channel-category-header" onClick={() => toggleCategory(category)}>
<span className={`category-chevron ${collapsedCategories[category] ? 'collapsed' : ''}`}></span>
<span className="category-label">{category}</span> <span className="category-label">{category}</span>
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); handleStartCreate(); }} title="Create Channel"> <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> </Tooltip>
</div> </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-separator" />
<div className="server-item-wrapper"> <div className="server-item-wrapper">

View File

@@ -148,7 +148,6 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 12px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -411,7 +410,7 @@ body {
.chat-input-form { .chat-input-form {
padding: 0 8px 8px; padding: 0 8px 8px;
margin-top: 8px; margin-top: 24px;
background-color: var(--bg-primary); background-color: var(--bg-primary);
} }
@@ -1863,7 +1862,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 8px; padding: 8px;
border-radius: 4px; border-radius: 8px;
cursor: pointer; cursor: pointer;
color: var(--text-muted); color: var(--text-muted);
transition: background-color 0.1s; transition: background-color 0.1s;
@@ -2086,7 +2085,7 @@ body {
============================================ */ ============================================ */
.titlebar-nav { .titlebar-nav {
position: absolute; position: absolute;
left: 80px; left: 8px;
top: 0; top: 0;
height: 100%; height: 100%;
display: flex; display: flex;
@@ -2575,4 +2574,32 @@ body {
height: 8px; height: 8px;
border-radius: 0 4px 4px 0; border-radius: 0 4px 4px 0;
background: var(--header-primary); 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;
} }

10
TODO.md
View File

@@ -1,4 +1,12 @@
- 955px - 955px
- I want to give users the choice to update the app. Can we make updating work 2 ways. One, optional updates, i want to somehow make some updates marked as optional, where techinically older versions will still work so we dont care if they are on a older version. And some updates non optional, where we will require users to update to the latest version to continue using the app. So for example when they launch the app we will check if their is a update but we dont update the app right away. We will show a download icon like discord in the header, that will be the update.svg icon. Make the icon use this color "hsl(138.353 calc(1*38.117%) 56.275% /1);" - I want to give users the choice to update the app. Can we make updating work 2 ways. One, optional updates, i want to somehow make some updates marked as optional, where techinically older versions will still work so we dont care if they are on a older version. And some updates non optional, where we will require users to update to the latest version to continue using the app. So for example when they launch the app we will check if their is a update but we dont update the app right away. We will show a download icon like discord in the header, that will be the update.svg icon. Make the icon use this color "hsl(138.353 calc(1*38.117%) 56.275% /1);"
- When a user messages you, you should get a notification. On the server list that user profile picture should be their above all servers. right under the discord and above the server-separator. With a red dot next to it. If you get a private dm you should hear the ping sound also
- We should play a sound when a user mentions you also in the main server.
- In the mention list we should also have roles, so if people do @everyone they should mention everyone in the server, or they can @ a certain role they want to mention from the server. This does not apply to private messages.
- Owners should be able to delete anyones message in the server.

View File

@@ -50,6 +50,7 @@ export const listDMs = query({
other_user_id: v.string(), other_user_id: v.string(),
other_username: v.string(), other_username: v.string(),
other_user_status: v.optional(v.string()), other_user_status: v.optional(v.string()),
other_user_avatar_url: v.optional(v.union(v.string(), v.null())),
}) })
), ),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -74,12 +75,17 @@ export const listDMs = query({
const otherUser = await ctx.db.get(otherPart.userId); const otherUser = await ctx.db.get(otherPart.userId);
if (!otherUser) return null; if (!otherUser) return null;
const avatarUrl = otherUser.avatarStorageId
? await ctx.storage.getUrl(otherUser.avatarStorageId)
: null;
return { return {
channel_id: part.channelId, channel_id: part.channelId,
channel_name: channel.name, channel_name: channel.name,
other_user_id: otherUser._id as string, other_user_id: otherUser._id as string,
other_username: otherUser.username, other_username: otherUser.username,
other_user_status: otherUser.status || "offline", other_user_status: otherUser.status || "offline",
other_user_avatar_url: avatarUrl,
}; };
}) })
); );

View File

@@ -104,7 +104,7 @@ export const getLatestMessageTimestamps = query({
if (latestMsg) { if (latestMsg) {
results.push({ results.push({
channelId, channelId,
latestTimestamp: latestMsg._creationTime, latestTimestamp: Math.floor(latestMsg._creationTime),
}); });
} }
} }