/** * @file * @copyright 2020 Aleksej Komarov * @license MIT */ import type { Action, Store } from 'common/redux'; import { storage } from 'common/storage'; import DOMPurify from 'dompurify'; import { selectGame } from '../game/selectors'; import { addHighlightSetting, importSettings, loadSettings, removeHighlightSetting, updateHighlightSetting, updateSettings, updateToggle, } from '../settings/actions'; import { blacklisted_tags } from '../settings/constants'; import { selectSettings } from '../settings/selectors'; import { addChatPage, changeChatPage, changeScrollTracking, clearChat, getChatData, loadChat, moveChatPageLeft, moveChatPageRight, purgeChatMessageArchive, rebuildChat, removeChatPage, saveChatToDisk, toggleAcceptedType, updateMessageCount, } from './actions'; import { createMessage, serializeMessage } from './model'; import { chatRenderer } from './renderer'; import { selectChat, selectCurrentChatPage } from './selectors'; import type { message } from './types'; // List of blacklisted tags let storedRounds: number[] = []; let storedLines: number[] = []; const saveChatToStorage = async (store: Store>) => { const game = selectGame(store.getState()); const state = selectChat(store.getState()); const settings = selectSettings(store.getState()); storage.set('chat-state', state); if (!game.databaseBackendEnabled) { const fromIndex = Math.max( 0, chatRenderer.messages.length - settings.persistentMessageLimit, ); const messages = chatRenderer.messages .slice(fromIndex) .map((message) => serializeMessage(message)); storage.set('chat-messages', messages); storage.set( 'chat-messages-archive', chatRenderer.archivedMessages.map((message) => serializeMessage(message)), ); } // FIXME: Better chat history }; const loadChatFromStorage = async (store: Store>) => { const [state, messages, archivedMessages] = await Promise.all([ storage.get('chat-state'), storage.get('chat-messages'), storage.get('chat-messages-archive'), // FIXME: Better chat history ]); // Discard incompatible versions if (state && state.version <= 4) { store.dispatch(loadChat()); return; } if (messages) { for (const message of messages) { if (message.html) { message.html = DOMPurify.sanitize(message.html, { FORBID_TAGS: blacklisted_tags, }); } } const batch = [ ...messages, createMessage({ type: 'internal/reconnected', }), ]; chatRenderer.processBatch(batch, { prepend: true, }); } if (archivedMessages) { for (const archivedMessage of archivedMessages as message[]) { if (archivedMessage.html) { archivedMessage.html = DOMPurify.sanitize(archivedMessage.html, { FORBID_TAGS: blacklisted_tags, }); } } const settings = selectSettings(store.getState()); // Checks if the setting is actually set or set to -1 (infinite) // Otherwise make it grow infinitely if (settings.logRetainRounds) { storedRounds = []; storedLines = []; let oldId: number | null = null; let currentLine: number = 0; settings.storedRounds = 0; settings.exportStart = 0; settings.exportEnd = 0; for (const message of archivedMessages as message[]) { const currentId = message.roundId || 0; if (currentId !== oldId) { const round = currentId; const line = currentLine; storedRounds.push(round || 0); storedLines.push(line); oldId = currentId; currentLine++; } } if (storedRounds.length > settings.logRetainRounds) { chatRenderer.archivedMessages = archivedMessages.slice( storedLines[storedRounds.length - settings.logRetainRounds], ); settings.storedRounds = settings.logRetainRounds; } else { chatRenderer.archivedMessages = archivedMessages; } settings.lastId = oldId; } else { chatRenderer.archivedMessages = archivedMessages; } } store.dispatch(loadChat(state)); }; const loadChatFromDBStorage = async ( store: Store>, user_payload: { ckey: string; token: string }, ) => { const game = selectGame(store.getState()); const settings = selectSettings(store.getState()); const [state] = await Promise.all([storage.get('chat-state')]); // Discard incompatible versions if (state && state.version <= 4) { store.dispatch(loadChat()); return; } const messages: message[] = []; // FIX ME, load from DB, first load has errors => check console // Thanks for inventing async/await await new Promise((resolve) => { fetch( `${game.chatlogApiEndpoint}/api/logs/${user_payload.ckey}/${settings.persistentMessageLimit}`, { method: 'GET', headers: { Accept: 'application/json', Authorization: `Bearer ${user_payload.token}`, 'Content-Type': 'application/json', }, }, ) .then((response) => response.json()) .then((json) => { json.forEach( (obj: { msg_type: string | null; text_raw: string; created_at: number; round_id: number; }) => { const msg: message = { type: obj.msg_type ? obj.msg_type : '', html: obj.text_raw, createdAt: obj.created_at, roundId: obj.round_id, }; messages.push(msg); }, ); if (messages) { for (const message of messages) { if (message.html) { message.html = DOMPurify.sanitize(message.html, { FORBID_TAGS: blacklisted_tags, }); } } const batch = [ ...messages, createMessage({ type: 'internal/reconnected', }), ]; chatRenderer.processBatch(batch, { prepend: true, }); } store.dispatch(loadChat(state)); resolve(); }) .catch(() => { store.dispatch(loadChat(state)); resolve(); }); }); }; export const chatMiddleware = (store) => { let initialized = false; let loaded = false; let needsUpdate = true; const sequences: number[] = []; const sequences_requested: number[] = []; chatRenderer.events.on('batchProcessed', (countByType) => { // Use this flag to workaround unread messages caused by // loading them from storage. Side effect of that, is that // message count can not be trusted, only unread count. if (loaded) { store.dispatch(updateMessageCount(countByType)); } }); chatRenderer.events.on('scrollTrackingChanged', (scrollTracking) => { store.dispatch(changeScrollTracking(scrollTracking)); }); return (next) => (action) => { const { type, payload } = action; const settings = selectSettings(store.getState()); const game = selectGame(store.getState()); settings.totalStoredMessages = chatRenderer.getStoredMessages(); settings.storedRounds = storedRounds.length; chatRenderer.setVisualChatLimits( settings.visibleMessageLimit, settings.combineMessageLimit, settings.combineIntervalLimit, settings.logEnable, settings.logLimit, settings.storedTypes, game.roundId, settings.prependTimestamps, settings.hideImportantInAdminTab, settings.interleave, settings.interleaveColor, game.databaseBackendEnabled, settings.ttsVoice, settings.ttsCategories, ); // Load the chat once settings are loaded if (!initialized && settings.initialized) { initialized = true; setInterval(() => { if (!game.databaseBackendEnabled || needsUpdate) { saveChatToStorage(store); needsUpdate = false; } }, settings.saveInterval * 1000); // loadChatFromStorage(store); } if (type === 'chat/message') { let payload_obj; try { payload_obj = JSON.parse(payload); } catch (err) { return; } const sequence = payload_obj.sequence; if (sequences.includes(sequence)) { return; } const sequence_count = sequences.length; if (sequence_count > 0) { if (sequences_requested.includes(sequence)) { sequences_requested.splice(sequences_requested.indexOf(sequence), 1); // if we are receiving a message we requested, we can stop reliability checks } else { // cannot do reliability if we don't have any messages const expected_sequence = sequences[sequence_count - 1] + 1; if (sequence !== expected_sequence) { for ( let requesting = expected_sequence; requesting < sequence; requesting++ ) { sequences_requested.push(requesting); Byond.sendMessage('chat/resend', requesting); } } } } chatRenderer.processBatch([payload_obj.content], { doArchive: true, }); sequences.push(sequence); if (game.roundId !== settings.lastId) { storedRounds.push(game.roundId); storedLines.push(settings.totalStoredMessages - 1); settings.lastId = game.roundId; } return; } if (type === loadChat.type) { next(action); const page = selectCurrentChatPage(store.getState()); chatRenderer.changePage(page); chatRenderer.onStateLoaded(); loaded = true; return; } if ( type === changeChatPage.type || type === addChatPage.type || type === removeChatPage.type || type === toggleAcceptedType.type || type === moveChatPageLeft.type || type === moveChatPageRight.type ) { next(action); const page = selectCurrentChatPage(store.getState()); chatRenderer.changePage(page); needsUpdate = true; return; } if (type === rebuildChat.type) { chatRenderer.rebuildChat(settings.visibleMessages); return next(action); } if ( type === updateSettings.type || type === updateToggle.type || type === loadSettings.type || type === addHighlightSetting.type || type === removeHighlightSetting.type || type === updateHighlightSetting.type || type === importSettings.type ) { next(action); const nextSettings = selectSettings(store.getState()); chatRenderer.setHighlight( nextSettings.highlightSettings, nextSettings.highlightSettingById, ); needsUpdate = true; return; } if (type === 'roundrestart') { // Save chat as soon as possible saveChatToStorage(store); return next(action); } if (type === 'saveToDiskCommand') { chatRenderer.saveToDisk(settings.logLineCount); return; } if (type === saveChatToDisk.type) { chatRenderer.saveToDisk( settings.logLineCount, storedLines[storedLines.length - settings.exportEnd], storedLines[storedLines.length - settings.exportStart], settings.exportStart, settings.exportEnd, ); return; } if (type === clearChat.type) { chatRenderer.clearChat(); return; } if (type === purgeChatMessageArchive.type) { chatRenderer.purgeMessageArchive(); storedRounds = []; storedLines = []; settings.lastId = null; settings.storedRounds = 0; settings.exportStart = 0; settings.exportEnd = 0; return; } if (type === 'exportDownloadReady') { const event = new Event('chatexportplaced'); document.dispatchEvent(event); } if (type === getChatData.type) { const user_payload = payload; if (payload.token) { loadChatFromDBStorage(store, user_payload); } else { loadChatFromStorage(store); } return; } return next(action); }; };