From c83122548a6ca03a9633ee555569ce9f638e792a Mon Sep 17 00:00:00 2001 From: trotFunky Date: Mon, 12 May 2025 21:26:21 +0100 Subject: [PATCH] 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. --- .gitignore | 1 + LICENSE | 373 +++++++++++++++++++++++++++++++++++ README.md | 53 +++++ assets/chat_bubble.webp | Bin 0 -> 296 bytes lang/en.json | 57 ++++++ lang/fr.json | 57 ++++++ module.json | 53 +++++ scripts/mills_messages.mjs | 352 +++++++++++++++++++++++++++++++++ scripts/send_dialog_form.mjs | 110 +++++++++++ scripts/settings.mjs | 58 ++++++ styles/mills_messages.css | 29 +++ 11 files changed, 1143 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/chat_bubble.webp create mode 100644 lang/en.json create mode 100644 lang/fr.json create mode 100644 module.json create mode 100644 scripts/mills_messages.mjs create mode 100644 scripts/send_dialog_form.mjs create mode 100644 scripts/settings.mjs create mode 100644 styles/mills_messages.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d1767f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a06a054 --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" +means each individual or legal entity that creates, contributes to +the creation of, or owns Covered Software. + +1.2. "Contributor Version" +means the combination of the Contributions of others (if any) used +by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" +means Covered Software of a particular Contributor. + +1.4. "Covered Software" +means Source Code Form to which the initial Contributor has attached +the notice in Exhibit A, the Executable Form of such Source Code +Form, and Modifications of such Source Code Form, in each case +including portions thereof. + +1.5. "Incompatible With Secondary Licenses" +means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" +means any form of the work other than Source Code Form. + +1.7. "Larger Work" +means a work that combines Covered Software with other material, in +a separate file or files, that is not Covered Software. + +1.8. "License" +means this document. + +1.9. "Licensable" +means having the right to grant, to the maximum extent possible, +whether at the time of the initial grant or subsequently, any and +all of the rights conveyed by this License. + +1.10. "Modifications" +means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor +means any patent claim(s), including without limitation, method, +process, and apparatus claims, in any patent Licensable by such +Contributor that would be infringed, but for the grant of the +License, by the making, using, selling, offering for sale, having +made, import, or transfer of either its Contributions or its +Contributor Version. + +1.12. "Secondary License" +means either the GNU General Public License, Version 2.0, the GNU +Lesser General Public License, Version 2.1, the GNU Affero General +Public License, Version 3.0, or any later versions of those +licenses. + +1.13. "Source Code Form" +means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") +means an individual or a legal entity exercising rights under this +License. For legal entities, "You" includes any entity that +controls, is controlled by, or is under common control with You. For +purposes of this definition, "control" means (a) the power, direct +or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or (b) ownership of more than +fifty percent (50%) of the outstanding shares or beneficial +ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) +Licensable by such Contributor to use, reproduce, make available, +modify, display, perform, distribute, and otherwise exploit its +Contributions, either on an unmodified basis, with Modifications, or +as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer +for sale, have made, import, and otherwise transfer either its +Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; +or + +(b) for infringements caused by: (i) Your and any other third party's +modifications of Covered Software, or (ii) the combination of its +Contributions with other software (except as part of its Contributor +Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of +its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code +Form, as described in Section 3.1, and You must inform recipients of +the Executable Form how they can obtain a copy of such Source Code +Form by reasonable means in a timely manner, at a charge no more +than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this +License, or sublicense it under different terms, provided that the +license for the Executable Form does not attempt to limit or alter +the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + +This Source Code Form is "Incompatible With Secondary Licenses", as +defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c4e41b --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# The Mill's Messages - v0.1.0 + +This little [FoundryVTT](https://foundryvtt.com/) module gives the GMs a way to send messages to PCs with a bit more flair than +the chat, and more spontaneity than journals ! + +# Compatibility + +This is *only* compatible with Foundry v12 currently. + +# Installation + +Currently, this can only be installed via manifest file, using the following URL : +``` +https://git.tfk-astrodome.net/trotFunky/TheMillsMessages/raw/branch/release/module.json +``` + +# Behaviour + +For GMs, a new entry on the left toolbar will appear with a new tool (a chat bubble) bringing up +the dialog window to send messages. + +GMs can choose what name will appear as the sender and have access to the full ProseMirror editor +to send messages to as many players as they want at once. + +Messages can be chained together – though they will be locked to all share the same recipients – +and will be displayed one after the other, when a player closes a message. +In a chain, senders can be different from message to message. + +By default, each player has a journal that will serve as a history of the messages they receive, +each page containing the messages from one specific recipient. +Both the journals and pages will be created automatically. +Players only have observer permissions on their own journal, which will take the name of their +character, or their own if they don't control one yet. + +The GM can send a message to an offline player, which will add it to its history if it is enabled, +but won't show up as a pop-up when they next log in. + +# Settings + +A few behaviours can be changed through the settings : + - Title of the popup window showing the player the messages + - A header to the message, by default showing who they received a message from + - The name of the history journal + - If history needs to be saved at all (disabling will *not store anything*) + - A notification sound to be played for players when they receive a message (blank by default) + +# Dependencies + +The module depends on socketlib only at this time, to send the messages from the GM to the players. + +# License + +The code present in this repository is licensed under the Mozilla Public License 2.0. diff --git a/assets/chat_bubble.webp b/assets/chat_bubble.webp new file mode 100644 index 0000000000000000000000000000000000000000..0f749829b0893b90c5f4c4a792363fb67d7e499e GIT binary patch literal 296 zcmV+@0oVRgNk&E>0RRA3MM6+kP&il$0000G0000F000jF06|PpNFo3L00B_YMv^lA zO&`O9fQXm?Hl@y_4agb!0X>=t6S^WIOt=(QP&gpi0002c1pu7^Di8n=006)eC8Pi| z4(0&H9j5{PSIi6c55{hhPvD=>e>AL}l zVE^%vN&K%Uxt7?tuFf*&x3)GqgvYn_B2NChjR0113ocP#|LPz*uOI!<&*Poi|EY!k z{KGeXgK4ZGc%S>OAJfaN{qswA8!!D3)l}qHnQ#7w1edrmQ?LIOhoS!DNc;V-97AWC u5$pXg=l}ec$q#x=WB^Bhs2J3`X8nYOuCzdnza-PDnI(vYpX$jc_n-jR*@YAU literal 0 HcmV?d00001 diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..311ef90 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,57 @@ +{ + "MM": { + "UI": { + "ToolbarCategory": "Message controls", + "ToolbarSendMessage": "Send message", + "HistoryJournalTitle": "Messages for {name}" + }, + "Dialogs": { + "SendDialog": { + "Title": "Send a message from an NPC to some PCs", + "TitleChain": "Send a message chain from NPCs to some PCs", + "Send": "Send message", + "SendChain": "Send the message chain", + "Chain": "Chain another message", + "RecipientsLabel": "Recipients", + "RecipientsHint": "Pick one or more recipients", + "RecipientsChainHint": "The recipients of a chain are fixed", + "SenderLabel": "Sender of the message", + "SenderHint": "Used for the history and can be displayed at the top of the message", + "SenderPlaceholder": "Sender name", + "MessageEditorLabel": "Message to send" + }, + "MessageDialog": { + "Title": "New message", + "Dismiss": "Close", + "Header": "Message from {sender}" + } + }, + "Errors": { + "MessageSendIssue": "Sending message: {reason}", + "MissingFields": "Some fields were missing, cannot send the message", + "MissingHistory": "Did not find the journal entry for {user}'s history. Creating a new one and using it." + }, + "Settings": { + "MessageDialogTitle": { + "Title": "Message received window title", + "Hint": "The title of the message windows displayed to the players" + }, + "MessageDialogSenderTitle": { + "Title": "Title prepended to messages", + "Hint": "A title prepended to messages when displayed to players, {sender} will be replaced by the sender's name" + }, + "HistoryJournalTitle": { + "Title": "History journal title", + "Hint": "The title of the journal created to keep the message's history, {name} will be replaced by the player or their character's name. (Default if blank)" + }, + "StoreHistory": { + "Title": "Store message history ?", + "Hint": "Should the messages the players receive be stored in a history journal. History is saved for offline players as well." + }, + "NotificationSound": { + "Title": "Message reception sound", + "Hint": "A sound to play when the player receives a message" + } + } + } +} diff --git a/lang/fr.json b/lang/fr.json new file mode 100644 index 0000000..ade8c2e --- /dev/null +++ b/lang/fr.json @@ -0,0 +1,57 @@ +{ + "MM": { + "UI": { + "ToolbarCategory": "Contrôles messages", + "ToolbarSendMessage": "Envoyer des messages", + "HistoryJournalTitle": "Messages pour {name}" + }, + "Dialogs": { + "SendDialog": { + "Title": "Envoyer des messages d'un PNJ à des PJs", + "TitleChain": "Envoyer une chaîne de messages de PNJs à des PJs", + "Send": "Envoyer le message", + "Chain": "Ajouter un message à la chaîne", + "SendChain": "Envoyer la chaîne de messages", + "RecipientsLabel": "Destinataires", + "RecipientsHint": "Sélectionner un ou plusieurs destinataires", + "RecipientsChainHint": "Les destinataires d'une chaîne sont fixés", + "SenderLabel": "Expéditeur du message", + "SenderHint": "Utilisé pour l'historique et peut être inséré avant le message", + "SenderPlaceholder": "Nom de l'expéditeur", + "MessageEditorLabel": "Message à envoyer" + }, + "MessageDialog": { + "Title": "Nouveau message", + "Dismiss": "Fermer", + "Header": "Message de {sender}" + } + }, + "Errors": { + "MessageSendIssue": "Envoi de message: {reason}", + "MissingFields": "Certains champs sont vides, impossible d'envoyer le message.", + "MissingHistory": "L'entrée de journal pour l'historique de {user} n'a pas été trouvée. Création d'une nouvelle pour l'utiliser." + }, + "Settings": { + "MessageDialogTitle": { + "Title": "Titre fenêtre de messages reçus", + "Hint": "Le titre des fenêtres de messages montrées aux joueurs" + }, + "MessageDialogSenderTitle": { + "Title": "Titre ajouté aux messages", + "Hint": "Un titre ajouté au début des messages montrés aux joueurs, {sender} est remplacé par le nom de l'expéditeur" + }, + "HistoryJournalTitle": { + "Title": "Titre du journal d'historique", + "Hint": "Le titre du journal créé pour conserver l'historique des messages d'un joueur, {name} est remplacé par le nom du joueur ou de son personnage. (Valeur par défaut si vide)" + }, + "StoreHistory": { + "Title": "Sauvegarder l'historique des messages ?", + "Hint": "Les messages reçus par les joueurs devraient-ils être sauvegardés dans un journal d'historique ? Les messages pour les joueurs hors-ligne seront aussi sauvegardés." + }, + "NotificationSound": { + "Title": "Son de réception de message", + "Hint": "Un son à jouer quand un joueur reçoit un message" + } + } + } +} diff --git a/module.json b/module.json new file mode 100644 index 0000000..1acd06f --- /dev/null +++ b/module.json @@ -0,0 +1,53 @@ +{ + "id": "the-mills-messages", + "title": "The Mill's Messages", + "description": "A little message-sending modules for GMs", + "version": "0.1.0", + "compatibility": { + "minimum": "12", + "verified": "12", + "maximum": "12" + }, + "relationships": { + "requires": [ + { + "id": "socketlib", + "type": "module", + "compatibility": { + "verified": "1.1.2" + } + } + ] + }, + "authors": [ + { + "name": "trotFunky Sparks", + "flags": {} + } + ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + }, + { + "lang": "fr", + "name": "Français", + "path": "lang/fr.json" + } + ], + "esmodules": [ + "scripts/mills_messages.mjs" + ], + "styles": [ + "styles/mills_messages.css" + ], + "socket": true, + + "license": "LICENSE", + "readme": "README.md", + "url": "https://git.tfk-astrodome.net/trotFunky/TheMillsMessages/src/branch/release", + "manifest": "https://git.tfk-astrodome.net/trotFunky/TheMillsMessages/raw/branch/release/module.json", + "download": "https://git.tfk-astrodome.net/trotFunky/TheMillsMessages/archive/v0.1.0.zip" +} diff --git a/scripts/mills_messages.mjs b/scripts/mills_messages.mjs new file mode 100644 index 0000000..2383e79 --- /dev/null +++ b/scripts/mills_messages.mjs @@ -0,0 +1,352 @@ +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() diff --git a/scripts/send_dialog_form.mjs b/scripts/send_dialog_form.mjs new file mode 100644 index 0000000..1daa956 --- /dev/null +++ b/scripts/send_dialog_form.mjs @@ -0,0 +1,110 @@ +/* + * This files separates the form-specific validation, creation and types, + * so they can be used in the main module. + */ + +/** + * @typedef {Object} MessageFormResponse + * @property {string[]} recipients The list of player IDs the message will be sent to + * @property {string} sender The name to display as the origin of the message, and in the history + * @property {string} message The body of the message, a string of HTML + */ + +/** + * @typedef {Object} DialogFormResponse + * @property {boolean} valid Is the response valid ? + * @property {MessageFormResponse} form_response The response form the dialog form + */ + +/** + * Given that "required" does not seem to have much impact, check that everything + * has been properly filled. + * To be able to reopen the dialog with the existing inputs, return the result of the check + * as well as the input data. + * @param {MessageFormResponse} form_response The response from the form, pre-processed + * @return {DialogFormResponse} Pass the response through and a validity flag + */ +export function validate_form(form_response) { + let valid = true; + if (form_response.message.length === 0 || + form_response.recipients.length === 0 || + form_response.sender.length === 0) { + ui.notifications.warn("MM.Errors.MissingFields", {localize:true}); + valid = false; + } + return {valid, form_response}; +} + +/** + * Prepare the HTML used for the sending message dialog, filling previous values and handling if it is a chained message. + * @param {MessageFormResponse|null} [existing_values=null] Values from a previously filled form + * @param {boolean} [chain=false] If this is a chained message or not + * @returns {string} HTML for the send message Dialog content + */ +export function prepare_send_message_html(existing_values = null, chain = false) { + let players_options = [] + for (let user of game.users.filter((user) => !user.isGM)) { + players_options.push({ + label: user.character ? user.character.name : user.name, + value: user.id, + }) + } + if (existing_values && existing_values.recipients.length > 0) { + players_options + .filter((option) => existing_values.recipients.includes(option.value)) + .forEach((option) => {option.selected = true}) + } + + const recipient_input = foundry.applications.fields.createMultiSelectInput({ + name: "recipients_IDs", + required: true, + blank: false, + options: players_options, + /* + * Send chained messages to the same recipients, disallow changing it in this case. + * This is an arbitrary decision that simplifies the process of sending the messages. + */ + disabled: chain, + }) + + const recipient_group = foundry.applications.fields.createFormGroup({ + input: recipient_input, + label: "MM.Dialogs.SendDialog.RecipientsLabel", + hint: chain ? "MM.Dialogs.SendDialog.RecipientsChainHint" : "MM.Dialogs.SendDialog.RecipientsHint", + localize: true, + }) + + const sender_input = foundry.applications.fields.createTextInput({ + name: "sender_name", + required: true, + placeholder: game.i18n.localize("MM.Dialogs.SendDialog.SenderPlaceholder"), + value: existing_values ? existing_values.sender : null, + }) + + const sender_group = foundry.applications.fields.createFormGroup({ + input: sender_input, + label: "MM.Dialogs.SendDialog.SenderLabel", + hint: "MM.Dialogs.SendDialog.SenderHint", + localize: true + }) + + const message_input = foundry.applications.elements.HTMLProseMirrorElement.create({ + name: "message", + required: true, + value: existing_values && existing_values.message ? existing_values.message : "", + editable: true, + compact: true, + collaborate: false, + toggled: false, + height: 350, + }) + + const message_group = foundry.applications.fields.createFormGroup({ + input: message_input, + label: "MM.Dialogs.SendDialog.MessageEditorLabel", + stacked: true, + localize: true, + }) + + return recipient_group.outerHTML + sender_group.outerHTML + message_group.outerHTML; +} diff --git a/scripts/settings.mjs b/scripts/settings.mjs new file mode 100644 index 0000000..a567e58 --- /dev/null +++ b/scripts/settings.mjs @@ -0,0 +1,58 @@ +import {module_id} from "./mills_messages.mjs"; + +export const module_settings = Object.freeze({ + MessageDialogTitle: "messageDialogTitle", + MessageDialogSenderTitle: "messageDialogSenderTitle", + HistoryJournalTitle: "historyJournalTitle", + StoreHistory: "storeHistory", + NotificationSound: "notificationSound", +}) + +export function register_settings() { + game.settings.register(module_id, module_settings.MessageDialogTitle, { + name: "MM.Settings.MessageDialogTitle.Title", + hint: "MM.Settings.MessageDialogTitle.Hint", + type: String, + config: true, + scope: "world", + default: game.i18n.localize("MM.Dialogs.MessageDialog.Title"), + }) + + game.settings.register(module_id, module_settings.MessageDialogSenderTitle, { + name: "MM.Settings.MessageDialogSenderTitle.Title", + hint: "MM.Settings.MessageDialogSenderTitle.Hint", + type: String, + config: true, + scope: "world", + default: game.i18n.localize("MM.Dialogs.MessageDialog.Header"), + }) + + game.settings.register(module_id, module_settings.StoreHistory, { + name: "MM.Settings.StoreHistory.Title", + hint: "MM.Settings.StoreHistory.Hint", + type: Boolean, + config: true, + scope: "world", + default: true, + }) + + game.settings.register(module_id, module_settings.HistoryJournalTitle, { + name: "MM.Settings.HistoryJournalTitle.Title", + hint: "MM.Settings.HistoryJournalTitle.Hint", + type: String, + config: true, + scope: "world", + default: game.i18n.localize("MM.UI.HistoryJournalTitle"), + }) + + game.settings.register(module_id, module_settings.NotificationSound, { + name: "MM.Settings.NotificationSound.Title", + hint: "MM.Settings.NotificationSound.Hint", + type: String, + config: true, + scope: "world", + requiresReload: true, + filePicker: 'sound', + default: "", + }) +} diff --git a/styles/mills_messages.css b/styles/mills_messages.css new file mode 100644 index 0000000..f205cb0 --- /dev/null +++ b/styles/mills_messages.css @@ -0,0 +1,29 @@ +/* For some reason toolbar icons work as classes ? */ +.mm-chat-bubble { + display: inline-block; + width: 32px; + height: 32px; + background-size: cover; + cursor: pointer; + image-rendering: pixelated; + background-image: url("/modules/the-mills-messages/assets/chat_bubble.webp"); +} + +/* + * Override Foundry's `.application .scrollable`, whose right margin does not work + * with ProseMirror's editor internal divs which need a 0 right margin. + */ +.mm-send-dialog .scrollable { + margin-right: 0 !important; + padding-right: 0.2rem !important; +} + +/* Prevent the message window from getting too big. */ +.mm-dialog { + max-width: 60%; +} + +/* Separator between messages from the same sender */ +.mm-separator { + +}