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

This commit is contained in:
Bryan1029384756
2026-02-23 11:27:01 -06:00
parent 90cf99f7ab
commit a6af4dda00
22 changed files with 3462 additions and 2396 deletions

View File

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

View 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;

View 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;

View File

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

View 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;

View 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;

View 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;

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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() || {};

View File

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

View File

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

View File

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

View File

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

View File

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

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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' }}>&#9654;</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 }}>&#128196;</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>
);
};