Files
Jannis Braun a3a7527c9e chore: add system docs, specs, and misc updates from other sessions
- 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
2026-03-31 03:40:34 +02:00

284 lines
14 KiB
Markdown

# 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 client `search` namespace and `messagesAround` methods
- `packages/web/src/stores/chatStore.ts``loadMessagesAround()` store action
- `packages/web/src/components/chat/MessageList.tsx` — Jump-to-message scroll + highlight logic
- `packages/web/src/components/layout/MainContent.tsx` — Search button + popover wiring
- `packages/web/src/styles/globals.css``.search-highlight` animation
---
## 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](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` + `limit` query params
- **Default page size:** 25
- **Max page size:** 50
- **Total count:** Returned as `totalCount` in 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 `SELECT` from `users` with `inArray(users.id, userIds)`
- **Attachments:** Batch `SELECT` from `attachments` with `inArray(attachments.messageId, messageIds)`
- **Reactions:** `fetchReactionsForMessages(messageIds)` — from `routes/messages.ts`
- **Embeds:** `fetchEmbedsForMessages(messageIds)` — from `utils/embedResolver.ts`
- **Replies:** `fetchReplyToMessages(messageRows)` — from `routes/messages.ts`
- **Assembly:** `buildMessageWithUser()` — from `routes/messages.ts`
### DM Search Hydration
- **Users:** Same batch pattern
- **Attachments:** Batch `SELECT` from `attachments` with `inArray(attachments.dmMessageId, messageIds)`
- **Reactions:** `fetchDmReactionsForMessages(messageIds)` — from `routes/dm.ts`
- **Embeds:** `fetchDmEmbedsForMessages(messageIds)` — from `utils/embedResolver.ts`
- **Replies:** Inline implementation — fetches `dmMessages` by `replyToId`, builds minimal `DmMessageWithUser` (with empty `attachments`, `embeds`, `reactions` arrays)
- **Assembly:** `buildDmMessageWithUser()` — from `routes/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()` with `VIEW_CHANNEL | READ_MESSAGE_HISTORY`
- DM: Checks `isDmMember()`
- Channel: Queries `messages` table, uses `fetchReactionsForMessages`, `fetchEmbedsForMessages`, `fetchReplyToMessages`
- DM: Queries `dm_messages` table, uses `fetchDmReactionsForMessages`, `fetchDmEmbedsForMessages`, inline reply fetch
---
## Client-Side: SearchPopover
`packages/web/src/components/chat/SearchPopover.tsx`
### Component Props
```typescript
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
1. **Reset on open/channel change:** All state resets when `open` or `channelId` changes
2. **Debounced search:** 300ms debounce on any query/filter change, calls `doSearch(0)`
3. **Empty guard:** Search requires at least one of: `query`, `fromFilter`, `hasFilter`, `beforeFilter`, `afterFilter` — otherwise clears results
4. **Pagination:** "Load more" button appends next page. Offset tracked as `searchOffset + data.results.length`
5. **Federation-aware:** Uses `getChannelOrigin(channelId)` and `getApiForOrigin(origin)` to route API calls to the correct instance
6. **Dismiss:** Click-outside (`mousedown` listener) or Escape key closes the popover
7. **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, `glass` material, positioned via `useFloatingPosition` (bottom placement, 8px offset)
- **Search input:** `input-embedded` tier 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) — all `input-search` tier
- **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 with `bg-accent-primary/30` styling
- **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()` or `client.dm.messagesAround()` based on `isDmChannel()`
- Normalizes remote asset URLs for federated channels
- **Replaces** the entire message cache for that channel (not append/prepend)
- Sets `hasMore` to `true` (enables upward scroll loading from the new position)
- Updates `channelAccessTimes`
### search-highlight CSS
Defined in `globals.css`:
```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
1. **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.
2. **from: filter is exact match:** Uses `LIKE` without wildcards on username, but SQLite LIKE is case-insensitive for ASCII by default. Does not search display names.
3. **has:link is content-based:** Searches for `'%http%'` in message content — does not check the `embeds` table. May miss non-HTTP links or match false positives (e.g., a message containing the word "http" in prose).
4. **No cross-channel search:** Each search is scoped to a single channel or DM. There is no global/space-wide search endpoint.
5. **DM reply hydration is minimal:** Reply-to messages in DM search results have empty `attachments`, `embeds`, and `reactions` arrays (unlike channel search which uses `fetchReplyToMessages` with full attachment hydration).
6. **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.