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.displayName || member.username).substring(0, 1).toUpperCase()}
+
+ )}
+
+
+
+
+ {member.displayName || member.username}
+ {isOwner && }
+
+ {usersScreenSharing.has(member.id) ? (
+
+

+ Sharing their screen
+
+ ) : usersInVoice.has(member.id) ? (
+
+ ) : 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;
}