Skip to content

Chat Feature — Frontend Developer Guide

Everything a Nuxt frontend developer needs to build the buyer-seller chat.

Design doc: docs/2026-02-10-chat-design.md (full architecture, SharedWorker diagrams, FSD structure, user flows)


Environment Setup

Add to .env:

bash
# Centrifugo WebSocket — real-time chat events
# Without this variable chat still works via REST API, but no real-time delivery.
# Set only when Centrifugo is deployed and accessible.
NUXT_PUBLIC_CENTRIFUGO_URL=wss://dev.partizap.ru/connection/websocket
EnvironmentValue
Local dev (Centrifugo not deployed)(leave empty)
Local dev (Centrifugo on dev server)wss://dev.partizap.ru/connection/websocket
Productionwss://partizap.ru/connection/websocket

How chat works without Centrifugo:

  • Messages load via REST (GET /vendor/conversations/{id}/messages) on page open
  • Sending works via REST (POST /vendor/conversations/{id}/messages)
  • No real-time: new messages from companion appear only after page refresh
  • No typing indicators, no read receipts, no online status updates
  • Unread badge updates only on page load (GET /vendor/conversations/unread-count)

How chat works with Centrifugo:

  • All of the above, plus real-time delivery of all events via WebSocket
  • New messages appear instantly without refresh
  • Typing indicators, read receipts, online/offline status — all live

Overview

Real-time 1-to-1 messaging between buyer and seller, tied to a product. One conversation per (buyer, product) pair. Real-time delivery via Centrifugo WebSocket server.

Key points:

  • All REST endpoints require authentication (session cookie)
  • WebSocket auth uses the same session cookie (Centrifugo connect proxy validates it)
  • All events arrive on a single personal channel personal:#<userId>
  • No per-conversation channels — frontend routes events by conversation_id field

WebSocket Connection (Centrifugo)

Endpoint

EnvironmentWebSocket URL
Developmentwss://dev.partizap.ru/connection/websocket
Productionwss://partizap.ru/connection/websocket

How it works

  1. Frontend opens WebSocket via centrifuge-js — browser cookies are sent automatically
  2. Centrifugo calls the backend connect proxy (POST /api/centrifugo/connect)
  3. Backend validates PARTIZAP_SESSION cookie, returns { user: "42", channels: ["personal:#42"] }
  4. Centrifugo establishes connection and auto-subscribes to personal:#42
  5. All chat events arrive on this single channel

centrifuge-js setup

bash
npm install centrifuge
typescript
import { Centrifuge } from 'centrifuge'

const centrifuge = new Centrifuge('wss://dev.partizap.ru/connection/websocket', {
  // No token needed — auth via session cookie
})

// Listen for all events on personal channel
centrifuge.on('publication', (ctx) => {
  const event = ctx.data  // { type: string, data: {...} }

  switch (event.type) {
    case 'message.new':
      // Handle new message
      break
    case 'typing':
      // Handle typing indicator
      break
    case 'message.read':
      // Handle read receipt
      break
    case 'unread.update':
      // Handle total unread count change
      break
  }
})

centrifuge.connect()

Use a SharedWorker to maintain one WebSocket across all tabs. See docs/2026-02-10-chat-design.md section "SharedWorker" for the full protocol and fallback strategy.

Reconnection

centrifuge-js handles reconnection automatically with exponential backoff (1s, 2s, 4s, 8s, max 30s). After reconnect, Centrifugo delivers missed events from its internal cache. If the cache expired, call refresh() on conversations list and current chat.


Data Types

Conversation

typescript
interface Conversation {
  id: number
  product: {
    id: number
    title: string
    price: number        // float
    status: string       // "draft" | "pending" | "active" | "sold" | "archived" | "rejected"
  }
  companion: {
    id: number
    display_name: string
    avatar_url: string | null
  }
  last_message: Message | null
  unread_count: number
  created_at: string     // ISO 8601, e.g. "2026-02-11T18:51:59+00:00"
  updated_at: string     // ISO 8601
}

Message

typescript
interface Message {
  id: number
  conversation_id: number
  sender_id: number
  type: 'text' | 'image' | 'system'
  text: string | null
  image_url: string | null          // full-size image (CDN URL)
  image_thumbnail: string | null   // 200×200 max thumbnail (CDN URL, separate file)
  status: 'sent' | 'delivered' | 'read'
  created_at: string               // ISO 8601
}

Zod Schemas

typescript
// entities/message/model/message.schema.ts
import { z } from 'zod'

export const messageTypeSchema = z.enum(['text', 'image', 'system'])
export const messageStatusSchema = z.enum(['sent', 'delivered', 'read'])

export const messageSchema = z.object({
  id: z.number(),
  conversation_id: z.number(),
  sender_id: z.number().nullable(),
  type: messageTypeSchema,
  text: z.string().nullable(),
  image_url: z.string().nullable(),
  image_thumbnail: z.string().nullable(),
  status: messageStatusSchema,
  created_at: z.string(),
})

export type Message = z.infer<typeof messageSchema>

// entities/conversation/model/conversation.schema.ts
export const conversationSchema = z.object({
  id: z.number(),
  product: z.object({
    id: z.number(),
    title: z.string(),
    price: z.number(),
    status: z.string(),
    image_url: z.string().nullable(),
  }),
  companion: z.object({
    id: z.number(),
    display_name: z.string(),
    avatar_url: z.string().nullable(),
  }),
  last_message: messageSchema.nullable(),
  unread_count: z.number(),
  created_at: z.string(),
  updated_at: z.string(),
})

export type Conversation = z.infer<typeof conversationSchema>

REST API Endpoints

All endpoints are under /api/vendor/ and require authentication (session cookie + CSRF token for POST/PUT/DELETE).

Conversations

POST /api/vendor/conversations — Create or find conversation

Start a conversation about a product. Returns existing conversation if one exists.

typescript
// Request
await api.post('/vendor/conversations', { product_id: 123 })
FieldTypeRequiredNotes
product_idnumberyesMust be a valid product. Cannot be your own product.

Response: { data: Conversation } with status 201 (created) or 200 (existing).

If the buyer previously soft-deleted the conversation, it is restored automatically.

Errors:

  • 422product_id missing or messaging yourself
  • 404 — product not found

Frontend flow:

Product page → "Write to seller" button → POST /conversations → redirect to /cabinet/messages/:id

GET /api/vendor/conversations — List conversations

typescript
// Request
await api.get('/vendor/conversations', { limit: 20, cursor: '...', filter: 'unread' })
ParamTypeDefaultNotes
limitnumber20Max 100
cursorstringOpaque, from meta.next_cursor
filterstring"unread" = only with unread messages

Response: { data: Conversation[], meta: { has_more: boolean, next_cursor: string | null } }

Sorted by updated_at DESC (most recently active first). Soft-deleted conversations are excluded.


GET /api/vendor/conversations/{id} — Get conversation

typescript
await api.get(`/vendor/conversations/${id}`)

Response: { data: Conversation }

Errors: 404 (not found), 403 (not a participant)


DELETE /api/vendor/conversations/{id} — Soft-delete conversation

Hides conversation from the current user's list. Messages are preserved. If the companion sends a new message, the conversation reappears.

typescript
await api.delete(`/vendor/conversations/${id}`)

Response: { data: { message: "Conversation deleted" } }


GET /api/vendor/conversations/unread-count — Total unread count

For the badge in the header/sidebar.

typescript
await api.get('/vendor/conversations/unread-count')

Response:

json
{ "data": { "total_unread": 5 } }

Messages

GET /api/vendor/conversations/{id}/messages — List messages

typescript
await api.get(`/vendor/conversations/${id}/messages`, { limit: 50, cursor: '...' })
ParamTypeDefaultNotes
limitnumber50Max 100
cursorstringOpaque, from meta.next_cursor

Response: { data: Message[], meta: { has_more: boolean, next_cursor: string | null } }

Sorted by id DESC (newest first). Frontend should reverse for display (oldest on top).


POST /api/vendor/conversations/{id}/messages — Send text message

typescript
await api.post(`/vendor/conversations/${id}/messages`, { text: 'Hello!' })
FieldTypeRequiredNotes
textstringyes1-2000 chars after trim. Whitespace-only rejected.

Response: { data: Message } with status 201.

Side effects:

  • Companion's unread count incremented
  • Conversation's last_message and updated_at updated
  • If companion soft-deleted the conversation, it is restored
  • Centrifugo message.new event published to both users (see below)

POST /api/vendor/conversations/{id}/messages/image — Send image message

typescript
const formData = new FormData()
formData.append('image', file)
await $fetch(`/api/vendor/conversations/${id}/messages/image`, {
  method: 'POST',
  body: formData,
  credentials: 'include',
  headers: { 'X-CSRF-TOKEN': csrfToken },
})

Request: multipart/form-data with image field.

Validation: Max 10 MB, JPEG/PNG/WebP only (magic byte verification). Image is re-encoded to strip EXIF metadata.

Response: { data: Message } with status 201. The message has type: "image", image_url (full-size, EXIF-stripped, CDN URL), and image_thumbnail (200×200 max, aspect-ratio preserved, separate CDN URL).

S3 paths: chat/{conversationId}/{hex}.{ext} (full) and chat/{conversationId}/{hex}_thumb.{ext} (thumbnail).

Same side effects as text message.


Actions

POST /api/vendor/conversations/{id}/read — Mark as read

Call when user opens a conversation or scrolls to unread messages.

typescript
await api.post(`/vendor/conversations/${id}/read`)

Response: { data: { message: "Conversation marked as read" } }

Side effects:

  • All companion's messages in this conversation set to status "read" (including any with "delivered" status)
  • Current user's unread count for this conversation reset to 0
  • Centrifugo message.read event published to companion (with last_read_message_id)
  • Centrifugo unread.update event published to current user

POST /api/vendor/conversations/{id}/delivered — Mark messages as delivered

Call when the client receives new messages (e.g. upon message.new event). Confirms receipt up to a given message ID.

typescript
await api.post(`/vendor/conversations/${id}/delivered`, {
  last_message_id: 456
})
FieldTypeRequiredNotes
last_message_idnumberyesAll sent messages with id <= last_message_id from companion are set to delivered

Response: { data: { message: "Messages marked as delivered" } }

Side effects:

  • Companion's sent messages updated to "delivered"
  • Centrifugo message.delivered event published to companion (with last_message_id)

POST /api/vendor/conversations/{id}/typing — Send typing indicator

Call while user is typing. Frontend must debounce (recommended: send at most every 3 seconds, auto-clear after 4 seconds of inactivity).

typescript
await api.post(`/vendor/conversations/${id}/typing`)

Response: { data: { message: "OK" } }

Nothing is stored in the database. A Centrifugo typing event is published to the companion.


GET /api/vendor/users/{id}/online — Check online status

typescript
await api.get(`/vendor/users/${userId}/online`)

Response:

json
// Online
{ "data": { "online": true, "last_seen_at": null } }

// Offline
{ "data": { "online": false, "last_seen_at": "2026-02-11T18:30:00+00:00" } }

User is considered online if last activity was < 5 minutes ago. The backend's OnlinePresenceMiddleware automatically updates user:online:{id} in Redis (300s TTL) on every authenticated /vendor/* request — no frontend heartbeat needed.


Centrifugo Events

All events arrive on the personal channel personal:#<userId>. Each event has { type, data } structure.

message.new

Fired when a message is sent. Published to both sender and companion.

json
{
  "type": "message.new",
  "data": {
    "conversation_id": 123,
    "message": {
      "id": 456,
      "conversation_id": 123,
      "sender_id": 7,
      "type": "text",
      "text": "Hello!",
      "image_url": null,
      "image_thumbnail": null,
      "status": "sent",
      "created_at": "2026-02-11T18:51:59+00:00"
    }
  }
}

Frontend handling:

  • If conversation is open: append message, scroll down
  • If conversation is in list: update last_message, move to top, increment unread_count
  • If not on chat page: increment header badge, play notification sound
  • Sender receives this too (for tab sync via SharedWorker)

typing

Fired when companion is typing. Published to companion only.

json
{
  "type": "typing",
  "data": {
    "conversation_id": 123,
    "user_id": 7
  }
}

Frontend handling:

  • Show "typing..." indicator for 4 seconds
  • Reset timer on each new typing event
  • Clear indicator when message.new arrives for the same conversation

message.delivered

Fired when companion's device confirms receipt. Published to companion only (message sender).

json
{
  "type": "message.delivered",
  "data": {
    "conversation_id": 123,
    "last_message_id": 456
  }
}

Frontend handling:

  • Update all own messages with id <= last_message_id from status: "sent" to status: "delivered" (show double grey check marks)

message.read

Fired when companion reads the conversation. Published to companion only (the one whose messages were read).

json
{
  "type": "message.read",
  "data": {
    "conversation_id": 123,
    "reader_id": 7,
    "last_read_message_id": 456
  }
}

Frontend handling:

  • Update all own messages with id <= last_read_message_id to status: "read" (show double blue check marks)
  • Update conversation in list: set unread_count: 0 if viewer is the reader

unread.update

Fired when the current user's total unread count changes (after marking as read). Published to current user only.

json
{
  "type": "unread.update",
  "data": {
    "total_unread": 3
  }
}

Frontend handling:

  • Update header badge with new count
  • If 0, hide badge

presence

Fired when a chat companion connects or disconnects from Centrifugo. Published to all companions (users with active conversations).

json
{
  "type": "presence",
  "data": {
    "user_id": 7,
    "online": false,
    "last_seen_at": "2026-02-11T20:15:00+00:00"
  }
}

Frontend handling:

  • Update online indicator for the companion in conversation list and open chat
  • When online: false, show last_seen_at as "was online at..."
  • Note: online: true events may come from regular API activity (OnlinePresenceMiddleware), while online: false is broadcast on Centrifugo disconnect

User Flows

Flow 1: Buyer writes to seller

Product page → click "Write to seller"
  → POST /api/vendor/conversations { product_id }
  → 201 Created (new) or 200 OK (existing)
  → navigateTo(`/cabinet/messages/${conversation.id}`)
  → GET .../messages (load history)
  → User types → POST .../messages { text }
  → Message appears instantly (optimistic update)
  → Centrifugo delivers to seller

Flow 2: Receiving a message

Any page → SharedWorker receives message.new event
  → ChatUnreadBadge: increment counter
  → Play notification sound (if document.hidden)
  → /cabinet/messages page: move conversation to top, update last_message
  → /cabinet/messages/:id page: append message, scroll down

Flow 3: Opening a conversation

/cabinet/messages → click conversation
  → navigateTo(`/cabinet/messages/${id}`)
  → GET /api/vendor/conversations/${id} (conversation details)
  → GET /api/vendor/conversations/${id}/messages (load messages)
  → POST /api/vendor/conversations/${id}/read (mark as read)
  → Centrifugo: message.read → companion, unread.update → self

Flow 4: Sending an image

Chat input → click attachment icon → file picker
  → Show local preview (URL.createObjectURL)
  → POST .../messages/image (multipart FormData)
  → Backend: validates image, strips EXIF, uploads full + 200×200 thumbnail to S3
  → Response: replace blob URL with image_url, use image_thumbnail for list preview
  → Centrifugo: message.new → companion

Flow 5: Initial load (authenticated user)

App mount → SharedWorker.connect() → Centrifugo WebSocket
  → Auto-subscribe to personal:#userId
  → GET /api/vendor/conversations/unread-count → set header badge

Optimistic Updates

For POST .../messages:

  1. Immediately insert message into UI with a temporary negative id and status: "sent"
  2. Send POST request
  3. On success: replace temporary id with real id from response
  4. On error: show error icon on the message with a "retry" button
typescript
// Example
const tempId = -(Date.now())
messages.value.push({
  id: tempId,
  conversation_id: conversationId,
  sender_id: currentUserId,
  type: 'text',
  text: inputText,
  image_url: null,
  image_thumbnail: null,
  status: 'sent',
  created_at: new Date().toISOString(),
})

try {
  const { data } = await api.post(`/vendor/conversations/${conversationId}/messages`, { text: inputText })
  const idx = messages.value.findIndex(m => m.id === tempId)
  if (idx !== -1) messages.value[idx] = data
} catch (e) {
  const idx = messages.value.findIndex(m => m.id === tempId)
  if (idx !== -1) messages.value[idx].status = 'error'  // show retry button
}

FSD Structure

entities/
├── conversation/
│   ├── model/conversation.schema.ts    # Zod schema + types
│   └── ui/
│       └── ConversationCard.vue        # Card in conversation list
└── message/
    ├── model/message.schema.ts         # Zod schema + types
    └── ui/
        ├── MessageBubble.vue           # Bubble (text/image/system)
        └── MessageStatus.vue           # Check marks (sent/delivered/read)

features/
├── chat-connection/
│   ├── composables/useCentrifugo.ts    # SharedWorker wrapper
│   └── worker/chat.shared-worker.ts    # SharedWorker with centrifuge-js
├── chat-messaging/
│   ├── composables/useConversations.ts # List, filters, reactive updates
│   ├── composables/useMessages.ts      # History, send, optimistic updates
│   ├── composables/useTyping.ts        # Typing indicator (debounce 3s, clear 4s)
│   └── composables/useChatNotifications.ts  # Sound + badge
├── chat-image/
│   └── composables/useChatImageUpload.ts
└── contact-seller/
    └── ui/ContactSellerButton.vue      # "Write to seller" on product page

widgets/
└── chat-unread-badge/
    └── ui/ChatUnreadBadge.vue          # Badge in header

pages/
└── cabinet/
    └── messages/
        ├── index.vue                   # Conversation list (full screen)
        └── [id].vue                    # Chat view (full screen, back button)

i18n Keys (ru.json)

json
{
  "chat": {
    "title": "Сообщения",
    "writeToSeller": "Написать продавцу",
    "placeholder": "Введите сообщение...",
    "send": "Отправить",
    "typing": "печатает...",
    "noConversations": "Нет сообщений",
    "noMessages": "Начните диалог",
    "filterAll": "Все",
    "filterUnread": "Непрочитанные",
    "deleteConversation": "Удалить диалог",
    "deleteConfirm": "Диалог будет скрыт. Если собеседник напишет снова, диалог восстановится.",
    "online": "в сети",
    "lastSeen": "был(а) {time}",
    "today": "сегодня",
    "yesterday": "вчера",
    "sendError": "Не удалось отправить",
    "retry": "Повторить",
    "imageUploadError": "Не удалось загрузить изображение",
    "attachImage": "Прикрепить фото",
    "messageTooLong": "Сообщение слишком длинное (макс. 2000 символов)"
  }
}

Error Handling

HTTPCodeWhenFrontend Action
401authentication_errorSession expiredRedirect to /auth
403authorization_errorNot a participantShow "Access denied", navigate back
404not_found_errorConversation/product deletedShow "Not found", navigate to list
422validation_errorBad inputShow field-level errors from details
429rate_limit_errorToo many requestsShow "Try again later"

Cabinet Layout Integration

Add "Messages" link with unread badge to the cabinet sidebar:

html
<!-- layouts/cabinet.vue -->
<UButton
  label="Сообщения"
  to="/cabinet/messages"
  variant="ghost"
  block
>
  <template #trailing>
    <ChatUnreadBadge />
  </template>
</UButton>

The ChatUnreadBadge widget should:

  • Fetch initial count via GET /api/vendor/conversations/unread-count on mount
  • Listen for unread.update events via Centrifugo to stay reactive
  • Listen for message.new events to increment count when not in the active conversation

Infrastructure Notes

  • Centrifugo version: 5.4.9 (Docker)
  • Dev container: partizap-centrifugo-dev, ports 8000 (WS) / 8001 (API)
  • Nginx proxies wss://dev.partizap.ru/connection/websocket to Centrifugo port 8000
  • No tokens needed — auth is cookie-based via connect proxy
  • centrifuge-js: Use centrifuge npm package (official client)