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
All checks were successful
Build and Release / build-and-release (push) Successful in 15m0s
This commit is contained in:
@@ -8,7 +8,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 27
|
versionCode 27
|
||||||
versionName "1.0.30"
|
versionName "1.0.31"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/android",
|
"name": "@discord-clone/android",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.29",
|
"version": "1.0.31",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"cap:sync": "npx cap sync",
|
"cap:sync": "npx cap sync",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.30",
|
"version": "1.0.31",
|
||||||
"description": "Discord Clone - Electron app",
|
"description": "Discord Clone - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/web",
|
"name": "@discord-clone/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.30",
|
"version": "1.0.31",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.30",
|
"version": "1.0.31",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.jsx",
|
"main": "src/App.jsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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';
|
import Tooltip from './Tooltip';
|
||||||
|
|
||||||
const ChatHeader = ({
|
const ChatHeader = ({
|
||||||
channelName,
|
channelName,
|
||||||
channelType,
|
channelType,
|
||||||
channelTopic,
|
channelTopic,
|
||||||
|
channelId,
|
||||||
onToggleMembers,
|
onToggleMembers,
|
||||||
showMembers,
|
showMembers,
|
||||||
onTogglePinned,
|
onTogglePinned,
|
||||||
@@ -13,6 +17,7 @@ const ChatHeader = ({
|
|||||||
onMobileBack,
|
onMobileBack,
|
||||||
onStartCall,
|
onStartCall,
|
||||||
isDMCallActive,
|
isDMCallActive,
|
||||||
|
onOpenMembersScreen,
|
||||||
// Search props
|
// Search props
|
||||||
searchQuery,
|
searchQuery,
|
||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
@@ -25,6 +30,18 @@ const ChatHeader = ({
|
|||||||
const isDM = channelType === 'dm';
|
const isDM = channelType === 'dm';
|
||||||
const searchPlaceholder = isDM ? 'Search' : `Search ${serverName || 'Server'}`;
|
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) => {
|
const handleSearchKeyDown = (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
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>
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="chat-header-icon">{isDM ? '@' : '#'}</span>
|
{isMobile && !isDM ? (
|
||||||
<span className="chat-header-name">{channelName}</span>
|
<button className="mobile-channel-header-tap" onClick={onOpenMembersScreen}>
|
||||||
{channelTopic && !isDM && !isMobile && (
|
<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-icon">{isDM ? '@' : '#'}</span>
|
||||||
<span className="chat-header-topic" title={channelTopic}>{channelTopic}</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>
|
||||||
<div className="chat-header-right">
|
<div className="chat-header-right">
|
||||||
{!isDM && !isMobile && (
|
{!isDM && !isMobile && (
|
||||||
|
|||||||
208
packages/shared/src/components/MobileMembersScreen.jsx
Normal file
208
packages/shared/src/components/MobileMembersScreen.jsx
Normal 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;
|
||||||
@@ -4336,4 +4336,181 @@ img.search-dropdown-avatar {
|
|||||||
|
|
||||||
.ephemeral-message-dismiss:hover {
|
.ephemeral-message-dismiss:hover {
|
||||||
text-decoration: underline;
|
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;
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import FloatingStreamPiP from '../components/FloatingStreamPiP';
|
|||||||
import { useVoice } from '../contexts/VoiceContext';
|
import { useVoice } from '../contexts/VoiceContext';
|
||||||
import FriendsView from '../components/FriendsView';
|
import FriendsView from '../components/FriendsView';
|
||||||
import MembersList from '../components/MembersList';
|
import MembersList from '../components/MembersList';
|
||||||
|
import MobileMembersScreen from '../components/MobileMembersScreen';
|
||||||
import ChatHeader from '../components/ChatHeader';
|
import ChatHeader from '../components/ChatHeader';
|
||||||
import SearchPanel from '../components/SearchPanel';
|
import SearchPanel from '../components/SearchPanel';
|
||||||
import SearchDropdown from '../components/SearchDropdown';
|
import SearchDropdown from '../components/SearchDropdown';
|
||||||
@@ -38,6 +39,7 @@ const Chat = () => {
|
|||||||
const [activeDMChannel, setActiveDMChannel] = useState(null);
|
const [activeDMChannel, setActiveDMChannel] = useState(null);
|
||||||
const [showMembers, setShowMembers] = useState(true);
|
const [showMembers, setShowMembers] = useState(true);
|
||||||
const [showPinned, setShowPinned] = useState(false);
|
const [showPinned, setShowPinned] = useState(false);
|
||||||
|
const [showMobileMembersScreen, setShowMobileMembersScreen] = useState(false);
|
||||||
|
|
||||||
const { activeView: mobileView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping } =
|
const { activeView: mobileView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping } =
|
||||||
useSwipeNavigation({
|
useSwipeNavigation({
|
||||||
@@ -636,6 +638,7 @@ const Chat = () => {
|
|||||||
<ChatHeader
|
<ChatHeader
|
||||||
channelName={activeChannelObj?.name || activeChannel}
|
channelName={activeChannelObj?.name || activeChannel}
|
||||||
channelType="text"
|
channelType="text"
|
||||||
|
channelId={activeChannel}
|
||||||
channelTopic={activeChannelObj?.topic}
|
channelTopic={activeChannelObj?.topic}
|
||||||
onToggleMembers={() => setShowMembers(!showMembers)}
|
onToggleMembers={() => setShowMembers(!showMembers)}
|
||||||
showMembers={effectiveShowMembers}
|
showMembers={effectiveShowMembers}
|
||||||
@@ -643,6 +646,7 @@ const Chat = () => {
|
|||||||
serverName={serverName}
|
serverName={serverName}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onMobileBack={handleMobileBack}
|
onMobileBack={handleMobileBack}
|
||||||
|
onOpenMembersScreen={() => setShowMobileMembersScreen(true)}
|
||||||
{...searchProps}
|
{...searchProps}
|
||||||
/>
|
/>
|
||||||
<div className="chat-content">
|
<div className="chat-content">
|
||||||
@@ -774,6 +778,13 @@ const Chat = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
{showMobileMembersScreen && isMobile && activeChannel && activeChannelObj?.type !== 'voice' && (
|
||||||
|
<MobileMembersScreen
|
||||||
|
channelId={activeChannel}
|
||||||
|
channelName={activeChannelObj?.name || ''}
|
||||||
|
onClose={() => setShowMobileMembersScreen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PresenceProvider>
|
</PresenceProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -399,7 +399,8 @@
|
|||||||
.theme-dark .is-mobile .chat-container,
|
.theme-dark .is-mobile .chat-container,
|
||||||
.theme-dark .is-mobile .chat-area,
|
.theme-dark .is-mobile .chat-area,
|
||||||
.theme-dark .is-mobile .chat-header,
|
.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;
|
background-color: #1C1D22;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user