Модуль 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 в старых браузерах
Фиксированные размеры AvatarCLS = 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 зелёныйВсе тесты компонентов и сторов проходят
Скриншот StorybookChat/Avatar (3 sizes + fallback + status), Chat/MessageBubble (own/other + 5 statuses)
Скриншот MessageRowНа ui-gallery: 3-4 сообщения, разные авторы, статусы доставки

Вперёд → Модуль 6: overlay, навигация и a11y-аудит.


🧩 Проверь себя

1. Молекулы (Avatar, MessageBubble, Attachment) — это...
2. Props-drilling в молекулах — решение в FSD?
3. Compose vs extends в UI Kit (molecules) — что лучше?
4. i18n в молекулах (format-date) — зачем?
5. Slots (children / render-prop) в молекулах — зачем?

🔗 Мостик к Модулю 6

Молекулы готовы: Avatar, MessageBubble, Attachment, StatusBar с i18n-датами и плюрализацией. Теперь добавим оверлеи: Modal с focus-trap, Tooltip, Skeleton, Tabs — с формальным axe-аудитом WCAG 2.1 AA.