diff --git a/SQL/feedback_schema.sql b/SQL/feedback_schema.sql index bdc1f2c33a..2664fcd1dd 100644 --- a/SQL/feedback_schema.sql +++ b/SQL/feedback_schema.sql @@ -231,3 +231,22 @@ CREATE TABLE IF NOT EXISTS `round` ( `station_name` VARCHAR(80) NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `chatlogs_ckeys` ( + `ckey` varchar(45) NOT NULL, + `token` varchar(255) DEFAULT NULL, + PRIMARY KEY (`ckey`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `chatlogs_logs` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `round_id` int(11) NOT NULL DEFAULT -1, + `target` varchar(45) NOT NULL, + `text` mediumtext NOT NULL, + `text_raw` mediumtext NOT NULL, + `type` varchar(128) DEFAULT NULL, + `created_at` bigint(20) unsigned NOT NULL, + PRIMARY KEY (`id`), + KEY `chatlogs_ckeys_FK` (`target`), + CONSTRAINT `chatlogs_ckeys_FK` FOREIGN KEY (`target`) REFERENCES `chatlogs_ckeys` (`ckey`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/code/__defines/misc.dm b/code/__defines/misc.dm index 0a492c8202..85ef2d8912 100644 --- a/code/__defines/misc.dm +++ b/code/__defines/misc.dm @@ -52,6 +52,7 @@ #define TOTAL_HUDS 14 // Total number of HUDs. Like body layers, and other things, it comes up sometimes. #define CLIENT_FROM_VAR(I) (ismob(I) ? I:client : (isclient(I) ? I : null)) +#define CKEY_FROM_VAR(I) ((ismob(I) || isclient(I)) ? I:ckey : null) // Shuttles. diff --git a/code/__defines/vchatlog.dm b/code/__defines/vchatlog.dm new file mode 100644 index 0000000000..7b53cd1fbd --- /dev/null +++ b/code/__defines/vchatlog.dm @@ -0,0 +1,39 @@ +/* This comment bypasses grep checks */ /var/__vchatlog + +/proc/__detect_vchatlog() + if (world.system_type == UNIX) + return __vchatlog = (fexists("./libvchatlog.so") ? "./libvchatlog.so" : "libvchatlog") + else + return __vchatlog = "vchatlog" + +#define VCHATLOG (__vchatlog || __detect_vchatlog()) +#define VCHATLOG_CALL(name, args...) call_ext(VCHATLOG, "byond:" + name)(args) + +/** + * Generates and returns a random access token, for external API communication. + * The token is only valid for the current round. + * + * Arguments: + * * ckey - Ckey of the message receiver + * * token - Randomized token + */ +#define vchatlog_generate_token(ckey) VCHATLOG_CALL("generate_token", ckey) + +/** + * Writes a new chatlog entry to the database. This function does not return anything. + * + * Arguments: + * * ckey - Ckey of the message receiver + * * html - HTML of the received message + * * round_id - Current ID of the round (library will resolve this to -1 if invalid or non-existant) + */ +#define vchatlog_write(ckey, html, round_id, type) VCHATLOG_CALL("write_chatlog", ckey, html, round_id, type) + +/** + * This function returns a list of the 10 most recent roundids that are available to be exported. + * Note: -1 might appear. This id is used for internal library failures. Use with caution. + * + * Arguments: + * * ckey - Ckey of the message receiver + */ +#define vchatlog_get_recent_roundids(ckey) VCHATLOG_CALL("get_recent_roundids", ckey) diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm index 5f85722913..aadbeb50d5 100644 --- a/code/controllers/configuration/entries/general.dm +++ b/code/controllers/configuration/entries/general.dm @@ -712,3 +712,12 @@ /// Controls whether simple mobs may recolour themselves once/spawn by giving them the recolour verb /// Admins may manually give them the verb even if disabled /datum/config_entry/flag/allow_simple_mob_recolor + +/// Chatlogs are now saved by calling the chatlogging library instead of letting the clients handle it +/// REQUIRES an database +/datum/config_entry/flag/chatlog_database_backend + default = FALSE + +/// The endpoint for the chat to fetch the chatlogs from (for example, the last 2500 messages on init for the history) +/// REQUIRES chatlog_database_backend to be enabled +/datum/config_entry/string/chatlog_database_api_endpoint diff --git a/code/controllers/subsystems/inactivity.dm b/code/controllers/subsystems/inactivity.dm index 4708be526c..61b333d4bc 100644 --- a/code/controllers/subsystems/inactivity.dm +++ b/code/controllers/subsystems/inactivity.dm @@ -16,7 +16,7 @@ SUBSYSTEM_DEF(inactivity) var/client/C = client_list[client_list.len] client_list.len-- if(C.is_afk(CONFIG_GET(number/kick_inactive) MINUTES) && can_kick(C)) - to_chat_immediate(C, world.time, span_warning("You have been inactive for more than [CONFIG_GET(number/kick_inactive)] minute\s and have been disconnected.")) + to_chat_immediate(C, span_warning("You have been inactive for more than [CONFIG_GET(number/kick_inactive)] minute\s and have been disconnected.")) var/information if(C.mob) diff --git a/code/modules/client/client defines.dm b/code/modules/client/client defines.dm index cfb9bf2bdd..7963516df7 100644 --- a/code/modules/client/client defines.dm +++ b/code/modules/client/client defines.dm @@ -179,3 +179,6 @@ /// If this client has been fully initialized or not var/fully_created = FALSE + + /// Token used for the external chatlog api. Only valid for the current round. + var/chatlog_token diff --git a/code/modules/client/client procs.dm b/code/modules/client/client procs.dm index d02e4a0fe1..9f73e5b987 100644 --- a/code/modules/client/client procs.dm +++ b/code/modules/client/client procs.dm @@ -247,6 +247,9 @@ GLOB.clients += src GLOB.directory[ckey] = src + if (CONFIG_GET(flag/chatlog_database_backend)) + chatlog_token = vchatlog_generate_token(ckey) + // Instantiate stat panel stat_panel = new(src, "statbrowser") stat_panel.subscribe(src, .proc/on_stat_panel_message) diff --git a/code/modules/tgchat/to_chat.dm b/code/modules/tgchat/to_chat.dm index f04b53a6c9..7b2da90066 100644 --- a/code/modules/tgchat/to_chat.dm +++ b/code/modules/tgchat/to_chat.dm @@ -29,6 +29,9 @@ if(target == world) target = GLOB.clients + if(islist(target) && !LAZYLEN(target)) + return + // Build a message var/message = list() if(type) message["type"] = type @@ -39,6 +42,18 @@ // send it immediately SSchat.send_immediate(target, message) + if (CONFIG_GET(flag/chatlog_database_backend)) + if (islist(target)) + for(var/tgt in target) + var/our_ckey = CKEY_FROM_VAR(tgt) + if(isnull(our_ckey)) + continue + vchatlog_write(our_ckey, html, GLOB.round_id, type) + else + var/our_ckey = CKEY_FROM_VAR(target) + if(!isnull(our_ckey)) + vchatlog_write(our_ckey, html, GLOB.round_id, type) + /** * Sends the message to the recipient (target). * @@ -76,6 +91,9 @@ if(target == world) target = GLOB.clients + if(islist(target) && !LAZYLEN(target)) + return + // Build a message var/message = list() if(type) message["type"] = type @@ -83,3 +101,15 @@ if(html) message["html"] = html if(avoid_highlighting) message["avoidHighlighting"] = avoid_highlighting SSchat.queue(target, message) + + if (CONFIG_GET(flag/chatlog_database_backend)) + if (islist(target)) + for(var/tgt in target) + var/our_ckey = CKEY_FROM_VAR(tgt) + if(isnull(our_ckey)) + continue + vchatlog_write(our_ckey, html, GLOB.round_id, type) + else + var/our_ckey = CKEY_FROM_VAR(target) + if(!isnull(our_ckey)) + vchatlog_write(our_ckey, html, GLOB.round_id, type) diff --git a/code/modules/tgui_panel/tgui_panel.dm b/code/modules/tgui_panel/tgui_panel.dm index df1f162fab..2b27ceaa70 100644 --- a/code/modules/tgui_panel/tgui_panel.dm +++ b/code/modules/tgui_panel/tgui_panel.dm @@ -73,11 +73,18 @@ /datum/tgui_panel/proc/on_message(type, payload) if(type == "ready") broken = FALSE - window.send_message("connected", list("round_id" = GLOB.round_id)) // Sends the round ID to the chat, requires round IDs + var/list/stored_rounds = CONFIG_GET(flag/chatlog_database_backend) ? vchatlog_get_recent_roundids(client.ckey) : null + window.send_message("connected", list( + "round_id" = GLOB.round_id, // Sends the round ID to the chat, requires round IDs + "chatlog_db_backend" = CONFIG_GET(flag/chatlog_database_backend), + "chatlog_api_endpoint" = CONFIG_GET(string/chatlog_database_api_endpoint), + "chatlog_stored_rounds" = islist(stored_rounds) ? list("0") + stored_rounds : list("0"), + )) window.send_message("update", list( "config" = list( "client" = list( "ckey" = client.ckey, + "chatlog_token" = client.chatlog_token, "address" = client.address, "computer_id" = client.computer_id, ), diff --git a/config/example/config.txt b/config/example/config.txt index a2e6805750..ac42719316 100644 --- a/config/example/config.txt +++ b/config/example/config.txt @@ -587,3 +587,12 @@ JUKEBOX_TRACK_FILES config/jukebox.json # Admins may manually give them the verb even if disabled # Uncomment to enable #ALLOW_SIMPLE_MOB_RECOLOR + +# Chatlogs are now saved by calling the chatlogging library instead of letting the clients handle it +# REQUIRES an database +# Uncomment to enable +#CHATLOG_DATABASE_BACKEND + +# The endpoint for the chat to fetch the chatlogs from (for example, the last 2500 messages on init for the history) +# REQUIRES chatlog_database_backend to be enabled +#CHATLOG_DATABASE_API_ENDPOINT https://example.com diff --git a/libvchatlog.so b/libvchatlog.so new file mode 100755 index 0000000000..a427899fa8 Binary files /dev/null and b/libvchatlog.so differ diff --git a/tgui/package.json b/tgui/package.json index e627b359bd..644ed6bdf2 100644 --- a/tgui/package.json +++ b/tgui/package.json @@ -29,6 +29,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@types/webpack-env": "^1.18.5", + "@types/wicg-file-system-access": "^2023.10.5", "@typescript-eslint/parser": "^8.13.0", "@typescript-eslint/utils": "^8.13.0", "css-loader": "^7.1.2", diff --git a/tgui/packages/tgui-panel/chat/actions.ts b/tgui/packages/tgui-panel/chat/actions.ts index 0f87a8b0e2..e231a3292d 100644 --- a/tgui/packages/tgui-panel/chat/actions.ts +++ b/tgui/packages/tgui-panel/chat/actions.ts @@ -8,6 +8,7 @@ import { createAction } from 'common/redux'; import { createPage } from './model'; +export const getChatData = createAction('chat/getChatData'); export const loadChat = createAction('chat/load'); export const rebuildChat = createAction('chat/rebuild'); export const updateMessageCount = createAction('chat/updateMessageCount'); diff --git a/tgui/packages/tgui-panel/chat/chatExport.ts b/tgui/packages/tgui-panel/chat/chatExport.ts new file mode 100644 index 0000000000..1c6886c187 --- /dev/null +++ b/tgui/packages/tgui-panel/chat/chatExport.ts @@ -0,0 +1,162 @@ +import { useGame } from '../game'; +import type { gameState } from '../game/types'; +import { useSettings } from '../settings'; +import { MESSAGE_TYPE_UNKNOWN, MESSAGE_TYPES } from './constants'; +import { canPageAcceptType } from './model'; +import { createMessageNode } from './renderer'; +import type { message, Page } from './types'; + +export function exportToDisk( + cssText: string, + startRound: number, + endRound: number, + hasTimestamps: boolean, + page: Page | null, +) { + const game: gameState = useGame(); + + // Fetch from server database + const opts: SaveFilePickerOptions = { + id: `ss13-chatlog-${game.roundId}`, + suggestedName: `ss13-chatlog-${game.roundId}.html`, + types: [ + { + description: 'SS13 file', + accept: { 'text/plain': ['.html'] }, + }, + ], + }; + // We have to do it likes this, otherwise we get a security error + // only 516 can do this btw + if (startRound < endRound) { + getRound(cssText, game, page, opts, hasTimestamps, startRound, endRound); + } else if (startRound > 0) { + getRound(cssText, game, page, opts, hasTimestamps, startRound); + } else { + getRound(cssText, game, page, opts, hasTimestamps); + } +} + +async function getRound( + cssText: string, + game: gameState, + page: Page | null, + opts: SaveFilePickerOptions, + hasTimestamps: boolean, + startRound?: number, + endRound?: number, +) { + const settings = useSettings(); + const { ckey, token } = game.userData; + let messages: message[] = []; + + const d = new Date(); + const utcOffset = d.getTimezoneOffset() / -60; + + let roundToExport = startRound; + if (!roundToExport) { + roundToExport = game.roundId ? game.roundId : 0; + } + let requestString = `${game.chatlogApiEndpoint}/api/export/${ckey}/${settings.logLineCount}?start_id=${roundToExport}`; + if (hasTimestamps) { + requestString += `&timezone_offset=${utcOffset}`; + } + if (endRound) { + requestString += `&end_id=${endRound}`; + } + window + .showSaveFilePicker(opts) + .then(async (fileHandle) => { + await new Promise((resolve) => { + fetch(requestString, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${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, + }; + const node = createMessageNode(); + node.innerHTML = msg.html ? msg.html : ''; + + if (!msg.type) { + const typeDef = MESSAGE_TYPES.find( + (typeDef) => + typeDef.selector && node.querySelector(typeDef.selector), + ); + msg.type = typeDef?.type || MESSAGE_TYPE_UNKNOWN; + } + + msg.html = node.outerHTML; + + messages.push(msg); + }, + ); + + let messagesHtml = ''; + if (messages) { + for (let message of messages) { + // Filter messages according to active tab for export + if (page && canPageAcceptType(page, message.type)) { + messagesHtml += message.html + '\n'; + } + } + + const pageHtml = + '\n' + + '\n' + + '\n' + + 'SS13 Chat Log - Round ' + + game.roundId + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + messagesHtml + + '
\n' + + '\n' + + '\n'; + + try { + fileHandle.createWritable().then((writableHandle) => { + writableHandle.write(pageHtml); + writableHandle.close(); + }); + } catch (e) { + console.error(e); + } + + resolve(); + } + }) + .catch((e) => { + console.error(e); + resolve(); + }); + }); + }) + .catch((e) => { + // Log the error if the error has nothing to do with the user aborting the download + if (e.name !== 'AbortError') { + console.error(e); + } + }); +} diff --git a/tgui/packages/tgui-panel/chat/middleware.ts b/tgui/packages/tgui-panel/chat/middleware.ts index fef65dc38d..528702a5bb 100644 --- a/tgui/packages/tgui-panel/chat/middleware.ts +++ b/tgui/packages/tgui-panel/chat/middleware.ts @@ -16,11 +16,13 @@ import { updateHighlightSetting, updateSettings, } from '../settings/actions'; +import { blacklisted_tags } from '../settings/constants'; import { selectSettings } from '../settings/selectors'; import { addChatPage, changeChatPage, changeScrollTracking, + getChatData, loadChat, moveChatPageLeft, moveChatPageRight, @@ -34,29 +36,31 @@ import { import { createMessage, serializeMessage } from './model'; import { chatRenderer } from './renderer'; import { selectChat, selectCurrentChatPage } from './selectors'; -import { message } from './types'; +import type { message } from './types'; // List of blacklisted tags -const blacklisted_tags = ['a', 'iframe', 'link', 'video']; 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()); - const fromIndex = Math.max( - 0, - chatRenderer.messages.length - settings.persistentMessageLimit, - ); - const messages = chatRenderer.messages - .slice(fromIndex) - .map((message) => serializeMessage(message)); storage.set('chat-state', state); - storage.set('chat-messages', messages); - storage.set( - 'chat-messages-archive', - chatRenderer.archivedMessages.map((message) => serializeMessage(message)), - ); // FIXME: Better chat history + 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>) => { @@ -136,6 +140,83 @@ const loadChatFromStorage = async (store: Store>) => { 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 (let 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; @@ -170,6 +251,7 @@ export const chatMiddleware = (store) => { settings.hideImportantInAdminTab, settings.interleave, settings.interleaveColor, + game.databaseBackendEnabled, ); // Load the chat once settings are loaded if (!initialized && (settings.initialized || settings.firstLoad)) { @@ -177,7 +259,7 @@ export const chatMiddleware = (store) => { setInterval(() => { saveChatToStorage(store); }, settings.saveInterval * 1000); - loadChatFromStorage(store); + // loadChatFromStorage(store); } if (type === 'chat/message') { let payload_obj; @@ -280,6 +362,8 @@ export const chatMiddleware = (store) => { settings.logLineCount, storedLines[storedLines.length - settings.exportEnd], storedLines[storedLines.length - settings.exportStart], + settings.exportStart, + settings.exportEnd, ); return; } @@ -293,6 +377,19 @@ export const chatMiddleware = (store) => { 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); }; }; diff --git a/tgui/packages/tgui-panel/chat/model.ts b/tgui/packages/tgui-panel/chat/model.ts index 4a15d27250..5489996f1c 100644 --- a/tgui/packages/tgui-panel/chat/model.ts +++ b/tgui/packages/tgui-panel/chat/model.ts @@ -7,7 +7,7 @@ import { createUuid } from 'tgui-core/uuid'; import { MESSAGE_TYPE_INTERNAL, MESSAGE_TYPES } from './constants'; -import { message, Page } from './types'; +import type { message, Page } from './types'; export const canPageAcceptType = (page: Page, type: string): string | boolean => type.startsWith(MESSAGE_TYPE_INTERNAL) || page.acceptedTypes[type]; @@ -41,8 +41,10 @@ export const adminPageOnly = (page: Page): boolean => { return checked > 0 && adminTab; }; -export const canStoreType = (storedTypes: Object, type: string) => - storedTypes[type]; +export const canStoreType = ( + storedTypes: Record, + type: string, +) => storedTypes[type]; export const createPage = (obj?: Object): Page => { let acceptedTypes = {}; diff --git a/tgui/packages/tgui-panel/chat/renderer.tsx b/tgui/packages/tgui-panel/chat/renderer.tsx index c70ed28c14..808544d699 100644 --- a/tgui/packages/tgui-panel/chat/renderer.tsx +++ b/tgui/packages/tgui-panel/chat/renderer.tsx @@ -10,6 +10,7 @@ import { Tooltip } from 'tgui-core/components'; import { EventEmitter } from 'tgui-core/events'; import { classes } from 'tgui-core/react'; +import { exportToDisk } from './chatExport'; import { IMAGE_RETRY_DELAY, IMAGE_RETRY_LIMIT, @@ -29,7 +30,7 @@ import { typeIsImportant, } from './model'; import { highlightNode, linkifyNode } from './replaceInTextNode'; -import type { message } from './types'; +import type { message, Page } from './types'; const logger = createLogger('chatRenderer'); @@ -72,7 +73,7 @@ const createHighlightNode = (text: string, color: string): HTMLElement => { return node; }; -const createMessageNode = (): HTMLElement => { +export const createMessageNode = (): HTMLElement => { const node = document.createElement('div'); node.className = 'ChatMessage'; return node; @@ -123,18 +124,19 @@ const handleImageError = (e: ErrorEvent) => { setTimeout(() => { /** @type {HTMLImageElement} */ const node = e.target as HTMLImageElement; - if (node) { - const attempts = - parseInt(node.getAttribute('data-reload-n') || '', 10) || 0; - if (attempts >= IMAGE_RETRY_LIMIT) { - logger.error(`failed to load an image after ${attempts} attempts`); - return; - } - const src = node.src; - node.src = ''; - node.src = src + '#' + attempts; - node.setAttribute('data-reload-n', (attempts + 1).toString()); + if (!node) { + return; } + const attempts = + parseInt(node.getAttribute('data-reload-n') || '', 10) || 0; + if (attempts >= IMAGE_RETRY_LIMIT) { + // logger.error(`failed to load an image after ${attempts} attempts`); + return; + } + const src = node.src; + node.src = ''; + node.src = src + '#' + attempts; + node.setAttribute('data-reload-n', (attempts + 1).toString()); }, IMAGE_RETRY_DELAY); }; @@ -162,11 +164,11 @@ const updateMessageBadge = (message: message) => { class ChatRenderer { loaded: boolean; rootNode: HTMLElement | null; - queue: string[]; + queue: message[]; messages: message[]; archivedMessages: message[]; visibleMessages: message[]; - page: null; + page: Page | null; events: EventEmitter; prependTimestamps: boolean; visibleMessageLimit: number; @@ -176,7 +178,7 @@ class ChatRenderer { logLimit: number; logEnable: boolean; roundId: null | number; - storedTypes: {}; + storedTypes: Record; interleave: boolean; interleaveEnabled: boolean; interleaveColor: string; @@ -195,6 +197,7 @@ class ChatRenderer { blacklistregex: RegExp; }[] | null; + databaseBackendEnabled: boolean; constructor() { /** @type {HTMLElement} */ this.loaded = false; @@ -219,6 +222,7 @@ class ChatRenderer { this.interleaveEnabled = false; this.interleaveColor = '#909090'; this.hideImportantInAdminTab = false; + this.databaseBackendEnabled = false; // Scroll handler /** @type {HTMLElement} */ this.scrollNode = null; @@ -448,12 +452,13 @@ class ChatRenderer { combineIntervalLimit: number, logEnable: boolean, logLimit: number, - storedTypes: {}, + storedTypes: Record, roundId: number | null, prependTimestamps: boolean, hideImportantInAdminTab: boolean, interleaveEnabled: boolean, interleaveColor: string, + databaseBackendEnabled: boolean, ) { this.visibleMessageLimit = visibleMessageLimit; this.combineMessageLimit = combineMessageLimit; @@ -466,9 +471,10 @@ class ChatRenderer { this.hideImportantInAdminTab = hideImportantInAdminTab; this.interleaveEnabled = interleaveEnabled; this.interleaveColor = interleaveColor; + this.databaseBackendEnabled = databaseBackendEnabled; } - changePage(page) { + changePage(page: Page) { if (!this.isReady()) { this.page = page; this.tryFlushQueue(); @@ -509,7 +515,7 @@ class ChatRenderer { } } - getCombinableMessage(predicate) { + getCombinableMessage(predicate: message) { const now = Date.now(); const len = this.visibleMessages.length; const from = len - 1; @@ -532,7 +538,7 @@ class ChatRenderer { } processBatch( - batch, + batch: message[], options: { prepend?: boolean; notifyListeners?: boolean; @@ -692,6 +698,7 @@ class ChatRenderer { if ( doArchive && this.logEnable && + !this.databaseBackendEnabled && this.storedTypes && canStoreType(this.storedTypes, message.type) ) { @@ -792,7 +799,7 @@ class ChatRenderer { } } - rebuildChat(rebuildLimit) { + rebuildChat(rebuildLimit: number) { if (!this.isReady()) { return; } @@ -815,7 +822,13 @@ class ChatRenderer { }); } - saveToDisk(logLineCount, startLine = 0, endLine = 0) { + saveToDisk( + logLineCount: number = 0, + startLine: number = 0, + endLine: number = 0, + startRound: number = 0, + endRound: number = 0, + ) { // Compile currently loaded stylesheets as CSS text let cssText = ''; const styleSheets = document.styleSheets; @@ -830,59 +843,70 @@ class ChatRenderer { } cssText += 'body, html { background-color: #141414 }\n'; // Compile chat log as HTML text - let messagesHtml = ''; - let tmpMsgArray: message[] = []; - if (startLine || endLine) { - if (!endLine) { - tmpMsgArray = this.archivedMessages.slice(startLine); - } else { - tmpMsgArray = this.archivedMessages.slice(startLine, endLine); - } - if (logLineCount > 0) { - tmpMsgArray = tmpMsgArray.slice(-logLineCount); - } - } else if (logLineCount > 0) { - tmpMsgArray = this.archivedMessages.slice(-logLineCount); + if (this.databaseBackendEnabled) { + exportToDisk( + cssText, + startRound, + endRound, + this.prependTimestamps, + this.page, + ); } else { - tmpMsgArray = this.archivedMessages; - } - - // for (let message of this.visibleMessages) { // TODO: Actually having a better message archiving maybe for exports? - for (let message of tmpMsgArray) { - // Filter messages according to active tab for export - if (this.page && canPageAcceptType(this.page, message.type)) { - messagesHtml += message.html + '\n'; + // Fetch from chat storage + let messagesHtml = ''; + let tmpMsgArray: message[] = []; + if (startLine || endLine) { + if (!endLine) { + tmpMsgArray = this.archivedMessages.slice(startLine); + } else { + tmpMsgArray = this.archivedMessages.slice(startLine, endLine); + } + if (logLineCount > 0) { + tmpMsgArray = tmpMsgArray.slice(-logLineCount); + } + } else if (logLineCount > 0) { + tmpMsgArray = this.archivedMessages.slice(-logLineCount); + } else { + tmpMsgArray = this.archivedMessages; } - // if (message.node) { - // messagesHtml += message.node.outerHTML + '\n'; - // } - } - // Create a page - const pageHtml = - '\n' + - '\n' + - '\n' + - 'SS13 Chat Log\n' + - '\n' + - '\n' + - '\n' + - '
\n' + - messagesHtml + - '
\n' + - '\n' + - '\n'; - // Create and send a nice blob - const blob = new Blob([pageHtml], { type: 'text/plain' }); - const timestamp = new Date() - .toISOString() - .substring(0, 19) - .replace(/[-:]/g, '') - .replace('T', '-'); - Byond.saveBlob(blob, `ss13-chatlog-${timestamp}.html`, '.html'); + // for (let message of this.visibleMessages) { // TODO: Actually having a better message archiving maybe for exports? + for (let message of tmpMsgArray) { + // Filter messages according to active tab for export + if (this.page && canPageAcceptType(this.page, message.type)) { + messagesHtml += message.html + '\n'; + } + // if (message.node) { + // messagesHtml += message.node.outerHTML + '\n'; + // } + } + + // Create a page + const pageHtml = + '\n' + + '\n' + + '\n' + + 'SS13 Chat Log\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + messagesHtml + + '
\n' + + '\n' + + '\n'; + // Create and send a nice blob + const blob = new Blob([pageHtml], { type: 'text/plain' }); + const timestamp = new Date() + .toISOString() + .substring(0, 19) + .replace(/[-:]/g, '') + .replace('T', '-'); + Byond.saveBlob(blob, `ss13-chatlog-${timestamp}.html`, '.html'); + } } purgeMessageArchive() { diff --git a/tgui/packages/tgui-panel/game/actions.ts b/tgui/packages/tgui-panel/game/actions.ts index 0604f18272..f012bb474c 100644 --- a/tgui/packages/tgui-panel/game/actions.ts +++ b/tgui/packages/tgui-panel/game/actions.ts @@ -10,3 +10,4 @@ export const roundRestarted = createAction('roundrestart'); export const connectionLost = createAction('game/connectionLost'); export const connectionRestored = createAction('game/connectionRestored'); export const dismissWarning = createAction('game/dismissWarning'); +export const updateExportData = createAction('game/updateExportData'); diff --git a/tgui/packages/tgui-panel/game/reducer.ts b/tgui/packages/tgui-panel/game/reducer.ts index 966367e6e5..e2c2536e90 100644 --- a/tgui/packages/tgui-panel/game/reducer.ts +++ b/tgui/packages/tgui-panel/game/reducer.ts @@ -4,16 +4,21 @@ * @license MIT */ -import { connectionLost } from './actions'; +import { connectionLost, updateExportData } from './actions'; import { connectionRestored, dismissWarning } from './actions'; +import type { gameState } from './types'; -const initialState = { +const initialState: gameState = { // TODO: This is where round info should be. roundId: null, roundTime: null, roundRestartedAt: null, connectionLostAt: null, dismissedConnectionWarning: false, + databaseBackendEnabled: false, + chatlogApiEndpoint: '', + databaseStoredRounds: [], + userData: { ckey: '', token: '' }, }; export const gameReducer = (state = initialState, action) => { @@ -29,6 +34,9 @@ export const gameReducer = (state = initialState, action) => { return { ...state, roundId: payload.round_id, + databaseBackendEnabled: payload.chatlog_db_backend, + chatlogApiEndpoint: payload.chatlog_api_endpoint, + databaseStoredRounds: payload.chatlog_stored_rounds, }; } } @@ -51,5 +59,11 @@ export const gameReducer = (state = initialState, action) => { dismissedConnectionWarning: true, }; } + if (type === updateExportData.type) { + return { + ...state, + userData: payload, + }; + } return state; }; diff --git a/tgui/packages/tgui-panel/game/types.ts b/tgui/packages/tgui-panel/game/types.ts new file mode 100644 index 0000000000..e9084bb5ac --- /dev/null +++ b/tgui/packages/tgui-panel/game/types.ts @@ -0,0 +1,11 @@ +export type gameState = { + roundId: number | null; + roundTime: null | number; + roundRestartedAt: null | number; + connectionLostAt: null | number; + dismissedConnectionWarning: boolean; + databaseBackendEnabled: boolean; + chatlogApiEndpoint: string; + databaseStoredRounds: string[]; + userData: { ckey: string; token: string }; +}; diff --git a/tgui/packages/tgui-panel/settings/SettingTabs/ExportTab.tsx b/tgui/packages/tgui-panel/settings/SettingTabs/ExportTab.tsx index b5a219e1d2..73d9908da6 100644 --- a/tgui/packages/tgui-panel/settings/SettingTabs/ExportTab.tsx +++ b/tgui/packages/tgui-panel/settings/SettingTabs/ExportTab.tsx @@ -5,6 +5,7 @@ import { Button, Collapsible, Divider, + Dropdown, LabeledList, NumberInput, Section, @@ -32,63 +33,56 @@ export const ExportTab = (props) => { totalStoredMessages, storedTypes, } = useSelector(selectSettings); - const [purgeConfirm, setPurgeConfirm] = useState(0); - const [logConfirm, setLogConfirm] = useState(false); + const [purgeButtonText, setPurgeButtonText] = useState( + 'Purge message archive', + ); return (
- {logEnable ? ( - logConfirm ? ( - - ) : ( - - ) - ) : ( - - )} + ))} Round ID:  {game.roundId ? game.roundId : 'ERROR'} + DB Chatlogging:  + + {game.databaseBackendEnabled ? 'Enabled' : 'Disabled'} + - {logEnable ? ( + {logEnable && !game.databaseBackendEnabled && ( <> @@ -109,12 +103,10 @@ export const ExportTab = (props) => { } />   - {logRetainRounds > 3 ? ( + {logRetainRounds > 3 && ( Warning, might crash! - ) : ( - '' )} @@ -135,7 +127,7 @@ export const ExportTab = (props) => { } />   - {logLimit > 0 ? ( + {logLimit > 0 && ( { ? 'Warning, might crash! Takes priority above round retention.' : 'Takes priority above round retention.'} - ) : ( - '' )} @@ -170,48 +160,92 @@ export const ExportTab = (props) => {
- ) : ( - '' )} - toFixed(value)} - onDrag={(value) => - dispatch( - updateSettings({ - exportStart: value, - }), - ) - } - /> - toFixed(value)} - onDrag={(value) => - dispatch( - updateSettings({ - exportEnd: value, - }), - ) - } - /> -   - - Stored Rounds:  - - {storedRounds} + + {game.databaseBackendEnabled ? ( + <> + + + dispatch( + updateSettings({ + exportStart: value, + }), + ) + } + options={game.databaseStoredRounds} + selected={exportStart} + /> + + + + dispatch( + updateSettings({ + exportEnd: value, + }), + ) + } + options={game.databaseStoredRounds} + selected={exportEnd} + /> + + + ) : ( + <> + + toFixed(value)} + onDrag={(value) => + dispatch( + updateSettings({ + exportStart: value, + }), + ) + } + /> + + + toFixed(value)} + onDrag={(value) => + dispatch( + updateSettings({ + exportEnd: value, + }), + ) + } + /> + + + )} + + +  Stored Rounds:  + + + + + {game.databaseBackendEnabled + ? game.databaseStoredRounds.length - 1 + : storedRounds} + + + { } /> - - {totalStoredMessages} - + {!game.databaseBackendEnabled && ( + + {totalStoredMessages} + + )} - {purgeConfirm > 0 ? ( - - ) : ( - + {purgeButtonText} + )} ); diff --git a/tgui/packages/tgui-panel/settings/SettingTabs/MessageLimits.tsx b/tgui/packages/tgui-panel/settings/SettingTabs/MessageLimits.tsx index a26aa1c22b..501c46572c 100644 --- a/tgui/packages/tgui-panel/settings/SettingTabs/MessageLimits.tsx +++ b/tgui/packages/tgui-panel/settings/SettingTabs/MessageLimits.tsx @@ -2,11 +2,13 @@ import { useDispatch, useSelector } from 'tgui/backend'; import { Box, LabeledList, NumberInput, Section } from 'tgui-core/components'; import { toFixed } from 'tgui-core/math'; +import { useGame } from '../../game'; import { updateSettings } from '../actions'; import { selectSettings } from '../selectors'; export const MessageLimits = (props) => { const dispatch = useDispatch(); + const game = useGame(); const { visibleMessageLimit, persistentMessageLimit, @@ -35,12 +37,10 @@ export const MessageLimits = (props) => { } />   - {visibleMessageLimit >= 5000 ? ( + {visibleMessageLimit >= 5000 && ( Impacts performance! - ) : ( - '' )} @@ -61,12 +61,10 @@ export const MessageLimits = (props) => { } />   - {persistentMessageLimit >= 2500 ? ( + {persistentMessageLimit >= 2500 && ( Delays initialization! - ) : ( - '' )} @@ -106,33 +104,33 @@ export const MessageLimits = (props) => { } /> - - toFixed(value)} - onDrag={(value) => - dispatch( - updateSettings({ - saveInterval: value, - }), - ) - } - /> -   - {saveInterval <= 3 ? ( - - Warning, experimental! Might crash! - - ) : ( - '' - )} - + {!game.databaseBackendEnabled && ( + + toFixed(value)} + onDrag={(value) => + dispatch( + updateSettings({ + saveInterval: value, + }), + ) + } + /> +   + {saveInterval <= 3 && ( + + Warning, experimental! Might crash! + + )} + + )} ); diff --git a/tgui/packages/tgui-panel/settings/SettingTabs/TextHighlightSettings.tsx b/tgui/packages/tgui-panel/settings/SettingTabs/TextHighlightSettings.tsx index 57d57e2f76..8e0b45fb05 100644 --- a/tgui/packages/tgui-panel/settings/SettingTabs/TextHighlightSettings.tsx +++ b/tgui/packages/tgui-panel/settings/SettingTabs/TextHighlightSettings.tsx @@ -193,7 +193,7 @@ const TextHighlightSetting = (props) => { ) } /> - {highlightBlacklist ? ( + {!!highlightBlacklist && (