# Архитектура рендеринга и наполнения страницы чата ## 📋 Оглавление 1. [Общая схема](#общая-схема) 2. [Серверная часть (Backend)](#серверная-часть-backend) 3. [Шаблон страницы (Template)](#шаблон-страницы-template) 4. [JavaScript инициализация](#javascript-инициализация) 5. [WebSocket соединение](#websocket-соединение) 6. [Рендеринг сообщений](#рендеринг-сообщений) 7. [Поток данных](#поток-данных) --- ## 🎯 Общая схема Страница чата работает по следующей архитектуре: ``` HTTP Request → Django View → HTML Template → DOM Ready → WebSocket Connect → Load Messages ↓ ↓ ↓ ↓ ↓ Context Data Initial HTML Init JS Real-time Render to DOM ``` --- ## 🔧 Серверная часть (Backend) ### 1. `ChatDetailView` (communications/views.py) **Класс:** `ChatDetailView(LoginRequiredMixin, DetailView, FormView)` **Ответственность:** - Проверка прав доступа пользователя к чату - Подготовка контекста для шаблона - Установка заголовков для отключения кэширования #### Ключевые методы: ```python def get_object(self): """Получает объект чата с проверкой прав доступа""" # Фильтрует по типу чата: global/department/private/group # Проверяет membership для приватных чатов ``` ```python def get_context_data(self, **kwargs): """Подготавливает контекст для шаблона""" # 1. Проверка доступа через _user_has_access() # 2. Загрузка сообщений (последние 50, в обратном порядке) # 3. Определение has_more (есть ли еще история) # 4. Вычисление last_read_at и first_unread_id # 5. Проверка прав на отправку сообщений ``` **Контекст, передаваемый в шаблон:** ```python { 'chat': Chat, # Объект чата 'messages': [...], # Последние 50 сообщений (УСТАРЕЛО - не рендерятся) 'messages_has_more': bool, # Есть ли еще история 'messages_oldest_id': int, # ID самого старого сообщения 'messages_oldest_ts': int, # Timestamp самого старого сообщения 'messages_page_size': 50, # Размер страницы 'participants': QuerySet, # Участники чата 'last_read_at': datetime, # Время последнего прочтения 'first_unread_id': int, # ID первого непрочитанного 'can_send_messages': bool, # Может ли отправлять сообщения 'is_announcement_creator': bool # Создатель объявления } ``` #### Отключение кэширования: ```python def dispatch(self, request, *args, **kwargs): response = super().dispatch(request, *args, **kwargs) response['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' response['Pragma'] = 'no-cache' response['Expires'] = '0' return response ``` --- ## 🖼️ Шаблон страницы (Template) ### `templates/communications/chat_detail.html` #### Структура DOM: ```html
...
...
...

Загружаем сообщения...

...
``` #### Data-атрибуты контейнера: ```html
``` **Важно:** Сообщения из контекста `messages` больше НЕ рендерятся в шаблоне! Вместо этого используется **WebSocket для загрузки initial_messages**. --- ## ⚙️ JavaScript инициализация ### `static/js/pages/chatDetail.js` **Главный файл инициализации страницы чата.** #### Этапы инициализации: ```javascript // 1. Загрузка модулей const modules = await Promise.all([ import('../components/chatMarkRead.js'), import('../components/chatComposer.js'), import('../components/chatHistoryLoader.js'), import('../components/chatFormManager.js'), import('../components/messageRenderer.js'), import('../components/initialMessagesLoader.js') // Не используется ]); // 2. Ожидание DOM Ready document.addEventListener('DOMContentLoaded', initWhenReady); // 3. Чтение конфигурации из data-атрибутов const config = { chatId: Number(appContainer.dataset.chatId), userId: Number(appContainer.dataset.userId), uploadUrl: appContainer.dataset.uploadUrl, messagesUrl: appContainer.dataset.messagesUrl, // ... и другие }; // 4. Ожидание готовности WebSocket waitForWebSocket().then((userWs) => { initializeComponents(config, userWs); }); ``` #### Инициализация компонентов: ```javascript function initializeComponents(config, userWs) { // 1. FormManager - управление формой const formManager = initChatFormManager({ ... }); // 2. MarkRead - отметка прочитанных const markReadApi = initChatMarkRead({ ... }); // 3. Composer - композер сообщений const composer = initChatComposer({ ... }); // 4. HistoryLoader - загрузка старых сообщений const historyLoader = initChatHistoryLoader({ ... }); // 5. MessageRenderer - рендеринг сообщений const messageRenderer = new MessageRenderer({ ... }); // 6. Конфигурация WebSocket userWs.configure({ scrollContainerId: 'chatScroll', messageRenderer: messageRenderer, markReadApi: markReadApi, // ... }); // 7. Открытие чата в WebSocket userWs.openChat(config.chatId, true); // true = загрузить историю } ``` --- ## 🌐 WebSocket соединение ### `static/js/components/userWebSocket.js` **Единое WebSocket соединение для всех real-time обновлений.** #### Инициализация в base.html: ```javascript // templates/base.html const userWs = initUserWebSocket({ userId: {{ user.id }}, badgeManager: badgeManager, onListUpdate: (data) => { /* ... */ } }); window.userWebSocket = userWs; // Уведомляем модули о готовности window.dispatchEvent(new CustomEvent('user:ws-ready', { detail: { api: userWs } })); ``` #### Подключение: ```javascript const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const wsUrl = `${proto}://${location.host}/ws/`; const ws = new WebSocket(wsUrl); ``` #### Открытие чата: ```javascript function openChat(chatId, loadHistory = false) { send({ action: 'open_chat', chat_id: chatId, load_history: loadHistory }); } ``` --- ## 📨 Загрузка сообщений через WebSocket ### Backend: `communications/consumers.py` #### `ChatConsumer.connect()`: ```python async def connect(self): # 1. Проверка аутентификации user = self.scope.get("user") # 2. Проверка прав доступа к чату chat = await self._get_chat(self.chat_id) if not await self._user_can_access(chat, user): await self.close(code=4403) return # 3. Подключение к группе await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() # 4. Отправка начальной истории await self._send_initial_messages() # 5. Отметка как прочитанное await self._mark_read(self.chat, user) # 6. Запуск ping цикла self.ping_task = asyncio.create_task(self._ping_loop()) ``` #### `_send_initial_messages()`: ```python async def _send_initial_messages(self): """Отправить начальную историю сообщений при подключении""" messages = await self._get_initial_messages(self.chat_id, limit=50) await self.send_json({ "type": "initial_messages", "messages": messages }) @database_sync_to_async def _get_initial_messages(self, chat_id: int, limit: int = 50): """Получить последние N сообщений из чата""" messages = Message.objects.filter( chat_id=chat_id ).select_related( 'author' ).prefetch_related( 'attachments', 'reactions__user' ).order_by('-created_at')[:limit] # Возвращаем в прямом порядке (старые -> новые) return [serialize_message(msg) for msg in reversed(list(messages))] ``` #### Формат сообщения: ```python def serialize_message(m: Message) -> dict: return { "id": m.id, "chat_id": m.chat_id, "author_id": m.author_id, "author_name": m.author.get_full_name() or m.author.username, "avatar": m.author.avatar.url if hasattr(m.author, 'avatar') and m.author.avatar else None, "content": m.content, "created_ts": int(m.created_at.timestamp() * 1000), "is_edited": m.is_edited, "edited_at": m.edited_at.isoformat() if m.edited_at else None, "attachments": [ { "id": att.id, "file_url": att.file.url if att.file else None, "file_name": att.file.name.split('/')[-1] if att.file else "file", "file_type": att.file_type or "unknown" } for att in m.attachments.all() ], "reactions_summary": m.get_reactions_summary(), "is_forwarded": m.is_forwarded, "forwarded_from": { ... } if m.is_forwarded else None, "reply_to": { ... } if m.reply_to else None, "poll": { ... } if hasattr(m, 'poll') else None, } ``` --- ## 🎨 Рендеринг сообщений ### `static/js/components/messageRenderer.js` #### Класс `MessageRenderer`: ```javascript class MessageRenderer { constructor(config) { this.containerId = config.containerId; this.currentUserId = config.currentUserId; this.currentUserAvatar = config.currentUserAvatar; this.profileUrl = config.profileUrl; this.detailUrlTemplate = config.detailUrlTemplate; } /** * Рендерит массив сообщений (с разделителями дней) */ renderMessages(messages) { const container = document.getElementById(this.containerId); let lastDay = null; messages.forEach(msg => { // Добавляем разделитель дня если нужно const msgDate = new Date(msg.created_ts); const msgDay = this.formatDay(msgDate); if (msgDay !== lastDay) { this.addDayDivider(msgDay, container); lastDay = msgDay; } this.renderMessage(msg, container); }); } /** * Рендерит одно сообщение */ renderMessage(msg, container) { // Проверка на дубликаты const existingMessage = container.querySelector(`[data-message-id="${msg.id}"]`); if (existingMessage) { console.log('Message already exists, skipping:', msg.id); return; } const isOwn = msg.author_id === this.currentUserId; const messageHtml = this.buildMessageHtml(msg, isOwn); // Добавляем в конец контейнера container.insertAdjacentHTML('beforeend', messageHtml); } } ``` #### Структура HTML сообщения: ```html
...
...
Привет, как дела? (изменено)
...
...
...
...
``` --- ## 🔄 Поток данных ### 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 с пустым контейнером #chatScroll │ │ + индикатор загрузки #initialLoader │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ JS: DOMContentLoaded │ │ ├─ chatDetail.js → initWhenReady() │ │ ├─ Чтение config из data-атрибутов │ │ └─ waitForWebSocket() │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ base.html → initUserWebSocket() │ │ ├─ ws = new WebSocket('ws://host/ws/') │ │ └─ dispatchEvent('user:ws-ready') │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ chatDetail.js → initializeComponents() │ │ ├─ FormManager │ │ ├─ MarkRead │ │ ├─ Composer │ │ ├─ HistoryLoader │ │ ├─ MessageRenderer │ │ └─ userWs.openChat(chatId, true) │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ WebSocket → { action: 'open_chat', chat_id: 123 } │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Backend: ChatConsumer.connect() │ │ ├─ _send_initial_messages() │ │ │ └─ _get_initial_messages(limit=50) │ │ └─ send_json({ type: 'initial_messages', messages: [...] }) │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ userWebSocket.js → handleInitialMessages() │ │ └─ messageRenderer.renderMessage() для каждого сообщения │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ messageRenderer.js → renderMessage() │ │ ├─ Проверка дубликатов │ │ ├─ Добавление разделителей дней │ │ ├─ buildMessageHtml() │ │ └─ container.insertAdjacentHTML('beforeend', html) │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ DOM обновлён → сообщения видны пользователю │ │ ├─ Скролл вниз │ │ ├─ Удаление #initialLoader │ │ ├─ dispatchEvent('chat:initial-messages-loaded') │ │ └─ Инициализация poll виджетов │ └─────────────────────────────────────────────────────────────────┘ ``` ### 2. Получение нового сообщения ``` ┌─────────────────────────────────────────────────────────────────┐ │ Другой пользователь отправляет сообщение │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Backend: ChatConsumer → channel_layer.group_send() │ │ → broadcast в chat_123 │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ WebSocket → { type: 'new_message', message: {...} } │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ userWebSocket.js → handleNewMessage() │ │ └─ messageRenderer.renderMessage(message) │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ DOM обновлён → новое сообщение появилось в чате │ │ ├─ Автоскролл (если внизу) │ │ └─ Отметка как прочитанное (если в viewport) │ └─────────────────────────────────────────────────────────────────┘ ``` ### 3. Загрузка истории (скролл вверх) ``` ┌─────────────────────────────────────────────────────────────────┐ │ Пользователь скроллит вверх │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ chatHistoryLoader.js → IntersectionObserver срабатывает │ │ └─ loadMoreMessages() │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ API Request: GET /api/v1/chats/123/messages/ │ │ ?before_id=456&limit=50 │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Backend: ChatMessagesAPIView.get() │ │ └─ Возвращает { messages: [...], has_more: true } │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ chatHistoryLoader.js → прикрепляет сообщения в начало │ │ └─ messageRenderer.prependMessage() для каждого │ └───────────────────────────────┬─────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ DOM обновлён → старые сообщения видны │ │ └─ Сохранение позиции скролла │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## 📊 Компоненты и их взаимодействие ``` ┌─────────────────────────────────────────────────────────────────┐ │ chatDetail.js │ │ (Оркестратор компонентов) │ └────────────────┬────────────────────────────────┬───────────────┘ │ │ ┌───────────▼──────────┐ ┌──────────▼──────────┐ │ userWebSocket.js │◄────────┤ messageRenderer.js │ │ (WebSocket соединение)│ │ (Рендеринг HTML) │ └───────────┬──────────┘ └─────────────────────┘ │ ┌───────────▼──────────┐ │ chatComposer.js │ │ (Форма отправки) │ └──────────────────────┘ │ ┌───────────▼──────────┐ │ chatMarkRead.js │ │ (Отметка прочитанных)│ └──────────────────────┘ │ ┌───────────▼──────────┐ │chatHistoryLoader.js │ │ (Загрузка истории) │ └──────────────────────┘ ``` --- ## 🔑 Ключевые особенности ### 1. ✅ Отключен серверный рендеринг сообщений - Раньше: Django рендерил `messages` через `{% for message in messages %}` - Сейчас: WebSocket отправляет `initial_messages` после подключения - Преимущество: Единый механизм рендеринга (JS), меньше дублирования кода ### 2. ✅ Предотвращение дубликатов ```javascript const existingMessage = container.querySelector(`[data-message-id="${msg.id}"]`); if (existingMessage) { console.log('Message already exists, skipping:', msg.id); return; } ``` ### 3. ✅ Автоматические разделители дней ```javascript let lastDay = null; messages.forEach(msg => { const msgDay = this.formatDay(msgDate); if (msgDay !== lastDay) { this.addDayDivider(msgDay, container); lastDay = msgDay; } this.renderMessage(msg, container); }); ``` ### 4. ✅ Ленивая загрузка истории - IntersectionObserver отслеживает элемент `.history-loader` - При видимости → загружает старые сообщения - Сохраняет позицию скролла ### 5. ✅ Real-time обновления - Новые сообщения → `new_message` - Редактирование → `message_updated` - Удаление → `message_deleted` - Реакции → `reaction_added/removed` - Голосования → `poll_update` - Индикатор печати → `typing_start/stop` --- ## 🛠️ Порядок инициализации ``` 1. HTTP Request → ChatDetailView 2. Render HTML Template 3. DOM Ready 4. Load JS Modules (chatDetail.js, components/*) 5. Read Config from data-attributes 6. Wait for WebSocket Ready (user:ws-ready event) 7. Initialize Components: ├─ FormManager ├─ MarkRead ├─ Composer ├─ HistoryLoader └─ MessageRenderer 8. Configure WebSocket with components 9. Open Chat: userWs.openChat(chatId, true) 10. WebSocket → Backend: { action: 'open_chat' } 11. Backend → WebSocket: { type: 'initial_messages' } 12. Render Messages → DOM 13. Remove #initialLoader 14. Scroll to Bottom 15. Initialize Polls 16. Ready! ✓ ``` --- ## 📝 Итоговая схема ``` ┌──────────────────────────────────────────────────────────────────────┐ │ СТРАНИЦА ЧАТА │ ├──────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 1. DJANGO VIEW (Backend) │ │ │ │ - Проверка доступа │ │ │ │ - Подготовка контекста (без messages) │ │ │ │ - Рендеринг HTML шаблона │ │ │ └────────────────────────┬────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 2. HTML TEMPLATE │ │ │ │ - Пустой контейнер #chatScroll │ │ │ │ - Индикатор загрузки #initialLoader │ │ │ │ - Data-атрибуты с конфигом │ │ │ │ - Форма композера #chatForm │ │ │ └────────────────────────┬────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 3. JAVASCRIPT INIT (chatDetail.js) │ │ │ │ - Загрузка модулей │ │ │ │ - Ожидание WebSocket │ │ │ │ - Инициализация компонентов │ │ │ └────────────────────────┬────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 4. WEBSOCKET CONNECTION (userWebSocket.js) │ │ │ │ - openChat(chatId, loadHistory=true) │ │ │ │ - Backend → initial_messages │ │ │ └────────────────────────┬────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 5. MESSAGE RENDERING (messageRenderer.js) │ │ │ │ - renderMessages(messages) │ │ │ │ - Разделители дней │ │ │ │ - Проверка дубликатов │ │ │ │ - Вставка в DOM │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 6. REAL-TIME UPDATES │ │ │ │ - new_message → добавить │ │ │ │ - message_updated → обновить │ │ │ │ - message_deleted → удалить │ │ │ │ - reaction_added/removed → обновить реакции │ │ │ │ - poll_update → обновить голосование │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────────────────────────┘ ``` --- ## 📚 Связанные документы - `CHAT_RENDERING_ANALYSIS.md` - Подробный анализ архитектуры рендеринга - `WEBSOCKET_UNIFIED_MIGRATION.md` - Миграция на единое WebSocket соединение - `CHAT_CACHING_FIX.md` - Отключение кэширования страницы чата - `MESSAGE_EDITING_ANALYSIS.md` - Механизм редактирования сообщений - `POLLS_IMPLEMENTATION.md` - Реализация голосований --- **Документ обновлён:** 4 декабря 2025 г.