Рефакторинг рендеринга сообщений и голосований: объединение логики, исправление whitespace, обновление SCSS и CSS
This commit is contained in:
parent
00deb794bb
commit
08cdf2badd
827
CHAT_PAGE_RENDERING_ARCHITECTURE.md
Normal file
827
CHAT_PAGE_RENDERING_ARCHITECTURE.md
Normal 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
198
MESSAGE_NEWLINES_FIX.md
Normal 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. ✅ Работает для всех типов сообщений (свои и чужие)
|
||||
|
||||
---
|
||||
|
||||
**Статус:** ✅ Исправлено
|
||||
**Тестирование:** Требуется проверка в браузере
|
||||
272
MESSAGE_RENDERING_REFACTORING.md
Normal file
272
MESSAGE_RENDERING_REFACTORING.md
Normal 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 г.
|
||||
2
backend/static/css/bootstrap-custom.css
vendored
2
backend/static/css/bootstrap-custom.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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="${reply.id}"]'); 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;
|
||||
|
||||
@ -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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user