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
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user