feat: Introduce multi-platform architecture for Electron and Web clients with shared UI components, Convex backend for messaging, and integrated search functionality.
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-02-16 13:08:39 -06:00
parent 8ff9213b34
commit ec12313996
49 changed files with 2449 additions and 3914 deletions

View File

@@ -9,12 +9,16 @@ import { useVoice } from '../contexts/VoiceContext';
import FriendsView from '../components/FriendsView';
import MembersList from '../components/MembersList';
import ChatHeader from '../components/ChatHeader';
import SearchPanel from '../components/SearchPanel';
import SearchDropdown from '../components/SearchDropdown';
import { useToasts } from '../components/Toast';
import { PresenceProvider } from '../contexts/PresenceContext';
import { getUserPref, setUserPref } from '../utils/userPreferences';
import { usePlatform } from '../platform';
import { useIsMobile } from '../hooks/useIsMobile';
const MAX_SEARCH_HISTORY = 10;
const Chat = () => {
const { crypto, settings } = usePlatform();
const isMobile = useIsMobile();
@@ -31,6 +35,17 @@ const Chat = () => {
const [showPinned, setShowPinned] = useState(false);
const [mobileView, setMobileView] = useState('sidebar');
// Search state
const [searchQuery, setSearchQuery] = useState('');
const [showSearchDropdown, setShowSearchDropdown] = useState(false);
const [showSearchResults, setShowSearchResults] = useState(false);
const [searchSortOrder, setSearchSortOrder] = useState('newest');
const [searchHistory, setSearchHistory] = useState(() => {
const id = localStorage.getItem('userId');
return id ? getUserPref(id, 'searchHistory', []) : [];
});
const searchInputRef = useRef(null);
const convex = useConvex();
const { toasts, addToast, removeToast, ToastContainer } = useToasts();
const prevDmChannelsRef = useRef(null);
@@ -41,7 +56,11 @@ const Chat = () => {
const handler = (e) => {
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
// Quick switcher placeholder - could open a search modal
// Focus the search input
const input = searchInputRef.current?.querySelector('input');
if (input) {
input.focus();
}
}
if (e.ctrlKey && e.shiftKey && e.key === 'M') {
e.preventDefault();
@@ -57,6 +76,7 @@ const Chat = () => {
const serverSettings = useQuery(api.serverSettings.get);
const serverName = serverSettings?.serverName || 'Secure Chat';
const serverIconUrl = serverSettings?.iconUrl || null;
const allMembers = useQuery(api.members.listAll) || [];
const rawChannelKeys = useQuery(
api.channelKeys.getKeysForUser,
@@ -195,6 +215,127 @@ const Chat = () => {
}
}, [voiceActiveChannelId]);
// Search handlers
const handleSearchQueryChange = useCallback((val) => {
setSearchQuery(val);
if (val === '') {
setShowSearchResults(false);
}
if (!showSearchDropdown && val !== undefined) {
setShowSearchDropdown(true);
}
}, [showSearchDropdown]);
const handleSearchFocus = useCallback(() => {
setShowSearchDropdown(true);
}, []);
const handleSearchBlur = useCallback(() => {
// Dropdown close is handled by click-outside in SearchDropdown
}, []);
const handleSearchSubmit = useCallback(() => {
if (!searchQuery.trim()) return;
setShowSearchDropdown(false);
setShowSearchResults(true);
// Save to history
setSearchHistory(prev => {
const filtered = prev.filter(h => h !== searchQuery.trim());
const updated = [searchQuery.trim(), ...filtered].slice(0, MAX_SEARCH_HISTORY);
if (userId) {
setUserPref(userId, 'searchHistory', updated, settings);
}
return updated;
});
}, [searchQuery, userId, settings]);
const handleSelectFilter = useCallback((prefix, value) => {
if (value !== null) {
// Replace the current active prefix with the completed token
const beforePrefix = searchQuery.replace(/\b(from|in|has|mentions):\S*$/i, '').trimEnd();
const newQuery = beforePrefix + (beforePrefix ? ' ' : '') + prefix + ':' + value + ' ';
setSearchQuery(newQuery);
} else {
// Just insert the prefix (e.g., clicking "from:" suggestion)
const newQuery = searchQuery + (searchQuery && !searchQuery.endsWith(' ') ? ' ' : '') + prefix + ':';
setSearchQuery(newQuery);
}
// Re-focus input
setTimeout(() => {
const input = searchInputRef.current?.querySelector('input');
if (input) input.focus();
}, 0);
}, [searchQuery]);
const handleSelectHistoryItem = useCallback((item) => {
setSearchQuery(item);
setShowSearchDropdown(false);
setShowSearchResults(true);
}, []);
const handleClearHistory = useCallback(() => {
setSearchHistory([]);
if (userId) {
setUserPref(userId, 'searchHistory', [], settings);
}
}, [userId, settings]);
const handleClearHistoryItem = useCallback((index) => {
setSearchHistory(prev => {
const updated = prev.filter((_, i) => i !== index);
if (userId) {
setUserPref(userId, 'searchHistory', updated, settings);
}
return updated;
});
}, [userId, settings]);
const handleCloseSearchDropdown = useCallback(() => {
setShowSearchDropdown(false);
}, []);
const handleCloseSearchResults = useCallback(() => {
setShowSearchResults(false);
setSearchQuery('');
}, []);
const handleJumpToMessage = useCallback((channelId, messageId) => {
// Switch to the correct channel if needed
const isDM = dmChannels.some(dm => dm.channel_id === channelId);
if (isDM) {
const dm = dmChannels.find(d => d.channel_id === channelId);
if (dm) {
setActiveDMChannel(dm);
setView('me');
}
} else {
setActiveChannel(channelId);
setView('server');
}
setShowSearchResults(false);
setSearchQuery('');
// Give time for channel to render then scroll
setTimeout(() => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('message-highlight');
setTimeout(() => el.classList.remove('message-highlight'), 2000);
}
}, 300);
}, [dmChannels]);
// Shared search props for ChatHeader
const searchProps = {
searchQuery,
onSearchQueryChange: handleSearchQueryChange,
onSearchSubmit: handleSearchSubmit,
onSearchFocus: handleSearchFocus,
onSearchBlur: handleSearchBlur,
searchInputRef,
searchActive: showSearchDropdown || showSearchResults,
};
function renderMainContent() {
if (view === 'me') {
if (activeDMChannel) {
@@ -208,6 +349,7 @@ const Chat = () => {
onTogglePinned={() => setShowPinned(p => !p)}
isMobile={isMobile}
onMobileBack={handleMobileBack}
{...searchProps}
/>
<div className="chat-content">
<ChatArea
@@ -223,6 +365,17 @@ const Chat = () => {
showPinned={showPinned}
onTogglePinned={() => setShowPinned(false)}
/>
<SearchPanel
visible={showSearchResults}
onClose={handleCloseSearchResults}
channels={channels}
isDM={true}
dmChannelId={activeDMChannel.channel_id}
onJumpToMessage={handleJumpToMessage}
query={searchQuery}
sortOrder={searchSortOrder}
onSortChange={setSearchSortOrder}
/>
</div>
</div>
);
@@ -277,6 +430,7 @@ const Chat = () => {
serverName={serverName}
isMobile={isMobile}
onMobileBack={handleMobileBack}
{...searchProps}
/>
<div className="chat-content">
<ChatArea
@@ -297,6 +451,15 @@ const Chat = () => {
visible={effectiveShowMembers}
onMemberClick={(member) => {}}
/>
<SearchPanel
visible={showSearchResults}
onClose={handleCloseSearchResults}
channels={channels}
onJumpToMessage={handleJumpToMessage}
query={searchQuery}
sortOrder={searchSortOrder}
onSortChange={setSearchSortOrder}
/>
</div>
</div>
);
@@ -359,6 +522,21 @@ const Chat = () => {
)}
{showMainContent && renderMainContent()}
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}
{showSearchDropdown && !isMobile && (
<SearchDropdown
visible={showSearchDropdown}
searchText={searchQuery}
channels={channels}
members={allMembers}
searchHistory={searchHistory}
onSelectFilter={handleSelectFilter}
onSelectHistoryItem={handleSelectHistoryItem}
onClearHistory={handleClearHistory}
onClearHistoryItem={handleClearHistoryItem}
anchorRef={searchInputRef}
onClose={handleCloseSearchDropdown}
/>
)}
<ToastContainer />
</div>
</PresenceProvider>