- Создана структура 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 приложения - Вся документация организована в структурированные папки
24 KiB
Анализ архитектуры рендеринга страницы чата
Обзор текущей архитектуры
Страница чата состоит из серверного (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
- ❌ Разбросанное состояние
- ❌ Нет компонентного подхода
- ❌ Сложность поддержки
Приоритеты рефакторинга:
- Унифицировать рендеринг (только клиент)
- Внедрить компонентный фреймворк
- Централизовать состояние
- Оптимизировать виртуальный скролл
- Добавить TypeScript