Игорь Надеин d96d6633f0 chore: Add documentation and supporting files for bidirectional loading
Added:
- Autoscroll audit and reports
- Date divider analysis and Telegram comparison
- Bidirectional loading tests and results
- Date group manager and utilities
- Test scripts and HTML test pages
- CSS for chat date groups
- IntersectionObserver utilities
2026-01-13 15:02:20 +03:00

461 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends "base.html" %}
{% load static %}
{% load api_tags %}
{% block title %}Чат{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
<link rel="stylesheet" href="{% static 'css/components/chat-detail.css' %}">
<link rel="stylesheet" href="{% static 'css/components/chat-date-groups.css' %}">
<link rel="stylesheet" href="{% static 'css/message-reactions.css' %}">
<link rel="stylesheet" href="{% static 'css/message-context-menu.css' %}">
<link rel="stylesheet" href="{% static 'css/message-selection.css' %}">
<link rel="stylesheet" href="{% static 'css/components/chat-polls.css' %}">
<link rel="stylesheet" href="{% static 'css/components/chat-new-messages-indicator.css' %}">
{% endblock %}
{% block content %}
<!-- Чат без лишних контейнеров -->
<div class="chat-page">
<!-- Шапка чата -->
<div class="chat-header">
<div class="chat-header-content">
<div class="chat-header-title">
{% if chat.avatar %}
<!-- Аватар чата с маленькой иконкой типа -->
<div class="chat-avatar-wrapper">
<img src="{{ chat.avatar.url }}" alt="{{ chat.name }}" class="chat-avatar">
<span class="chat-type-badge badge-telegram badge-telegram-avatar animate-in">
{% if chat.type == 'global' %}
<i class="bi-globe"></i>
{% elif chat.type == 'department' %}
<i class="bi-diagram-3"></i>
{% elif chat.type == 'announcement' %}
<i class="bi-megaphone"></i>
{% elif chat.type == 'group' %}
<i class="bi-people"></i>
{% elif chat.type == 'channel' %}
<i class="bi-broadcast"></i>
{% else %}
<i class="bi-person"></i>
{% endif %}
</span>
</div>
<h1>{{ chat.name|default:"Чат" }}</h1>
{% else %}
<!-- Иконка категории, если нет аватара -->
{% if chat.type == 'global' %}
<i class="bi-globe text-primary"></i>
<h1>{{ chat.name|default:"Глобальный чат" }}</h1>
{% elif chat.type == 'department' %}
<i class="bi-diagram-3 text-primary"></i>
<h1>{{ chat.department.name|default:chat.name }}</h1>
{% elif chat.type == 'announcement' %}
<i class="bi-megaphone text-warning"></i>
<h1>{{ chat.name|default:"Объявление" }}</h1>
{% else %}
<i class="bi-person text-primary"></i>
<h1>
{% if chat.name %}
{{ chat.name }}
{% else %}
{% for u in chat.participants.all %}
{% if u != user %}{{ u.get_full_name|default:u.username }}{% endif %}
{% endfor %}
{% endif %}
</h1>
{% endif %}
{% endif %}
</div>
<div class="chat-header-actions">
{% if chat.type == 'announcement' and user.is_staff %}
{% if chat.is_blocked %}
<button type="button" class="btn btn-success btn-sm" onclick="unblockAnnouncement({{ chat.id }})">
<i class="bi-unlock"></i>
</button>
{% else %}
<button type="button" class="btn btn-danger btn-sm" onclick="blockAnnouncement({{ chat.id }})">
<i class="bi-lock"></i>
</button>
{% endif %}
{% endif %}
<a href="{% url 'communications:chat_list' %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i>
</a>
</div>
</div>
</div>
<!-- Контейнер чата -->
<div class="chat-container"
id="chatDetailApp"
data-chat-id="{{ chat.id }}"
data-user-id="{{ user.id }}"
data-user-name="{{ user.get_full_name|default:user.username }}"
data-user-avatar="{% if user.avatar %}{{ user.avatar.url }}{% endif %}"
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/"
data-last-read-timestamp="{{ chat.last_read_timestamp|default:'0' }}"
data-last-read-message-id="{% if chat.last_read_message_id %}{{ chat.last_read_message_id }}{% else %}0{% endif %}">
<!-- Предупреждение о блокировке -->
{% if chat.is_blocked %}
<div class="alert alert-danger m-3 mb-0" role="alert">
<div class="d-flex align-items-center">
<i class="bi-lock-fill fs-4 me-2"></i>
<div>
<strong>Это объявление заблокировано администратором</strong>
<br>
<small>
Заблокировано: {{ chat.blocked_at|date:"d.m.Y в H:i" }}
{% if chat.blocked_by %}
пользователем <strong>{{ chat.blocked_by.get_full_name|default:chat.blocked_by.username }}</strong>
{% endif %}
</small>
</div>
</div>
</div>
{% endif %}
<!-- Информация об объявлении -->
{% if chat.type == 'announcement' and not is_announcement_creator %}
<div class="alert alert-light border m-3 mb-0 py-2" role="alert">
<small class="text-secondary">
<i class="bi-info-circle me-1"></i>
Объявление от <strong>{{ chat.created_by.get_full_name|default:chat.created_by.username }}</strong>
</small>
</div>
{% endif %}
<!-- Кнопка «вниз» -->
<button id="scrollBtn" class="scroll-to-bottom" type="button" aria-label="Прокрутить вниз">
<i class="bi-arrow-down"></i>
</button>
<!-- Индикатор новых сообщений (V2) -->
<div id="newMessagesIndicator" class="new-messages-indicator" style="display: none;">
<span class="new-messages-count">0</span> новых сообщений
</div>
<!-- Лента сообщений -->
<div id="chatScroll"
class="chat-messages-scroll"
data-me-id="{{ user.id }}"
data-me-avatar="{% if user.avatar %}{{ user.avatar.url }}{% endif %}"
data-profile-url="{% url 'employees:profile' %}"
data-detail-url-template="{% url 'employees:employee_detail' 0 %}"
data-chat-id="{{ chat.id }}"
data-fetch-url="{% url 'api:v1:chat_messages' chat.pk %}"
data-page-size="{{ messages_page_size }}"
data-oldest-id="{{ messages_oldest_id }}"
data-oldest-ts="{{ messages_oldest_ts }}"
data-has-more="{% if messages_has_more %}1{% else %}0{% endif %}"
data-last-read-ts="{% if last_read_at %}{{ last_read_at|date:'U' }}000{% endif %}">
<!-- Индикатор загрузки истории -->
<div class="history-loader d-none" data-history-loader>
<div class="spinner-border spinner-border-sm text-secondary" role="status" aria-hidden="true"></div>
<span class="small">Загружаем ранние сообщения…</span>
</div>
<!-- Индикатор первоначальной загрузки -->
<div class="initial-loader text-center py-5" id="initialLoader">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<p class="text-secondary">Загружаем сообщения...</p>
</div>
</div>
<!-- Индикатор печати -->
<div id="typing" class="chat-typing-indicator d-none">
<span class="typing-dots">
<span></span><span></span><span></span>
</span>
<span class="typing-text">Собеседник печатает…</span>
</div>
<!-- Композер -->
<div class="chat-composer-wrapper">
{% if can_send_messages %}
<form id="chatForm"
class="composer-wrap"
method="post"
action="{% url 'api:v1:upload_message' %}"
enctype="multipart/form-data"
data-chat-composer="1"
data-chat-id="{{ chat.id }}"
data-upload-url="{% url 'api:v1:upload_message' %}"
data-me-id="{{ user.id }}"
data-me-name="{{ user.get_full_name|default:user.username }}"
data-me-avatar="{% if user.avatar %}{{ user.avatar.url }}{% endif %}"
data-profile-url="{% url 'employees:profile' %}"
data-detail-url-template="{% url 'employees:employee_detail' 0 %}"
onsubmit="return false;">
{% csrf_token %}
<input type="hidden" name="chat_id" value="{{ chat.id }}">
<div class="message-field">
<!-- Кнопка вложений с выпадающим меню -->
<div class="dropdown message-attach">
<button type="button" class="btn btn-ghost btn-attachment message-icon-btn"
id="attachmentDropdown"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Прикрепить файл">
<i class="bi-paperclip"></i>
</button>
<ul class="dropdown-menu dropdown-menu-start" aria-labelledby="attachmentDropdown">
<li>
<a class="dropdown-item" href="#" id="attachDocument">
<i class="bi-file-earmark text-primary me-2"></i>
Документ
</a>
</li>
<li>
<a class="dropdown-item" href="#" id="attachImage">
<i class="bi-image text-success me-2"></i>
Фото или видео
</a>
</li>
<li>
<a class="dropdown-item" href="#" id="attachCamera">
<i class="bi-camera text-info me-2"></i>
Камера
</a>
</li>
<li>
<a class="dropdown-item" href="#" id="attachAudio">
<i class="bi-mic text-danger me-2"></i>
Аудио
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="#" id="createPoll">
<i class="bi-bar-chart text-warning me-2"></i>
Создать голосование
</a>
</li>
</ul>
</div>
<!-- Кнопка выбора эмодзи -->
<div class="dropdown message-emoji">
<button type="button" class="btn btn-ghost btn-emoji message-icon-btn"
id="emojiDropdown"
data-bs-toggle="dropdown"
data-emoji-toggle="1"
aria-expanded="false"
title="Вставить смайлик">
<i class="bi-emoji-smile"></i>
</button>
<div class="dropdown-menu dropdown-menu-start message-emoji-menu" aria-labelledby="emojiDropdown">
<emoji-picker data-emoji-picker class="chat-emoji-picker"></emoji-picker>
</div>
</div>
<!-- Поле ввода текста -->
<textarea name="content"
id="id_content"
class="form-control composer-textarea message-input"
rows="1"
placeholder="Напишите сообщение…"></textarea>
<!-- Кнопка отправки -->
<button type="submit" class="btn btn-primary btn-send message-send" title="Отправить">
<i class="bi-send-fill"></i>
</button>
</div>
<!-- Скрытые input для файлов -->
<input type="file" id="documentInput" class="d-none" accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar">
<input type="file" id="imageInput" class="d-none" accept="image/*,video/*" multiple>
<input type="file" id="cameraInput" class="d-none" accept="image/*" capture="environment">
<input type="file" id="audioInput" class="d-none" accept="audio/*">
<!-- Превью прикрепленных файлов -->
<div id="attachmentPreview" class="attachment-preview d-none"></div>
</form>
{% else %}
<div class="text-center text-secondary py-3">
<i class="bi-lock fs-4"></i>
<p class="mb-0 mt-2">
{% if chat.type == 'announcement' %}
Только автор объявления может публиковать сообщения
{% else %}
У вас нет прав для отправки сообщений в этот чат
{% endif %}
</p>
</div>
{% endif %}
</div>
</div> <!-- .chat-container -->
<!-- Кнопка «вниз» -->
<button id="scrollBtn" class="scroll-to-bottom" type="button" aria-label="Прокрутить вниз">
<i class="bi-arrow-down"></i>
</button>
</div> <!-- .chat-page -->
{% endblock %}
{% block extra_js %}
{{ block.super }}
{# ========== THIRD-PARTY ========== #}
<script type="module" src="{% static 'vendor/emoji-picker/index.js' %}"></script>
{# ========== CHAT DETAIL PAGE - CORE FUNCTIONALITY ========== #}
{# chatDetail.js - V2 архитектура с прямым импортом ChatControllerV2 #}
<script type="module" src="{% static 'js/pages/chatDetail.js' %}"></script>
{# ========== CHAT DETAIL - UI ENHANCEMENTS ========== #}
{# Реакции, контекстное меню, выделение, голосования #}
<script type="module" src="{% static 'js/chat-detail-enhanced.js' %}"></script>
<script>
// Блокировка объявления
async function blockAnnouncement(chatId) {
if (!confirm('Вы уверены, что хотите заблокировать это объявление? Оно исчезнет из списка чатов всех пользователей.')) {
return;
}
try {
const response = await fetch(`/communications/api/announcement/${chatId}/block/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
});
const data = await response.json();
if (response.ok) {
location.reload();
} else {
alert(data.error || 'Ошибка блокировки');
}
} catch (error) {
console.error('Error blocking announcement:', error);
alert('Ошибка при блокировке объявления');
}
}
// Разблокировка объявления
async function unblockAnnouncement(chatId) {
try {
const response = await fetch(`/communications/api/announcement/${chatId}/unblock/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
});
const data = await response.json();
if (response.ok) {
location.reload();
} else {
alert(data.error || 'Ошибка разблокировки');
}
} catch (error) {
console.error('Error unblocking announcement:', error);
alert('Ошибка при разблокировке объявления');
}
}
</script>
<!-- Модальное окно создания голосования -->
<div class="modal fade" id="pollModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi-bar-chart me-2"></i>Создать голосование
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="pollQuestion" class="form-label">Вопрос</label>
<input type="text" class="form-control" id="pollQuestion"
placeholder="Введите вопрос" maxlength="500">
</div>
<div class="mb-3">
<label class="form-label">Варианты ответа</label>
<div id="pollOptions">
<div class="input-group mb-2">
<span class="input-group-text">1</span>
<input type="text" class="form-control poll-option"
placeholder="Вариант ответа" maxlength="200">
</div>
<div class="input-group mb-2">
<span class="input-group-text">2</span>
<input type="text" class="form-control poll-option"
placeholder="Вариант ответа" maxlength="200">
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="addPollOption">
<i class="bi-plus-circle me-1"></i>Добавить вариант
</button>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="pollAnonymous">
<label class="form-check-label" for="pollAnonymous">
Анонимное голосование
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="pollMultiple">
<label class="form-check-label" for="pollMultiple">
Множественный выбор
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="pollQuiz">
<label class="form-check-label" for="pollQuiz">
Викторина (с правильным ответом)
</label>
</div>
</div>
<div class="mb-3" id="quizCorrectAnswer" style="display: none;">
<label class="form-label">Правильный ответ</label>
<select class="form-select" id="pollCorrectOption">
<option value="">Выберите правильный вариант</option>
</select>
</div>
<div class="mb-3">
<label for="pollCloses" class="form-label">
Автоматическое закрытие (минуты, необязательно)
</label>
<input type="number" class="form-control" id="pollCloses"
min="1" placeholder="Оставьте пустым для бессрочного">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Отмена
</button>
<button type="button" class="btn btn-primary" id="submitPoll">
Создать
</button>
</div>
</div>
</div>
</div>
</div> <!-- .chat-page -->
{% endblock %}