feat: Implement core chat functionality, shared components, and initial multi-platform project structure.
All checks were successful
Build and Release / build-and-release (push) Successful in 15m0s

This commit is contained in:
Bryan1029384756
2026-02-20 13:05:39 -06:00
parent a64ef84771
commit 5feb3f288d
10 changed files with 445 additions and 13 deletions

View File

@@ -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": {

View File

@@ -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 = ({
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
)}
<span className="chat-header-icon">{isDM ? '@' : '#'}</span>
<span className="chat-header-name">{channelName}</span>
{channelTopic && !isDM && !isMobile && (
{isMobile && !isDM ? (
<button className="mobile-channel-header-tap" onClick={onOpenMembersScreen}>
<div className="mobile-channel-header-top">
<span className="chat-header-icon">#</span>
<span className="chat-header-name">{channelName}</span>
<svg className="mobile-channel-chevron" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.29 6.71a1 1 0 0 0 0 1.41L13.17 12l-3.88 3.88a1 1 0 1 0 1.42 1.41l4.59-4.59a1 1 0 0 0 0-1.41L10.71 6.7a1 1 0 0 0-1.42 0Z"/>
</svg>
</div>
<div className="mobile-channel-header-bottom">
<span className="mobile-online-dot" />
<span className="mobile-online-count">{onlineCount ?? 0} Online</span>
</div>
</button>
) : (
<>
<div className="chat-header-divider" />
<span className="chat-header-topic" title={channelTopic}>{channelTopic}</span>
<span className="chat-header-icon">{isDM ? '@' : '#'}</span>
<span className="chat-header-name">{channelName}</span>
{channelTopic && !isDM && !isMobile && (
<>
<div className="chat-header-divider" />
<span className="chat-header-topic" title={channelTopic}>{channelTopic}</span>
</>
)}
{isDM && <span className="chat-header-status-text"></span>}
</>
)}
{isDM && <span className="chat-header-status-text"></span>}
</div>
<div className="chat-header-right">
{!isDM && !isMobile && (

View File

@@ -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 (
<div
key={member.id}
className="mobile-member-item"
style={effectiveStatus === 'offline' ? { opacity: 0.3 } : {}}
>
<div className="member-avatar-wrapper">
{member.avatarUrl ? (
<img
className="member-avatar"
src={member.avatarUrl}
alt={member.username}
style={{ objectFit: 'cover' }}
/>
) : (
<div
className="member-avatar"
style={{ backgroundColor: getUserColor(member.username) }}
>
{(member.displayName || member.username).substring(0, 1).toUpperCase()}
</div>
)}
<div
className="member-status-dot"
style={{ backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline }}
/>
</div>
<div className="member-info">
<span className="member-name" style={{ color: nameColor, display: 'flex', alignItems: 'center', gap: '4px' }}>
{member.displayName || member.username}
{isOwner && <ColoredIcon src={CrownIcon} color="var(--text-feedback-warning)" size="14px" />}
</span>
{usersScreenSharing.has(member.id) ? (
<div className="member-screen-sharing-indicator">
<img src={SharingIcon} alt="" />
Sharing their screen
</div>
) : usersInVoice.has(member.id) ? (
<div className="member-voice-indicator">
<svg viewBox="0 0 24 24" fill="#3ba55c">
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1zm3.1 17.75c-.58.14-1.1-.33-1.1-.92v-.03c0-.5.37-.92.85-1.05a7 7 0 0 0 0-13.5A1.11 1.11 0 0 1 14 4.2v-.03c0-.6.52-1.06 1.1-.92a9 9 0 0 1 0 17.5" />
<path d="M15.16 16.51c-.57.28-1.16-.2-1.16-.83v-.14c0-.43.28-.8.63-1.02a3 3 0 0 0 0-5.04c-.35-.23-.63-.6-.63-1.02v-.14c0-.63.59-1.1 1.16-.83a5 5 0 0 1 0 9.02" />
</svg>
In Voice
</div>
) : member.customStatus ? (
<div style={{ fontSize: '12px', color: 'var(--text-muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{member.customStatus}
</div>
) : null}
</div>
</div>
);
};
return ReactDOM.createPortal(
<div className={`mobile-members-screen${visible ? ' visible' : ''}`}>
<div className="mobile-members-header">
<button className="mobile-members-back" onClick={handleClose}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
<div className="mobile-members-header-info">
<div className="mobile-members-header-name">
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>#</span>
{channelName}
</div>
<div className="mobile-members-header-subtitle">Text Channel</div>
</div>
</div>
<div className="mobile-members-tabs">
{TABS.map(tab => (
<button
key={tab}
className={`mobile-members-tab${activeTab === tab ? ' active' : ''}`}
onClick={() => setActiveTab(tab)}
>
{tab}
</button>
))}
</div>
<div className="mobile-members-content">
{activeTab === 'Members' ? (
<>
{sortedGroups.map(group => (
<React.Fragment key={group.role.name}>
<div className="members-role-header">
{group.role.name} {group.members.length}
</div>
{group.members.map(renderMember)}
</React.Fragment>
))}
{ungrouped.length > 0 && (
<>
<div className="members-role-header">
ONLINE {ungrouped.length}
</div>
{ungrouped.map(renderMember)}
</>
)}
{offlineMembers.length > 0 && (
<>
<div className="members-role-header">
OFFLINE {offlineMembers.length}
</div>
{offlineMembers.map(renderMember)}
</>
)}
</>
) : (
<div className="mobile-members-placeholder">
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>
{activeTab} coming soon
</span>
</div>
)}
</div>
</div>,
document.body
);
};
export default MobileMembersScreen;

View File

@@ -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;
}

View File

@@ -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 = () => {
<ChatHeader
channelName={activeChannelObj?.name || activeChannel}
channelType="text"
channelId={activeChannel}
channelTopic={activeChannelObj?.topic}
onToggleMembers={() => setShowMembers(!showMembers)}
showMembers={effectiveShowMembers}
@@ -643,6 +646,7 @@ const Chat = () => {
serverName={serverName}
isMobile={isMobile}
onMobileBack={handleMobileBack}
onOpenMembersScreen={() => setShowMobileMembersScreen(true)}
{...searchProps}
/>
<div className="chat-content">
@@ -774,6 +778,13 @@ const Chat = () => {
/>
)}
<ToastContainer />
{showMobileMembersScreen && isMobile && activeChannel && activeChannelObj?.type !== 'voice' && (
<MobileMembersScreen
channelId={activeChannel}
channelName={activeChannelObj?.name || ''}
onClose={() => setShowMobileMembersScreen(false)}
/>
)}
</div>
</PresenceProvider>
);

View File

@@ -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;
}