Files
Foundry-VTT-Docker/resources/app/client/data/documents/chat-message.js
2025-01-04 00:34:03 +01:00

519 lines
17 KiB
JavaScript

/**
* 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<jQuery>}
*/
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<string>} 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 = $("<article>").html(this.content.replace(/<\/div>/g, "</div>|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")}`;
}
}