Message Search
Adding a robust message search functionality was important to me since it's a fundamental feature for any chat application. I wanted users to be able to quickly find past messages without endlessly scrolling.
Core Search Functionality
I implemented a search system that allows users to find messages by:
- Text content
- Sender name
- Date range
- Media type
// src/components/search/MessageSearch.jsx
function MessageSearch() {
const [searchQuery, setSearchQuery] = useState("");
const [searchType, setSearchType] = useState("text");
const [dateRange, setDateRange] = useState({ start: null, end: null });
const [searchResults, setSearchResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const [error, setError] = useState(null);
const { currentChat } = useChat();
const { currentUser } = useAuth();
const handleSearch = async (e) => {
e.preventDefault();
if (!searchQuery.trim() && !dateRange.start && !dateRange.end) return;
setIsSearching(true);
setError(null);
try {
const chatRef = ref(db, `messages/${currentChat.id}`);
let query = chatRef;
// Order by timestamp for date filtering
if (dateRange.start || dateRange.end) {
query = query.orderByChild('timestamp');
if (dateRange.start) {
query = query.startAt(dateRange.start.getTime());
}
if (dateRange.end) {
query = query.endAt(dateRange.end.getTime());
}
}
const snapshot = await get(query);
if (!snapshot.exists()) {
setSearchResults([]);
setIsSearching(false);
return;
}
const messages = [];
snapshot.forEach((child) => {
const message = {
id: child.key,
...child.val()
};
messages.push(message);
});
// Filter by search query text
let filteredMessages = messages;
if (searchQuery.trim() !== "") {
const normalizedQuery = searchQuery.toLowerCase();
switch (searchType) {
case "text":
filteredMessages = messages.filter(message =>
message.content &&
message.content.toLowerCase().includes(normalizedQuery)
);
break;
case "sender":
filteredMessages = messages.filter(message =>
message.senderName &&
message.senderName.toLowerCase().includes(normalizedQuery)
);
break;
case "media":
filteredMessages = messages.filter(message =>
message.type === 'image' ||
message.type === 'video' ||
message.type === 'file' ||
(message.embed && message.embed.contentType &&
message.embed.contentType.startsWith(normalizedQuery))
);
break;
default:
break;
}
}
setSearchResults(filteredMessages);
} catch (err) {
console.error("Error searching messages:", err);
setError("Failed to search messages. Please try again.");
} finally {
setIsSearching(false);
}
};
return (
<div className={styles.searchContainer}>
<form onSubmit={handleSearch} className={styles.searchForm}>
<div className={styles.searchInputs}>
<input
type="text"
placeholder="Search messages..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={styles.searchInput}
/>
<select
value={searchType}
onChange={(e) => setSearchType(e.target.value)}
className={styles.searchType}
>
<option value="text">By Content</option>
<option value="sender">By Sender</option>
<option value="media">By Media Type</option>
</select>
</div>
<div className={styles.dateFilters}>
<div>
<label>From:</label>
<DatePicker
selected={dateRange.start}
onChange={date => setDateRange({...dateRange, start: date})}
className={styles.datePicker}
maxDate={new Date()}
/>
</div>
<div>
<label>To:</label>
<DatePicker
selected={dateRange.end}
onChange={date => setDateRange({...dateRange, end: date})}
className={styles.datePicker}
maxDate={new Date()}
minDate={dateRange.start}
/>
</div>
</div>
<button
type="submit"
className={styles.searchButton}
disabled={isSearching}
>
{isSearching ? 'Searching...' : 'Search'}
</button>
</form>
{error && <div className={styles.errorMessage}>{error}</div>}
<div className={styles.resultsContainer}>
{searchResults.length === 0 && !isSearching ? (
<div className={styles.noResults}>No messages found</div>
) : (
<div className={styles.resultsList}>
{searchResults.map(message => (
<SearchResultItem
key={message.id}
message={message}
currentUser={currentUser}
/>
))}
</div>
)}
</div>
</div>
);
}
Search Result Display
I created a specialized component to display search results with context:
// src/components/search/SearchResultItem.jsx
function SearchResultItem({ message, currentUser }) {
const { navigateToMessage } = useChat();
const messageDate = new Date(message.timestamp);
// Format the date for display
const formattedDate = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
}).format(messageDate);
// Highlight the search terms in the message content
const highlightMatch = (content, searchTerm) => {
if (!searchTerm || !content) return content;
const regex = new RegExp(`(${searchTerm})`, 'gi');
return content.replace(regex, '<mark>$1</mark>');
};
// Used when user clicks on a search result
const handleNavigateToMessage = () => {
navigateToMessage(message.id);
};
return (
<div
className={styles.searchResultItem}
onClick={handleNavigateToMessage}
>
<div className={styles.messageHeader}>
<span className={styles.senderName}>
{message.senderName}
{message.senderId === currentUser.uid && " (you)"}
</span>
<span className={styles.messageTime}>{formattedDate}</span>
</div>
<div className={styles.messageContent}>
{message.type === 'text' ? (
<div
dangerouslySetInnerHTML={{
__html: highlightMatch(message.content)
}}
className={styles.messageText}
/>
) : message.type === 'image' ? (
<div className={styles.imagePreview}>
<img src={message.imageUrl} alt="Image" />
<span>Image</span>
</div>
) : message.type === 'file' ? (
<div className={styles.filePreview}>
<span className={styles.fileIcon}>📄</span>
<span>{message.fileName}</span>
</div>
) : (
<div className={styles.messageText}>{message.content}</div>
)}
</div>
</div>
);
}
Search Context Provider
To make search functionality available throughout the app, I created a dedicated context:
// src/contexts/SearchContext.jsx
const SearchContext = createContext();
export function SearchProvider({ children }) {
const [searchOpen, setSearchOpen] = useState(false);
const [lastSearch, setLastSearch] = useState({
query: "",
type: "text",
dateRange: { start: null, end: null }
});
const [recentSearches, setRecentSearches] = useState([]);
// Store recent searches in local storage
useEffect(() => {
const storedSearches = localStorage.getItem('recentSearches');
if (storedSearches) {
try {
setRecentSearches(JSON.parse(storedSearches));
} catch (err) {
console.error("Error parsing stored searches:", err);
localStorage.removeItem('recentSearches');
}
}
}, []);
// Update recent searches when a new search is performed
const addRecentSearch = (search) => {
// Don't add empty searches
if (!search.query.trim()) return;
// Add to recent searches (avoiding duplicates)
const updatedSearches = [
search,
...recentSearches.filter(s => s.query !== search.query)
].slice(0, 5); // Keep only 5 most recent
setRecentSearches(updatedSearches);
localStorage.setItem('recentSearches', JSON.stringify(updatedSearches));
};
const clearRecentSearches = () => {
setRecentSearches([]);
localStorage.removeItem('recentSearches');
};
return (
<SearchContext.Provider value={{
searchOpen,
setSearchOpen,
lastSearch,
setLastSearch,
recentSearches,
addRecentSearch,
clearRecentSearches
}}>
{children}
</SearchContext.Provider>
);
}
export const useSearch = () => useContext(SearchContext);
Integration with Chat UI
I integrated the search feature into the main chat interface:
// In ChatHeader.jsx
function ChatHeader() {
const { currentChat } = useChat();
const { searchOpen, setSearchOpen } = useSearch();
return (
<div className={styles.chatHeader}>
<div className={styles.chatInfo}>
<h2>{currentChat?.name || 'Chat'}</h2>
<div className={styles.chatMeta}>
{currentChat?.participantCount || 0} participants
</div>
</div>
<div className={styles.chatActions}>
<button
className={`${styles.actionButton} ${searchOpen ? styles.active : ''}`}
onClick={() => setSearchOpen(!searchOpen)}
aria-label="Search messages"
title="Search messages"
>
<SearchIcon />
</button>
{/* Other action buttons */}
</div>
</div>
);
}
// In ChatArea.jsx
function ChatArea() {
const { searchOpen } = useSearch();
return (
<div className={styles.chatArea}>
<ChatHeader />
<div className={styles.chatContent}>
{searchOpen ? (
<div className={styles.searchOverlay}>
<MessageSearch />
</div>
) : (
<MessageList />
)}
</div>
<MessageInput />
</div>
);
}
Search Optimization
Since Firebase Realtime Database doesn't support full-text search, I had to optimize the search functionality:
// src/utils/searchUtils.js
// Helper function to preprocess search text
export const preprocessSearchText = (text) => {
if (!text) return '';
return text
.toLowerCase()
.replace(/[^\w\s]/g, '') // Remove special chars
.trim();
};
// For better performance with large message sets
export const batchProcessMessages = (messages, searchFunction, batchSize = 50) => {
return new Promise((resolve) => {
const results = [];
let index = 0;
function processNextBatch() {
const batch = messages.slice(index, index + batchSize);
if (batch.length === 0) {
resolve(results);
return;
}
// Process this batch
const batchResults = batch.filter(searchFunction);
results.push(...batchResults);
index += batchSize;
// Schedule next batch to avoid blocking the UI
setTimeout(processNextBatch, 0);
}
processNextBatch();
});
};
Technical Challenges
Large Message Sets
Searching through a large number of messages could cause performance issues:
-
Client-side Filtering - Since Firebase doesn't support text search, I had to download all messages and filter them client-side, which can be slow for large chats.
-
Pagination - To address this, I implemented pagination for search results, loading only a limited number of messages at a time.
// In MessageSearch.jsx
const [currentPage, setCurrentPage] = useState(1);
const resultsPerPage = 20;
// Calculate pagination
const totalPages = Math.ceil(searchResults.length / resultsPerPage);
const paginatedResults = searchResults.slice(
(currentPage - 1) * resultsPerPage,
currentPage * resultsPerPage
);
// Pagination controls
const handlePageChange = (newPage) => {
if (newPage < 1 || newPage > totalPages) return;
setCurrentPage(newPage);
};
Search Index
For faster searches, I experimented with a simple indexing approach:
// In ChatContext.jsx
// When a message is sent, we also update the search index
const sendMessage = async (messageData) => {
// ... existing code to send message ...
// Add to search index
if (messageData.type === 'text') {
try {
const words = messageData.content
.toLowerCase()
.split(/\s+/)
.filter(word => word.length > 3) // Only index meaningful words
.reduce((acc, word) => {
acc[word] = true;
return acc;
}, {});
// Store the search index
const indexRef = ref(db, `searchIndex/${messageData.chatId}/${messageData.id}`);
await set(indexRef, {
words,
senderId: messageData.senderId,
timestamp: messageData.timestamp
});
} catch (err) {
console.error("Error updating search index:", err);
}
}
};
Future Improvements
I have several ideas to enhance the search functionality:
-
Elasticsearch Integration - For larger deployments, integrating with Elasticsearch would provide much better search capabilities.
-
Search Suggestions - Add auto-complete and search suggestions based on message content.
-
Rich Media Search - Implement content-based search for images and files using metadata.
-
Jump To Context - When clicking a search result, show a few messages before and after for better context.
-
Export Search Results - Allow users to export search results for record-keeping.
The message search feature has been very well received by users who need to reference past conversations frequently. It's especially useful in work contexts where important information needs to be retrieved quickly.