Skip to content

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% on messages.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 }
}
GET /api/vendor/messages/search?q=генератор&limit=20&cursor=...
  • Auth: AuthMiddleware
  • Query: ILIKE on messages.text, JOIN conversations filtered by buyer_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

FileAction
Actions/Vendor/SearchMessagesAction.phpNew — search within conversation
Actions/Vendor/SearchAllMessagesAction.phpNew — global search
Domain/Repository/MessageRepositoryInterface.phpAdd searchInConversation(), searchAll()
Infrastructure/Persistence/DoctrineMessageRepository.phpILIKE implementation
config/routes.phpTwo new routes
docs/frontend-api-reference.mdDocument endpoints

UI

Search icon in conversation header. Click opens search bar:

┌─────────────────────────────────────────────┐
│  ← Иван · Генератор BMW E46          🔍    │  header
├─────────────────────────────────────────────┤
│  🔍 [генератор____________]  3/7  ▲  ▼  ✕  │  search bar
├─────────────────────────────────────────────┤
│  ...messages with <mark> highlights...      │
└─────────────────────────────────────────────┘

Behavior

  1. User types text (min 2 chars, debounce 300ms)
  2. Request to GET /conversations/{id}/messages/search?q=...&limit=50
  3. Scroll to first (newest) match
  4. Highlight matched text via <mark> in MessageBubble
  5. Arrows navigate between matches; load older messages if needed
  6. 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.

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

  1. Input (min 2 chars, debounce 300ms) → GET /vendor/messages/search?q=...&limit=20
  2. Results grouped by conversation_id on client
  3. Each result shows: companion, product, highlighted text, date
  4. Click → navigateTo(/cabinet/messages/${conversationId}?highlight=${messageId})
  5. Conversation page reads highlight query param, loads messages around that ID, scrolls and highlights
  6. Clear input → restore normal conversation list

States

StateDisplay
Empty inputNormal conversation list
LoadingSkeleton/spinner
Results foundGrouped messages
No results"Ничего не найдено"
ErrorToast, 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 field

Composable

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.