From 3ba512a93740bb62a32b238998f5fd609a51536b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 13 Jan 2026 15:07:52 +0300 Subject: [PATCH] feat: Smart scroll button with unread badge and auto-scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Telegram-like behavior for new messages: 1. Smart Auto-scroll (WS messages): ✅ User at bottom + present time → auto-scroll to new message ✅ User NOT at bottom + present time → show unread badge ❌ Historical view (hasMoreAfter=true) → no action 2. Smart Scroll Button (#scrollBtn): - Shows unread badge when new messages arrive - Click behavior: * Has unread + lastReadId → scroll to first unread * No unread → scroll to bottom - Auto-hides badge when user scrolls to bottom 3. Badge Styling: - Red badge with count on top-right of button - Pulse animation on appearance - Button subtle pulse when has unread Key Logic: - Only auto-scroll when hasMoreAfter=false (present time) - Historical bottom (hasMoreAfter=true) doesn't trigger auto-scroll - Badge resets on: manual scroll to bottom, button click - Respects user's scroll position unless at bottom --- backend/static/css/components/chat-detail.css | 53 +++++++ .../static/js/controllers/chatControllerV2.js | 137 +++++++++++++++++- backend/static/js/managers/scrollManagerV2.js | 10 +- 3 files changed, 193 insertions(+), 7 deletions(-) diff --git a/backend/static/css/components/chat-detail.css b/backend/static/css/components/chat-detail.css index fd72b60e..aef4886f 100644 --- a/backend/static/css/components/chat-detail.css +++ b/backend/static/css/components/chat-detail.css @@ -611,6 +611,59 @@ body:has(.chat-page) .bottom-navbar { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } +/* Badge непрочитанных на кнопке scrollBtn */ +.scroll-to-bottom { + position: relative; /* Для абсолютного позиционирования badge */ +} + +.scroll-to-bottom .unread-badge { + position: absolute; + top: -6px; + right: -6px; + min-width: 20px; + height: 20px; + padding: 0 6px; + display: none; /* По умолчанию скрыт */ + align-items: center; + justify-content: center; + background: var(--bs-danger); + color: white; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + line-height: 1; + box-shadow: 0 2px 6px rgba(220, 53, 69, 0.4); + animation: badgePulse 0.3s ease; +} + +.scroll-to-bottom.has-unread { + /* Опционально: можем изменить вид кнопки когда есть непрочитанные */ + animation: buttonPulse 2s ease infinite; +} + +@keyframes badgePulse { + 0% { + transform: scale(0); + opacity: 0; + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes buttonPulse { + 0%, 100% { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + } + 50% { + box-shadow: 0 4px 16px rgba(var(--bs-primary-rgb), 0.4); + } +} + /* ═══════════════════════════════════════════════════════════ ИНДИКАТОР ЗАГРУЗКИ ═══════════════════════════════════════════════════════════ */ diff --git a/backend/static/js/controllers/chatControllerV2.js b/backend/static/js/controllers/chatControllerV2.js index 2fe7db0b..bdca43ff 100644 --- a/backend/static/js/controllers/chatControllerV2.js +++ b/backend/static/js/controllers/chatControllerV2.js @@ -96,8 +96,9 @@ export class ChatControllerV2 { this._destroyed = false; // Индикатор новых сообщений (умный автоскролл) - this._newMessagesCount = 0; - this._newMessagesBtn = null; + this._unreadCount = 0; + this._scrollBtn = null; // Используем существующий #scrollBtn + this._scrollBtnBadge = null; this._scrollWatcherCleanup = null; // Слушатели для cleanup @@ -175,6 +176,9 @@ export class ChatControllerV2 { // 8. Настраиваем обработчик кликов на даты this._setupDateClickHandler(); + + // 9. Инициализируем умную кнопку скролла + this._initScrollButton(); // 9. Инициализируем watcher для умного автоскролла this._initScrollWatcher(); @@ -451,6 +455,15 @@ export class ChatControllerV2 { window.addEventListener(event, handler); this._eventListeners.push({ event, handler }); }); + + // Слушаем событие скролла вниз для сброса badge + const scrollBottomHandler = (e) => { + if (e.detail.chatId === this.chatId) { + this._resetUnreadBadge(); + } + }; + window.addEventListener('chat:userScrolledToBottom', scrollBottomHandler); + this._eventListeners.push({ event: 'chat:userScrolledToBottom', handler: scrollBottomHandler }); } /** @@ -460,6 +473,26 @@ export class ChatControllerV2 { const { message, chatId } = detail; if (chatId === this.chatId && message) { this.loader.handleNewMessage(message); + + // 🎯 УМНЫЙ АВТОСКРОЛЛ: только если мы в "настоящем времени" + const boundaries = this.loader.chatBoundaries.get(chatId); + const isInPresentTime = boundaries && !boundaries.hasMoreAfter; + + if (isInPresentTime) { + const isNearBottom = this.scrollManager.isNearBottom(); + + if (isNearBottom) { + // ✅ Пользователь внизу → автоскролл к новому сообщению + requestAnimationFrame(() => { + this.scrollElement.scrollTop = this.scrollElement.scrollHeight; + console.log('[ChatControllerV2] Auto-scrolled to new message (user at bottom)'); + }); + } else { + // ✅ Пользователь НЕ внизу → показываем badge + this._incrementUnreadBadge(); + } + } + // Если hasMoreAfter = true (исторический низ) - ничего не делаем } } @@ -614,11 +647,109 @@ export class ChatControllerV2 { window.dispatchEvent(new CustomEvent(eventName, { detail })); } - // ==================== Умный автоскролл ==================== + // ==================== Умный scrollBtn с badge ==================== + + /** + * Инициализирует умную кнопку скролла + * @private + */ + _initScrollButton() { + this._scrollBtn = document.getElementById('scrollBtn'); + + if (!this._scrollBtn) { + console.warn('[ChatControllerV2] #scrollBtn not found'); + return; + } + + // Создаем badge элемент если его нет + if (!this._scrollBtn.querySelector('.unread-badge')) { + this._scrollBtnBadge = document.createElement('span'); + this._scrollBtnBadge.className = 'unread-badge'; + this._scrollBtnBadge.style.display = 'none'; + this._scrollBtn.appendChild(this._scrollBtnBadge); + } else { + this._scrollBtnBadge = this._scrollBtn.querySelector('.unread-badge'); + } + + // Заменяем обработчик клика на умный + const oldHandler = this._scrollBtn.onclick; + this._scrollBtn.onclick = null; + + this._scrollBtn.addEventListener('click', () => this._handleScrollButtonClick()); + + console.log('[ChatControllerV2] Smart scroll button initialized'); + } + + /** + * Обработчик клика на умную кнопку + * @private + */ + _handleScrollButtonClick() { + const boundaries = this.loader.chatBoundaries.get(this.chatId); + const isInPresentTime = boundaries && !boundaries.hasMoreAfter; + + if (this._unreadCount > 0 && this.lastReadMessageId && isInPresentTime) { + // Режим "Перейти к непрочитанным" + // Пытаемся найти первое непрочитанное (lastReadId + 1) + const firstUnreadId = this.lastReadMessageId + 1; + const firstUnreadEl = this.scrollElement.querySelector(`[data-message-id="${firstUnreadId}"]`); + + if (firstUnreadEl) { + // Скроллим к первому непрочитанному + const containerRect = this.scrollElement.getBoundingClientRect(); + const messageRect = firstUnreadEl.getBoundingClientRect(); + const relativeTop = messageRect.top - containerRect.top; + const targetScrollTop = this.scrollElement.scrollTop + relativeTop - 100; + + this.scrollElement.scrollTop = Math.max(0, targetScrollTop); + console.log('[ChatControllerV2] Scrolled to first unread:', firstUnreadId); + } else { + // Не нашли первое непрочитанное - просто в низ + this.scrollElement.scrollTop = this.scrollElement.scrollHeight; + } + } else { + // Обычный режим "В самый низ" + this.scrollElement.scrollTop = this.scrollElement.scrollHeight; + console.log('[ChatControllerV2] Scrolled to bottom'); + } + + // Сбрасываем badge + this._resetUnreadBadge(); + } + + /** + * Увеличивает счетчик непрочитанных и показывает badge + * @private + */ + _incrementUnreadBadge() { + if (!this._scrollBtn || !this._scrollBtnBadge) return; + + this._unreadCount++; + this._scrollBtnBadge.textContent = this._unreadCount; + this._scrollBtnBadge.style.display = 'flex'; + + // Добавляем класс для анимации + this._scrollBtn.classList.add('has-unread'); + + console.log('[ChatControllerV2] Unread badge:', this._unreadCount); + } + + /** + * Сбрасывает badge непрочитанных + * @private + */ + _resetUnreadBadge() { + if (!this._scrollBtn || !this._scrollBtnBadge) return; + + this._unreadCount = 0; + this._scrollBtnBadge.style.display = 'none'; + this._scrollBtn.classList.remove('has-unread'); + } /** * Показывает индикатор новых сообщений * @private + * @deprecated Используем _incrementUnreadBadge вместо этого */ _showNewMessagesIndicator() { // Ищем или создаём кнопку diff --git a/backend/static/js/managers/scrollManagerV2.js b/backend/static/js/managers/scrollManagerV2.js index fa41ec54..d429409d 100644 --- a/backend/static/js/managers/scrollManagerV2.js +++ b/backend/static/js/managers/scrollManagerV2.js @@ -706,10 +706,12 @@ export class ScrollManagerV2 { * @private */ _onScroll(e) { - // Здесь можно добавить логику: - // - Показать/скрыть кнопку "scroll to bottom" - // - Отслеживание видимых сообщений для mark read - // - Отправка событий + // Если пользователь доскроллил вниз - сбрасываем badge + if (this.isNearBottom()) { + window.dispatchEvent(new CustomEvent('chat:userScrolledToBottom', { + detail: { chatId: this.chatId } + })); + } } /**