Skip to content

Chat Improvements Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement 7 chat UX improvements: image lightbox, upload loader, security hardening, profile navigation, message avatars, infinite scroll, and input limits with multi-photo support.

Architecture: All changes are frontend-only. No backend modifications needed. We modify existing chat components (MessageBubble.vue, [id].vue, useMessages.ts, useChatImageUpload.ts) and add one new component (ChatImageLightbox.vue). All work stays within existing FSD boundaries.

Tech Stack: Vue 3.5, Nuxt UI v3 (UModal, UAvatar, UButton, UIcon), VueUse (useIntersectionObserver, useSwipe), Zod, i18n.


Task 1: Security Audit of Chat Input (#23)

Why first: Establishes safe rendering patterns before we add new UI features.

Files:

  • Audit: app/entities/message/ui/MessageBubble.vue
  • Audit: app/pages/cabinet/messages/[id].vue
  • Audit: app/entities/conversation/ui/ConversationCard.vue
  • Modify: app/features/chat-messaging/composables/useMessages.ts

Step 1: Audit MessageBubble.vue for XSS vectors

Check that all user content uses {{ }} interpolation (safe) and NOT v-html (unsafe).

Current code at MessageBubble.vue:20: {{ message.text }} — SAFE (Vue auto-escapes). Current code at MessageBubble.vue:35: <img :src="message.image_url"> — Check that image_url comes from backend (it does, S3 URLs). No v-html found — SAFE.

Step 2: Audit [id].vue for XSS vectors

{{ conversation.companion.display_name }} — SAFE. {{ conversation.product.title }} — SAFE. No v-html found — SAFE.

Step 3: Add input sanitization in useMessages.ts

Add trim + length validation before sending. Currently send() trusts caller to trim:

typescript
// In useMessages.ts, inside send() function, add at the top:
async function send(text: string) {
  const trimmed = text.trim()
  if (!trimmed || trimmed.length > 2000) return

  const tempId = tempIdCounter--
  const optimistic: Message = {
    id: tempId,
    conversation_id: conversationId.value,
    sender_id: authStore.user!.id,
    type: 'text',
    text: trimmed,  // use trimmed
    // ...rest unchanged
  }
  // ...rest unchanged
}

Step 4: Run lint and typecheck

bash
cd /Users/mac/Работа/partizap/partizap-frontend && npm run lint && npm run typecheck

Step 5: Commit

bash
git add app/features/chat-messaging/composables/useMessages.ts
git commit -m "fix(chat): add input sanitization — trim + length check before send"

Task 2: Input Limits — Text Counter & File Validation (#28)

Files:

  • Modify: app/pages/cabinet/messages/[id].vue
  • Modify: app/features/chat-image/composables/useChatImageUpload.ts
  • Modify: i18n/locales/ru.json

Step 1: Add i18n keys

In i18n/locales/ru.json, inside the "chat" block, add:

json
"charCount": "{count} / {max}",
"maxFilesError": "Максимум {max} фото за раз",
"fileTooLargeError": "Файл слишком большой (макс. {max} МБ)",
"invalidFileType": "Допустимые форматы: JPEG, PNG, WebP"

Step 2: Add character counter and maxlength to [id].vue

Add a computed for character count display and a constant for max length:

typescript
const MAX_MESSAGE_LENGTH = 2000
const CHAR_WARN_THRESHOLD = 1800

const charCount = computed(() => inputText.value.length)
const showCharCount = computed(() => charCount.value > CHAR_WARN_THRESHOLD)
const isOverLimit = computed(() => charCount.value > MAX_MESSAGE_LENGTH)
const canSend = computed(() => {
  const trimmed = inputText.value.trim()
  return trimmed.length > 0 && trimmed.length <= MAX_MESSAGE_LENGTH
})

Update the template <UTextarea> to add :maxlength="MAX_MESSAGE_LENGTH", and below it add:

html
<div v-if="showCharCount" class="mt-1 text-xs" :class="isOverLimit ? 'text-red-500' : 'text-gray-400'">
  &#123;&#123; t('chat.charCount', { count: charCount, max: MAX_MESSAGE_LENGTH }) }}
</div>

Update send button: :disabled="!canSend".

Step 3: Update file input for multiple files + validation

In [id].vue, change handleFileSelect to handle multiple files with validation:

typescript
const MAX_FILES = 5
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']

function handleFileSelect(event: Event) {
  const input = event.target as HTMLInputElement
  const files = Array.from(input.files ?? [])
  input.value = '' // reset to allow re-selecting same files

  if (files.length > MAX_FILES) {
    useToast().add({ title: t('chat.maxFilesError', { max: MAX_FILES }), color: 'error' })
    return
  }

  const validFiles: File[] = []
  for (const file of files) {
    if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
      useToast().add({ title: t('chat.invalidFileType'), color: 'error' })
      return
    }
    if (file.size > MAX_FILE_SIZE) {
      useToast().add({ title: t('chat.fileTooLargeError', { max: 10 }), color: 'error' })
      return
    }
    validFiles.push(file)
  }

  for (const file of validFiles) {
    sendImage(file)
  }
  scrollToBottom()
}

Update file input in template: add multiple attribute.

html
<input type="file" accept="image/jpeg,image/png,image/webp" multiple class="hidden" @change="handleFileSelect" />

Step 4: Run lint and typecheck

bash
cd /Users/mac/Работа/partizap/partizap-frontend && npm run lint:fix && npm run typecheck

Step 5: Commit

bash
git add app/pages/cabinet/messages/[id].vue app/features/chat-image/composables/useChatImageUpload.ts i18n/locales/ru.json
git commit -m "feat(chat): add text character counter and multi-file validation with limits"

Task 3: Image Upload Loader with Ghost Messages (#22)

Files:

  • Modify: app/features/chat-messaging/composables/useMessages.ts
  • Modify: app/entities/message/ui/MessageBubble.vue
  • Modify: app/pages/cabinet/messages/[id].vue

Step 1: Add ghost message support to useMessages.ts

Extend sendImage to create optimistic (ghost) messages:

typescript
async function sendImage(file: File) {
  const tempId = tempIdCounter--
  const previewUrl = URL.createObjectURL(file)

  const optimistic: Message & { _uploading?: boolean; _previewUrl?: string; _error?: boolean; _file?: File } = {
    id: tempId,
    conversation_id: conversationId.value,
    sender_id: authStore.user!.id,
    type: 'image',
    text: null,
    image_url: previewUrl,
    image_thumbnail: previewUrl,
    status: 'sent',
    created_at: new Date().toISOString(),
    _uploading: true,
    _previewUrl: previewUrl,
    _file: file,
  }
  messages.value.push(optimistic)

  try {
    const formData = new FormData()
    formData.append('image', file)
    const res = await api.upload<ApiItemResponse<Message>>(
      `/vendor/conversations/${conversationId.value}/messages/image`,
      formData,
    )
    const idx = messages.value.findIndex(m => m.id === tempId)
    if (idx !== -1) messages.value[idx] = res.data
    URL.revokeObjectURL(previewUrl)
  }
  catch {
    const idx = messages.value.findIndex(m => m.id === tempId)
    if (idx !== -1) {
      const ghost = messages.value[idx] as Message & { _uploading?: boolean; _error?: boolean; _file?: File }
      ghost._uploading = false
      ghost._error = true
    }
  }
}

Add a retry function:

typescript
async function retryImage(tempId: number) {
  const idx = messages.value.findIndex(m => m.id === tempId)
  if (idx === -1) return
  const ghost = messages.value[idx] as Message & { _uploading?: boolean; _error?: boolean; _file?: File; _previewUrl?: string }
  if (!ghost._file) return

  ghost._uploading = true
  ghost._error = false

  try {
    const formData = new FormData()
    formData.append('image', ghost._file)
    const res = await api.upload<ApiItemResponse<Message>>(
      `/vendor/conversations/${conversationId.value}/messages/image`,
      formData,
    )
    messages.value[idx] = res.data
    if (ghost._previewUrl) URL.revokeObjectURL(ghost._previewUrl)
  }
  catch {
    ghost._uploading = false
    ghost._error = true
  }
}

Export retryImage from useMessages return value.

Step 2: Update MessageBubble.vue to show upload state

Add new props and ghost message rendering:

html
<script setup lang="ts">
import type { Message } from '~/entities/message/model/message.schema'

type GhostMessage = Message & { _uploading?: boolean; _error?: boolean; _previewUrl?: string }

const props = defineProps<{ message: GhostMessage; isMine: boolean }>()
const emit = defineEmits<{
  'image-click': [messageId: number]
  'retry-upload': [tempId: number]
}>()

const isUploading = computed(() => props.message._uploading === true)
const hasError = computed(() => props.message._error === true)
// ...existing timeLabel, isSystem computed...
</script>

In the template, for image messages, wrap with upload overlay:

html
<div v-if="message.type === 'image' && message.image_url" class="relative">
  <img
    :src="message.image_thumbnail || message.image_url"
    class="max-h-64 max-w-full cursor-pointer rounded-lg object-contain"
    @click="!isUploading && !hasError && emit('image-click', message.id)"
  />
  <!-- Upload spinner overlay -->
  <div
    v-if="isUploading"
    class="absolute inset-0 flex items-center justify-center rounded-lg bg-black/40"
  >
    <UIcon name="i-lucide-loader-2" class="h-8 w-8 animate-spin text-white" />
  </div>
  <!-- Error overlay -->
  <div
    v-if="hasError"
    class="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-lg bg-black/40"
  >
    <UIcon name="i-lucide-alert-circle" class="h-8 w-8 text-red-400" />
    <UButton size="xs" color="white" variant="solid" :label="$t('chat.retry')" @click="emit('retry-upload', message.id)" />
  </div>
</div>

Step 3: Wire up in [id].vue

Handle the retry-upload event and remove old useChatImageUpload usage:

html
<MessageBubble
  v-for="msg in messages"
  :key="msg.id"
  :message="msg"
  :is-mine="msg.sender_id === authStore.user?.id"
  @image-click="handleImageClick"
  @retry-upload="retryImage"
/>

Remove the handleFileSelect call to old sendImage and use the new useMessages version. The composable useChatImageUpload can be kept but is no longer used directly in [id].vueuseMessages.sendImage handles everything.

Step 4: Run lint and typecheck

bash
cd /Users/mac/Работа/partizap/partizap-frontend && npm run lint:fix && npm run typecheck

Step 5: Commit

bash
git add app/features/chat-messaging/composables/useMessages.ts app/entities/message/ui/MessageBubble.vue app/pages/cabinet/messages/[id].vue
git commit -m "feat(chat): add ghost messages with upload spinner and retry on error"

Task 4: Infinite Scroll for Message History (#27)

Files:

  • Modify: app/pages/cabinet/messages/[id].vue

Step 1: Replace "Load earlier" button with IntersectionObserver sentinel

In <script setup>, add:

typescript
const sentinelRef = ref<HTMLElement | null>(null)

// Infinite scroll: load older messages when sentinel is visible
useIntersectionObserver(
  sentinelRef,
  ([entry]) => {
    if (entry?.isIntersecting && hasMore.value && !isLoading.value) {
      loadOlderAndPreserveScroll()
    }
  },
  { root: messagesContainer },
)

async function loadOlderAndPreserveScroll() {
  const el = messagesContainer.value
  if (!el) return

  const previousHeight = el.scrollHeight
  const previousTop = el.scrollTop

  await loadOlder()

  // Preserve scroll position after new messages are prepended
  nextTick(() => {
    const newHeight = el.scrollHeight
    el.scrollTop = previousTop + (newHeight - previousHeight)
  })
}

In the template, replace the "Load earlier" button:

html
<!-- Messages -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto py-4 space-y-2">
  <!-- Sentinel for infinite scroll -->
  <div ref="sentinelRef" class="h-1" />
  <div v-if="isLoading && messages.length > 0" class="flex justify-center py-2">
    <UIcon name="i-lucide-loader-2" class="h-5 w-5 animate-spin text-gray-400" />
  </div>
  <MessageBubble
    v-for="msg in messages"
    :key="msg.id"
    :message="msg"
    :is-mine="msg.sender_id === authStore.user?.id"
    @image-click="handleImageClick"
    @retry-upload="retryImage"
  />
</div>

Step 2: Run lint and typecheck

bash
cd /Users/mac/Работа/partizap/partizap-frontend && npm run lint:fix && npm run typecheck

Step 3: Commit

bash
git add app/pages/cabinet/messages/[id].vue
git commit -m "feat(chat): replace load-earlier button with infinite scroll"

Task 5: Avatars on Message Groups (#26)

Files:

  • Modify: app/entities/message/ui/MessageBubble.vue
  • Modify: app/pages/cabinet/messages/[id].vue

Step 1: Add grouping logic to [id].vue

Add a computed that determines which messages should show an avatar (last message in a group of consecutive same-sender messages):

typescript
const messagesWithMeta = computed(() => {
  const msgs = messages.value
  return msgs.map((msg, index) => {
    const nextMsg = msgs[index + 1]
    // Show avatar on the last message in a group (next message is from different sender, or is the last message)
    const isLastInGroup = !nextMsg || nextMsg.sender_id !== msg.sender_id || nextMsg.type === 'system'
    const isMine = msg.sender_id === authStore.user?.id
    return {
      ...msg,
      showAvatar: isLastInGroup && msg.type !== 'system',
      avatarUrl: isMine ? authStore.user?.avatar_url : conversation.value?.companion.avatar_url,
      displayName: isMine ? authStore.user?.display_name : conversation.value?.companion.display_name,
      isMine,
    }
  })
})

Update template to use messagesWithMeta:

html
<MessageBubble
  v-for="msg in messagesWithMeta"
  :key="msg.id"
  :message="msg"
  :is-mine="msg.isMine"
  :show-avatar="msg.showAvatar"
  :avatar-url="msg.avatarUrl ?? null"
  :display-name="msg.displayName ?? ''"
  :companion-id="conversation?.companion.id"
  @image-click="handleImageClick"
  @retry-upload="retryImage"
/>

Step 2: Update MessageBubble.vue to render avatars

Add new props:

typescript
const props = defineProps<{
  message: GhostMessage
  isMine: boolean
  showAvatar?: boolean
  avatarUrl?: string | null
  displayName?: string
  companionId?: number
}>()

Update the regular message template to include avatar:

html
<!-- Regular message bubble -->
<div v-else class="flex items-end gap-2" :class="isMine ? 'justify-end' : 'justify-start'">
  <!-- Companion avatar (left side) -->
  <NuxtLink
    v-if="!isMine && showAvatar"
    :to="`/seller/${companionId}`"
    class="flex-shrink-0"
  >
    <UAvatar
      :src="avatarUrl ?? undefined"
      :alt="displayName"
      size="xs"
    />
  </NuxtLink>
  <div v-else-if="!isMine" class="w-7 flex-shrink-0" />

  <!-- Bubble content (existing) -->
  <div
    class="max-w-[75%] rounded-2xl px-4 py-2"
    :class="
      isMine
        ? 'rounded-br-md bg-primary text-white'
        : 'rounded-bl-md bg-gray-100 text-[var(--ui-text)] dark:bg-gray-800'
    "
  >
    <!-- ...existing bubble content... -->
  </div>

  <!-- Own avatar (right side) -->
  <div v-if="isMine && showAvatar" class="flex-shrink-0">
    <UAvatar
      :src="avatarUrl ?? undefined"
      :alt="displayName"
      size="xs"
    />
  </div>
  <div v-else-if="isMine" class="w-7 flex-shrink-0" />
</div>

Note: UAvatar size="xs" is 24px in Nuxt UI v3 — close to the 28px spec. Alternatively use size="2xs" and set custom class. Check which size is closest.

Step 3: Run lint and typecheck

bash
cd /Users/mac/Работа/partizap/partizap-frontend && npm run lint:fix && npm run typecheck

Step 4: Commit

bash
git add app/entities/message/ui/MessageBubble.vue app/pages/cabinet/messages/[id].vue
git commit -m "feat(chat): add grouped avatars for both participants"

Task 6: Click Avatar/Name → Seller Profile (#24)

Files:

  • Modify: app/pages/cabinet/messages/[id].vue

Step 1: Wrap companion info in chat header with NuxtLink

Replace the current static companion display in the header:

html
<!-- Replace the existing companion display section -->
<NuxtLink
  v-if="conversation"
  :to="`/seller/${conversation.companion.id}`"
  class="flex items-center gap-3 flex-1 min-w-0 hover:opacity-80 transition-opacity"
>
  <div class="relative w-10 h-10 flex-shrink-0">
    <div class="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden">
      <img
        v-if="conversation.companion.avatar_url"
        :src="conversation.companion.avatar_url"
        class="w-full h-full object-cover"
      />
      <UIcon v-else name="i-lucide-user" class="w-5 h-5 text-gray-400" />
    </div>
    <span
      v-if="companionOnline"
      class="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-green-500 border-2 border-white dark:border-gray-900"
    />
  </div>
  <div class="min-w-0">
    <div class="font-medium truncate">&#123;&#123; conversation.companion.display_name }}</div>
    <div class="text-xs text-gray-500 dark:text-gray-400">
      <span v-if="companionTyping" class="text-primary">&#123;&#123; t('chat.typing') }}</span>
      <span v-else-if="companionOnline" class="text-green-500">&#123;&#123; t('chat.online') }}</span>
      <span v-else-if="lastSeenLabel">&#123;&#123; t('chat.lastSeen', { time: lastSeenLabel }) }}</span>
      <span v-else>&#123;&#123; conversation.product.title }} &middot; &#123;&#123; conversation.product.price.toLocaleString('ru-RU') }} &#8381;</span>
    </div>
  </div>
</NuxtLink>

The key change: <div> wrapper becomes <NuxtLink> with :to="/seller/${id}" and subtle hover effect.

Step 2: Run lint and typecheck

bash
cd /Users/mac/Работа/partizap/partizap-frontend && npm run lint:fix && npm run typecheck

Step 3: Commit

bash
git add app/pages/cabinet/messages/[id].vue
git commit -m "feat(chat): make companion avatar and name clickable to seller profile"

Files:

  • Create: app/features/chat-image/ui/ChatImageLightbox.vue
  • Modify: app/pages/cabinet/messages/[id].vue
  • Modify: i18n/locales/ru.json

Step 1: Add i18n keys

In i18n/locales/ru.json, inside the "chat" block, add:

json
"imageCounter": "{current} из {total}"

Step 2: Create ChatImageLightbox.vue

Create app/features/chat-image/ui/ChatImageLightbox.vue:

html
<script setup lang="ts">
const props = defineProps<{
  images: Array<{ id: number; url: string }>
  initialIndex: number
  open: boolean
}>()

const emit = defineEmits<{
  'update:open': [value: boolean]
}>()

const { t } = useI18n()
const currentIndex = ref(props.initialIndex)

watch(() => props.initialIndex, (val) => { currentIndex.value = val })

const currentImage = computed(() => props.images[currentIndex.value])
const hasPrev = computed(() => currentIndex.value > 0)
const hasNext = computed(() => currentIndex.value < props.images.length - 1)

function prev() { if (hasPrev.value) currentIndex.value-- }
function next() { if (hasNext.value) currentIndex.value++ }
function close() { emit('update:open', false) }

// Keyboard navigation
function handleKeydown(e: KeyboardEvent) {
  if (e.key === 'ArrowLeft') prev()
  else if (e.key === 'ArrowRight') next()
  else if (e.key === 'Escape') close()
}

watch(() => props.open, (isOpen) => {
  if (isOpen) {
    window.addEventListener('keydown', handleKeydown)
  }
  else {
    window.removeEventListener('keydown', handleKeydown)
  }
})

onScopeDispose(() => { window.removeEventListener('keydown', handleKeydown) })

// Swipe support for mobile
const lightboxRef = ref<HTMLElement | null>(null)
const { direction } = useSwipe(lightboxRef, {
  onSwipeEnd() {
    if (direction.value === 'left') next()
    else if (direction.value === 'right') prev()
  },
})

// Preload adjacent images
watch(currentIndex, (idx) => {
  for (const offset of [-1, 1]) {
    const neighbor = props.images[idx + offset]
    if (neighbor) {
      const img = new Image()
      img.src = neighbor.url
    }
  }
})
</script>

<template>
  <UModal
    :model-value="open"
    fullscreen
    :ui="{ background: 'bg-black/95' }"
    @update:model-value="emit('update:open', $event)"
  >
    <div ref="lightboxRef" class="relative flex h-full w-full items-center justify-center">
      <!-- Close button -->
      <UButton
        icon="i-lucide-x"
        variant="ghost"
        color="white"
        size="lg"
        class="absolute right-4 top-4 z-10"
        @click="close"
      />

      <!-- Counter -->
      <div class="absolute left-4 top-4 z-10 rounded-full bg-black/60 px-3 py-1 text-sm text-white">
        &#123;&#123; t('chat.imageCounter', { current: currentIndex + 1, total: images.length }) }}
      </div>

      <!-- Prev button -->
      <UButton
        v-if="hasPrev"
        icon="i-lucide-chevron-left"
        variant="ghost"
        color="white"
        size="xl"
        class="absolute left-4 z-10"
        @click="prev"
      />

      <!-- Image -->
      <img
        v-if="currentImage"
        :src="currentImage.url"
        class="max-h-[90vh] max-w-[90vw] object-contain"
        @click.stop
      />

      <!-- Next button -->
      <UButton
        v-if="hasNext"
        icon="i-lucide-chevron-right"
        variant="ghost"
        color="white"
        size="xl"
        class="absolute right-4 z-10"
        @click="next"
      />
    </div>
  </UModal>
</template>

Step 3: Wire up lightbox in [id].vue

Add lightbox state and image collection:

typescript
// Lightbox state
const lightboxOpen = ref(false)
const lightboxIndex = ref(0)

const chatImages = computed(() =>
  messages.value
    .filter(m => m.type === 'image' && m.image_url && m.id > 0) // exclude ghost messages
    .map(m => ({ id: m.id, url: m.image_url! }))
)

function handleImageClick(messageId: number) {
  const index = chatImages.value.findIndex(img => img.id === messageId)
  if (index === -1) return
  lightboxIndex.value = index
  lightboxOpen.value = true
}

Add the component to the template (before closing </div> of the root):

html
<!-- Image Lightbox -->
<ChatImageLightbox
  :images="chatImages"
  :initial-index="lightboxIndex"
  :open="lightboxOpen"
  @update:open="lightboxOpen = $event"
/>

Note: ChatImageLightbox is auto-imported by Nuxt via the features component dir.

Step 4: Verify the image-click emit works in MessageBubble.vue

In MessageBubble template, the image already has:

html
@click="!isUploading && !hasError && emit('image-click', message.id)"

This was added in Task 3. Verify it's wired correctly.

Step 5: Run lint and typecheck

bash
cd /Users/mac/Работа/partizap/partizap-frontend && npm run lint:fix && npm run typecheck

Step 6: Commit

bash
git add app/features/chat-image/ui/ChatImageLightbox.vue app/pages/cabinet/messages/[id].vue i18n/locales/ru.json
git commit -m "feat(chat): add fullscreen image lightbox with gallery navigation and swipe"

Task 8: Final Verification & Cleanup

Step 1: Run full lint + typecheck

bash
cd /Users/mac/Работа/partizap/partizap-frontend && npm run lint:fix && npm run typecheck

Step 2: Run tests

bash
cd /Users/mac/Работа/partizap/partizap-frontend && npm run test:run

Step 3: Manual testing checklist

  • [ ] Open a conversation — messages load, infinite scroll works (scroll to top loads older)
  • [ ] Avatars appear on last message in each group (both sides)
  • [ ] Click companion avatar/name in header → navigates to /seller/{id}
  • [ ] Click companion avatar on message → navigates to /seller/{id}
  • [ ] Type >1800 chars → counter appears. >2000 → counter turns red, send disabled
  • [ ] Select 6+ files → toast error "Максимум 5 фото за раз"
  • [ ] Select file >10MB → toast error
  • [ ] Send image → ghost message with spinner appears, replaced by real message on success
  • [ ] Click image in chat → lightbox opens. Arrow keys / swipe / buttons navigate. Escape closes.
  • [ ] No v-html used for user content anywhere

Step 4: Final commit if any fixes needed

bash
git add -A && git commit -m "fix(chat): final adjustments after testing"

Summary of All Changes

FileActionTask(s)
features/chat-image/ui/ChatImageLightbox.vueCREATE#21
entities/message/ui/MessageBubble.vueMODIFY#22, #26
pages/cabinet/messages/[id].vueMODIFY#21, #22, #24, #26, #27, #28
features/chat-messaging/composables/useMessages.tsMODIFY#22, #23
i18n/locales/ru.jsonMODIFY#21, #28

No backend changes needed. All 7 improvements are pure frontend.