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
|
||||
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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/web",
|
||||
"private": true,
|
||||
"version": "1.0.30",
|
||||
"version": "1.0.31",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,6 +62,22 @@ 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>
|
||||
)}
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
<span className="chat-header-icon">{isDM ? '@' : '#'}</span>
|
||||
<span className="chat-header-name">{channelName}</span>
|
||||
{channelTopic && !isDM && !isMobile && (
|
||||
@@ -54,6 +87,8 @@ const ChatHeader = ({
|
||||
</>
|
||||
)}
|
||||
{isDM && <span className="chat-header-status-text"></span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-header-right">
|
||||
{!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;
|
||||
@@ -4337,3 +4337,180 @@ 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user