Модуль 5. Составные компоненты чата — Avatar, MessageBubble, Attachment
Цель модуля: собрать компоненты чата из примитивов (М2) и форм (М4), добавить утилиты i18n для мессенджера, закрепить работу с Effector на уровне state-запросов.
К концу модуля у вас:
Avatar,MessageBubble,Attachment,StatusBar— нейтральные компоненты с CSS Modules, тестами и stories.- Утилиты
formatDate,pluralize— для человеческих дат и множественного числа. - Стор
$messagesи эффектfetchMessagesFx— для демо загрузки чата.
📌 Перед началом: Модули 1-4. 🛠 Инструменты: React, Intl API, i18n. ⏱ Время: ~5 ч.
🔥 Инцидент: 1000 сообщений тормозит чат
В чате 1000 сообщений. Пользователь скроллит вверх — список тормозит, аватары мерцают, даты на английском хотя интерфейс русский. i18n отсутствует (Intl не используется), виртуализации нет, MessageBubble рендерится за 50мс каждый. Этот модуль решает i18n и даёт молекулы, готовые к виртуализации в М9.
📖 Теория
5.1 Avatar — аватар с fallback и статусом
tsx// ui-kit/src/components/Avatar/Avatar.tsx import { useState, type ImgHTMLAttributes } from 'react' import styles from './Avatar.module.css' type Size = 'xs' | 'sm' | 'md' | 'lg' type Status = 'online' | 'offline' | 'away' interface AvatarProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'size'> { src?: string | null alt: string size?: Size status?: Status } const sizeMap: Record<Size, number> = { xs: 24, sm: 32, md: 40, lg: 56 } export function Avatar({ src, alt, size = 'md', status, className, ...rest }: AvatarProps) { const [errored, setErrored] = useState(false) const px = sizeMap[size] const showFallback = !src || errored return ( <div className={[styles.wrapper, className].filter(Boolean).join(' ')} style={{ width: px, height: px }} > {showFallback ? ( <div className={styles.fallback} style={{ width: px, height: px, fontSize: px / 2.5 }}> {alt.charAt(0).toUpperCase()} </div> ) : ( <img src={src} alt={alt} width={px} height={px} className={styles.img} onError={() => setErrored(true)} {...rest} /> )} {status && <span className={[styles.status, styles[status]].filter(Boolean).join(' ')} aria-label={status} />} </div> ) }
css/* ui-kit/src/components/Avatar/Avatar.module.css */ .wrapper { position: relative; border-radius: var(--radius-full); flex-shrink: 0; } .img { border-radius: var(--radius-full); object-fit: cover; width: 100%; height: 100%; } .fallback { display: flex; align-items: center; justify-content: center; background: var(--c-surface-2); color: var(--c-text-muted); border-radius: var(--radius-full); font-weight: 600; } .status { position: absolute; bottom: 0; right: 0; width: 10px; height: 10px; border-radius: var(--radius-full); border: 2px solid var(--c-surface); } .online { background: var(--c-success); } .offline { background: var(--c-text-faint); } .away { background: #f59e0b; }
CLS: фиксированные размеры (
width,heightв px) предотвращают layout shift при загрузке. Критично для Core Web Vitals (М10).
5.2 MessageBubble — сообщение с темизацией
tsx// ui-kit/src/components/MessageBubble/MessageBubble.tsx import { type ReactNode } from 'react' import { Text } from '../Text' import styles from './MessageBubble.module.css' interface MessageBubbleProps { own?: boolean children: ReactNode status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error' timestamp?: string } export function MessageBubble({ own, children, status, timestamp }: MessageBubbleProps) { return ( <div className={[styles.bubble, own && styles.own].filter(Boolean).join(' ')}> <Text variant="body" className={styles.text}>{children}</Text> {(status || timestamp) && ( <div className={styles.meta}> {timestamp && <Text variant="caption" color="faint">{timestamp}</Text>} {status && <span className={styles[status]} aria-label={`Status: ${status}`} />} </div> )} </div> ) }
css/* ui-kit/src/components/MessageBubble/MessageBubble.module.css */ .bubble { max-width: 70%; padding: var(--s-sm) var(--s-md); border-radius: var(--radius-lg); background: var(--c-surface); word-wrap: break-word; } .bubble.own { background: var(--c-primary); color: var(--on-accent, #fff); margin-left: auto; } .bubble.own .text { color: inherit; } .meta { display: flex; gap: var(--s-xs); align-items: center; margin-top: var(--s-xs); justify-content: flex-end; } /* status dots */ .sending { width: 8px; height: 8px; border-radius: 50%; background: var(--c-text-faint); } .sent { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: 0.5; } .delivered { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: 0.7; } .read { width: 8px; height: 8px; border-radius: 50%; background: var(--c-success); } .error { width: 8px; height: 8px; border-radius: 50%; background: var(--c-danger); }
5.3 Attachment — вложения
tsx// ui-kit/src/components/Attachment/Attachment.tsx import { Icon } from '../Icon' import { Text } from '../Text' import styles from './Attachment.module.css' type AttachmentType = 'image' | 'file' | 'voice' interface AttachmentProps { type: AttachmentType name: string size?: string previewUrl?: string progress?: number // 0..100, если загрузка error?: string } export function Attachment({ type, name, size, previewUrl, progress, error }: AttachmentProps) { if (type === 'image' && previewUrl) { return ( <div className={styles.imageWrapper}> <img src={previewUrl} alt={name} className={styles.image} loading="lazy" /> {progress !== undefined && progress < 100 && ( <div className={styles.progress} style={{ width: `${progress}%` }} /> )} </div> ) } return ( <div className={[styles.file, error && styles.errorState].filter(Boolean).join(' ')}> <Icon name={type === 'file' ? 'Chat' : 'Reaction'} size={24} /> <div className={styles.fileInfo}> <Text variant="body">{name}</Text> {error ? ( <Text variant="caption" color="danger">{error}</Text> ) : progress !== undefined ? ( <Text variant="caption" color="muted">{progress}%</Text> ) : size ? ( <Text variant="caption" color="muted">{size}</Text> ) : null} </div> </div> ) }
5.4 i18n: formatDate и pluralize
Мессенджер постоянно показывает даты и количества: «5 минут назад», «3 сообщения», «12 апреля». UI Kit предоставляет утилиты через Intl API.
ts// ui-kit/src/utils/format.ts /** Человекочитаемое время: «только что», «5 мин назад», «вчера в 14:30» */ export function formatRelativeTime(date: Date | string, locale = 'ru'): string { const d = typeof date === 'string' ? new Date(date) : date const now = new Date() const diffMs = now.getTime() - d.getTime() const diffMin = Math.floor(diffMs / 60000) if (diffMin < 1) return 'только что' if (diffMin < 60) { return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-diffMin, 'minute') } const diffHr = Math.floor(diffMin / 60) if (diffHr < 24) { return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-diffHr, 'hour') } return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(d) } /** Плюрализация: pluralize(3, 'сообщение', 'сообщения', 'сообщений') → «3 сообщения» */ export function pluralize(count: number, one: string, few: string, many: string): string { const mod10 = count % 10 const mod100 = count % 100 let form: string if (mod10 === 1 && mod100 !== 11) form = one else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) form = few else form = many return `${count} ${form}` } // Примеры: // pluralize(1, 'сообщение', 'сообщения', 'сообщений') → "1 сообщение" // pluralize(3, 'сообщение', 'сообщения', 'сообщений') → "3 сообщения" // pluralize(5, 'сообщение', 'сообщения', 'сообщений') → "5 сообщений"
Тесты:
ts// ui-kit/src/utils/format.test.ts import { describe, it, expect } from 'vitest' import { pluralize, formatRelativeTime } from './format' describe('pluralize', () => { it('1 → one', () => { expect(pluralize(1, 'apple', 'apples', 'apples')).toBe('1 apple') }) it('2 → few', () => { expect(pluralize(2, 'сообщение', 'сообщения', 'сообщений')).toBe('2 сообщения') }) it('5 → many', () => { expect(pluralize(5, 'сообщение', 'сообщения', 'сообщений')).toBe('5 сообщений') }) it('11 → many', () => { expect(pluralize(11, 'сообщение', 'сообщения', 'сообщений')).toBe('11 сообщений') }) it('21 → one', () => { expect(pluralize(21, 'сообщение', 'сообщения', 'сообщений')).toBe('21 сообщение') }) }) describe('formatRelativeTime', () => { it('recent', () => { const now = new Date() expect(formatRelativeTime(now)).toBe('только что') }) })
5.5 Стор $messages и эффект fetchMessagesFx
ts// app/entities/message/model.ts import { createEffect, createStore, type Domain } from 'effector' export interface Message { id: string text: string authorId: string authorName: string authorAvatar: string | null createdAt: string status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error' } export const $messages = createStore<Message[]>([]) export const $status = createStore<'idle' | 'loading' | 'done' | 'error'>('idle') export const fetchMessagesFx = createEffect(async (chatId: string): Promise<Message[]> => { const res = await fetch(`/api/messages?chatId=${chatId}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }) $messages.on(fetchMessagesFx.doneData, (_, msgs) => msgs) $status.on(fetchMessagesFx, () => 'loading') $status.on(fetchMessagesFx.doneData, () => 'done') $status.on(fetchMessagesFx.fail, () => 'error')
Компромиссы (Trade-offs)
| Решение | Плюсы | Минусы |
|---|---|---|
| Composition (не inheritance) | Меньше связанности, легче тестировать | Больше пропсов на верхнем уровне |
Intl.RelativeTimeFormat (не своя функция) | Нативная локализация, без зависимостей | Нужен polyfill в старых браузерах |
| Фиксированные размеры Avatar | CLS = 0 | Меньше гибкости |
Стор $messages в entities/ | FSD-изоляция, переиспользование | Больше слоёв абстракции |
💻 5.6 Практика
Шаг 1. Реализовать Avatar, MessageBubble, Attachment
С CSS Modules, тестами и stories.
Шаг 2. Утилиты formatDate и pluralize
С тестами на плюрализацию (5 кейсов).
Шаг 3. Стор $messages
С эффектом fetchMessagesFx и unit-тестом через effector/testing.
Шаг 4. Собрать MessageRow
В app/widgets/message-row/ соберите из Avatar + MessageBubble:
tsx'use client' import { Avatar, MessageBubble } from '@myorg/ui-kit' import { formatRelativeTime } from '@myorg/ui-kit/utils' import type { Message } from '@/entities/message/model' export function MessageRow({ message }: { message: Message }) { return ( <div style={{ display: 'flex', gap: 'var(--s-sm)', flexDirection: message.authorId === 'me' ? 'row-reverse' : 'row' }}> <Avatar src={message.authorAvatar} alt={message.authorName} size="sm" /> <MessageBubble own={message.authorId === 'me'} status={message.status} timestamp={formatRelativeTime(message.createdAt)}> {message.text} </MessageBubble> </div> ) }
5.7 Артефакты сдачи М5
| Артефакт | Критерий |
|---|---|
verify зелёный | Все тесты компонентов и сторов проходят |
| Скриншот Storybook | Chat/Avatar (3 sizes + fallback + status), Chat/MessageBubble (own/other + 5 statuses) |
| Скриншот MessageRow | На ui-gallery: 3-4 сообщения, разные авторы, статусы доставки |
🧩 Проверь себя
🔗 Мостик к Модулю 6
Молекулы готовы: Avatar, MessageBubble, Attachment, StatusBar с i18n-датами и плюрализацией. Теперь добавим оверлеи: Modal с focus-trap, Tooltip, Skeleton, Tabs — с формальным axe-аудитом WCAG 2.1 AA.