Merge branch 'MDL-63826' of git://github.com/stronk7/moodle
authorJun Pataleta <jun@moodle.com>
Thu, 1 Nov 2018 01:28:45 +0000 (09:28 +0800)
committerJun Pataleta <jun@moodle.com>
Thu, 1 Nov 2018 01:28:45 +0000 (09:28 +0800)
45 files changed:
blocks/myoverview/amd/build/view.min.js
blocks/myoverview/amd/src/view.js
blocks/myoverview/lang/en/block_myoverview.php
blocks/myoverview/templates/course-action-menu.mustache
blocks/myoverview/templates/view-cards.mustache
blocks/myoverview/templates/view-list.mustache
blocks/myoverview/templates/view-summary.mustache
blocks/starredcourses/amd/build/main.min.js [new file with mode: 0644]
blocks/starredcourses/amd/build/repository.min.js [new file with mode: 0644]
blocks/starredcourses/amd/src/main.js [new file with mode: 0644]
blocks/starredcourses/amd/src/repository.js [new file with mode: 0644]
blocks/starredcourses/block_starredcourses.php [new file with mode: 0644]
blocks/starredcourses/classes/external.php [new file with mode: 0644]
blocks/starredcourses/classes/output/main.php [new file with mode: 0644]
blocks/starredcourses/classes/output/renderer.php [new file with mode: 0644]
blocks/starredcourses/classes/privacy/provider.php [new file with mode: 0644]
blocks/starredcourses/db/access.php [new file with mode: 0644]
blocks/starredcourses/db/services.php [new file with mode: 0644]
blocks/starredcourses/lang/en/block_starredcourses.php [new file with mode: 0644]
blocks/starredcourses/pix/courses.svg [new file with mode: 0644]
blocks/starredcourses/templates/main.mustache [new file with mode: 0644]
blocks/starredcourses/templates/no-courses.mustache [new file with mode: 0644]
blocks/starredcourses/templates/placeholder-course.mustache [new file with mode: 0644]
blocks/starredcourses/templates/view-cards.mustache [new file with mode: 0644]
blocks/starredcourses/templates/view.mustache [new file with mode: 0644]
blocks/starredcourses/version.php [new file with mode: 0644]
course/amd/build/events.min.js [new file with mode: 0644]
course/amd/src/events.js [new file with mode: 0644]
course/templates/favouriteicon.mustache [moved from blocks/myoverview/templates/favourite-icon.mustache with 82% similarity]
lang/en/course.php
lib/classes/plugin_manager.php
lib/db/services.php
message/classes/api.php
message/classes/helper.php
message/externallib.php
message/index.php
message/tests/api_test.php
message/tests/externallib_test.php
message/upgrade.txt
mod/scorm/classes/event/sco_launched.php
mod/scorm/tests/externallib_test.php
theme/boost/scss/moodle/blocks.scss
theme/boost/style/moodle.css
theme/bootstrapbase/less/moodle/blocks.less
theme/bootstrapbase/style/moodle.css

index 5f3fb07..a278dcb 100644 (file)
Binary files a/blocks/myoverview/amd/build/view.min.js and b/blocks/myoverview/amd/build/view.min.js differ
index f4304fa..49a0b92 100644 (file)
@@ -26,17 +26,21 @@ define(
     'jquery',
     'block_myoverview/repository',
     'core/paged_content_factory',
+    'core/pubsub',
     'core/custom_interaction_events',
     'core/notification',
     'core/templates',
+    'core_course/events'
 ],
 function(
     $,
     Repository,
     PagedContentFactory,
+    PubSub,
     CustomEvents,
     Notification,
-    Templates
+    Templates,
+    CourseEvents
 ) {
 
     var SELECTORS = {
@@ -197,6 +201,7 @@ function(
 
         setCourseFavouriteState(courseId, true).then(function(success) {
             if (success) {
+                PubSub.publish(CourseEvents.favourited);
                 removeAction.removeClass('hidden');
                 addAction.addClass('hidden');
                 showFavouriteIcon(root, courseId);
@@ -219,6 +224,7 @@ function(
 
         setCourseFavouriteState(courseId, false).then(function(success) {
             if (success) {
+                PubSub.publish(CourseEvents.unfavorited);
                 removeAction.addClass('hidden');
                 addAction.removeClass('hidden');
                 hideFavouriteIcon(root, courseId);
index 9500656..de95d3c 100644 (file)
@@ -29,12 +29,9 @@ $string['aria:allcourses'] = 'All courses';
 $string['aria:card'] = 'Switch to card view';
 $string['aria:controls'] = 'Course overview controls';
 $string['aria:courseactions'] = 'Actions for current course';
-$string['aria:courseimage'] = 'Course image:';
-$string['aria:coursename'] = 'Course name:';
 $string['aria:coursesummary'] = 'Course summary text:';
 $string['aria:courseprogress'] = 'Course progress:';
 $string['aria:displaydropdown'] = 'Display dropdown';
-$string['aria:favourite'] = 'Course is starred';
 $string['aria:favourites'] = 'Show starred courses';
 $string['aria:future'] = 'Show future courses';
 $string['aria:groupingdropdown'] = 'Grouping dropdown';
@@ -49,11 +46,9 @@ $string['aria:sortingdropdown'] = 'Sorting dropdown';
 $string['card'] = 'Card';
 $string['cards'] = 'Cards';
 $string['complete'] = 'Complete';
-$string['favourite'] = 'Starred course';
 $string['favourites'] = 'Starred';
 $string['future'] = 'Future';
 $string['inprogress'] = 'In progress';
-$string['notfavourite'] = 'Not starred';
 $string['lastaccessed'] = 'Last accessed';
 $string['list'] = 'List';
 $string['myoverview:addinstance'] = 'Add a new course overview block';
index 027bdab..17085d4 100644 (file)
@@ -41,7 +41,7 @@
             data-course-id="{{id}}"
             aria-controls="favorite-icon-{{ id }}"
             >
-            {{#pix}} i/star, core, {{#str}} favourite, block_myoverview {{/str}} {{/pix}}
+            {{#pix}} i/star, core, {{#str}} favourites, block_myoverview {{/str}} {{/pix}}
             {{#str}} addtofavourites, block_myoverview {{/str}}
             <div class="sr-only">
                 {{#str}} aria:addtofavourites, block_myoverview {{/str}} {{{fullname}}}
index ce97c1f..352d2ed 100644 (file)
     <div class="card dashboard-card" role="listitem">
         <a href="{{viewurl}}" tabindex="-1">
             <div class="card-img dashboard-card-img" style='background-image: url("{{{courseimage}}}");'>
-                <span class="sr-only">{{#str}}aria:courseimage, block_myoverview{{/str}}</span>
-                {{> block_myoverview/favourite-icon }}
+                <span class="sr-only">{{#str}}aria:courseimage, core_course{{/str}}</span>
+                {{> core_course/favouriteicon }}
             </div>
         </a>
         <div class="card-body pr-1 course-info-container" id="course-info-container-{{id}}">
             <div class="d-flex align-items-start">
                 <div class="card-title mr-2">
                     <a href="{{viewurl}}">
-                        <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
+                        <span class="sr-only">{{#str}}aria:coursename, core_course{{/str}}</span>
                         {{#shortentext}}40, {{{fullname}}} {{/shortentext}}
                     </a>
                 </div>
index 389036b..e39dafd 100644 (file)
@@ -40,9 +40,9 @@
         <div class="row-fluid">
             <div class="{{#hasprogress}}col-6 span6{{/hasprogress}}{{^hasprogress}}col-11 span11{{/hasprogress}} p-l-0">
                 <div class="d-flex align-items-center">
-                    {{> block_myoverview/favourite-icon }}
+                    {{> core_course/favouriteicon }}
                     <a href="{{viewurl}}">
-                        <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
+                        <span class="sr-only">{{#str}}aria:coursename, core_course{{/str}}</span>
                         {{{fullname}}}
                     </a>
                 </div>
index 0470ca4..7ee3737 100644 (file)
         <div class="row-fluid d-flex">
             <a href="{{viewurl}}" class="col-sm-4 col-xl-3 span4 position-relative" tabindex="-1">
                 <div class="position-absolute">
-                    {{> block_myoverview/favourite-icon }}
+                    {{> core_course/favouriteicon }}
                 </div>
                 <div class="card-img-top summaryimage" style='background-image: url("{{{courseimage}}}");'>
-                    <span class="sr-only">{{#str}}aria:courseimage, block_myoverview{{/str}}</span>
+                    <span class="sr-only">{{#str}}aria:courseimage, core_course{{/str}}</span>
                 </div>
             </a>
             <div class="col-sm-8 col-xl-9 span8 align-self-stretch d-flex flex-column">
                 <div class="d-flex">
                     <a href="{{viewurl}}">
-                        <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
+                        <span class="sr-only">{{#str}}aria:coursename, core_course{{/str}}</span>
                         <h4>{{{fullname}}}</h4>
                     </a>
                     {{> block_myoverview/course-action-menu }}
diff --git a/blocks/starredcourses/amd/build/main.min.js b/blocks/starredcourses/amd/build/main.min.js
new file mode 100644 (file)
index 0000000..8ebbc68
Binary files /dev/null and b/blocks/starredcourses/amd/build/main.min.js differ
diff --git a/blocks/starredcourses/amd/build/repository.min.js b/blocks/starredcourses/amd/build/repository.min.js
new file mode 100644 (file)
index 0000000..7c88600
Binary files /dev/null and b/blocks/starredcourses/amd/build/repository.min.js differ
diff --git a/blocks/starredcourses/amd/src/main.js b/blocks/starredcourses/amd/src/main.js
new file mode 100644 (file)
index 0000000..81d4130
--- /dev/null
@@ -0,0 +1,119 @@
+// 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/>.
+
+/**
+ * Javascript to initialise the starred courses block.
+ *
+ * @copyright   2018 Simey Lameze <simey@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(
+[
+    'jquery',
+    'core/notification',
+    'block_starredcourses/repository',
+    'core/pubsub',
+    'core/templates',
+    'core_course/events'
+],
+function(
+    $,
+    Notification,
+    Repository,
+    PubSub,
+    Templates,
+    CourseEvents
+) {
+
+    var SELECTORS = {
+        STARRED_COURSES_REGION_VIEW: '[data-region="starred-courses-view"]',
+        STARRED_COURSES_REGION: '[data-region="starred-courses-view-content"]'
+    };
+
+    /**
+     * Render the starred courses.
+     *
+     * @method renderCourses
+     * @param {object} root The root element for the starred view.
+     * @param {array} courses containing array of returned courses.
+     * @returns {promise} Resolved with HTML and JS strings
+     */
+    var renderCourses = function(root, courses) {
+        if (courses.length > 0) {
+            return Templates.render('block_starredcourses/view-cards', {
+                courses: courses
+            });
+        } else {
+            var nocoursesimg = root.find(SELECTORS.STARRED_COURSES_REGION_VIEW).attr('data-nocoursesimg');
+            return Templates.render('block_starredcourses/no-courses', {
+                nocoursesimg: nocoursesimg
+            });
+        }
+    };
+
+    /**
+     * Fetch user's starred courses and reload the content of the block.
+     *
+     * @param {object} root The root element for the starred view.
+     * @returns {promise} The updated content for the block.
+     */
+    var reloadContent = function(root) {
+        var content = root.find(SELECTORS.STARRED_COURSES_REGION);
+
+        var args = {
+            limit: 0,
+            offset: 0,
+        };
+
+        return Repository.getStarredCourses(args)
+            .then(function(courses) {
+                return renderCourses(root, courses);
+            }).then(function(html, js) {
+                return Templates.replaceNodeContents(content, html, js);
+            }).catch(Notification.exception);
+    };
+
+    /**
+     * Register event listeners for the block.
+     *
+     * @param {object} root The calendar root element
+     */
+    var registerEventListeners = function(root) {
+        PubSub.subscribe(CourseEvents.favourited, function() {
+            reloadContent(root);
+        });
+
+        PubSub.subscribe(CourseEvents.unfavorited, function() {
+            reloadContent(root);
+        });
+    };
+
+    /**
+     * Initialise all of the modules for the starred courses block.
+     *
+     * @param {object} root The root element for the block.
+     */
+    var init = function(root) {
+        root = $(root);
+
+        registerEventListeners(root);
+        reloadContent(root);
+    };
+
+    return {
+        init: init
+    };
+});
diff --git a/blocks/starredcourses/amd/src/repository.js b/blocks/starredcourses/amd/src/repository.js
new file mode 100644 (file)
index 0000000..7de121e
--- /dev/null
@@ -0,0 +1,53 @@
+// 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 javascript module to retrieve user's starred courses.
+ *
+ * @package    block_starredcourses
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notification) {
+
+    /**
+     * Retrieve a list of starred courses.
+     *
+     * Valid args are:
+     * int limit    number of records to retrieve
+     * int offset   the offset of records to retrieve
+     *
+     * @method getStarredCourses
+     * @param {object} args The request arguments
+     * @return {promise} Resolved with an array of courses
+     */
+    var getStarredCourses = function(args) {
+
+        var request = {
+            methodname: 'block_starredcourses_get_starred_courses',
+            args: args
+        };
+
+        var promise = Ajax.call([request])[0];
+
+        promise.fail(Notification.exception);
+
+        return promise;
+    };
+
+    return {
+        getStarredCourses: getStarredCourses
+    };
+});
\ No newline at end of file
diff --git a/blocks/starredcourses/block_starredcourses.php b/blocks/starredcourses/block_starredcourses.php
new file mode 100644 (file)
index 0000000..2da98ce
--- /dev/null
@@ -0,0 +1,75 @@
+<?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/>.
+
+/**
+ * Starred courses block.
+ *
+ * @package   block_starredcourses
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Starred courses block definition class.
+ *
+ * @package   block_starredcourses
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class block_starredcourses extends block_base {
+
+    /**
+     * Initialises the block.
+     *
+     * @return void
+     */
+    public function init() {
+        $this->title = get_string('pluginname', 'block_starredcourses');
+    }
+
+    /**
+     * Gets the block contents.
+     *
+     * @return string The block HTML.
+     */
+    public function get_content() {
+
+        if ($this->content !== null) {
+            return $this->content;
+        }
+
+        $renderable = new \block_starredcourses\output\main();
+        $renderer = $this->page->get_renderer('block_starredcourses');
+
+        $this->content = (object) [
+            'text' => $renderer->render($renderable),
+            'footer' => ''
+        ];
+
+        return $this->content;
+    }
+
+    /**
+     * Locations where block can be displayed.
+     *
+     * @return array
+     */
+    public function applicable_formats() {
+        return array('my' => true);
+    }
+}
diff --git a/blocks/starredcourses/classes/external.php b/blocks/starredcourses/classes/external.php
new file mode 100644 (file)
index 0000000..f5cb1c2
--- /dev/null
@@ -0,0 +1,111 @@
+<?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/>.
+/**
+ * Starred courses block external API
+ *
+ * @package    block_starredcourses
+ * @category   external
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die;
+
+require_once($CFG->libdir . '/externallib.php');
+
+require_once($CFG->dirroot . '/course/lib.php');
+require_once($CFG->dirroot . '/course/externallib.php');
+
+use \core_course\external\course_summary_exporter;
+
+/**
+ * Starred courses block external functions.
+ *
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class block_starredcourses_external extends core_course_external {
+
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.6
+     */
+    public static function get_starred_courses_parameters() {
+        return new external_function_parameters([
+            'limit' => new external_value(PARAM_INT, 'Limit', VALUE_DEFAULT, 0),
+            'offset' => new external_value(PARAM_INT, 'Offset', VALUE_DEFAULT, 0)
+        ]);
+    }
+
+    /**
+     * Get users starred courses.
+     *
+     * @param int $limit Limit
+     * @param int $offset Offset
+     *
+     * @return  array list of courses and warnings
+     */
+    public static function get_starred_courses($limit, $offset) {
+        global $USER, $PAGE;
+
+        $params = self::validate_parameters(self::get_starred_courses_parameters(), [
+            'limit' => $limit,
+            'offset' => $offset
+        ]);
+
+        $limit = $params['limit'];
+        $offset = $params['offset'];
+
+        $usercontext = context_user::instance($USER->id);
+
+        self::validate_context($usercontext);
+        $PAGE->set_context($usercontext);
+        $renderer = $PAGE->get_renderer('core');
+
+        // Get the user favourites service, scoped to a single user (their favourites only).
+        $userservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+
+        // Get the favourites, by type, for the user.
+        $favourites = $userservice->find_favourites_by_type('core_course', 'courses', $offset, $limit);
+
+        // Sort the favourites getting last added first.
+        usort($favourites, function($a, $b) {
+            if ($a->timemodified == $b->timemodified) return 0;
+            return ($a->timemodified > $b->timemodified) ? -1 : 1;
+        });
+
+        $formattedcourses = array_map(function($favourite) use ($renderer) {
+            $course = get_course($favourite->itemid);
+            $context = \context_course::instance($favourite->itemid);
+
+            $exporter = new course_summary_exporter($course, ['context' => $context, 'isfavourite' => true]);
+            return $exporter->export($renderer);
+        }, $favourites);
+
+        return $formattedcourses;
+    }
+
+    /**
+     * Returns description of method result value
+     *
+     * @return external_description
+     * @since Moodle 3.6
+     */
+    public static function get_starred_courses_returns() {
+        return new external_multiple_structure(course_summary_exporter::get_read_structure());
+    }
+}
diff --git a/blocks/starredcourses/classes/output/main.php b/blocks/starredcourses/classes/output/main.php
new file mode 100644 (file)
index 0000000..6b7d427
--- /dev/null
@@ -0,0 +1,59 @@
+<?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/>.
+
+/**
+ * Class containing data for starred courses block.
+ *
+ * @package     block_starredcourses
+ * @copyright   2018 Simey Lameze <simey@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace block_starredcourses\output;
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use renderer_base;
+use templatable;
+use core_course\external\course_summary_exporter;
+
+require_once($CFG->dirroot . '/course/lib.php');
+require_once($CFG->libdir . '/completionlib.php');
+
+/**
+ * Class containing data for starred courses block.
+ *
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class main implements renderable, templatable {
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param \renderer_base $output
+     * @return array
+     */
+    public function export_for_template(renderer_base $output) {
+        global $USER;
+
+        $nocoursesurl = $output->image_url('courses', 'block_starredcourses')->out();
+
+        return [
+            'userid' => $USER->id,
+            'nocoursesimg' => $nocoursesurl
+        ];
+    }
+}
diff --git a/blocks/starredcourses/classes/output/renderer.php b/blocks/starredcourses/classes/output/renderer.php
new file mode 100644 (file)
index 0000000..9fc1a27
--- /dev/null
@@ -0,0 +1,48 @@
+<?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/>.
+
+/**
+ * Starred courses block renderer.
+ *
+ * @package    block_starredcourses
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace block_starredcourses\output;
+defined('MOODLE_INTERNAL') || die;
+
+use plugin_renderer_base;
+
+/**
+ * Starred courses block renderer.
+ *
+ * @package    block_starredcourses
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends plugin_renderer_base {
+
+    /**
+     * Return the main content for the block.
+     *
+     * @param main $main The main renderable
+     * @return string HTML string
+     */
+    public function render_main(main $main) {
+        return $this->render_from_template('block_starredcourses/main',
+            $main->export_for_template($this));
+    }
+}
diff --git a/blocks/starredcourses/classes/privacy/provider.php b/blocks/starredcourses/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..c5e8c87
--- /dev/null
@@ -0,0 +1,45 @@
+<?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/>.
+
+/**
+ * Privacy Subsystem implementation for block_starredcourses.
+ *
+ * @package    block_starredcourses
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace block_starredcourses\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Privacy Subsystem for block_starredcourses.
+ *
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
diff --git a/blocks/starredcourses/db/access.php b/blocks/starredcourses/db/access.php
new file mode 100644 (file)
index 0000000..2f5e365
--- /dev/null
@@ -0,0 +1,48 @@
+<?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/>.
+
+/**
+ * Starred courses block capabilities.
+ *
+ * @package   block_starredcourses
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$capabilities = array(
+
+    'block/starredcourses:myaddinstance' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_SYSTEM,
+        'archetypes' => array(
+            'user' => CAP_ALLOW
+        ),
+
+        'clonepermissionsfrom' => 'moodle/my:manageblocks'
+    ),
+
+    'block/starredcourses:addinstance' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_BLOCK,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW
+        ),
+
+        'clonepermissionsfrom' => 'moodle/site:manageblocks'
+    ),
+);
diff --git a/blocks/starredcourses/db/services.php b/blocks/starredcourses/db/services.php
new file mode 100644 (file)
index 0000000..fc3ebb9
--- /dev/null
@@ -0,0 +1,38 @@
+<?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/>.
+
+/**
+ * File description.
+ *
+ * @package   block_starredcourses
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$functions = array(
+
+    'block_starredcourses_get_starred_courses' => array(
+        'classpath' => 'block/starredcourses/classes/external.php',
+        'classname'   => 'block_starredcourses_external',
+        'methodname'  => 'get_starred_courses',
+        'description' => 'Get users starred courses.',
+        'type'        => 'read',
+        'ajax'        => true,
+    ),
+);
+
diff --git a/blocks/starredcourses/lang/en/block_starredcourses.php b/blocks/starredcourses/lang/en/block_starredcourses.php
new file mode 100644 (file)
index 0000000..8d7d7f1
--- /dev/null
@@ -0,0 +1,30 @@
+<?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/>.
+
+/**
+ * Starred courses block language strings.
+ *
+ * @package   block_starredcourses
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['nocourses'] = 'No starred courses';
+$string['pluginname'] = 'Starred courses';
+$string['privacy:metadata'] = 'The starred courses block does not store any personal data.';
+$string['starredcourses:addinstance'] = 'Add a new instance of starred courses block';
+$string['starredcourses:myaddinstance'] = 'Add a new instance of starred block to Dashboard';
+
diff --git a/blocks/starredcourses/pix/courses.svg b/blocks/starredcourses/pix/courses.svg
new file mode 100644 (file)
index 0000000..edc3e6d
--- /dev/null
@@ -0,0 +1,49 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="157 -1305 148 125" preserveAspectRatio="xMinYMid meet">
+    <defs>
+        <style>
+            .cls-1 {
+            clip-path: url(#clip-Courses);
+            }
+            .cls-2 {
+            fill: #eee;
+            }
+            .cls-3 {
+            fill: #c4c8cc;
+            }
+            .cls-4 {
+            fill: #fff;
+            }
+        </style>
+        <clipPath id="clip-Courses">
+            <rect x="157" y="-1305" width="148" height="125"/>
+        </clipPath>
+    </defs>
+    <g id="Courses" class="cls-1">
+        <g id="Group_44" data-name="Group 44" transform="translate(-268 -1781)">
+            <ellipse id="Ellipse_41" data-name="Ellipse 41" class="cls-2" cx="74" cy="14.785" rx="74" ry="14.785" transform="translate(425 571.43)"/>
+            <rect id="Rectangle_87" data-name="Rectangle 87" class="cls-3" width="95.097" height="110.215" transform="translate(451.909 476)"/>
+            <g id="Group_43" data-name="Group 43" transform="translate(464.04 494)">
+                <rect id="Rectangle_88" data-name="Rectangle 88" class="cls-4" width="31.043" height="34" transform="translate(0)"/>
+                <rect id="Rectangle_89" data-name="Rectangle 89" class="cls-4" width="31.043" height="34" transform="translate(0 42)"/>
+                <rect id="Rectangle_90" data-name="Rectangle 90" class="cls-4" width="31.067" height="34" transform="translate(39.005)"/>
+                <rect id="Rectangle_91" data-name="Rectangle 91" class="cls-4" width="31.067" height="34" transform="translate(39.005 42)"/>
+                <rect id="Rectangle_92" data-name="Rectangle 92" class="cls-3" width="23.023" height="3.18" transform="translate(3.081 16.549)"/>
+                <rect id="Rectangle_93" data-name="Rectangle 93" class="cls-3" width="23.023" height="3.18" transform="translate(3.081 58.549)"/>
+                <rect id="Rectangle_94" data-name="Rectangle 94" class="cls-3" width="23.023" height="3.18" transform="translate(43.122 16.549)"/>
+                <rect id="Rectangle_95" data-name="Rectangle 95" class="cls-3" width="23.023" height="3.18" transform="translate(43.122 58.549)"/>
+                <rect id="Rectangle_96" data-name="Rectangle 96" class="cls-3" width="14.014" height="3.18" transform="translate(3.081 21.825)"/>
+                <rect id="Rectangle_97" data-name="Rectangle 97" class="cls-3" width="18.845" height="3.18" transform="translate(3.081 26.825)"/>
+                <rect id="Rectangle_98" data-name="Rectangle 98" class="cls-3" width="14.014" height="3.18" transform="translate(3.081 63.825)"/>
+                <rect id="Rectangle_99" data-name="Rectangle 99" class="cls-3" width="18.845" height="3.18" transform="translate(3.081 68.825)"/>
+                <rect id="Rectangle_100" data-name="Rectangle 100" class="cls-3" width="14.014" height="3.18" transform="translate(43.122 21.825)"/>
+                <rect id="Rectangle_101" data-name="Rectangle 101" class="cls-3" width="18.845" height="3.18" transform="translate(43.122 26.825)"/>
+                <rect id="Rectangle_102" data-name="Rectangle 102" class="cls-3" width="14.014" height="3.18" transform="translate(43.122 63.825)"/>
+                <rect id="Rectangle_103" data-name="Rectangle 103" class="cls-3" width="18.845" height="3.18" transform="translate(43.122 68.825)"/>
+                <ellipse id="Ellipse_42" data-name="Ellipse 42" class="cls-3" cx="5.658" cy="5.652" rx="5.658" ry="5.652" transform="translate(3.003 3.55)"/>
+                <ellipse id="Ellipse_43" data-name="Ellipse 43" class="cls-3" cx="5.658" cy="5.652" rx="5.658" ry="5.652" transform="translate(3.003 45.55)"/>
+                <ellipse id="Ellipse_44" data-name="Ellipse 44" class="cls-3" cx="5.658" cy="5.652" rx="5.658" ry="5.652" transform="translate(43.044 3.55)"/>
+                <ellipse id="Ellipse_45" data-name="Ellipse 45" class="cls-3" cx="5.658" cy="5.652" rx="5.658" ry="5.652" transform="translate(43.044 45.55)"/>
+            </g>
+        </g>
+    </g>
+</svg>
diff --git a/blocks/starredcourses/templates/main.mustache b/blocks/starredcourses/templates/main.mustache
new file mode 100644 (file)
index 0000000..b12dc66
--- /dev/null
@@ -0,0 +1,35 @@
+{{!
+    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/>.
+}}
+{{!
+    @template block_starredcourses/main
+
+    This template renders the main content area for the starred courses block.
+
+    Example context (json):
+    {}
+}}
+
+<div id="block-starredcourses-{{uniqid}}" class="block-starredcourses" data-region="starred-courses">
+    <div class="container-fluid p-0">
+        {{> block_starredcourses/view }}
+    </div>
+</div>
+{{#js}}
+require(['block_starredcourses/main'], function(Main) {
+    Main.init('#block-starredcourses-{{uniqid}}');
+});
+{{/js}}
diff --git a/blocks/starredcourses/templates/no-courses.mustache b/blocks/starredcourses/templates/no-courses.mustache
new file mode 100644 (file)
index 0000000..1b74964
--- /dev/null
@@ -0,0 +1,28 @@
+{{!
+    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/>.
+}}
+{{!
+    @template block_starredcourses/no-courses
+     This template renders the no courses message.
+     Example context (json):
+    {
+        "nocoursesimg": "https://moodlesite/theme/image.php/boost/block_recentcourses/1535727318/courses"
+    }
+}}
+<div class="text-xs-center text-center m-t-3" data-region="empty-message">
+    <img class="empty-placeholder-image-lg"
+         src="{{nocoursesimg}}"
+         alt="{{#str}} nocourses, block_starredcourses {{/str}}"
+         role="presentation">
+    <p class="text-muted mt-3">{{#str}} nocourses, block_starredcourses {{/str}}</p>
+</div>
diff --git a/blocks/starredcourses/templates/placeholder-course.mustache b/blocks/starredcourses/templates/placeholder-course.mustache
new file mode 100644 (file)
index 0000000..0e35afe
--- /dev/null
@@ -0,0 +1,26 @@
+{{!
+    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/>.
+}}
+{{!
+    @template block_starredcourses/placeholder-course
+     This template renders an course card item loading placeholder for the starred courses block.
+     Example context (json):
+    {}
+}}
+<div class="card dashboard-card border-0">
+    <div class="card-img-top bg-pulse-grey w-100" style="height: 7rem">
+    </div>
+    <div class="card-body recent-course-info-container">
+        <div class="bg-pulse-grey w-100 m-b-3" style="height: 1rem"></div>
+    </div>
+</div>
diff --git a/blocks/starredcourses/templates/view-cards.mustache b/blocks/starredcourses/templates/view-cards.mustache
new file mode 100644 (file)
index 0000000..05d672a
--- /dev/null
@@ -0,0 +1,50 @@
+{{!
+    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 Licensebllsdsadfasfd
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_starredcourses/view-cards
+     This template renders the carousel for the starredcourses block.
+     Example context (json):
+    {
+        "courses": [
+            {
+                "name": "Assignment due 1",
+                "viewurl": "https://moodlesite/course/view.php?id=2",
+                "courseimage": "https://moodlesite/pluginfile/123/course/overviewfiles/123.jpg",
+                "fullname": "course 3"
+            }
+        ]
+    }
+}}
+<div class="card-deck dashboard-card-deck" role="list">
+{{#courses}}
+    <div class="card dashboard-card m-b-1" role="listitem">
+        <a href="{{viewurl}}" tabindex="-1">
+            <div class="card-img dashboard-card-img" style='background-image: url("{{{courseimage}}}");'>
+                <span class="sr-only">{{#str}}aria:courseimage, core_course{{/str}}</span>
+                {{>core_course/favouriteicon }}
+            </div>
+        </a>
+        <div class="card-body pr-1 course-info-container" id="course-info-container-{{id}}">
+            <div class="d-flex align-items-start">
+                <div class="card-title mr-2">
+                    <a href="{{viewurl}}">
+                        <span class="sr-only">{{#str}}aria:coursename, core_course{{/str}}</span>
+                        {{#shortentext}}40, {{{fullname}}} {{/shortentext}}
+                    </a>
+                </div>
+            </div>
+        </div>
+    </div>
+{{/courses}}
+</div>
diff --git a/blocks/starredcourses/templates/view.mustache b/blocks/starredcourses/templates/view.mustache
new file mode 100644 (file)
index 0000000..8492cfb
--- /dev/null
@@ -0,0 +1,39 @@
+{{!
+    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/>.
+}}
+{{!
+    @template block_starredcourses/view
+
+    This template renders the view for the starred courses block.
+
+    Example context (json):
+    {
+        "nocoursesimg": "https://moodlesite/theme/image.php/boost/block_starredcourses/1535727318/courses"
+    }
+}}
+<div id="starred-courses-view-{{uniqid}}"
+     data-region="starred-courses-view"
+     data-nocoursesimg="{{nocoursesimg}}">
+    <div data-region="starred-courses-view-content">
+        <div data-region="starred-courses-loading-placeholder">
+            <div class="card-deck dashboard-card-deck">
+                {{> block_starredcourses/placeholder-course }}
+                {{> block_starredcourses/placeholder-course }}
+                {{> block_starredcourses/placeholder-course }}
+            </div>
+        </div>
+    </div>
+</div>
diff --git a/blocks/starredcourses/version.php b/blocks/starredcourses/version.php
new file mode 100644 (file)
index 0000000..6514e19
--- /dev/null
@@ -0,0 +1,28 @@
+<?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/>.
+
+/**
+ * Starred courses block version details.
+ *
+ * @package   block_starredcourses
+ * @copyright 2018 Simey Lameze <simey@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die;
+
+$plugin->version = 2018051402;
+$plugin->requires  = 2018050800;
+$plugin->component = 'block_starredcourses';
\ No newline at end of file
diff --git a/course/amd/build/events.min.js b/course/amd/build/events.min.js
new file mode 100644 (file)
index 0000000..4ea5c9e
Binary files /dev/null and b/course/amd/build/events.min.js differ
diff --git a/course/amd/src/events.js b/course/amd/src/events.js
new file mode 100644 (file)
index 0000000..7609702
--- /dev/null
@@ -0,0 +1,29 @@
+// 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/>.
+
+/**
+ * Contain the events the course component can trigger.
+ *
+ * @module     core_course/events
+ * @package    core_course
+ * @copyright  2018 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([], function() {
+    return {
+        favourited: 'core_course:favourited',
+        unfavorited: 'core_course:unfavorited',
+    };
+});
\ No newline at end of file
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template block_myoverview/favourite-icon
+    @template core_course/favouriteicon
 
     This template renders the favourite icon for a course.
 
             data-region="is-favourite"
             aria-hidden="{{^isfavourite}}true{{/isfavourite}}{{#isfavourite}}false{{/isfavourite}}"
             >
-            {{#pix}} i/star, core, {{#str}} favourite, block_myoverview {{/str}} {{/pix}}
-            <span class="sr-only">{{#str}} aria:favourite, block_myoverview {{/str}}</span>
+            {{#pix}} i/star, core, {{#str}} favourite, core_course {{/str}} {{/pix}}
+            <span class="sr-only">{{#str}} aria:favourite, core_course {{/str}}</span>
         </span>
         <span
             {{#isfavourite}}class="hidden"{{/isfavourite}}
             data-region="not-favourite"
             aria-hidden="{{^isfavourite}}false{{/isfavourite}}{{#isfavourite}}true{{/isfavourite}}"
             >
-            {{#pix}} i/empty, core, {{#str}} notfavourite, block_myoverview {{/str}} {{/pix}}
+            {{#pix}} i/empty, core, {{#str}} notfavourite, core_course {{/str}} {{/pix}}
         </span>
     </div>
 </div>
index bd1a992..511efd8 100644 (file)
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['aria:courseimage'] = 'Course image:';
+$string['aria:coursename'] = 'Course name:';
+$string['aria:favourite'] = 'Course is starred';
+$string['favourite'] = 'Starred course';
+$string['notfavourite'] = 'Not starred';
 $string['privacy:perpage'] = 'The number of courses to show per page.';
 $string['privacy:completionpath'] = 'Course completion';
 $string['privacy:metadata:completionsummary'] = 'The course contains completion information about the user.';
index 6abf53d..ca1f308 100644 (file)
@@ -1721,7 +1721,7 @@ class core_plugin_manager {
                 'private_files', 'quiz_results', 'recent_activity',
                 'rss_client', 'search_forums', 'section_links',
                 'selfcompletion', 'settings', 'site_main_menu',
-                'social_activities', 'tag_flickr', 'tag_youtube', 'tags', 'timeline'
+                'social_activities', 'starredcourses', 'tag_flickr', 'tag_youtube', 'tags', 'timeline'
             ),
 
             'booktool' => array(
index 1c4a37f..9d5cd50 100644 (file)
@@ -989,7 +989,8 @@ $functions = array(
         'classname' => 'core_message_external',
         'methodname' => 'data_for_messagearea_conversations',
         'classpath' => 'message/externallib.php',
-        'description' => 'Retrieve the template data for the conversation list',
+        'description' => '** DEPRECATED ** Please do not call this function any more.
+                          Retrieve the template data for the conversation list',
         'type' => 'read',
         'ajax' => true,
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
@@ -1036,6 +1037,14 @@ $functions = array(
         'type' => 'read',
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+    'core_message_get_conversations' => array(
+        'classname' => 'core_message_external',
+        'methodname' => 'get_conversations',
+        'classpath' => 'message/externallib.php',
+        'description' => 'Retrieve a list of conversations for a user',
+        'type' => 'read',
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
     'core_message_get_messages' => array(
         'classname' => 'core_message_external',
         'methodname' => 'get_messages',
index ee60a6b..0824394 100644 (file)
@@ -275,6 +275,50 @@ class api {
         return array($contacts, $courses, $noncontacts);
     }
 
+    /**
+     * Gets the subnames for any conversations linked to components.
+     *
+     * The subname is like a subtitle for the conversation, to compliment it's name.
+     *
+     * @param array $conversations a list of conversations records.
+     * @return array the array of subnames, index by conversation id.
+     */
+    protected static function get_linked_conversation_subnames(array $conversations) {
+        global $DB;
+
+        $linkedconversations = [];
+        foreach ($conversations as $conversation) {
+            if (!is_null($conversation->component) && !is_null($conversation->itemtype)) {
+                $linkedconversations[$conversation->component][$conversation->itemtype][$conversation->id]
+                    = $conversation->itemid;
+            }
+        }
+        if (empty($linkedconversations)) {
+            return [];
+        }
+
+        // TODO: MDL-63814: Working out the subname for linked conversations should be done in a generic way.
+        // Get the itemid, but only for course group linked conversation for now.
+        $convsubnames = [];
+        if (!empty($linkeditems = $linkedconversations['core_group']['groups'])) { // Format: [conversationid => itemid].
+            // Get the name of the course to which the group belongs.
+            list ($groupidsql, $groupidparams) = $DB->get_in_or_equal(array_values($linkeditems), SQL_PARAMS_NAMED, 'groupid');
+            $sql = "SELECT g.id, c.shortname
+                      FROM {groups} g
+                      JOIN {course} c
+                        ON g.courseid = c.id
+                     WHERE g.id $groupidsql";
+            $courseinfo = $DB->get_records_sql($sql, $groupidparams);
+            foreach ($linkeditems as $convid => $groupid) {
+                if (array_key_exists($groupid, $courseinfo)) {
+                    $convsubnames[$convid] = format_string($courseinfo[$groupid]->shortname);
+                }
+            }
+        }
+        return $convsubnames;
+    }
+
+
     /**
      * Returns the contacts and their conversation to display in the contacts area.
      *
@@ -296,36 +340,58 @@ class api {
      * @param int $userid The user id
      * @param int $limitfrom
      * @param int $limitnum
-     * @param int $type the conversation type.
-     * @param bool $favouritesonly whether to retrieve only the favourite conversations for the user, or not.
-     * @return array
+     * @param int $type the type of the conversation, if you wish to filter to a certain type (see api constants).
+     * @param bool $favourites whether to include NO favourites (false) or ONLY favourites (true), or null to ignore this setting.
+     * @return array the array of conversations
+     * @throws \moodle_exception
      */
     public static function get_conversations($userid, $limitfrom = 0, $limitnum = 20, int $type = null,
-            bool $favouritesonly = false) {
+            bool $favourites = null) {
         global $DB;
 
+        if (!is_null($type) && !in_array($type, [self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+                self::MESSAGE_CONVERSATION_TYPE_GROUP])) {
+            throw new \moodle_exception("Invalid value ($type) for type param, please see api constants.");
+        }
+
+        // We need to know which conversations are favourites, so we can either:
+        // 1) Include the 'isfavourite' attribute on conversations (when $favourite = null and we're including all conversations)
+        // 2) Restrict the results to ONLY those conversations which are favourites (when $favourite = true)
+        // 3) Restrict the results to ONLY those conversations which are NOT favourites (when $favourite = false).
+        $service = \core_favourites\service_factory::get_service_for_user_context(\context_user::instance($userid));
+        $favouriteconversations = $service->find_favourites_by_type('core_message', 'message_conversations');
+        $favouriteconversationids = array_column($favouriteconversations, 'itemid');
+        if ($favourites && empty($favouriteconversationids)) {
+            return []; // If we are aiming to return ONLY favourites, and we have none, there's nothing more to do.
+        }
+
+        // CONVERSATIONS AND MOST RECENT MESSAGE.
+        // Include those conversations with messages first (ordered by most recent message, desc), then add any conversations which
+        // don't have messages, such as newly created group conversations.
+        // Because we're sorting by message 'timecreated', those conversations without messages could be at either the start or the
+        // end of the results (behaviour for sorting of nulls differs between DB vendors), so we use the case to presort these.
+
+        // If we need to return ONLY favourites, or NO favourites, generate the SQL snippet.
         $favouritesql = "";
         $favouriteparams = [];
-        if ($favouritesonly) {
-            // Ask the favourites subsystem for the user's favourite conversations.
-            $service = \core_favourites\service_factory::get_service_for_user_context(\context_user::instance($userid));
-            $favourites = $service->find_favourites_by_type('core_message', 'message_conversations');
-            if (empty($favourites)) {
-                return []; // No favourited conversations, so return none.
+        if (is_bool($favourites)) {
+            if (!empty($favouriteconversationids)) {
+                list ($insql, $inparams) = $DB->get_in_or_equal($favouriteconversationids, SQL_PARAMS_NAMED, 'favouriteids');
+                $favouritesql = $favourites ? " AND mc.id {$insql} " : " AND mc.id NOT {$insql} ";
+                $favouriteparams = $inparams;
             }
-            $favids = array_values(array_map(function ($fav) {
-                return $fav->itemid;
-            }, $favourites));
-            list ($insql, $inparams) = $DB->get_in_or_equal($favids, SQL_PARAMS_NAMED, 'favouriteids');
-            $favouritesql = " AND m.conversationid {$insql} ";
-            $favouriteparams = $inparams;
         }
 
-        // Get the last message from each conversation that the user belongs to.
-        $sql = "SELECT m.id, m.conversationid, m.useridfrom, mcm2.userid as useridto, m.smallmessage, m.timecreated
-                  FROM {messages} m
-            INNER JOIN (
-                          SELECT MAX(m.id) AS messageid
+        // If we need to restrict type, generate the SQL snippet.
+        $typesql = !is_null($type) ? " AND mc.type = :convtype " : "";
+
+        $sql = "SELECT m.id as messageid, mc.id as id, mc.name as conversationname, mc.type as conversationtype, m.useridfrom,
+                       m.smallmessage, m.timecreated, mc.component, mc.itemtype, mc.itemid
+                  FROM {message_conversations} mc
+            INNER JOIN {message_conversation_members} mcm
+                    ON (mcm.conversationid = mc.id AND mcm.userid = :userid3)
+            LEFT JOIN (
+                          SELECT m.conversationid, MAX(m.id) AS messageid
                             FROM {messages} m
                       INNER JOIN (
                                       SELECT m.conversationid, MAX(m.timecreated) as maxtime
@@ -341,61 +407,124 @@ class api {
                                ON maxmessage.maxtime = m.timecreated AND maxmessage.conversationid = m.conversationid
                          GROUP BY m.conversationid
                        ) lastmessage
-                    ON lastmessage.messageid = m.id
-            INNER JOIN {message_conversation_members} mcm
-                    ON mcm.conversationid = m.conversationid
-            INNER JOIN {message_conversation_members} mcm2
-                    ON mcm2.conversationid = m.conversationid
-                 WHERE mcm.userid = m.useridfrom
-                   AND mcm.id != mcm2.id $favouritesql
-              ORDER BY m.timecreated DESC";
+                    ON lastmessage.conversationid = mc.id
+            LEFT JOIN {messages} m
+                   ON m.id = lastmessage.messageid
+                WHERE mc.id IS NOT NULL $typesql $favouritesql
+              ORDER BY (CASE WHEN m.timecreated IS NULL THEN 0 ELSE 1 END) DESC, m.timecreated DESC, id DESC";
 
         $params = array_merge($favouriteparams, ['userid' => $userid, 'action' => self::MESSAGE_ACTION_DELETED,
-            'userid2' => $userid]);
-        $messageset = $DB->get_recordset_sql($sql, $params, $limitfrom, $limitnum);
+            'userid2' => $userid, 'userid3' => $userid, 'convtype' => $type]);
+        $conversationset = $DB->get_recordset_sql($sql, $params, $limitfrom, $limitnum);
 
-        $messages = [];
-        foreach ($messageset as $message) {
-            $messages[$message->id] = $message;
+        $conversations = [];
+        $uniquemembers = [];
+        $members = [];
+        foreach ($conversationset as $conversation) {
+            $conversations[] = $conversation;
+            $members[$conversation->id] = [];
         }
-        $messageset->close();
+        $conversationset->close();
 
-        // If there are no messages return early.
-        if (empty($messages)) {
+        // If there are no conversations found, then return early.
+        if (empty($conversations)) {
             return [];
         }
 
-        // We need to pull out the list of other users that are part of each of these conversations. This
+        // COMPONENT-LINKED CONVERSATION SUBNAME.
+        // This subname will vary, depending on the component which created the linked conversation.
+        // For now, this is ONLY course groups.
+        $convsubnames = self::get_linked_conversation_subnames($conversations);
+
+        // MEMBERS.
+        // Ideally, we want to get 1 member for each conversation, but this depends on the type and whether there is a recent
+        // message or not.
+        //
+        // For 'individual' type conversations between 2 users, regardless of who sent the last message,
+        // we want the details of the other member in the conversation (i.e. not the current user).
+        //
+        // For 'group' type conversations, we want the details of the member who sent the last message, if there is one.
+        // This can be the current user or another group member, but for groups without messages, this will be empty.
+        //
+        // This also means that if type filtering is specified and only group conversations are returned, we don't need this extra
+        // query to get the 'other' user as we already have that information.
+
+        // Work out which members we have already, and which ones we might need to fetch.
+        // If all the last messages were from another user, then we don't need to fetch anything further.
+        foreach ($conversations as $conversation) {
+            if ($conversation->conversationtype == self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) {
+                if (!is_null($conversation->useridfrom) && $conversation->useridfrom != $userid) {
+                    $members[$conversation->id][$conversation->useridfrom] = $conversation->useridfrom;
+                    $uniquemembers[$conversation->useridfrom] = $conversation->useridfrom;
+                } else {
+                    $individualconversations[] = $conversation->id;
+                }
+            } else if ($conversation->conversationtype == self::MESSAGE_CONVERSATION_TYPE_GROUP) {
+                // If we have a recent message, the sender is our member.
+                if (!is_null($conversation->useridfrom)) {
+                    $members[$conversation->id][$conversation->useridfrom] = $conversation->useridfrom;
+                    $uniquemembers[$conversation->useridfrom] = $conversation->useridfrom;
+                }
+            }
+        }
+        // If we need to fetch any member information for any of the individual conversations.
+        // This is the case if any of the individual conversations have a recent message sent by the current user.
+        if (!empty($individualconversations)) {
+            list ($icidinsql, $icidinparams) = $DB->get_in_or_equal($individualconversations, SQL_PARAMS_NAMED, 'convid');
+            $indmembersql = "SELECT mcm.id, mcm.conversationid, mcm.userid
+                        FROM {message_conversation_members} mcm
+                       WHERE mcm.conversationid $icidinsql
+                       AND mcm.userid != :userid
+                       ORDER BY mcm.id";
+            $indmemberparams = array_merge($icidinparams, ['userid' => $userid]);
+            $conversationmembers = $DB->get_records_sql($indmembersql, $indmemberparams);
+
+            foreach ($conversationmembers as $mid => $member) {
+                $members[$member->conversationid][$member->userid] = $member->userid;
+                $uniquemembers[$member->userid] = $member->userid;
+            }
+        }
+        $memberids = array_values($uniquemembers);
+
+        // We could fail early here if we're sure that:
+        // a) we have no otherusers for all the conversations (users may have been deleted)
+        // b) we're sure that all conversations are individual (1:1).
+
+        // We need to pull out the list of users info corresponding to the memberids in the conversations.This
         // needs to be done in a separate query to avoid doing a join on the messages tables and the user
         // tables because on large sites these tables are massive which results in extremely slow
         // performance (typically due to join buffer exhaustion).
-        $otheruserids = array_map(function($message) use ($userid) {
-            return ($message->useridfrom == $userid) ? $message->useridto : $message->useridfrom;
-        }, array_values($messages));
-
-        // Ok, let's get the other members in the conversations.
-        list($useridsql, $usersparams) = $DB->get_in_or_equal($otheruserids);
-        $userfields = \user_picture::fields('u', array('lastaccess'));
-        $userssql = "SELECT $userfields
-                       FROM {user} u
-                      WHERE id $useridsql
-                        AND deleted = 0";
-        $otherusers = $DB->get_records_sql($userssql, $usersparams);
-
-        // If there are no other users (user may have been deleted), then do not continue.
-        if (empty($otherusers)) {
-            return [];
+        if (!empty($memberids)) {
+            $memberinfo = helper::get_member_info($userid, $memberids);
+
+            // Update the members array with the member information.
+            $deletedmembers = [];
+            foreach ($members as $convid => $memberarr) {
+                foreach ($memberarr as $key => $memberid) {
+                    if (array_key_exists($memberid, $memberinfo)) {
+                        // If the user is deleted, remember that.
+                        if ($memberinfo[$memberid]->isdeleted) {
+                            $deletedmembers[$convid][] = $memberid;
+                        }
+                        $members[$convid][$key] = $memberinfo[$memberid];
+                    }
+                }
+            }
         }
 
-        $contactssql = "SELECT contactid
-                          FROM {message_contacts}
-                         WHERE userid = ?
-                           AND contactid $useridsql";
-        $contacts = $DB->get_records_sql($contactssql, array_merge([$userid], $usersparams));
+        // MEMBER COUNT.
+        $cids = array_column($conversations, 'id');
+        list ($cidinsql, $cidinparams) = $DB->get_in_or_equal($cids, SQL_PARAMS_NAMED, 'convid');
+        $membercountsql = "SELECT conversationid, count(id) AS membercount
+                             FROM {message_conversation_members} mcm
+                            WHERE mcm.conversationid $cidinsql
+                         GROUP BY mcm.conversationid";
+        $membercounts = $DB->get_records_sql($membercountsql, $cidinparams);
 
-        // Finally, let's get the unread messages count for this user so that we can add them
+        // UNREAD MESSAGE COUNT.
+        // Finally, let's get the unread messages count for this user so that we can add it
         // to the conversation. Remember we need to ignore the messages the user sent.
-        $unreadcountssql = 'SELECT m.useridfrom, count(m.id) as count
+        $unreadcountssql = 'SELECT m.conversationid, count(m.id) as unreadcount
                               FROM {messages} m
                         INNER JOIN {message_conversations} mc
                                 ON mc.id = m.conversationid
@@ -407,49 +536,45 @@ class api {
                              WHERE mcm.userid = ?
                                AND m.useridfrom != ?
                                AND mua.id is NULL
-                          GROUP BY useridfrom';
+                          GROUP BY m.conversationid';
         $unreadcounts = $DB->get_records_sql($unreadcountssql, [$userid, self::MESSAGE_ACTION_READ, self::MESSAGE_ACTION_DELETED,
             $userid, $userid]);
 
-        // Get rid of the table prefix.
-        $userfields = str_replace('u.', '', $userfields);
-        $userproperties = explode(',', $userfields);
-        $arrconversations = array();
-        foreach ($messages as $message) {
-            $conversation = new \stdClass();
-            $otheruserid = ($message->useridfrom == $userid) ? $message->useridto : $message->useridfrom;
-            $otheruser = isset($otherusers[$otheruserid]) ? $otherusers[$otheruserid] : null;
-            $contact = isset($contacts[$otheruserid]) ? $contacts[$otheruserid] : null;
-
-            // It's possible the other user was deleted, so, skip.
-            if (is_null($otheruser)) {
+        // Now, create the final return structure.
+        $arrconversations = [];
+        foreach ($conversations as $conversation) {
+            // It's possible other users have been deleted.
+            // In cases like this, we still want to include the conversation if it's of type 'group'.
+            // Individual conversations are skipped if the other member has been deleted.
+            if (isset($deletedmembers[$conversation->id]) &&
+                    $conversation->conversationtype == self::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) {
                 continue;
             }
 
-            // Add the other user's information to the conversation, if we have one.
-            foreach ($userproperties as $prop) {
-                $conversation->$prop = ($otheruser) ? $otheruser->$prop : null;
+            $conv = new \stdClass();
+            $conv->id = $conversation->id;
+            $conv->name = $conversation->conversationname;
+            $conv->subname = $convsubnames[$conv->id] ?? null;
+            $conv->type = $conversation->conversationtype;
+            $conv->membercount = $membercounts[$conv->id]->membercount;
+            $conv->isfavourite = in_array($conv->id, $favouriteconversationids);
+            $conv->isread = isset($unreadcounts[$conv->id]) ? false : true;
+            $conv->unreadcount = isset($unreadcounts[$conv->id]) ? $unreadcounts[$conv->id]->unreadcount : null;
+            $conv->members = $members[$conv->id];
+
+            // Add the most recent message information.
+            $conv->messages = [];
+            if ($conversation->smallmessage) {
+                $msg = new \stdClass();
+                $msg->id = $conversation->messageid;
+                $msg->text = clean_param($conversation->smallmessage, PARAM_NOTAGS);
+                $msg->useridfrom = $conversation->useridfrom;
+                $msg->timecreated = $conversation->timecreated;
+                $conv->messages[] = $msg;
             }
 
-            // Add the contact's information, if we have one.
-            $conversation->blocked = ($contact) ? $contact->blocked : null;
-
-            // Add the message information.
-            $conversation->messageid = $message->id;
-            $conversation->smallmessage = $message->smallmessage;
-            $conversation->useridfrom = $message->useridfrom;
-
-            // Only consider it unread if $user has unread messages.
-            if (isset($unreadcounts[$otheruserid])) {
-                $conversation->isread = false;
-                $conversation->unreadcount = $unreadcounts[$otheruserid]->count;
-            } else {
-                $conversation->isread = true;
-            }
-
-            $arrconversations[$otheruserid] = helper::create_contact($conversation);
+            $arrconversations[] = $conv;
         }
-
         return $arrconversations;
     }
 
index 3b09075..67dcf87 100644 (file)
@@ -491,14 +491,13 @@ class helper {
 
         list($useridsql, $usersparams) = $DB->get_in_or_equal($userids);
         $userfields = \user_picture::fields('u', array('lastaccess'));
-        $userssql = "SELECT $userfields, mc.id AS contactid, mub.id AS blockedid
+        $userssql = "SELECT $userfields, u.deleted, mc.id AS contactid, mub.id AS blockedid
                        FROM {user} u
                   LEFT JOIN {message_contacts} mc
                          ON (mc.userid = ? AND mc.contactid = u.id)
                   LEFT JOIN {message_users_blocked} mub
                          ON (mub.userid = ? AND mub.blockeduserid = u.id)
-                      WHERE u.id $useridsql
-                        AND u.deleted = 0";
+                      WHERE u.id $useridsql";
         $usersparams = array_merge([$referenceuserid, $referenceuserid], $usersparams);
         $otherusers = $DB->get_records_sql($userssql, $usersparams);
 
@@ -524,8 +523,43 @@ class helper {
             $data->iscontact = ($member->contactid) ? true : false;
             $data->isblocked = ($member->blockedid) ? true : false;
 
+            $data->isdeleted = ($member->deleted) ? true : false;
+
             $members[$data->id] = $data;
         }
         return $members;
     }
+
+    /**
+     * Backwards compatibility formatter, transforming the new output of get_conversations() into the old format.
+     *
+     * @param array $conversations the array of conversations, which must come from get_conversations().
+     * @return array the array of conversations, formatted in the legacy style.
+     */
+    public static function get_conversations_legacy_formatter(array $conversations) : array {
+        // Transform new data format back into the old format, just for BC during the deprecation life cycle.
+        $tmp = [];
+        foreach ($conversations as $id => $conv) {
+            $data = new \stdClass();
+            // The logic for the 'other user' is as follows:
+            // If a conversation is of type 'individual', the other user is always the member who is not the current user.
+            // If the conversation is of type 'group', the other user is always the sender of the most recent message.
+            // The get_conversations method already follows this logic, so we just need the first member.
+            $otheruser = reset($conv->members);
+            $data->userid = $otheruser->id;
+            $data->useridfrom = $conv->messages[0]->useridfrom ?? null;
+            $data->fullname = $conv->members[$otheruser->id]->fullname;
+            $data->profileimageurl = $conv->members[$otheruser->id]->profileimageurl;
+            $data->profileimageurlsmall = $conv->members[$otheruser->id]->profileimageurlsmall;
+            $data->ismessaging = isset($conv->messages[0]->text) ? true : false;
+            $data->lastmessage = $conv->messages[0]->text ?? null;
+            $data->messageid = $conv->messages[0]->id ?? null;
+            $data->isonline = $conv->members[$otheruser->id]->isonline ?? null;
+            $data->isblocked = $conv->members[$otheruser->id]->isblocked ?? null;
+            $data->isread = $conv->isread;
+            $data->unreadcount = $conv->unreadcount;
+            $tmp[$data->userid] = $data;
+        }
+        return $tmp;
+    }
 }
index 75c19ec..0d71f3f 100644 (file)
@@ -868,6 +868,34 @@ class core_message_external extends external_api {
         );
     }
 
+    /**
+     * Return the structure of a conversation.
+     *
+     * @return external_single_structure
+     * @since Moodle 3.6
+     */
+    private static function get_conversation_structure() {
+        return new external_single_structure(
+            array(
+                'id' => new external_value(PARAM_INT, 'The conversation id'),
+                'name' => new external_value(PARAM_NOTAGS, 'The conversation name, if set', VALUE_DEFAULT, null),
+                'subname' => new external_value(PARAM_NOTAGS, 'A subtitle for the conversation name, if set', VALUE_DEFAULT, null),
+                'type' => new external_value(PARAM_INT, 'The type of the conversation (1=individual,2=group)'),
+                'membercount' => new external_value(PARAM_INT, 'Total number of conversation members'),
+                'isfavourite' => new external_value(PARAM_BOOL, 'If the user marked conversation this conversation as a favourite'),
+                'isread' => new external_value(PARAM_BOOL, 'If the user has read all messages in the conversation'),
+                'unreadcount' => new external_value(PARAM_INT, 'The number of unread messages in this conversation',
+                    VALUE_DEFAULT, null),
+                'members' => new external_multiple_structure(
+                    self::get_conversation_member_structure()
+                ),
+                'messages' => new external_multiple_structure(
+                    self::get_conversation_message_structure()
+                ),
+            )
+        );
+    }
+
     /**
      * Return the structure of a conversation member.
      *
@@ -1165,9 +1193,87 @@ class core_message_external extends external_api {
         );
     }
 
+    /**
+     * Get conversations parameters.
+     *
+     * @return external_function_parameters
+     * @since 3.6
+     */
+    public static function get_conversations_parameters() {
+        return new external_function_parameters(
+            array(
+                'userid' => new external_value(PARAM_INT, 'The id of the user who we are viewing conversations for'),
+                'limitfrom' => new external_value(PARAM_INT, 'The offset to start at', VALUE_DEFAULT, 0),
+                'limitnum' => new external_value(PARAM_INT, 'Limit number of conversations to this', VALUE_DEFAULT, 0),
+                'type' => new external_value(PARAM_INT, 'Filter by type', VALUE_DEFAULT, null),
+                'favourites' => new external_value(PARAM_BOOL, 'Whether to restrict the results to contain NO favourite
+                conversations (false), ONLY favourite conversation (true), or ignore any restriction altogether (null)',
+                    VALUE_DEFAULT, null),
+
+            )
+        );
+    }
+
+    /**
+     * Get the list of conversations for the user.
+     *
+     * @param int $userid The id of the user who is performing the search
+     * @param int $limitfrom
+     * @param int $limitnum
+     * @param int|null $type
+     * @param bool|null $favourites
+     * @return stdClass
+     * @throws \moodle_exception if the messaging feature is disabled on the site.
+     * @since 3.2
+     */
+    public static function get_conversations($userid, $limitfrom = 0, $limitnum = 0, int $type = null, bool $favourites = null) {
+        global $CFG, $USER;
+
+        // All the standard BL checks.
+        if (empty($CFG->messaging)) {
+            throw new moodle_exception('disabled', 'message');
+        }
+
+        $params = array(
+            'userid' => $userid,
+            'limitfrom' => $limitfrom,
+            'limitnum' => $limitnum,
+            'type' => $type,
+            'favourites' => $favourites
+        );
+        self::validate_parameters(self::get_conversations_parameters(), $params);
+
+        $systemcontext = context_system::instance();
+        self::validate_context($systemcontext);
+
+        if (($USER->id != $userid) && !has_capability('moodle/site:readallmessages', $systemcontext)) {
+            throw new moodle_exception('You do not have permission to perform this action.');
+        }
+
+        $conversations = \core_message\api::get_conversations($userid, $limitfrom, $limitnum, $type, $favourites);
+        return (object) ['conversations' => $conversations];
+    }
+
+    /**
+     * Get conversations returns.
+     *
+     * @return external_single_structure
+     * @since 3.6
+     */
+    public static function get_conversations_returns() {
+        return new external_single_structure(
+            [
+                'conversations' => new external_multiple_structure(
+                    self::get_conversation_structure()
+                )
+            ]
+        );
+    }
+
     /**
      * The messagearea conversations parameters.
      *
+     * @deprecated since 3.6
      * @return external_function_parameters
      * @since 3.2
      */
@@ -1184,6 +1290,13 @@ class core_message_external extends external_api {
     /**
      * Get messagearea conversations.
      *
+     * NOTE FOR FINAL DEPRECATION:
+     * When removing this method, please also consider removal of get_conversations_legacy_formatter()
+     * from the \core_message\helper class. This helper method was used solely to format the new get_conversations() return data
+     * into the old format used here, and in message/index.php. If we no longer need either of these, then that method can be
+     * removed.
+     *
+     * @deprecated since 3.6
      * @param int $userid The id of the user who we are viewing conversations for
      * @param int $limitfrom
      * @param int $limitnum
@@ -1214,6 +1327,10 @@ class core_message_external extends external_api {
         }
 
         $conversations = \core_message\api::get_conversations($userid, $limitfrom, $limitnum);
+
+        // Format the conversations in the legacy style, as the get_conversations method has since been changed.
+        $conversations = \core_message\helper::get_conversations_legacy_formatter($conversations);
+
         $conversations = new \core_message\output\messagearea\contacts(null, $conversations);
 
         $renderer = $PAGE->get_renderer('core_message');
@@ -1223,6 +1340,7 @@ class core_message_external extends external_api {
     /**
      * The messagearea conversations return structure.
      *
+     * @deprecated since 3.6
      * @return external_single_structure
      * @since 3.2
      */
@@ -1236,6 +1354,15 @@ class core_message_external extends external_api {
         );
     }
 
+    /**
+     * Marking the method as deprecated.
+     *
+     * @return bool
+     */
+    public static function data_for_messagearea_conversations_is_deprecated() {
+        return true;
+    }
+
     /**
      * The messagearea contacts return parameters.
      *
index 45cf605..9d30f34 100644 (file)
@@ -106,6 +106,9 @@ if ($contactsfirst) {
     $conversations = \core_message\api::get_contacts($user1->id, 0, 20);
 } else {
     $conversations = \core_message\api::get_conversations($user1->id, 0, 20);
+
+    // Format the conversations in the legacy style, as the get_conversations method has since been changed.
+    $conversations = \core_message\helper::get_conversations_legacy_formatter($conversations);
 }
 $messages = [];
 if (!$user2realuser) {
index 2b9cbc4..d752843 100644 (file)
@@ -488,7 +488,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
 
         // Confirm the conversation is from the non-deleted user.
         $conversation = reset($conversations);
-        $this->assertEquals($user3->id, $conversation->userid);
+        $this->assertEquals($convoids[1], $conversation->id);
     }
 
     /**
@@ -642,183 +642,366 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
     }
 
     /**
-     * Tests retrieving conversations.
+     * Helper to seed the database with initial state.
      */
-    public function test_get_conversations() {
+    protected function create_conversation_test_data() {
         // Create some users.
         $user1 = self::getDataGenerator()->create_user();
         $user2 = self::getDataGenerator()->create_user();
         $user3 = self::getDataGenerator()->create_user();
         $user4 = self::getDataGenerator()->create_user();
 
-        // The person doing the search.
-        $this->setUser($user1);
+        $time = 1;
 
-        // No conversations yet.
+        // Create some conversations. We want:
+        // 1) At least one of each type (group, individual) of which user1 IS a member and DID send the most recent message.
+        // 2) At least one of each type (group, individual) of which user1 IS a member and DID NOT send the most recent message.
+        // 3) At least one of each type (group, individual) of which user1 IS NOT a member.
+        // 4) At least two group conversation having 0 messages, of which user1 IS a member (To confirm conversationid ordering).
+        // 5) At least one group conversation having 0 messages, of which user1 IS NOT a member.
+
+        // Individual conversation, user1 is a member, last message from other user.
+        $ic1 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]);
+        testhelper::send_fake_message_to_conversation($user1, $ic1->id, 'Message 1', $time);
+        testhelper::send_fake_message_to_conversation($user2, $ic1->id, 'Message 2', $time + 1);
+
+        // Individual conversation, user1 is a member, last message from user1.
+        $ic2 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user3->id]);
+        testhelper::send_fake_message_to_conversation($user3, $ic2->id, 'Message 3', $time + 2);
+        testhelper::send_fake_message_to_conversation($user1, $ic2->id, 'Message 4', $time + 3);
+
+        // Individual conversation, user1 is not a member.
+        $ic3 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user2->id, $user3->id]);
+        testhelper::send_fake_message_to_conversation($user2, $ic3->id, 'Message 5', $time + 4);
+        testhelper::send_fake_message_to_conversation($user3, $ic3->id, 'Message 6', $time + 5);
+
+        // Group conversation, user1 is not a member.
+        $gc1 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user2->id, $user3->id, $user4->id], 'Project discussions');
+        testhelper::send_fake_message_to_conversation($user2, $gc1->id, 'Message 7', $time + 6);
+        testhelper::send_fake_message_to_conversation($user4, $gc1->id, 'Message 8', $time + 7);
+
+        // Group conversation, user1 is a member, last message from another user.
+        $gc2 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user3->id, $user4->id], 'Group chat');
+        testhelper::send_fake_message_to_conversation($user1, $gc2->id, 'Message 9', $time + 8);
+        testhelper::send_fake_message_to_conversation($user3, $gc2->id, 'Message 10', $time + 9);
+        testhelper::send_fake_message_to_conversation($user4, $gc2->id, 'Message 11', $time + 10);
+
+        // Group conversation, user1 is a member, last message from user1.
+        $gc3 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user2->id, $user3->id, $user4->id], 'Group chat again!');
+        testhelper::send_fake_message_to_conversation($user4, $gc3->id, 'Message 12', $time + 11);
+        testhelper::send_fake_message_to_conversation($user3, $gc3->id, 'Message 13', $time + 12);
+        testhelper::send_fake_message_to_conversation($user1, $gc3->id, 'Message 14', $time + 13);
+
+        // Empty group conversations (x2), user1 is a member.
+        $gc4 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user2->id, $user3->id], 'Empty group');
+        $gc5 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user2->id, $user4->id], 'Another empty group');
+
+        // Empty group conversation, user1 is NOT a member.
+        $gc6 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user2->id, $user3->id, $user4->id], 'Empty group 3');
+
+        return [$user1, $user2, $user3, $user4, $ic1, $ic2, $ic3, $gc1, $gc2, $gc3, $gc4, $gc5, $gc6];
+    }
+
+    /**
+     * Test verifying get_conversations when no limits, offsets, type filters or favourite restrictions are used.
+     */
+    public function test_get_conversations_no_restrictions() {
+        // No conversations should exist yet.
+        $user1 = self::getDataGenerator()->create_user();
         $this->assertEquals([], \core_message\api::get_conversations($user1->id));
 
-        // Send some messages back and forth, have some different conversations with different users.
-        $time = 1;
-        $this->send_fake_message($user1, $user2, 'Yo!', 0, $time + 1);
-        $this->send_fake_message($user2, $user1, 'Sup mang?', 0, $time + 2);
-        $this->send_fake_message($user1, $user2, 'Writing PHPUnit tests!', 0, $time + 3);
-        $messageid1 = $this->send_fake_message($user2, $user1, 'Word.', 0, $time + 4);
-
-        $this->send_fake_message($user1, $user3, 'Booyah', 0, $time + 5);
-        $this->send_fake_message($user3, $user1, 'Whaaat?', 0, $time + 6);
-        $this->send_fake_message($user1, $user3, 'Nothing.', 0, $time + 7);
-        $messageid2 = $this->send_fake_message($user3, $user1, 'Cool.', 0, $time + 8);
+        // Get a bunch of conversations, some group, some individual and in different states.
+        list($user1, $user2, $user3, $user4, $ic1, $ic2, $ic3,
+            $gc1, $gc2, $gc3, $gc4, $gc5, $gc6) = $this->create_conversation_test_data();
+
+        // Get all conversations for user1.
+        $conversations = core_message\api::get_conversations($user1->id);
+
+        // Verify there are 2 individual conversation, 2 group conversations, and 2 empty group conversations.
+        // The conversations with the most recent messages should be listed first, followed by the empty
+        // conversations, with the most recently created first.
+        $this->assertCount(6, $conversations);
+        $typecounts  = array_count_values(array_column($conversations, 'type'));
+        $this->assertEquals(2, $typecounts[1]);
+        $this->assertEquals(4, $typecounts[2]);
+
+        // Those conversations having messages should be listed first, ordered by most recent message time.
+        $this->assertEquals($gc3->id, $conversations[0]->id);
+        $this->assertEquals(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP, $conversations[0]->type);
+        $this->assertFalse($conversations[0]->isfavourite);
+        $this->assertCount(1, $conversations[0]->members);
+        $this->assertEquals(4, $conversations[0]->membercount);
+        $this->assertCount(1, $conversations[0]->messages);
+        $this->assertEquals("Message 14", $conversations[0]->messages[0]->text);
+        $this->assertEquals($user1->id, $conversations[0]->messages[0]->useridfrom);
+
+        $this->assertEquals($gc2->id, $conversations[1]->id);
+        $this->assertEquals(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP, $conversations[1]->type);
+        $this->assertFalse($conversations[1]->isfavourite);
+        $this->assertCount(1, $conversations[1]->members);
+        $this->assertEquals(3, $conversations[1]->membercount);
+        $this->assertCount(1, $conversations[1]->messages);
+        $this->assertEquals("Message 11", $conversations[1]->messages[0]->text);
+        $this->assertEquals($user4->id, $conversations[1]->messages[0]->useridfrom);
+
+        $this->assertEquals($ic2->id, $conversations[2]->id);
+        $this->assertEquals(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL, $conversations[2]->type);
+        $this->assertFalse($conversations[2]->isfavourite);
+        $this->assertCount(1, $conversations[2]->members);
+        $this->assertEquals($user3->id, $conversations[2]->members[$user3->id]->id);
+        $this->assertEquals(2, $conversations[2]->membercount);
+        $this->assertCount(1, $conversations[2]->messages);
+        $this->assertEquals("Message 4", $conversations[2]->messages[0]->text);
+        $this->assertEquals($user1->id, $conversations[2]->messages[0]->useridfrom);
+
+        $this->assertEquals($ic1->id, $conversations[3]->id);
+        $this->assertEquals(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL, $conversations[3]->type);
+        $this->assertFalse($conversations[3]->isfavourite);
+        $this->assertCount(1, $conversations[3]->members);
+        $this->assertEquals(2, $conversations[3]->membercount);
+        $this->assertCount(1, $conversations[3]->messages);
+        $this->assertEquals("Message 2", $conversations[3]->messages[0]->text);
+        $this->assertEquals($user2->id, $conversations[3]->messages[0]->useridfrom);
+
+        // Of the groups without messages, we expect to see the most recently created first.
+        $this->assertEquals($gc5->id, $conversations[4]->id);
+        $this->assertEquals(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP, $conversations[4]->type);
+        $this->assertFalse($conversations[4]->isfavourite);
+        $this->assertCount(0, $conversations[4]->members); // No members returned, because no recent messages exist.
+        $this->assertEquals(3, $conversations[4]->membercount);
+        $this->assertEmpty($conversations[4]->messages);
+
+        $this->assertEquals($gc4->id, $conversations[5]->id);
+        $this->assertEquals(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP, $conversations[5]->type);
+        $this->assertFalse($conversations[5]->isfavourite);
+        $this->assertCount(0, $conversations[5]->members);
+        $this->assertEquals(3, $conversations[5]->membercount);
+        $this->assertEmpty($conversations[5]->messages);
+
+        // Verify format of the return structure.
+        foreach ($conversations as $conv) {
+            $this->assertObjectHasAttribute('id', $conv);
+            $this->assertObjectHasAttribute('name', $conv);
+            $this->assertObjectHasAttribute('subname', $conv);
+            $this->assertObjectHasAttribute('type', $conv);
+            $this->assertObjectHasAttribute('isfavourite', $conv);
+            $this->assertObjectHasAttribute('membercount', $conv);
+            $this->assertObjectHasAttribute('isread', $conv);
+            $this->assertObjectHasAttribute('unreadcount', $conv);
+            $this->assertObjectHasAttribute('members', $conv);
+            foreach ($conv->members as $member) {
+                $this->assertObjectHasAttribute('id', $member);
+                $this->assertObjectHasAttribute('fullname', $member);
+                $this->assertObjectHasAttribute('profileimageurl', $member);
+                $this->assertObjectHasAttribute('profileimageurlsmall', $member);
+                $this->assertObjectHasAttribute('isonline', $member);
+                $this->assertObjectHasAttribute('showonlinestatus', $member);
+                $this->assertObjectHasAttribute('isblocked', $member);
+                $this->assertObjectHasAttribute('iscontact', $member);
+            }
+            $this->assertObjectHasAttribute('messages', $conv);
+            foreach ($conv->messages as $message) {
+                $this->assertObjectHasAttribute('id', $message);
+                $this->assertObjectHasAttribute('useridfrom', $message);
+                $this->assertObjectHasAttribute('text', $message);
+                $this->assertObjectHasAttribute('timecreated', $message);
+            }
+        }
+    }
 
-        $this->send_fake_message($user1, $user4, 'Hey mate, you see the new messaging UI in Moodle?', 0, $time + 9);
-        $this->send_fake_message($user4, $user1, 'Yah brah, it\'s pretty rad.', 0, $time + 10);
-        $messageid3 = $this->send_fake_message($user1, $user4, 'Dope.', 0, $time + 11);
+    /**
+     * Tests retrieving conversations with a limit and offset to ensure pagination works correctly.
+     */
+    public function test_get_conversations_limit_offset() {
+        // Get a bunch of conversations, some group, some individual and in different states.
+        list($user1, $user2, $user3, $user4, $ic1, $ic2, $ic3,
+            $gc1, $gc2, $gc3, $gc4, $gc5, $gc6) = $this->create_conversation_test_data();
 
-        // Retrieve the conversations.
-        $conversations = \core_message\api::get_conversations($user1->id);
+        // Get all conversations for user1, limited to 1 result.
+        $conversations = core_message\api::get_conversations($user1->id, 0, 1);
 
-        // Confirm the data is correct.
-        $this->assertEquals(3, count($conversations));
+        // Verify the first conversation.
+        $this->assertCount(1, $conversations);
+        $conversation = array_shift($conversations);
+        $this->assertEquals($conversation->id, $gc3->id);
 
-        $message1 = array_shift($conversations);
-        $message2 = array_shift($conversations);
-        $message3 = array_shift($conversations);
+        // Verify the next conversation.
+        $conversations = \core_message\api::get_conversations($user1->id, 1, 1);
+        $this->assertCount(1, $conversations);
+        $this->assertEquals($gc2->id, $conversations[0]->id);
 
-        $this->assertEquals($user4->id, $message1->userid);
-        $this->assertEquals($user1->id, $message1->useridfrom);
-        $this->assertTrue($message1->ismessaging);
-        $this->assertEquals('Dope.', $message1->lastmessage);
-        $this->assertEquals($messageid3, $message1->messageid);
-        $this->assertNull($message1->isonline);
-        $this->assertFalse($message1->isread);
-        $this->assertFalse($message1->isblocked);
-        $this->assertEquals(1, $message1->unreadcount);
+        // Verify the next conversation.
+        $conversations = \core_message\api::get_conversations($user1->id, 2, 1);
+        $this->assertCount(1, $conversations);
+        $this->assertEquals($ic2->id, $conversations[0]->id);
 
-        $this->assertEquals($user3->id, $message2->userid);
-        $this->assertEquals($user3->id, $message2->useridfrom);
-        $this->assertTrue($message2->ismessaging);
-        $this->assertEquals('Cool.', $message2->lastmessage);
-        $this->assertEquals($messageid2, $message2->messageid);
-        $this->assertNull($message2->isonline);
-        $this->assertFalse($message2->isread);
-        $this->assertFalse($message2->isblocked);
-        $this->assertEquals(2, $message2->unreadcount);
+        // Skip one and get both empty conversations.
+        $conversations = \core_message\api::get_conversations($user1->id, 4, 2);
+        $this->assertCount(2, $conversations);
+        $this->assertEquals($gc5->id, $conversations[0]->id);
+        $this->assertEmpty($conversations[0]->messages);
+        $this->assertEquals($gc4->id, $conversations[1]->id);
+        $this->assertEmpty($conversations[1]->messages);
 
-        $this->assertEquals($user2->id, $message3->userid);
-        $this->assertEquals($user2->id, $message3->useridfrom);
-        $this->assertTrue($message3->ismessaging);
-        $this->assertEquals('Word.', $message3->lastmessage);
-        $this->assertEquals($messageid1, $message3->messageid);
-        $this->assertNull($message3->isonline);
-        $this->assertFalse($message3->isread);
-        $this->assertFalse($message3->isblocked);
-        $this->assertEquals(2, $message3->unreadcount);
+        // Ask for an offset that doesn't exist and verify no conversations are returned.
+        $conversations = \core_message\api::get_conversations($user1->id, 10, 1);
+        $this->assertCount(0, $conversations);
     }
 
     /**
-     * Tests retrieving conversations with a limit and offset to ensure pagination works correctly.
+     * Test verifying the type filtering behaviour of the
      */
-    public function test_get_conversations_limit_offset() {
-        // Create some users.
-        $user1 = self::getDataGenerator()->create_user();
-        $user2 = self::getDataGenerator()->create_user();
-        $user3 = self::getDataGenerator()->create_user();
-        $user4 = self::getDataGenerator()->create_user();
+    public function test_get_conversations_type_filter() {
+        // Get a bunch of conversations, some group, some individual and in different states.
+        list($user1, $user2, $user3, $user4, $ic1, $ic2, $ic3,
+            $gc1, $gc2, $gc3, $gc4, $gc5, $gc6) = $this->create_conversation_test_data();
 
-        // The person doing the search.
-        $this->setUser($user1);
+        // Verify we can ask for only individual conversations.
+        $conversations = \core_message\api::get_conversations($user1->id, 0, 20,
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL);
+        $this->assertCount(2, $conversations);
 
-        // Send some messages back and forth, have some different conversations with different users.
-        $time = 1;
-        $this->send_fake_message($user1, $user2, 'Yo!', 0, $time + 1);
-        $this->send_fake_message($user2, $user1, 'Sup mang?', 0, $time + 2);
-        $this->send_fake_message($user1, $user2, 'Writing PHPUnit tests!', 0, $time + 3);
-        $messageid1 = $this->send_fake_message($user2, $user1, 'Word.', 0, $time + 4);
+        // Verify we can ask for only group conversations.
+        $conversations = \core_message\api::get_conversations($user1->id, 0, 20,
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP);
+        $this->assertCount(4, $conversations);
 
-        $this->send_fake_message($user1, $user3, 'Booyah', 0, $time + 5);
-        $this->send_fake_message($user3, $user1, 'Whaaat?', 0, $time + 6);
-        $this->send_fake_message($user1, $user3, 'Nothing.', 0, $time + 7);
-        $messageid2 = $this->send_fake_message($user3, $user1, 'Cool.', 0, $time + 8);
+        // Verify an exception is thrown if an unrecognized type is specified.
+        $this->expectException(\moodle_exception::class);
+        $conversations = \core_message\api::get_conversations($user1->id, 0, 20, 0);
+    }
 
-        $this->send_fake_message($user1, $user4, 'Hey mate, you see the new messaging UI in Moodle?', 0, $time + 9);
-        $this->send_fake_message($user4, $user1, 'Yah brah, it\'s pretty rad.', 0, $time + 10);
-        $messageid3 = $this->send_fake_message($user1, $user4, 'Dope.', 0, $time + 11);
+    /**
+     * Tests retrieving conversations when a conversation contains a deleted user.
+     */
+    public function test_get_conversations_with_deleted_user() {
+        // Get a bunch of conversations, some group, some individual and in different states.
+        list($user1, $user2, $user3, $user4, $ic1, $ic2, $ic3,
+            $gc1, $gc2, $gc3, $gc4, $gc5, $gc6) = $this->create_conversation_test_data();
 
-        // Retrieve the conversations.
-        $conversations = \core_message\api::get_conversations($user1->id, 1, 1);
+        // Delete the second user and retrieve the conversations.
+        // We should have 5, as $ic1 drops off the list.
+        // Group conversations remain albeit with less members.
+        delete_user($user2);
+        $conversations = \core_message\api::get_conversations($user1->id);
+        $this->assertCount(5, $conversations);
+        $this->assertEquals($gc3->id, $conversations[0]->id);
+        $this->assertcount(1, $conversations[0]->members);
+        $this->assertEquals($gc2->id, $conversations[1]->id);
+        $this->assertcount(1, $conversations[1]->members);
+        $this->assertEquals($ic2->id, $conversations[2]->id);
+        $this->assertEquals($gc5->id, $conversations[3]->id);
+        $this->assertEquals($gc4->id, $conversations[4]->id);
+
+        // Delete a user from a group conversation where that user had sent the most recent message.
+        // This user will still be present in the members array, as will the message in the messages array.
+        delete_user($user4);
+        $conversations = \core_message\api::get_conversations($user1->id);
+        $this->assertCount(5, $conversations);
+        $this->assertEquals($gc2->id, $conversations[1]->id);
+        $this->assertcount(1, $conversations[1]->members);
+        $this->assertEquals($user4->id, $conversations[1]->members[$user4->id]->id);
+        $this->assertcount(1, $conversations[1]->messages);
+        $this->assertEquals($user4->id, $conversations[1]->messages[0]->useridfrom);
+
+        // Delete the third user and retrieve the conversations.
+        // We should have 4, as $ic1, $ic2 drop off the list.
+        // Group conversations remain albeit with less members.
+        delete_user($user3);
+        $conversations = \core_message\api::get_conversations($user1->id);
+        $this->assertCount(4, $conversations);
+        $this->assertEquals($gc3->id, $conversations[0]->id);
+        $this->assertcount(1, $conversations[0]->members);
+        $this->assertEquals($gc2->id, $conversations[1]->id);
+        $this->assertcount(1, $conversations[1]->members);
+        $this->assertEquals($gc5->id, $conversations[2]->id);
+        $this->assertEquals($gc4->id, $conversations[3]->id);
+    }
 
-        // We should only have one conversation because of the limit.
-        $this->assertCount(1, $conversations);
+    /**
+     * Test verifying the behaviour of get_conversations() when fetching favourite conversations.
+     */
+    public function test_get_conversations_favourite_conversations() {
+        // Get a bunch of conversations, some group, some individual and in different states.
+        list($user1, $user2, $user3, $user4, $ic1, $ic2, $ic3,
+            $gc1, $gc2, $gc3, $gc4, $gc5, $gc6) = $this->create_conversation_test_data();
 
-        $conversation = array_shift($conversations);
+        // Try to get ONLY favourite conversations, when no favourites exist.
+        $this->assertEquals([], \core_message\api::get_conversations($user1->id, 0, 20, null, true));
 
-        $this->assertEquals($user3->id, $conversation->userid);
-        $this->assertEquals($user3->id, $conversation->useridfrom);
-        $this->assertTrue($conversation->ismessaging);
-        $this->assertEquals('Cool.', $conversation->lastmessage);
-        $this->assertEquals($messageid2, $conversation->messageid);
-        $this->assertNull($conversation->isonline);
-        $this->assertFalse($conversation->isread);
-        $this->assertFalse($conversation->isblocked);
-        $this->assertEquals(2, $conversation->unreadcount);
-
-        // Retrieve the next conversation.
-        $conversations = \core_message\api::get_conversations($user1->id, 2, 1);
+        // Try to get NO favourite conversations, when no favourites exist.
+        $this->assertCount(6, \core_message\api::get_conversations($user1->id, 0, 20, null, false));
 
-        // We should only have one conversation because of the limit.
-        $this->assertCount(1, $conversations);
+        // Mark a few conversations as favourites.
+        \core_message\api::set_favourite_conversation($ic1->id, $user1->id);
+        \core_message\api::set_favourite_conversation($gc2->id, $user1->id);
+        \core_message\api::set_favourite_conversation($gc5->id, $user1->id);
 
-        $conversation = array_shift($conversations);
+        // Get the conversations, first with no restrictions, confirming the favourite status of the conversations.
+        $conversations = \core_message\api::get_conversations($user1->id);
+        $this->assertCount(6, $conversations);
+        foreach ($conversations as $conv) {
+            if (in_array($conv->id, [$ic1->id, $gc2->id, $gc5->id])) {
+                $this->assertTrue($conv->isfavourite);
+            }
+        }
 
-        $this->assertEquals($user2->id, $conversation->userid);
-        $this->assertEquals($user2->id, $conversation->useridfrom);
-        $this->assertTrue($conversation->ismessaging);
-        $this->assertEquals('Word.', $conversation->lastmessage);
-        $this->assertEquals($messageid1, $conversation->messageid);
-        $this->assertNull($conversation->isonline);
-        $this->assertFalse($conversation->isread);
-        $this->assertFalse($conversation->isblocked);
-        $this->assertEquals(2, $conversation->unreadcount);
+        // Now, get ONLY favourite conversations.
+        $conversations = \core_message\api::get_conversations($user1->id, 0, 20, null, true);
+        $this->assertCount(3, $conversations);
+        foreach ($conversations as $conv) {
+            $this->assertTrue($conv->isfavourite);
+        }
 
-        // Ask for an offset that doesn't exist.
-        $conversations = \core_message\api::get_conversations($user1->id, 4, 1);
+        // Now, try ONLY favourites of type 'group'.
+        $conversations = \core_message\api::get_conversations($user1->id, 0, 20,
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP, true);
+        $this->assertCount(2, $conversations);
+        foreach ($conversations as $conv) {
+            $this->assertTrue($conv->isfavourite);
+        }
 
-        // We should not get any conversations back.
-        $this->assertCount(0, $conversations);
+        // And NO favourite conversations.
+        $conversations = \core_message\api::get_conversations($user1->id, 0, 20, null, false);
+        $this->assertCount(3, $conversations);
+        foreach ($conversations as $conv) {
+            $this->assertFalse($conv->isfavourite);
+        }
     }
 
     /**
-     * Tests retrieving conversations when a conversation contains a deleted user.
+     * Test verifying that group linked conversations are returned and contain a subname matching the course name.
      */
-    public function test_get_conversations_with_deleted_user() {
+    public function test_get_conversations_group_linked() {
         // Create some users.
         $user1 = self::getDataGenerator()->create_user();
         $user2 = self::getDataGenerator()->create_user();
         $user3 = self::getDataGenerator()->create_user();
 
-        // Send some messages back and forth, have some different conversations with different users.
-        $time = 1;
-        $this->send_fake_message($user1, $user2, 'Yo!', 0, $time + 1);
-        $this->send_fake_message($user2, $user1, 'Sup mang?', 0, $time + 2);
-        $this->send_fake_message($user1, $user2, 'Writing PHPUnit tests!', 0, $time + 3);
-        $this->send_fake_message($user2, $user1, 'Word.', 0, $time + 4);
+        $course1 = $this->getDataGenerator()->create_course();
 
-        $this->send_fake_message($user1, $user3, 'Booyah', 0, $time + 5);
-        $this->send_fake_message($user3, $user1, 'Whaaat?', 0, $time + 6);
-        $this->send_fake_message($user1, $user3, 'Nothing.', 0, $time + 7);
-        $this->send_fake_message($user3, $user1, 'Cool.', 0, $time + 8);
+        // Create a group with a linked conversation.
+        $this->setAdminUser();
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user3->id, $course1->id);
+        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 1));
 
-        // Delete the second user.
-        delete_user($user2);
+        // Add users to group1.
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user2->id));
 
-        // Retrieve the conversations.
         $conversations = \core_message\api::get_conversations($user1->id);
-
-        // We should only have one conversation because the other user was deleted.
-        $this->assertCount(1, $conversations);
-
-        // Confirm the conversation is from the non-deleted user.
-        $conversation = reset($conversations);
-        $this->assertEquals($user3->id, $conversation->userid);
+        $this->assertEquals($course1->shortname, $conversations[0]->subname);
     }
 
    /**
@@ -916,47 +1099,6 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                     ),
                 ),
             ),
-            'Test that users with contacts and messages to self work as expected' => array(
-                'users' => array(
-                    'user1',
-                    'user2',
-                    'user3',
-                ),
-                'contacts' => array(
-                    'user1' => array(
-                        'user2' => 0,
-                        'user3' => 0,
-                    ),
-                    'user2' => array(
-                        'user3' => 0,
-                    ),
-                ),
-                'messages' => array(
-                    array(
-                        'from'          => 'user1',
-                        'to'            => 'user1',
-                        'state'         => 'unread',
-                        'subject'       => 'S1',
-                    ),
-                    array(
-                        'from'          => 'user1',
-                        'to'            => 'user1',
-                        'state'         => 'unread',
-                        'subject'       => 'S2',
-                    ),
-                ),
-                'expectations' => array(
-                    'user1' => array(
-                        // User1 has conversed most recently with user1. The most recent message is S2.
-                        array(
-                            'messageposition'   => 0,
-                            'with'              => 'user1',
-                            'subject'           => 'S2',
-                            'unreadcount'       => 0, // Messages sent to and from the same user are counted as read.
-                        ),
-                    ),
-                ),
-            ),
             'Test conversations with a single user, where some messages are read and some are not.' => array(
                 'users' => array(
                     'user1',
@@ -1219,8 +1361,8 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
             foreach ($data as $expectation) {
                 $otheruser = $users[$expectation['with']];
                 $conversation = $conversations[$expectation['messageposition']];
-                $this->assertEquals($otheruser->id, $conversation->userid);
-                $this->assertEquals($expectation['subject'], $conversation->lastmessage);
+                $this->assertEquals($otheruser->id, $conversation->members[$otheruser->id]->id);
+                $this->assertEquals($expectation['subject'], $conversation->messages[0]->text);
                 $this->assertEquals($expectation['unreadcount'], $conversation->unreadcount);
             }
         }
index f406dbd..11cea21 100644 (file)
@@ -4339,4 +4339,343 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         $this->expectException(\moodle_exception::class);
         $result = core_message_external::unset_favourite_conversations($user1->id, [0]);
     }
+
+    /**
+     * Helper to seed the database with initial state.
+     */
+    protected function create_conversation_test_data() {
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+        $user4 = self::getDataGenerator()->create_user();
+
+        $time = 1;
+
+        // Create some conversations. We want:
+        // 1) At least one of each type (group, individual) of which user1 IS a member and DID send the most recent message.
+        // 2) At least one of each type (group, individual) of which user1 IS a member and DID NOT send the most recent message.
+        // 3) At least one of each type (group, individual) of which user1 IS NOT a member.
+        // 4) At least two group conversation having 0 messages, of which user1 IS a member (To confirm conversationid ordering).
+        // 5) At least one group conversation having 0 messages, of which user1 IS NOT a member.
+
+        // Individual conversation, user1 is a member, last message from other user.
+        $ic1 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]);
+        testhelper::send_fake_message_to_conversation($user1, $ic1->id, 'Message 1', $time);
+        testhelper::send_fake_message_to_conversation($user2, $ic1->id, 'Message 2', $time + 1);
+
+        // Individual conversation, user1 is a member, last message from user1.
+        $ic2 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user3->id]);
+        testhelper::send_fake_message_to_conversation($user3, $ic2->id, 'Message 3', $time + 2);
+        testhelper::send_fake_message_to_conversation($user1, $ic2->id, 'Message 4', $time + 3);
+
+        // Individual conversation, user1 is not a member.
+        $ic3 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user2->id, $user3->id]);
+        testhelper::send_fake_message_to_conversation($user2, $ic3->id, 'Message 5', $time + 4);
+        testhelper::send_fake_message_to_conversation($user3, $ic3->id, 'Message 6', $time + 5);
+
+        // Group conversation, user1 is not a member.
+        $gc1 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user2->id, $user3->id, $user4->id], 'Project discussions');
+        testhelper::send_fake_message_to_conversation($user2, $gc1->id, 'Message 7', $time + 6);
+        testhelper::send_fake_message_to_conversation($user4, $gc1->id, 'Message 8', $time + 7);
+
+        // Group conversation, user1 is a member, last message from another user.
+        $gc2 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user3->id, $user4->id], 'Group chat');
+        testhelper::send_fake_message_to_conversation($user1, $gc2->id, 'Message 9', $time + 8);
+        testhelper::send_fake_message_to_conversation($user3, $gc2->id, 'Message 10', $time + 9);
+        testhelper::send_fake_message_to_conversation($user4, $gc2->id, 'Message 11', $time + 10);
+
+        // Group conversation, user1 is a member, last message from user1.
+        $gc3 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user2->id, $user3->id, $user4->id], 'Group chat again!');
+        testhelper::send_fake_message_to_conversation($user4, $gc3->id, 'Message 12', $time + 11);
+        testhelper::send_fake_message_to_conversation($user3, $gc3->id, 'Message 13', $time + 12);
+        testhelper::send_fake_message_to_conversation($user1, $gc3->id, 'Message 14', $time + 13);
+
+        // Empty group conversations (x2), user1 is a member.
+        $gc4 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user2->id, $user3->id], 'Empty group');
+        $gc5 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user1->id, $user2->id, $user4->id], 'Another empty group');
+
+        // Empty group conversation, user1 is NOT a member.
+        $gc6 = \core_message\api::create_conversation(\core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+            [$user2->id, $user3->id, $user4->id], 'Empty group 3');
+
+        return [$user1, $user2, $user3, $user4, $ic1, $ic2, $ic3, $gc1, $gc2, $gc3, $gc4, $gc5, $gc6];
+    }
+
+    /**
+     * Test confirming the basic use of get_conversations, with no limits, nor type or favourite restrictions.
+     */
+    public function test_get_conversations_no_restrictions() {
+        $this->resetAfterTest(true);
+
+        // Get a bunch of conversations, some group, some individual and in different states.
+        list($user1, $user2, $user3, $user4, $ic1, $ic2, $ic3,
+            $gc1, $gc2, $gc3, $gc4, $gc5, $gc6) = $this->create_conversation_test_data();
+
+        // The user making the request.
+        $this->setUser($user1);
+
+        // Get all conversations for user1.
+        $result = core_message_external::get_conversations($user1->id);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+
+        // Verify there are 6 conversations: 2 individual, 2 group with message, and 2 group without messages.
+        // The conversations with the most recent messages should be listed first, followed by the most newly created
+        // conversations without messages.
+        $this->assertCount(6, $conversations);
+        $this->assertEquals($gc3->id, $conversations[0]['id']);
+        $this->assertEquals($gc2->id, $conversations[1]['id']);
+        $this->assertEquals($ic2->id, $conversations[2]['id']);
+        $this->assertEquals($ic1->id, $conversations[3]['id']);
+        $this->assertEquals($gc5->id, $conversations[4]['id']);
+        $this->assertEquals($gc4->id, $conversations[5]['id']);
+
+        foreach ($conversations as $conv) {
+            $this->assertArrayHasKey('id', $conv);
+            $this->assertArrayHasKey('name', $conv);
+            $this->assertArrayHasKey('subname', $conv);
+            $this->assertArrayHasKey('type', $conv);
+            $this->assertArrayHasKey('membercount', $conv);
+            $this->assertArrayHasKey('isfavourite', $conv);
+            $this->assertArrayHasKey('isread', $conv);
+            $this->assertArrayHasKey('unreadcount', $conv);
+            $this->assertArrayHasKey('members', $conv);
+            foreach ($conv['members'] as $member) {
+                $this->assertArrayHasKey('id', $member);
+                $this->assertArrayHasKey('fullname', $member);
+                $this->assertArrayHasKey('profileimageurl', $member);
+                $this->assertArrayHasKey('profileimageurlsmall', $member);
+                $this->assertArrayHasKey('isonline', $member);
+                $this->assertArrayHasKey('showonlinestatus', $member);
+                $this->assertArrayHasKey('isblocked', $member);
+                $this->assertArrayHasKey('iscontact', $member);
+            }
+            $this->assertArrayHasKey('messages', $conv);
+            foreach ($conv['messages'] as $message) {
+                $this->assertArrayHasKey('id', $message);
+                $this->assertArrayHasKey('useridfrom', $message);
+                $this->assertArrayHasKey('text', $message);
+                $this->assertArrayHasKey('timecreated', $message);
+            }
+        }
+    }
+
+    /**
+     * Tests retrieving conversations with a limit and offset to ensure pagination works correctly.
+     */
+    public function test_get_conversations_limit_offset() {
+        $this->resetAfterTest(true);
+
+        // Get a bunch of conversations, some group, some individual and in different states.
+        list($user1, $user2, $user3, $user4, $ic1, $ic2, $ic3,
+            $gc1, $gc2, $gc3, $gc4, $gc5, $gc6) = $this->create_conversation_test_data();
+
+        // The user making the request.
+        $this->setUser($user1);
+
+        // Get all conversations for user1.
+        $result = core_message_external::get_conversations($user1->id, 0, 1);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+
+        // Verify the first conversation.
+        $this->assertCount(1, $conversations);
+        $conversation = array_shift($conversations);
+        $this->assertEquals($gc3->id, $conversation['id']);
+
+        // Verify the next conversation.
+        $result = core_message_external::get_conversations($user1->id, 1, 1);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertCount(1, $conversations);
+        $this->assertEquals($gc2->id, $conversations[0]['id']);
+
+        // Verify the next conversation.
+        $result = core_message_external::get_conversations($user1->id, 2, 1);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertCount(1, $conversations);
+        $this->assertEquals($ic2->id, $conversations[0]['id']);
+
+        // Skip one and get both empty conversations.
+        $result = core_message_external::get_conversations($user1->id, 4, 2);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertCount(2, $conversations);
+        $this->assertEquals($gc5->id, $conversations[0]['id']);
+        $this->assertEmpty($conversations[0]['messages']);
+        $this->assertEquals($gc4->id, $conversations[1]['id']);
+        $this->assertEmpty($conversations[1]['messages']);
+
+        // Ask for an offset that doesn't exist and verify no conversations are returned.
+        $conversations = \core_message\api::get_conversations($user1->id, 10, 1);
+        $this->assertCount(0, $conversations);
+    }
+
+    /**
+     * Test verifying the type filtering behaviour of the get_conversations external method.
+     */
+    public function test_get_conversations_type_filter() {
+        $this->resetAfterTest(true);
+
+        // Get a bunch of conversations, some group, some individual and in different states.
+        list($user1, $user2, $user3, $user4, $ic1, $ic2, $ic3,
+            $gc1, $gc2, $gc3, $gc4, $gc5, $gc6) = $this->create_conversation_test_data();
+
+        // The user making the request.
+        $this->setUser($user1);
+
+        // Verify we can ask for only individual conversations.
+        $result = core_message_external::get_conversations($user1->id, 0, 20,
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertCount(2, $conversations);
+
+        // Verify we can ask for only group conversations.
+        $result = core_message_external::get_conversations($user1->id, 0, 20,
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertCount(4, $conversations);
+
+        // Verify an exception is thrown if an unrecognized type is specified.
+        $this->expectException(\moodle_exception::class);
+        core_message_external::get_conversations($user1->id, 0, 20, 0);
+    }
+
+    /**
+     * Tests retrieving conversations when a conversation contains a deleted user.
+     */
+    public function test_get_conversations_deleted_user() {
+        $this->resetAfterTest(true);
+
+        // Get a bunch of conversations, some group, some individual and in different states.
+        list($user1, $user2, $user3, $user4, $ic1, $ic2, $ic3,
+            $gc1, $gc2, $gc3, $gc4, $gc5, $gc6) = $this->create_conversation_test_data();
+
+        // The user making the request.
+        $this->setUser($user1);
+
+        // Delete the second user and retrieve the conversations.
+        // We should have 5, as $ic1 drops off the list.
+        // Group conversations remain albeit with less members.
+        delete_user($user2);
+        $result = core_message_external::get_conversations($user1->id);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertCount(5, $conversations);
+        $this->assertEquals($gc3->id, $conversations[0]['id']);
+        $this->assertcount(1, $conversations[0]['members']);
+        $this->assertEquals($gc2->id, $conversations[1]['id']);
+        $this->assertcount(1, $conversations[1]['members']);
+        $this->assertEquals($ic2->id, $conversations[2]['id']);
+        $this->assertEquals($gc5->id, $conversations[3]['id']);
+        $this->assertEquals($gc4->id, $conversations[4]['id']);
+
+        // Delete a user from a group conversation where that user had sent the most recent message.
+        // This user will still be present in the members array, as will the message in the messages array.
+        delete_user($user4);
+        $result = core_message_external::get_conversations($user1->id);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertCount(5, $conversations);
+        $this->assertEquals($gc2->id, $conversations[1]['id']);
+        $this->assertcount(1, $conversations[1]['members']);
+        $this->assertEquals($user4->id, $conversations[1]['members'][0]['id']);
+        $this->assertcount(1, $conversations[1]['messages']);
+        $this->assertEquals($user4->id, $conversations[1]['messages'][0]['useridfrom']);
+
+        // Delete the third user and retrieve the conversations.
+        // We should have 4, as $ic1, $ic2 drop off the list.
+        // Group conversations remain albeit with less members.
+        delete_user($user3);
+        $result = core_message_external::get_conversations($user1->id);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertCount(4, $conversations);
+        $this->assertEquals($gc3->id, $conversations[0]['id']);
+        $this->assertcount(1, $conversations[0]['members']);
+        $this->assertEquals($gc2->id, $conversations[1]['id']);
+        $this->assertcount(1, $conversations[1]['members']);
+        $this->assertEquals($gc5->id, $conversations[2]['id']);
+        $this->assertEquals($gc4->id, $conversations[3]['id']);
+    }
+
+    /**
+     * Test verifying the behaviour of get_conversations() when fetching favourite conversations.
+     */
+    public function test_get_conversations_favourite_conversations() {
+        $this->resetAfterTest(true);
+
+        // Get a bunch of conversations, some group, some individual and in different states.
+        list($user1, $user2, $user3, $user4, $ic1, $ic2, $ic3,
+            $gc1, $gc2, $gc3, $gc4, $gc5, $gc6) = $this->create_conversation_test_data();
+
+        // The user making the request.
+        $this->setUser($user1);
+
+        // Try to get ONLY favourite conversations, when no favourites exist.
+        $result = core_message_external::get_conversations($user1->id, 0, 20, null, true);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertEquals([], $conversations);
+
+        // Try to get NO favourite conversations, when no favourites exist.
+        $result = core_message_external::get_conversations($user1->id, 0, 20, null, false);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertCount(6, $conversations);
+
+        // Mark a few conversations as favourites.
+        \core_message\api::set_favourite_conversation($ic1->id, $user1->id);
+        \core_message\api::set_favourite_conversation($gc2->id, $user1->id);
+        \core_message\api::set_favourite_conversation($gc5->id, $user1->id);
+
+        // Get the conversations, first with no restrictions, confirming the favourite status of the conversations.
+        $result = core_message_external::get_conversations($user1->id);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertCount(6, $conversations);
+        foreach ($conversations as $conv) {
+            if (in_array($conv['id'], [$ic1->id, $gc2->id, $gc5->id])) {
+                $this->assertTrue($conv['isfavourite']);
+            }
+        }
+
+        // Now, get ONLY favourite conversations.
+        $result = core_message_external::get_conversations($user1->id, 0, 20, null, true);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertCount(3, $conversations);
+        foreach ($conversations as $conv) {
+            $this->assertTrue($conv['isfavourite']);
+        }
+
+        // Now, try ONLY favourites of type 'group'.
+        $conversations = \core_message\api::get_conversations($user1->id, 0, 20,
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP, true);
+        $this->assertCount(2, $conversations);
+        foreach ($conversations as $conv) {
+            $this->assertTrue($conv->isfavourite);
+        }
+
+        // And NO favourite conversations.
+        $result = core_message_external::get_conversations($user1->id, 0, 20, null, false);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $this->assertCount(3, $conversations);
+        foreach ($conversations as $conv) {
+            $this->assertFalse($conv['isfavourite']);
+        }
+    }
 }
index a74dc18..5eaf792 100644 (file)
@@ -44,6 +44,8 @@ information provided here is intended especially for developers.
   - core_message_external::delete_conversation(), please use core_message_external::delete_conversations_by_id() instead.
   - core_message_external::core_message_mark_all_messages_as_read(), please use
     core_message_external::core_message_mark_all_conversation_messages_as_read() instead.
+  - core_message_external::data_for_messagearea_conversations(), please use core_message_external::get_conversations()
+    instead
 * The following function has been added for getting the privacy messaging preference:
   - get_user_privacy_messaging_preference()
 
index 06b66d2..a26d221 100644 (file)
@@ -76,7 +76,7 @@ class sco_launched extends \core\event\base {
      * @return \moodle_url
      */
     public function get_url() {
-        return new \moodle_url('/mod/scorm/player.php', array('id' => $this->contextinstanceid, 'scoid' => $this->objectid));
+        return new \moodle_url('/mod/scorm/player.php', array('cm' => $this->contextinstanceid, 'scoid' => $this->objectid));
     }
 
     /**
index 9beb1c2..00c5cdd 100644 (file)
@@ -855,7 +855,7 @@ class mod_scorm_external_testcase extends externallib_advanced_testcase {
         // Checking that the event contains the expected values.
         $this->assertInstanceOf('\mod_scorm\event\sco_launched', $event);
         $this->assertEquals($this->context, $event->get_context());
-        $moodleurl = new \moodle_url('/mod/scorm/player.php', array('id' => $this->cm->id, 'scoid' => $sco->id));
+        $moodleurl = new \moodle_url('/mod/scorm/player.php', array('cm' => $this->cm->id, 'scoid' => $sco->id));
         $this->assertEquals($moodleurl, $event->get_url());
         $this->assertEventContextNotUsed($event);
         $this->assertNotEmpty($event->get_name());
index c4cc09b..80df6e2 100644 (file)
@@ -77,10 +77,12 @@ $blocks-plus-gutter: $blocks-column-width + ( $grid-gutter-width / 2 );
 
 $card-gutter : $card-deck-margin * 2;
 
-.block_myoverview {
-    .empty-placeholder-image-lg {
-        height: 125px;
-    }
+.block .empty-placeholder-image-lg {
+    height: 125px;
+}
+
+.block_myoverview,
+.block_starredcourses {
     .course-info-container {
         padding: 0.8rem;
     }
@@ -110,6 +112,13 @@ $card-gutter : $card-deck-margin * 2;
     }
 }
 
+.block_starredcourses {
+    .dashboard-card-deck {
+        flex-flow: nowrap;
+        overflow-y: scroll;
+    }
+}
+
 .summaryimage {
     height: 7rem;
     background-position: center;
index 94feba3..7dcd406 100644 (file)
@@ -11137,34 +11137,65 @@ div.editor_atto_toolbar button .icon {
   [data-region="blocks-column"] {
     width: 100%; } }
 
-.block_myoverview .empty-placeholder-image-lg {
+.block .empty-placeholder-image-lg {
   height: 125px; }
 
-.block_myoverview .course-info-container {
+.block_myoverview .course-info-container,
+.block_starredcourses .course-info-container {
   padding: 0.8rem; }
 
-.block_myoverview .progress {
+.block_myoverview .progress,
+.block_starredcourses .progress {
   height: 0.5rem; }
 
-.block_myoverview .course-summaryitem {
+.block_myoverview .course-summaryitem,
+.block_starredcourses .course-summaryitem {
   border: 1px solid #dee2e6;
   background-color: #fff; }
 
-.block_myoverview .icon {
+.block_myoverview .icon,
+.block_starredcourses .icon {
   margin-right: 0; }
 
 .block_myoverview a,
-.block_myoverview .btn-link {
+.block_myoverview .btn-link,
+.block_starredcourses a,
+.block_starredcourses .btn-link {
   color: inherit; }
 
-.block_myoverview .btn.btn-link.btn-icon, .block_myoverview #page-grade-grading-manage .actions .btn-link.btn-icon.action, #page-grade-grading-manage .actions .block_myoverview .btn-link.btn-icon.action, .block_myoverview #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-link.btn-icon, #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .block_myoverview input.btn-link.btn-icon, .block_myoverview #rubric-rubric.gradingform_rubric .btn-link.btn-icon.addcriterion, #rubric-rubric.gradingform_rubric .block_myoverview .btn-link.btn-icon.addcriterion {
+.block_myoverview .btn.btn-link.btn-icon, .block_myoverview #page-grade-grading-manage .actions .btn-link.btn-icon.action, #page-grade-grading-manage .actions .block_myoverview .btn-link.btn-icon.action, .block_myoverview #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-link.btn-icon, #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .block_myoverview input.btn-link.btn-icon, .block_myoverview #rubric-rubric.gradingform_rubric .btn-link.btn-icon.addcriterion, #rubric-rubric.gradingform_rubric .block_myoverview .btn-link.btn-icon.addcriterion,
+.block_starredcourses .btn.btn-link.btn-icon,
+.block_starredcourses #page-grade-grading-manage .actions .btn-link.btn-icon.action,
+#page-grade-grading-manage .actions .block_starredcourses .btn-link.btn-icon.action,
+.block_starredcourses #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-link.btn-icon,
+#rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .block_starredcourses input.btn-link.btn-icon,
+.block_starredcourses #rubric-rubric.gradingform_rubric .btn-link.btn-icon.addcriterion,
+#rubric-rubric.gradingform_rubric .block_starredcourses .btn-link.btn-icon.addcriterion {
   height: 36px;
   width: 36px;
   padding: 0;
   border-radius: 50%; }
-  .block_myoverview .btn.btn-link.btn-icon:hover, .block_myoverview #page-grade-grading-manage .actions .btn-link.btn-icon.action:hover, #page-grade-grading-manage .actions .block_myoverview .btn-link.btn-icon.action:hover, .block_myoverview #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-link.btn-icon:hover, #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .block_myoverview input.btn-link.btn-icon:hover, .block_myoverview #rubric-rubric.gradingform_rubric .btn-link.btn-icon.addcriterion:hover, #rubric-rubric.gradingform_rubric .block_myoverview .btn-link.btn-icon.addcriterion:hover, .block_myoverview .btn.btn-link.btn-icon:focus, .block_myoverview #page-grade-grading-manage .actions .btn-link.btn-icon.action:focus, #page-grade-grading-manage .actions .block_myoverview .btn-link.btn-icon.action:focus, .block_myoverview #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-link.btn-icon:focus, #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .block_myoverview input.btn-link.btn-icon:focus, .block_myoverview #rubric-rubric.gradingform_rubric .btn-link.btn-icon.addcriterion:focus, #rubric-rubric.gradingform_rubric .block_myoverview .btn-link.btn-icon.addcriterion:focus {
+  .block_myoverview .btn.btn-link.btn-icon:hover, .block_myoverview #page-grade-grading-manage .actions .btn-link.btn-icon.action:hover, #page-grade-grading-manage .actions .block_myoverview .btn-link.btn-icon.action:hover, .block_myoverview #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-link.btn-icon:hover, #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .block_myoverview input.btn-link.btn-icon:hover, .block_myoverview #rubric-rubric.gradingform_rubric .btn-link.btn-icon.addcriterion:hover, #rubric-rubric.gradingform_rubric .block_myoverview .btn-link.btn-icon.addcriterion:hover, .block_myoverview .btn.btn-link.btn-icon:focus, .block_myoverview #page-grade-grading-manage .actions .btn-link.btn-icon.action:focus, #page-grade-grading-manage .actions .block_myoverview .btn-link.btn-icon.action:focus, .block_myoverview #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-link.btn-icon:focus, #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .block_myoverview input.btn-link.btn-icon:focus, .block_myoverview #rubric-rubric.gradingform_rubric .btn-link.btn-icon.addcriterion:focus, #rubric-rubric.gradingform_rubric .block_myoverview .btn-link.btn-icon.addcriterion:focus,
+  .block_starredcourses .btn.btn-link.btn-icon:hover,
+  .block_starredcourses #page-grade-grading-manage .actions .btn-link.btn-icon.action:hover,
+  #page-grade-grading-manage .actions .block_starredcourses .btn-link.btn-icon.action:hover,
+  .block_starredcourses #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-link.btn-icon:hover,
+  #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .block_starredcourses input.btn-link.btn-icon:hover,
+  .block_starredcourses #rubric-rubric.gradingform_rubric .btn-link.btn-icon.addcriterion:hover,
+  #rubric-rubric.gradingform_rubric .block_starredcourses .btn-link.btn-icon.addcriterion:hover,
+  .block_starredcourses .btn.btn-link.btn-icon:focus,
+  .block_starredcourses #page-grade-grading-manage .actions .btn-link.btn-icon.action:focus,
+  #page-grade-grading-manage .actions .block_starredcourses .btn-link.btn-icon.action:focus,
+  .block_starredcourses #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel input.btn-link.btn-icon:focus,
+  #rubric-rubric.gradingform_rubric #rubric-criteria .criterion .addlevel .block_starredcourses input.btn-link.btn-icon:focus,
+  .block_starredcourses #rubric-rubric.gradingform_rubric .btn-link.btn-icon.addcriterion:focus,
+  #rubric-rubric.gradingform_rubric .block_starredcourses .btn-link.btn-icon.addcriterion:focus {
     background-color: #e9ecef; }
 
+.block_starredcourses .dashboard-card-deck {
+  flex-flow: nowrap;
+  overflow-y: scroll; }
+
 .summaryimage {
   height: 7rem;
   background-position: center;
index 10c58a6..e8df3e6 100644 (file)
     }
 }
 
+.block_starredcourses,
 .block_myoverview {
 
     .empty-placeholder-image-lg {
     }
 }
 
+.block_starredcourses {
+    .dashboard-card-deck {
+        flex-flow: nowrap;
+        overflow-y: scroll;
+    }
+}
+
 .dashboard-card-deck {
     box-sizing: border-box;
     *,
 
     @media (min-width: 1200px) {
         .dashboard-card {
-            flex-basis: ~"calc(33% - 0.5rem)";
+            flex-basis: ~"calc(33.333% - 0.5rem)";
         }
     }
 }
index 2fd008a..61ae121 100644 (file)
@@ -16546,13 +16546,16 @@ body {
   height: 35px;
   width: 35px;
 }
+.block_starredcourses .empty-placeholder-image-lg,
 .block_myoverview .empty-placeholder-image-lg {
   height: 125px;
 }
+.block_starredcourses .course-info-container,
 .block_myoverview .course-info-container {
   flex: 1 1 auto;
   padding: 0.8rem;
 }
+.block_starredcourses .dashboard-card-footer,
 .block_myoverview .dashboard-card-footer {
   padding: 0.8rem;
   background-color: #f5f5f5;
@@ -16564,13 +16567,16 @@ body {
   -moz-border-radius-bottomleft: 4px;
   border-bottom-left-radius: 4px;
 }
+.block_starredcourses .progress,
 .block_myoverview .progress {
   height: 0.5rem;
   margin-bottom: 0;
 }
+.block_starredcourses .list-group,
 .block_myoverview .list-group {
   margin: 0;
 }
+.block_starredcourses .course-listitem,
 .block_myoverview .course-listitem {
   display: block;
   padding: 0.75rem 1.25rem;
@@ -16581,6 +16587,7 @@ body {
   -moz-border-radius: 4px;
   border-radius: 4px;
 }
+.block_starredcourses .course-summaryitem,
 .block_myoverview .course-summaryitem {
   padding: 0.5rem;
   background-color: #fff;
@@ -16589,14 +16596,20 @@ body {
   -moz-border-radius: 4px;
   border-radius: 4px;
 }
+.block_starredcourses .summary img,
 .block_myoverview .summary img {
   max-width: 100%;
 }
 @media (max-width: 576px) {
+  .block_starredcourses .summaryimage,
   .block_myoverview .summaryimage {
     max-height: 7rem;
   }
 }
+.block_starredcourses .dashboard-card-deck {
+  flex-flow: nowrap;
+  overflow-y: scroll;
+}
 .dashboard-card-deck {
   box-sizing: border-box;
   display: flex;
@@ -16630,7 +16643,7 @@ body {
 }
 @media (min-width: 1200px) {
   .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(33% - 0.5rem);
+    flex-basis: calc(33.333% - 0.5rem);
   }
 }
 @media (min-width: 768px) {