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} 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} 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 + "
" 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 ? '

' + formated_header_title + '

' : ""; 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()