TheMillsMessages/scripts/mills_messages.mjs
trotFunky 30c0324799 Introduce utils.mjs for misc constants and functions
Move some generic constants and functions, or supporting functions
to a new file.
This makes the main script smaller and makes it more focused on the
core functionality of the module.
2025-06-11 17:56:54 +01:00

337 lines
14 KiB
JavaScript

import { module_id, history_flag, str_format, create_history_journal } from "./utils.mjs"
import { module_settings, register_settings } from "./settings.mjs";
import { prepare_send_message_html, validate_form } from "./send_dialog_form.mjs";
/** @type {SocketlibSocket} */
let socket;
const SocketMessages = Object.freeze({
DisplayMessage: "display-message",
})
/**
* 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.
*
* We also use this to pre-fill the first form with data if any is passed.
*
* 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.
*
* @param {MessageFormResponse|null} initial_form_data Data to pre-fill the first form with.
* @returns {Promise<void>} The Promise for the processing of the messages.
*/
async function send_message_dialog(initial_form_data = null) {
/** @type{MessageFormResponse[]} */
let messages = []
let current_message = 0
if (initial_form_data !== null) {
messages.push(initial_form_data);
}
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-to-all",
title: "MM.UI.ToolbarSendMessageToAll",
button: true,
visible: true,
icon: "mm-bubble-above-three-players",
onClick: () => send_message_dialog({
recipients: game.users.players.map((user) => user.id)
})
},
{
name: "send-message-to-connected",
title: "MM.UI.ToolbarSendMessageToConnected",
button: true,
visible: true,
icon: "mm-bubble-above-two-players",
onClick: () => send_message_dialog({
recipients: game.users.players
.filter((user) => user.active)
.map((user) => user.id)
})
},
{
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()