Appearance
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 typecheckStep 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'">
{{ 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 typecheckStep 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].vue — useMessages.sendImage handles everything.
Step 4: Run lint and typecheck
bash
cd /Users/mac/Работа/partizap/partizap-frontend && npm run lint:fix && npm run typecheckStep 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 typecheckStep 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 typecheckStep 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">{{ conversation.companion.display_name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
<span v-if="companionTyping" class="text-primary">{{ t('chat.typing') }}</span>
<span v-else-if="companionOnline" class="text-green-500">{{ t('chat.online') }}</span>
<span v-else-if="lastSeenLabel">{{ t('chat.lastSeen', { time: lastSeenLabel }) }}</span>
<span v-else>{{ conversation.product.title }} · {{ conversation.product.price.toLocaleString('ru-RU') }} ₽</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 typecheckStep 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"Task 7: Image Lightbox with Gallery (#21)
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">
{{ 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 typecheckStep 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 typecheckStep 2: Run tests
bash
cd /Users/mac/Работа/partizap/partizap-frontend && npm run test:runStep 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-htmlused 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
| File | Action | Task(s) |
|---|---|---|
features/chat-image/ui/ChatImageLightbox.vue | CREATE | #21 |
entities/message/ui/MessageBubble.vue | MODIFY | #22, #26 |
pages/cabinet/messages/[id].vue | MODIFY | #21, #22, #24, #26, #27, #28 |
features/chat-messaging/composables/useMessages.ts | MODIFY | #22, #23 |
i18n/locales/ru.json | MODIFY | #21, #28 |
No backend changes needed. All 7 improvements are pure frontend.