diff --git a/backend/static/css/components/chat-detail.css b/backend/static/css/components/chat-detail.css
index aef4886f..d368adc2 100644
--- a/backend/static/css/components/chat-detail.css
+++ b/backend/static/css/components/chat-detail.css
@@ -612,35 +612,43 @@ body:has(.chat-page) .bottom-navbar {
}
/* Badge непрочитанных на кнопке scrollBtn */
+/* Унифицирован со стилем sticky-date */
.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;
+ top: -8px;
+ right: -8px;
+
+ /* Layout - как у sticky-date span */
display: none; /* По умолчанию скрыт */
align-items: center;
justify-content: center;
- background: var(--bs-danger);
- color: white;
- border-radius: 10px;
+ min-width: 20px;
+ height: 20px;
+ padding: 0 6px;
+
+ /* Typography - как у sticky-date */
font-size: 11px;
font-weight: 600;
line-height: 1;
+ color: white;
+
+ /* Visual - унифицировано с sticky-date */
+ background-color: var(--bs-danger);
+ border-radius: 10px;
+ border: 2px solid var(--bs-body-bg); /* Контраст с фоном */
+
+ /* Shadow - как у sticky-date */
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;
+
+ /* Transitions - как у sticky-date */
+ transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
}
+/* Анимация появления - как у sticky-date hover */
@keyframes badgePulse {
0% {
transform: scale(0);
@@ -655,6 +663,15 @@ body:has(.chat-page) .bottom-navbar {
}
}
+.scroll-to-bottom .unread-badge {
+ animation: badgePulse 0.3s ease;
+}
+
+/* Кнопка с непрочитанными - subtle pulse как у sticky-date.interactive */
+.scroll-to-bottom.has-unread {
+ animation: buttonPulse 2s ease infinite;
+}
+
@keyframes buttonPulse {
0%, 100% {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
diff --git a/backend/static/js/controllers/chatControllerV2.js b/backend/static/js/controllers/chatControllerV2.js
index e833f4b4..baa96fa7 100644
--- a/backend/static/js/controllers/chatControllerV2.js
+++ b/backend/static/js/controllers/chatControllerV2.js
@@ -16,6 +16,7 @@ import { MessageRendererV2 } from '../renderers/messageRendererV2.js';
import { ScrollManagerV2 } from '../managers/scrollManagerV2.js';
import { DateGroupManager } from '../managers/dateGroupManager.js';
import { StickyDateObserver } from '../observers/stickyDateObserver.js';
+import { UnreadBadgeManager } from '../managers/unreadBadgeManager.js';
import { CHAT_EVENTS, WS_EVENTS, SCROLL_CONFIG, AUTOSCROLL_CONFIG } from '../config/chatConfig.js';
/**
@@ -95,10 +96,10 @@ export class ChatControllerV2 {
this._initializing = false;
this._destroyed = false;
- // Индикатор новых сообщений (умный автоскролл)
- this._unreadCount = 0;
- this._scrollBtn = null; // Используем существующий #scrollBtn
- this._scrollBtnBadge = null;
+ // Badge Manager для непрочитанных сообщений
+ this.badgeManager = new UnreadBadgeManager('scrollBtn');
+
+ // Слушатели
this._scrollWatcherCleanup = null;
// Слушатели для cleanup
@@ -459,7 +460,7 @@ export class ChatControllerV2 {
// Слушаем событие скролла вниз для сброса badge
const scrollBottomHandler = (e) => {
if (e.detail.chatId === this.chatId) {
- this._resetUnreadBadge();
+ this.badgeManager.reset();
}
};
window.addEventListener('chat:userScrolledToBottom', scrollBottomHandler);
@@ -489,12 +490,12 @@ export class ChatControllerV2 {
});
} else {
// ✅ Пользователь НЕ внизу → показываем badge
- this._incrementUnreadBadge();
+ this.badgeManager.increment();
}
} else {
// ❌ Исторический просмотр (hasMoreAfter=true) - НЕ рендерим, только badge
// Не добавляем в Store (не рендерим), только показываем что есть новые
- this._incrementUnreadBadge();
+ this.badgeManager.increment();
console.log('[ChatControllerV2] New message in historical view - showing badge only');
}
}
@@ -658,28 +659,16 @@ export class ChatControllerV2 {
* @private
*/
_initScrollButton() {
- this._scrollBtn = document.getElementById('scrollBtn');
+ const scrollBtn = document.getElementById('scrollBtn');
- if (!this._scrollBtn) {
+ if (!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());
+ scrollBtn.onclick = null;
+ scrollBtn.addEventListener('click', () => this._handleScrollButtonClick());
// Настраиваем умную видимость
this._setupSmartButtonVisibility();
@@ -692,7 +681,8 @@ export class ChatControllerV2 {
* @private
*/
_setupSmartButtonVisibility() {
- if (!this._scrollBtn) return;
+ const scrollBtn = document.getElementById('scrollBtn');
+ if (!scrollBtn) return;
// Обновляем видимость при скролле
const updateVisibility = () => {
@@ -705,7 +695,7 @@ export class ChatControllerV2 {
// 2. ИЛИ hasMoreAfter=true (даже если внизу)
const shouldShow = !isNearBottom || hasMoreAfter;
- this._scrollBtn.classList.toggle('show', shouldShow);
+ scrollBtn.classList.toggle('show', shouldShow);
};
// Обновляем при скролле
@@ -749,14 +739,14 @@ export class ChatControllerV2 {
}
// Сбрасываем badge
- this._resetUnreadBadge();
+ this.badgeManager.reset();
return;
}
// 🔵 НАСТОЯЩИЙ НИЗ: Умная логика
const isInPresentTime = !hasMoreAfter;
- if (this._unreadCount > 0 && this.lastReadMessageId && isInPresentTime) {
+ if (this.badgeManager.hasUnread() && this.lastReadMessageId && isInPresentTime) {
// Режим "Перейти к непрочитанным"
// Пытаемся найти первое непрочитанное (lastReadId + 1)
const firstUnreadId = this.lastReadMessageId + 1;
@@ -782,131 +772,12 @@ export class ChatControllerV2 {
}
// Сбрасываем badge
- this._resetUnreadBadge();
+ this.badgeManager.reset();
}
- /**
- * Увеличивает счетчик непрочитанных и показывает 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() {
- // Ищем или создаём кнопку
- if (!this._newMessagesBtn) {
- this._newMessagesBtn = this._findOrCreateNewMessagesButton();
- }
- if (!this._newMessagesBtn) {
- console.warn('[ChatControllerV2] New messages button not found');
- return;
- }
-
- // Увеличиваем счётчик
- this._newMessagesCount++;
-
- // Обновляем текст кнопки
- const badge = this._newMessagesBtn.querySelector('.badge');
- if (badge) {
- badge.textContent = this._newMessagesCount;
- }
-
- // Показываем кнопку
- this._newMessagesBtn.style.display = 'flex';
- this._newMessagesBtn.classList.add('show');
- }
-
- /**
- * Скрывает индикатор новых сообщений
- * @private
- */
- _hideNewMessagesIndicator() {
- if (!this._newMessagesBtn) {
- return;
- }
-
- // Сбрасываем счётчик
- this._newMessagesCount = 0;
-
- // Обновляем текст
- const badge = this._newMessagesBtn.querySelector('.badge');
- if (badge) {
- badge.textContent = '0';
- }
-
- // Скрываем кнопку
- this._newMessagesBtn.style.display = 'none';
- this._newMessagesBtn.classList.remove('show');
- }
-
- /**
- * Находит или создаёт кнопку новых сообщений
- * @private
- * @returns {HTMLElement|null}
- */
- _findOrCreateNewMessagesButton() {
- // Ищем существующую кнопку
- let btn = document.getElementById(AUTOSCROLL_CONFIG.NEW_MESSAGES_BTN_ID);
-
- if (btn) {
- return btn;
- }
-
- // Создаём новую кнопку
- btn = document.createElement('button');
- btn.id = AUTOSCROLL_CONFIG.NEW_MESSAGES_BTN_ID;
- btn.className = AUTOSCROLL_CONFIG.NEW_MESSAGES_BTN_CLASS;
- btn.style.display = 'none';
- btn.innerHTML = `
-
- 0 новых сообщения
- `;
-
- // Добавляем обработчик клика
- btn.addEventListener('click', () => {
- this.scrollManager.scrollToBottom({
- instant: !AUTOSCROLL_CONFIG.SMOOTH_SCROLL_ON_CLICK,
- force: true
- });
- this._hideNewMessagesIndicator();
- });
-
- // Добавляем в DOM
- const chatContainer = this.scrollElement.parentElement || document.body;
- chatContainer.appendChild(btn);
-
- return btn;
- }
/**
* Настраивает обработчик кликов на даты
diff --git a/backend/static/js/managers/unreadBadgeManager.js b/backend/static/js/managers/unreadBadgeManager.js
new file mode 100644
index 00000000..32de29fb
--- /dev/null
+++ b/backend/static/js/managers/unreadBadgeManager.js
@@ -0,0 +1,149 @@
+/**
+ * @fileoverview UnreadBadgeManager - управление badge непрочитанных на scrollBtn
+ * @module managers/unreadBadgeManager
+ *
+ * ОТВЕТСТВЕННОСТЬ:
+ * - Создание и управление badge элементом
+ * - Увеличение/сброс счетчика
+ * - Визуальные эффекты (анимации, классы)
+ * - НЕТ бизнес-логики (когда показывать/скрывать)
+ */
+
+/**
+ * Управляет badge непрочитанных сообщений на кнопке скролла
+ */
+export class UnreadBadgeManager {
+ /**
+ * @param {string} buttonId - ID кнопки скролла
+ */
+ constructor(buttonId) {
+ this.buttonId = buttonId;
+ this.button = null;
+ this.badge = null;
+ this.count = 0;
+
+ this._init();
+ }
+
+ /**
+ * Инициализирует badge
+ * @private
+ */
+ _init() {
+ this.button = document.getElementById(this.buttonId);
+
+ if (!this.button) {
+ console.warn(`[UnreadBadgeManager] Button #${this.buttonId} not found`);
+ return;
+ }
+
+ // Ищем существующий badge или создаем новый
+ this.badge = this.button.querySelector('.unread-badge');
+
+ if (!this.badge) {
+ this.badge = this._createBadge();
+ this.button.appendChild(this.badge);
+ }
+
+ console.log('[UnreadBadgeManager] Initialized');
+ }
+
+ /**
+ * Создает badge элемент
+ * @private
+ * @returns {HTMLElement}
+ */
+ _createBadge() {
+ const badge = document.createElement('span');
+ badge.className = 'unread-badge';
+ badge.style.display = 'none';
+ badge.textContent = '0';
+ return badge;
+ }
+
+ /**
+ * Увеличивает счетчик непрочитанных
+ * @returns {number} Новое значение счетчика
+ */
+ increment() {
+ if (!this.badge || !this.button) {
+ console.warn('[UnreadBadgeManager] Cannot increment: not initialized');
+ return this.count;
+ }
+
+ this.count++;
+ this.badge.textContent = this.count;
+ this.badge.style.display = 'flex';
+ this.button.classList.add('has-unread');
+
+ console.log('[UnreadBadgeManager] Incremented:', this.count);
+ return this.count;
+ }
+
+ /**
+ * Сбрасывает счетчик непрочитанных
+ */
+ reset() {
+ if (!this.badge || !this.button) {
+ console.warn('[UnreadBadgeManager] Cannot reset: not initialized');
+ return;
+ }
+
+ this.count = 0;
+ this.badge.textContent = '0';
+ this.badge.style.display = 'none';
+ this.button.classList.remove('has-unread');
+
+ console.log('[UnreadBadgeManager] Reset');
+ }
+
+ /**
+ * Устанавливает конкретное значение счетчика
+ * @param {number} value - Новое значение
+ */
+ setCount(value) {
+ if (!this.badge || !this.button) {
+ console.warn('[UnreadBadgeManager] Cannot set count: not initialized');
+ return;
+ }
+
+ this.count = Math.max(0, value);
+ this.badge.textContent = this.count;
+
+ if (this.count > 0) {
+ this.badge.style.display = 'flex';
+ this.button.classList.add('has-unread');
+ } else {
+ this.badge.style.display = 'none';
+ this.button.classList.remove('has-unread');
+ }
+ }
+
+ /**
+ * Получает текущий счетчик
+ * @returns {number}
+ */
+ getCount() {
+ return this.count;
+ }
+
+ /**
+ * Проверяет есть ли непрочитанные
+ * @returns {boolean}
+ */
+ hasUnread() {
+ return this.count > 0;
+ }
+
+ /**
+ * Уничтожает badge manager
+ */
+ destroy() {
+ if (this.badge && this.badge.parentNode) {
+ this.badge.remove();
+ }
+ this.button = null;
+ this.badge = null;
+ this.count = 0;
+ }
+}