ГЛУБОКИЙ АУДИТ выявил корневые причины прыжков: НАЙДЕННЫЕ ПРОБЛЕМЫ: 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: полный аудит
657 lines
20 KiB
JavaScript
657 lines
20 KiB
JavaScript
/**
|
||
* @fileoverview User WebSocket - единое WebSocket соединение для пользователя
|
||
* Обрабатывает все real-time обновления:
|
||
* - Чаты и сообщения
|
||
* - Уведомления
|
||
* - Бейджи в sidebar
|
||
* - Онлайн-статус
|
||
* - Календарь и другие события
|
||
* @module components/userWebSocket
|
||
*/
|
||
|
||
import { getChatAvatar } from './chatAvatarMap.js';
|
||
import { formatDay, createDayDivider, createMessageElement } from './chatMessageTemplates.js';
|
||
|
||
/**
|
||
* Инициализирует WebSocket соединение для пользователя
|
||
* @param {Object} options - Опции инициализации
|
||
* @param {number} options.userId - ID текущего пользователя
|
||
* @param {string} [options.scrollContainerId='chatScroll'] - ID контейнера скролла (если открыт чат)
|
||
* @param {string} [options.formId='chatForm'] - ID формы отправки (если открыт чат)
|
||
* @param {string} [options.textareaId='id_content'] - ID textarea (если открыт чат)
|
||
* @param {string} [options.typingIndicatorId='typing'] - ID индикатора "печатает..." (если открыт чат)
|
||
* @param {Object} [options.avatarMap] - Карта аватаров пользователей
|
||
* @param {Object} [options.markReadApi] - API из chatMarkRead для интеграции
|
||
* @param {Object} [options.badgeManager] - Менеджер бейджей для обновления счетчиков
|
||
* @param {Function} [options.onListUpdate] - Callback для обновления списка чатов
|
||
* @param {Function} [options.onNotification] - Callback для уведомлений (будущее)
|
||
* @returns {Object} API WebSocket
|
||
*/
|
||
|
||
let activeConnection = null;
|
||
|
||
export function initUserWebSocket(options = {}) {
|
||
const {
|
||
userId,
|
||
scrollContainerId = 'chatScroll',
|
||
formId = 'chatForm',
|
||
textareaId = 'id_content',
|
||
typingIndicatorId = 'typing',
|
||
avatarMap = {},
|
||
markReadApi,
|
||
badgeManager,
|
||
onListUpdate
|
||
} = options;
|
||
|
||
if (!userId) {
|
||
console.warn('[UserWS] userId is required');
|
||
return null;
|
||
}
|
||
|
||
// Закрываем существующее соединение
|
||
if (activeConnection) {
|
||
console.log('[UserWS] Closing existing connection');
|
||
activeConnection.close(1000, 'Reconnecting');
|
||
}
|
||
|
||
// Состояние
|
||
const state = {
|
||
activeChatId: null,
|
||
scrollEl: null,
|
||
form: null,
|
||
textarea: null,
|
||
typingEl: null,
|
||
reconnectAttempts: 0,
|
||
maxReconnectAttempts: 5,
|
||
reconnectDelay: 3000,
|
||
typingTimeout: null,
|
||
lastTypingSent: 0,
|
||
typingThrottle: 3000
|
||
};
|
||
|
||
// Создание WebSocket соединения
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const wsUrl = `${proto}://${location.host}/ws/`;
|
||
const ws = new WebSocket(wsUrl);
|
||
|
||
activeConnection = ws;
|
||
|
||
console.log('[UserWS] Connecting to', wsUrl);
|
||
|
||
// ==================== WebSocket события ====================
|
||
|
||
ws.onopen = () => {
|
||
console.log('[UserWS] Connected');
|
||
state.reconnectAttempts = 0;
|
||
|
||
// Если есть активный чат на странице, открываем его
|
||
const currentChatId = detectCurrentChatId();
|
||
if (currentChatId) {
|
||
openChat(currentChatId, true);
|
||
}
|
||
};
|
||
|
||
ws.onclose = (event) => {
|
||
console.log('[UserWS] Disconnected', event.code, event.reason);
|
||
|
||
if (event.code !== 1000 && state.reconnectAttempts < state.maxReconnectAttempts) {
|
||
state.reconnectAttempts++;
|
||
console.log(`[UserWS] Reconnecting attempt ${state.reconnectAttempts}...`);
|
||
setTimeout(() => {
|
||
initUserWebSocket(options);
|
||
}, state.reconnectDelay);
|
||
}
|
||
};
|
||
|
||
ws.onerror = (error) => {
|
||
console.error('[UserWS] Error:', error);
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
handleMessage(data);
|
||
} catch (err) {
|
||
console.error('[UserWS] Failed to parse message:', err);
|
||
}
|
||
};
|
||
|
||
// ==================== Обработка входящих сообщений ====================
|
||
|
||
function handleMessage(data) {
|
||
const { type } = data;
|
||
|
||
console.log('[UserWebSocket] Received message:', type, data);
|
||
|
||
switch (type) {
|
||
case 'ping':
|
||
// Keepalive - просто логируем
|
||
break;
|
||
|
||
case 'chat_opened':
|
||
console.log('[UserWS] Chat opened:', data.chat_id);
|
||
break;
|
||
|
||
case 'chat_closed':
|
||
console.log('[UserWS] Chat closed:', data.chat_id);
|
||
break;
|
||
|
||
case 'initial_messages':
|
||
handleInitialMessages(data);
|
||
break;
|
||
|
||
case 'new_message':
|
||
handleNewMessage(data);
|
||
break;
|
||
|
||
case 'message_updated':
|
||
handleMessageUpdated(data);
|
||
break;
|
||
|
||
case 'message_deleted':
|
||
console.log('[UserWebSocket] Handling message_deleted event');
|
||
handleMessageDeleted(data);
|
||
break;
|
||
|
||
case 'list_update':
|
||
handleListUpdate(data);
|
||
break;
|
||
|
||
case 'message_edited':
|
||
handleMessageEditedInList(data);
|
||
break;
|
||
|
||
case 'reaction_added':
|
||
handleReactionAdded(data);
|
||
break;
|
||
|
||
case 'reaction_removed':
|
||
handleReactionRemoved(data);
|
||
break;
|
||
|
||
case 'typing_start':
|
||
handleTypingStart(data);
|
||
break;
|
||
|
||
case 'typing_stop':
|
||
handleTypingStop(data);
|
||
break;
|
||
|
||
case 'poll_vote':
|
||
case 'poll_update':
|
||
handlePollUpdate(data);
|
||
break;
|
||
|
||
case 'error':
|
||
console.error('[UserWS] Server error:', data.error);
|
||
break;
|
||
|
||
default:
|
||
console.warn('[UserWS] Unknown message type:', type);
|
||
}
|
||
}
|
||
|
||
// ==================== Управление активным чатом ====================
|
||
|
||
function detectCurrentChatId() {
|
||
// Определяем ID чата из DOM (если мы на странице чата)
|
||
const scrollEl = document.getElementById(scrollContainerId);
|
||
if (scrollEl) {
|
||
const chatId = scrollEl.dataset.chatId;
|
||
if (chatId) {
|
||
return parseInt(chatId);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function openChat(chatId, loadHistory = false) {
|
||
console.log('[UserWS] Opening chat:', chatId);
|
||
|
||
state.activeChatId = chatId;
|
||
|
||
// Получаем элементы
|
||
state.scrollEl = document.getElementById(scrollContainerId);
|
||
state.form = document.getElementById(formId);
|
||
state.textarea = document.getElementById(textareaId);
|
||
state.typingEl = document.getElementById(typingIndicatorId);
|
||
|
||
// Отправляем на сервер
|
||
send({
|
||
action: 'open_chat',
|
||
chat_id: chatId,
|
||
load_history: loadHistory
|
||
});
|
||
|
||
// ОТКЛЮЧЕНО: Форма теперь обрабатывается chatComposer.js
|
||
// initChatFormHandlers() больше не используется
|
||
}
|
||
|
||
function closeChat(chatId) {
|
||
console.log('[UserWS] Closing chat:', chatId);
|
||
|
||
send({
|
||
action: 'close_chat',
|
||
chat_id: chatId
|
||
});
|
||
|
||
state.activeChatId = null;
|
||
state.scrollEl = null;
|
||
state.form = null;
|
||
state.textarea = null;
|
||
state.typingEl = null;
|
||
}
|
||
|
||
// ==================== Обработчики событий чата ====================
|
||
|
||
function handleInitialMessages(data) {
|
||
if (!state.scrollEl) return;
|
||
|
||
const { messages } = data;
|
||
|
||
// Убираем индикатор загрузки независимо от наличия сообщений
|
||
const loader = document.getElementById('initialLoader');
|
||
if (loader) {
|
||
loader.remove();
|
||
}
|
||
|
||
// Отправляем событие для других модулей
|
||
window.dispatchEvent(new CustomEvent('chat:initial-messages-loaded', {
|
||
detail: { count: messages ? messages.length : 0 }
|
||
}));
|
||
|
||
// Если сообщений нет, завершаем обработку
|
||
if (!messages || !messages.length) {
|
||
console.log('[UserWS] No initial messages to render');
|
||
return;
|
||
}
|
||
|
||
// Используем MessageRenderer для добавления сообщений (с проверкой дубликатов)
|
||
if (!options.messageRenderer) {
|
||
console.error('[UserWebSocket] MessageRenderer not configured for initial_messages!');
|
||
return;
|
||
}
|
||
|
||
console.log('[UserWS] Rendering %d initial messages via MessageRenderer', messages.length);
|
||
|
||
// ОПТИМИЗАЦИЯ: Скрываем контейнер перед рендерингом для предотвращения reflow
|
||
const wasVisible = state.scrollEl.style.visibility;
|
||
state.scrollEl.style.visibility = 'hidden';
|
||
|
||
// Используем батчевый renderMessages вместо forEach для лучшей производительности
|
||
options.messageRenderer.renderMessages(messages);
|
||
|
||
// Используем requestAnimationFrame для гарантии что DOM обновлен перед прокруткой
|
||
requestAnimationFrame(() => {
|
||
// Устанавливаем scroll в конец ДО показа контейнера
|
||
state.scrollEl.scrollTop = state.scrollEl.scrollHeight;
|
||
|
||
// Показываем контейнер - пользователь сразу видит конец диалога
|
||
state.scrollEl.style.visibility = wasVisible || '';
|
||
});
|
||
|
||
// Инициализируем голосования после загрузки сообщений
|
||
if (window.chatPoll) {
|
||
console.log('[UserWS] Initializing polls after initial messages load');
|
||
window.chatPoll.initializeExistingPolls();
|
||
}
|
||
}
|
||
|
||
function handleNewMessage(data) {
|
||
if (!state.scrollEl) return;
|
||
|
||
const { message } = data;
|
||
if (!message) return;
|
||
|
||
// Рендерим сообщение через MessageRenderer
|
||
if (!options.messageRenderer) {
|
||
console.error('[UserWebSocket] MessageRenderer not configured!');
|
||
return;
|
||
}
|
||
|
||
options.messageRenderer.renderMessage(message);
|
||
|
||
// Автоскролл ТОЛЬКО если это наше сообщение ИЛИ мы уже внизу
|
||
// Предотвращаем принудительный скролл при получении чужих сообщений
|
||
const isAtBottom = markReadApi?.atBottom?.() ?? false;
|
||
const isOwnMessage = message.author_id === userId;
|
||
|
||
if (isOwnMessage || isAtBottom) {
|
||
// Используем requestAnimationFrame для плавности
|
||
requestAnimationFrame(() => {
|
||
scrollToBottom();
|
||
});
|
||
}
|
||
|
||
// Отмечаем как прочитанное
|
||
const msgEl = state.scrollEl.querySelector(`[data-message-id="${message.id}"]`);
|
||
if (msgEl && markReadApi?.observeLastForeign) {
|
||
markReadApi.observeLastForeign(msgEl);
|
||
}
|
||
|
||
// Инициализируем реакции для нового сообщения
|
||
window.dispatchEvent(new CustomEvent('chat:message-added', {
|
||
detail: { messageElement: msgEl, messageId: message.id }
|
||
}));
|
||
|
||
// Если в сообщении есть голосование, инициализируем его
|
||
if (message.poll && window.chatPoll) {
|
||
console.log('[UserWS] New message with poll detected, poll_id=%s', message.poll.id);
|
||
// Небольшая задержка чтобы DOM успел обновиться
|
||
setTimeout(() => {
|
||
window.chatPoll.refreshPoll(message.poll.id);
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
function handleMessageUpdated(data) {
|
||
const { message } = data;
|
||
if (!message) return;
|
||
|
||
// УСТАРЕЛО: Старая логика частичного обновления
|
||
// Теперь используем полную перерисовку через событие
|
||
console.log('[UserWebSocket] message_updated received, dispatching chat:message-edited');
|
||
|
||
// Диспатчим событие для полной перерисовки
|
||
window.dispatchEvent(new CustomEvent('chat:message-edited', {
|
||
detail: {
|
||
payload: message,
|
||
chat_id: data.chat_id
|
||
}
|
||
}));
|
||
}
|
||
|
||
function handleMessageDeleted(data) {
|
||
console.log('[UserWebSocket] handleMessageDeleted called:', data);
|
||
|
||
const { message_id } = data;
|
||
if (!message_id) {
|
||
console.warn('[UserWebSocket] No message_id in delete event');
|
||
return;
|
||
}
|
||
|
||
console.log('[UserWebSocket] Looking for message element with id:', message_id);
|
||
console.log('[UserWebSocket] Scroll element:', state.scrollEl?.id);
|
||
|
||
const msgEl = state.scrollEl?.querySelector(`[data-message-id="${message_id}"]`);
|
||
|
||
if (msgEl) {
|
||
console.log('[UserWebSocket] Message element found:', msgEl);
|
||
console.log('[UserWebSocket] Message element classes:', msgEl.className);
|
||
console.log('[UserWebSocket] Removing message from DOM...');
|
||
|
||
// Плавная анимация удаления
|
||
msgEl.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
|
||
msgEl.style.opacity = '0';
|
||
msgEl.style.transform = 'scale(0.8)';
|
||
|
||
setTimeout(() => {
|
||
msgEl.remove();
|
||
console.log('[UserWebSocket] Message removed successfully');
|
||
}, 300);
|
||
} else {
|
||
console.warn('[UserWebSocket] Message element NOT found for id:', message_id);
|
||
console.log('[UserWebSocket] Available messages:',
|
||
Array.from(state.scrollEl?.querySelectorAll('[data-message-id]') || [])
|
||
.map(el => el.dataset.messageId)
|
||
);
|
||
}
|
||
}
|
||
|
||
function handleListUpdate(data) {
|
||
const { chat_id, message } = data;
|
||
if (!chat_id || !message) return;
|
||
|
||
// Обновляем бейдж (если сообщение не от нас)
|
||
if (badgeManager && message.author_id !== userId) {
|
||
badgeManager.incrementChat(chat_id);
|
||
}
|
||
|
||
// Обновляем карточку чата в списке
|
||
if (onListUpdate) {
|
||
onListUpdate(chat_id, message);
|
||
}
|
||
|
||
// Диспатчим событие для других модулей
|
||
window.dispatchEvent(new CustomEvent('chat:list-update', {
|
||
detail: { chatId: chat_id, message }
|
||
}));
|
||
}
|
||
|
||
function handleMessageEditedInList(data) {
|
||
// Обновление последнего сообщения в списке чатов
|
||
if (onListUpdate) {
|
||
onListUpdate(data.chat_id, data.message);
|
||
}
|
||
|
||
// Диспатчим событие для обновления сообщения в открытом чате
|
||
window.dispatchEvent(new CustomEvent('chat:message-edited', {
|
||
detail: {
|
||
payload: data.message, // Данные сообщения
|
||
chat_id: data.chat_id
|
||
}
|
||
}));
|
||
}
|
||
|
||
function handleReactionAdded(data) {
|
||
const { message_id, emoji, user_id, user_name, reactions_summary } = data;
|
||
|
||
window.dispatchEvent(new CustomEvent('chat:reaction-added', {
|
||
detail: {
|
||
messageId: message_id,
|
||
emoji,
|
||
userId: user_id,
|
||
userName: user_name,
|
||
reactions_summary: reactions_summary // ← ДОБАВЛЕНО
|
||
}
|
||
}));
|
||
}
|
||
|
||
function handleReactionRemoved(data) {
|
||
const { message_id, emoji, user_id, reactions_summary } = data;
|
||
|
||
window.dispatchEvent(new CustomEvent('chat:reaction-removed', {
|
||
detail: {
|
||
messageId: message_id,
|
||
emoji,
|
||
userId: user_id,
|
||
reactions_summary: reactions_summary
|
||
}
|
||
}));
|
||
}
|
||
|
||
function handleTypingStart(data) {
|
||
const { user_name } = data;
|
||
if (state.typingEl) {
|
||
state.typingEl.textContent = `${user_name} печатает...`;
|
||
state.typingEl.classList.remove('d-none');
|
||
}
|
||
}
|
||
|
||
function handleTypingStop(data) {
|
||
if (state.typingEl) {
|
||
state.typingEl.classList.add('d-none');
|
||
}
|
||
}
|
||
|
||
function handlePollUpdate(data) {
|
||
console.log('[UserWebSocket] Poll update received:', data);
|
||
window.dispatchEvent(new CustomEvent('chat:poll-update', {
|
||
detail: {
|
||
poll_id: data.poll_id,
|
||
message_id: data.message_id,
|
||
results: data.results
|
||
}
|
||
}));
|
||
}
|
||
|
||
// ==================== Отправка сообщений ====================
|
||
|
||
// УДАЛЕНО: initChatFormHandlers() больше не используется
|
||
// Форма теперь обрабатывается chatComposer.js через стандартный HTML submit
|
||
// Отправка сообщений идёт через HTTP POST, а не через WebSocket
|
||
|
||
function sendMessage(content) {
|
||
send({
|
||
action: 'send_message',
|
||
content: content
|
||
});
|
||
}
|
||
|
||
function sendTyping() {
|
||
send({
|
||
action: 'typing'
|
||
});
|
||
}
|
||
|
||
function sendStopTyping() {
|
||
send({
|
||
action: 'stop_typing'
|
||
});
|
||
}
|
||
|
||
// ==================== Вспомогательные методы ====================
|
||
|
||
function send(data) {
|
||
if (ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify(data));
|
||
} else {
|
||
console.warn('[UserWS] Cannot send, connection not open');
|
||
}
|
||
}
|
||
|
||
function scrollToBottom(instant = false) {
|
||
if (!state.scrollEl) return;
|
||
|
||
// Прямая установка scrollTop для предсказуемости
|
||
// Избегаем вызова markReadApi.autoscroll чтобы не было конфликтов
|
||
if (instant) {
|
||
const prev = state.scrollEl.style.scrollBehavior;
|
||
state.scrollEl.style.scrollBehavior = 'auto';
|
||
state.scrollEl.scrollTop = state.scrollEl.scrollHeight;
|
||
if (prev) {
|
||
state.scrollEl.style.scrollBehavior = prev;
|
||
} else {
|
||
state.scrollEl.style.removeProperty('scroll-behavior');
|
||
}
|
||
} else {
|
||
state.scrollEl.scrollTop = state.scrollEl.scrollHeight;
|
||
}
|
||
}
|
||
|
||
// ==================== Public API ====================
|
||
|
||
const api = {
|
||
ws,
|
||
|
||
/**
|
||
* Динамическая конфигурация WebSocket
|
||
* Позволяет расширять функциональность после инициализации
|
||
* @param {Object} newOptions - Дополнительные опции
|
||
*/
|
||
configure: (newOptions = {}) => {
|
||
console.log('[UserWS] Configuring with:', Object.keys(newOptions));
|
||
|
||
// Обновляем DOM элементы
|
||
if (newOptions.scrollContainerId) {
|
||
state.scrollEl = document.getElementById(newOptions.scrollContainerId);
|
||
}
|
||
if (newOptions.formId) {
|
||
state.form = document.getElementById(newOptions.formId);
|
||
}
|
||
if (newOptions.textareaId) {
|
||
state.textarea = document.getElementById(newOptions.textareaId);
|
||
}
|
||
if (newOptions.typingIndicatorId) {
|
||
state.typingEl = document.getElementById(newOptions.typingIndicatorId);
|
||
}
|
||
|
||
// Обновляем avatarMap
|
||
if (newOptions.avatarMap) {
|
||
Object.assign(avatarMap, newOptions.avatarMap);
|
||
}
|
||
|
||
// Обновляем markReadApi
|
||
if (newOptions.markReadApi) {
|
||
options.markReadApi = newOptions.markReadApi;
|
||
}
|
||
|
||
// Обновляем messageRenderer
|
||
if (newOptions.messageRenderer) {
|
||
options.messageRenderer = newOptions.messageRenderer;
|
||
}
|
||
|
||
// Обновляем callbacks
|
||
if (newOptions.onListUpdate) {
|
||
options.onListUpdate = newOptions.onListUpdate;
|
||
}
|
||
|
||
console.log('[UserWS] Configuration updated');
|
||
},
|
||
|
||
// Управление чатом
|
||
openChat,
|
||
closeChat,
|
||
|
||
// Отправка сообщений
|
||
sendMessage,
|
||
|
||
// Реакции
|
||
addReaction: (messageId, emoji) => {
|
||
send({
|
||
action: 'add_reaction',
|
||
message_id: messageId,
|
||
emoji: emoji
|
||
});
|
||
},
|
||
|
||
removeReaction: (messageId, emoji) => {
|
||
send({
|
||
action: 'remove_reaction',
|
||
message_id: messageId,
|
||
emoji: emoji
|
||
});
|
||
},
|
||
|
||
// Редактирование
|
||
editMessage: (messageId, content) => {
|
||
send({
|
||
action: 'edit_message',
|
||
message_id: messageId,
|
||
content: content
|
||
});
|
||
},
|
||
|
||
// Удаление
|
||
deleteMessage: (messageId) => {
|
||
send({
|
||
action: 'delete_message',
|
||
message_id: messageId
|
||
});
|
||
},
|
||
|
||
// Отметка прочитанного
|
||
markRead: (chatId) => {
|
||
send({
|
||
action: 'mark_read',
|
||
chat_id: chatId
|
||
});
|
||
},
|
||
|
||
// Голосование
|
||
votePoll: (pollId, optionIds) => {
|
||
send({
|
||
action: 'vote_poll',
|
||
poll_id: pollId,
|
||
option_ids: optionIds
|
||
});
|
||
},
|
||
|
||
// Состояние
|
||
getActiveChatId: () => state.activeChatId,
|
||
isConnected: () => ws.readyState === WebSocket.OPEN
|
||
};
|
||
|
||
return api;
|
||
}
|