uChat Message Search (E2EE)
Overview
Message search in uChat follows the Signal model โ the server never sees plaintext content, so full-text search happens entirely on the client. The architecture uses a hybrid approach:
| Layer | Scope | Privacy | Technology |
|---|---|---|---|
| Client-side content search | Full-text plaintext matching | โ Zero-knowledge โ queries never leave the browser | IndexedDB + in-memory decryptedMessages Map |
| Server-side metadata search | Sender, date range, message type, attachment status | โ ๏ธ Metadata only โ no content is exposed | PostgreSQL via sqlx::QueryBuilder |
IMPORTANT
Plaintext search terms never leave the device. The searchMessages GraphQL query only filters on non-encrypted metadata fields. Content matching is handled exclusively by the Svelte 5 store in-browser.
Client-Side Content Search
Architecture
When a message is decrypted by the crypto engine, the plaintext is cached in two locations:
- In-memory โ the
decryptedMessagesMap instore.svelte.ts(instant, always available for the current session) - IndexedDB โ the
uchat_messagesobject store in themallnline_storedatabase (persists across page reloads)
The search function (searchMessagesLocal) queries both locations and merges results:
// In-memory search runs first (instant)
for (const msg of messages) {
const plaintext = decryptedMessages.get(msg.id);
if (!plaintext) continue;
if (msg.msgType !== 'TEXT') continue; // Skip voice/file/system
// Case-insensitive substring match
const idx = plaintext.toLowerCase().indexOf(needle);
if (idx !== -1) results.push({ message, matchStart: idx, matchEnd });
}
// IDB search supplements with previous sessions (500ms timeout)
const idbResults = await Promise.race([
searchLocalMessages(query, filters),
new Promise(resolve => setTimeout(() => resolve([]), 500))
]);
Filtering Rules
The search engine applies these filters to prevent metadata leakage:
| Filter | Purpose |
|---|---|
msgType !== 'TEXT' |
Excludes VOICE, FILE, IMAGE, SYSTEM messages |
| JSON blob detection | Skips crypto envelopes ({"type":"voice_message","key":[...]}) |
| Conversation scope | Searches only the active conversation by default |
IndexedDB Schema
The uchat_messages store was added in IDB version 2:
export const IDB_STORES = {
UCHAT_UNREAD: 'uchat_unread',
UCHAT_MESSAGES: 'uchat_messages', // Added for search cache
};
Each cached message record (CachedMessage):
| Field | Type | Description |
|---|---|---|
id |
string (key) |
Message UUID |
conversationId |
string |
Parent conversation |
senderId |
string |
Sender's platform user ID |
plaintext |
string |
Decrypted content |
msgType |
string |
TEXT, FILE, VOICE, IMAGE, SYSTEM |
createdAt |
string |
ISO timestamp |
hasAttachment |
boolean |
Whether fileUrl is present |
Cache Lifecycle
| Event | Action |
|---|---|
| Message decrypted | cacheDecryptedMessage() fires (fire-and-forget) |
| User logs out | clearMessageCache() wipes the IDB store |
| IDB upgrade blocked | In-memory search still works โ IDB is supplementary |
Server-Side Metadata Search
GraphQL API
query SearchMessages($input: MessageSearchInput!, $first: Int, $after: String) {
searchMessages(input: $input, first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
id
conversationId
senderId
encryptedBody
msgType
createdAt
fileUrl
fileName
}
}
}
}
Input Schema
input MessageSearchInput {
conversationId: String # Scope to a specific conversation
senderId: String # Filter by sender
msgType: MessageType # TEXT, IMAGE, FILE, SYSTEM, VOICE
convType: ConversationType # DIRECT, GROUP, SUPPORT, SOCIAL
hasAttachment: Boolean # Filter messages with file attachments
startDate: DateTime # Messages after this timestamp
endDate: DateTime # Messages before this timestamp
}
Security Model
The search_messages resolver enforces participant-based access control via an INNER JOIN:
SELECT m.* FROM uchat_messages m
INNER JOIN uchat_participants p
ON p.conversation_id = m.conversation_id
WHERE p.user_id = $1 -- Caller must be a participant
AND p.left_at IS NULL -- Must not have left the conversation
AND m.deleted_at IS NULL -- Exclude soft-deleted messages
-- ... dynamic filters appended via QueryBuilder
This guarantees users can only ever search metadata for conversations they are actively joined to.
Database Indexes
Migration 016_add_search_indexes.sql adds composite indexes for common filter patterns:
CREATE INDEX IF NOT EXISTS idx_messages_sender
ON uchat_messages (conversation_id, sender_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_messages_type
ON uchat_messages (conversation_id, msg_type, created_at DESC);
Frontend UX
Search Bar
The search bar is toggled via a ๐ button in the message panel header. It includes:
- Privacy tag โ
๐ Localbadge indicating the search runs on-device - Debounced input โ 300ms debounce before executing the search
- Clear button โ resets input and results
Result Navigation
| Control | Behavior |
|---|---|
| Click a result | Scrolls to the message and plays a 2-second highlight animation |
| โฒ / โผ arrows | Cycles through results (wraps around) |
| Collapse | Hides the search bar but keeps the nav bar with "2 of 7" counter |
| โ dismiss | Fully clears results and resets all search state |
Store API
uchatStore.searchMessagesLocal(query, filters?); // Trigger local search
uchatStore.clearSearch(); // Reset results
uchatStore.searchResults; // SearchResult[]
uchatStore.searchLoading; // boolean
File Reference
| File | Purpose |
|---|---|
src/lib/uchat/searchCache.ts |
IndexedDB caching + local search engine |
src/lib/uchat/store.svelte.ts |
In-memory search, state management, IDB integration |
src/lib/uchat/queries.ts |
SEARCH_MESSAGES GraphQL query + MessageSearchInput type |
src/lib/idb.ts |
IndexedDB v2 schema with UCHAT_MESSAGES store |
apps/uchat/src/graphql/query.rs |
search_messages resolver with dynamic QueryBuilder |
apps/uchat/src/models/message.rs |
MessageSearchInput struct |
apps/uchat/migrations/016_add_search_indexes.sql |
Performance indexes for metadata filtering |
Related
- uChat โ E2EE Messenger โ Core uChat subgraph architecture and API reference
- uChat Client SDK & Interface โ Frontend store, crypto engine, and UI components
- IndexedDB Storage Layer โ Shared IDB infrastructure used for search caching
- Crypto โ Encryption Microservice โ AES-256-GCM encryption pipeline for file attachments
- Search Engine Administration โ Platform-wide Meilisearch configuration (separate from E2EE message search)
- User Guide: Searching uChat Messages โ Visitor-facing guide to using message search