Corp/CHAT_RENDERING_ANALYSIS.md

843 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Анализ архитектуры рендеринга страницы чата
## Обзор текущей архитектуры
Страница чата состоит из серверного (Django) и клиентского (JavaScript) компонентов, работающих вместе для обеспечения real-time обновлений.
---
## 1. Серверный рендеринг (Django)
### `communications/views.py` → `ChatDetailView`
**Ответственность:**
- Начальная загрузка данных чата и сообщений
- Проверка прав доступа пользователя
- Подготовка контекста для шаблона
**Ключевые методы:**
```python
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)
```
**Контекст для шаблона:**
```python
{
'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-атрибуты контейнера:**
```html
<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)
**Порядок загрузки:**
```javascript
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 соединение
- Получение новых сообщений
- Отправка сообщений
- Реакции, редактирование, удаление
- Индикатор "печатает..."
**Ключевые методы:**
```javascript
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)
```
**Обработка нового сообщения:**
```javascript
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 элементов сообщений
- Форматирование времени/даты
- Рендеринг вложений, пересылок, ответов, голосований
**Ключевые функции:**
```javascript
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"
```
**Структура сообщения:**
```html
<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"
**Архитектура:**
```javascript
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 для ручной отметки
**Архитектура:**
```javascript
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
- Индикатор "печатает..."
**Архитектура:**
```javascript
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 для новых сообщений
**Архитектура:**
```javascript
(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
{# Django template #}
<div class="bubble">
{% if message.is_forwarded %}
<div class="forwarded-indicator">...</div>
{% endif %}
{{ message.content|linebreaksbr }}
</div>
```
```javascript
// 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` глобальные переменные
- Состояние в замыканиях модулей
- Нет единого источника правды
**Пример:**
```javascript
// Состояние в разных местах
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:**
```javascript
{
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
```typescript
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