diff --git a/Frontend/Electron/package.json b/Frontend/Electron/package.json index 22aa83e..5bf9d1d 100644 --- a/Frontend/Electron/package.json +++ b/Frontend/Electron/package.json @@ -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", diff --git a/Frontend/Electron/src/assets/icons/friends.svg b/Frontend/Electron/src/assets/icons/friends.svg new file mode 100644 index 0000000..39eaef5 --- /dev/null +++ b/Frontend/Electron/src/assets/icons/friends.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Frontend/Electron/src/assets/icons/index.js b/Frontend/Electron/src/assets/icons/index.js index 4a0c10e..90e4042 100644 --- a/Frontend/Electron/src/assets/icons/index.js +++ b/Frontend/Electron/src/assets/icons/index.js @@ -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 }; diff --git a/Frontend/Electron/src/components/ChatArea.jsx b/Frontend/Electron/src/components/ChatArea.jsx index 974dafd..07ba3bc 100644 --- a/Frontend/Electron/src/components/ChatArea.jsx +++ b/Frontend/Electron/src/components/ChatArea.jsx @@ -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(); diff --git a/Frontend/Electron/src/components/DMList.jsx b/Frontend/Electron/src/components/DMList.jsx index 6acad46..83b3d72 100644 --- a/Frontend/Electron/src/components/DMList.jsx +++ b/Frontend/Electron/src/components/DMList.jsx @@ -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 ( -
+
{/* Search Input */}
{ onClick={() => onSelectDM('friends')} >
- - - - +
+ +
Friends
{/* DM List Header */} -
+
Direct Messages - - + -
{/* DM Channel List */} @@ -211,7 +222,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => { >
- +
{
{dm.other_username}
-
- {STATUS_LABELS[effectiveStatus] || 'Offline'} -
handleCloseDM(e, dm)}> diff --git a/Frontend/Electron/src/components/Sidebar.jsx b/Frontend/Electron/src/components/Sidebar.jsx index cd0021b..b5d4639 100644 --- a/Frontend/Electron/src/components/Sidebar.jsx +++ b/Frontend/Electron/src/components/Sidebar.jsx @@ -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]) => (
toggleCategory(category)}> - {category}
+ {unreadDMs.map(dm => ( +
+
+ +
{ + setActiveDMChannel(dm); + onViewChange('me'); + }} + > + +
+
+ +
+ ))} +
diff --git a/Frontend/Electron/src/index.css b/Frontend/Electron/src/index.css index c826d95..bfeeb9c 100644 --- a/Frontend/Electron/src/index.css +++ b/Frontend/Electron/src/index.css @@ -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; } \ No newline at end of file diff --git a/TODO.md b/TODO.md index d277bcb..9434cd5 100644 --- a/TODO.md +++ b/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);" \ No newline at end of file +- 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. \ No newline at end of file diff --git a/convex/dms.ts b/convex/dms.ts index 96932d9..b8a180b 100644 --- a/convex/dms.ts +++ b/convex/dms.ts @@ -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, }; }) ); diff --git a/convex/readState.ts b/convex/readState.ts index 5f2524e..154f3f9 100644 --- a/convex/readState.ts +++ b/convex/readState.ts @@ -104,7 +104,7 @@ export const getLatestMessageTimestamps = query({ if (latestMsg) { results.push({ channelId, - latestTimestamp: latestMsg._creationTime, + latestTimestamp: Math.floor(latestMsg._creationTime), }); } }