Corp/docs/architecture/CHAT_RENDERING_ANALYSIS.md
Игорь Надеин 48631f43fd Реорганизация документации и скриптов проекта
- Создана структура docs/ с категориями: completed, guides, architecture, diagnostic, in-progress, reports
- Перемещено 47+ MD файлов из корня проекта в docs/
- Удалено 6 устаревших/дублирующихся файлов
- Создана структура backend/scripts/ для тестов и утилит
- Перемещено 35 Python скриптов в backend/scripts/
- Создана структура backend/docs/ для технической документации
- Перемещено 12 MD файлов backend в backend/docs/
- Перемещены LDAP тестовые данные в backend/tests/fixtures/
- Добавлены README файлы для навигации
- Добавлены правила в .github/copilot-instructions.md для размещения документации

Результат:
- Корень проекта: только README.md
- Корень backend/: только manage.py и Django приложения
- Вся документация организована в структурированные папки
2025-12-26 13:49:51 +03:00

24 KiB
Raw Blame History

Анализ архитектуры рендеринга страницы чата

Обзор текущей архитектуры

Страница чата состоит из серверного (Django) и клиентского (JavaScript) компонентов, работающих вместе для обеспечения real-time обновлений.


1. Серверный рендеринг (Django)

communications/views.py → ChatDetailView

Ответственность:

  • Начальная загрузка данных чата и сообщений
  • Проверка прав доступа пользователя
  • Подготовка контекста для шаблона

Ключевые методы:

get_queryset()
├─ Фильтрует чаты по правам доступа
├─ global / department / private / group
└─ membership-based access

get_context_data()
├─ Загружает последние N сообщений (по умолчанию 50)
├─ messages = chat.messages.order_by("-created_at")[:50]
├─ Определяет has_more (есть ли еще история)
├─ Загружает участников
├─ Вычисляет last_read_at
├─ Находит first_unread_id
└─ Проверяет can_send_messages

post()
└─ Обрабатывает отправку сообщений через форму (legacy)

Контекст для шаблона:

{
    'chat': Chat,
    'messages': [...],              # Последние 50 сообщений
    'messages_has_more': bool,
    'messages_oldest_id': int,
    'messages_oldest_ts': int,
    'messages_page_size': 50,
    'participants': QuerySet,
    'last_read_at': datetime,
    'first_unread_id': int,
    'can_send_messages': bool,
    'is_announcement_creator': bool
}

2. Шаблон HTML

templates/communications/chat_detail.html

Структура DOM:

.chat-root
└─ .chat-col
   ├─ .section-header           (заголовок чата)
   │  ├─ Иконка + название
   │  └─ Кнопки управления
   │
   └─ .chat-box (article.card)
      ├─ .alert (предупреждения)
      ├─ #scrollBtn               (кнопка "вниз")
      │
      ├─ #chatScroll              (контейнер сообщений)
      │  │  data-me-id="{{ user.id }}"
      │  │  data-chat-id="{{ chat.id }}"
      │  │  data-oldest-id="{{ messages_oldest_id }}"
      │  │  data-has-more="1"
      │  │
      │  ├─ .history-loader       (лоадер истории)
      │  │
      │  └─ {% for message in messages %}
      │     ├─ .day-divider       (разделитель дня)
      │     └─ .msg               (сообщение)
      │        ├─ .mini-ava       (аватар)
      │        ├─ .small          (имя + время)
      │        ├─ .bubble         (тело сообщения)
      │        │  ├─ .forwarded-indicator
      │        │  ├─ текст
      │        │  ├─ .poll-widget
      │        │  └─ .message-attachments
      │        └─ .message-reactions-wrapper
      │
      └─ .chat-composer           (композер)
         ├─ #typing               (индикатор "печатает...")
         └─ #chatForm
            ├─ .message-field
            │  ├─ #attachmentDropdown
            │  ├─ #emojiDropdown
            │  ├─ #id_content       (textarea)
            │  └─ .btn-send
            └─ #attachmentPreview

Data-атрибуты контейнера:

<div id="chatScroll"
     data-me-id="{{ user.id }}"
     data-chat-id="{{ chat.id }}"
     data-fetch-url="/api/v1/chats/{{ chat.pk }}/messages/"
     data-page-size="50"
     data-oldest-id="{{ messages_oldest_id }}"
     data-oldest-ts="{{ messages_oldest_ts }}"
     data-has-more="1"
     data-last-read-ts="{{ last_read_at|date:'U' }}000">

3. JavaScript модули

3.1 Инициализация (base.html)

Порядок загрузки:

1. initUserWebSocket() (base.html)
   └─ ws://host/ws/
   └─ Подключается СРАЗУ на всех страницах
   
2. {% if chat %} Только на странице чата:
   
   initChatMarkRead()
   ├─ Отслеживает скролл
   ├─ Находит последнее непрочитанное
   └─ Отправляет mark_read
   
3. Дополнительные модули (chat_detail.html):
   
   chatComposer.js
   ├─ Обработка формы отправки
   ├─ Загрузка файлов
   └─ Индикатор "печатает..."
   
   chatHistoryLoader.js
   ├─ IntersectionObserver
   ├─ Подгрузка истории
   └─ Сохранение позиции скролла
   
   chat-detail-enhanced.js
   ├─ MessageReactions
   ├─ MessageContextMenu
   ├─ MessageSelection
   ├─ ChatPoll
   └─ messageEditing

3.2 userWebSocket.js (НОВЫЙ)

Ответственность:

  • Единое WebSocket соединение
  • Получение новых сообщений
  • Отправка сообщений
  • Реакции, редактирование, удаление
  • Индикатор "печатает..."

Ключевые методы:

initUserWebSocket(options)
├─ connectWebSocket()
│  └─ ws = new WebSocket('ws://host/ws/')
│
├─ handleMessage(data)
│  ├─ case 'new_message':      handleNewMessage()
│  ├─ case 'list_update':      handleListUpdate()
│  ├─ case 'reaction_added':   handleReactionAdded()
│  └─ case 'typing_start':     handleTypingStart()
│
└─ API:
   ├─ openChat(chatId, loadHistory)
   ├─ sendMessage(content)
   ├─ addReaction(messageId, emoji)
   └─ markRead(chatId)

Обработка нового сообщения:

handleNewMessage(data) {
  const { message } = data;
  
  // 1. Проверка разделителя дня
  const msgDay = formatDay(message.created_ts);
  if (msgDay !== lastDay) {
    scrollEl.appendChild(createDayDivider(msgDay));
  }
  
  // 2. Создание элемента сообщения
  const msgEl = createMessageElement(message, {
    meId, avatarMap, profileUrl, detailUrlTemplate
  });
  
  // 3. Добавление в DOM
  scrollEl.appendChild(msgEl);
  
  // 4. Автоскролл (если внизу)
  if (atBottom() || message.author_id === meId) {
    scrollToBottom();
  }
  
  // 5. Инициализация реакций
  window.dispatchEvent(new CustomEvent('chat:message-added', {
    detail: { messageElement: msgEl, messageId: message.id }
  }));
}

3.3 chatMessageTemplates.js

Ответственность:

  • Создание HTML элементов сообщений
  • Форматирование времени/даты
  • Рендеринг вложений, пересылок, ответов, голосований

Ключевые функции:

createMessageElement(msg, options)
├─ Создает обертку .msg
├─ data-id, data-message-id, data-ts, data-author-id
├─ data-reactions (JSON)
│
├─ Форматирует контент:
│  ├─ Имя автора + время
│  ├─ Аватар
│  ├─ .bubble
│  │  ├─ .forwarded-indicator
│  │  ├─ .reply-indicator
│  │  ├─ Текст (с linebreaks)
│  │  ├─ .poll-widget
│  │  └─ .message-attachments
│  └─ .message-reactions-wrapper
│
└─ Возвращает HTMLElement

createDayDivider(dateStr)
└─ <div class="day-divider"><span>DD.MM.YYYY</span></div>

formatTime(ts) → "HH:MM"
formatDay(ts) → "DD.MM.YYYY"

Структура сообщения:

<div class="d-flex mb-3 msg justify-content-start"
     data-id="123"
     data-message-id="123"
     data-ts="1733059200000"
     data-author-id="456"
     data-reactions='{"👍":{"count":2,"users":[1,2]}}'>
  
  <a href="/employees/456/"><span class="mini-ava">...</span></a>
  
  <div class="d-flex flex-column">
    <div class="small text-secondary">
      <a href="/employees/456/">Иван Иванов</a> · 12:30
    </div>
    
    <div class="bubble bubble-other">
      <!-- Переслано от -->
      <div class="forwarded-indicator">...</div>
      
      <!-- Ответ на -->
      <div class="reply-indicator">...</div>
      
      <!-- Текст -->
      Привет, как дела?
      
      <!-- Голосование -->
      <div class="poll-widget">...</div>
      
      <!-- Вложения -->
      <div class="message-attachments">...</div>
    </div>
    
    <!-- Реакции (заполняется JS) -->
    <div class="message-reactions-wrapper"></div>
  </div>
</div>

3.4 chatHistoryLoader.js

Ответственность:

  • Подгрузка старых сообщений при скролле вверх
  • Сохранение позиции скролла после загрузки
  • Управление состоянием "has_more"

Архитектура:

initChatHistoryLoader()
├─ Читает data-атрибуты:
│  ├─ data-fetch-url
│  ├─ data-oldest-ts
│  ├─ data-has-more
│  └─ data-page-size
│
├─ IntersectionObserver на первом сообщении
│  └─ При появлении в viewport → загружаем
│
└─ loadMoreMessages()
   ├─ fetch('/api/v1/chats/{id}/messages/?before={ts}')
   ├─ Получаем старые сообщения
   ├─ Сохраняем текущую высоту scrollHeight
   ├─ Вставляем в начало chatScroll
   ├─ Восстанавливаем scrollTop
   └─ Обновляем data-oldest-ts, data-has-more

Последовательность загрузки истории:

Пользователь скроллит вверх
         ↓
IntersectionObserver срабатывает
         ↓
const beforeTs = scrollEl.dataset.oldestTs;
fetch(`/api/v1/chats/${chatId}/messages/?before=${beforeTs}&limit=50`)
         ↓
response.json() → { messages: [...], has_more: bool }
         ↓
const oldHeight = scrollEl.scrollHeight;
messages.reverse().forEach(msg => {
  const dayDiv = createDayDivider(msg);
  const msgEl = createMessageElement(msg);
  scrollEl.insertBefore(msgEl, firstMessage);
});
         ↓
const newHeight = scrollEl.scrollHeight;
scrollEl.scrollTop = newHeight - oldHeight;
         ↓
scrollEl.dataset.oldestTs = messages[0].created_ts;
scrollEl.dataset.hasMore = response.has_more;

3.5 chatMarkRead.js

Ответственность:

  • Отслеживание прочитанных сообщений
  • Автоматическая отметка при скролле
  • API для ручной отметки

Архитектура:

initChatMarkRead(options)
├─ IntersectionObserver на чужих сообщениях
│  └─ Когда сообщение видно → markAsRead()
│
├─ observeLastForeign(msgEl)
│  └─ Наблюдает за последним чужим сообщением
│
├─ markRead()
│  ├─ fetch('/communications/chats/{id}/mark-read/')
│  └─ window.dispatchEvent('chat:read', { chatId })
│
└─ API:
   ├─ atBottom() → bool
   ├─ autoscroll(instant)
   └─ markRead()

3.6 chatComposer.js

Ответственность:

  • Обработка отправки сообщений
  • Управление файлами (загрузка через API)
  • Автоматическое изменение размера textarea
  • Индикатор "печатает..."

Архитектура:

initChatComposer()
├─ Обработка submit формы
│  ├─ Отключена (сообщения через WebSocket)
│  └─ Файлы через API: POST /api/v1/upload-message/
│
├─ Загрузка файлов:
│  ├─ #attachDocument → documentInput.click()
│  ├─ #attachImage → imageInput.click()
│  ├─ #attachCamera → cameraInput.click()
│  └─ #attachAudio → audioInput.click()
│
├─ onChange file input:
│  ├─ Превью в #attachmentPreview
│  ├─ FormData + fetch(uploadUrl)
│  └─ Добавляет message в DOM (pending)
│
├─ Textarea input:
│  ├─ Auto-expand (textareaAutoExpand.js)
│  └─ Throttled typing indicator (WebSocket)
│
└─ Emoji picker:
   └─ <emoji-picker> (CDN)

3.7 chat-detail-enhanced.js

Ответственность:

  • Инициализация UI компонентов
  • Реакции, контекстное меню, выделение
  • Голосования
  • MutationObserver для новых сообщений

Архитектура:

(function() {
  const reactions = new MessageReactions();
  const contextMenu = new MessageContextMenu();
  const messageSelection = new MessageSelection();
  const chatPoll = new ChatPoll();
  initMessageEditing();
  
  // Инициализация существующих сообщений
  initMessageReactions();
  
  // Наблюдатель за новыми
  const messageObserver = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      mutation.addedNodes.forEach((node) => {
        if (node.dataset && node.dataset.messageId) {
          // Инициализируем реакции
          // Подключаем контекстное меню
        }
      });
    });
  });
  
  messageObserver.observe(chatScroll, {
    childList: true,
    subtree: true
  });
})();

4. Поток данных

4.1 Начальная загрузка страницы

HTTP GET /communications/chats/123/
         ↓
ChatDetailView.get()
├─ get_queryset() → проверка доступа
├─ get_context_data()
│  ├─ messages = последние 50
│  ├─ has_more = True/False
│  ├─ last_read_at
│  └─ first_unread_id
└─ render(chat_detail.html)
         ↓
HTML с {% for message in messages %}
         ↓
JS: base.html
├─ initUserWebSocket() → ws.connect()
│  └─ send({ action: 'open_chat', chat_id: 123 })
│
├─ initChatMarkRead()
│  └─ IntersectionObserver на сообщениях
│
└─ {% block extra_js %}
   ├─ chatComposer.js
   ├─ chatHistoryLoader.js
   └─ chat-detail-enhanced.js
         ↓
initMessageReactions()
└─ Для каждого .msg:
   ├─ Парсит data-reactions
   ├─ Рендерит кнопки реакций
   └─ Подключает обработчики

4.2 Получение нового сообщения

Другой пользователь отправляет сообщение
         ↓
Backend: UserConsumer._handle_send_message()
├─ message = Message.objects.create(...)
├─ msg_data = serialize_message(message)
└─ channel_layer.group_send('chat_123', {
     type: 'chat_message',
     chat_id: 123,
     payload: msg_data
   })
         ↓
Frontend: userWebSocket.handleMessage()
├─ case 'new_message':
│  └─ handleNewMessage(data)
│     ├─ createDayDivider() (если нужно)
│     ├─ createMessageElement(message)
│     ├─ scrollEl.appendChild(msgEl)
│     ├─ scrollToBottom() (если внизу)
│     └─ dispatch 'chat:message-added'
│
└─ case 'list_update':
   └─ badgeManager.incrementChat(chatId)
         ↓
MutationObserver в chat-detail-enhanced.js
├─ Обнаруживает новый .msg
├─ Инициализирует реакции
└─ Подключает контекстное меню
         ↓
IntersectionObserver в chatMarkRead.js
└─ Когда сообщение видно → markAsRead()

4.3 Подгрузка истории

Пользователь скроллит к началу чата
         ↓
IntersectionObserver (chatHistoryLoader.js)
└─ Первое сообщение появилось в viewport
         ↓
loadMoreMessages()
├─ const beforeTs = scrollEl.dataset.oldestTs;
├─ fetch(`/api/v1/chats/123/messages/?before=${beforeTs}&limit=50`)
│  └─ Backend: ChatMessagesViewSet.list()
│     └─ messages = Message.objects.filter(
│           chat_id=123,
│           created_at__lt=beforeTs
│         ).order_by('-created_at')[:50]
├─ const oldHeight = scrollEl.scrollHeight;
├─ messages.reverse().forEach(msg => {
│    scrollEl.insertBefore(createMessageElement(msg), firstMsg);
│  });
├─ scrollEl.scrollTop = scrollEl.scrollHeight - oldHeight;
└─ scrollEl.dataset.oldestTs = messages[0].created_ts;

5. Проблемы текущей архитектуры

5.1 Дублирование логики

Проблема:

  • Серверный рендеринг в chat_detail.html (Django template)
  • Клиентский рендеринг в chatMessageTemplates.js
  • Два разных HTML шаблона для одного и того же

Пример:

{# Django template #}
<div class="bubble">
  {% if message.is_forwarded %}
    <div class="forwarded-indicator">...</div>
  {% endif %}
  {{ message.content|linebreaksbr }}
</div>
// JavaScript
const bubble = `
  <div class="bubble">
    ${msg.is_forwarded ? '<div class="forwarded-indicator">...</div>' : ''}
    ${text}
  </div>`;

Последствия:

  • Сложность поддержки (изменения в двух местах)
  • Риск рассинхронизации
  • Дублирование кода

5.2 Смешанная ответственность

Проблема:

  • userWebSocket.js создает DOM элементы
  • chat-detail-enhanced.js инициализирует компоненты
  • chatMarkRead.js отслеживает видимость
  • Нет четкого разделения ответственности

Последствия:

  • Сложность отладки
  • Трудно понять, где что происходит
  • Зависимости между модулями

5.3 Imperative DOM manipulation

Проблема:

  • Прямое создание HTML строк
  • innerHTML, appendChild, insertBefore
  • Нет виртуального DOM
  • Нет реактивности

Последствия:

  • Сложность управления состоянием
  • Проблемы с производительностью при большом количестве сообщений
  • Трудно оптимизировать рендеринг

5.4 Состояние разбросано

Проблема:

  • data- атрибуты на DOM элементах
  • window глобальные переменные
  • Состояние в замыканиях модулей
  • Нет единого источника правды

Пример:

// Состояние в разных местах
scrollEl.dataset.oldestTs = '1733059200000';
scrollEl.dataset.hasMore = '1';
window.chatPoll = chatPoll;
window.chatWebSocketApi = userWs;
const state = { activeChatId: 123 }; // в замыкании

5.5 Отсутствие компонентного подхода

Проблема:

  • Монолитные скрипты
  • Нет переиспользуемых компонентов
  • Сложно тестировать

Последствия:

  • Трудно расширять функциональность
  • Дублирование логики
  • Сложность рефакторинга

6. Рекомендации по рефакторингу

6.1 Унифицировать рендеринг

Предложение:

  • Использовать только клиентский рендеринг
  • Сервер отдает только JSON с данными
  • Один шаблон для всех сообщений

Архитектура:

Backend:
└─ API endpoint: GET /api/v1/chats/123/
   └─ { chat: {...}, messages: [...] }

Frontend:
└─ renderChat(chatData)
   ├─ renderHeader(chat)
   ├─ renderMessages(messages)
   └─ renderComposer(chat)

6.2 Компонентный подход

Предложение:

  • React, Vue, или Lit (Web Components)
  • Компоненты: Message, MessageList, Composer
  • Props и state management

Структура:

<ChatPage>
  ├─ <ChatHeader chat={chat} />
  ├─ <MessageList 
  │    messages={messages}
  │    onLoadMore={loadMore}
  │  />
  └─ <Composer 
       onSend={sendMessage}
       canSend={permissions.canSend}
     />

6.3 Централизованное состояние

Предложение:

  • Redux, Zustand, или Pinia
  • Единый store для состояния чата

State shape:

{
  chat: { id, name, type, ... },
  messages: {
    byId: {
      '123': { id, content, author, ... },
      '124': { ... }
    },
    allIds: [123, 124, ...],
    hasMore: true,
    oldestId: 123
  },
  ui: {
    isLoadingHistory: false,
    activeMessageId: null
  }
}

6.4 Оптимизация рендеринга

Предложение:

  • Виртуальный скролл (react-window, vue-virtual-scroller)
  • Lazy loading изображений
  • Memo/PureComponent для сообщений

6.5 TypeScript

Предложение:

  • Типизация для безопасности
  • Интерфейсы для Message, Chat, User
interface Message {
  id: number;
  content: string;
  author: User;
  created_at: string;
  is_forwarded: boolean;
  attachments: Attachment[];
  reactions: Reaction[];
}

7. Зависимости модулей

base.html
├─ userWebSocket.js
│  └─ chatMessageTemplates.js
│
├─ chatMarkRead.js
│
└─ chatAvatarMap.js

chat_detail.html
├─ chatComposer.js
│  └─ chatFileUpload.js
│
├─ chatHistoryLoader.js
│  └─ chatMessageTemplates.js
│
└─ chat-detail-enhanced.js
   ├─ messageReactions.js
   ├─ messageContextMenu.js
   ├─ messageSelection.js
   ├─ chatPoll.js
   └─ messageEditing.js

8. API endpoints

Чаты

GET  /communications/chats/123/           → HTML страница (view)
GET  /api/v1/chats/123/                   → JSON чата
GET  /api/v1/chats/123/messages/          → JSON сообщений
POST /api/v1/upload-message/              → Загрузка с файлами
POST /communications/chats/123/mark-read/ → Отметка прочитанного

WebSocket

ws://host/ws/
├─ open_chat        → Открыть чат
├─ close_chat       → Закрыть чат
├─ send_message     → Отправить сообщение
├─ add_reaction     → Добавить реакцию
└─ typing           → Индикатор печати

Итого

Сильные стороны:

  • ✅ Real-time через WebSocket
  • ✅ Модульная архитектура
  • ✅ Разделение UI компонентов

Слабые стороны:

  • ❌ Дублирование рендеринга (Django + JS)
  • ❌ Imperative DOM manipulation
  • ❌ Разбросанное состояние
  • ❌ Нет компонентного подхода
  • ❌ Сложность поддержки

Приоритеты рефакторинга:

  1. Унифицировать рендеринг (только клиент)
  2. Внедрить компонентный фреймворк
  3. Централизовать состояние
  4. Оптимизировать виртуальный скролл
  5. Добавить TypeScript