feat: Smart button visibility and load-to-present on click

Two types of bottom:
1. Historical bottom (hasMoreAfter=true):
   - Button ALWAYS visible (even at scroll bottom)
   - Click → loads latest 50 messages to present time
   - Renders and scrolls to bottom

2. Present bottom (hasMoreAfter=false):
   - Button hidden when isNearBottom()
   - Click → smart scroll (to unread or bottom)

Changes:
- _setupSmartButtonVisibility() monitors hasMoreAfter
- _handleScrollButtonClick() loads history if hasMoreAfter
- Added public loadLatest() method
- Emits 'chat:messagesLoaded' event for UI updates

Result:
- Historical view: button always shows, loads to present
- Present view: button hides at bottom, smart unread scroll
- No more stuck in history without way to present
This commit is contained in:
Игорь Надеин 2026-01-13 15:21:52 +03:00
parent 3ba512a937
commit 1dd01ae2c1
2 changed files with 109 additions and 8 deletions

View File

@ -472,13 +472,13 @@ export class ChatControllerV2 {
_handleWsNewMessage(detail) {
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) {
// ✅ Настоящее время (hasMoreAfter=false) - обрабатываем как обычно
this.loader.handleNewMessage(message);
const isNearBottom = this.scrollManager.isNearBottom();
if (isNearBottom) {
@ -491,8 +491,12 @@ export class ChatControllerV2 {
// ✅ Пользователь НЕ внизу → показываем badge
this._incrementUnreadBadge();
}
} else {
// ❌ Исторический просмотр (hasMoreAfter=true) - НЕ рендерим, только badge
// Не добавляем в Store (не рендерим), только показываем что есть новые
this._incrementUnreadBadge();
console.log('[ChatControllerV2] New message in historical view - showing badge only');
}
// Если hasMoreAfter = true (исторический низ) - ничего не делаем
}
}
@ -677,16 +681,80 @@ export class ChatControllerV2 {
this._scrollBtn.addEventListener('click', () => this._handleScrollButtonClick());
// Настраиваем умную видимость
this._setupSmartButtonVisibility();
console.log('[ChatControllerV2] Smart scroll button initialized');
}
/**
* Настраивает умную видимость кнопки
* @private
*/
_setupSmartButtonVisibility() {
if (!this._scrollBtn) return;
// Обновляем видимость при скролле
const updateVisibility = () => {
const boundaries = this.loader.chatBoundaries.get(this.chatId);
const hasMoreAfter = boundaries?.hasMoreAfter || false;
const isNearBottom = this.scrollManager.isNearBottom();
// Показываем кнопку если:
// 1. Пользователь НЕ внизу (любой случай)
// 2. ИЛИ hasMoreAfter=true (даже если внизу)
const shouldShow = !isNearBottom || hasMoreAfter;
this._scrollBtn.classList.toggle('show', shouldShow);
};
// Обновляем при скролле
this.scrollElement.addEventListener('scroll', updateVisibility);
// Обновляем при загрузке новых сообщений
window.addEventListener('chat:messagesLoaded', updateVisibility);
// Начальное обновление
setTimeout(updateVisibility, 100);
}
/**
* Обработчик клика на умную кнопку
* @private
*/
_handleScrollButtonClick() {
async _handleScrollButtonClick() {
const boundaries = this.loader.chatBoundaries.get(this.chatId);
const isInPresentTime = boundaries && !boundaries.hasMoreAfter;
const hasMoreAfter = boundaries?.hasMoreAfter || false;
if (hasMoreAfter) {
// 📥 ИСТОРИЧЕСКИЙ НИЗ: Загружаем всю историю до конца
console.log('[ChatControllerV2] Loading history to present time...');
try {
// Загружаем последние сообщения (настоящее время)
const result = await this.loader.loadLatest(this.chatId, 50);
// Рендерим новые сообщения
await this.renderer.render(this.chatId);
// Скроллим в низ
await new Promise(resolve => requestAnimationFrame(resolve));
this.scrollElement.scrollTop = this.scrollElement.scrollHeight;
console.log('[ChatControllerV2] Loaded to present, messages:', result.messages.length);
} catch (error) {
console.error('[ChatControllerV2] Failed to load history:', error);
// Если ошибка - просто скроллим в низ загруженного
this.scrollElement.scrollTop = this.scrollElement.scrollHeight;
}
// Сбрасываем badge
this._resetUnreadBadge();
return;
}
// 🔵 НАСТОЯЩИЙ НИЗ: Умная логика
const isInPresentTime = !hasMoreAfter;
if (this._unreadCount > 0 && this.lastReadMessageId && isInPresentTime) {
// Режим "Перейти к непрочитанным"

View File

@ -241,9 +241,37 @@ export class MessageLoaderV2 {
* Загружает новые сообщения (после текущего newest)
* Используется для прокрутки вниз после перехода по дате (как в Telegram)
* @param {number} chatId - ID чата
* @param {number} [limit] - Количество сообщений (опционально)
* @returns {Promise<Array<Message>>}
*/
async loadNewer(chatId) {
async loadNewer(chatId, limit = null) {
/**
* Загружает последние сообщения (самые свежие в чате)
* Используется для перехода к настоящему времени из исторического просмотра
* @param {number} chatId - ID чата
* @param {number} [limit] - Количество сообщений
* @returns {Promise<LoadResult>}
*/
async loadLatest(chatId, limit = 50) {
const requestKey = `latest_${chatId}`;
console.log('[MessageLoaderV2] Loading latest messages to present time');
try {
const result = await this._loadLatest(chatId, limit, requestKey);
// Эмитим событие
window.dispatchEvent(new CustomEvent('chat:messagesLoaded', {
detail: { chatId, count: result.messages.length, type: 'latest' }
}));
return result;
} catch (error) {
console.error('[MessageLoaderV2] Failed to load latest:', error);
throw error;
}
}
const requestKey = `newer_${chatId}`;
const state = this._getLoadingState(chatId);
@ -277,7 +305,7 @@ export class MessageLoaderV2 {
const result = await this._fetchWithRetry(
buildUrl(API_ENDPOINTS.MESSAGES(chatId), {
after_id: boundaries.newestId,
limit: this.config.HISTORY_LIMIT
limit: limit || this.config.HISTORY_LIMIT
}),
requestKey
);
@ -302,6 +330,11 @@ export class MessageLoaderV2 {
this._updateBoundaries(chatId, { hasMoreAfter: false });
}
// Эмитим событие для обновления UI
window.dispatchEvent(new CustomEvent('chat:messagesLoaded', {
detail: { chatId, count: messages.length, type: 'newer' }
}));
return messages;
} catch (error) {