/** * The client-side ChatMessage document which extends the common BaseChatMessage model. * * @extends foundry.documents.BaseChatMessage * @mixes ClientDocumentMixin * * @see {@link Messages} The world-level collection of ChatMessage documents * * @property {Roll[]} rolls The prepared array of Roll instances */ class ChatMessage extends ClientDocumentMixin(foundry.documents.BaseChatMessage) { /** * Is the display of dice rolls in this message collapsed (false) or expanded (true) * @type {boolean} * @private */ _rollExpanded = false; /** * Is this ChatMessage currently displayed in the sidebar ChatLog? * @type {boolean} */ logged = false; /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * Return the recommended String alias for this message. * The alias could be a Token name in the case of in-character messages or dice rolls. * Alternatively it could be the name of a User in the case of OOC chat or whispers. * @type {string} */ get alias() { const speaker = this.speaker; if ( speaker.alias ) return speaker.alias; else if ( game.actors.has(speaker.actor) ) return game.actors.get(speaker.actor).name; else return this.author?.name ?? game.i18n.localize("CHAT.UnknownUser"); } /* -------------------------------------------- */ /** * Is the current User the author of this message? * @type {boolean} */ get isAuthor() { return game.user === this.author; } /* -------------------------------------------- */ /** * Return whether the content of the message is visible to the current user. * For certain dice rolls, for example, the message itself may be visible while the content of that message is not. * @type {boolean} */ get isContentVisible() { if ( this.isRoll ) { const whisper = this.whisper || []; const isBlind = whisper.length && this.blind; if ( whisper.length ) return whisper.includes(game.user.id) || (this.isAuthor && !isBlind); return true; } else return this.visible; } /* -------------------------------------------- */ /** * Does this message contain dice rolls? * @type {boolean} */ get isRoll() { return this.rolls.length > 0; } /* -------------------------------------------- */ /** * Return whether the ChatMessage is visible to the current User. * Messages may not be visible if they are private whispers. * @type {boolean} */ get visible() { if ( this.whisper.length ) { if ( this.isRoll ) return true; return this.isAuthor || (this.whisper.indexOf(game.user.id) !== -1); } return true; } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** @inheritdoc */ prepareDerivedData() { super.prepareDerivedData(); // Create Roll instances for contained dice rolls this.rolls = this.rolls.reduce((rolls, rollData) => { try { rolls.push(Roll.fromData(rollData)); } catch(err) { Hooks.onError("ChatMessage#rolls", err, {rollData, log: "error"}); } return rolls; }, []); } /* -------------------------------------------- */ /** * Transform a provided object of ChatMessage data by applying a certain rollMode to the data object. * @param {object} chatData The object of ChatMessage data prior to applying a rollMode preference * @param {string} rollMode The rollMode preference to apply to this message data * @returns {object} The modified ChatMessage data with rollMode preferences applied */ static applyRollMode(chatData, rollMode) { const modes = CONST.DICE_ROLL_MODES; if ( rollMode === "roll" ) rollMode = game.settings.get("core", "rollMode"); if ( [modes.PRIVATE, modes.BLIND].includes(rollMode) ) { chatData.whisper = ChatMessage.getWhisperRecipients("GM").map(u => u.id); } else if ( rollMode === modes.SELF ) chatData.whisper = [game.user.id]; else if ( rollMode === modes.PUBLIC ) chatData.whisper = []; chatData.blind = rollMode === modes.BLIND; return chatData; } /* -------------------------------------------- */ /** * Update the data of a ChatMessage instance to apply a requested rollMode * @param {string} rollMode The rollMode preference to apply to this message data */ applyRollMode(rollMode) { const updates = {}; this.constructor.applyRollMode(updates, rollMode); this.updateSource(updates); } /* -------------------------------------------- */ /** * Attempt to determine who is the speaking character (and token) for a certain Chat Message * First assume that the currently controlled Token is the speaker * * @param {object} [options={}] Options which affect speaker identification * @param {Scene} [options.scene] The Scene in which the speaker resides * @param {Actor} [options.actor] The Actor who is speaking * @param {TokenDocument} [options.token] The Token who is speaking * @param {string} [options.alias] The name of the speaker to display * * @returns {object} The identified speaker data */ static getSpeaker({scene, actor, token, alias}={}) { // CASE 1 - A Token is explicitly provided const hasToken = (token instanceof Token) || (token instanceof TokenDocument); if ( hasToken ) return this._getSpeakerFromToken({token, alias}); const hasActor = actor instanceof Actor; if ( hasActor && actor.isToken ) return this._getSpeakerFromToken({token: actor.token, alias}); // CASE 2 - An Actor is explicitly provided if ( hasActor ) { alias = alias || actor.name; const tokens = actor.getActiveTokens(); if ( !tokens.length ) return this._getSpeakerFromActor({scene, actor, alias}); const controlled = tokens.filter(t => t.controlled); token = controlled.length ? controlled.shift() : tokens.shift(); return this._getSpeakerFromToken({token: token.document, alias}); } // CASE 3 - Not the viewed Scene else if ( ( scene instanceof Scene ) && !scene.isView ) { const char = game.user.character; if ( char ) return this._getSpeakerFromActor({scene, actor: char, alias}); return this._getSpeakerFromUser({scene, user: game.user, alias}); } // CASE 4 - Infer from controlled tokens if ( canvas.ready ) { let controlled = canvas.tokens.controlled; if (controlled.length) return this._getSpeakerFromToken({token: controlled.shift().document, alias}); } // CASE 5 - Infer from impersonated Actor const char = game.user.character; if ( char ) { const tokens = char.getActiveTokens(false, true); if ( tokens.length ) return this._getSpeakerFromToken({token: tokens.shift(), alias}); return this._getSpeakerFromActor({actor: char, alias}); } // CASE 6 - From the alias and User return this._getSpeakerFromUser({scene, user: game.user, alias}); } /* -------------------------------------------- */ /** * A helper to prepare the speaker object based on a target TokenDocument * @param {object} [options={}] Options which affect speaker identification * @param {TokenDocument} options.token The TokenDocument of the speaker * @param {string} [options.alias] The name of the speaker to display * @returns {object} The identified speaker data * @private */ static _getSpeakerFromToken({token, alias}) { return { scene: token.parent?.id || null, token: token.id, actor: token.actor?.id || null, alias: alias || token.name }; } /* -------------------------------------------- */ /** * A helper to prepare the speaker object based on a target Actor * @param {object} [options={}] Options which affect speaker identification * @param {Scene} [options.scene] The Scene is which the speaker resides * @param {Actor} [options.actor] The Actor that is speaking * @param {string} [options.alias] The name of the speaker to display * @returns {Object} The identified speaker data * @private */ static _getSpeakerFromActor({scene, actor, alias}) { return { scene: (scene || canvas.scene)?.id || null, actor: actor.id, token: null, alias: alias || actor.name }; } /* -------------------------------------------- */ /** * A helper to prepare the speaker object based on a target User * @param {object} [options={}] Options which affect speaker identification * @param {Scene} [options.scene] The Scene in which the speaker resides * @param {User} [options.user] The User who is speaking * @param {string} [options.alias] The name of the speaker to display * @returns {Object} The identified speaker data * @private */ static _getSpeakerFromUser({scene, user, alias}) { return { scene: (scene || canvas.scene)?.id || null, actor: null, token: null, alias: alias || user.name }; } /* -------------------------------------------- */ /** * Obtain an Actor instance which represents the speaker of this message (if any) * @param {Object} speaker The speaker data object * @returns {Actor|null} */ static getSpeakerActor(speaker) { if ( !speaker ) return null; let actor = null; // Case 1 - Token actor if ( speaker.scene && speaker.token ) { const scene = game.scenes.get(speaker.scene); const token = scene ? scene.tokens.get(speaker.token) : null; actor = token?.actor; } // Case 2 - explicit actor if ( speaker.actor && !actor ) { actor = game.actors.get(speaker.actor); } return actor || null; } /* -------------------------------------------- */ /** * Obtain a data object used to evaluate any dice rolls associated with this particular chat message * @returns {object} */ getRollData() { const actor = this.constructor.getSpeakerActor(this.speaker) ?? this.author?.character; return actor ? actor.getRollData() : {}; } /* -------------------------------------------- */ /** * Given a string whisper target, return an Array of the user IDs which should be targeted for the whisper * * @param {string} name The target name of the whisper target * @returns {User[]} An array of User instances */ static getWhisperRecipients(name) { // Whisper to groups if (["GM", "DM"].includes(name.toUpperCase())) { return game.users.filter(u => u.isGM); } else if (name.toLowerCase() === "players") { return game.users.players; } const lowerName = name.toLowerCase(); const users = game.users.filter(u => u.name.toLowerCase() === lowerName); if ( users.length ) return users; const actors = game.users.filter(a => a.character && (a.character.name.toLowerCase() === lowerName)); if ( actors.length ) return actors; // Otherwise, return an empty array return []; } /* -------------------------------------------- */ /** * Render the HTML for the ChatMessage which should be added to the log * @returns {Promise} */ async getHTML() { // Determine some metadata const data = this.toObject(false); data.content = await TextEditor.enrichHTML(this.content, {rollData: this.getRollData()}); const isWhisper = this.whisper.length; // Construct message data const messageData = { message: data, user: game.user, author: this.author, alias: this.alias, cssClass: [ this.style === CONST.CHAT_MESSAGE_STYLES.IC ? "ic" : null, this.style === CONST.CHAT_MESSAGE_STYLES.EMOTE ? "emote" : null, isWhisper ? "whisper" : null, this.blind ? "blind": null ].filterJoin(" "), isWhisper: this.whisper.length, canDelete: game.user.isGM, // Only GM users are allowed to have the trash-bin icon in the chat log itself whisperTo: this.whisper.map(u => { let user = game.users.get(u); return user ? user.name : null; }).filterJoin(", ") }; // Render message data specifically for ROLL type messages if ( this.isRoll ) await this._renderRollContent(messageData); // Define a border color if ( this.style === CONST.CHAT_MESSAGE_STYLES.OOC ) messageData.borderColor = this.author?.color.css; // Render the chat message let html = await renderTemplate(CONFIG.ChatMessage.template, messageData); html = $(html); // Flag expanded state of dice rolls if ( this._rollExpanded ) html.find(".dice-tooltip").addClass("expanded"); Hooks.call("renderChatMessage", this, html, messageData); return html; } /* -------------------------------------------- */ /** * Render the inner HTML content for ROLL type messages. * @param {object} messageData The chat message data used to render the message HTML * @returns {Promise} * @private */ async _renderRollContent(messageData) { const data = messageData.message; const renderRolls = async isPrivate => { let html = ""; for ( const r of this.rolls ) { html += await r.render({isPrivate}); } return html; }; // Suppress the "to:" whisper flavor for private rolls if ( this.blind || this.whisper.length ) messageData.isWhisper = false; // Display standard Roll HTML content if ( this.isContentVisible ) { const el = document.createElement("div"); el.innerHTML = data.content; // Ensure the content does not already contain custom HTML if ( !el.childElementCount && this.rolls.length ) data.content = await this._renderRollHTML(false); } // Otherwise, show "rolled privately" messages for Roll content else { const name = this.author?.name ?? game.i18n.localize("CHAT.UnknownUser"); data.flavor = game.i18n.format("CHAT.PrivateRollContent", {user: name}); data.content = await renderRolls(true); messageData.alias = name; } } /* -------------------------------------------- */ /** * Render HTML for the array of Roll objects included in this message. * @param {boolean} isPrivate Is the chat message private? * @returns {Promise} The rendered HTML string * @private */ async _renderRollHTML(isPrivate) { let html = ""; for ( const roll of this.rolls ) { html += await roll.render({isPrivate}); } return html; } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ async _preCreate(data, options, user) { const allowed = await super._preCreate(data, options, user); if ( allowed === false ) return false; if ( foundry.utils.getType(data.content) === "string" ) { // Evaluate any immediately-evaluated inline rolls. const matches = data.content.matchAll(/\[\[[^/].*?]{2,3}/g); let content = data.content; for ( const [expression] of matches ) { content = content.replace(expression, await TextEditor.enrichHTML(expression, { documents: false, secrets: false, links: false, rolls: true, rollData: this.getRollData() })); } this.updateSource({content}); } if ( this.isRoll ) { if ( !("sound" in data) ) this.updateSource({sound: CONFIG.sounds.dice}); if ( options.rollMode || !(data.whisper?.length > 0) ) this.applyRollMode(options.rollMode || "roll"); } } /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); ui.chat.postOne(this, {notify: true}); if ( options.chatBubble && canvas.ready ) { game.messages.sayBubble(this); } } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { if ( !this.visible ) ui.chat.deleteMessage(this.id); else ui.chat.updateMessage(this); super._onUpdate(changed, options, userId); } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { ui.chat.deleteMessage(this.id, options); super._onDelete(options, userId); } /* -------------------------------------------- */ /* Importing and Exporting */ /* -------------------------------------------- */ /** * Export the content of the chat message into a standardized log format * @returns {string} */ export() { let content = []; // Handle HTML content if ( this.content ) { const html = $("
").html(this.content.replace(/<\/div>/g, "|n")); const text = html.length ? html.text() : this.content; const lines = text.replace(/\n/g, "").split(" ").filter(p => p !== "").join(" "); content = lines.split("|n").map(l => l.trim()); } // Add Roll content for ( const roll of this.rolls ) { content.push(`${roll.formula} = ${roll.result} = ${roll.total}`); } // Author and timestamp const time = new Date(this.timestamp).toLocaleDateString("en-US", { hour: "numeric", minute: "numeric", second: "numeric" }); // Format logged result return `[${time}] ${this.alias}\n${content.filterJoin("\n")}`; } }