From 77c25ab7252b82012c77117ccfdd899bd823d65e Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 13 Dec 2017 15:28:54 +1030 Subject: [PATCH] Add infinite scrolling in the notifications list --- js/forum/dist/app.js | 206 +++++++++++------- js/forum/src/components/NotificationList.js | 146 +++++++++---- less/forum/NotificationsDropdown.less | 1 - .../ListNotificationsController.php | 31 ++- 4 files changed, 260 insertions(+), 124 deletions(-) diff --git a/js/forum/dist/app.js b/js/forum/dist/app.js index c19f8ae36..f3bccdee2 100644 --- a/js/forum/dist/app.js +++ b/js/forum/dist/app.js @@ -23964,39 +23964,18 @@ System.register('flarum/components/NotificationList', ['flarum/Component', 'flar * @type {Boolean} */ this.loading = false; + + /** + * Whether or not there are more results that can be loaded. + * + * @type {Boolean} + */ + this.moreResults = false; } }, { key: 'view', value: function view() { - var groups = []; - - if (app.cache.notifications) { - var discussions = {}; - - // Build an array of discussions which the notifications are related to, - // and add the notifications as children. - app.cache.notifications.forEach(function (notification) { - var subject = notification.subject(); - - if (typeof subject === 'undefined') return; - - // Get the discussion that this notification is related to. If it's not - // directly related to a discussion, it may be related to a post or - // other entity which is related to a discussion. - var discussion = false; - if (subject instanceof Discussion) discussion = subject;else if (subject && subject.discussion) discussion = subject.discussion(); - - // If the notification is not related to a discussion directly or - // indirectly, then we will assign it to a neutral group. - var key = discussion ? discussion.id() : 0; - discussions[key] = discussions[key] || { discussion: discussion, notifications: [] }; - discussions[key].notifications.push(notification); - - if (groups.indexOf(discussions[key]) === -1) { - groups.push(discussions[key]); - } - }); - } + var pages = app.cache.notifications || []; return m( 'div', @@ -24023,71 +24002,144 @@ System.register('flarum/components/NotificationList', ['flarum/Component', 'flar m( 'div', { className: 'NotificationList-content' }, - groups.length ? groups.map(function (group) { - var badges = group.discussion && group.discussion.badges().toArray(); + pages.length ? pages.map(function (notifications) { + var groups = []; + var discussions = {}; - return m( - 'div', - { className: 'NotificationGroup' }, - group.discussion ? m( - 'a', - { className: 'NotificationGroup-header', - href: app.route.discussion(group.discussion), - config: m.route }, - badges && badges.length ? m( - 'ul', - { className: 'NotificationGroup-badges badges' }, - listItems(badges) - ) : '', - group.discussion.title() - ) : m( + notifications.forEach(function (notification) { + var subject = notification.subject(); + + if (typeof subject === 'undefined') return; + + // Get the discussion that this notification is related to. If it's not + // directly related to a discussion, it may be related to a post or + // other entity which is related to a discussion. + var discussion = false; + if (subject instanceof Discussion) discussion = subject;else if (subject && subject.discussion) discussion = subject.discussion(); + + // If the notification is not related to a discussion directly or + // indirectly, then we will assign it to a neutral group. + var key = discussion ? discussion.id() : 0; + discussions[key] = discussions[key] || { discussion: discussion, notifications: [] }; + discussions[key].notifications.push(notification); + + if (groups.indexOf(discussions[key]) === -1) { + groups.push(discussions[key]); + } + }); + + return groups.map(function (group) { + var badges = group.discussion && group.discussion.badges().toArray(); + + return m( 'div', - { className: 'NotificationGroup-header' }, - app.forum.attribute('title') - ), - m( - 'ul', - { className: 'NotificationGroup-content' }, - group.notifications.map(function (notification) { - var NotificationComponent = app.notificationComponents[notification.contentType()]; - return NotificationComponent ? m( - 'li', - null, - NotificationComponent.component({ notification: notification }) - ) : ''; - }) - ) - ); - }) : !this.loading ? m( + { className: 'NotificationGroup' }, + group.discussion ? m( + 'a', + { className: 'NotificationGroup-header', + href: app.route.discussion(group.discussion), + config: m.route }, + badges && badges.length ? m( + 'ul', + { className: 'NotificationGroup-badges badges' }, + listItems(badges) + ) : '', + group.discussion.title() + ) : m( + 'div', + { className: 'NotificationGroup-header' }, + app.forum.attribute('title') + ), + m( + 'ul', + { className: 'NotificationGroup-content' }, + group.notifications.map(function (notification) { + var NotificationComponent = app.notificationComponents[notification.contentType()]; + return NotificationComponent ? m( + 'li', + null, + NotificationComponent.component({ notification: notification }) + ) : ''; + }) + ) + ); + }); + }) : '', + this.loading ? m(LoadingIndicator, { className: 'LoadingIndicator--block' }) : pages.length ? '' : m( 'div', { className: 'NotificationList-empty' }, app.translator.trans('core.forum.notifications.empty_text') - ) : LoadingIndicator.component({ className: 'LoadingIndicator--block' }) + ) ) ); } }, { - key: 'load', - value: function load() { + key: 'config', + value: function config(isInitialized, context) { var _this2 = this; - if (app.cache.notifications && !app.session.user.newNotificationsCount()) { + if (isInitialized) return; + + var $notifications = this.$('.NotificationList-content'); + var $scrollParent = $notifications.css('overflow') === 'auto' ? $notifications : $(window); + + var scrollHandler = function scrollHandler() { + var scrollTop = $scrollParent.scrollTop(); + var viewportHeight = $scrollParent.height(); + var contentTop = $scrollParent === $notifications ? 0 : $notifications.offset().top; + var contentHeight = $notifications[0].scrollHeight; + + if (_this2.moreResults && !_this2.loading && scrollTop + viewportHeight >= contentTop + contentHeight) { + _this2.loadMore(); + } + }; + + $scrollParent.on('scroll', scrollHandler); + + context.onunload = function () { + $scrollParent.off('scroll', scrollHandler); + }; + } + }, { + key: 'load', + value: function load() { + if (app.session.user.newNotificationsCount()) { + delete app.cache.notifications; + } + + if (app.cache.notifications) { return; } + app.session.user.pushAttributes({ newNotificationsCount: 0 }); + + this.loadMore(); + } + }, { + key: 'loadMore', + value: function loadMore() { + var _this3 = this; + this.loading = true; m.redraw(); - app.store.find('notifications').then(function (notifications) { - app.session.user.pushAttributes({ newNotificationsCount: 0 }); - app.cache.notifications = notifications.sort(function (a, b) { - return b.time() - a.time(); - }); - }).catch(function () {}).then(function () { - _this2.loading = false; + var params = app.cache.notifications ? { page: { offset: app.cache.notifications.length * 10 } } : null; + + return app.store.find('notifications', params).then(this.parseResults.bind(this)).catch(function () {}).then(function () { + _this3.loading = false; m.redraw(); }); } + }, { + key: 'parseResults', + value: function parseResults(results) { + app.cache.notifications = app.cache.notifications || []; + app.cache.notifications.push(results); + + this.moreResults = !!results.payload.links.next; + + return results; + } }, { key: 'markAllAsRead', value: function markAllAsRead() { @@ -24095,8 +24147,10 @@ System.register('flarum/components/NotificationList', ['flarum/Component', 'flar app.session.user.pushAttributes({ unreadNotificationsCount: 0 }); - app.cache.notifications.forEach(function (notification) { - return notification.pushAttributes({ isRead: true }); + app.cache.notifications.forEach(function (notifications) { + notifications.forEach(function (notification) { + return notification.pushAttributes({ isRead: true }); + }); }); app.request({ diff --git a/js/forum/src/components/NotificationList.js b/js/forum/src/components/NotificationList.js index 23b644344..7032b339b 100644 --- a/js/forum/src/components/NotificationList.js +++ b/js/forum/src/components/NotificationList.js @@ -16,39 +16,17 @@ export default class NotificationList extends Component { * @type {Boolean} */ this.loading = false; + + /** + * Whether or not there are more results that can be loaded. + * + * @type {Boolean} + */ + this.moreResults = false; } view() { - const groups = []; - - if (app.cache.notifications) { - const discussions = {}; - - // Build an array of discussions which the notifications are related to, - // and add the notifications as children. - app.cache.notifications.forEach(notification => { - const subject = notification.subject(); - - if (typeof subject === 'undefined') return; - - // Get the discussion that this notification is related to. If it's not - // directly related to a discussion, it may be related to a post or - // other entity which is related to a discussion. - let discussion = false; - if (subject instanceof Discussion) discussion = subject; - else if (subject && subject.discussion) discussion = subject.discussion(); - - // If the notification is not related to a discussion directly or - // indirectly, then we will assign it to a neutral group. - const key = discussion ? discussion.id() : 0; - discussions[key] = discussions[key] || {discussion: discussion, notifications: []}; - discussions[key].notifications.push(notification); - - if (groups.indexOf(discussions[key]) === -1) { - groups.push(discussions[key]); - } - }); - } + const pages = app.cache.notifications || []; return (
@@ -66,8 +44,34 @@ export default class NotificationList extends Component {
- {groups.length - ? groups.map(group => { + {pages.length ? pages.map(notifications => { + const groups = []; + const discussions = {}; + + notifications.forEach(notification => { + const subject = notification.subject(); + + if (typeof subject === 'undefined') return; + + // Get the discussion that this notification is related to. If it's not + // directly related to a discussion, it may be related to a post or + // other entity which is related to a discussion. + let discussion = false; + if (subject instanceof Discussion) discussion = subject; + else if (subject && subject.discussion) discussion = subject.discussion(); + + // If the notification is not related to a discussion directly or + // indirectly, then we will assign it to a neutral group. + const key = discussion ? discussion.id() : 0; + discussions[key] = discussions[key] || {discussion: discussion, notifications: []}; + discussions[key].notifications.push(notification); + + if (groups.indexOf(discussions[key]) === -1) { + groups.push(discussions[key]); + } + }); + + return groups.map(group => { const badges = group.discussion && group.discussion.badges().toArray(); return ( @@ -94,32 +98,71 @@ export default class NotificationList extends Component {
); - }) - : !this.loading - ?
{app.translator.trans('core.forum.notifications.empty_text')}
- : LoadingIndicator.component({className: 'LoadingIndicator--block'})} + }); + }) : ''} + {this.loading + ? + : (pages.length ? '' :
{app.translator.trans('core.forum.notifications.empty_text')}
)} ); } + config(isInitialized, context) { + if (isInitialized) return; + + const $notifications = this.$('.NotificationList-content'); + const $scrollParent = $notifications.css('overflow') === 'auto' ? $notifications : $(window); + + const scrollHandler = () => { + const scrollTop = $scrollParent.scrollTop(); + const viewportHeight = $scrollParent.height(); + const contentTop = $scrollParent === $notifications ? 0 : $notifications.offset().top; + const contentHeight = $notifications[0].scrollHeight; + + if (this.moreResults && !this.loading && scrollTop + viewportHeight >= contentTop + contentHeight) { + this.loadMore(); + } + }; + + $scrollParent.on('scroll', scrollHandler); + + context.onunload = () => { + $scrollParent.off('scroll', scrollHandler); + }; + } + /** * Load notifications into the application's cache if they haven't already * been loaded. */ load() { - if (app.cache.notifications && !app.session.user.newNotificationsCount()) { + if (app.session.user.newNotificationsCount()) { + delete app.cache.notifications; + } + + if (app.cache.notifications) { return; } + app.session.user.pushAttributes({newNotificationsCount: 0}); + + this.loadMore(); + } + + /** + * Load the next page of notification results. + * + * @public + */ + loadMore() { this.loading = true; m.redraw(); - app.store.find('notifications') - .then(notifications => { - app.session.user.pushAttributes({newNotificationsCount: 0}); - app.cache.notifications = notifications.sort((a, b) => b.time() - a.time()); - }) + const params = app.cache.notifications ? {page: {offset: app.cache.notifications.length * 10}} : null; + + return app.store.find('notifications', params) + .then(this.parseResults.bind(this)) .catch(() => {}) .then(() => { this.loading = false; @@ -127,6 +170,21 @@ export default class NotificationList extends Component { }); } + /** + * Parse results and append them to the notification list. + * + * @param {Notification[]} results + * @return {Notification[]} + */ + parseResults(results) { + app.cache.notifications = app.cache.notifications || []; + app.cache.notifications.push(results); + + this.moreResults = !!results.payload.links.next; + + return results; + } + /** * Mark all of the notifications as read. */ @@ -135,7 +193,9 @@ export default class NotificationList extends Component { app.session.user.pushAttributes({unreadNotificationsCount: 0}); - app.cache.notifications.forEach(notification => notification.pushAttributes({isRead: true})); + app.cache.notifications.forEach(notifications => { + notifications.forEach(notification => notification.pushAttributes({isRead: true})) + }); app.request({ url: app.forum.attribute('apiUrl') + '/notifications/read', diff --git a/less/forum/NotificationsDropdown.less b/less/forum/NotificationsDropdown.less index 7ce7bfb1d..a36c635ee 100644 --- a/less/forum/NotificationsDropdown.less +++ b/less/forum/NotificationsDropdown.less @@ -6,7 +6,6 @@ .NotificationList-content { max-height: 70vh; overflow: auto; - padding-bottom: 10px; } } & .Dropdown-toggle .Button-label { diff --git a/src/Api/Controller/ListNotificationsController.php b/src/Api/Controller/ListNotificationsController.php index 7af1cd561..284f103b3 100644 --- a/src/Api/Controller/ListNotificationsController.php +++ b/src/Api/Controller/ListNotificationsController.php @@ -11,6 +11,7 @@ namespace Flarum\Api\Controller; +use Flarum\Api\UrlGenerator; use Flarum\Core\Discussion; use Flarum\Core\Exception\PermissionDeniedException; use Flarum\Core\Repository\NotificationRepository; @@ -39,16 +40,23 @@ class ListNotificationsController extends AbstractCollectionController public $limit = 10; /** - * @var \Flarum\Core\Repository\NotificationRepository + * @var NotificationRepository */ protected $notifications; /** - * @param \Flarum\Core\Repository\NotificationRepository $notifications + * @var UrlGenerator */ - public function __construct(NotificationRepository $notifications) + protected $url; + + /** + * @param NotificationRepository $notifications + * @param UrlGenerator $url + */ + public function __construct(NotificationRepository $notifications, UrlGenerator $url) { $this->notifications = $notifications; + $this->url = $url; } /** @@ -68,10 +76,25 @@ class ListNotificationsController extends AbstractCollectionController $offset = $this->extractOffset($request); $include = $this->extractInclude($request); - $notifications = $this->notifications->findByUser($actor, $limit, $offset) + $notifications = $this->notifications->findByUser($actor, $limit + 1, $offset) ->load(array_diff($include, ['subject.discussion'])) ->all(); + $areMoreResults = false; + + if (count($notifications) > $limit) { + array_pop($notifications); + $areMoreResults = true; + } + + $document->addPaginationLinks( + $this->url->toRoute('notifications.index'), + $request->getQueryParams(), + $offset, + $limit, + $areMoreResults ? null : 0 + ); + if (in_array('subject.discussion', $include)) { $this->loadSubjectDiscussions($notifications); }