Developer Docs

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.


Architecture

When a message is decrypted by the crypto engine, the plaintext is cached in two locations:

  1. In-memory โ€” the decryptedMessages Map in store.svelte.ts (instant, always available for the current session)
  2. IndexedDB โ€” the uchat_messages object store in the mallnline_store database (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

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

The search bar is toggled via a ๐Ÿ” button in the message panel header. It includes:

  • Privacy tag โ€” ๐Ÿ”’ Local badge 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