From 5feb3f288d989258400bbeab2007526f67395441 Mon Sep 17 00:00:00 2001 From: Bryan1029384756 <23323626+Bryan1029384756@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:05:39 -0600 Subject: [PATCH] feat: Implement core chat functionality, shared components, and initial multi-platform project structure. --- apps/android/android/app/build.gradle | 2 +- apps/android/package.json | 2 +- apps/electron/package.json | 2 +- apps/web/package.json | 2 +- packages/shared/package.json | 2 +- packages/shared/src/components/ChatHeader.jsx | 49 ++++- .../src/components/MobileMembersScreen.jsx | 208 ++++++++++++++++++ packages/shared/src/index.css | 177 +++++++++++++++ packages/shared/src/pages/Chat.jsx | 11 + packages/shared/src/styles/themes.css | 3 +- 10 files changed, 445 insertions(+), 13 deletions(-) create mode 100644 packages/shared/src/components/MobileMembersScreen.jsx diff --git a/apps/android/android/app/build.gradle b/apps/android/android/app/build.gradle index 261b1c7..ff74b8a 100644 --- a/apps/android/android/app/build.gradle +++ b/apps/android/android/app/build.gradle @@ -8,7 +8,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 27 - versionName "1.0.30" + versionName "1.0.31" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/apps/android/package.json b/apps/android/package.json index 929ef2a..af07843 100644 --- a/apps/android/package.json +++ b/apps/android/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/android", "private": true, - "version": "1.0.29", + "version": "1.0.31", "type": "module", "scripts": { "cap:sync": "npx cap sync", diff --git a/apps/electron/package.json b/apps/electron/package.json index c2535c3..b932602 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/electron", "private": true, - "version": "1.0.30", + "version": "1.0.31", "description": "Discord Clone - Electron app", "author": "Moyettes", "type": "module", diff --git a/apps/web/package.json b/apps/web/package.json index e666ae0..9dbeff6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/web", "private": true, - "version": "1.0.30", + "version": "1.0.31", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shared/package.json b/packages/shared/package.json index 4dd82de..74b9968 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/shared", "private": true, - "version": "1.0.30", + "version": "1.0.31", "type": "module", "main": "src/App.jsx", "dependencies": { diff --git a/packages/shared/src/components/ChatHeader.jsx b/packages/shared/src/components/ChatHeader.jsx index bfd67aa..0a0d5c3 100644 --- a/packages/shared/src/components/ChatHeader.jsx +++ b/packages/shared/src/components/ChatHeader.jsx @@ -1,10 +1,14 @@ -import React from 'react'; +import React, { useMemo } from 'react'; +import { useQuery } from 'convex/react'; +import { api } from '../../../../convex/_generated/api'; +import { useOnlineUsers } from '../contexts/PresenceContext'; import Tooltip from './Tooltip'; const ChatHeader = ({ channelName, channelType, channelTopic, + channelId, onToggleMembers, showMembers, onTogglePinned, @@ -13,6 +17,7 @@ const ChatHeader = ({ onMobileBack, onStartCall, isDMCallActive, + onOpenMembersScreen, // Search props searchQuery, onSearchQueryChange, @@ -25,6 +30,18 @@ const ChatHeader = ({ const isDM = channelType === 'dm'; const searchPlaceholder = isDM ? 'Search' : `Search ${serverName || 'Server'}`; + // Query members on mobile text channels only for online count + const shouldQueryMembers = isMobile && !isDM && channelId; + const members = useQuery( + api.members.getChannelMembers, + shouldQueryMembers ? { channelId } : "skip" + ) || []; + const { resolveStatus } = useOnlineUsers(); + const onlineCount = useMemo(() => { + if (!shouldQueryMembers) return 0; + return members.filter(m => resolveStatus(m.status, m.id) !== 'offline').length; + }, [members, resolveStatus, shouldQueryMembers]); + const handleSearchKeyDown = (e) => { if (e.key === 'Enter') { e.preventDefault(); @@ -45,15 +62,33 @@ const ChatHeader = ({ )} - {isDM ? '@' : '#'} - {channelName} - {channelTopic && !isDM && !isMobile && ( + {isMobile && !isDM ? ( + + ) : ( <> -
- {channelTopic} + {isDM ? '@' : '#'} + {channelName} + {channelTopic && !isDM && !isMobile && ( + <> +
+ {channelTopic} + + )} + {isDM && } )} - {isDM && }
{!isDM && !isMobile && ( diff --git a/packages/shared/src/components/MobileMembersScreen.jsx b/packages/shared/src/components/MobileMembersScreen.jsx new file mode 100644 index 0000000..6c70d77 --- /dev/null +++ b/packages/shared/src/components/MobileMembersScreen.jsx @@ -0,0 +1,208 @@ +import React, { useState, useEffect } from 'react'; +import ReactDOM from 'react-dom'; +import { useQuery } from 'convex/react'; +import { api } from '../../../../convex/_generated/api'; +import { useOnlineUsers } from '../contexts/PresenceContext'; +import { useVoice } from '../contexts/VoiceContext'; +import { CrownIcon, SharingIcon } from '../assets/icons'; +import ColoredIcon from './ColoredIcon'; + +const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245']; + +function getUserColor(name) { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return USER_COLORS[Math.abs(hash) % USER_COLORS.length]; +} + +const STATUS_COLORS = { + online: '#3ba55c', + idle: '#faa61a', + dnd: '#ed4245', + invisible: '#747f8d', + offline: '#747f8d', +}; + +const TABS = ['Members', 'Media', 'Pins', 'Threads', 'Links', 'Files']; + +const MobileMembersScreen = ({ channelId, channelName, onClose }) => { + const [activeTab, setActiveTab] = useState('Members'); + const [visible, setVisible] = useState(false); + const members = useQuery( + api.members.getChannelMembers, + channelId ? { channelId } : "skip" + ) || []; + const { resolveStatus } = useOnlineUsers(); + const { voiceStates } = useVoice(); + + const usersInVoice = new Set(); + const usersScreenSharing = new Set(); + Object.values(voiceStates).forEach(users => { + users.forEach(u => { + usersInVoice.add(u.userId); + if (u.isScreenSharing) usersScreenSharing.add(u.userId); + }); + }); + + useEffect(() => { + requestAnimationFrame(() => setVisible(true)); + }, []); + + const handleClose = () => { + setVisible(false); + setTimeout(onClose, 250); + }; + + const onlineMembers = members.filter(m => resolveStatus(m.status, m.id) !== 'offline'); + const offlineMembers = members.filter(m => resolveStatus(m.status, m.id) === 'offline'); + + const roleGroups = {}; + const ungrouped = []; + + onlineMembers.forEach(member => { + const hoistedRole = member.roles.find(r => r.isHoist && r.name !== '@everyone' && r.name !== 'Owner'); + if (hoistedRole) { + const key = `${hoistedRole.position}_${hoistedRole.name}`; + if (!roleGroups[key]) { + roleGroups[key] = { role: hoistedRole, members: [] }; + } + roleGroups[key].members.push(member); + } else { + ungrouped.push(member); + } + }); + + const sortedGroups = Object.values(roleGroups).sort((a, b) => b.role.position - a.role.position); + + const renderMember = (member) => { + const displayRole = member.roles.find(r => r.name !== '@everyone' && r.name !== 'Owner') || null; + const nameColor = displayRole ? displayRole.color : '#fff'; + const isOwner = member.roles.some(r => r.name === 'Owner'); + const effectiveStatus = resolveStatus(member.status, member.id); + + return ( +
+
+ {member.avatarUrl ? ( + {member.username} + ) : ( +
+ {(member.displayName || member.username).substring(0, 1).toUpperCase()} +
+ )} +
+
+
+ + {member.displayName || member.username} + {isOwner && } + + {usersScreenSharing.has(member.id) ? ( +
+ + Sharing their screen +
+ ) : usersInVoice.has(member.id) ? ( +
+ + + + + In Voice +
+ ) : member.customStatus ? ( +
+ {member.customStatus} +
+ ) : null} +
+
+ ); + }; + + return ReactDOM.createPortal( +
+
+ +
+
+ # + {channelName} +
+
Text Channel
+
+
+
+ {TABS.map(tab => ( + + ))} +
+
+ {activeTab === 'Members' ? ( + <> + {sortedGroups.map(group => ( + +
+ {group.role.name} — {group.members.length} +
+ {group.members.map(renderMember)} +
+ ))} + {ungrouped.length > 0 && ( + <> +
+ ONLINE — {ungrouped.length} +
+ {ungrouped.map(renderMember)} + + )} + {offlineMembers.length > 0 && ( + <> +
+ OFFLINE — {offlineMembers.length} +
+ {offlineMembers.map(renderMember)} + + )} + + ) : ( +
+ + {activeTab} coming soon + +
+ )} +
+
, + document.body + ); +}; + +export default MobileMembersScreen; diff --git a/packages/shared/src/index.css b/packages/shared/src/index.css index bcbccca..cb03243 100644 --- a/packages/shared/src/index.css +++ b/packages/shared/src/index.css @@ -4336,4 +4336,181 @@ img.search-dropdown-avatar { .ephemeral-message-dismiss:hover { text-decoration: underline; +} + +/* ── Mobile Channel Header ── */ + +.mobile-channel-header-tap { + display: flex; + flex-direction: column; + align-items: flex-start; + background: none; + border: none; + padding: 2px 4px; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + min-height: 40px; + justify-content: center; +} + +.mobile-channel-header-top { + display: flex; + align-items: center; + gap: 2px; +} + +.mobile-channel-header-top .chat-header-name { + font-size: 16px; + font-weight: 700; +} + +.mobile-channel-chevron { + color: var(--text-muted); + margin-left: 2px; + flex-shrink: 0; +} + +.mobile-channel-header-bottom { + display: flex; + align-items: center; + gap: 4px; + margin-top: 1px; +} + +.mobile-online-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #3ba55c; + flex-shrink: 0; +} + +.mobile-online-count { + font-size: 11px; + color: var(--text-muted); + font-weight: 500; +} + +/* ── Mobile Members Screen ── */ + +.mobile-members-screen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--bg-primary, #313338); + z-index: 9999; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.mobile-members-screen.visible { + transform: translateX(0); +} + +.mobile-members-header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--bg-tertiary, #1e1f22); + min-height: 56px; + flex-shrink: 0; +} + +.mobile-members-back { + background: none; + border: none; + color: var(--text-normal, #dbdee1); + padding: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + -webkit-tap-highlight-color: transparent; +} + +.mobile-members-header-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.mobile-members-header-name { + font-size: 16px; + font-weight: 700; + color: var(--text-normal, #dbdee1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mobile-members-header-subtitle { + font-size: 12px; + color: var(--text-muted, #949ba4); + margin-top: 1px; +} + +.mobile-members-tabs { + display: flex; + gap: 0; + padding: 0 8px; + border-bottom: 1px solid var(--bg-tertiary, #1e1f22); + overflow-x: auto; + flex-shrink: 0; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} + +.mobile-members-tabs::-webkit-scrollbar { + display: none; +} + +.mobile-members-tab { + background: none; + border: none; + color: var(--text-muted, #949ba4); + font-size: 13px; + font-weight: 600; + padding: 12px 12px; + cursor: pointer; + white-space: nowrap; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; + -webkit-tap-highlight-color: transparent; +} + +.mobile-members-tab.active { + color: var(--text-normal, #dbdee1); + border-bottom-color: var(--text-normal, #dbdee1); +} + +.mobile-members-content { + flex: 1; + overflow-y: auto; + padding: 8px 0; + -webkit-overflow-scrolling: touch; +} + +.mobile-member-item { + display: flex; + align-items: center; + padding: 8px 16px; + gap: 12px; + min-height: 48px; + cursor: pointer; +} + +.mobile-member-item:active { + background: var(--bg-secondary, #2b2d31); +} + +.mobile-members-placeholder { + display: flex; + align-items: center; + justify-content: center; + padding: 48px 16px; } \ No newline at end of file diff --git a/packages/shared/src/pages/Chat.jsx b/packages/shared/src/pages/Chat.jsx index b3c9c54..87f80ec 100644 --- a/packages/shared/src/pages/Chat.jsx +++ b/packages/shared/src/pages/Chat.jsx @@ -8,6 +8,7 @@ import FloatingStreamPiP from '../components/FloatingStreamPiP'; import { useVoice } from '../contexts/VoiceContext'; import FriendsView from '../components/FriendsView'; import MembersList from '../components/MembersList'; +import MobileMembersScreen from '../components/MobileMembersScreen'; import ChatHeader from '../components/ChatHeader'; import SearchPanel from '../components/SearchPanel'; import SearchDropdown from '../components/SearchDropdown'; @@ -38,6 +39,7 @@ const Chat = () => { const [activeDMChannel, setActiveDMChannel] = useState(null); const [showMembers, setShowMembers] = useState(true); const [showPinned, setShowPinned] = useState(false); + const [showMobileMembersScreen, setShowMobileMembersScreen] = useState(false); const { activeView: mobileView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping } = useSwipeNavigation({ @@ -636,6 +638,7 @@ const Chat = () => { setShowMembers(!showMembers)} showMembers={effectiveShowMembers} @@ -643,6 +646,7 @@ const Chat = () => { serverName={serverName} isMobile={isMobile} onMobileBack={handleMobileBack} + onOpenMembersScreen={() => setShowMobileMembersScreen(true)} {...searchProps} />
@@ -774,6 +778,13 @@ const Chat = () => { /> )} + {showMobileMembersScreen && isMobile && activeChannel && activeChannelObj?.type !== 'voice' && ( + setShowMobileMembersScreen(false)} + /> + )}
); diff --git a/packages/shared/src/styles/themes.css b/packages/shared/src/styles/themes.css index 95c7896..dcb2d89 100644 --- a/packages/shared/src/styles/themes.css +++ b/packages/shared/src/styles/themes.css @@ -399,7 +399,8 @@ .theme-dark .is-mobile .chat-container, .theme-dark .is-mobile .chat-area, .theme-dark .is-mobile .chat-header, -.theme-dark .is-mobile .chat-input-form { +.theme-dark .is-mobile .chat-input-form, +.theme-dark .mobile-members-screen { background-color: #1C1D22; }