Commit 734b198f authored by Sara Arjona's avatar Sara Arjona
Browse files

MDL-64715 message: add support for self conversations

Added new MESSAGE_CONVERSATION_TYPE_SELF type for self-conversations
and upgraded legacy self-conversations to the new type, removing
repeated members in the message_conversation_members table.
Besides, from now, a self-conversation will be created by default for
all the existing users.

All the self-conversations have been also starred and a default message
will be displayed always to explain how to use them.
parent fcd7f0f9
......@@ -52,9 +52,11 @@ $string['defaultmessageoutputs'] = 'Notification settings';
$string['defaults'] = 'Defaults';
$string['deleteallconfirm'] = "Are you sure you would like to delete this entire conversation? This will not delete it for other conversation participants.";
$string['deleteallmessages'] = "Delete all messages";
$string['deleteallselfconfirm'] = "Are you sure you would like to delete this entire personal conversation?";
$string['deleteconversation'] = "Delete conversation";
$string['deleteselectedmessages'] = 'Delete selected messages';
$string['deleteselectedmessagesconfirm'] = 'Are you sure you would like to delete the selected messages? This will not delete them for other conversation participants.';
$string['deleteselectedmessagesconfirmselfconversation'] = 'Are you sure you would like to delete the selected personal messages?';
$string['disableall'] = 'Disable notifications';
$string['disabled'] = 'Messaging is disabled on this site';
$string['disallowed'] = 'Disallowed';
......@@ -211,6 +213,8 @@ $string['searchcombined'] = 'Search people and messages';
$string['seeall'] = 'See all';
$string['selectmessagestodelete'] = 'Select messages to delete';
$string['selectnotificationtoview'] = 'Select from the list of notifications on the side to view more details';
$string['selfconversation'] = 'Personal space';
$string['selfconversationdefaultmessage'] = 'Save draft messages, links, notes etc. to access later.';
$string['send'] = 'Send';
$string['sender'] = '{$a}:';
$string['sendingvia'] = 'Sending "{$a->provider}" via "{$a->processor}"';
......
......@@ -109,6 +109,12 @@ class manager {
// Get conversation type and name. We'll use this to determine which message subject to generate, depending on type.
$conv = $DB->get_record('message_conversations', ['id' => $eventdata->convid], 'id, type, name');
// For now Self conversations are not processed because users are aware of the messages sent by themselves, so we
// can return early.
if ($conv->type == \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF) {
return $savemessage->id;
}
// We treat individual conversations the same as any direct message with 'userfrom' and 'userto' specified.
// We know the other user, so set the 'userto' field so that the event code will get access to this field.
// If this was a legacy caller (eventdata->userto is set), then use that instead, as we want to use the fields specified
......
......@@ -1170,6 +1170,15 @@ $functions = array(
'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
'ajax' => true
),
'core_message_get_self_conversation' => array(
'classname' => 'core_message_external',
'methodname' => 'get_self_conversation',
'classpath' => 'message/externallib.php',
'description' => 'Retrieve a self-conversation for a user',
'type' => 'read',
'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
'ajax' => true
),
'core_message_get_messages' => array(
'classname' => 'core_message_external',
'methodname' => 'get_messages',
......
......@@ -2988,5 +2988,189 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2019041000.02);
}
if ($oldversion < 2019041300.01) {
// STEP 1. For the existing and migrated self-conversations, set the type to the new MESSAGE_CONVERSATION_TYPE_SELF, update
// the convhash and star them.
$sql = "SELECT mcm.conversationid, mcm.userid, MAX(mcm.id) as maxid
FROM {message_conversation_members} mcm
GROUP BY mcm.conversationid, mcm.userid
HAVING COUNT(*) > 1";
$selfconversationsrs = $DB->get_recordset_sql($sql);
$maxids = [];
foreach ($selfconversationsrs as $selfconversation) {
$DB->update_record('message_conversations',
['id' => $selfconversation->conversationid,
'type' => \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF,
'convhash' => \core_message\helper::get_conversation_hash([$selfconversation->userid])
]
);
// Star the existing self-conversation.
$favouriterecord = new \stdClass();
$favouriterecord->component = 'core_message';
$favouriterecord->itemtype = 'message_conversations';
$favouriterecord->itemid = $selfconversation->conversationid;
$userctx = \context_user::instance($selfconversation->userid);
$favouriterecord->contextid = $userctx->id;
$favouriterecord->userid = $selfconversation->userid;
$favouriterecord->timecreated = time();
$favouriterecord->timemodified = $favouriterecord->timecreated;
$DB->insert_record('favourite', $favouriterecord);
// Set the self-conversation member with maxid to remove it later.
$maxids[] = $selfconversation->maxid;
}
$selfconversationsrs->close();
// Remove the repeated member with the higher id for all the existing self-conversations.
if (!empty($maxids)) {
list($insql, $inparams) = $DB->get_in_or_equal($maxids);
$DB->delete_records_select('message_conversation_members', "id $insql", $inparams);
}
// STEP 2. Migrate existing self-conversation relying on old message tables, setting the type to the new
// MESSAGE_CONVERSATION_TYPE_SELF and the convhash to the proper one. Star them also.
// On the messaging legacy tables, self-conversations are only present in the 'message_read' table, so we don't need to
// check the content in the 'message' table.
$select = 'useridfrom = useridto AND notification = 0';
$legacyselfmessagesrs = $DB->get_recordset_select('message_read', $select);
foreach ($legacyselfmessagesrs as $message) {
// Get the self-conversation or create and star it if doesn't exist.
$conditions = [
'type' => \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF,
'convhash' => \core_message\helper::get_conversation_hash([$message->useridfrom])
];
$selfconversation = $DB->get_record('message_conversations', $conditions);
if (empty($selfconversation)) {
// Create the self-conversation.
$selfconversation = new \stdClass();
$selfconversation->type = \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF;
$selfconversation->convhash = \core_message\helper::get_conversation_hash([$message->useridfrom]);
$selfconversation->enabled = 1;
$selfconversation->timecreated = time();
$selfconversation->timemodified = $selfconversation->timecreated;
$selfconversation->id = $DB->insert_record('message_conversations', $selfconversation);
// Add user to this self-conversation.
$member = new \stdClass();
$member->conversationid = $selfconversation->id;
$member->userid = $message->useridfrom;
$member->timecreated = time();
$member->id = $DB->insert_record('message_conversation_members', $member);
// Star the self-conversation.
$favouriterecord = new \stdClass();
$favouriterecord->component = 'core_message';
$favouriterecord->itemtype = 'message_conversations';
$favouriterecord->itemid = $selfconversation->id;
$userctx = \context_user::instance($message->useridfrom);
$favouriterecord->contextid = $userctx->id;
$favouriterecord->userid = $message->useridfrom;
$favouriterecord->timecreated = time();
$favouriterecord->timemodified = $favouriterecord->timecreated;
$DB->insert_record('favourite', $favouriterecord);
}
// Create the object we will be inserting into the database.
$tabledata = new \stdClass();
$tabledata->useridfrom = $message->useridfrom;
$tabledata->conversationid = $selfconversation->id;
$tabledata->subject = $message->subject;
$tabledata->fullmessage = $message->fullmessage;
$tabledata->fullmessageformat = $message->fullmessageformat ?? FORMAT_MOODLE;
$tabledata->fullmessagehtml = $message->fullmessagehtml;
$tabledata->smallmessage = $message->smallmessage;
$tabledata->timecreated = $message->timecreated;
$messageid = $DB->insert_record('messages', $tabledata);
// Check if we need to mark this message as deleted (self-conversations add this information on the
// timeuserfromdeleted field.
if ($message->timeuserfromdeleted) {
$mua = new \stdClass();
$mua->userid = $message->useridfrom;
$mua->messageid = $messageid;
$mua->action = \core_message\api::MESSAGE_ACTION_DELETED;
$mua->timecreated = $message->timeuserfromdeleted;
$DB->insert_record('message_user_actions', $mua);
}
// Mark this message as read.
$mua = new \stdClass();
$mua->userid = $message->useridto;
$mua->messageid = $messageid;
$mua->action = \core_message\api::MESSAGE_ACTION_READ;
$mua->timecreated = $message->timeread;
$DB->insert_record('message_user_actions', $mua);
}
$legacyselfmessagesrs->close();
// We can now delete the records from legacy table because the self-conversations have been migrated from the legacy tables.
$DB->delete_records_select('message_read', $select);
// STEP 3. For existing users without self-conversations, create and star it.
// Get all the users without a self-conversation.
$sql = "SELECT u.id
FROM {user} u
WHERE u.id NOT IN (SELECT mcm.userid
FROM {message_conversation_members} mcm
INNER JOIN mdl_message_conversations mc
ON mc.id = mcm.conversationid AND mc.type = ?
)";
$useridsrs = $DB->get_recordset_sql($sql, [\core_message\api::MESSAGE_CONVERSATION_TYPE_SELF]);
// Create the self-conversation for all these users.
foreach ($useridsrs as $user) {
$conditions = [
'type' => \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF,
'convhash' => \core_message\helper::get_conversation_hash([$user->id])
];
$selfconversation = $DB->get_record('message_conversations', $conditions);
if (empty($selfconversation)) {
// Create the self-conversation.
$selfconversation = new \stdClass();
$selfconversation->type = \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF;
$selfconversation->convhash = \core_message\helper::get_conversation_hash([$user->id]);
$selfconversation->enabled = 1;
$selfconversation->timecreated = time();
$selfconversation->timemodified = $selfconversation->timecreated;
$selfconversation->id = $DB->insert_record('message_conversations', $selfconversation);
// Add user to this self-conversation.
$member = new \stdClass();
$member->conversationid = $selfconversation->id;
$member->userid = $user->id;
$member->timecreated = time();
$member->id = $DB->insert_record('message_conversation_members', $member);
// Star the self-conversation.
$favouriterecord = new \stdClass();
$favouriterecord->component = 'core_message';
$favouriterecord->itemtype = 'message_conversations';
$favouriterecord->itemid = $selfconversation->id;
$userctx = \context_user::instance($user->id);
$favouriterecord->contextid = $userctx->id;
$favouriterecord->userid = $user->id;
$favouriterecord->timecreated = time();
$favouriterecord->timemodified = $favouriterecord->timecreated;
$DB->insert_record('favourite', $favouriterecord);
}
}
$useridsrs->close();
// Main savepoint reached.
upgrade_main_savepoint(true, 2019041300.01);
}
return true;
}
......@@ -118,18 +118,30 @@ function message_send(\core\message\message $eventdata) {
return false;
}
if (!$conversationid = \core_message\api::get_conversation_between_users([$eventdata->userfrom->id,
$eventdata->userto->id])) {
$conversation = \core_message\api::create_conversation(
\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
[
$eventdata->userfrom->id,
$eventdata->userto->id
]
);
if ($eventdata->userfrom->id == $eventdata->userto->id) {
// It's a self conversation.
$conversation = \core_message\api::get_self_conversation($eventdata->userfrom->id);
if (empty($conversation)) {
$conversation = \core_message\api::create_conversation(
\core_message\api::MESSAGE_CONVERSATION_TYPE_SELF,
[$eventdata->userfrom->id]
);
}
} else {
if (!$conversationid = \core_message\api::get_conversation_between_users([$eventdata->userfrom->id,
$eventdata->userto->id])) {
// It's a private conversation between users.
$conversation = \core_message\api::create_conversation(
\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
[
$eventdata->userfrom->id,
$eventdata->userto->id
]
);
}
}
// We either have found a conversation, or created one.
$conversationid = $conversationid ? $conversationid : $conversation->id;
$conversationid = !empty($conversationid) ? $conversationid : $conversation->id;
$eventdata->convid = $conversationid;
}
......
......@@ -819,6 +819,59 @@ class core_messagelib_testcase extends advanced_testcase {
$sink->clear();
}
/**
* Tests calling message_send() with $eventdata representing a message to a self-conversation.
*
* This test will verify:
* - that the 'messages' record is created.
* - that the processors is not called (for now self-conversations are not processed).
* - the a single event will be generated - 'message_sent'
*
* Note: We won't redirect/capture messages in this test because doing so causes message_send() to return early, before
* processors and events code is called. We need to test this code here, as we generally redirect messages elsewhere and we
* need to be sure this is covered.
*/
public function test_message_send_to_self_conversation() {
global $DB;
$this->preventResetByRollback();
$this->resetAfterTest();
// Create some users and a conversation between them.
$user1 = $this->getDataGenerator()->create_user(array('maildisplay' => 1));
set_config('allowedemaildomains', 'example.com');
$conversation = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_SELF,
[$user1->id]);
// Generate the message.
$message = new \core\message\message();
$message->courseid = 1;
$message->component = 'moodle';
$message->name = 'instantmessage';
$message->userfrom = $user1;
$message->convid = $conversation->id;
$message->subject = 'message subject 1';
$message->fullmessage = 'message body';
$message->fullmessageformat = FORMAT_MARKDOWN;
$message->fullmessagehtml = '<p>message body</p>';
$message->smallmessage = 'small message';
$message->notification = '0';
// Content specific to the email processor.
$content = array('*' => array('header' => ' test ', 'footer' => ' test '));
$message->set_additional_content('email', $content);
// Ensure we're going to hit the email processor for this user.
$DB->set_field_select('message_processors', 'enabled', 0, "name <> 'email'");
set_user_preference('message_provider_moodle_instantmessage_loggedoff', 'email', $user1);
// Now, send a message and verify the message processors are empty (self-conversations are not processed for now).
$sink = $this->redirectEmails();
$messageid = message_send($message);
$emails = $sink->get_messages();
$this->assertCount(0, $emails);
$sink->clear();
}
/**
* Tests calling message_send() with $eventdata representing a message to an group conversation.
*
......
......@@ -30,6 +30,14 @@ attribute on forms to avoid collisions in forms loaded in AJAX requests.
in this category. To work with list of courses use API methods in core_course_category and also 'course' form element.
* It is possible to pass additional conditions to get_courses_search();
core_course_category::search_courses() now allows to search only among courses with completion enabled.
* A new conversation type has been created for self-conversations. During the upgrading process:
- Firstly, the existing self-conversations will be starred and migrated to the new type, removing the duplicated members in the
message_conversation_members table.
- Secondly, the legacy self conversations will be migrated from the legacy 'message_read' table. They will be created using the
new conversation type and will be favourited.
- Finally, the self-conversations for all remaining users without them will be created and starred.
Besides, from now, a self-conversation will be created and starred by default to all the new users (even when $CFG->messaging
is disabled).
=== 3.6 ===
......
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
......@@ -120,11 +120,16 @@ function(
* @return {Number} Userid.
*/
var getOtherUserId = function() {
if (!viewState || viewState.type != CONVERSATION_TYPES.PRIVATE) {
if (!viewState || (viewState.type != CONVERSATION_TYPES.PRIVATE && viewState.type != CONVERSATION_TYPES.SELF)) {
return null;
}
var loggedInUserId = viewState.loggedInUserId;
if (viewState.type == CONVERSATION_TYPES.SELF) {
// It's a self-conversation, so the other user is the one logged in.
return loggedInUserId;
}
var otherUserIds = Object.keys(viewState.members).filter(function(userId) {
return loggedInUserId != userId;
});
......@@ -144,7 +149,7 @@ function(
if (!carry) {
var state = stateCache[id].state;
if (state.type == CONVERSATION_TYPES.PRIVATE) {
if (state.type == CONVERSATION_TYPES.PRIVATE || state.type == CONVERSATION_TYPES.SELF) {
if (userId in state.members) {
// We've found a cached conversation for this user!
carry = state.id;
......@@ -287,7 +292,7 @@ function(
newState = StateManager.setLoadingMembers(newState, false);
newState = StateManager.setLoadingMessages(newState, false);
newState = StateManager.setName(newState, profile.fullname);
newState = StateManager.setType(newState, 1);
newState = StateManager.setType(newState, CONVERSATION_TYPES.PRIVATE);
newState = StateManager.setImageUrl(newState, profile.profileimageurl);
newState = StateManager.setTotalMemberCount(newState, 2);
return render(newState)
......@@ -302,6 +307,50 @@ function(
});
};
/**
* Load up an empty self-conversation for the logged in user.
* Sets all of the conversation details based on the current user.
*
* A conversation isn't created until the user sends the first message.
*
* @param {Object} loggedInUserProfile The logged in user profile.
* @return {Object} Profile returned from repository.
*/
var loadEmptySelfConversation = function(loggedInUserProfile) {
var loggedInUserId = loggedInUserProfile.id;
var newState = StateManager.setLoadingMembers(viewState, true);
newState = StateManager.setLoadingMessages(newState, true);
return render(newState)
.then(function() {
return Repository.getMemberInfo(loggedInUserId, [loggedInUserId], true, true);
})
.then(function(profiles) {
if (profiles.length) {
return profiles[0];
} else {
throw new Error('Unable to load other user profile');
}
})
.then(function(profile) {
var newState = StateManager.addMembers(viewState, [profile, loggedInUserProfile]);
newState = StateManager.setLoadingMembers(newState, false);
newState = StateManager.setLoadingMessages(newState, false);
newState = StateManager.setName(newState, profile.fullname);
newState = StateManager.setType(newState, CONVERSATION_TYPES.SELF);
newState = StateManager.setImageUrl(newState, profile.profileimageurl);
newState = StateManager.setTotalMemberCount(newState, 1);
return render(newState)
.then(function() {
return profile;
});
})
.catch(function(error) {
var newState = StateManager.setLoadingMembers(viewState, false);
render(newState);
Notification.exception(error);
});
};
/**
* Create a new state from a conversation object.
*
......@@ -310,14 +359,21 @@ function(
* @return {Object} new state.
*/
var updateStateFromConversation = function(conversation, loggedInUserId) {
var otherUsers = conversation.members.filter(function(member) {
return member.id != loggedInUserId;
});
var otherUser = otherUsers.length ? otherUsers[0] : null;
var otherUser = null;
if (conversation.type == CONVERSATION_TYPES.PRIVATE) {
// For private conversations, remove current logged in user from the members list to get the other user.
var otherUsers = conversation.members.filter(function(member) {
return member.id != loggedInUserId;
});
otherUser = otherUsers.length ? otherUsers[0] : null;
} else if (conversation.type == CONVERSATION_TYPES.SELF) {
// Self-conversations have only one member.
otherUser = conversation.members[0];
}
var name = conversation.name;
var imageUrl = conversation.imageurl;
if (conversation.type == CONVERSATION_TYPES.PRIVATE) {
if (conversation.type == CONVERSATION_TYPES.PRIVATE || conversation.type == CONVERSATION_TYPES.SELF) {
name = name || otherUser ? otherUser.fullname : '';
imageUrl = imageUrl || otherUser ? otherUser.profileimageurl : '';
}
......@@ -921,6 +977,7 @@ function(
newState = StateManager.setPendingDeleteConversation(newState, false);
newState = StateManager.setLoadingConfirmAction(newState, false);
PubSub.publish(MessageDrawerEvents.CONVERSATION_DELETED, newState.id);
return render(newState);
});
};
......@@ -1019,7 +1076,8 @@ function(
var newConversationId = null;
return render(newState)
.then(function() {
if (!conversationId && viewState.type == CONVERSATION_TYPES.PRIVATE) {
if (!conversationId &&
(viewState.type == CONVERSATION_TYPES.PRIVATE || viewState.type == CONVERSATION_TYPES.SELF)) {
// If it's a new private conversation then we need to use the old
// web service function to create the conversation.
var otherUserId = getOtherUserId();
......@@ -1471,7 +1529,7 @@ function(
};
/**
* Load a new empty private conversation between two users.
* Load a new empty private conversation between two users or self-conversation.
*
* @param {Object} body Conversation body container element.
* @param {Object} loggedInUserProfile The logged in user's profile.
......@@ -1481,28 +1539,50 @@ function(
var resetNoConversation = function(body, loggedInUserProfile, otherUserId) {
// Always reset the state back to the initial state so that the
// state manager and patcher can work correctly.
return resetState(body, null, loggedInUserProfile)
.then(function() {
return Repository.getConversationBetweenUsers(
loggedInUserProfile.id,
otherUserId,
true,
true,
0,
0,
LOAD_MESSAGE_LIMIT,
0,
NEWEST_FIRST
)
.then(function(conversation) {
// Looks like we have a conversation after all! Let's use that.
return resetByConversation(body, conversation, loggedInUserProfile);
})
.catch(function() {
// Can't find a conversation. Oh well. Just load up a blank one.
return loadEmptyPrivateConversation(loggedInUserProfile, otherUserId);
});
});
if (loggedInUserProfile.id != otherUserId) {
// This is a private conversation between two users.
return resetState(body, null, loggedInUserProfile)
.then(function() {
return Repository.getConversationBetweenUsers(
loggedInUserProfile.id,
otherUserId,
true,
true,
0,
0,
LOAD_MESSAGE_LIMIT,
0,
NEWEST_FIRST
)
.then(function(conversation) {
// Looks like we have a conversation after all! Let's use that.
return resetByConversation(body, conversation, loggedInUserProfile);
})
.catch(function() {
// Can't find a conversation. Oh well. Just load up a blank one.
return loadEmptyPrivateConversation(loggedInUserProfile, otherUserId);
});
});
} else {
// This is a self-conversation.
return resetState(body, null, loggedInUserProfile)
.then(function() {
return Repository.getSelfConversation(
loggedInUserProfile.id,
LOAD_MESSAGE_LIMIT,
0,
NEWEST_FIRST
)
.then(function(conversation) {
// Looks like we have a conversation after all! Let's use that.
return resetByConversation(body, conversation, loggedInUserProfile);
})
.catch(function() {
// Can't find a conversation. Oh well. Just load up a blank one.
return loadEmptySelfConversation(loggedInUserProfile);
});
});
}
};
/**
......
......@@ -78,6 +78,7 @@ define([], function() {
MORE_MESSAGES_LOADING_ICON_CONTAINER: '[data-region="more-messages-loading-icon-container"]',
MUTED_ICON_CONTAINER: '[data-region="muted-icon-container"]',
PLACEHOLDER_CONTAINER: '[data-region="placeholder-container"]',
SELF_CONVERSATION_MESSAGE_CONTAINER: '[data-region="self-conversation-message-container"]',
SEND_MESSAGE_BUTTON: '[data-action="send-message"]',
SEND_MESSAGE_ICON_CONTAINER: '[data-region="send-icon-container"]',
TEXT: '[data-region="text"]',
......@@ -88,20 +89,32 @@ define([], function() {
HEADER_PRIVATE: 'core_message/message_drawer_view_conversation_header_content_type_private',
HEADER_PRIVATE_NO_CONTROLS: 'core_message/message_drawer_view_conversation_header_content_type_private_no_controls',