TheMillsMessages/scripts/mills_messages.mjs
trotFunky a091ce93df Allow pre-filling the message dialog
Given that send_message_dialog() fills the form with previous inputs
if there are any, we can also provide data to pre-fill the first form.

This can be useful for preset dialogs, like an "all players" dialog
with all players already input.

Add an initial data parameter to send_message_dialog() and add it
to the message list if it is provided.
2025-05-27 21:36:03 +01:00

360 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.
*
* 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",
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()