feat: Implement SearchPanel, various mobile UI screens, and foundational shared components across applications.
All checks were successful
Build and Release / build-and-release (push) Successful in 15m35s
All checks were successful
Build and Release / build-and-release (push) Successful in 15m35s
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/shared",
|
||||
"private": true,
|
||||
"version": "1.0.37",
|
||||
"version": "1.0.38",
|
||||
"type": "module",
|
||||
"main": "src/App.jsx",
|
||||
"dependencies": {
|
||||
|
||||
126
packages/shared/src/components/MobileChannelDrawer.jsx
Normal file
126
packages/shared/src/components/MobileChannelDrawer.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
import settingsIcon from '../assets/icons/settings.svg';
|
||||
|
||||
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
|
||||
|
||||
const MobileChannelDrawer = ({ channel, isUnread, onMarkAsRead, onEditChannel, onClose }) => {
|
||||
const [closing, setClosing] = useState(false);
|
||||
const drawerRef = useRef(null);
|
||||
const dragStartY = useRef(null);
|
||||
const dragCurrentY = useRef(null);
|
||||
const dragStartTime = useRef(null);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(onClose, 200);
|
||||
}, [onClose]);
|
||||
|
||||
const handleAction = useCallback((cb) => {
|
||||
dismiss();
|
||||
setTimeout(cb, 220);
|
||||
}, [dismiss]);
|
||||
|
||||
// Swipe-to-dismiss
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
dragStartTime.current = Date.now();
|
||||
if (drawerRef.current) {
|
||||
drawerRef.current.style.transition = 'none';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
if (dragStartY.current === null) return;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
if (dy > 0 && drawerRef.current) {
|
||||
drawerRef.current.style.transform = `translateY(${dy}px)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (dragStartY.current === null || !drawerRef.current) return;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
const dt = (Date.now() - dragStartTime.current) / 1000;
|
||||
const velocity = dt > 0 ? dy / dt : 0;
|
||||
const drawerHeight = drawerRef.current.offsetHeight;
|
||||
const threshold = drawerHeight * 0.3;
|
||||
|
||||
if (dy > threshold || velocity > 500) {
|
||||
dismiss();
|
||||
} else {
|
||||
drawerRef.current.style.transition = 'transform 0.2s ease-out';
|
||||
drawerRef.current.style.transform = 'translateY(0)';
|
||||
}
|
||||
dragStartY.current = null;
|
||||
}, [dismiss]);
|
||||
|
||||
const isVoice = channel?.type === 'voice';
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<div className="mobile-drawer-overlay" onClick={dismiss} />
|
||||
<div
|
||||
ref={drawerRef}
|
||||
className={`mobile-drawer${closing ? ' mobile-drawer-closing' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="mobile-drawer-handle">
|
||||
<div className="mobile-drawer-handle-bar" />
|
||||
</div>
|
||||
|
||||
{/* Channel header */}
|
||||
<div style={{
|
||||
padding: '4px 16px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}>
|
||||
{isVoice ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--interactive-normal)">
|
||||
<path d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904ZM14 5.00304V7.00304C16.757 7.00304 19 9.24604 19 12.003C19 14.76 16.757 17.003 14 17.003V19.003C17.86 19.003 21 15.863 21 12.003C21 8.14304 17.86 5.00304 14 5.00304ZM14 9.00304V15.003C15.654 15.003 17 13.657 17 12.003C17 10.349 15.654 9.00304 14 9.00304Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<span style={{ color: 'var(--interactive-normal)', fontSize: 20, fontWeight: 500 }}>#</span>
|
||||
)}
|
||||
<span style={{
|
||||
color: 'var(--text-normal)',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{channel?.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mobile-drawer-card">
|
||||
<button
|
||||
className={`mobile-drawer-action${!isUnread ? ' mobile-drawer-action-disabled' : ''}`}
|
||||
onClick={isUnread ? () => handleAction(onMarkAsRead) : undefined}
|
||||
disabled={!isUnread}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill={isUnread ? ICON_COLOR_DEFAULT : 'var(--text-muted)'}>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
<span>Mark As Read</span>
|
||||
</button>
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onEditChannel)}>
|
||||
<ColoredIcon src={settingsIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Edit Channel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileChannelDrawer;
|
||||
227
packages/shared/src/components/MobileChannelSettingsScreen.jsx
Normal file
227
packages/shared/src/components/MobileChannelSettingsScreen.jsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
|
||||
const MobileChannelSettingsScreen = ({ channel, categories, onClose, onDelete }) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [channelName, setChannelName] = useState(channel.name);
|
||||
const [channelTopic, setChannelTopic] = useState(channel.topic || '');
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState(channel.categoryId || null);
|
||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const convex = useConvex();
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'));
|
||||
};
|
||||
|
||||
const hasChanges =
|
||||
channelName.trim() !== channel.name ||
|
||||
channelTopic.trim() !== (channel.topic || '') ||
|
||||
selectedCategoryId !== (channel.categoryId || null);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasChanges || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const trimmedName = channelName.trim();
|
||||
if (trimmedName && trimmedName !== channel.name) {
|
||||
await convex.mutation(api.channels.rename, { id: channel._id, name: trimmedName });
|
||||
}
|
||||
const trimmedTopic = channelTopic.trim();
|
||||
if (trimmedTopic !== (channel.topic || '')) {
|
||||
await convex.mutation(api.channels.updateTopic, { id: channel._id, topic: trimmedTopic });
|
||||
}
|
||||
if (selectedCategoryId !== (channel.categoryId || null)) {
|
||||
await convex.mutation(api.channels.moveChannel, {
|
||||
id: channel._id,
|
||||
categoryId: selectedCategoryId || undefined,
|
||||
position: 0,
|
||||
});
|
||||
}
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to save channel settings:', err);
|
||||
alert('Failed to save: ' + err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await convex.mutation(api.channels.remove, { id: channel._id });
|
||||
if (onDelete) onDelete(channel._id);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete channel:', err);
|
||||
alert('Failed to delete: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const currentCategoryName = selectedCategoryId
|
||||
? (categories || []).find(c => c._id === selectedCategoryId)?.name || 'Unknown'
|
||||
: 'None';
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-create-screen${visible ? ' visible' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="mobile-create-header">
|
||||
<button className="mobile-create-close-btn" 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>
|
||||
<span className="mobile-create-title">Channel Settings</span>
|
||||
<button
|
||||
className={`mobile-create-submit-btn${!hasChanges || saving ? ' disabled' : ''}`}
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="mobile-create-body">
|
||||
{/* Channel Name */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Channel Name</label>
|
||||
<div className="mobile-create-input-wrapper">
|
||||
<span className="mobile-create-input-prefix">
|
||||
{channel.type === 'voice' ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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-1z" />
|
||||
</svg>
|
||||
) : '#'}
|
||||
</span>
|
||||
<input
|
||||
className="mobile-create-input"
|
||||
type="text"
|
||||
placeholder="channel-name"
|
||||
value={channelName}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Topic */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">
|
||||
Channel Topic
|
||||
<span style={{ float: 'right', fontWeight: 400, textTransform: 'none' }}>
|
||||
{channelTopic.length}/1024
|
||||
</span>
|
||||
</label>
|
||||
<div className="mobile-create-input-wrapper" style={{ alignItems: 'flex-start' }}>
|
||||
<textarea
|
||||
className="mobile-create-input"
|
||||
placeholder="Set a topic for this channel"
|
||||
value={channelTopic}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 1024) setChannelTopic(e.target.value);
|
||||
}}
|
||||
rows={3}
|
||||
style={{
|
||||
resize: 'none',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Category</label>
|
||||
<div
|
||||
className="mobile-create-input-wrapper"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setShowCategoryPicker(!showCategoryPicker)}
|
||||
>
|
||||
<span className="mobile-create-input" style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||
{currentCategoryName}
|
||||
</span>
|
||||
<svg
|
||||
width="20" height="20" viewBox="0 0 24 24"
|
||||
fill="var(--interactive-normal)"
|
||||
style={{
|
||||
marginRight: 8,
|
||||
flexShrink: 0,
|
||||
transform: showCategoryPicker ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.15s',
|
||||
}}
|
||||
>
|
||||
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" />
|
||||
</svg>
|
||||
</div>
|
||||
{showCategoryPicker && (
|
||||
<div className="mobile-channel-settings-category-list">
|
||||
<div
|
||||
className={`mobile-channel-settings-category-option${!selectedCategoryId ? ' selected' : ''}`}
|
||||
onClick={() => { setSelectedCategoryId(null); setShowCategoryPicker(false); }}
|
||||
>
|
||||
None
|
||||
</div>
|
||||
{(categories || []).map(cat => (
|
||||
<div
|
||||
key={cat._id}
|
||||
className={`mobile-channel-settings-category-option${selectedCategoryId === cat._id ? ' selected' : ''}`}
|
||||
onClick={() => { setSelectedCategoryId(cat._id); setShowCategoryPicker(false); }}
|
||||
>
|
||||
{cat.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Channel */}
|
||||
<div className="mobile-create-section" style={{ marginTop: 16 }}>
|
||||
{!confirmDelete ? (
|
||||
<button
|
||||
className="mobile-channel-settings-delete-btn"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
Delete Channel
|
||||
</button>
|
||||
) : (
|
||||
<div className="mobile-channel-settings-delete-confirm">
|
||||
<p style={{ color: '#ed4245', fontSize: 14, margin: '0 0 12px' }}>
|
||||
Are you sure you want to delete <strong>#{channel.name}</strong>? This cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="mobile-channel-settings-cancel-btn"
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="mobile-channel-settings-delete-btn"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileChannelSettingsScreen;
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
const MobileCreateCategoryScreen = ({ onClose, onSubmit }) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [categoryName, setCategoryName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!categoryName.trim()) return;
|
||||
onSubmit(categoryName.trim());
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-create-screen${visible ? ' visible' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="mobile-create-header">
|
||||
<button className="mobile-create-close-btn" onClick={handleClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="mobile-create-title">Create Category</span>
|
||||
<button
|
||||
className={`mobile-create-submit-btn${!categoryName.trim() ? ' disabled' : ''}`}
|
||||
onClick={handleCreate}
|
||||
disabled={!categoryName.trim()}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="mobile-create-body">
|
||||
{/* Category Name */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Category Name</label>
|
||||
<div className="mobile-create-input-wrapper">
|
||||
<input
|
||||
className="mobile-create-input"
|
||||
type="text"
|
||||
placeholder="New Category"
|
||||
value={categoryName}
|
||||
onChange={(e) => setCategoryName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Private Category Toggle */}
|
||||
<div className="mobile-create-section">
|
||||
<p className="mobile-create-private-desc">
|
||||
By making a category private, only selected members and roles will be able to view this category.
|
||||
</p>
|
||||
<div className="mobile-create-toggle-row">
|
||||
<div className="mobile-create-toggle-left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--interactive-normal)' }}>
|
||||
<path d="M17 11V7C17 4.243 14.757 2 12 2C9.243 2 7 4.243 7 7V11C5.897 11 5 11.896 5 13V20C5 21.103 5.897 22 7 22H17C18.103 22 19 21.103 19 20V13C19 11.896 18.103 11 17 11ZM12 18C11.172 18 10.5 17.328 10.5 16.5C10.5 15.672 11.172 15 12 15C12.828 15 13.5 15.672 13.5 16.5C13.5 17.328 12.828 18 12 18ZM15 11H9V7C9 5.346 10.346 4 12 4C13.654 4 15 5.346 15 7V11Z" />
|
||||
</svg>
|
||||
<span className="mobile-create-toggle-label">Private Category</span>
|
||||
</div>
|
||||
<div className="category-toggle-switch">
|
||||
<div className="category-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileCreateCategoryScreen;
|
||||
128
packages/shared/src/components/MobileCreateChannelScreen.jsx
Normal file
128
packages/shared/src/components/MobileCreateChannelScreen.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
const MobileCreateChannelScreen = ({ onClose, onSubmit, categoryId }) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [channelType, setChannelType] = useState('text');
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!channelName.trim()) return;
|
||||
onSubmit(channelName.trim(), channelType, categoryId);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'));
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-create-screen${visible ? ' visible' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="mobile-create-header">
|
||||
<button className="mobile-create-close-btn" onClick={handleClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="mobile-create-title">Create Channel</span>
|
||||
<button
|
||||
className={`mobile-create-submit-btn${!channelName.trim() ? ' disabled' : ''}`}
|
||||
onClick={handleCreate}
|
||||
disabled={!channelName.trim()}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="mobile-create-body">
|
||||
{/* Channel Name */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Channel Name</label>
|
||||
<div className="mobile-create-input-wrapper">
|
||||
<span className="mobile-create-input-prefix">
|
||||
{channelType === 'text' ? '#' : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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-1z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
className="mobile-create-input"
|
||||
type="text"
|
||||
placeholder="new-channel"
|
||||
value={channelName}
|
||||
onChange={handleNameChange}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Type */}
|
||||
<div className="mobile-create-section">
|
||||
<label className="mobile-create-section-label">Channel Type</label>
|
||||
<div className="mobile-create-type-list">
|
||||
<div
|
||||
className={`mobile-create-type-option${channelType === 'text' ? ' selected' : ''}`}
|
||||
onClick={() => setChannelType('text')}
|
||||
>
|
||||
<span className="mobile-create-type-icon">#</span>
|
||||
<div className="mobile-create-type-info">
|
||||
<div className="mobile-create-type-name">Text</div>
|
||||
<div className="mobile-create-type-desc">Send messages, images, GIFs, emoji, opinions, and puns</div>
|
||||
</div>
|
||||
<div className={`mobile-create-radio${channelType === 'text' ? ' selected' : ''}`}>
|
||||
{channelType === 'text' && <div className="mobile-create-radio-dot" />}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`mobile-create-type-option${channelType === 'voice' ? ' selected' : ''}`}
|
||||
onClick={() => setChannelType('voice')}
|
||||
>
|
||||
<span className="mobile-create-type-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904ZM14 5.00304V7.00304C16.757 7.00304 19 9.24604 19 12.003C19 14.76 16.757 17.003 14 17.003V19.003C17.86 19.003 21 15.863 21 12.003C21 8.14304 17.86 5.00304 14 5.00304ZM14 9.00304V15.003C15.654 15.003 17 13.657 17 12.003C17 10.349 15.654 9.00304 14 9.00304Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="mobile-create-type-info">
|
||||
<div className="mobile-create-type-name">Voice</div>
|
||||
<div className="mobile-create-type-desc">Hang out together with voice, video, and screen share</div>
|
||||
</div>
|
||||
<div className={`mobile-create-radio${channelType === 'voice' ? ' selected' : ''}`}>
|
||||
{channelType === 'voice' && <div className="mobile-create-radio-dot" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Private Channel Toggle */}
|
||||
<div className="mobile-create-section">
|
||||
<div className="mobile-create-toggle-row">
|
||||
<div className="mobile-create-toggle-left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--interactive-normal)' }}>
|
||||
<path d="M17 11V7C17 4.243 14.757 2 12 2C9.243 2 7 4.243 7 7V11C5.897 11 5 11.896 5 13V20C5 21.103 5.897 22 7 22H17C18.103 22 19 21.103 19 20V13C19 11.896 18.103 11 17 11ZM12 18C11.172 18 10.5 17.328 10.5 16.5C10.5 15.672 11.172 15 12 15C12.828 15 13.5 15.672 13.5 16.5C13.5 17.328 12.828 18 12 18ZM15 11H9V7C9 5.346 10.346 4 12 4C13.654 4 15 5.346 15 7V11Z" />
|
||||
</svg>
|
||||
<span className="mobile-create-toggle-label">Private Channel</span>
|
||||
</div>
|
||||
<div className="category-toggle-switch">
|
||||
<div className="category-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileCreateChannelScreen;
|
||||
464
packages/shared/src/components/MobileSearchScreen.jsx
Normal file
464
packages/shared/src/components/MobileSearchScreen.jsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { useOnlineUsers } from '../contexts/PresenceContext';
|
||||
import { useSearch } from '../contexts/SearchContext';
|
||||
import { usePlatform } from '../platform';
|
||||
import { LinkPreview } from './ChatArea';
|
||||
import { extractUrls } from './MessageItem';
|
||||
import Avatar from './Avatar';
|
||||
import {
|
||||
formatTime, escapeHtml, linkifyHtml, formatEmojisHtml, getAvatarColor,
|
||||
SearchResultImage, SearchResultVideo, SearchResultFile
|
||||
} from '../utils/searchRendering';
|
||||
|
||||
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 BROWSE_TABS = ['Recent', 'Members', 'Channels'];
|
||||
const SEARCH_TABS = ['Messages', 'Media', 'Links', 'Files'];
|
||||
|
||||
function formatTimeAgo(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'Active just now';
|
||||
if (minutes < 60) return `Active ${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `Active ${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return 'Active 1d ago';
|
||||
return `Active ${days}d ago`;
|
||||
}
|
||||
|
||||
const MobileSearchScreen = ({ channels, allMembers, serverName, onClose, onSelectChannel, onJumpToMessage }) => {
|
||||
const [activeTab, setActiveTab] = useState('Recent');
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const { resolveStatus } = useOnlineUsers();
|
||||
const { search, isReady } = useSearch() || {};
|
||||
const { links } = usePlatform();
|
||||
const customEmojis = useQuery(api.customEmojis.list) || [];
|
||||
|
||||
// Search result state
|
||||
const [messageResults, setMessageResults] = useState([]);
|
||||
const [mediaResults, setMediaResults] = useState([]);
|
||||
const [linkResults, setLinkResults] = useState([]);
|
||||
const [fileResults, setFileResults] = useState([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const searchTimerRef = useRef(null);
|
||||
|
||||
const channelIds = useMemo(() => channels.map(c => c._id), [channels]);
|
||||
const latestTimestampsRaw = useQuery(
|
||||
api.readState.getLatestMessageTimestamps,
|
||||
channelIds.length > 0 ? { channelIds } : "skip"
|
||||
) || [];
|
||||
const latestTimestamps = useMemo(() => {
|
||||
const map = {};
|
||||
for (const item of latestTimestampsRaw) {
|
||||
map[item.channelId] = item.latestTimestamp;
|
||||
}
|
||||
return map;
|
||||
}, [latestTimestampsRaw]);
|
||||
|
||||
const serverChannelIds = useMemo(() => new Set(channels.map(c => c._id)), [channels]);
|
||||
|
||||
const channelMap = useMemo(() => {
|
||||
const map = {};
|
||||
for (const c of channels) map[c._id] = c.name;
|
||||
return map;
|
||||
}, [channels]);
|
||||
|
||||
// Determine mode based on search text
|
||||
const hasQuery = searchText.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
// Debounced search execution
|
||||
useEffect(() => {
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
|
||||
if (!hasQuery || !search || !isReady) {
|
||||
setMessageResults([]);
|
||||
setMediaResults([]);
|
||||
setLinkResults([]);
|
||||
setFileResults([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
const q = searchText.trim();
|
||||
const filterToServer = (results) => results.filter(r => serverChannelIds.has(r.channel_id));
|
||||
|
||||
// Messages search
|
||||
const msgs = filterToServer(search({ query: q, limit: 50 }));
|
||||
msgs.sort((a, b) => b.created_at - a.created_at);
|
||||
setMessageResults(msgs);
|
||||
|
||||
// Media search (images + videos, deduped)
|
||||
const images = filterToServer(search({ query: q, hasImage: true, limit: 50 }));
|
||||
const videos = filterToServer(search({ query: q, hasVideo: true, limit: 50 }));
|
||||
const mediaMap = new Map();
|
||||
for (const r of [...images, ...videos]) mediaMap.set(r.id, r);
|
||||
const media = Array.from(mediaMap.values());
|
||||
media.sort((a, b) => b.created_at - a.created_at);
|
||||
setMediaResults(media);
|
||||
|
||||
// Links search
|
||||
const lnks = filterToServer(search({ query: q, hasLink: true, limit: 50 }));
|
||||
lnks.sort((a, b) => b.created_at - a.created_at);
|
||||
setLinkResults(lnks);
|
||||
|
||||
// Files search
|
||||
const files = filterToServer(search({ query: q, hasFile: true, limit: 50 }));
|
||||
files.sort((a, b) => b.created_at - a.created_at);
|
||||
setFileResults(files);
|
||||
|
||||
setSearching(false);
|
||||
}, 300);
|
||||
|
||||
return () => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current); };
|
||||
}, [searchText, hasQuery, search, isReady, serverChannelIds]);
|
||||
|
||||
// Reset to first search tab when entering search mode
|
||||
useEffect(() => {
|
||||
if (hasQuery) {
|
||||
setActiveTab('Messages');
|
||||
} else {
|
||||
setActiveTab('Recent');
|
||||
}
|
||||
}, [hasQuery]);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleSelectChannel = (channelId) => {
|
||||
setVisible(false);
|
||||
setTimeout(() => {
|
||||
onSelectChannel(channelId);
|
||||
onClose();
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const handleResultClick = useCallback((result) => {
|
||||
if (onJumpToMessage) {
|
||||
setVisible(false);
|
||||
setTimeout(() => {
|
||||
onJumpToMessage(result.channel_id, result.id);
|
||||
}, 250);
|
||||
}
|
||||
}, [onJumpToMessage]);
|
||||
|
||||
const query = searchText.toLowerCase().trim();
|
||||
|
||||
// Browse mode data
|
||||
const recentChannels = useMemo(() => {
|
||||
const textChannels = channels.filter(c => c.type === 'text');
|
||||
return textChannels
|
||||
.map(c => ({ ...c, lastActivity: latestTimestamps[c._id] || 0 }))
|
||||
.sort((a, b) => b.lastActivity - a.lastActivity)
|
||||
.filter(c => !query || c.name.toLowerCase().includes(query));
|
||||
}, [channels, latestTimestamps, query]);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
if (!query) return allMembers;
|
||||
return allMembers.filter(m =>
|
||||
m.username.toLowerCase().includes(query) ||
|
||||
(m.displayName && m.displayName.toLowerCase().includes(query))
|
||||
);
|
||||
}, [allMembers, query]);
|
||||
|
||||
const filteredChannels = useMemo(() => {
|
||||
if (!query) return channels;
|
||||
return channels.filter(c => c.name.toLowerCase().includes(query));
|
||||
}, [channels, query]);
|
||||
|
||||
// Group results by channel
|
||||
const groupByChannel = useCallback((results) => {
|
||||
const grouped = {};
|
||||
for (const r of results) {
|
||||
const chName = channelMap[r.channel_id] || 'Unknown';
|
||||
if (!grouped[chName]) grouped[chName] = [];
|
||||
grouped[chName].push(r);
|
||||
}
|
||||
return grouped;
|
||||
}, [channelMap]);
|
||||
|
||||
const renderSearchResult = useCallback((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="mobile-search-result-item"
|
||||
onClick={() => handleResultClick(r)}
|
||||
>
|
||||
<div
|
||||
className="mobile-search-result-avatar"
|
||||
style={{ backgroundColor: getAvatarColor(r.username) }}
|
||||
>
|
||||
{r.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="mobile-search-result-body">
|
||||
<div className="mobile-search-result-header">
|
||||
<span className="mobile-search-result-username" style={{ color: getAvatarColor(r.username) }}>
|
||||
{r.username}
|
||||
</span>
|
||||
<span className="mobile-search-result-time">{formatTime(r.created_at)}</span>
|
||||
</div>
|
||||
{!(r.has_attachment && r.attachment_meta) && (
|
||||
<div
|
||||
className="mobile-search-result-content"
|
||||
dangerouslySetInnerHTML={{ __html: formatEmojisHtml(linkifyHtml(r.snippet || escapeHtml(r.content)), customEmojis) }}
|
||||
onClick={(e) => {
|
||||
if (e.target.tagName === 'A' && e.target.href) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
links.openExternal(e.target.href);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{r.has_attachment && r.attachment_meta ? (() => {
|
||||
try {
|
||||
const meta = JSON.parse(r.attachment_meta);
|
||||
if (r.attachment_type?.startsWith('image/')) return <SearchResultImage metadata={meta} />;
|
||||
if (r.attachment_type?.startsWith('video/')) return <SearchResultVideo metadata={meta} />;
|
||||
return <SearchResultFile metadata={meta} />;
|
||||
} catch { return <span className="search-result-badge">File</span>; }
|
||||
})() : r.has_attachment ? <span className="search-result-badge">File</span> : null}
|
||||
{r.has_link && r.content && (() => {
|
||||
const urls = extractUrls(r.content);
|
||||
return urls.map((url, i) => <LinkPreview key={i} url={url} />);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
), [handleResultClick, customEmojis, links]);
|
||||
|
||||
const renderGroupedResults = useCallback((results) => {
|
||||
if (searching) {
|
||||
return <div className="mobile-search-empty">Searching...</div>;
|
||||
}
|
||||
if (!isReady) {
|
||||
return <div className="mobile-search-empty">Search database is loading...</div>;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="mobile-search-empty">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" style={{ opacity: 0.3, marginBottom: 8 }}>
|
||||
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
|
||||
</svg>
|
||||
<div>No results found</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const grouped = groupByChannel(results);
|
||||
return (
|
||||
<div className="mobile-search-results">
|
||||
{Object.entries(grouped).map(([chName, msgs]) => (
|
||||
<div key={chName} className="mobile-search-channel-group">
|
||||
<div className="mobile-search-channel-group-header">#{chName}</div>
|
||||
{msgs.map(renderSearchResult)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [searching, isReady, groupByChannel, renderSearchResult]);
|
||||
|
||||
const renderContent = () => {
|
||||
// Search mode
|
||||
if (hasQuery) {
|
||||
switch (activeTab) {
|
||||
case 'Messages': return renderGroupedResults(messageResults);
|
||||
case 'Media': return renderGroupedResults(mediaResults);
|
||||
case 'Links': return renderGroupedResults(linkResults);
|
||||
case 'Files': return renderGroupedResults(fileResults);
|
||||
default: return renderGroupedResults(messageResults);
|
||||
}
|
||||
}
|
||||
|
||||
// Browse mode
|
||||
switch (activeTab) {
|
||||
case 'Recent':
|
||||
return (
|
||||
<div className="mobile-search-section">
|
||||
<div className="mobile-search-section-title">Suggested</div>
|
||||
{recentChannels.length === 0 ? (
|
||||
<div className="mobile-search-empty">No channels found</div>
|
||||
) : (
|
||||
recentChannels.map(channel => (
|
||||
<button
|
||||
key={channel._id}
|
||||
className="mobile-search-channel-item"
|
||||
onClick={() => handleSelectChannel(channel._id)}
|
||||
>
|
||||
<span className="mobile-search-channel-hash">#</span>
|
||||
<div className="mobile-search-channel-info">
|
||||
<span className="mobile-search-channel-name">{channel.name}</span>
|
||||
<span className="mobile-search-channel-activity">
|
||||
{formatTimeAgo(channel.lastActivity)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'Members':
|
||||
return (
|
||||
<div className="mobile-search-section">
|
||||
{filteredMembers.length === 0 ? (
|
||||
<div className="mobile-search-empty">No members found</div>
|
||||
) : (
|
||||
filteredMembers.map(member => {
|
||||
const effectiveStatus = resolveStatus(member.status, member.id);
|
||||
return (
|
||||
<div key={member.id} className="mobile-search-member-item">
|
||||
<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>
|
||||
<span className="mobile-search-member-name">
|
||||
{member.displayName || member.username}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'Channels':
|
||||
return (
|
||||
<div className="mobile-search-section">
|
||||
{filteredChannels.length === 0 ? (
|
||||
<div className="mobile-search-empty">No channels found</div>
|
||||
) : (
|
||||
filteredChannels.map(channel => (
|
||||
<button
|
||||
key={channel._id}
|
||||
className="mobile-search-channel-item"
|
||||
onClick={() => handleSelectChannel(channel._id)}
|
||||
>
|
||||
<span className="mobile-search-channel-hash">
|
||||
{channel.type === 'voice' ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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-1z" />
|
||||
</svg>
|
||||
) : '#'}
|
||||
</span>
|
||||
<span className="mobile-search-channel-name">{channel.name}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="mobile-search-empty">
|
||||
{activeTab} coming soon
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const currentTabs = hasQuery ? SEARCH_TABS : BROWSE_TABS;
|
||||
const getTabLabel = (tab) => {
|
||||
if (!hasQuery) return tab;
|
||||
switch (tab) {
|
||||
case 'Messages': return `Messages${!searching ? ` (${messageResults.length})` : ''}`;
|
||||
case 'Media': return `Media${!searching ? ` (${mediaResults.length})` : ''}`;
|
||||
case 'Links': return `Links${!searching ? ` (${linkResults.length})` : ''}`;
|
||||
case 'Files': return `Files${!searching ? ` (${fileResults.length})` : ''}`;
|
||||
default: return tab;
|
||||
}
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className={`mobile-search-screen${visible ? ' visible' : ''}`}>
|
||||
<div className="mobile-search-header">
|
||||
<button className="mobile-search-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-search-input-wrapper">
|
||||
<svg className="mobile-search-input-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
|
||||
</svg>
|
||||
<input
|
||||
className="mobile-search-input"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{searchText && (
|
||||
<button className="mobile-search-clear" onClick={() => setSearchText('')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mobile-search-tabs">
|
||||
{currentTabs.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`mobile-search-tab${activeTab === tab ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{getTabLabel(tab)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mobile-search-content">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileSearchScreen;
|
||||
124
packages/shared/src/components/MobileServerDrawer.jsx
Normal file
124
packages/shared/src/components/MobileServerDrawer.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import ColoredIcon from './ColoredIcon';
|
||||
import inviteUserIcon from '../assets/icons/invite_user.svg';
|
||||
import settingsIcon from '../assets/icons/settings.svg';
|
||||
import createIcon from '../assets/icons/create.svg';
|
||||
import createCategoryIcon from '../assets/icons/create_category.svg';
|
||||
|
||||
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
|
||||
|
||||
const MobileServerDrawer = ({ serverName, serverIconUrl, memberCount, onInvite, onSettings, onCreateChannel, onCreateCategory, onClose }) => {
|
||||
const [closing, setClosing] = useState(false);
|
||||
const drawerRef = useRef(null);
|
||||
const dragStartY = useRef(null);
|
||||
const dragCurrentY = useRef(null);
|
||||
const dragStartTime = useRef(null);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(onClose, 200);
|
||||
}, [onClose]);
|
||||
|
||||
const handleAction = useCallback((cb) => {
|
||||
dismiss();
|
||||
setTimeout(cb, 220);
|
||||
}, [dismiss]);
|
||||
|
||||
// Swipe-to-dismiss
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
dragStartY.current = e.touches[0].clientY;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
dragStartTime.current = Date.now();
|
||||
if (drawerRef.current) {
|
||||
drawerRef.current.style.transition = 'none';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e) => {
|
||||
if (dragStartY.current === null) return;
|
||||
dragCurrentY.current = e.touches[0].clientY;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
if (dy > 0 && drawerRef.current) {
|
||||
drawerRef.current.style.transform = `translateY(${dy}px)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (dragStartY.current === null || !drawerRef.current) return;
|
||||
const dy = dragCurrentY.current - dragStartY.current;
|
||||
const dt = (Date.now() - dragStartTime.current) / 1000;
|
||||
const velocity = dt > 0 ? dy / dt : 0;
|
||||
const drawerHeight = drawerRef.current.offsetHeight;
|
||||
const threshold = drawerHeight * 0.3;
|
||||
|
||||
if (dy > threshold || velocity > 500) {
|
||||
dismiss();
|
||||
} else {
|
||||
drawerRef.current.style.transition = 'transform 0.2s ease-out';
|
||||
drawerRef.current.style.transform = 'translateY(0)';
|
||||
}
|
||||
dragStartY.current = null;
|
||||
}, [dismiss]);
|
||||
|
||||
const initials = serverName ? serverName.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase() : '?';
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<div className="mobile-drawer-overlay" onClick={dismiss} />
|
||||
<div
|
||||
ref={drawerRef}
|
||||
className={`mobile-drawer${closing ? ' mobile-drawer-closing' : ''}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="mobile-drawer-handle">
|
||||
<div className="mobile-drawer-handle-bar" />
|
||||
</div>
|
||||
|
||||
{/* Server header */}
|
||||
<div className="mobile-server-drawer-header">
|
||||
<div className="mobile-server-drawer-icon">
|
||||
{serverIconUrl ? (
|
||||
<img src={serverIconUrl} alt={serverName} />
|
||||
) : (
|
||||
<span>{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mobile-server-drawer-name">{serverName}</div>
|
||||
<div className="mobile-server-drawer-members">{memberCount} {memberCount === 1 ? 'member' : 'members'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions card 1 */}
|
||||
<div className="mobile-drawer-card">
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onInvite)}>
|
||||
<ColoredIcon src={inviteUserIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Invite People</span>
|
||||
</button>
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onSettings)}>
|
||||
<ColoredIcon src={settingsIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Server Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions card 2 */}
|
||||
<div className="mobile-drawer-card">
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onCreateChannel)}>
|
||||
<ColoredIcon src={createIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Create Channel</span>
|
||||
</button>
|
||||
<button className="mobile-drawer-action" onClick={() => handleAction(onCreateCategory)}>
|
||||
<ColoredIcon src={createCategoryIcon} color={ICON_COLOR_DEFAULT} size="20px" />
|
||||
<span>Create Category</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileServerDrawer;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { useSearch } from '../contexts/SearchContext';
|
||||
@@ -6,216 +6,10 @@ import { parseFilters } from '../utils/searchUtils';
|
||||
import { usePlatform } from '../platform';
|
||||
import { LinkPreview } from './ChatArea';
|
||||
import { extractUrls } from './MessageItem';
|
||||
import { AllEmojis } from '../assets/emojis';
|
||||
|
||||
function formatTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function linkifyHtml(html) {
|
||||
if (!html) return '';
|
||||
return html.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" class="search-result-link">$1</a>');
|
||||
}
|
||||
|
||||
function formatEmojisHtml(html, customEmojis = []) {
|
||||
if (!html) return '';
|
||||
return html.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
|
||||
const custom = customEmojis.find(e => e.name === name);
|
||||
if (custom) return `<img src="${custom.src}" alt="${match}" style="width:48px;height:48px;vertical-align:bottom;margin:0 1px;display:inline" />`;
|
||||
const emoji = AllEmojis.find(e => e.name === name);
|
||||
if (emoji) return `<img src="${emoji.src}" alt="${match}" style="width:48px;height:48px;vertical-align:bottom;margin:0 1px;display:inline" />`;
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
function getAvatarColor(name) {
|
||||
const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
const CONVEX_PUBLIC_URL = 'https://api.brycord.com';
|
||||
const rewriteStorageUrl = (url) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const pub = new URL(CONVEX_PUBLIC_URL);
|
||||
u.hostname = pub.hostname;
|
||||
u.port = pub.port;
|
||||
u.protocol = pub.protocol;
|
||||
return u.toString();
|
||||
} catch { return url; }
|
||||
};
|
||||
|
||||
const toHexString = (bytes) =>
|
||||
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
|
||||
|
||||
const searchImageCache = new Map();
|
||||
|
||||
const SearchResultImage = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) throw new Error('Invalid file data');
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search image decrypt error:', err);
|
||||
if (isMounted) { setError('Failed to load'); setLoading(false); }
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading image...</div>;
|
||||
if (error) return null;
|
||||
return <img src={url} alt={metadata.filename} style={{ width: '100%', height: 'auto', borderRadius: 4, marginTop: 4, display: 'block' }} />;
|
||||
};
|
||||
|
||||
const SearchResultVideo = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
const [error, setError] = useState(null);
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) throw new Error('Invalid file data');
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search video decrypt error:', err);
|
||||
if (isMounted) { setError('Failed to load'); setLoading(false); }
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading video...</div>;
|
||||
if (error) return null;
|
||||
|
||||
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
|
||||
return (
|
||||
<div style={{ marginTop: 4, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
|
||||
<video ref={videoRef} src={url} controls={showControls} style={{ width: '100%', maxHeight: 200, borderRadius: 4, display: 'block', backgroundColor: 'black' }} />
|
||||
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>▶</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchResultFile = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) return;
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search file decrypt error:', err);
|
||||
if (isMounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
const sizeStr = metadata.size ? `${(metadata.size / 1024).toFixed(1)} KB` : '';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '8px 10px', borderRadius: 4, marginTop: 4, maxWidth: '100%' }}>
|
||||
<span style={{ marginRight: 8, fontSize: 20 }}>📄</span>
|
||||
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 }}>{metadata.filename}</div>
|
||||
{sizeStr && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>{sizeStr}</div>}
|
||||
{url && <a href={url} download={metadata.filename} onClick={e => e.stopPropagation()} style={{ color: 'var(--header-secondary)', fontSize: 11, textDecoration: 'underline' }}>Download</a>}
|
||||
{loading && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>Decrypting...</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import {
|
||||
formatTime, escapeHtml, linkifyHtml, formatEmojisHtml, getAvatarColor,
|
||||
SearchResultImage, SearchResultVideo, SearchResultFile
|
||||
} from '../utils/searchRendering';
|
||||
|
||||
const SearchPanel = ({ visible, onClose, channels, isDM, dmChannelId, onJumpToMessage, query, sortOrder, onSortChange }) => {
|
||||
const { search, isReady } = useSearch() || {};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useQuery, useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import { AllEmojis } from '../assets/emojis';
|
||||
import AvatarCropModal from './AvatarCropModal';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
function getCroppedEmojiImg(imageSrc, pixelCrop, rotation, flipH, flipV) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -95,19 +97,44 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
const [savingIcon, setSavingIcon] = useState(false);
|
||||
const iconInputRef = useRef(null);
|
||||
|
||||
// Mobile state
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileScreen, setMobileScreen] = useState('menu');
|
||||
|
||||
const mobileGoBack = () => {
|
||||
if (mobileScreen === 'role-edit') setMobileScreen('roles');
|
||||
else setMobileScreen('menu');
|
||||
};
|
||||
|
||||
const mobileSelectRole = (role) => {
|
||||
setSelectedRole(role);
|
||||
setMobileScreen('role-edit');
|
||||
};
|
||||
|
||||
// Auto-navigate to overview on icon crop (so user sees save button)
|
||||
useEffect(() => {
|
||||
if (isMobile && iconDirty && mobileScreen === 'menu') {
|
||||
setMobileScreen('overview');
|
||||
}
|
||||
}, [isMobile, iconDirty, mobileScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showEmojiModal) {
|
||||
handleEmojiModalClose();
|
||||
} else if (!showIconCropModal) {
|
||||
onClose();
|
||||
if (isMobile && mobileScreen !== 'menu') {
|
||||
mobileGoBack();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [onClose, showEmojiModal, showIconCropModal]);
|
||||
}, [onClose, showEmojiModal, showIconCropModal, isMobile, mobileScreen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (serverSettings) {
|
||||
@@ -745,6 +772,506 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Mobile render functions ───
|
||||
|
||||
const roleMemberCounts = React.useMemo(() => {
|
||||
const counts = {};
|
||||
for (const m of members) {
|
||||
for (const r of (m.roles || [])) {
|
||||
counts[r._id] = (counts[r._id] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [members]);
|
||||
|
||||
const renderMobileHeader = (title, onBack, rightAction) => (
|
||||
<div className="msm-header">
|
||||
<button className="msm-back-btn" onClick={onBack}>
|
||||
<svg width="22" height="22" 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="msm-header-title">{title}</div>
|
||||
{rightAction || <div style={{ width: 32 }} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileMenu = () => (
|
||||
<div className="msm-screen">
|
||||
<div className="msm-header">
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="msm-back-btn" onClick={onClose}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="msm-content">
|
||||
{/* Server icon + name */}
|
||||
<div className="msm-icon-section">
|
||||
<div className="msm-icon-wrapper" onClick={() => myPermissions.manage_channels && iconInputRef.current?.click()}>
|
||||
{currentIconUrl ? (
|
||||
<img src={currentIconUrl} alt="Server Icon" className="msm-icon-img" />
|
||||
) : (
|
||||
<div className="msm-icon-placeholder">{serverName.substring(0, 2)}</div>
|
||||
)}
|
||||
{myPermissions.manage_channels && (
|
||||
<div className="msm-icon-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="#fff"><path d="M19 7v2.99s-1.99.01-2 0V7h-3s.01-1.99 0-2h3V2h2v3h3v2h-3zm-3 4V8h-3V5H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8h-3zM5 19l3-4 2 3 3-4 4 5H5z"/></svg>
|
||||
</div>
|
||||
)}
|
||||
<input ref={iconInputRef} type="file" accept="image/*" onChange={handleIconFileChange} style={{ display: 'none' }} />
|
||||
</div>
|
||||
<div className="msm-icon-name">{serverName}</div>
|
||||
</div>
|
||||
|
||||
{/* Settings menu */}
|
||||
<div className="msm-section-label">Settings</div>
|
||||
<div className="msm-card">
|
||||
{[
|
||||
{ key: 'overview', label: 'Overview', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg> },
|
||||
{ key: 'emoji', label: 'Emoji', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/></svg> },
|
||||
{ key: 'roles', label: 'Roles', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/></svg> },
|
||||
{ key: 'members', label: 'Members', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg> },
|
||||
].map(item => (
|
||||
<div key={item.key} className="msm-card-item" onClick={() => setMobileScreen(item.key)}>
|
||||
<span className="msm-card-item-icon">{item.icon}</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>{item.label}</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileOverview = () => (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Overview', mobileGoBack)}
|
||||
<div className="msm-content">
|
||||
{/* Server icon (small) */}
|
||||
<div className="msm-section-label">Server Icon</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||
<div className="msm-icon-wrapper" style={{ width: 56, height: 56 }} onClick={() => myPermissions.manage_channels && iconInputRef.current?.click()}>
|
||||
{currentIconUrl ? (
|
||||
<img src={currentIconUrl} alt="Icon" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 16 }} />
|
||||
) : (
|
||||
<div className="msm-icon-placeholder" style={{ width: 56, height: 56, fontSize: 18, borderRadius: 16 }}>{serverName.substring(0, 2)}</div>
|
||||
)}
|
||||
<input ref={iconInputRef} type="file" accept="image/*" onChange={handleIconFileChange} style={{ display: 'none' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{iconDirty && myPermissions.manage_channels && (
|
||||
<button className="msm-btn-primary" onClick={handleSaveIcon} disabled={savingIcon} style={{ fontSize: 13, padding: '6px 14px' }}>
|
||||
{savingIcon ? 'Saving...' : 'Save Icon'}
|
||||
</button>
|
||||
)}
|
||||
{currentIconUrl && !iconDirty && myPermissions.manage_channels && (
|
||||
<button className="msm-btn-danger-outline" onClick={handleRemoveIcon} disabled={savingIcon} style={{ fontSize: 13, padding: '5px 12px' }}>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server name */}
|
||||
<div className="msm-section-label">Server Name</div>
|
||||
<input
|
||||
className="msm-input"
|
||||
value={serverName}
|
||||
onChange={(e) => { setServerName(e.target.value); setServerNameDirty(true); }}
|
||||
disabled={!myPermissions.manage_channels}
|
||||
maxLength={100}
|
||||
style={{ opacity: myPermissions.manage_channels ? 1 : 0.5 }}
|
||||
/>
|
||||
{serverNameDirty && myPermissions.manage_channels && (
|
||||
<button className="msm-btn-primary msm-btn-full" onClick={handleSaveServerName} disabled={!serverName.trim()} style={{ marginTop: 8 }}>
|
||||
Save Changes
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* AFK settings */}
|
||||
<div className="msm-section-label">Inactive Channel</div>
|
||||
<select
|
||||
className="msm-select"
|
||||
value={afkChannelId}
|
||||
onChange={(e) => { setAfkChannelId(e.target.value); setAfkDirty(true); }}
|
||||
disabled={!myPermissions.manage_channels}
|
||||
style={{ opacity: myPermissions.manage_channels ? 1 : 0.5 }}
|
||||
>
|
||||
<option value="">No Inactive Channel</option>
|
||||
{voiceChannels.map(ch => (
|
||||
<option key={ch._id} value={ch._id}>{ch.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="msm-section-label">Inactive Timeout</div>
|
||||
<select
|
||||
className="msm-select"
|
||||
value={afkTimeout}
|
||||
onChange={(e) => { setAfkTimeout(Number(e.target.value)); setAfkDirty(true); }}
|
||||
disabled={!myPermissions.manage_channels}
|
||||
style={{ opacity: myPermissions.manage_channels ? 1 : 0.5 }}
|
||||
>
|
||||
{TIMEOUT_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{afkDirty && myPermissions.manage_channels && (
|
||||
<button className="msm-btn-primary msm-btn-full" onClick={handleSaveAfkSettings} style={{ marginTop: 8 }}>
|
||||
Save Changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileEmoji = () => (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Emoji', mobileGoBack)}
|
||||
<div className="msm-content">
|
||||
{myPermissions.manage_channels && (
|
||||
<>
|
||||
<button className="msm-btn-primary msm-btn-full" onClick={() => emojiFileInputRef.current?.click()} style={{ marginBottom: 12 }}>
|
||||
Upload Emoji
|
||||
</button>
|
||||
<input ref={emojiFileInputRef} type="file" accept="image/*,.gif" onChange={handleEmojiFileSelect} style={{ display: 'none' }} />
|
||||
</>
|
||||
)}
|
||||
<p className="msm-description">Add custom emoji that anyone can use in this server.</p>
|
||||
|
||||
{customEmojis.length === 0 ? (
|
||||
<div className="msm-empty">No custom emojis yet</div>
|
||||
) : (
|
||||
<div className="msm-card">
|
||||
{customEmojis.map(emoji => (
|
||||
<div key={emoji._id} className="msm-emoji-row">
|
||||
<img src={emoji.src} alt={emoji.name} className="msm-emoji-img" />
|
||||
<div className="msm-emoji-info">
|
||||
<span className="msm-emoji-name">:{emoji.name}:</span>
|
||||
<span className="msm-emoji-uploader">{emoji.uploadedByUsername}</span>
|
||||
</div>
|
||||
{myPermissions.manage_channels && (
|
||||
<button className="msm-emoji-delete" onClick={() => handleEmojiDelete(emoji._id)}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileRoles = () => (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Roles', mobileGoBack, canManageRoles ? (
|
||||
<button className="msm-header-action" onClick={handleCreateRole}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
</button>
|
||||
) : null)}
|
||||
<div className="msm-content">
|
||||
<p className="msm-description">Roles let you organize members and customize permissions.</p>
|
||||
|
||||
{/* @everyone */}
|
||||
<div className="msm-card" style={{ marginBottom: 16 }}>
|
||||
<div className="msm-card-item" onClick={() => mobileSelectRole(editableRoles.find(r => r.name === '@everyone') || editableRoles[0])}>
|
||||
<span className="msm-role-dot" style={{ backgroundColor: '#99aab5' }} />
|
||||
<span style={{ flex: 1, color: 'var(--header-primary)', fontSize: 15 }}>@everyone</span>
|
||||
<span className="msm-role-count">{members.length}</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other roles */}
|
||||
{editableRoles.filter(r => r.name !== '@everyone').length > 0 && (
|
||||
<>
|
||||
<div className="msm-section-label">Roles - {editableRoles.filter(r => r.name !== '@everyone').length}</div>
|
||||
<div className="msm-card">
|
||||
{editableRoles.filter(r => r.name !== '@everyone').map(r => (
|
||||
<div key={r._id} className="msm-card-item" onClick={() => mobileSelectRole(r)}>
|
||||
<span className="msm-role-dot" style={{ backgroundColor: r.color || '#99aab5' }} />
|
||||
<span style={{ flex: 1, color: 'var(--header-primary)', fontSize: 15 }}>{r.name}</span>
|
||||
<span className="msm-role-count">{roleMemberCounts[r._id] || 0}</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileRoleEdit = () => {
|
||||
if (!selectedRole) return renderMobileRoles();
|
||||
const permList = ['manage_channels', 'manage_roles', 'manage_nicknames', 'create_invite', 'embed_links', 'attach_files', 'move_members', 'mute_members'];
|
||||
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader(`Edit Role`, () => setMobileScreen('roles'))}
|
||||
<div className="msm-content">
|
||||
{/* Role name */}
|
||||
<div className="msm-section-label">Role Name</div>
|
||||
<input
|
||||
className="msm-input"
|
||||
value={selectedRole.name}
|
||||
onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })}
|
||||
disabled={!canManageRoles}
|
||||
style={{ opacity: canManageRoles ? 1 : 0.5 }}
|
||||
/>
|
||||
|
||||
{/* Role color */}
|
||||
<div className="msm-section-label">Role Color</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<input
|
||||
type="color"
|
||||
className="msm-color-input"
|
||||
value={selectedRole.color}
|
||||
onChange={(e) => handleUpdateRole(selectedRole._id, { color: e.target.value })}
|
||||
disabled={!canManageRoles}
|
||||
/>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{selectedRole.color}</span>
|
||||
</div>
|
||||
|
||||
{/* Display separately toggle (isHoist) */}
|
||||
<div className="msm-section-label">Display</div>
|
||||
<div className="msm-card">
|
||||
<div className="msm-card-item" onClick={() => canManageRoles && handleUpdateRole(selectedRole._id, { isHoist: !selectedRole.isHoist })}>
|
||||
<span style={{ flex: 1, color: 'var(--header-primary)', fontSize: 15 }}>Display separately</span>
|
||||
<div className={`msm-toggle ${selectedRole.isHoist ? 'msm-toggle-on' : ''}`}>
|
||||
<div className="msm-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="msm-section-label">Permissions</div>
|
||||
<div className="msm-card">
|
||||
{permList.map(perm => (
|
||||
<div key={perm} className="msm-card-item" onClick={() => canManageRoles && handleUpdateRole(selectedRole._id, { permissions: { ...selectedRole.permissions, [perm]: !selectedRole.permissions?.[perm] } })}>
|
||||
<span style={{ flex: 1, color: 'var(--header-primary)', fontSize: 15, textTransform: 'capitalize' }}>{perm.replace(/_/g, ' ')}</span>
|
||||
<div className={`msm-toggle ${selectedRole.permissions?.[perm] ? 'msm-toggle-on' : ''}`}>
|
||||
<div className="msm-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
{canManageRoles && selectedRole.name !== 'Owner' && selectedRole.name !== '@everyone' && (
|
||||
<div className="msm-card" style={{ marginTop: 24 }}>
|
||||
<div className="msm-card-item msm-card-item-danger" onClick={() => handleDeleteRole(selectedRole._id)}>
|
||||
Delete Role
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMobileMembers = () => (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Members', mobileGoBack)}
|
||||
<div className="msm-content">
|
||||
{members.length === 0 ? (
|
||||
<div className="msm-empty">No members found</div>
|
||||
) : (
|
||||
<div className="msm-card">
|
||||
{members.map(m => (
|
||||
<div key={m.id} className="msm-member-row">
|
||||
<div className="msm-member-avatar">
|
||||
{m.avatarUrl ? (
|
||||
<img src={m.avatarUrl} alt="" style={{ width: 36, height: 36, borderRadius: '50%' }} />
|
||||
) : (
|
||||
m.username[0].toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="msm-member-info">
|
||||
<div className="msm-member-name">{m.username}</div>
|
||||
<div className="msm-member-roles">
|
||||
{m.roles?.map(r => (
|
||||
<span key={r._id} className="msm-member-role-pill" style={{ backgroundColor: r.color + '33', color: r.color, borderColor: r.color + '66' }}>
|
||||
{r.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{canManageRoles && (
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
{editableRoles.map(r => {
|
||||
const hasRole = m.roles?.some(ur => ur._id === r._id);
|
||||
return (
|
||||
<button
|
||||
key={r._id}
|
||||
className="msm-member-role-toggle"
|
||||
onClick={() => handleAssignRole(r._id, m.id, !hasRole)}
|
||||
style={{
|
||||
borderColor: r.color,
|
||||
backgroundColor: hasRole ? r.color : 'transparent',
|
||||
}}
|
||||
title={hasRole ? `Remove ${r.name}` : `Add ${r.name}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileContent = () => {
|
||||
switch (mobileScreen) {
|
||||
case 'overview': return renderMobileOverview();
|
||||
case 'emoji': return renderMobileEmoji();
|
||||
case 'roles': return renderMobileRoles();
|
||||
case 'role-edit': return renderMobileRoleEdit();
|
||||
case 'members': return renderMobileMembers();
|
||||
default: return renderMobileMenu();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Shared modals ───
|
||||
const sharedModals = (
|
||||
<>
|
||||
{showIconCropModal && rawIconUrl && (
|
||||
<AvatarCropModal
|
||||
imageUrl={rawIconUrl}
|
||||
onApply={handleIconCropApply}
|
||||
onCancel={handleIconCropCancel}
|
||||
cropShape="rect"
|
||||
/>
|
||||
)}
|
||||
{showEmojiModal && emojiPreviewUrl && (
|
||||
<div
|
||||
onClick={handleEmojiModalClose}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 2000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)', borderRadius: 8,
|
||||
width: 580, maxWidth: '90vw', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '16px 16px 0',
|
||||
}}>
|
||||
<h2 style={{ color: 'var(--header-primary)', margin: 0, fontSize: 20, fontWeight: 600 }}>Add Emoji</h2>
|
||||
<button
|
||||
onClick={handleEmojiModalClose}
|
||||
style={{
|
||||
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
|
||||
cursor: 'pointer', fontSize: 20, padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', padding: '20px 16px 16px', gap: 24 }}>
|
||||
<div style={{ width: 240, minWidth: 240, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 240, height: 240, position: 'relative',
|
||||
backgroundColor: 'var(--bg-tertiary)', borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Cropper
|
||||
image={emojiPreviewUrl}
|
||||
crop={emojiCrop}
|
||||
zoom={emojiZoom}
|
||||
rotation={emojiRotation}
|
||||
aspect={1}
|
||||
cropShape="rect"
|
||||
showGrid={false}
|
||||
onCropChange={setEmojiCrop}
|
||||
onZoomChange={setEmojiZoom}
|
||||
onCropComplete={onEmojiCropComplete}
|
||||
style={{
|
||||
containerStyle: { width: 240, height: 240 },
|
||||
mediaStyle: {
|
||||
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 4 }}>
|
||||
<button onClick={() => setEmojiRotation((r) => (r - 90 + 360) % 360)} title="Rotate left" style={{ width: 36, height: 36, borderRadius: 4, background: 'var(--bg-tertiary)', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
|
||||
</button>
|
||||
<button onClick={() => setEmojiRotation((r) => (r + 90) % 360)} title="Rotate right" style={{ width: 36, height: 36, borderRadius: 4, background: 'var(--bg-tertiary)', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'scaleX(-1)' }}><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
|
||||
</button>
|
||||
<button onClick={() => setEmojiFlipH((f) => !f)} title="Flip horizontal" style={{ width: 36, height: 36, borderRadius: 4, background: emojiFlipH ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)', border: emojiFlipH ? '1px solid #5865F2' : 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
|
||||
</button>
|
||||
<button onClick={() => setEmojiFlipV((f) => !f)} title="Flip vertical" style={{ width: 36, height: 36, borderRadius: 4, background: emojiFlipV ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)', border: emojiFlipV ? '1px solid #5865F2' : 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(90deg)' }}><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="avatar-crop-slider-row" style={{ padding: 0, margin: 0 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)"><path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/></svg>
|
||||
<input type="range" min={1} max={3} step={0.01} value={emojiZoom} onChange={(e) => setEmojiZoom(Number(e.target.value))} className="avatar-crop-slider" />
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)"><path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>Preview</span>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, backgroundColor: 'rgba(88, 101, 242, 0.15)', border: '1px solid var(--brand-experiment, #5865F2)', borderRadius: 8, padding: '2px 6px', cursor: 'default' }}>
|
||||
<img src={emojiPreviewUrl} alt="" style={{ width: 16, height: 16, objectFit: 'contain', transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined }} />
|
||||
<span style={{ color: 'var(--text-normal)', fontSize: 14, marginLeft: 2 }}>1</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
|
||||
Emoji name <span style={{ color: '#ed4245' }}>*</span>
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input type="text" value={emojiName} onChange={(e) => { setEmojiName(e.target.value); setEmojiError(''); }} maxLength={32} style={{ width: '100%', padding: '10px 32px 10px 10px', background: 'var(--bg-tertiary)', border: 'none', borderRadius: 4, color: 'var(--header-primary)', fontSize: 14, boxSizing: 'border-box' }} />
|
||||
{emojiName && (
|
||||
<button onClick={() => setEmojiName('')} style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'transparent', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 14, padding: '2px 4px' }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
{emojiError && <div style={{ color: '#ed4245', fontSize: 13, marginTop: 6 }}>{emojiError}</div>}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={handleEmojiUpload} disabled={emojiUploading || !emojiName.trim()} style={{ backgroundColor: '#5865F2', color: '#fff', border: 'none', borderRadius: 3, padding: '10px 0', cursor: emojiUploading ? 'not-allowed' : 'pointer', fontWeight: 600, fontSize: 14, width: '100%', opacity: (emojiUploading || !emojiName.trim()) ? 0.5 : 1 }}>
|
||||
{emojiUploading ? 'Uploading...' : 'Finish'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// ─── Main return ───
|
||||
|
||||
if (isMobile) {
|
||||
return ReactDOM.createPortal(
|
||||
<div className="msm-root">
|
||||
{renderMobileContent()}
|
||||
{sharedModals}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'var(--bg-primary)', zIndex: 1000, display: 'flex', color: 'var(--text-normal)' }}>
|
||||
{renderSidebar()}
|
||||
@@ -772,231 +1299,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
</div>
|
||||
<div style={{ flex: '0.5' }} />
|
||||
</div>
|
||||
{showIconCropModal && rawIconUrl && (
|
||||
<AvatarCropModal
|
||||
imageUrl={rawIconUrl}
|
||||
onApply={handleIconCropApply}
|
||||
onCancel={handleIconCropCancel}
|
||||
cropShape="rect"
|
||||
/>
|
||||
)}
|
||||
{showEmojiModal && emojiPreviewUrl && (
|
||||
<div
|
||||
onClick={handleEmojiModalClose}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 2000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)', borderRadius: 8,
|
||||
width: 580, maxWidth: '90vw', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Modal header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '16px 16px 0',
|
||||
}}>
|
||||
<h2 style={{ color: 'var(--header-primary)', margin: 0, fontSize: 20, fontWeight: 600 }}>Add Emoji</h2>
|
||||
<button
|
||||
onClick={handleEmojiModalClose}
|
||||
style={{
|
||||
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
|
||||
cursor: 'pointer', fontSize: 20, padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal body */}
|
||||
<div style={{ display: 'flex', padding: '20px 16px 16px', gap: 24 }}>
|
||||
{/* Left: Cropper + toolbar + zoom */}
|
||||
<div style={{ width: 240, minWidth: 240, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 240, height: 240, position: 'relative',
|
||||
backgroundColor: 'var(--bg-tertiary)', borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Cropper
|
||||
image={emojiPreviewUrl}
|
||||
crop={emojiCrop}
|
||||
zoom={emojiZoom}
|
||||
rotation={emojiRotation}
|
||||
aspect={1}
|
||||
cropShape="rect"
|
||||
showGrid={false}
|
||||
onCropChange={setEmojiCrop}
|
||||
onZoomChange={setEmojiZoom}
|
||||
onCropComplete={onEmojiCropComplete}
|
||||
style={{
|
||||
containerStyle: { width: 240, height: 240 },
|
||||
mediaStyle: {
|
||||
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: rotate + flip */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 4 }}>
|
||||
<button
|
||||
onClick={() => setEmojiRotation((r) => (r - 90 + 360) % 360)}
|
||||
title="Rotate left"
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: 'var(--bg-tertiary)', border: 'none',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEmojiRotation((r) => (r + 90) % 360)}
|
||||
title="Rotate right"
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: 'var(--bg-tertiary)', border: 'none',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'scaleX(-1)' }}><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEmojiFlipH((f) => !f)}
|
||||
title="Flip horizontal"
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: emojiFlipH ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)',
|
||||
border: emojiFlipH ? '1px solid #5865F2' : 'none',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEmojiFlipV((f) => !f)}
|
||||
title="Flip vertical"
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 4,
|
||||
background: emojiFlipV ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)',
|
||||
border: emojiFlipV ? '1px solid #5865F2' : 'none',
|
||||
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(90deg)' }}><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Zoom slider */}
|
||||
<div className="avatar-crop-slider-row" style={{ padding: 0, margin: 0 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)">
|
||||
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={emojiZoom}
|
||||
onChange={(e) => setEmojiZoom(Number(e.target.value))}
|
||||
className="avatar-crop-slider"
|
||||
/>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)">
|
||||
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Reaction preview + Name + Finish */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Reaction pill preview */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
|
||||
Preview
|
||||
</span>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
backgroundColor: 'rgba(88, 101, 242, 0.15)',
|
||||
border: '1px solid var(--brand-experiment, #5865F2)',
|
||||
borderRadius: 8, padding: '2px 6px', cursor: 'default',
|
||||
}}>
|
||||
<img
|
||||
src={emojiPreviewUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: 16, height: 16, objectFit: 'contain',
|
||||
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-normal)', fontSize: 14, marginLeft: 2 }}>1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emoji name input */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
|
||||
Emoji name <span style={{ color: '#ed4245' }}>*</span>
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={emojiName}
|
||||
onChange={(e) => { setEmojiName(e.target.value); setEmojiError(''); }}
|
||||
maxLength={32}
|
||||
style={{
|
||||
width: '100%', padding: '10px 32px 10px 10px',
|
||||
background: 'var(--bg-tertiary)', border: 'none',
|
||||
borderRadius: 4, color: 'var(--header-primary)', fontSize: 14,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{emojiName && (
|
||||
<button
|
||||
onClick={() => setEmojiName('')}
|
||||
style={{
|
||||
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
|
||||
cursor: 'pointer', fontSize: 14, padding: '2px 4px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{emojiError && (
|
||||
<div style={{ color: '#ed4245', fontSize: 13, marginTop: 6 }}>{emojiError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Finish button */}
|
||||
<button
|
||||
onClick={handleEmojiUpload}
|
||||
disabled={emojiUploading || !emojiName.trim()}
|
||||
style={{
|
||||
backgroundColor: '#5865F2', color: '#fff', border: 'none',
|
||||
borderRadius: 3, padding: '10px 0', cursor: emojiUploading ? 'not-allowed' : 'pointer',
|
||||
fontWeight: 600, fontSize: 14, width: '100%',
|
||||
opacity: (emojiUploading || !emojiName.trim()) ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{emojiUploading ? 'Uploading...' : 'Finish'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sharedModals}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,11 @@ import { useVoice } from '../contexts/VoiceContext';
|
||||
import ChannelSettingsModal from './ChannelSettingsModal';
|
||||
import ServerSettingsModal from './ServerSettingsModal';
|
||||
import ScreenShareModal from './ScreenShareModal';
|
||||
import MobileServerDrawer from './MobileServerDrawer';
|
||||
import MobileCreateChannelScreen from './MobileCreateChannelScreen';
|
||||
import MobileCreateCategoryScreen from './MobileCreateCategoryScreen';
|
||||
import MobileChannelDrawer from './MobileChannelDrawer';
|
||||
import MobileChannelSettingsScreen from './MobileChannelSettingsScreen';
|
||||
import DMList from './DMList';
|
||||
import Avatar from './Avatar';
|
||||
import UserSettings from './UserSettings';
|
||||
@@ -808,7 +813,7 @@ const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId, serverName = 'Secure Chat', serverIconUrl, isMobile, onStartCallWithUser }) => {
|
||||
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId, serverName = 'Secure Chat', serverIconUrl, isMobile, onStartCallWithUser, onOpenMobileSearch }) => {
|
||||
const { crypto, settings } = usePlatform();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
||||
@@ -835,6 +840,11 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
const [activeDragItem, setActiveDragItem] = useState(null);
|
||||
const [dragOverChannelId, setDragOverChannelId] = useState(null);
|
||||
const [voiceNicknameModal, setVoiceNicknameModal] = useState(null);
|
||||
const [showMobileServerDrawer, setShowMobileServerDrawer] = useState(false);
|
||||
const [showMobileCreateChannel, setShowMobileCreateChannel] = useState(false);
|
||||
const [showMobileCreateCategory, setShowMobileCreateCategory] = useState(false);
|
||||
const [mobileChannelDrawer, setMobileChannelDrawer] = useState(null);
|
||||
const [showMobileChannelSettings, setShowMobileChannelSettings] = useState(null);
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
@@ -844,6 +854,9 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
userId ? { userId } : "skip"
|
||||
) || {};
|
||||
|
||||
// Member count for mobile server drawer
|
||||
const allUsersForDrawer = useQuery(api.auth.getPublicKeys) || [];
|
||||
|
||||
// DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: isMobile ? Infinity : 5 } })
|
||||
@@ -1099,6 +1112,52 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
}
|
||||
};
|
||||
|
||||
// Long-press handler factory for mobile channel items
|
||||
const createLongPressHandlers = (callback) => {
|
||||
let timer = null;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let triggered = false;
|
||||
return {
|
||||
onTouchStart: (e) => {
|
||||
triggered = false;
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
timer = setTimeout(() => {
|
||||
triggered = true;
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
callback();
|
||||
}, 500);
|
||||
},
|
||||
onTouchMove: (e) => {
|
||||
if (!timer) return;
|
||||
const dx = e.touches[0].clientX - startX;
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
},
|
||||
onTouchEnd: (e) => {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
if (triggered) { e.preventDefault(); triggered = false; }
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleMarkAsRead = async (channelId) => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
await convex.mutation(api.readState.markRead, {
|
||||
userId,
|
||||
channelId,
|
||||
lastReadTimestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to mark as read:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const renderDMView = () => (
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
|
||||
<DMList
|
||||
@@ -1393,12 +1452,34 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
|
||||
const renderServerView = () => (
|
||||
<div className="channel-panel" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
|
||||
<div className="server-header" style={{ borderBottom: '1px solid var(--app-frame-border)' }}>
|
||||
<span className="server-header-name" onClick={() => setIsServerSettingsOpen(true)}>{serverName}</span>
|
||||
<button className="server-header-invite" onClick={handleCreateInvite} title="Invite People">
|
||||
<img src={inviteUserIcon} alt="Invite" />
|
||||
</button>
|
||||
<div className="server-header" style={{ borderBottom: isMobile ? 'none' : '1px solid var(--app-frame-border)' }}>
|
||||
<span className="server-header-name" onClick={() => isMobile ? setShowMobileServerDrawer(true) : setIsServerSettingsOpen(true)}>
|
||||
{serverName}
|
||||
{isMobile && (
|
||||
<svg className="mobile-server-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>
|
||||
)}
|
||||
</span>
|
||||
{!isMobile && (
|
||||
<button className="server-header-invite" onClick={handleCreateInvite} title="Invite People">
|
||||
<img src={inviteUserIcon} alt="Invite" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isMobile && (
|
||||
<div className="mobile-search-invite-row">
|
||||
<button className="mobile-search-bar-btn" onClick={onOpenMobileSearch}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
|
||||
</svg>
|
||||
Search
|
||||
</button>
|
||||
<button className="mobile-search-invite-btn" onClick={handleCreateInvite} title="Invite People">
|
||||
<img src={inviteUserIcon} alt="Invite" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }} onContextMenu={isMobile ? undefined : (e) => {
|
||||
if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) {
|
||||
@@ -1497,6 +1578,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''} ${dragOverChannelId === channel._id ? 'voice-drop-target' : ''}`}
|
||||
onClick={() => handleChannelClick(channel)}
|
||||
{...channelDragListeners}
|
||||
{...(isMobile ? createLongPressHandlers(() => setMobileChannelDrawer(channel)) : {})}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
@@ -1523,6 +1605,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<button
|
||||
className="channel-settings-icon"
|
||||
onClick={(e) => {
|
||||
@@ -1539,6 +1622,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
>
|
||||
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
|
||||
</button>
|
||||
)}
|
||||
</div>}
|
||||
{isCollapsed
|
||||
? renderCollapsedVoiceUsers(channel)
|
||||
@@ -1712,7 +1796,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
|
||||
<UserControlPanel username={username} userId={userId} />
|
||||
|
||||
{editingChannel && (
|
||||
{editingChannel && !isMobile && (
|
||||
<ChannelSettingsModal
|
||||
channel={editingChannel}
|
||||
onClose={() => setEditingChannel(null)}
|
||||
@@ -1723,6 +1807,18 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
{isServerSettingsOpen && (
|
||||
<ServerSettingsModal onClose={() => setIsServerSettingsOpen(false)} />
|
||||
)}
|
||||
{showMobileServerDrawer && (
|
||||
<MobileServerDrawer
|
||||
serverName={serverName}
|
||||
serverIconUrl={serverIconUrl}
|
||||
memberCount={allUsersForDrawer.length}
|
||||
onInvite={handleCreateInvite}
|
||||
onSettings={() => setIsServerSettingsOpen(true)}
|
||||
onCreateChannel={() => { setCreateChannelCategoryId(null); setShowMobileCreateChannel(true); }}
|
||||
onCreateCategory={() => setShowMobileCreateCategory(true)}
|
||||
onClose={() => setShowMobileServerDrawer(false)}
|
||||
/>
|
||||
)}
|
||||
{isScreenShareModalOpen && (
|
||||
<ScreenShareModal
|
||||
onClose={() => setIsScreenShareModalOpen(false)}
|
||||
@@ -1828,6 +1924,54 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showMobileCreateChannel && (
|
||||
<MobileCreateChannelScreen
|
||||
categoryId={createChannelCategoryId}
|
||||
onClose={() => setShowMobileCreateChannel(false)}
|
||||
onSubmit={async (name, type, catId) => {
|
||||
const userId = localStorage.getItem('userId');
|
||||
if (!userId) { alert("Please login first."); return; }
|
||||
try {
|
||||
const createArgs = { name, type };
|
||||
if (catId) createArgs.categoryId = catId;
|
||||
const { id: channelId } = await convex.mutation(api.channels.create, createArgs);
|
||||
const keyHex = randomHex(32);
|
||||
try { await encryptKeyForUsers(convex, channelId, keyHex, crypto); }
|
||||
catch (keyErr) { console.error("Critical: Failed to distribute keys", keyErr); alert("Channel created but key distribution failed."); }
|
||||
} catch (err) { console.error(err); alert("Failed to create channel: " + err.message); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showMobileCreateCategory && (
|
||||
<MobileCreateCategoryScreen
|
||||
onClose={() => setShowMobileCreateCategory(false)}
|
||||
onSubmit={async (name) => {
|
||||
try {
|
||||
await convex.mutation(api.categories.create, { name });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Failed to create category: " + err.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{mobileChannelDrawer && (
|
||||
<MobileChannelDrawer
|
||||
channel={mobileChannelDrawer}
|
||||
isUnread={unreadChannels.has(mobileChannelDrawer._id)}
|
||||
onMarkAsRead={() => handleMarkAsRead(mobileChannelDrawer._id)}
|
||||
onEditChannel={() => setShowMobileChannelSettings(mobileChannelDrawer)}
|
||||
onClose={() => setMobileChannelDrawer(null)}
|
||||
/>
|
||||
)}
|
||||
{showMobileChannelSettings && (
|
||||
<MobileChannelSettingsScreen
|
||||
channel={showMobileChannelSettings}
|
||||
categories={categories}
|
||||
onClose={() => setShowMobileChannelSettings(null)}
|
||||
onDelete={onDeleteChannel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useQuery, useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import Avatar from './Avatar';
|
||||
@@ -7,6 +8,7 @@ import { useTheme, THEMES, THEME_LABELS } from '../contexts/ThemeContext';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import { useSearch } from '../contexts/SearchContext';
|
||||
import { usePlatform } from '../platform';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
const THEME_PREVIEWS = {
|
||||
[THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' },
|
||||
@@ -26,14 +28,22 @@ const TABS = [
|
||||
|
||||
const UserSettings = ({ onClose, userId, username, onLogout }) => {
|
||||
const [activeTab, setActiveTab] = useState('account');
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileScreen, setMobileScreen] = useState('menu');
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') {
|
||||
if (isMobile && mobileScreen !== 'menu') {
|
||||
setMobileScreen('menu');
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
}, [onClose, isMobile, mobileScreen]);
|
||||
|
||||
const renderSidebar = () => {
|
||||
let lastSection = null;
|
||||
@@ -91,6 +101,174 @@ const UserSettings = ({ onClose, userId, username, onLogout }) => {
|
||||
return items;
|
||||
};
|
||||
|
||||
// ─── Mobile render functions ───
|
||||
|
||||
const renderMobileHeader = (title, onBack) => (
|
||||
<div className="msm-header">
|
||||
<button className="msm-back-btn" onClick={onBack}>
|
||||
<svg width="22" height="22" 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="msm-header-title">{title}</div>
|
||||
<div style={{ width: 32 }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileMenu = () => (
|
||||
<div className="msm-screen">
|
||||
<div className="msm-header">
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="msm-back-btn" onClick={onClose}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="msm-content">
|
||||
<div className="msm-section-label">User Settings</div>
|
||||
<div className="msm-card">
|
||||
<div className="msm-card-item" onClick={() => setMobileScreen('account')}>
|
||||
<span className="msm-card-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Account</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="msm-card-item" onClick={() => setMobileScreen('security')}>
|
||||
<span className="msm-card-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/></svg>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Security</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="msm-section-label">App Settings</div>
|
||||
<div className="msm-card">
|
||||
<div className="msm-card-item" onClick={() => setMobileScreen('appearance')}>
|
||||
<span className="msm-card-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.38-.61-.38-1 0-.83.67-1.5 1.5-1.5H16c2.76 0 5-2.24 5-5 0-4.42-4.03-8-9-8zm-5.5 9c-.83 0-1.5-.67-1.5-1.5S5.67 9 6.5 9 8 9.67 8 10.5 7.33 12 6.5 12zm3-4C8.67 8 8 7.33 8 6.5S8.67 5 9.5 5s1.5.67 1.5 1.5S10.33 8 9.5 8zm5 0c-.83 0-1.5-.67-1.5-1.5S13.67 5 14.5 5s1.5.67 1.5 1.5S15.33 8 14.5 8zm3 4c-.83 0-1.5-.67-1.5-1.5S16.67 9 17.5 9s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/></svg>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Appearance</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="msm-card-item" onClick={() => setMobileScreen('voice')}>
|
||||
<span className="msm-card-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/></svg>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Voice & Video</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="msm-card-item" onClick={() => setMobileScreen('keybinds')}>
|
||||
<span className="msm-card-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z"/></svg>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Keybinds</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className="msm-card-item" onClick={() => setMobileScreen('search')}>
|
||||
<span className="msm-card-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Search</span>
|
||||
<span className="msm-card-item-chevron">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Out */}
|
||||
<div className="msm-card" style={{ marginTop: 24 }}>
|
||||
<div className="msm-card-item msm-card-item-danger" onClick={onLogout}>
|
||||
<span className="msm-card-item-icon" style={{ color: '#ed4245' }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M16 17L21 12L16 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M21 12H9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
Log Out
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMobileContent = () => {
|
||||
switch (mobileScreen) {
|
||||
case 'account':
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Account', () => setMobileScreen('menu'))}
|
||||
<div className="msm-content">
|
||||
<MyAccountTab userId={userId} username={username} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'security':
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Security', () => setMobileScreen('menu'))}
|
||||
<div className="msm-content">
|
||||
<SecurityTab />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'appearance':
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Appearance', () => setMobileScreen('menu'))}
|
||||
<div className="msm-content">
|
||||
<AppearanceTab />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'voice':
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Voice & Video', () => setMobileScreen('menu'))}
|
||||
<div className="msm-content">
|
||||
<VoiceVideoTab />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'keybinds':
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Keybinds', () => setMobileScreen('menu'))}
|
||||
<div className="msm-content">
|
||||
<KeybindsTab />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'search':
|
||||
return (
|
||||
<div className="msm-screen">
|
||||
{renderMobileHeader('Search', () => setMobileScreen('menu'))}
|
||||
<div className="msm-content">
|
||||
<SearchTab userId={userId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return renderMobileMenu();
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return ReactDOM.createPortal(
|
||||
<div className="msm-root">{renderMobileContent()}</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import { useVoice } from '../contexts/VoiceContext';
|
||||
import FriendsView from '../components/FriendsView';
|
||||
import MembersList from '../components/MembersList';
|
||||
import MobileMembersScreen from '../components/MobileMembersScreen';
|
||||
import MobileSearchScreen from '../components/MobileSearchScreen';
|
||||
import ChatHeader from '../components/ChatHeader';
|
||||
import SearchPanel from '../components/SearchPanel';
|
||||
import SearchDropdown from '../components/SearchDropdown';
|
||||
@@ -40,6 +41,7 @@ const Chat = () => {
|
||||
const [showMembers, setShowMembers] = useState(true);
|
||||
const [showPinned, setShowPinned] = useState(false);
|
||||
const [showMobileMembersScreen, setShowMobileMembersScreen] = useState(false);
|
||||
const [showMobileSearchScreen, setShowMobileSearchScreen] = useState(false);
|
||||
|
||||
const { activeView: mobileView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping, swipeBindProps } =
|
||||
useSwipeNavigation({
|
||||
@@ -497,7 +499,8 @@ const Chat = () => {
|
||||
setShowSearchResults(false);
|
||||
setSearchQuery('');
|
||||
setJumpToMessageId(messageId);
|
||||
}, [dmChannels]);
|
||||
if (isMobile) goToChat();
|
||||
}, [dmChannels, isMobile, goToChat]);
|
||||
|
||||
// Shared search props for ChatHeader
|
||||
const searchProps = {
|
||||
@@ -617,7 +620,7 @@ const Chat = () => {
|
||||
return (
|
||||
<div className="chat-container">
|
||||
{isMobile && (
|
||||
<div className="chat-header">
|
||||
<div className="chat-header voice-header">
|
||||
<div className="chat-header-left">
|
||||
<button className="mobile-back-btn" onClick={handleMobileBack}>
|
||||
<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>
|
||||
@@ -736,6 +739,7 @@ const Chat = () => {
|
||||
serverIconUrl={serverIconUrl}
|
||||
isMobile={isMobile}
|
||||
onStartCallWithUser={handleStartCallWithUser}
|
||||
onOpenMobileSearch={() => setShowMobileSearchScreen(true)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -786,6 +790,22 @@ const Chat = () => {
|
||||
onClose={() => setShowMobileMembersScreen(false)}
|
||||
/>
|
||||
)}
|
||||
{showMobileSearchScreen && isMobile && (
|
||||
<MobileSearchScreen
|
||||
channels={channels}
|
||||
allMembers={allMembers}
|
||||
serverName={serverName}
|
||||
onClose={() => setShowMobileSearchScreen(false)}
|
||||
onSelectChannel={(channelId) => {
|
||||
handleSelectChannel(channelId);
|
||||
setShowMobileSearchScreen(false);
|
||||
}}
|
||||
onJumpToMessage={(channelId, messageId) => {
|
||||
handleJumpToMessage(channelId, messageId);
|
||||
setShowMobileSearchScreen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PresenceProvider>
|
||||
);
|
||||
|
||||
@@ -398,16 +398,21 @@
|
||||
/* Chat area/header use var(--bg-primary), not --bg-tertiary, so override explicitly */
|
||||
.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 .mobile-members-screen {
|
||||
.theme-dark .mobile-members-screen,
|
||||
.theme-dark .mobile-search-screen {
|
||||
background-color: #1C1D22;
|
||||
}
|
||||
|
||||
.theme-dark .is-mobile .chat-header {
|
||||
background-color: #1C1D22;
|
||||
border-bottom: 1px solid var(--app-frame-border);
|
||||
}
|
||||
|
||||
.theme-dark .is-mobile .chat-header.voice-header {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.theme-dark .is-mobile .chat-input-wrapper {
|
||||
background-color: #26262E;
|
||||
}
|
||||
|
||||
212
packages/shared/src/utils/searchRendering.jsx
Normal file
212
packages/shared/src/utils/searchRendering.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { usePlatform } from '../platform';
|
||||
import { AllEmojis } from '../assets/emojis';
|
||||
|
||||
export function formatTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export function linkifyHtml(html) {
|
||||
if (!html) return '';
|
||||
return html.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" class="search-result-link">$1</a>');
|
||||
}
|
||||
|
||||
export function formatEmojisHtml(html, customEmojis = []) {
|
||||
if (!html) return '';
|
||||
return html.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
|
||||
const custom = customEmojis.find(e => e.name === name);
|
||||
if (custom) return `<img src="${custom.src}" alt="${match}" style="width:48px;height:48px;vertical-align:bottom;margin:0 1px;display:inline" />`;
|
||||
const emoji = AllEmojis.find(e => e.name === name);
|
||||
if (emoji) return `<img src="${emoji.src}" alt="${match}" style="width:48px;height:48px;vertical-align:bottom;margin:0 1px;display:inline" />`;
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
export function getAvatarColor(name) {
|
||||
const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
export const CONVEX_PUBLIC_URL = 'https://api.brycord.com';
|
||||
export const rewriteStorageUrl = (url) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const pub = new URL(CONVEX_PUBLIC_URL);
|
||||
u.hostname = pub.hostname;
|
||||
u.port = pub.port;
|
||||
u.protocol = pub.protocol;
|
||||
return u.toString();
|
||||
} catch { return url; }
|
||||
};
|
||||
|
||||
export const toHexString = (bytes) =>
|
||||
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
|
||||
|
||||
export const searchImageCache = new Map();
|
||||
|
||||
export const SearchResultImage = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) throw new Error('Invalid file data');
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search image decrypt error:', err);
|
||||
if (isMounted) { setError('Failed to load'); setLoading(false); }
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading image...</div>;
|
||||
if (error) return null;
|
||||
return <img src={url} alt={metadata.filename} style={{ width: '100%', height: 'auto', borderRadius: 4, marginTop: 4, display: 'block' }} />;
|
||||
};
|
||||
|
||||
export const SearchResultVideo = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
const [error, setError] = useState(null);
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) throw new Error('Invalid file data');
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search video decrypt error:', err);
|
||||
if (isMounted) { setError('Failed to load'); setLoading(false); }
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading video...</div>;
|
||||
if (error) return null;
|
||||
|
||||
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
|
||||
return (
|
||||
<div style={{ marginTop: 4, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
|
||||
<video ref={videoRef} src={url} controls={showControls} style={{ width: '100%', maxHeight: 200, borderRadius: 4, display: 'block', backgroundColor: 'black' }} />
|
||||
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>▶</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SearchResultFile = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) return;
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search file decrypt error:', err);
|
||||
if (isMounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
const sizeStr = metadata.size ? `${(metadata.size / 1024).toFixed(1)} KB` : '';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '8px 10px', borderRadius: 4, marginTop: 4, maxWidth: '100%' }}>
|
||||
<span style={{ marginRight: 8, fontSize: 20 }}>📄</span>
|
||||
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 }}>{metadata.filename}</div>
|
||||
{sizeStr && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>{sizeStr}</div>}
|
||||
{url && <a href={url} download={metadata.filename} onClick={e => e.stopPropagation()} style={{ color: 'var(--header-secondary)', fontSize: 11, textDecoration: 'underline' }}>Download</a>}
|
||||
{loading && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>Decrypting...</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user