- Add complete docs/systems/ reference (18 system docs) - Add federation relay status doc and prior spec/plan docs - Remove superseded docs/federation-dm-s2s.md (replaced by docs/systems/federation.md) - CLAUDE.md updates - Minor fixes in social.ts, types.ts, AddDmMemberModal, NewDmModal, UserSettings
14 KiB
Search System
Source files:
packages/server/src/routes/search.ts— Server-side search endpoints (channel search, DM search, messages-around)packages/web/src/components/chat/SearchPopover.tsx— Client-side search UI (filter bar, result rendering, jump-to-message)packages/web/src/api/client.ts— API clientsearchnamespace andmessagesAroundmethodspackages/web/src/stores/chatStore.ts—loadMessagesAround()store actionpackages/web/src/components/chat/MessageList.tsx— Jump-to-message scroll + highlight logicpackages/web/src/components/layout/MainContent.tsx— Search button + popover wiringpackages/web/src/styles/globals.css—.search-highlightanimation
Endpoints
Four endpoints, all requiring JWT authentication (preHandler: authenticate).
| Endpoint | Auth Check | Response Shape |
|---|---|---|
GET /api/channels/:id/search |
VIEW_CHANNEL + READ_MESSAGE_HISTORY via hasPermission() |
{ results: MessageWithUser[], totalCount: number } |
GET /api/dm/:id/search |
isDmMember() |
{ results: DmMessageWithUser[], totalCount: number } |
GET /api/channels/:id/messages/around |
VIEW_CHANNEL + READ_MESSAGE_HISTORY via hasPermission() |
MessageWithUser[] (flat array) |
GET /api/dm/:id/messages/around |
isDmMember() |
DmMessageWithUser[] (flat array) |
For full endpoint signatures, see api.md under "Search".
Filter Syntax
All filters are query string parameters on the search endpoints.
| Parameter | Type | Description | SQL Behavior |
|---|---|---|---|
q |
string | Text query (trimmed) | LIKE '%{q}%' on content column — case-insensitive in SQLite by default for ASCII |
from |
string | Username filter | Exact LIKE match on users.username (not partial — no wildcards added). If no user found, returns empty results immediately (not 404). |
has |
file | image | link |
Attachment/content filter | See "has: filter" section below |
before |
string | ISO 8601 date | createdAt < new Date(before).getTime() — parsed via new Date(), invalid dates silently ignored |
after |
string | ISO 8601 date | createdAt > new Date(after).getTime() — parsed via new Date(), invalid dates silently ignored |
offset |
number | Pagination offset | Math.max(Number(offset) || 0, 0) — floored to 0 |
limit |
number | Page size | Clamped: Math.min(Math.max(Number(limit) || 25, 1), 50) — default 25, max 50 |
has: Filter Implementation
The has filter uses two different mechanisms depending on the value:
| Value | Mechanism | SQL |
|---|---|---|
file |
EXISTS subquery on attachments table |
EXISTS (SELECT 1 FROM attachments WHERE attachments.message_id = messages.id) |
image |
EXISTS subquery with mimetype filter |
EXISTS (SELECT 1 FROM attachments WHERE attachments.message_id = messages.id AND attachments.mimetype LIKE 'image/%') |
link |
LIKE on content column | content LIKE '%http%' — appended to the WHERE conditions (not a subquery) |
For DM search, the subquery joins on attachments.dm_message_id = dm_messages.id instead.
Important: The has: file/has: image filter uses a raw SQL EXISTS subquery (hasFilter) that is combined with the main whereClause using and(). This filter is applied separately from the main conditions array because Drizzle ORM conditions and raw SQL fragments are combined at query time.
Pagination
- Offset-based: Uses
offset+limitquery params - Default page size: 25
- Max page size: 50
- Total count: Returned as
totalCountin every search response (separate COUNT query) - Sort order: Results ordered by
createdAt DESC(newest first)
Result Hydration
Both search endpoints follow the same hydration pipeline after fetching raw message rows:
1. Fetch message rows (filtered, paginated)
2. Batch-fetch users → userMap (userId → user)
3. Batch-fetch attachments → attachmentMap (messageId → attachments[])
4. Batch-fetch reactions → fetchReactionsForMessages() / fetchDmReactionsForMessages()
5. Batch-fetch embeds → fetchEmbedsForMessages() / fetchDmEmbedsForMessages()
6. Batch-fetch reply parents → fetchReplyToMessages() / inline DM reply fetch
7. Assemble via buildMessageWithUser() / buildDmMessageWithUser()
8. Filter out messages with missing users (null check)
Channel Search Hydration
- Users: Batch
SELECTfromuserswithinArray(users.id, userIds) - Attachments: Batch
SELECTfromattachmentswithinArray(attachments.messageId, messageIds) - Reactions:
fetchReactionsForMessages(messageIds)— fromroutes/messages.ts - Embeds:
fetchEmbedsForMessages(messageIds)— fromutils/embedResolver.ts - Replies:
fetchReplyToMessages(messageRows)— fromroutes/messages.ts - Assembly:
buildMessageWithUser()— fromroutes/messages.ts
DM Search Hydration
- Users: Same batch pattern
- Attachments: Batch
SELECTfromattachmentswithinArray(attachments.dmMessageId, messageIds) - Reactions:
fetchDmReactionsForMessages(messageIds)— fromroutes/dm.ts - Embeds:
fetchDmEmbedsForMessages(messageIds)— fromutils/embedResolver.ts - Replies: Inline implementation — fetches
dmMessagesbyreplyToId, builds minimalDmMessageWithUser(with emptyattachments,embeds,reactionsarrays) - Assembly:
buildDmMessageWithUser()— fromroutes/dm.ts
Response Types
Both types are defined in packages/shared/src/types.ts. See database.md for underlying table schemas.
MessageWithUser extends Message with: user: User, attachments: Attachment[], embeds: Embed[], reactions: Reaction[], replyTo?: MessageWithUser | null
DmMessageWithUser extends DmMessage with: user: User, attachments: Attachment[], embeds: Embed[], reactions: Reaction[], replyTo?: DmMessageWithUser | null
Messages-Around Endpoint
Used for jump-to-message navigation (from search results and deep links). Loads a window of messages centered on a target message.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
messageId |
string | Yes | Target message ID (returns 400 if missing) |
limit |
number | No | Window size, default 50, max 100, clamped to [1, 100] |
Algorithm
1. Validate target message exists in the channel (404 if not found)
2. half = floor(limit / 2)
3. Fetch "before" rows: messages with id <= messageId, ordered DESC, limit half+1 (includes target)
4. Fetch "after" rows: messages with id > messageId, ordered ASC, limit half
5. Reverse beforeRows to chronological order
6. Concatenate: [...beforeRows, ...afterRows]
7. Deduplicate by id (target may appear in both sets)
8. Hydrate with same pipeline as search results
9. Return flat MessageWithUser[] / DmMessageWithUser[] array
Note: The "before" query uses id <= messageId (not timestamp-based), and the "after" query uses id > messageId. This means the pivot is on Snowflake ID ordering, not createdAt. The ORDER BY clause still uses createdAt, which works because Snowflake IDs are monotonically increasing and correlate with creation time.
Channel vs DM Differences
- Channel: Checks
getChannelSpaceId()+hasPermission()withVIEW_CHANNEL | READ_MESSAGE_HISTORY - DM: Checks
isDmMember() - Channel: Queries
messagestable, usesfetchReactionsForMessages,fetchEmbedsForMessages,fetchReplyToMessages - DM: Queries
dm_messagestable, usesfetchDmReactionsForMessages,fetchDmEmbedsForMessages, inline reply fetch
Client-Side: SearchPopover
packages/web/src/components/chat/SearchPopover.tsx
Component Props
interface SearchPopoverProps {
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLElement | null>;
channelId: string;
isDm: boolean;
onJumpToMessage: (messageId: string) => void;
}
State
| State Variable | Type | Purpose |
|---|---|---|
query |
string | Text search input |
fromFilter |
string | Username filter input |
hasFilter |
string | '' | 'file' | 'image' | 'link' — select dropdown |
beforeFilter |
string | Date string from <input type="date"> |
afterFilter |
string | Date string from <input type="date"> |
showFilters |
boolean | Filter panel visibility toggle |
results |
AnyMessage[] |
Accumulated search results (MessageWithUser | DmMessageWithUser) |
totalCount |
number | Total matching results (from server) |
isSearching |
boolean | Loading state |
offset |
number | Current pagination offset |
Behavior
- Reset on open/channel change: All state resets when
openorchannelIdchanges - Debounced search: 300ms debounce on any query/filter change, calls
doSearch(0) - Empty guard: Search requires at least one of:
query,fromFilter,hasFilter,beforeFilter,afterFilter— otherwise clears results - Pagination: "Load more" button appends next page. Offset tracked as
searchOffset + data.results.length - Federation-aware: Uses
getChannelOrigin(channelId)andgetApiForOrigin(origin)to route API calls to the correct instance - Dismiss: Click-outside (
mousedownlistener) or Escape key closes the popover - Auto-focus: Input focused 50ms after popover opens
API Call Flow
SearchPopover.doSearch(offset)
→ getChannelOrigin(channelId) // resolve federation origin
→ getApiForOrigin(origin) // get API client for that origin
→ isDm ? client.search.dm(channelId, params)
: client.search.channel(channelId, params)
→ params: { q, from, has, before, after, offset, limit: 25 }
The client always sends limit: 25 (hardcoded in SearchPopover).
UI Layout
- Container: 420px wide, max 500px tall,
glassmaterial, positioned viauseFloatingPosition(bottom placement, 8px offset) - Search input:
input-embeddedtier with search icon and clear button - Filter toggle: Collapsed by default, shows active-filter indicator dot when any filter is set
- Filter panel: 2-column grid —
From(text),Has(select),Before(date),After(date) — allinput-searchtier - Results list: Scrollable area with result count header
- Result items: Avatar + display name + timestamp + content snippet (2-line clamp) + attachment count
- Query highlighting:
highlightMatch()wraps matches in<mark>tags withbg-accent-primary/30styling - Load more: Shows remaining count, disabled while loading
Result Rendering
Each result shows:
- User avatar (via
<Avatar>component) - Display name (falls back to username, then "Unknown")
- Timestamp via
formatTime(): "Today at HH:MM", "Yesterday at HH:MM", or "MM/DD/YYYY HH:MM" - Content with query term highlighting (case-insensitive regex split)
- Attachment count badge (paperclip icon) if
msg.attachments.length > 0
Client-Side: Jump-to-Message
The jump-to-message flow spans three components.
Flow
1. User clicks search result in SearchPopover
→ onJumpToMessage(messageId) callback fires
→ MainContent: setJumpToMessageId(id), setSearchOpen(false)
2. MessageList receives jumpToMessageId prop
→ Check if message element exists in DOM: document.getElementById(`msg-${jumpToMessageId}`)
→ If found: scroll + highlight immediately
→ If not found: call loadMessagesAround(channelId, messageId)
→ chatStore.loadMessagesAround() replaces the channel's message cache entirely
→ After React render (double requestAnimationFrame), scroll + highlight
3. Scroll + Highlight:
→ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
→ el.classList.add('search-highlight')
→ setTimeout 2000ms → el.classList.remove('search-highlight')
→ onJumpComplete() → resets jumpToMessageId to null
loadMessagesAround (chatStore)
search.ts:chatStore.loadMessagesAround(channelId, messageId):
- Routes to
client.channels.messagesAround()orclient.dm.messagesAround()based onisDmChannel() - Normalizes remote asset URLs for federated channels
- Replaces the entire message cache for that channel (not append/prepend)
- Sets
hasMoretotrue(enables upward scroll loading from the new position) - Updates
channelAccessTimes
search-highlight CSS
Defined in globals.css:
@keyframes search-flash {
0% { background-color: rgba(124, 108, 246, 0.2); }
100% { background-color: transparent; }
}
.search-highlight { animation: search-flash 2s ease-out; }
Purple flash (accent color at 20% opacity) that fades to transparent over 2 seconds.
Known Limitations
- SQL LIKE for text search: Uses
LIKE '%query%'— no full-text indexing (FTS5), no relevance ranking, no word boundary matching. Performance degrades linearly with message count. - from: filter is exact match: Uses
LIKEwithout wildcards on username, but SQLite LIKE is case-insensitive for ASCII by default. Does not search display names. - has:link is content-based: Searches for
'%http%'in message content — does not check theembedstable. May miss non-HTTP links or match false positives (e.g., a message containing the word "http" in prose). - No cross-channel search: Each search is scoped to a single channel or DM. There is no global/space-wide search endpoint.
- DM reply hydration is minimal: Reply-to messages in DM search results have empty
attachments,embeds, andreactionsarrays (unlike channel search which usesfetchReplyToMessageswith full attachment hydration). - Offset pagination: Uses offset/limit (not cursor-based). Large offsets may have performance implications on big result sets since SQLite must scan and skip rows.