Corp/backend/static/js/components/chatMarkRead.js
Игорь Надеин 1efbe8cd12 fix(messenger): полное устранение конфликтов прокрутки
ГЛУБОКИЙ АУДИТ выявил корневые причины прыжков:

НАЙДЕННЫЕ ПРОБЛЕМЫ:
1. chatMarkRead делал свою прокрутку ПОСЛЕ handleInitialMessages
   - mark.scrollIntoView() или autoscroll() в requestAnimationFrame
   - Конфликтовал с уже установленной прокруткой

2. Дублирование обработчика кнопки 'вниз':
   - chatMarkRead.js: btn.addEventListener
   - chat-detail-enhanced.js: scrollBtn.addEventListener
   - Два обработчика на одну кнопку!

3. Излишняя сложность в scrollToBottom():
   - Вызывал markReadApi.autoscroll() вместо прямой установки
   - Дополнительный уровень косвенности = источник багов

ИСПРАВЛЕНИЯ:
1. chatMarkRead НЕ скроллит при инициализации:
   - Divider создается, но не скроллится к нему
   - Прокрутка управляется ТОЛЬКО из handleInitialMessages
   - Единая точка ответственности

2. Убрано дублирование кнопки:
   - Удален обработчик из chat-detail-enhanced.js
   - Остался только в chatMarkRead.js

3. Упрощен scrollToBottom():
   - Прямая установка scrollTop вместо вызова markReadApi
   - Поддержка instant режима (без анимации)
   - Предсказуемое поведение

4. Добавлены комментарии и документация:
   - Пояснено почему НЕ скроллим в chatMarkRead
   - Описано назначение каждой функции прокрутки
   - Создан полный аудит в docs/completed/SCROLL_CONFLICTS_AUDIT.md

АРХИТЕКТУРА ПРОКРУТКИ:
✅ При загрузке: ТОЛЬКО handleInitialMessages
✅ Кнопка вниз: chatMarkRead.autoscroll()
✅ Новые сообщения: ТОЛЬКО если свое ИЛИ уже внизу
✅ Ввод текста: autoscroll() если внизу

РЕЗУЛЬТАТ:
✅ Нет прыжков - единая точка управления прокруткой
✅ Нет конфликтов - каждая функция делает одно
✅ Нет дублирования - один обработчик на событие
✅ Предсказуемость - прямые вызовы вместо косвенных

Файлы:
- userWebSocket.js: упрощен scrollToBottom
- chatMarkRead.js: убрана автопрокрутка при инициализации
- chat-detail-enhanced.js: убран дублирующий обработчик
- docs/completed/SCROLL_CONFLICTS_AUDIT.md: полный аудит
2026-01-12 11:11:02 +03:00

338 lines
11 KiB
JavaScript
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.

/**
* @fileoverview Chat Mark Read - Отметка прочитанных сообщений в чате
* Включает автоскролл, разделитель "Новые сообщения", IntersectionObserver,
* синхронизацию с localStorage и API
* @module components/chatMarkRead
*/
import { getCookie } from '../utils/stringUtils.js';
/**
* Инициализирует обработчик отметки прочитанных сообщений
* @param {Object} options - Опции инициализации
* @param {string} [options.scrollContainerId='chatScroll'] - ID контейнера скролла
* @param {string} [options.textareaId='id_content'] - ID textarea для сообщений
* @param {string} [options.formId='chatForm'] - ID формы отправки
* @param {string} [options.scrollBtnId='scrollBtn'] - ID кнопки "вниз"
* @param {number} options.chatId - ID чата
* @param {number} options.meId - ID текущего пользователя
* @param {string} [options.markReadUrl] - URL для отметки прочитанных (опционально)
* @param {number} [options.initialLastReadTs] - Начальный timestamp последнего прочитанного
* @returns {Object|null} API обработчика или null если элементы не найдены
*/
export function initChatMarkRead(options = {}) {
const {
scrollContainerId = 'chatScroll',
textareaId = 'id_content',
formId = 'chatForm',
scrollBtnId = 'scrollBtn',
chatId,
meId,
markReadUrl,
initialLastReadTs
} = options;
// Проверка обязательных параметров
if (!chatId || !meId) {
console.warn('ChatMarkRead: chatId and meId are required');
return null;
}
// Получение элементов
const box = document.getElementById(scrollContainerId);
if (!box) {
console.warn(`ChatMarkRead: element #${scrollContainerId} not found`);
return null;
}
const ta = document.getElementById(textareaId);
const form = document.getElementById(formId);
const btn = document.getElementById(scrollBtnId);
const csrf = getCookie('csrftoken');
const LS_KEY = `chat:lastRead:${chatId}`;
/**
* Получает timestamp последнего прочитанного из localStorage
*/
function getLocalLastReadTs() {
const v = localStorage.getItem(LS_KEY);
return v ? Number(v) : NaN;
}
/**
* Сохраняет timestamp последнего прочитанного в localStorage
*/
function setLocalLastReadTs(ts) {
localStorage.setItem(LS_KEY, String(ts));
}
/**
* Инициализирует последний прочитанный timestamp
*/
function initLastRead() {
let lastRead = initialLastReadTs || Number(box.dataset.lastReadTs || NaN);
if (!Number.isFinite(lastRead)) {
const ls = getLocalLastReadTs();
if (Number.isFinite(ls)) lastRead = ls;
}
if (Number.isFinite(lastRead)) {
box.dataset.lastReadTs = String(lastRead);
}
}
initLastRead();
let lastMarkedTs = Number(box.dataset.lastReadTs || 0);
/**
* Проверяет, находится ли скролл внизу
*/
function atBottom() {
return (box.scrollHeight - box.scrollTop - box.clientHeight) < 60;
}
/**
* Скроллит вниз без анимации
* ВНИМАНИЕ: Используется только для кнопки "вниз" и при вводе текста
* НЕ используется при инициализации!
*/
function autoscroll() {
const prev = box.style.scrollBehavior;
box.style.scrollBehavior = 'auto';
box.scrollTop = box.scrollHeight;
if (prev) {
box.style.scrollBehavior = prev;
} else {
box.style.removeProperty('scroll-behavior');
}
}
/**
* Переключает видимость кнопки "вниз"
*/
function toggleBtn() {
btn?.classList.toggle('show', !atBottom());
}
/**
* Создаёт разделитель (divider)
*/
function makeDivider(cls, text) {
const div = document.createElement('div');
div.className = cls;
div.setAttribute('role', 'separator');
div.setAttribute('aria-label', text);
div.innerHTML = `<span>${text}</span>`;
return div;
}
/**
* Находит первое непрочитанное сообщение
*/
function findUnreadTarget() {
const id = window.__FIRST_UNREAD_ID__;
const ts = window.__UNREAD_FROM_TS__;
const lastReadTs = Number(box.dataset.lastReadTs || NaN);
// 1. Поиск по ID
if (id != null) {
const el = box.querySelector(`.msg[data-id="${id}"]`);
if (el) return el;
}
// 2. Поиск по timestamp из window
if (Number.isFinite(ts)) {
const items = Array.from(box.querySelectorAll('.msg[data-ts]'));
const el = items.find(e => Number(e.dataset.ts) >= Number(ts));
if (el) return el;
}
// 3. Поиск по lastReadTs из dataset
if (Number.isFinite(lastReadTs)) {
const items = Array.from(box.querySelectorAll('.msg[data-ts][data-author-id]'));
return items.find(e =>
Number(e.dataset.ts) > lastReadTs &&
Number(e.dataset.authorId) !== meId
) || null;
}
return null;
}
/**
* Вставляет разделитель "Новые сообщения"
*/
function insertUnreadDivider() {
if (!box || box.querySelector('.unread-divider')) return null;
const target = findUnreadTarget();
if (target) {
const div = makeDivider('unread-divider', 'Новые сообщения');
target.parentNode.insertBefore(div, target);
return div;
}
return null;
}
/**
* Удаляет разделитель "Новые сообщения"
*/
function removeUnreadDivider() {
const d = box.querySelector('.unread-divider');
if (d) d.remove();
}
/**
* Отмечает сообщения как прочитанные до указанного timestamp
* @param {number} ts - Timestamp последнего прочитанного сообщения
*/
async function markRead(ts) {
ts = Number(ts);
if (!Number.isFinite(ts) || ts <= lastMarkedTs) return;
lastMarkedTs = ts;
setLocalLastReadTs(ts);
box.dataset.lastReadTs = String(ts);
removeUnreadDivider();
// Определение URL для отметки
let url = markReadUrl;
if (!url) {
const base = location.pathname.endsWith("/")
? location.pathname
: (location.pathname + "/");
url = base + "mark-read/";
}
try {
await fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': csrf,
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({ upto_ts: String(ts) }),
credentials: 'same-origin'
});
// Отправляем событие для обновления глобального бейджа
window.dispatchEvent(new CustomEvent('chat:read', {
detail: { chatId: String(chatId) }
}));
} catch (err) {
console.warn('ChatMarkRead: failed to mark as read', err);
}
}
/**
* Автоматически изменяет высоту textarea
* DEPRECATED: теперь используется универсальный модуль textareaAutoExpand.js
* Оставлено для совместимости с autoscroll
*/
function autosize() {
if (!ta) return;
// Авторасширение теперь обрабатывается textareaAutoExpand.js
// Оставляем только autoscroll логику
if (atBottom()) autoscroll();
}
/**
* Настраивает IntersectionObserver для автоматической отметки
*/
const io = new IntersectionObserver((entries) => {
entries.forEach(en => {
if (en.isIntersecting) {
const el = en.target.closest('.msg');
if (!el) return;
const ts = Number(el.dataset.ts || 0);
markRead(ts);
}
});
}, { root: box, threshold: 1.0 });
/**
* Наблюдает за последним чужим сообщением
*/
function observeLastForeign() {
io.disconnect();
const msgs = Array.from(box.querySelectorAll('.msg[data-ts][data-author-id]'));
const lastForeign = [...msgs].reverse().find(e =>
Number(e.dataset.authorId) !== meId
);
if (lastForeign) {
const bubble = lastForeign.querySelector('.bubble') || lastForeign;
io.observe(bubble);
}
}
// Инициализация
const mark = insertUnreadDivider();
// ВАЖНО: НЕ скроллим при инициализации!
// Прокрутка управляется из handleInitialMessages в userWebSocket.js
// Здесь только инициализируем UI состояние
requestAnimationFrame(() => {
autosize();
toggleBtn();
window.__SCROLLED_ON_INIT__ = true;
});
// События textarea
ta?.addEventListener('input', autosize);
ta?.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
// Enter без Shift - отправка сообщения
e.preventDefault();
form?.requestSubmit();
}
// Shift+Enter - перенос строки (стандартное поведение)
});
// События скролла
box.addEventListener('scroll', toggleBtn);
btn?.addEventListener('click', autoscroll);
// Наблюдение за последним чужим сообщением
observeLastForeign();
// Отметка при достижении низа
let bottomTimer = null;
box.addEventListener('scroll', () => {
clearTimeout(bottomTimer);
if (atBottom()) {
bottomTimer = setTimeout(() => {
const lastMsg = box.querySelector('.msg:last-of-type');
const ts = lastMsg ? Number(lastMsg.dataset.ts || 0) : 0;
markRead(ts);
}, 300);
}
});
// API
const api = {
markRead,
observeLastForeign,
autoscroll,
atBottom,
destroy: () => {
io.disconnect();
clearTimeout(bottomTimer);
ta?.removeEventListener('input', autosize);
box.removeEventListener('scroll', toggleBtn);
btn?.removeEventListener('click', autoscroll);
}
};
// Экспорт в window для совместимости
window.__CHAT_MARK_READ__ = api;
return api;
}
// Публикуем в window
if (typeof window !== 'undefined') {
window.initChatMarkRead = initChatMarkRead;
}