This commit is contained in:
Huong Nguyen
2025-01-21 11:23:18 +07:00
13 changed files with 425 additions and 7 deletions
+1
View File
@@ -266,6 +266,7 @@ $string['unreadnotification'] = 'Unread notification: {$a}';
$string['unreadnotifications'] = 'There are {$a} unread notifications';
$string['unreadnewgroupconversationmessage'] = 'New message from {$a->name} in {$a->conversationname}';
$string['unreadnewmessage'] = 'New message from {$a}';
$string['unsentmessagenotification'] = 'You have an unsent message. It will be lost if you leave this page.';
$string['useentertosend'] = 'Use enter to send';
$string['usercantbemessaged'] = 'You can\'t message this user due to their message preferences. Try adding them as a contact.';
$string['userwouldliketocontactyou'] = '{$a} would like to contact you';
+12
View File
@@ -3292,6 +3292,18 @@ $functions = array(
'type' => 'write',
'ajax' => true,
],
'core_message_set_unsent_message' => [
'classname' => 'core_message\external\set_unsent_message',
'description' => 'Store an unsent message string',
'type' => 'write',
'ajax' => true,
],
'core_message_get_unsent_message' => [
'classname' => 'core_message\external\get_unsent_message',
'description' => 'Get an unsent message string',
'type' => 'read',
'ajax' => true,
],
);
$services = array(
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+188 -2
View File
@@ -38,6 +38,10 @@ define(
'core_message/message_drawer_helper',
'core/pending',
'core/drawer',
'core/toast',
'core/str',
'core/config',
'core/ajax',
],
function(
$,
@@ -55,7 +59,11 @@ function(
Events,
Helper,
Pending,
Drawer
Drawer,
Toast,
Str,
Config,
Ajax,
) {
var SELECTORS = {
@@ -66,6 +74,8 @@ function(
VIEW_CONTACT: '[data-region="view-contact"]',
VIEW_CONTACTS: '[data-region="view-contacts"]',
VIEW_CONVERSATION: '[data-region="view-conversation"]',
VIEW_CONVERSATION_WITH_ID: '[data-region="view-conversation"][data-conversation-id]',
VIEW_CONVERSATION_WITH_USER: '[data-region="view-conversation"][data-other-user-id]',
VIEW_GROUP_INFO: '[data-region="view-group-info"]',
VIEW_OVERVIEW: '[data-region="view-overview"]',
VIEW_SEARCH: '[data-region="view-search"]',
@@ -75,7 +85,9 @@ function(
HEADER_CONTAINER: '[data-region="header-container"]',
BODY_CONTAINER: '[data-region="body-container"]',
FOOTER_CONTAINER: '[data-region="footer-container"]',
CLOSE_BUTTON: '[data-action="closedrawer"]'
CLOSE_BUTTON: '[data-action="closedrawer"]',
MESSAGE_INDEX: '[data-region="message-index"]',
MESSAGE_TEXT_AREA: '[data-region="send-message-txt"]',
};
/**
@@ -182,6 +194,165 @@ function(
$(SELECTORS.DRAWER).attr('data-origin', buttonid);
};
/**
* Store an unsent message.
*
* Don't store this if the user has already seen the unsent message.
* This avoids spamming and ensures the user is only reminded once per unsent message.
* If the unsent message is sent, this attribute is removed and notification is possible again (see sendMessage).
*/
const storeUnsentMessage = async() => {
const messageTextArea = document.querySelector(SELECTORS.MESSAGE_TEXT_AREA);
if (messageTextArea.value.trim().length > 0 && !messageTextArea.hasAttribute('data-unsent-message-viewed')) {
let message = messageTextArea.value;
let conversationid = 0;
let otheruserid = 0;
// We don't always have a conversation to link the unsent message to, so let's check for that.
const conversationId = document.querySelector(SELECTORS.VIEW_CONVERSATION_WITH_ID);
if (conversationId) {
const conversationWithId = messageTextArea.closest(SELECTORS.VIEW_CONVERSATION_WITH_ID);
conversationid = conversationWithId.getAttribute('data-conversation-id');
}
// Store the 'other' user id if it is there. This can be used to create conversations.
const conversationUser = document.querySelector(SELECTORS.VIEW_CONVERSATION_WITH_USER);
if (conversationUser) {
const conversationWithUser = messageTextArea.closest(SELECTORS.VIEW_CONVERSATION_WITH_USER);
otheruserid = conversationWithUser.getAttribute('data-other-user-id');
}
setStoredUnsentMessage(message, conversationid, otheruserid);
}
};
/**
* Get the stored unsent message from the session via web service.
*
* @returns {Promise}
*/
const getStoredUnsentMessage = () => Ajax.call([{
methodname: 'core_message_get_unsent_message',
args: {}
}])[0];
/**
* Set the unsent message value in the session via web service.
*
* SendBeacon is used here because this is called on 'beforeunload'.
*
* @param {string} message The message string.
* @param {number} conversationid The conversation id.
* @param {number} otheruserid The other user id.
* @returns {Promise}
*/
const setStoredUnsentMessage = (message, conversationid, otheruserid) => {
const method = 'core_message_set_unsent_message';
const requestUrl = new URL(`${Config.wwwroot}/lib/ajax/service.php`);
requestUrl.searchParams.set('sesskey', Config.sesskey);
requestUrl.searchParams.set('info', method);
navigator.sendBeacon(requestUrl, JSON.stringify([{
index: 0,
methodname: method,
args: {
message: message,
conversationid: conversationid,
otheruserid: otheruserid,
}
}]));
};
/**
* Check for an unsent message.
*
* @param {String} uniqueId Unique identifier for the Routes.
* @param {Object} root The message drawer container.
*/
const getUnsentMessage = async(uniqueId, root) => {
let type;
let messageRoot;
// We need to check if we are on the message/index page.
// This logic is needed to handle the two message widgets here and ensure we are targetting the right one.
const messageIndex = document.querySelector(SELECTORS.MESSAGE_INDEX);
if (messageIndex !== null) {
type = 'index';
messageRoot = document.getElementById(`message-index-${uniqueId}`);
if (!messageRoot) {
// This is not the correct widget.
return;
}
} else {
type = 'drawer';
messageRoot = document.getElementById(`message-drawer-${uniqueId}`);
}
const storedMessage = await getStoredUnsentMessage();
const messageTextArea = messageRoot.querySelector(SELECTORS.MESSAGE_TEXT_AREA);
if (storedMessage.message && messageTextArea !== null) {
showUnsentMessage(messageTextArea, storedMessage, type, uniqueId, root);
}
};
/**
* Show an unsent message.
*
* There are two message widgets on the message/index page.
* Because of that, we need to try and target the correct widget.
*
* @param {String} textArea The textarea element.
* @param {Object} stored The stored message content.
* @param {String} type Is this from the drawer or index page?
* @param {String} uniqueId Unique identifier for the Routes.
* @param {Object} root The message drawer container.
*/
const showUnsentMessage = (textArea, stored, type, uniqueId, root) => {
// The user has already been notified.
if (textArea.hasAttribute('data-unsent-message-viewed')) {
return;
}
// Depending on the type, show the conversation with the data we have available.
// A conversation can be continued if there is a conversationid.
// If the user was messaging a new non-contact, we won't have a conversationid yet.
// In that case, we use the otheruserid value to start a conversation with them.
switch (type) {
case 'index':
// Show the conversation in the main panel on the message/index page.
if (stored.conversationid) {
Router.go(uniqueId, Routes.VIEW_CONVERSATION, stored.conversationid, 'frompanel');
// There was no conversation id, let's get a conversation going using the user id.
} else if (stored.otheruserid) {
Router.go(uniqueId, Routes.VIEW_CONVERSATION, null, 'create', stored.otheruserid);
}
break;
case 'drawer':
// Open the drawer and show the conversation.
if (stored.conversationid) {
let args = {
conversationid: stored.conversationid
};
Helper.showConversation(args);
// There was no conversation id, let's get a conversation going using the user id.
} else if (stored.otheruserid) {
show(uniqueId, root);
Router.go(uniqueId, Routes.VIEW_CONVERSATION, null, 'create', stored.otheruserid);
}
break;
}
// Populate the text area.
textArea.value = stored.message;
textArea.setAttribute('data-unsent-message-viewed', 1);
// Notify the user.
Toast.add(Str.get_string('unsentmessagenotification', 'core_message'));
};
/**
* Listen to and handle events for routing, showing and hiding the message drawer.
*
@@ -331,6 +502,18 @@ function(
viewConversationFooter.attr('data-enter-to-send', enterToSendPreference.value);
}
});
// If our textarea is modified, remove the attribute which indicates the user has seen the unsent message notification.
// This will allow the user to be notified again.
const textArea = document.querySelector(SELECTORS.MESSAGE_TEXT_AREA);
if (textArea) {
textArea.addEventListener('keyup', function() {
textArea.removeAttribute('data-unsent-message-viewed');
});
}
// Catch any unsent messages and store them.
window.addEventListener('beforeunload', storeUnsentMessage);
};
/**
@@ -358,6 +541,9 @@ function(
// Mark the drawer as ready.
Helper.markDrawerReady();
// Get and show any unsent message.
getUnsentMessage(uniqueId, root);
};
return {
@@ -1336,6 +1336,12 @@ function(
};
sendMessageBuffer.push(message);
processSendMessageBuffer();
// Remove the unsent message attribute so we can be notified of new unsent messages (see storeUnsentMessage).
const textArea = document.querySelector(SELECTORS.MESSAGE_TEXT_AREA);
if (textArea) {
textArea.removeAttribute('data-unsent-message-viewed');
}
};
/**
@@ -2119,6 +2125,9 @@ function(
conversationId = getCachedPrivateConversationIdFromUserId(otherUserId);
}
// Set attributes to aid in the restoration of unsent messages.
setConversationAttributes(footer, conversationId, otherUserId);
// This is a new conversation if:
// 1. We don't already have a state
// 2. The given conversation doesn't match the one currently loaded
@@ -2196,6 +2205,26 @@ function(
return Str.get_string('messagedrawerviewconversation', 'core_message', viewState.name);
};
/**
* Set some attributes that will help reboot a conversation.
*
* These attributes aid in the storage and retrieval of unsent messages.
* When a conversationid is not present, otheruserid can be used to create a conversation.
*
* @param {Object} element The element to target.
* @param {Number|null} conversationId The conversationid.
* @param {Number|null} otherUserId The otheruserid.
*/
const setConversationAttributes = function(element, conversationId, otherUserId) {
element.removeAttr('data-conversation-id');
element.removeAttr('data-other-user-id');
if (conversationId) {
element.attr('data-conversation-id', conversationId);
} else if (otherUserId) {
element.attr('data-other-user-id', otherUserId);
}
};
return {
show: show,
description: description
+70
View File
@@ -0,0 +1,70 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_message\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_single_structure;
use core_external\external_value;
/**
* External service to get unsent messages from the session.
*
* @package core_message
* @category external
* @copyright 2024 David Woloszyn <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class get_unsent_message extends external_api {
/**
* Returns description of method parameters.
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([]);
}
/**
* Get unsent messages from the session.
*/
public static function execute(): array {
global $SESSION, $USER;
$usercontext = \context_user::instance($USER->id);
self::validate_context($usercontext);
$message = isset($SESSION->core_message_set_unsent_message) ? $SESSION->core_message_set_unsent_message : [];
// Unset this as we only want to return this once.
unset($SESSION->core_message_set_unsent_message);
return $message;
}
/**
* Describe the return structure of the external service.
*
* @return external_value
*/
public static function execute_returns(): external_single_structure {
return new external_single_structure([
'message' => new external_value(PARAM_TEXT, 'The message string', VALUE_OPTIONAL, ''),
'conversationid' => new external_value(PARAM_INT, 'The conversation id', VALUE_OPTIONAL, 0),
'otheruserid' => new external_value(PARAM_INT, 'The other user id', VALUE_OPTIONAL, 0),
]);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_message\external;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_value;
/**
* External service to store unsent messages in the session.
*
* @package core_message
* @category external
* @copyright 2024 David Woloszyn <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class set_unsent_message extends external_api {
/**
* Returns description of method parameters.
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'message' => new external_value(PARAM_TEXT, 'The message string', VALUE_REQUIRED, ''),
'conversationid' => new external_value(PARAM_INT, 'The conversation id', VALUE_REQUIRED, 0),
'otheruserid' => new external_value(PARAM_INT, 'The other user id', VALUE_REQUIRED, 0),
]);
}
/**
* Store the unsent message, along with conversation params, in the session for later retrieval.
*
* @param string $message The message string.
* @param int $conversationid The conversation id.
* @param int $otheruserid The other user id.
*/
public static function execute(string $message, int $conversationid, int $otheruserid): void {
global $SESSION, $USER;
self::validate_parameters(self::execute_parameters(), [
'message' => $message,
'conversationid' => $conversationid,
'otheruserid' => $otheruserid,
]);
$usercontext = \context_user::instance($USER->id);
self::validate_context($usercontext);
$SESSION->core_message_set_unsent_message = [
'message' => $message,
'conversationid' => $conversationid,
'otheruserid' => $otheruserid,
];
}
/**
* Describes the data returned from the external function.
*/
public static function execute_returns(): void {
}
}
@@ -85,3 +85,14 @@ Feature: Message send messages
And I open messaging
And I select "Student 1" conversation in messaging
And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
Scenario: Unsent messages will notify users and give them a chance to send it
Given I log in as "student1"
And I open messaging
And I send "Hi!" message to "Student 2" user
And I select "Student 2" user in messaging
And I set the field "Write a message..." to "What you doing?"
When I follow "Dashboard"
Then I should see "You have an unsent message. It will be lost if you leave this page."
And I press "Send message"
And I should see "What you doing?" in the "Student 2" "core_message > Message conversation"
+33
View File
@@ -5887,4 +5887,37 @@ final class externallib_test extends externallib_advanced_testcase {
return [$user1, $user2, $user3, $convgroup, $convindividual];
}
/**
* Test the getting and setting of unsent messages.
*
* @covers ::get_unsent_message
*/
public function test_get_unsent_message(): void {
$this->resetAfterTest();
// Create some users.
$user1 = self::getDataGenerator()->create_user();
$user2 = self::getDataGenerator()->create_user();
// Set an unsent message for user1.
$this->setUser($user1);
$message = 'Hello there';
$conversationid = 123;
$otheruserid = 456;
external\set_unsent_message::execute($message, $conversationid, $otheruserid);
// Get the unsent message for user1.
$result = external\get_unsent_message::execute();
$result = external_api::clean_returnvalue(external\get_unsent_message::execute_returns(), $result);
$this->assertEquals($message, $result['message']);
$this->assertEquals($conversationid, $result['conversationid']);
$this->assertEquals($otheruserid, $result['otheruserid']);
// We should not be able to get user1's messages as another user.
$this->setUser($user2);
$result = external\get_unsent_message::execute();
$result = external_api::clean_returnvalue(external\get_unsent_message::execute_returns(), $result);
$this->assertEmpty($result);
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2025011700.00; // YYYYMMDD = weekly release date of this DEV branch.
$version = 2025011700.01; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '5.0dev (Build: 20250117)'; // Human-friendly version name