/** * @typedef {ApplicationOptions} ChatLogOptions * @property {boolean} [stream] Is this chat log being rendered as part of the stream view? */ /** * The sidebar directory which organizes and displays world-level ChatMessage documents. * @extends {SidebarTab} * @see {Sidebar} * @param {ChatLogOptions} [options] Application configuration options. */ class ChatLog extends SidebarTab { constructor(options) { super(options); /** * Track any pending text which the user has submitted in the chat log textarea * @type {string} * @private */ this._pendingText = ""; /** * Track the history of the past 5 sent messages which can be accessed using the arrow keys * @type {object[]} * @private */ this._sentMessages = []; /** * Track which remembered message is being currently displayed to cycle properly * @type {number} * @private */ this._sentMessageIndex = -1; /** * Track the time when the last message was sent to avoid flooding notifications * @type {number} * @private */ this._lastMessageTime = 0; /** * Track the id of the last message displayed in the log * @type {string|null} * @private */ this._lastId = null; /** * Track the last received message which included the user as a whisper recipient. * @type {ChatMessage|null} * @private */ this._lastWhisper = null; /** * A reference to the chat text entry bound key method * @type {Function|null} * @private */ this._onChatKeyDownBinding = null; // Update timestamps every 15 seconds setInterval(this.updateTimestamps.bind(this), 1000 * 15); } /** * A flag for whether the chat log is currently scrolled to the bottom * @type {boolean} */ #isAtBottom = true; /** * A cache of the Jump to Bottom element */ #jumpToBottomElement; /** * A semaphore to queue rendering of Chat Messages. * @type {Semaphore} */ #renderingQueue = new foundry.utils.Semaphore(1); /** * Currently rendering the next batch? * @type {boolean} */ #renderingBatch = false; /* -------------------------------------------- */ /** * Returns if the chat log is currently scrolled to the bottom * @returns {boolean} */ get isAtBottom() { return this.#isAtBottom; } /* -------------------------------------------- */ /** * @override * @returns {ChatLogOptions} */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "chat", template: "templates/sidebar/chat-log.html", title: game.i18n.localize("CHAT.Title"), stream: false, scrollY: ["#chat-log"] }); } /* -------------------------------------------- */ /** * An enumeration of regular expression patterns used to match chat messages. * @enum {RegExp} */ static MESSAGE_PATTERNS = (() => { const dice = "([^#]+)(?:#(.*))?"; // Dice expression with appended flavor text const any = "([^]*)"; // Any character, including new lines return { roll: new RegExp(`^(\\/r(?:oll)? )${dice}$`, "i"), // Regular rolls: /r or /roll gmroll: new RegExp(`^(\\/gmr(?:oll)? )${dice}$`, "i"), // GM rolls: /gmr or /gmroll blindroll: new RegExp(`^(\\/b(?:lind)?r(?:oll)? )${dice}$`, "i"), // Blind rolls: /br or /blindroll selfroll: new RegExp(`^(\\/s(?:elf)?r(?:oll)? )${dice}$`, "i"), // Self rolls: /sr or /selfroll publicroll: new RegExp(`^(\\/p(?:ublic)?r(?:oll)? )${dice}$`, "i"), // Public rolls: /pr or /publicroll ic: new RegExp(`^(/ic )${any}`, "i"), ooc: new RegExp(`^(/ooc )${any}`, "i"), emote: new RegExp(`^(/(?:em(?:ote)?|me) )${any}`, "i"), whisper: new RegExp(/^(\/w(?:hisper)?\s)(\[(?:[^\]]+)\]|(?:[^\s]+))\s*([^]*)/, "i"), reply: new RegExp(`^(/reply )${any}`, "i"), gm: new RegExp(`^(/gm )${any}`, "i"), players: new RegExp(`^(/players )${any}`, "i"), macro: new RegExp(`^(\\/m(?:acro)? )${any}`, "i"), invalid: /^(\/[^\s]+)/ // Any other message starting with a slash command is invalid }; })(); /* -------------------------------------------- */ /** * The set of commands that can be processed over multiple lines. * @type {Set} */ static MULTILINE_COMMANDS = new Set(["roll", "gmroll", "blindroll", "selfroll", "publicroll"]); /* -------------------------------------------- */ /** * A reference to the Messages collection that the chat log displays * @type {Messages} */ get collection() { return game.messages; } /* -------------------------------------------- */ /* Application Rendering */ /* -------------------------------------------- */ /** @override */ async getData(options={}) { const context = await super.getData(options); return foundry.utils.mergeObject(context, { rollMode: game.settings.get("core", "rollMode"), rollModes: Object.entries(CONFIG.Dice.rollModes).map(([k, v]) => ({ group: "CHAT.RollDefault", value: k, label: v })), isStream: !!this.options.stream }); } /* -------------------------------------------- */ /** @inheritdoc */ async _render(force, options) { if ( this.rendered ) return; // Never re-render the Chat Log itself, only its contents await super._render(force, options); return this.scrollBottom({waitImages: true}); } /* -------------------------------------------- */ /** @inheritdoc */ async _renderInner(data) { const html = await super._renderInner(data); await this._renderBatch(html, CONFIG.ChatMessage.batchSize); return html; } /* -------------------------------------------- */ /** * Render a batch of additional messages, prepending them to the top of the log * @param {jQuery} html The rendered jQuery HTML object * @param {number} size The batch size to include * @returns {Promise} * @private */ async _renderBatch(html, size) { if ( this.#renderingBatch ) return; this.#renderingBatch = true; return this.#renderingQueue.add(async () => { const messages = this.collection.contents; const log = html.find("#chat-log, #chat-log-popout"); // Get the index of the last rendered message let lastIdx = messages.findIndex(m => m.id === this._lastId); lastIdx = lastIdx !== -1 ? lastIdx : messages.length; // Get the next batch to render let targetIdx = Math.max(lastIdx - size, 0); let m = null; if ( lastIdx !== 0 ) { let html = []; for ( let i=targetIdx; i { // Get the chat message being removed from the log const message = game.messages.get(messageId, {strict: false}); if ( message ) message.logged = false; // Get the current HTML element for the message let li = this.element.find(`.message[data-message-id="${messageId}"]`); if ( !li.length ) return; // Update the last index if ( deleteAll ) { this._lastId = null; } else if ( messageId === this._lastId ) { const next = li[0].nextElementSibling; this._lastId = next ? next.dataset.messageId : null; } // Remove the deleted message li.slideUp(100, () => li.remove()); // Delete from popout tab if ( this._popout ) this._popout.deleteMessage(messageId, {deleteAll}); if ( this.popOut ) this.setPosition(); }); } /* -------------------------------------------- */ /** * Trigger a notification that alerts the user visually and audibly that a new chat log message has been posted * @param {ChatMessage} message The message generating a notification */ notify(message) { this._lastMessageTime = Date.now(); if ( !this.rendered ) return; // Display the chat notification icon and remove it 3 seconds later let icon = $("#chat-notification"); if ( icon.is(":hidden") ) icon.fadeIn(100); setTimeout(() => { if ( (Date.now() - this._lastMessageTime > 3000) && icon.is(":visible") ) icon.fadeOut(100); }, 3001); // Play a notification sound effect if ( message.sound ) game.audio.play(message.sound, {context: game.audio.interface}); } /* -------------------------------------------- */ /** * Parse a chat string to identify the chat command (if any) which was used * @param {string} message The message to match * @returns {string[]} The identified command and regex match */ static parse(message) { for ( const [rule, rgx] of Object.entries(this.MESSAGE_PATTERNS) ) { // For multi-line matches, the first line must match if ( this.MULTILINE_COMMANDS.has(rule) ) { const lines = message.split("\n"); if ( rgx.test(lines[0]) ) return [rule, lines.map(l => l.match(rgx))]; } // For single-line matches, match directly else { const match = message.match(rgx); if ( match ) return [rule, match]; } } return ["none", [message, "", message]]; } /* -------------------------------------------- */ /** * Post a single chat message to the log * @param {ChatMessage} message A ChatMessage document instance to post to the log * @param {object} [options={}] Additional options for how the message is posted to the log * @param {string} [options.before] An existing message ID to append the message before, by default the new message is * appended to the end of the log. * @param {boolean} [options.notify] Trigger a notification which shows the log as having a new unread message. * @returns {Promise} A Promise which resolves once the message is posted */ async postOne(message, {before, notify=false}={}) { if ( !message.visible ) return; return this.#renderingQueue.add(async () => { message.logged = true; // Track internal flags if ( !this._lastId ) this._lastId = message.id; // Ensure that new messages don't result in batched scrolling if ( (message.whisper || []).includes(game.user.id) && !message.isRoll ) { this._lastWhisper = message; } // Render the message to the log const html = await message.getHTML(); const log = this.element.find("#chat-log"); // Append the message after some other one const existing = before ? this.element.find(`.message[data-message-id="${before}"]`) : []; if ( existing.length ) existing.before(html); // Otherwise, append the message to the bottom of the log else { log.append(html); if ( this.isAtBottom || (message.author._id === game.user._id) ) this.scrollBottom({waitImages: true}); } // Post notification if ( notify ) this.notify(message); // Update popout tab if ( this._popout ) await this._popout.postOne(message, {before, notify: false}); if ( this.popOut ) this.setPosition(); }); } /* -------------------------------------------- */ /** * Scroll the chat log to the bottom * @param {object} [options] * @param {boolean} [options.popout=false] If a popout exists, scroll it to the bottom too. * @param {boolean} [options.waitImages=false] Wait for any images embedded in the chat log to load first * before scrolling? * @param {ScrollIntoViewOptions} [options.scrollOptions] Options to configure scrolling behaviour. */ async scrollBottom({popout=false, waitImages=false, scrollOptions={}}={}) { if ( !this.rendered ) return; if ( waitImages ) await this._waitForImages(); const log = this.element[0].querySelector("#chat-log"); log.lastElementChild?.scrollIntoView(scrollOptions); if ( popout ) this._popout?.scrollBottom({waitImages, scrollOptions}); } /* -------------------------------------------- */ /** * Update the content of a previously posted message after its data has been replaced * @param {ChatMessage} message The ChatMessage instance to update * @param {boolean} notify Trigger a notification which shows the log as having a new unread message */ async updateMessage(message, notify=false) { let li = this.element.find(`.message[data-message-id="${message.id}"]`); if ( li.length ) { const html = await message.getHTML(); li.replaceWith(html); } // Add a newly visible message to the log else { const messages = game.messages.contents; const messageIndex = messages.findIndex(m => m === message); let nextMessage; for ( let i = messageIndex + 1; i < messages.length; i++ ) { if ( messages[i].visible ) { nextMessage = messages[i]; break; } } await this.postOne(message, {before: nextMessage?.id, notify: false}); } // Post notification of update if ( notify ) this.notify(message); // Update popout tab if ( this._popout ) await this._popout.updateMessage(message, false); if ( this.popOut ) this.setPosition(); } /* -------------------------------------------- */ /** * Update the displayed timestamps for every displayed message in the chat log. * Timestamps are displayed in a humanized "timesince" format. */ updateTimestamps() { const messages = this.element.find("#chat-log .message"); for ( let li of messages ) { const message = game.messages.get(li.dataset.messageId); if ( !message?.timestamp ) return; const stamp = li.querySelector(".message-timestamp"); if (stamp) stamp.textContent = foundry.utils.timeSince(message.timestamp); } } /* -------------------------------------------- */ /* Event Listeners and Handlers /* -------------------------------------------- */ /** @inheritdoc */ activateListeners(html) { // Load new messages on scroll html.find("#chat-log").scroll(this._onScrollLog.bind(this)); // Chat message entry this._onChatKeyDownBinding = this._onChatKeyDown.bind(this); html.find("#chat-message").keydown(this._onChatKeyDownBinding); // Expand dice roll tooltips html.on("click", ".dice-roll", this._onDiceRollClick.bind(this)); // Modify Roll Type html.find('select[name="rollMode"]').change(this._onChangeRollMode.bind(this)); // Single Message Delete html.on("click", "a.message-delete", this._onDeleteMessage.bind(this)); // Flush log html.find("a.chat-flush").click(this._onFlushLog.bind(this)); // Export log html.find("a.export-log").click(this._onExportLog.bind(this)); // Jump to Bottom html.find(".jump-to-bottom > a").click(() => this.scrollBottom()); // Content Link Dragging html[0].addEventListener("drop", ChatLog._onDropTextAreaData); // Chat Entry context menu this._contextMenu(html); } /* -------------------------------------------- */ /** * Handle dropping of transferred data onto the chat editor * @param {DragEvent} event The originating drop event which triggered the data transfer * @private */ static async _onDropTextAreaData(event) { event.preventDefault(); const textarea = event.target; // Drop cross-linked content const eventData = TextEditor.getDragEventData(event); const link = await TextEditor.getContentLink(eventData); if ( link ) textarea.value += link; // Record pending text this._pendingText = textarea.value; } /* -------------------------------------------- */ /** * Prepare the data object of chat message data depending on the type of message being posted * @param {string} message The original string of the message content * @param {object} [options] Additional options * @param {ChatSpeakerData} [options.speaker] The speaker data * @returns {Promise} The prepared chat data object, or void if we were executing a macro instead */ async processMessage(message, {speaker}={}) { message = message.trim(); if ( !message ) return; const cls = ChatMessage.implementation; // Set up basic chat data const chatData = { user: game.user.id, speaker: speaker ?? cls.getSpeaker() }; if ( Hooks.call("chatMessage", this, message, chatData) === false ) return; // Parse the message to determine the matching handler let [command, match] = this.constructor.parse(message); // Special handlers for no command if ( command === "invalid" ) throw new Error(game.i18n.format("CHAT.InvalidCommand", {command: match[1]})); else if ( command === "none" ) command = chatData.speaker.token ? "ic" : "ooc"; // Process message data based on the identified command type const createOptions = {}; switch (command) { case "roll": case "gmroll": case "blindroll": case "selfroll": case "publicroll": await this._processDiceCommand(command, match, chatData, createOptions); break; case "whisper": case "reply": case "gm": case "players": this._processWhisperCommand(command, match, chatData, createOptions); break; case "ic": case "emote": case "ooc": this._processChatCommand(command, match, chatData, createOptions); break; case "macro": this._processMacroCommand(command, match); return; } // Create the message using provided data and options return cls.create(chatData, createOptions); } /* -------------------------------------------- */ /** * Process messages which are posted using a dice-roll command * @param {string} command The chat command type * @param {RegExpMatchArray[]} matches Multi-line matched roll expressions * @param {Object} chatData The initial chat data * @param {Object} createOptions Options used to create the message * @private */ async _processDiceCommand(command, matches, chatData, createOptions) { const actor = ChatMessage.getSpeakerActor(chatData.speaker) || game.user.character; const rollData = actor ? actor.getRollData() : {}; const rolls = []; const rollMode = command === "roll" ? game.settings.get("core", "rollMode") : command; for ( const match of matches ) { if ( !match ) continue; const [formula, flavor] = match.slice(2, 4); if ( flavor && !chatData.flavor ) chatData.flavor = flavor; const roll = Roll.create(formula, rollData); await roll.evaluate({allowInteractive: rollMode !== CONST.DICE_ROLL_MODES.BLIND}); rolls.push(roll); } chatData.rolls = rolls; chatData.sound = CONFIG.sounds.dice; chatData.content = rolls.reduce((t, r) => t + r.total, 0); createOptions.rollMode = rollMode; } /* -------------------------------------------- */ /** * Process messages which are posted using a chat whisper command * @param {string} command The chat command type * @param {RegExpMatchArray} match The matched RegExp expressions * @param {Object} chatData The initial chat data * @param {Object} createOptions Options used to create the message * @private */ _processWhisperCommand(command, match, chatData, createOptions) { delete chatData.speaker; // Determine the recipient users let users = []; let message= ""; switch ( command ) { case "whisper": message = match[3]; const names = match[2].replace(/[\[\]]/g, "").split(",").map(n => n.trim()); users = names.reduce((arr, n) => arr.concat(ChatMessage.getWhisperRecipients(n)), []); break; case "reply": message = match[2]; const w = this._lastWhisper; if ( w ) { const group = new Set(w.whisper); group.delete(game.user.id); group.add(w.author.id); users = Array.from(group).map(id => game.users.get(id)); } break; case "gm": message = match[2]; users = ChatMessage.getWhisperRecipients("gm"); break; case "players": message = match[2]; users = ChatMessage.getWhisperRecipients("players"); break; } // Add line break elements message = message.replace(/\n/g, "
"); // Ensure we have valid whisper targets if ( !users.length ) throw new Error(game.i18n.localize("ERROR.NoTargetUsersForWhisper")); if ( users.some(u => !u.isGM) && !game.user.can("MESSAGE_WHISPER") ) { throw new Error(game.i18n.localize("ERROR.CantWhisper")); } // Update chat data chatData.whisper = users.map(u => u.id); chatData.content = message; chatData.sound = CONFIG.sounds.notification; } /* -------------------------------------------- */ /** * Process messages which are posted using a chat whisper command * @param {string} command The chat command type * @param {RegExpMatchArray} match The matched RegExp expressions * @param {Object} chatData The initial chat data * @param {Object} createOptions Options used to create the message * @private */ _processChatCommand(command, match, chatData, createOptions) { if ( ["ic", "emote"].includes(command) && !(chatData.speaker.actor || chatData.speaker.token) ) { throw new Error("You cannot chat in-character without an identified speaker"); } chatData.content = match[2].replace(/\n/g, "
"); // Augment chat data if ( command === "ic" ) { chatData.style = CONST.CHAT_MESSAGE_STYLES.IC; createOptions.chatBubble = true; } else if ( command === "emote" ) { chatData.style = CONST.CHAT_MESSAGE_STYLES.EMOTE; chatData.content = `${chatData.speaker.alias} ${chatData.content}`; createOptions.chatBubble = true; } else { chatData.style = CONST.CHAT_MESSAGE_STYLES.OOC; delete chatData.speaker; } } /* -------------------------------------------- */ /** * Process messages which execute a macro. * @param {string} command The chat command typed. * @param {RegExpMatchArray} match The RegExp matches. * @private */ _processMacroCommand(command, match) { // Parse the macro command with the form /macro {macroName} [param1=val1] [param2=val2] ... let [macroName, ...params] = match[2].split(" "); let expandName = true; const scope = {}; let k = undefined; for ( const p of params ) { const kv = p.split("="); if ( kv.length === 2 ) { k = kv[0]; scope[k] = kv[1]; expandName = false; } else if ( expandName ) macroName += ` ${p}`; // Macro names may contain spaces else if ( k ) scope[k] += ` ${p}`; // Expand prior argument value } macroName = macroName.trimEnd(); // Eliminate trailing spaces // Get the target macro by number or by name let macro; if ( Number.isNumeric(macroName) ) { const macroID = game.user.hotbar[macroName]; macro = game.macros.get(macroID); } if ( !macro ) macro = game.macros.getName(macroName); if ( !macro ) throw new Error(`Requested Macro "${macroName}" was not found as a named macro or hotbar position`); // Execute the Macro with provided scope return macro.execute(scope); } /* -------------------------------------------- */ /** * Add a sent message to an array of remembered messages to be re-sent if the user pages up with the up arrow key * @param {string} message The message text being remembered * @private */ _remember(message) { if ( this._sentMessages.length === 5 ) this._sentMessages.splice(4, 1); this._sentMessages.unshift(message); this._sentMessageIndex = -1; } /* -------------------------------------------- */ /** * Recall a previously sent message by incrementing up (1) or down (-1) through the sent messages array * @param {number} direction The direction to recall, positive for older, negative for more recent * @return {string} The recalled message, or an empty string * @private */ _recall(direction) { if ( this._sentMessages.length > 0 ) { let idx = this._sentMessageIndex + direction; this._sentMessageIndex = Math.clamp(idx, -1, this._sentMessages.length-1); } return this._sentMessages[this._sentMessageIndex] || ""; } /* -------------------------------------------- */ /** @inheritdoc */ _contextMenu(html) { ContextMenu.create(this, html, ".message", this._getEntryContextOptions()); } /* -------------------------------------------- */ /** * Get the ChatLog entry context options * @return {object[]} The ChatLog entry context options * @private */ _getEntryContextOptions() { return [ { name: "CHAT.PopoutMessage", icon: '', condition: li => { const message = game.messages.get(li.data("messageId")); return message.getFlag("core", "canPopout") === true; }, callback: li => { const message = game.messages.get(li.data("messageId")); new ChatPopout(message).render(true); } }, { name: "CHAT.RevealMessage", icon: '', condition: li => { const message = game.messages.get(li.data("messageId")); const isLimited = message.whisper.length || message.blind; return isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible; }, callback: li => { const message = game.messages.get(li.data("messageId")); return message.update({whisper: [], blind: false}); } }, { name: "CHAT.ConcealMessage", icon: '', condition: li => { const message = game.messages.get(li.data("messageId")); const isLimited = message.whisper.length || message.blind; return !isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible; }, callback: li => { const message = game.messages.get(li.data("messageId")); return message.update({whisper: ChatMessage.getWhisperRecipients("gm").map(u => u.id), blind: false}); } }, { name: "SIDEBAR.Delete", icon: '', condition: li => { const message = game.messages.get(li.data("messageId")); return message.canUserModify(game.user, "delete"); }, callback: li => { const message = game.messages.get(li.data("messageId")); return message.delete(); } } ]; } /* -------------------------------------------- */ /** * Handle keydown events in the chat entry textarea * @param {KeyboardEvent} event * @private */ _onChatKeyDown(event) { const code = event.code; const textarea = event.currentTarget; if ( event.originalEvent.isComposing ) return; // Ignore IME composition // UP/DOWN ARROW -> Recall Previous Messages const isArrow = ["ArrowUp", "ArrowDown"].includes(code); if ( isArrow ) { if ( this._pendingText ) return; event.preventDefault(); textarea.value = this._recall(code === "ArrowUp" ? 1 : -1); return; } // ENTER -> Send Message const isEnter = ( (code === "Enter") || (code === "NumpadEnter") ) && !event.shiftKey; if ( isEnter ) { event.preventDefault(); const message = textarea.value; if ( !message ) return; event.stopPropagation(); this._pendingText = ""; // Prepare chat message data and handle result return this.processMessage(message).then(() => { textarea.value = ""; this._remember(message); }).catch(error => { ui.notifications.error(error); throw error; }); } // BACKSPACE -> Remove pending text if ( event.key === "Backspace" ) { this._pendingText = this._pendingText.slice(0, -1); return } // Otherwise, record that there is pending text this._pendingText = textarea.value + (event.key.length === 1 ? event.key : ""); } /* -------------------------------------------- */ /** * Handle setting the preferred roll mode * @param {Event} event * @private */ _onChangeRollMode(event) { event.preventDefault(); game.settings.set("core", "rollMode", event.target.value); } /* -------------------------------------------- */ /** * Handle single message deletion workflow * @param {Event} event * @private */ _onDeleteMessage(event) { event.preventDefault(); const li = event.currentTarget.closest(".message"); const messageId = li.dataset.messageId; const message = game.messages.get(messageId); return message ? message.delete() : this.deleteMessage(messageId); } /* -------------------------------------------- */ /** * Handle clicking of dice tooltip buttons * @param {Event} event * @private */ _onDiceRollClick(event) { event.preventDefault(); // Toggle the message flag let roll = event.currentTarget; const message = game.messages.get(roll.closest(".message").dataset.messageId); message._rollExpanded = !message._rollExpanded; // Expand or collapse tooltips const tooltips = roll.querySelectorAll(".dice-tooltip"); for ( let tip of tooltips ) { if ( message._rollExpanded ) $(tip).slideDown(200); else $(tip).slideUp(200); tip.classList.toggle("expanded", message._rollExpanded); } } /* -------------------------------------------- */ /** * Handle click events to export the chat log * @param {Event} event * @private */ _onExportLog(event) { event.preventDefault(); game.messages.export(); } /* -------------------------------------------- */ /** * Handle click events to flush the chat log * @param {Event} event * @private */ _onFlushLog(event) { event.preventDefault(); game.messages.flush(this.#jumpToBottomElement); } /* -------------------------------------------- */ /** * Handle scroll events within the chat log container * @param {UIEvent} event The initial scroll event * @private */ _onScrollLog(event) { if ( !this.rendered ) return; const log = event.target; const pct = log.scrollTop / (log.scrollHeight - log.clientHeight); if ( !this.#jumpToBottomElement ) this.#jumpToBottomElement = this.element.find(".jump-to-bottom")[0]; this.#isAtBottom = (pct > 0.99) || Number.isNaN(pct); this.#jumpToBottomElement.classList.toggle("hidden", this.#isAtBottom); if ( pct < 0.01 ) return this._renderBatch(this.element, CONFIG.ChatMessage.batchSize); } /* -------------------------------------------- */ /** * Update roll mode select dropdowns when the setting is changed * @param {string} mode The new roll mode setting */ static _setRollMode(mode) { for ( let select of $(".roll-type-select") ) { for ( let option of select.options ) { option.selected = option.value === mode; } } } }