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.
360 lines
14 KiB
JavaScript
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()
|