Commit fb1469d8 authored by Ryan Wyllie's avatar Ryan Wyllie Committed by Mark Nelson
Browse files

MDL-56139 message: ajax poll for new messages in message area

parent b4d6669d
......@@ -52,6 +52,7 @@ $string['cachedef_suspended_userids'] = 'List of suspended users per course';
$string['cachedef_groupdata'] = 'Course group information';
$string['cachedef_htmlpurifier'] = 'HTML Purifier - cleaned content';
$string['cachedef_langmenu'] = 'List of available languages';
$string['cachedef_message_last_created'] = 'Time created for most recent message between users';
$string['cachedef_locking'] = 'Locking';
$string['cachedef_message_processors_enabled'] = "Message processors enabled status";
$string['cachedef_navigation_expandcourse'] = 'Navigation expandable courses';
......
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
// 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/>.
/**
* A timer that will execute a callback with decreasing frequency. Useful for
* doing polling on the server without overwhelming it with requests.
*
* @module core/backoff_timer
* @class backoff_timer
* @package core
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define(function() {
// Default to one second.
var DEFAULT_TIME = 1000;
/**
* The default back off function for the timer. It uses the Fibonacci
* sequence to determine what the next timeout value should be.
*
* @param {(int|null)} time The current timeout value or null if none set
* @param {array} previousTimes An array containing all previous timeout values
* @return {int} The new timeout value
*/
var fibonacciBackOff = function(time, previousTimes) {
if (!time) {
return DEFAULT_TIME;
}
if (previousTimes.length) {
var lastTime = previousTimes[previousTimes.length - 1];
return time + lastTime;
} else {
return DEFAULT_TIME;
}
};
/**
* Constructor for the back off timer.
*
* @param {function} callback The function to execute after each tick
*/
var Timer = function(callback) {
this.reset();
this.setCallback(callback);
// Set the default backoff function to be the Fibonacci sequence.
this.setBackOffFunction(fibonacciBackOff);
};
/**
* Set the callback function to be executed after each tick of the
* timer.
*
* @method setCallback
* @param {function} callback The callback function
* @return {object} this
*/
Timer.prototype.setCallback = function(callback) {
this.callback = callback;
return this;
};
/**
* Get the callback function for this timer.
*
* @method getCallback
* @return {function}
*/
Timer.prototype.getCallback = function() {
return this.callback;
};
/**
* Set the function to be used when calculating the back off time
* for each tick of the timer.
*
* The back off function will be given two parameters: the current
* time and an array containing all previous times.
*
* @method setBackOffFunction
* @param {function} backOffFunction The function to calculate back off times
* @return {object} this
*/
Timer.prototype.setBackOffFunction = function(backOffFunction) {
this.backOffFunction = backOffFunction;
return this;
};
/**
* Get the current back off function.
*
* @method getBackOffFunction
* @return {function}
*/
Timer.prototype.getBackOffFunction = function() {
return this.backOffFunction;
};
/**
* Generate the next timeout in the back off time sequence
* for the timer.
*
* The back off function is called to calculate the next value.
* It is given the current value and an array of all previous values.
*
* @method generateNextTime
* @return {int} The new timeout value (in milliseconds)
*/
Timer.prototype.generateNextTime = function() {
var newTime = this.getBackOffFunction().call(
this.getBackOffFunction(),
this.time,
this.previousTimes
);
this.previousTimes.push(this.time);
this.time = newTime;
return newTime;
};
/**
* Stop the current timer and clear the previous time values
*
* @method reset
* @return {object} this
*/
Timer.prototype.reset = function() {
this.time = null;
this.previousTimes = [];
this.stop();
return this;
};
/**
* Clear the current timeout, if one is set.
*
* @method stop
* @return {object} this
*/
Timer.prototype.stop = function() {
if (this.timeout) {
window.clearTimeout(this.timeout);
this.timeout = null;
}
return this;
};
/**
* Start the current timer by generating the new timeout value and
* starting the ticks.
*
* This function recurses after each tick with a new timeout value
* generated each time.
*
* The callback function is called after each tick.
*
* @method start
* @return {object} this
*/
Timer.prototype.start = function() {
// If we haven't already started.
if (!this.timeout) {
var time = this.generateNextTime();
this.timeout = window.setTimeout(function() {
this.getCallback().call();
// Clear the existing timer.
this.stop();
// Start the next timer.
this.start();
}.bind(this), time);
}
return this;
};
/**
* Reset the timer and start it again from the initial timeout
* values
*
* @method restart
* @return {object} this
*/
Timer.prototype.restart = function() {
return this.reset().start();
};
return Timer;
});
......@@ -301,4 +301,13 @@ $definitions = array(
'staticacceleration' => true,
'staticaccelerationsize' => 3
),
// Cache for storing the user's last received message time.
'message_last_created' => array(
'mode' => cache_store::MODE_APPLICATION,
'simplekeys' => true, // The id of the sender and recipient is used.
'simplevalues' => true,
'datasource' => 'message_last_created_cache_source',
'datasourcefile' => 'message/classes/message_last_created_cache_source.php'
),
);
......@@ -234,6 +234,16 @@ function message_send($eventdata) {
}
}
// Only cache messages, not notifications.
if (empty($savemessage->notification)) {
// Cache the timecreated value of the last message between these two users.
$cache = cache::make('core', 'message_last_created');
$ids = [$savemessage->useridfrom, $savemessage->useridto];
sort($ids);
$key = implode('_', $ids);
$cache->set($key, $savemessage->timecreated);
}
// Store unread message just in case we get a fatal error any time later.
$savemessage->id = $DB->insert_record('message', $savemessage);
$eventdata->savedmessageid = $savemessage->id;
......
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
......@@ -23,8 +23,9 @@
*/
define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/custom_interaction_events',
'core/auto_rows', 'core_message/message_area_actions', 'core/modal_factory', 'core/modal_events',
'core/str', 'core_message/message_area_events'],
function($, Ajax, Templates, Notification, CustomEvents, AutoRows, Actions, ModalFactory, ModalEvents, Str, Events) {
'core/str', 'core_message/message_area_events', 'core/backoff_timer'],
function($, Ajax, Templates, Notification, CustomEvents, AutoRows, Actions, ModalFactory,
ModalEvents, Str, Events, BackOffTimer) {
/** @type {int} The message area default height. */
var MESSAGES_AREA_DEFAULT_HEIGHT = 500;
......@@ -77,6 +78,12 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/cust
/** @type {Modal} the confirmation modal */
Messages.prototype._confirmationModal = null;
/** @type {int} the timestamp for the earliest visible message */
Messages.prototype._earliestMessageTimestamp = 0;
/** @type {BackOffTime} the backoff timer */
Messages.prototype._timer = null;
/** @type {Messagearea} The messaging area object. */
Messages.prototype.messageArea = null;
......@@ -137,6 +144,14 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/cust
if (messages.length) {
this._addScrollEventListener(messages.find(SELECTORS.MESSAGE).length);
}
// Create a timer to poll the server for new messages.
this._timer = new BackOffTimer(function() {
this._loadNewMessages();
}.bind(this));
// Start the timer.
this._timer.start();
};
/**
......@@ -150,6 +165,10 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/cust
Messages.prototype._viewMessages = function(event, userid) {
// We are viewing another user, or re-loading the panel, so set number of messages displayed to 0.
this._numMessagesDisplayed = 0;
// Stop the existing timer so we can set up the new user's messages.
this._timer.stop();
// Reset the earliest timestamp when we change the messages view.
this._earliestMessageTimestamp = 0;
// Mark all the messages as read.
var markMessagesAsRead = Ajax.call([{
......@@ -183,6 +202,8 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/cust
}).then(function(html, js) {
Templates.replaceNodeContents(this.messageArea.find(SELECTORS.MESSAGESAREA), html, js);
this._addScrollEventListener(numberreceived);
// Restart the poll timer.
this._timer.restart();
}.bind(this)).fail(Notification.exception);
};
......@@ -240,28 +261,130 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/cust
}.bind(this)).fail(Notification.exception);
};
/**
* Loads and renders messages newer than the most recently seen messages.
*
* @return {Promise|boolean} The promise resolved when the messages have been loaded.
* @private
*/
Messages.prototype._loadNewMessages = function() {
if (this._isLoadingMessages) {
return false;
}
// If we have no user id yet then bail early.
if (!this._getUserId()) {
return false;
}
this._isLoadingMessages = true;
// Only scroll the message window if the user hasn't scrolled up.
var shouldScrollBottom = false;
var messages = this.messageArea.find(SELECTORS.MESSAGES);
if (messages.length !== 0) {
var scrollTop = messages.scrollTop();
var innerHeight = messages.innerHeight();
var scrollHeight = messages[0].scrollHeight;
if (scrollTop + innerHeight >= scrollHeight) {
shouldScrollBottom = true;
}
}
// Keep track of the number of messages received.
var numberreceived = 0;
return this._getMessages(this._getUserId(), true).then(function(data) {
// Filter out any messages already rendered.
var messagesArea = this.messageArea.find(SELECTORS.MESSAGES);
data.messages = data.messages.filter(function(message) {
var id = "" + message.id + message.isread;
var result = messagesArea.find(SELECTORS.MESSAGE + '[data-id="' + id + '"]');
return !result.length;
});
numberreceived = data.messages.length;
// We have the data - lets render the template with it.
return Templates.render('core_message/message_area_messages', data);
}.bind(this)).then(function(html, js) {
// Check if we got something to do.
if (numberreceived > 0) {
html = $(html);
// Remove the new block time as it's present above.
html.find(SELECTORS.BLOCKTIME).remove();
// Show the new content.
Templates.appendNodeContents(this.messageArea.find(SELECTORS.MESSAGES), html, js);
// Scroll the new message into view.
if (shouldScrollBottom) {
this._scrollBottom();
}
// Increment the number of messages displayed.
this._numMessagesDisplayed += numberreceived;
// Reset the poll timer because the user may be active.
this._timer.restart();
}
}.bind(this)).always(function() {
// Mark that we are no longer busy loading data.
this._isLoadingMessages = false;
}.bind(this)).fail(Notification.exception);
};
/**
* Handles returning a list of messages to display.
*
* @param {int} userid
* @param {bool} fromTimestamp Load messages from the earliest known timestamp
* @return {Promise} The promise resolved when the contact area has been rendered
* @private
*/
Messages.prototype._getMessages = function(userid) {
Messages.prototype._getMessages = function(userid, fromTimestamp) {
var args = {
currentuserid: this.messageArea.getCurrentUserId(),
otheruserid: userid,
limitfrom: this._numMessagesDisplayed,
limitnum: this._numMessagesToRetrieve,
newest: true
};
// If we're trying to load new messages since the message UI was
// rendered. Used for ajax polling while user is on the message UI.
if (fromTimestamp) {
args.createdfrom = this._earliestMessageTimestamp;
// Remove limit and offset. We want all new messages.
args.limitfrom = 0;
args.limitnum = 0;
}
// Call the web service to get our data.
var promises = Ajax.call([{
methodname: 'core_message_data_for_messagearea_messages',
args: {
currentuserid: this.messageArea.getCurrentUserId(),
otheruserid: userid,
limitfrom: this._numMessagesDisplayed,
limitnum: this._numMessagesToRetrieve,
newest: true
}
args: args,
}]);
// Do stuff when we get data back.
return promises[0];
return promises[0].then(function(data) {
var messages = data.messages;
// Did we get any new messages?
if (messages && messages.length) {
var earliestMessage = messages[messages.length - 1];
// If we haven't set the timestamp yet then just use the earliest message.
if (!this._earliestMessageTimestamp) {
// Next request should be for the second after the most recent message we've seen.
this._earliestMessageTimestamp = earliestMessage.timecreated + 1;
// Update our record of the earliest known message for future requests.
} else if (earliestMessage.timecreated < this._earliestMessageTimestamp) {
// Next request should be for the second after the most recent message we've seen.
this._earliestMessageTimestamp = earliestMessage.timecreated + 1;
}
}
return data;
}.bind(this)).fail(function() {
// Stop the timer if we received an error so that we don't keep spamming the server.
this._timer.stop();
}.bind(this)).fail(Notification.exception);
};
/**
......
......@@ -291,11 +291,32 @@ class api {
* @param int $limitfrom
* @param int $limitnum
* @param string $sort
* @param int $createdfrom the timestamp from which the messages were created
* @param int $createdto the time up until which the message was created
* @return array
*/
public static function get_messages($userid, $otheruserid, $limitfrom = 0, $limitnum = 0, $sort = 'timecreated ASC') {
public static function get_messages($userid, $otheruserid, $limitfrom = 0, $limitnum = 0,
$sort = 'timecreated ASC', $createdfrom = 0, $createdto = 0) {
if (!empty($createdfrom)) {
// Check the cache to see if we even need to do a DB query.
$cache = \cache::make('core', 'message_last_created');
$ids = [$otheruserid, $userid];
sort($ids);
$key = implode('_', $ids);
$lastcreated = $cache->get($key);
// The last known message time is earlier than the one being requested so we can
// just return an empty result set rather than having to query the DB.
if ($lastcreated && $lastcreated < $createdfrom) {
return [];
}
}
$arrmessages = array();
if ($messages = helper::get_messages($userid, $otheruserid, 0, $limitfrom, $limitnum, $sort)) {
if ($messages = helper::get_messages($userid, $otheruserid, 0, $limitfrom, $limitnum,
$sort, $createdfrom, $createdto)) {
$arrmessages = helper::create_messages($userid, $messages);
}
......
......@@ -43,10 +43,12 @@ class helper {
* @param int $limitfrom
* @param int $limitnum
* @param string $sort
* @param int $createdfrom the time from which the message was created
* @param int $createdto the time up until which the message was created
* @return array of messages
*/
public static function get_messages($userid, $otheruserid, $timedeleted = 0, $limitfrom = 0, $limitnum = 0,
$sort = 'timecreated ASC') {
$sort = 'timecreated ASC', $createdfrom = 0, $createdto = 0) {
global $DB;
$messageid = $DB->sql_concat("'message_'", 'id');
......@@ -58,6 +60,7 @@ class helper {
WHERE ((useridto = ? AND useridfrom = ? AND timeusertodeleted = ?)
OR (useridto = ? AND useridfrom = ? AND timeuserfromdeleted = ?))
AND notification = 0
%where%
UNION ALL
SELECT {$messagereadid} AS fakeid, id, useridfrom, useridto, subject, fullmessage, fullmessagehtml, fullmessageformat,
smallmessage, notification, timecreated, timeread
......@@ -65,11 +68,29 @@ class helper {
WHERE ((useridto = ? AND useridfrom = ? AND timeusertodeleted = ?)
OR (useridto = ? AND useridfrom = ? AND timeuserfromdeleted = ?))
AND notification = 0
%where%
ORDER BY $sort";
$params = array($userid, $otheruserid, $timedeleted,
$otheruserid, $userid, $timedeleted,
$userid, $otheruserid, $timedeleted,
$otheruserid, $userid, $timedeleted);
$params1 = array($userid, $otheruserid, $timedeleted,
$otheruserid, $userid, $timedeleted);
$params2 = array($userid, $otheruserid, $timedeleted,
$otheruserid, $userid, $timedeleted);
$where = array();
if (!empty($createdfrom)) {
$where[] = 'AND timecreated >= ?';
$params1[] = $createdfrom;
$params2[] = $createdfrom;
}
if (!empty($createdto)) {
$where[] = 'AND timecreated <= ?';
$params1[] = $createdto;
$params2[] = $createdto;
}
$sql = str_replace('%where%', implode(' ', $where), $sql);
$params = array_merge($params1, $params2);
return $DB->get_records_sql($sql, $params, $limitfrom, $limitnum);
}
......
<?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/>.
/**
* Cache data source for the last created message between users.
*
* @package core_message
* @category cache
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Cache data source for the last created message between users.
*
* @package core_message
* @category cache
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class message_last_created_cache_source implements \cache_data_source {
/** @var message_last_created_cache_source the singleton instance of this class. */
protected static $instance = null;
/**
* Returns an instance of the data source class that the cache can use for loading data using the other methods
* specified by the cache_data_source interface.
*
* @param cache_definition $definition
* @return object
*/
public static function get_instance_for_cache(cache_definition $definition) {
if (is_null(self::$instance)) {
self::$instance = new message_last_created_cache_source();
}
return self::$instance;
}
/**
* Loads the data for the key provided ready formatted for caching.
*
* @param string|int $key The key to load.
* @return mixed What ever data should be returned, or false if it can't be loaded.
*/
public function load_for_cache($key) {
list($userid1, $userid2) = explode('_', $key);
$message = \core_message\api::get_most_recent_message($userid1, $userid2);
if ($message) {
return $message->timecreated;
} else {
return null;
}
}