TheMillsMessages/scripts/mills_messages.mjs
trotFunky c83122548a Initial commit - v0.1.0
This is the initial version of the module, for Foundry v12.

It allows GMs to write and send messages to players via socketlib,
storing an history of those messages, and displaying them on the player's
side, with some potential flair, and some controls in the settings.

This *does not work* with Foundry v13, as the toolbar registration process
has changed.
2025-05-12 21:26:21 +01:00

352 lines
14 KiB
JavaScript

export const module_id = "the-mills-messages";
import { module_settings, register_settings } from "./settings.mjs";
import { prepare_send_message_html, validate_form } from "./send_dialog_form.mjs";
// User flag to point to the ID of the journal to store message history to
const history_flag = "history-journal";
/** @type {SocketlibSocket} */
let socket;
const SocketMessages = Object.freeze({
DisplayMessage: "display-message",
})
/**
* This is a copy of Foundry's internal formatting used by
* localize.format(), so that we can be consistent.
* @param {String} str The string whose "{named_args}" will be formatted
* @param {Object} data An object with named_args members, whose data is replaced
* @returns {String} The formatted string
*/
function str_format(str, data) {
const fmt = /{[^}]+}/g;
str = str.replace(fmt, k => {
return data[k.slice(1, -1)];
});
return str;
}
/**
* Create the journal entry for the history of the specified user,
* update the user's history flag and return the entry for further
* manipulation if needed.
* @param {User} user The user object that we are creating the history for
* @return {Promise<JournalEntry>} The JournalEntry that was created.
*/
async function create_history_journal(user) {
// Use the settings' title for the journal, or revert to the default one if it is empty.
let format_name = {name: user.character ? user.character.name : user.name};
let formated_journal_title = str_format(game.settings.get(module_id, module_settings.HistoryJournalTitle), format_name)
let title = formated_journal_title.length > 0 ?
formated_journal_title :
game.i18n.format("MM.UI.HistoryJournalTitle", format_name)
return JournalEntry.create({
name: title,
ownership: {
default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE,
[user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER // Make the history private and unmodifiable
},
}).then(journal => {
return user.setFlag(module_id, history_flag, journal.id).then(() => journal);
});
}
/**
* Handle the dialog to send messages to players from NPCs.
* The basic dialog is simple : display a few fields selecting which players
* to send the message to, with what sender and body.
*
* We validate the fields with the button callbacks, as we need to get the return
* value out of the dialog. To be able to differentiate between an invalid form
* and a closed dialog, we need to return something in all cases if the form was
* submitted, which allows to check for the validity and save the values of the
* fields if there were some already.
* The dialog window is re-opened on submit with its previous values until it is valid,
* after which the message can be sent.
*
* We want to be able to send a chain of messages, so we enclose this whole process
* within another loop, and track the messages in an array.
* This allows us to populate the form with previous inputs and go back and forth
* within the chain, to be able to fix mistakes or send fewer messages.
*
* NOTE: The recipients are the same for all the messages of a chain, to simplify
* the processing later as we only have to build one array of messages, shared among
* the recipients.
* @returns {Promise<void>} The Promise for the processing of the messages.
*/
async function send_message_dialog() {
/** @type{MessageFormResponse[]} */
let messages = []
let current_message = 0
let chain = false;
do {
if (chain) {
// Propagate the recipients of the previous message, as the field is disabled in chained messages
messages[current_message] = Object.assign(messages[current_message] ? messages[current_message] : {},
{recipients: messages[current_message - 1].recipients})
}
/** @type{DialogFormResponse} */
let dialog_response
do {
dialog_response = await foundry.applications.api.DialogV2.wait({
window: {title: current_message > 0 ? "MM.Dialogs.SendDialog.TitleChain" : "MM.Dialogs.SendDialog.Title"},
classes: ["mm-send-dialog"],
// Only count as a chain after the first message.
content: prepare_send_message_html(messages[current_message], current_message > 0),
buttons: [{
action: "submit",
default: true,
label: current_message > 0 ? "MM.Dialogs.SendDialog.SendChain" : "MM.Dialogs.SendDialog.Send",
callback: (_, button, __) => {
chain = false
return validate_form({
recipients: button.form.elements.recipients_IDs.value,
sender: button.form.elements.sender_name.value,
message: button.form.elements.message.value,
})
}
}, {
action: "chain",
label: "MM.Dialogs.SendDialog.Chain",
callback: (_, button, __) => {
chain = true
return validate_form({
recipients: button.form.elements.recipients_IDs.value,
sender: button.form.elements.sender_name.value,
message: button.form.elements.message.value,
})
}
}],
rejectClose: false, // No error on close, simply dismiss.
localize: true,
// The content container element only exists after rendering, not when creating the editor element.
render: (event, element) =>
element.getElementsByClassName("editor-content")[0].classList.add("scrollable"),
})
.catch((reason) => ui.notifications.warn(
game.i18n.format("MM.Errors.MessageSendIssue", {reason: reason})
))
// Dialog was dismissed or otherwise closed
if (!dialog_response && current_message === 0) {
console.debug("Send message dialog dismissed")
return;
} else if (!dialog_response) {
console.debug("Closed a chain message dialog")
/*
* Dismissed in the middle of a chain.
* Don't lose the whole chain : go back to the previous message.
*/
current_message -= 1
continue;
}
/*
* Form was submitted, update the inputs even if invalid as
* they are used to repopulate the form.
*/
messages[current_message] = dialog_response.form_response // Checked above as non-null
} while (!dialog_response || !dialog_response.valid)
current_message += 1
} while (chain);
/*
* There might be more messages in the chain, but
* only send those up to the one the user submitted.
*/
process_messages(messages.slice(0, current_message));
}
/**
* @typedef {Object} Message
* @property {string} sender The name to display as the origin of the message, and in the history
* @property {string} text The body of the message, a string of HTML
*/
/**
* Send the message to be displayed by the selected recipients and save them to the journals.
* This assumes that the recipients of a chain are the same, so we can build one array of messages
* used for all of them.
* We try to have some concurrency as all players can be updated independently, but keep it simple
* for now.
* @param {MessageFormResponse[]} chained_form_responses
*/
async function process_messages(chained_form_responses) {
/** @type{string[]} */
let rendered_messages = await Promise.all(
chained_form_responses.map(
(form_response) => TextEditor.enrichHTML(form_response.message)
)
).then((results) => results)
/**
* Build the array of messages to be sent from the original chain and the rendered text,
* as the recipients are not sent (unlike with email !).
* @type{Message[]}
*/
let messages = chained_form_responses.map(
(form_response, index) => {return {
sender: form_response.sender,
text: rendered_messages[index],
}})
socket.executeForUsers(SocketMessages.DisplayMessage,
chained_form_responses[0].recipients, // The recipients are the same for all the messages.
messages)
// Exit early if we don't need to store the message history.
if(!game.settings.get(module_id, module_settings.StoreHistory))
return;
/*
* As the recipients of all the messages are the same, we can go about it user by user
* and setup missing journals if needed, then process all the messages.
* We could try to be smarter and concatenate all the messages from each sender
* and have only one update, but keep it simple for now, even if it forces some extra `await`s.
*/
for (let recipient of chained_form_responses[0].recipients) {
// All players are independents, so execute asynchronously.
new Promise(async () => {
// If we can't find the user that was selected from the user list seconds ago, we're in trouble.
/** @type{User} */
const user = game.users.get(recipient, {strict: true})
/** @type{JournalEntry} */
let journal = game.journal.get(user.getFlag(module_id, history_flag))
// Check if we do have a history journal, just in case.
if (!journal) {
ui.notifications.warn(game.i18n.format("MM.Errors.MissingHistory", {user: user.name}))
journal = await create_history_journal(user)
}
for (const message of messages) {
/** @type{JournalEntryPage} */
let senders_history = journal.pages.getName(message.sender)
if (!senders_history) {
senders_history = (
await journal.createEmbeddedDocuments(
"JournalEntryPage",
[{
name: message.sender,
type: "text",
text: {content: ""},
}]
)
)[0]
}
let new_history = senders_history.text.content + message.text + "<hr class='mm-separator'/>"
await senders_history.update({text: {content: new_history}})
}
})
}
}
/**
* Display a chain of messages sent by an NPC to the PC,
* play a notification sound if one is configured.
* Saving the history is handled on the GM's side.
* @param {Message[]} messages Array of messages to be displayed one after the other.
*/
async function display_messages(messages) {
// We don't want one sound per message if it is a chain.
let notification_sound_path = game.settings.get(module_id, module_settings.NotificationSound)
if (notification_sound_path) {
// TODO: Maybe a way to pre-load would be nice ?
foundry.audio.AudioHelper.play({
src: notification_sound_path,
channel: CONST.AUDIO_CHANNELS.environment,
volume: 0.5,
autoplay: true,
loop: false,
}, false)
}
for (const message of messages) {
let formated_header_title = str_format(
game.settings.get(module_id, module_settings.MessageDialogSenderTitle),
{sender: message.sender})
let header = formated_header_title.length > 0 ?
'<h1 style="text-align: center">' + formated_header_title + '</h1>' :
"";
await foundry.applications.api.DialogV2.wait({
window: {title: game.settings.get(module_id, module_settings.MessageDialogTitle)},
classes: ["mm-dialog"],
content:
header +
message.text,
rejectClose: false, // Act as another dismiss, no user input.
buttons: [{
action: "submit",
label: "MM.Dialogs.MessageDialog.Dismiss",
}],
localize: true,
render: (event, element) =>
element.getElementsByTagName("form")[0].classList.add("scrollable"),
}).finally(() => new Promise(resolve => setTimeout(resolve, 550)))
}
}
// Needed for v12 toolbar
class MillsMessagesControlLayer extends InteractionLayer {}
function main() {
Hooks.once("socketlib.ready", () => {
socket = socketlib.registerModule(module_id);
socket.register(SocketMessages.DisplayMessage, display_messages);
})
Hooks.once("setup", () => {
// In setup, so that we can get localization
register_settings()
if (!game.user?.isGM) {
return;
}
// Setup interaction layer to be able to show a button on the sidebar
CONFIG.Canvas.layers = foundry.utils.mergeObject(Canvas.layers, {
millsMessagesInterfaceLayer: {
layerClass: MillsMessagesControlLayer,
group: "interface"
}
});
// TODO: Maybe use permissions to be able to set outside of setup hook ?
Hooks.on('getSceneControlButtons', (controls) => {
controls.push({
name: module_id,
title: "MM.UI.ToolbarCategory",
icon: "mm-chat-bubble",
tools: [{
name: "send-message",
title: "MM.UI.ToolbarSendMessage",
button: true,
visible: true,
icon: "mm-chat-bubble",
onClick: send_message_dialog
}],
layer: "millsMessagesInterfaceLayer",
localize: true,
})
})
// If we don't need to store history, we're done setting up.
if(!game.settings.get(module_id, module_settings.StoreHistory))
return;
// Check if the journal for the message history exist, create them if not.
for (let user of game.users) {
if (user.isGM || user.getFlag(module_id, history_flag)) {
continue;
}
create_history_journal(user)
}
})
}
main()