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;
|
||||
}
|
||||
10
TODO.md
10
TODO.md
@@ -1,4 +1,12 @@
|
||||
- 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.
|
||||
@@ -50,6 +50,7 @@ export const listDMs = query({
|
||||
other_user_id: v.string(),
|
||||
other_username: 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) => {
|
||||
@@ -74,12 +75,17 @@ export const listDMs = query({
|
||||
const otherUser = await ctx.db.get(otherPart.userId);
|
||||
if (!otherUser) return null;
|
||||
|
||||
const avatarUrl = otherUser.avatarStorageId
|
||||
? await ctx.storage.getUrl(otherUser.avatarStorageId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
channel_id: part.channelId,
|
||||
channel_name: channel.name,
|
||||
other_user_id: otherUser._id as string,
|
||||
other_username: otherUser.username,
|
||||
other_user_status: otherUser.status || "offline",
|
||||
other_user_avatar_url: avatarUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -104,7 +104,7 @@ export const getLatestMessageTimestamps = query({
|
||||
if (latestMsg) {
|
||||
results.push({
|
||||
channelId,
|
||||
latestTimestamp: latestMsg._creationTime,
|
||||
latestTimestamp: Math.floor(latestMsg._creationTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user