Рефакторинг рендеринга сообщений и голосований: объединение логики, исправление whitespace, обновление SCSS и CSS

This commit is contained in:
admin i 2025-12-04 15:35:08 +03:00
parent 00deb794bb
commit 08cdf2badd
9 changed files with 1380 additions and 275 deletions

View File

@ -0,0 +1,827 @@
# Архитектура рендеринга и наполнения страницы чата
## 📋 Оглавление
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
<div class="container chat-root" id="chatDetailApp" data-chat-id="..." data-user-id="...">
<div class="row gx-0">
<div class="col-12 chat-col">
<!-- 1. Заголовок чата -->
<div class="section-header">
<!-- Иконка, название, кнопки управления -->
</div>
<!-- 2. Корпус чата -->
<article class="card chat-box">
<!-- 2.1 Предупреждения (блокировка, etc) -->
<div class="alert">...</div>
<!-- 2.2 Кнопка "вниз" -->
<button id="scrollBtn" class="scroll-to-bottom">...</button>
<!-- 2.3 Контейнер сообщений -->
<div id="chatScroll" class="chat-scroll"
data-me-id="..."
data-chat-id="..."
data-fetch-url="..."
data-oldest-id="..."
data-has-more="...">
<!-- Индикатор загрузки истории (при скролле вверх) -->
<div class="history-loader d-none">...</div>
<!-- Индикатор первоначальной загрузки -->
<div class="initial-loader" id="initialLoader">
<div class="spinner-border">...</div>
<p>Загружаем сообщения...</p>
</div>
<!-- Сообщения добавляются через JavaScript -->
</div>
<!-- 2.4 Индикатор "печатает..." -->
<div id="typing" class="d-none">...</div>
<!-- 2.5 Композер (форма ввода) -->
<div class="chat-composer">
<form id="chatForm">
<!-- Кнопки вложений, эмодзи -->
<!-- Поле ввода textarea -->
<!-- Кнопка отправки -->
</form>
</div>
</article>
</div>
</div>
</div>
```
#### Data-атрибуты контейнера:
```html
<div id="chatDetailApp"
data-chat-id="{{ chat.id }}"
data-user-id="{{ user.id }}"
data-user-name="{{ user.get_full_name }}"
data-user-avatar="{{ user.avatar.url }}"
data-upload-url="{% url 'api:v1:upload_message' %}"
data-messages-url="{% url 'api:v1:chat_messages' chat.pk %}"
data-mark-read-url="{% url 'communications:chat_mark_read' chat.pk %}"
data-edit-url-template="/api/v1/communications/messages/{id}/edit/">
```
**Важно:** Сообщения из контекста `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
<div class="d-flex mb-3 msg"
data-id="123"
data-message-id="123"
data-ts="1701734400000"
data-author-id="5"
data-is-edited="false"
data-reactions='{"👍": 2, "❤️": 1}'>
<!-- Аватар (только для чужих сообщений слева) -->
<a class="me-2" href="/employees/5/">
<span class="mini-ava border">
<img src="/media/avatars/user.jpg" alt="">
</span>
</a>
<div class="d-flex flex-column">
<!-- Имя и время -->
<div class="small text-secondary">
<a href="/employees/5/">Иван Иванов</a> · <time>12:30</time>
</div>
<!-- Bubble с содержимым -->
<div class="bubble bubble-other">
<!-- Индикатор пересылки (если есть) -->
<div class="forwarded-indicator">...</div>
<!-- Ответ на сообщение (если есть) -->
<div class="reply-reference">...</div>
<!-- Текст сообщения -->
Привет, как дела?
<!-- Индикатор редактирования -->
<span class="message-edited-indicator">(изменено)</span>
<!-- Голосование (если есть) -->
<div class="poll-widget">...</div>
<!-- Вложения (если есть) -->
<div class="message-attachments">...</div>
</div>
<!-- Реакции -->
<div class="message-reactions-wrapper">...</div>
</div>
<!-- Аватар справа для своих сообщений -->
<a class="ms-2" href="/employees/profile/">...</a>
</div>
```
---
## 🔄 Поток данных
### 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 г.

198
MESSAGE_NEWLINES_FIX.md Normal file
View File

@ -0,0 +1,198 @@
# Исправление: Поддержка переносов строк в сообщениях
**Дата:** 4 декабря 2025 г.
**Проблема:** Сообщения не отображали переносы строк (многострочный текст показывался одной строкой)
---
## 🔴 Проблема
При отправке многострочных сообщений в чате (с нажатием Enter для переноса строки), текст отображался одной строкой, игнорируя все переносы.
### Пример:
**Ввод пользователя:**
```
Строка 1
Строка 2
Строка 3
```
**Отображалось как:**
```
Строка 1 Строка 2 Строка 3
```
---
## 🔍 Анализ
### 1. Рендеринг сообщений (`messageRenderer.js`)
```javascript
// Строка 210
${msg.content ? `<div>${this.escapeHtml(msg.content)}</div>` : ''}
// Метод escapeHtml (строка 318)
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
```
Метод `escapeHtml()` корректно экранирует HTML, но переносы строк (`\n`) остаются в виде символов, которые браузер игнорирует в обычном режиме отображения.
### 2. CSS стили bubble (`chat-detail.css`)
```css
.bubble {
border-radius: 16px;
padding: var(--space-1) var(--space-2);
word-break: break-word;
/* white-space не указан - по умолчанию: normal */
box-shadow: 0 1px 2px rgba(0, 0, 0, .06);
}
```
По умолчанию `white-space: normal` схлопывает последовательные пробелы и переносы строк в один пробел.
---
## ✅ Решение
Добавлено CSS свойство `white-space: pre-wrap` для класса `.bubble`:
```css
.bubble {
border-radius: 16px;
padding: var(--space-1) var(--space-2);
word-break: break-word;
white-space: pre-wrap; /* ← ДОБАВЛЕНО */
box-shadow: 0 1px 2px rgba(0, 0, 0, .06);
}
```
### Что делает `white-space: pre-wrap`:
| Свойство | Переносы строк | Пробелы | Перенос текста |
|----------|---------------|---------|----------------|
| `normal` (по умолчанию) | ❌ Игнорируются | ❌ Схлопываются | ✅ Есть |
| `pre` | ✅ Сохраняются | ✅ Сохраняются | ❌ Нет |
| **`pre-wrap`** | **✅ Сохраняются** | **✅ Сохраняются** | **✅ Есть** |
| `pre-line` | ✅ Сохраняются | ❌ Схлопываются | ✅ Есть |
`pre-wrap` — идеальный вариант для чатов:
- ✅ Сохраняет переносы строк
- ✅ Сохраняет множественные пробелы (для форматирования)
- ✅ Автоматически переносит длинные строки (не вылезает за пределы)
---
## 🎨 Результат
### До исправления:
```
Привет! Как дела? Давно не виделись!
```
### После исправления:
```
Привет!
Как дела?
Давно не виделись!
```
---
## 🧪 Тестирование
### Примеры для тестирования:
1. **Простой перенос:**
```
Строка 1
Строка 2
```
2. **Множественные переносы:**
```
Строка 1
Строка 2 (после двух пустых строк)
```
3. **Длинная строка с переносом:**
```
Очень длинная строка текста, которая должна автоматически перенестись на новую строку если не помещается в ширину bubble
```
4. **Форматированный текст:**
```
Список покупок:
- Молоко
- Хлеб
- Масло
```
5. **Код или табуляция:**
```
function hello() {
console.log("Hello!");
}
```
---
## 📁 Изменённые файлы
- ✅ `backend/static/css/components/chat-detail.css` - добавлен `white-space: pre-wrap` для `.bubble`
---
## 🔄 Альтернативные решения (не использовались)
### Вариант 2: Конвертация `\n` в `<br>` в JavaScript
```javascript
// В messageRenderer.js
formatContent(text) {
return this.escapeHtml(text).replace(/\n/g, '<br>');
}
// Использование:
${msg.content ? `<div>${this.formatContent(msg.content)}</div>` : ''}
```
**Недостатки:**
- ❌ Не сохраняет множественные пробелы
- ❌ Требует изменения JS кода
- ❌ Менее гибко для будущих доработок
### Вариант 3: CSS `white-space: pre-line`
```css
.bubble {
white-space: pre-line;
}
```
**Недостатки:**
- ❌ Не сохраняет множественные пробелы (схлопывает в один)
- ❌ Менее удобно для форматированного текста
---
## 🎯 Выводы
1. ✅ **CSS решение** (`white-space: pre-wrap`) — наиболее правильное и простое
2. ✅ Не требует изменений в JavaScript
3. ✅ Поддерживает все виды форматирования текста
4. ✅ Совместимо с существующей логикой экранирования HTML
5. ✅ Работает для всех типов сообщений (свои и чужие)
---
**Статус:** ✅ Исправлено
**Тестирование:** Требуется проверка в браузере

View File

@ -0,0 +1,272 @@
# Рефакторинг системы рендеринга сообщений
## Дата: 4 декабря 2025 г.
## Проблема
Система рендеринга сообщений имела дублирование кода:
- `messageRenderer.js` - рендерил новые сообщения через WebSocket
- `chatMessageTemplates.js` - рендерил старые сообщения при скролле вверх
Оба файла содержали:
1. **Многострочные HTML-шаблоны** с переносами строк и отступами
2. **Дублирующуюся логику** для формирования HTML
3. **Разные подходы** к обработке данных
### Симптомы
- Пилюли сообщений растягивались по высоте из-за whitespace в HTML
- `white-space: pre-line` сохранял переносы из исходного кода JavaScript
- Различное отображение сообщений в зависимости от способа загрузки
## Решение
### 1. Единый источник правды
**messageRenderer.js** стал единственным местом генерации HTML:
```javascript
export class MessageRenderer {
// Полный HTML с обёрткой .msg для WebSocket
buildMessageHtml(msg, isOwn) {
return `<div class="d-flex mb-3 msg...">${this.buildMessageInnerHtml(msg, isOwn)}</div>`;
}
// Внутренний HTML без обёртки для DOM-элементов
buildMessageInnerHtml(msg, isOwn) {
// Все шаблоны в одну строку без переносов
return `${avatarHtml}<div class="d-flex flex-column">...</div>${rightAvatarHtml}`;
}
}
```
### 2. Обёртка в chatMessageTemplates.js
**chatMessageTemplates.js** теперь использует MessageRenderer:
```javascript
import { MessageRenderer } from './messageRenderer.js';
export function createMessageElement(msg, options = {}) {
const renderer = new MessageRenderer({
meId: options.meId,
profileUrl: options.profileUrl,
detailUrlTemplate: options.detailUrlTemplate,
currentUserAvatar: options.avatarMap[options.meId]
});
const wrap = document.createElement('div');
// Настройка атрибутов wrap...
// Используем единый рендерер
wrap.innerHTML = renderer.buildMessageInnerHtml(enrichedMsg, mine);
return wrap;
}
```
### 3. Устранение многострочных шаблонов
**ДО:**
```javascript
const forwardedHtml = msg.is_forwarded ? `
<div class="forwarded-indicator small mb-2">
<i class="bi-arrow-90deg-right me-2"></i>
<div>
<div>Переслано от <strong>${author}</strong></div>
</div>
</div>` : '';
```
**ПОСЛЕ:**
```javascript
const forwardedHtml = msg.is_forwarded ? `<div class="forwarded-indicator small mb-2 d-flex align-items-center"><i class="bi-arrow-90deg-right me-2"></i><div><div>Переслано от <strong>${msg.forwarded_from.author_name || 'Пользователя'}</strong>${msg.forwarded_from.chat_name ? ` из «${msg.forwarded_from.chat_name}»` : ''}</div>${msg.forwarded_from.created_at ? `<div class="small opacity-75">${msg.forwarded_from.created_at}</div>` : ''}</div></div>` : '';
```
### 4. Рефакторинг всех компонентов
Применено ко всем HTML-генераторам:
- ✅ `forwardedHtml` - информация о пересылке
- ✅ `replyHtml` - цитата ответа
- ✅ `avatarHtml` - аватары пользователей
- ✅ `rightAvatarHtml` - аватар справа (свои сообщения)
- ✅ `attachmentsHtml` - вложения (изображения, файлы)
- ✅ `pollHtml` - голосования/опросы
- ✅ `bubble` - основной контейнер сообщения
## Архитектура после рефакторинга
```
┌──────────────────────────────────────────────────┐
│ messageRenderer.js │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ buildMessageHtml(msg, isOwn) │ │
│ │ → Полный HTML с обёрткой .msg │ │
│ │ → Используется для WebSocket │ │
│ └─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ buildMessageInnerHtml(msg, isOwn) │◄───┼─── Единый источник
│ │ → HTML без обёртки .msg │ │ правды для всех
│ │ → Все шаблоны в одну строку │ │ HTML-шаблонов
│ │ → Нет whitespace из исходного кода │ │
│ └─────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ buildAttachmentHtml buildPollHtml ... │
└──────────────────────────────────────────────────┘
▲
│
│ import & использование
│
┌──────────────────────────────────────────────────┐
│ chatMessageTemplates.js │
│ │
│ createMessageElement(msg, options) │
│ → Создаёт DOM элемент │
│ → Использует MessageRenderer.buildMessageInnerHtml │
│ → Добавляет pending статус если нужно │
└──────────────────────────────────────────────────┘
▲
│
│ использование
│
┌──────────────────────────────────────────────────┐
│ chatHistoryLoader.js │
│ │
│ prependMessages() → createMessageElement() │
│ → Загрузка старых сообщений при скролле вверх │
└──────────────────────────────────────────────────┘
```
## Поток данных
### WebSocket (новые сообщения)
```
WebSocket event
↓
chatDetail.js
↓
messageRenderer.buildMessageHtml()
↓
insertAdjacentHTML()
↓
DOM
```
### HTTP + Скролл (старые сообщения)
```
Scroll вверх
↓
chatHistoryLoader.loadMore()
↓
fetch(/api/messages?before_id=...)
↓
chatMessageTemplates.createMessageElement()
↓
→ MessageRenderer.buildMessageInnerHtml()
↓
DOM element
↓
insertBefore()
```
## Преимущества рефакторинга
### ✅ Единообразие
- Одинаковый HTML независимо от способа загрузки
- Нет расхождений между WebSocket и HTTP
- Единая логика обработки всех компонентов (ответы, вложения, опросы)
### ✅ Производительность
- Нет лишних whitespace в HTML
- Компактные пилюли сообщений
- Правильная работа `white-space: pre-line` (сохраняет только `\n` из контента)
### ✅ Поддерживаемость
- DRY (Don't Repeat Yourself)
- Одно место для изменений
- Легче тестировать и дебажить
### ✅ Расширяемость
- Новые типы вложений добавляются в одном месте
- Новые поля сообщений обрабатываются единообразно
## CSS изменения
### _chat-detail.scss
```scss
.bubble {
border-radius: 16px;
padding: var(--space-1) var(--space-2);
word-break: break-word;
white-space: pre-line; /* Сохраняет \n из контента, схлопывает пробелы из HTML */
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
> div {
white-space: inherit; /* Наследование для вложенных элементов */
}
}
```
## Тестирование
После рефакторинга проверить:
1. ✅ Переносы строк (`\n`) отображаются корректно
2. ✅ Высота пилюль соответствует контенту (без растягивания)
3. ✅ Сообщения с 1 символом компактные
4. ✅ Старые сообщения (скролл вверх) выглядят так же как новые (WebSocket)
5. ✅ Сообщения с вложениями, опросами, ответами не имеют лишних отступов
6. ✅ Аватары отображаются корректно
7. ✅ Реакции работают
8. ✅ Редактирование сообщений работает
## Совместимость
### Обратная совместимость
- ✅ Все существующие функции сохранены
- ✅ API не изменился
- ✅ DOM-структура осталась прежней
- ✅ CSS-классы не изменились
### Зависимости
```javascript
// messageRenderer.js
export class MessageRenderer { ... }
export function createMessageRenderer(config) { ... }
// chatMessageTemplates.js
import { MessageRenderer } from './messageRenderer.js';
import { esc } from '../utils/stringUtils.js';
// chatHistoryLoader.js
import { createMessageElement } from './chatMessageTemplates.js';
```
## Следующие шаги
1. **Тестирование** - проверить все сценарии использования
2. **Мониторинг** - отследить производительность в production
3. **Документация** - обновить комментарии в коде
4. **Оптимизация** - возможно кеширование MessageRenderer
5. **Расширение** - добавить поддержку новых типов контента
## Файлы изменены
- ✅ `backend/static/js/components/messageRenderer.js`
- Добавлен метод `buildMessageInnerHtml()`
- Все шаблоны переведены в одну строку
- Добавлена поддержка параметра `meId`
- ✅ `backend/static/js/components/chatMessageTemplates.js`
- Переписан `createMessageElement()` для использования MessageRenderer
- Удалён дублирующийся код генерации HTML
- Сохранена обратная совместимость API
- ✅ `backend/static/scss/components/_chat-detail.scss`
- Изменён `white-space: pre-wrap` → `pre-line`
- Добавлено правило наследования для вложенных `div`
## Авторы
- Рефакторинг: GitHub Copilot + Igor
- Дата: 4 декабря 2025 г.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -398,6 +398,7 @@ main.main {
border-radius: 16px;
padding: var(--space-1) var(--space-2);
word-break: break-word;
white-space: pre-wrap; /* Сохраняет переносы строк и пробелы */
box-shadow: 0 1px 2px rgba(0, 0, 0, .06);
}

View File

@ -1,9 +1,12 @@
/**
* @fileoverview Chat Message Templates - HTML шаблоны для рендеринга сообщений чата
* @module components/chatMessageTemplates
*
* РЕФАКТОРИНГ: Теперь использует MessageRenderer как единый источник правды
*/
import { esc } from '../utils/stringUtils.js';
import { MessageRenderer } from './messageRenderer.js';
/**
* Дополняет число нулём слева
@ -128,10 +131,23 @@ export function createMessageElement(msg, options = {}) {
avatarMap = {}
} = options;
// Используем MessageRenderer для единообразия
const renderer = new MessageRenderer({
meId,
profileUrl,
detailUrlTemplate,
currentUserAvatar: avatarMap[meId] || ''
});
// Обогащаем сообщение аватаром из карты
const enrichedMsg = {
...msg,
avatar: msg.avatar || avatarMap[msg.author_id] || ''
};
const mine = Number(msg.author_id) === Number(meId);
const isPending = Boolean(msg.is_pending);
const ts = toTimestamp(msg);
// Создание обёртки
const wrap = document.createElement('div');
wrap.className = `d-flex mb-3 msg ${mine ? 'justify-content-end' : 'justify-content-start'}`;
@ -142,182 +158,35 @@ export function createMessageElement(msg, options = {}) {
if (msg.id != null) {
wrap.setAttribute('data-id', String(msg.id));
wrap.setAttribute('data-message-id', String(msg.id));
// Добавляем реакции если они есть
const reactionsData = msg.reactions_summary || {};
wrap.setAttribute('data-reactions', JSON.stringify(reactionsData));
}
const ts = toTimestamp(msg);
wrap.setAttribute('data-ts', String(ts));
wrap.setAttribute('data-author-id', String(msg.author_id || ''));
wrap.setAttribute('data-is-edited', String(msg.is_edited || false));
wrap.setAttribute('data-edited-at', String(msg.edited_at || ''));
// Формирование контента
const who = nameHTML(
msg.author_id,
msg.author_name || msg.author,
msg.author_url,
meId,
profileUrl,
detailUrlTemplate
);
const time = formatTime(ts);
const text = esc(msg.content || '').replace(/\n/g, '<br>');
// Используем buildMessageInnerHtml из MessageRenderer для единообразного рендеринга
const htmlContent = renderer.buildMessageInnerHtml(enrichedMsg, mine);
// Формирование информации о пересылке
let forwardedHTML = '';
if (msg.is_forwarded && msg.forwarded_from) {
const fwd = msg.forwarded_from;
const fwdAuthor = esc(fwd.author_name || 'Неизвестный');
const fwdTime = fwd.created_at ? fwd.created_at : '';
const fwdChat = fwd.chat_name ? ` из «${esc(fwd.chat_name)}»` : '';
// Добавляем статус отправки для pending сообщений
if (isPending) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
forwardedHTML = `
<div class="forwarded-indicator small mb-2 d-flex align-items-center">
<i class="bi-arrow-90deg-right me-2"></i>
<div>
<div>Переслано от <strong>${fwdAuthor}</strong>${fwdChat}</div>
${fwdTime ? `<div class="small opacity-75">${fwdTime}</div>` : ''}
</div>
</div>`;
}
// Формирование информации об ответе
let replyHTML = '';
if (msg.reply_to) {
const reply = msg.reply_to;
const replyAuthor = esc(reply.author_name || 'Неизвестный');
const replyContent = esc(reply.content || '');
const replyPreview = replyContent.substring(0, 50);
const replyFull = replyContent.length > 50 ? replyPreview + '...' : replyPreview;
const bubble = tempDiv.querySelector('.bubble');
if (bubble) {
const pendingStatus = document.createElement('div');
pendingStatus.className = 'message-status small text-secondary mt-2';
pendingStatus.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Отправляем…';
bubble.appendChild(pendingStatus);
}
replyHTML = `
<div class="reply-indicator small mb-2" style="
padding: 8px 12px;
background: rgba(0, 0, 0, 0.05);
border-left: 3px solid #007bff;
border-radius: 4px;
cursor: pointer;"
data-reply-to-id="${reply.id}"
onclick="event.stopPropagation(); const el = document.querySelector('[data-message-id=&quot;${reply.id}&quot;]'); if (el) el.scrollIntoView({behavior: 'smooth', block: 'center'});">
<div style="display: flex; align-items: center;">
<i class="bi-reply me-2"></i>
<div>
<div style="font-weight: 600; color: #007bff;">${replyAuthor}</div>
<div style="opacity: 0.75;">${replyFull}</div>
</div>
</div>
</div>`;
}
// Формирование вложений
let attachmentsHTML = '';
if (msg.has_attachments && msg.attachments && msg.attachments.length > 0) {
attachmentsHTML = '<div class="message-attachments mt-2">';
msg.attachments.forEach((att, index) => {
attachmentsHTML += '<div class="attachment-item mb-2">';
if (att.file_type === 'image') {
attachmentsHTML += `
<a href="${att.file_url}" target="_blank">
<img src="${att.file_url}"
alt="${esc(att.file_name)}"
loading="lazy"
class="chat-media chat-media--image rounded">
</a>`;
} else if (att.file_type === 'audio') {
attachmentsHTML += `
<audio controls class="w-100">
<source src="${att.file_url}" type="${att.mime_type}">
</audio>
<div class="small text-secondary">${esc(att.file_name)}</div>`;
} else if (att.file_type === 'video') {
attachmentsHTML += `
<video controls preload="metadata" playsinline class="chat-media chat-media--video">
<source src="${att.file_url}" type="${att.mime_type || 'video/mp4'}">
Ваш браузер не поддерживает видео.
</video>
<div class="small text-secondary">${esc(att.file_name)}</div>`;
} else {
const fileSize = att.file_size ? formatFileSize(att.file_size) : '';
attachmentsHTML += `
<a href="${att.file_url}"
class="d-flex align-items-center text-decoration-none p-2 rounded"
download="${esc(att.file_name)}">
<i class="bi-file-earmark fs-3 me-2"></i>
<div>
<div class="fw-semibold">${esc(att.file_name)}</div>
<div class="small text-secondary">${fileSize}</div>
</div>
</a>`;
}
attachmentsHTML += '</div>';
});
attachmentsHTML += '</div>';
}
const pendingStatus = isPending
? '<div class="message-status small text-secondary mt-2"><span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Отправляем…</div>'
: '';
// Рендер голосования
let pollHTML = '';
if (msg.poll) {
const poll = msg.poll;
pollHTML += `
<div class="poll-widget mt-2" data-poll-id="${poll.id}">
<div class="poll-question mb-3">
<strong>${esc(poll.question)}</strong>
</div>
<div class="poll-options">`;
poll.options.forEach(option => {
pollHTML += `
<div class="poll-option mb-2" data-option-id="${option.id}">
<button type="button" class="btn btn-outline-secondary btn-poll-option w-100 text-start"
data-option-id="${option.id}">
${esc(option.text)}
</button>
</div>`;
});
pollHTML += `
</div>
<div class="poll-footer mt-3 d-flex justify-content-between align-items-center">
<div class="small text-muted">
${poll.total_voters || 0} проголосовало
${poll.is_anonymous ? ' • Анонимное' : ''}
${poll.is_multiple_choice ? ' • Множественный выбор' : ''}
${poll.is_closed ? ' • Закрыто' : ''}
</div>
</div>
</div>`;
}
const bubble = `
<div class="d-flex flex-column" style="max-width:80%;">
<div class="small text-secondary ${mine ? 'text-end' : ''}">
${who} · ${time}
</div>
<div class="mt-1 bubble ${mine ? 'bubble-me' : 'bubble-other'}">
${replyHTML}
${forwardedHTML}
${text ? text : ''}
${pollHTML}
${attachmentsHTML}
${pendingStatus}
</div>
<div class="message-reactions-wrapper mt-1"></div>
</div>`;
// Аватар
const avaUrl = msg.avatar || avatarMap[msg.author_id] || "";
const ava = avatarHTML(avaUrl);
// Финальная сборка
if (!mine) {
const link = msg.author_url || detailUrlTemplate.replace('/0/', `/${msg.author_id}/`);
wrap.innerHTML = `<a class="me-2 text-decoration-none" href="${link}">${ava}</a>${bubble}`;
wrap.innerHTML = tempDiv.innerHTML;
} else {
wrap.innerHTML = `${bubble}<a class="ms-2 text-decoration-none" href="${profileUrl}">${ava}</a>`;
wrap.innerHTML = htmlContent;
}
return wrap;

View File

@ -6,7 +6,7 @@
export class MessageRenderer {
constructor(config = {}) {
this.containerId = config.containerId || 'chatScroll';
this.currentUserId = config.currentUserId;
this.currentUserId = config.currentUserId || config.meId; // Поддержка обоих параметров
this.currentUserAvatar = config.currentUserAvatar || '';
this.profileUrl = config.profileUrl || '/employees/profile/';
this.detailUrlTemplate = config.detailUrlTemplate || '/employees/detail/0/';
@ -124,6 +124,24 @@ export class MessageRenderer {
* Строит HTML одного сообщения
*/
buildMessageHtml(msg, isOwn) {
return `
<div class="d-flex mb-3 msg ${isOwn ? 'justify-content-end' : 'justify-content-start'}"
data-id="${msg.id}"
data-message-id="${msg.id}"
data-ts="${msg.created_ts}"
data-author-id="${msg.author_id}"
data-is-edited="${msg.is_edited || false}"
data-edited-at="${msg.edited_at || ''}"
data-reactions='${JSON.stringify(msg.reactions_summary || {})}'>
${this.buildMessageInnerHtml(msg, isOwn)}
</div>`;
}
/**
* Строит внутренний HTML сообщения (без внешней обёртки .msg)
* Используется в chatMessageTemplates.js для создания DOM элементов
*/
buildMessageInnerHtml(msg, isOwn) {
const authorUrl = isOwn
? this.profileUrl
: this.detailUrlTemplate.replace('/0/', `/${msg.author_id}/`);
@ -132,32 +150,11 @@ export class MessageRenderer {
? 'Вы'
: (msg.author_name || 'Пользователь');
const avatarHtml = isOwn ? '' : `
<a class="me-2 text-decoration-none" href="${authorUrl}">
<span class="mini-ava border">
${msg.avatar
? `<img src="${msg.avatar}" alt="" loading="lazy">`
: '<i class="bi-person"></i>'}
</span>
</a>`;
const avatarHtml = isOwn ? '' : `<a class="me-2 text-decoration-none" href="${authorUrl}"><span class="mini-ava border">${msg.avatar ? `<img src="${msg.avatar}" alt="" loading="lazy">` : '<i class="bi-person"></i>'}</span></a>`;
const forwardedHtml = msg.is_forwarded && msg.forwarded_from ? `
<div class="forwarded-indicator small mb-2 d-flex align-items-center">
<i class="bi-arrow-90deg-right me-2"></i>
<div>
<div>Переслано от <strong>${msg.forwarded_from.author_name || 'Пользователя'}</strong>
${msg.forwarded_from.chat_name ? ` из «${msg.forwarded_from.chat_name}»` : ''}</div>
${msg.forwarded_from.created_at ? `<div class="small opacity-75">${msg.forwarded_from.created_at}</div>` : ''}
</div>
</div>` : '';
const forwardedHtml = msg.is_forwarded && msg.forwarded_from ? `<div class="forwarded-indicator small mb-2 d-flex align-items-center"><i class="bi-arrow-90deg-right me-2"></i><div><div>Переслано от <strong>${msg.forwarded_from.author_name || 'Пользователя'}</strong>${msg.forwarded_from.chat_name ? ` из «${msg.forwarded_from.chat_name}»` : ''}</div>${msg.forwarded_from.created_at ? `<div class="small opacity-75">${msg.forwarded_from.created_at}</div>` : ''}</div></div>` : '';
const replyHtml = msg.reply_to ? `
<div class="reply-reference small mb-2"
style="border-left:3px solid var(--bs-primary);padding-left:8px;opacity:0.8;cursor:pointer;"
onclick="document.querySelector('[data-message-id=\\\"${msg.reply_to.id}\\\"]')?.scrollIntoView({behavior:'smooth',block:'center'})">
<div class="fw-semibold">${msg.reply_to.author_name || 'Пользователь'}</div>
<div class="text-truncate">${this.escapeHtml(msg.reply_to.content || '[файл]')}</div>
</div>` : '';
const replyHtml = msg.reply_to ? `<div class="reply-reference small mb-2" style="border-left:3px solid var(--bs-primary);padding-left:8px;opacity:0.8;cursor:pointer;" onclick="document.querySelector('[data-message-id=\\\"${msg.reply_to.id}\\\"]')?.scrollIntoView({behavior:'smooth',block:'center'})"><div class="fw-semibold">${msg.reply_to.author_name || 'Пользователь'}</div><div class="text-truncate">${this.escapeHtml(msg.reply_to.content || '[файл]')}</div></div>` : '';
const attachmentsHtml = msg.attachments?.length ?
msg.attachments.map(att => this.buildAttachmentHtml(att)).join('') : '';
@ -175,43 +172,9 @@ export class MessageRenderer {
const timeHtml = this.formatTime(msg.created_ts);
// Аватар справа для своих сообщений
const rightAvatarHtml = isOwn ? `
<a class="ms-2 text-decoration-none" href="${this.profileUrl}">
<span class="mini-ava border">
${this.currentUserAvatar
? `<img src="${this.currentUserAvatar}" alt="" loading="lazy">`
: '<i class="bi-person"></i>'}
</span>
</a>` : '';
const rightAvatarHtml = isOwn ? `<a class="ms-2 text-decoration-none" href="${this.profileUrl}"><span class="mini-ava border">${this.currentUserAvatar ? `<img src="${this.currentUserAvatar}" alt="" loading="lazy">` : '<i class="bi-person"></i>'}</span></a>` : '';
return `
<div class="d-flex mb-3 msg ${isOwn ? 'justify-content-end' : 'justify-content-start'}"
data-id="${msg.id}"
data-message-id="${msg.id}"
data-ts="${msg.created_ts}"
data-author-id="${msg.author_id}"
data-is-edited="${msg.is_edited || false}"
data-edited-at="${msg.edited_at || ''}"
data-reactions='${JSON.stringify(msg.reactions_summary || {})}'>
${avatarHtml}
<div class="d-flex flex-column" style="max-width:80%;">
<div class="small text-secondary ${isOwn ? 'text-end' : ''}">
<a href="${authorUrl}" class="text-decoration-none ${isOwn ? '' : 'fw-semibold'}">${authorName}</a>
· <time datetime="${new Date(msg.created_ts).toISOString()}">${timeHtml}</time>
${editedIndicator}
</div>
<div class="mt-1 bubble ${isOwn ? 'bubble-me' : 'bubble-other'}">
${forwardedHtml}
${replyHtml}
${msg.content ? `<div>${this.escapeHtml(msg.content)}</div>` : ''}
${attachmentsHtml}
${pollHtml}
</div>
<div class="message-reactions-wrapper mt-1">
</div>
</div>
${rightAvatarHtml}
</div>`;
return `${avatarHtml}<div class="d-flex flex-column" style="max-width:80%;"><div class="small text-secondary ${isOwn ? 'text-end' : ''}"><a href="${authorUrl}" class="text-decoration-none ${isOwn ? '' : 'fw-semibold'}">${authorName}</a> · <time datetime="${new Date(msg.created_ts).toISOString()}">${timeHtml}</time>${editedIndicator}</div><div class="mt-1 bubble ${isOwn ? 'bubble-me' : 'bubble-other'}">${forwardedHtml}${replyHtml}${msg.content ? this.escapeHtml(msg.content) : ''}${attachmentsHtml}${pollHtml}</div><div class="message-reactions-wrapper mt-1"></div></div>${rightAvatarHtml}`;
}
/**
@ -222,28 +185,10 @@ export class MessageRenderer {
const isImage = attachment.file_url?.match(/\.(jpg|jpeg|png|gif|webp)$/i);
if (isImage) {
return `
<div class="attachment-item mb-2">
<a href="${attachment.file_url}" target="_blank">
<img src="${attachment.file_url}"
alt="${this.escapeHtml(fileName)}"
class="img-fluid rounded"
style="max-width:100%;max-height:300px;"
loading="lazy">
</a>
</div>`;
return `<div class="attachment-item mb-2"><a href="${attachment.file_url}" target="_blank"><img src="${attachment.file_url}" alt="${this.escapeHtml(fileName)}" class="img-fluid rounded" style="max-width:100%;max-height:300px;" loading="lazy"></a></div>`;
}
return `
<div class="attachment-item mb-2">
<a href="${attachment.file_url}"
target="_blank"
class="d-flex align-items-center gap-2 text-decoration-none rounded-3 px-3 py-2"
title="${this.escapeHtml(fileName)}">
<i class="bi-file-earmark"></i>
<span class="text-truncate fw-semibold" style="max-width:200px;">${this.escapeHtml(fileName)}</span>
</a>
</div>`;
return `<div class="attachment-item mb-2"><a href="${attachment.file_url}" target="_blank" class="d-flex align-items-center gap-2 text-decoration-none rounded-3 px-3 py-2" title="${this.escapeHtml(fileName)}"><i class="bi-file-earmark"></i><span class="text-truncate fw-semibold" style="max-width:200px;">${this.escapeHtml(fileName)}</span></a></div>`;
}
/**
@ -256,30 +201,8 @@ export class MessageRenderer {
const isClosed = poll.is_closed || false;
// Базовая структура - детали будут добавлены через ChatPoll.js
return `
<div class="poll-widget mt-2" data-poll-id="${poll.id}">
<div class="poll-question mb-3">
<strong>${this.escapeHtml(poll.question)}</strong>
</div>
<div class="poll-options">
${poll.options?.map(option => `
<div class="poll-option mb-2" data-option-id="${option.id}">
<button type="button" class="btn btn-outline-secondary btn-poll-option w-100 text-start"
data-option-id="${option.id}">
${this.escapeHtml(option.text)}
</button>
</div>
`).join('') || ''}
</div>
<div class="poll-footer mt-3 d-flex justify-content-between align-items-center">
<div class="small text-muted">
${totalVotes} проголосовало
${poll.is_anonymous ? ' • Анонимное' : ''}
${poll.is_multiple_choice ? ' • Множественный выбор' : ''}
${isClosed ? ' • Закрыто' : ''}
</div>
</div>
</div>`;
// Убираем лишние margin-классы: mt-2 у poll-widget, mb-3 у poll-question, mt-3 у poll-footer
return `<div class="poll-widget" data-poll-id="${poll.id}"><div class="poll-question mb-2"><strong>${this.escapeHtml(poll.question)}</strong></div><div class="poll-options">${poll.options?.map(option => `<div class="poll-option mb-2" data-option-id="${option.id}"><button type="button" class="btn btn-outline-secondary btn-poll-option w-100 text-start" data-option-id="${option.id}">${this.escapeHtml(option.text)}</button></div>`).join('') || ''}</div><div class="poll-footer mt-2 d-flex justify-content-between align-items-center"><div class="small text-muted">${totalVotes} проголосовало${poll.is_anonymous ? ' • Анонимное' : ''}${poll.is_multiple_choice ? ' • Множественный выбор' : ''}${isClosed ? ' • Закрыто' : ''}</div></div></div>`;
}
/**

View File

@ -417,7 +417,22 @@ main.main {
border-radius: 16px;
padding: var(--space-1) var(--space-2);
word-break: break-word;
white-space: pre-line; /* Сохраняет \n, но схлопывает пробелы из HTML */
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
// Наследование white-space для вложенных элементов
> div {
white-space: inherit;
}
// Специально для голосований - схлопываем whitespace
.poll-widget,
.poll-question,
.poll-options,
.poll-option,
.poll-footer {
white-space: normal;
}
}
.bubble-me {