Appearance
Chat Message Search — Design
Date: 2026-02-25 Status: Approved
Overview
Add message search to the chat system: search within a single conversation (Telegram-style Ctrl+F) and global search across all user's conversations.
Backend API
Endpoint 1: Search Within Conversation
GET /api/vendor/conversations/{id}/messages/search?q=генератор&limit=50&cursor=...- Auth:
AuthMiddleware(must be conversation participant) - Query: ILIKE
%query%onmessages.text, minimum 2 characters - Order:
m.id DESC(newest first) - Pagination: cursor-based (existing
CursorPaginator)
Response:
json
{
"data": [
{
"id": 542,
"conversation_id": 12,
"sender_id": 5,
"type": "text",
"text": "Генератор в наличии, могу отправить фото",
"status": "read",
"created_at": "2026-02-20T14:30:00+03:00"
}
],
"meta": { "has_more": false, "next_cursor": null }
}Endpoint 2: Global Search
GET /api/vendor/messages/search?q=генератор&limit=20&cursor=...- Auth:
AuthMiddleware - Query: ILIKE on
messages.text, JOINconversationsfiltered bybuyer_id = userId OR seller_id = userId - Excludes conversations soft-deleted by current user (
deleted_by_buyer/deleted_by_seller) - Response includes conversation context (companion, product)
Response:
json
{
"data": [
{
"id": 542,
"conversation_id": 12,
"sender_id": 5,
"type": "text",
"text": "Генератор в наличии, могу отправить фото",
"status": "read",
"created_at": "2026-02-20T14:30:00+03:00",
"conversation": {
"id": 12,
"companion": { "id": 5, "name": "Иван" },
"product": { "id": 88, "title": "Генератор BMW E46" }
}
}
],
"meta": { "has_more": true, "next_cursor": "abc123" }
}Backend Files
| File | Action |
|---|---|
Actions/Vendor/SearchMessagesAction.php | New — search within conversation |
Actions/Vendor/SearchAllMessagesAction.php | New — global search |
Domain/Repository/MessageRepositoryInterface.php | Add searchInConversation(), searchAll() |
Infrastructure/Persistence/DoctrineMessageRepository.php | ILIKE implementation |
config/routes.php | Two new routes |
docs/frontend-api-reference.md | Document endpoints |
Frontend: In-Chat Search
UI
Search icon in conversation header. Click opens search bar:
┌─────────────────────────────────────────────┐
│ ← Иван · Генератор BMW E46 🔍 │ header
├─────────────────────────────────────────────┤
│ 🔍 [генератор____________] 3/7 ▲ ▼ ✕ │ search bar
├─────────────────────────────────────────────┤
│ ...messages with <mark> highlights... │
└─────────────────────────────────────────────┘Behavior
- User types text (min 2 chars, debounce 300ms)
- Request to
GET /conversations/{id}/messages/search?q=...&limit=50 - Scroll to first (newest) match
- Highlight matched text via
<mark>in MessageBubble - Arrows navigate between matches; load older messages if needed
- Esc or X closes search bar
Text Highlighting
MessageBubble receives highlightQuery?: string prop. Wraps matches in <mark class="bg-yellow-200 rounded">.
Scroll to Message
Messages have data-message-id attribute. Navigation uses scrollIntoView({ behavior: 'smooth', block: 'center' }). If message is beyond loaded range — fetch page with cursor, then scroll.
Frontend: Global Search
UI
Search field in conversation list header. Results replace conversation list:
┌──────────────────────────┐
│ 🔍 [генератор________] │ search field
├──────────────────────────┤
│ Иван · Генератор BMW E46│ grouped by conversation
│ │ <mark>Генератор</mark>│
│ │ в наличии... 14:30 │
│ │
│ Петр · Стартер Audi A4 │ another conversation
│ │ У меня <mark>генера- │
│ │ тор</mark> тоже 11:00│
│ │
│ [Загрузить ещё] │
└──────────────────────────┘Behavior
- Input (min 2 chars, debounce 300ms) →
GET /vendor/messages/search?q=...&limit=20 - Results grouped by
conversation_idon client - Each result shows: companion, product, highlighted text, date
- Click →
navigateTo(/cabinet/messages/${conversationId}?highlight=${messageId}) - Conversation page reads
highlightquery param, loads messages around that ID, scrolls and highlights - Clear input → restore normal conversation list
States
| State | Display |
|---|---|
| Empty input | Normal conversation list |
| Loading | Skeleton/spinner |
| Results found | Grouped messages |
| No results | "Ничего не найдено" |
| Error | Toast, keep conversation list |
FSD Structure
app/
├── features/
│ └── chat-search/
│ ├── api/
│ │ └── chat-search.api.ts # searchInConversation(), searchAllMessages()
│ ├── model/
│ │ └── use-chat-search.ts # composable: query, results, navigation, loading
│ └── ui/
│ ├── ChatSearchBar.vue # in-chat search panel (input + ▲▼ + counter)
│ └── GlobalSearchResults.vue # global search results list
├── entities/
│ └── message/
│ └── ui/
│ └── MessageBubble.vue # EXISTING — add highlightQuery prop
├── widgets/
│ ├── chat-window/
│ │ └── ChatWindow.vue # EXISTING — integrate ChatSearchBar
│ └── conversation-list/
│ └── ConversationList.vue # EXISTING — integrate global search fieldComposable
ts
// features/chat-search/model/use-chat-search.ts
export function useChatSearch(conversationId?: Ref<number>) {
const query = ref('')
const results = ref<SearchResult[]>([])
const currentIndex = ref(0)
const isLoading = ref(false)
// debounced watcher on query → API call
// goNext() / goPrev() — navigate between matches
// currentMessageId — computed from results[currentIndex]
// highlightQuery — passed to MessageBubble
// close() — reset state
}Zod Schemas
ts
const SearchMessageSchema = z.object({
id: z.number(),
conversation_id: z.number(),
sender_id: z.number().nullable(),
type: z.enum(['text', 'image', 'system']),
text: z.string().nullable(),
status: z.enum(['sent', 'delivered', 'read']),
created_at: z.string(),
})
const GlobalSearchMessageSchema = SearchMessageSchema.extend({
conversation: z.object({
id: z.number(),
companion: z.object({ id: z.number(), name: z.string() }),
product: z.object({ id: z.number(), title: z.string() }),
}),
})Search Technology
MVP uses ILIKE '%query%' — sufficient for early volumes. Migration path to PostgreSQL FTS (add search_vector TSVECTOR + GIN index on messages) when needed. API contract stays the same; only repository implementation changes.