From c6bcb79541aa6b7319da7cd187909d3527aaadad Mon Sep 17 00:00:00 2001 From: David Sevilla Martin Date: Tue, 22 Oct 2019 18:19:32 -0400 Subject: [PATCH] Add notifications, and frontend framework rewrite changes changelog file --- FRONTEND FRAMEWORK REWRITE CHANGES.md | 19 ++ js/src/common/Application.ts | 18 +- js/src/common/Translator.ts | 2 +- js/src/common/models/Notification.ts | 18 ++ js/src/common/utils/Drawer.ts | 51 +++++ js/src/common/utils/ItemList.ts | 21 +- js/src/forum/Forum.ts | 4 +- js/src/forum/components/HeaderSecondary.tsx | 10 +- js/src/forum/components/NotificationList.tsx | 201 ++++++++++++++++++ .../components/NotificationsDropdown.tsx | 73 +++++++ js/src/forum/components/Search.tsx | 6 +- js/src/forum/components/SessionDropdown.tsx | 89 ++++++++ 12 files changed, 482 insertions(+), 30 deletions(-) create mode 100644 FRONTEND FRAMEWORK REWRITE CHANGES.md create mode 100644 js/src/common/models/Notification.ts create mode 100644 js/src/common/utils/Drawer.ts create mode 100644 js/src/forum/components/NotificationList.tsx create mode 100644 js/src/forum/components/NotificationsDropdown.tsx create mode 100644 js/src/forum/components/SessionDropdown.tsx diff --git a/FRONTEND FRAMEWORK REWRITE CHANGES.md b/FRONTEND FRAMEWORK REWRITE CHANGES.md new file mode 100644 index 000000000..a8c811362 --- /dev/null +++ b/FRONTEND FRAMEWORK REWRITE CHANGES.md @@ -0,0 +1,19 @@ +### Changes + +* Mithril + - See changes from v0.2.x @ https://mithril.js.org/migration-v02x.html + - Kept `m.prop` and `m.withAttr` + - Actual Promises are used now instead of `m.deferred` +* Component + - Use new Mithril lifecycle hooks (`component.config` is gone) + - `component.render` is gone +* Application + - New different methods + - `app.bus` for some event hooking +* Translator + - Added `app.translator.transText`, automatically extracts text from `translator.trans` output + +#### Forum +* Forum Application + - Renamed to `Forum` + - `app.search` is no longer global, extend using `extend` diff --git a/js/src/common/Application.ts b/js/src/common/Application.ts index 4bb020a1b..cd36a2968 100644 --- a/js/src/common/Application.ts +++ b/js/src/common/Application.ts @@ -7,6 +7,7 @@ import Store from './Store'; import extract from './utils/extract'; import mapRoutes from './utils/mapRoutes'; +import Drawer from './utils/Drawer'; import {extend} from './extend'; import Forum from './models/Forum'; @@ -14,6 +15,7 @@ import Discussion from './models/Discussion'; import User from './models/User'; import Post from './models/Post'; import Group from './models/Group'; +import Notification from './models/Notification'; import RequestError from './utils/RequestError'; import Alert from './components/Alert'; @@ -32,7 +34,7 @@ export default abstract class Application { */ forum: Forum; - data: ApplicationData | undefined; + data: ApplicationData; translator = new Translator(); bus = new Bus(); @@ -51,9 +53,17 @@ export default abstract class Application { discussions: Discussion, posts: Post, groups: Group, - // notifications: Notification + notifications: Notification }); + drawer = new Drawer(); + + /** + * A local cache that can be used to store data at the application level, so + * that is persists between different routes. + */ + cache = {}; + routes = {}; title = ''; @@ -69,8 +79,6 @@ export default abstract class Application { // this.modal = m.mount(document.getElementById('modal'), ); // this.alerts = m.mount(document.getElementById('alerts'), ); - // this.drawer = new Drawer(); - m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath)); } @@ -151,7 +159,7 @@ export default abstract class Application { route(name: string, params: object = {}): string { const route = this.routes[name]; - if (!route) throw new Error(`Route ${name} does not exist`); + if (!route) throw new Error(`Route '${name}' does not exist`); const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key)); const queryString = m.buildQueryString(params); diff --git a/js/src/common/Translator.ts b/js/src/common/Translator.ts index 92f1714a6..7f1d2cec6 100644 --- a/js/src/common/Translator.ts +++ b/js/src/common/Translator.ts @@ -1,6 +1,6 @@ import extract from './utils/extract'; +import extractText from './utils/extractText'; import username from './helpers/username'; -import extractText from "./utils/extractText"; type Translations = { [key: string]: string }; diff --git a/js/src/common/models/Notification.ts b/js/src/common/models/Notification.ts new file mode 100644 index 000000000..366db5d9b --- /dev/null +++ b/js/src/common/models/Notification.ts @@ -0,0 +1,18 @@ +import Model from '../Model'; +import User from './User'; + +export default class Notification extends Model { + static ADMINISTRATOR_ID = '1'; + static GUEST_ID = '2'; + static MEMBER_ID = '3'; + + contentType = Model.attribute('contentType') as () => string; + content = Model.attribute('content') as () => string; + createdAt = Model.attribute('createdAt', Model.transformDate) as () => Date; + + isRead = Model.attribute('isRead') as () => boolean; + + user = Model.hasOne('user') as () => User; + fromUser = Model.hasOne('fromUser') as () => User; + subject = Model.hasOne('subhect') as () => any; +} diff --git a/js/src/common/utils/Drawer.ts b/js/src/common/utils/Drawer.ts new file mode 100644 index 000000000..dd903261d --- /dev/null +++ b/js/src/common/utils/Drawer.ts @@ -0,0 +1,51 @@ +/** + * The `Drawer` class controls the page's drawer. The drawer is the area the + * slides out from the left on mobile devices; it contains the header and the + * footer. + */ +export default class Drawer { + private $backdrop?: ZeptoCollection; + + constructor() { + // Set up an event handler so that whenever the content area is tapped, + // the drawer will close. + $('#content').click(e => { + if (this.isOpen()) { + e.preventDefault(); + this.hide(); + } + }); + } + + /** + * Check whether or not the drawer is currently open. + */ + isOpen(): boolean { + return $('#app').hasClass('drawerOpen'); + } + + /** + * Hide the drawer. + */ + hide() { + $('#app').removeClass('drawerOpen'); + + if (this.$backdrop) this.$backdrop.remove(); + } + + /** + * Show the drawer. + * + * @public + */ + show() { + $('#app').addClass('drawerOpen'); + + this.$backdrop = $('
') + .addClass('drawer-backdrop fade') + .appendTo('body') + .click(() => this.hide()); + + setTimeout(() => this.$backdrop.addClass('in')); + } +} diff --git a/js/src/common/utils/ItemList.ts b/js/src/common/utils/ItemList.ts index 3481c1c7a..b01c270c9 100644 --- a/js/src/common/utils/ItemList.ts +++ b/js/src/common/utils/ItemList.ts @@ -1,5 +1,5 @@ -class Item { - content: any; +class Item { + content: T; priority: number; key: number = 0; @@ -9,8 +9,8 @@ class Item { } } -export default class ItemList { - private items: { [key: string]: Item } = {}; +export default class ItemList { + private items: { [key: string]: Item } = {}; /** * Check whether the list is empty. @@ -30,9 +30,6 @@ export default class ItemList { /** * Check whether an item is present in the list. - * - * @param key - * @returns {boolean} */ has(key: any): boolean { return !!this.items[key]; @@ -40,12 +37,8 @@ export default class ItemList { /** * Get the content of an item. - * - * @param {String} key - * @return {*} - * @public */ - get(key: any) { + get(key: any): T { return this.items[key].content; } @@ -65,8 +58,8 @@ export default class ItemList { return this; } - toArray(): T[] { - const items: Item[] = []; + toArray(): T[] { + const items: Item[] = []; for (const i in this.items) { if (this.items.hasOwnProperty(i)) { diff --git a/js/src/forum/Forum.ts b/js/src/forum/Forum.ts index 7692b1074..ec63769d9 100644 --- a/js/src/forum/Forum.ts +++ b/js/src/forum/Forum.ts @@ -30,7 +30,7 @@ export default class Forum extends Application { } this.routes[defaultAction].path = '/'; - this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/'); + this.history.push(defaultAction, this.translator.transText('core.forum.header.back_to_index_tooltip'), '/'); // m.mount(document.getElementById('app-navigation'), Navigation.component({className: 'App-backControl', drawer: true})); // m.mount(document.getElementById('header-navigation'), Navigation.component()); @@ -47,7 +47,7 @@ export default class Forum extends Application { // Route the home link back home when clicked. We do not want it to register // if the user is opening it in a new tab, however. - $('#home-link').click(e => { + $('#home-link').click((e: MouseEvent) => { if (e.ctrlKey || e.metaKey || e.which === 2) return; e.preventDefault(); app.history.home(); diff --git a/js/src/forum/components/HeaderSecondary.tsx b/js/src/forum/components/HeaderSecondary.tsx index 411f8510d..7f9b42639 100644 --- a/js/src/forum/components/HeaderSecondary.tsx +++ b/js/src/forum/components/HeaderSecondary.tsx @@ -2,9 +2,9 @@ import Component from '../../common/Component'; import Button from '../../common/components/Button'; // import LogInModal from './LogInModal'; // import SignUpModal from './SignUpModal'; -// import SessionDropdown from './SessionDropdown'; +import SessionDropdown from './SessionDropdown'; import SelectDropdown from '../../common/components/SelectDropdown'; -// import NotificationsDropdown from './NotificationsDropdown'; +import NotificationsDropdown from './NotificationsDropdown'; import ItemList from '../../common/utils/ItemList'; import listItems from '../../common/helpers/listItems'; @@ -32,7 +32,7 @@ export default class HeaderSecondary extends Component { items.add('search', Search.component(), 30); - if (app.forum.attribute("showLanguageSelector") && Object.keys(app.data.locales).length > 0) { + if (app.forum.attribute("showLanguageSelector") && Object.keys(app.data.locales).length > 1) { const locales = []; for (const locale in app.data.locales) { @@ -58,8 +58,8 @@ export default class HeaderSecondary extends Component { } if (app.session.user) { - // items.add('notifications', NotificationsDropdown.component(), 10); - // items.add('session', SessionDropdown.component(), 0); + items.add('notifications', NotificationsDropdown.component(), 10); + items.add('session', SessionDropdown.component(), 0); } else { if (app.forum.attribute('allowSignUp')) { items.add('signUp', diff --git a/js/src/forum/components/NotificationList.tsx b/js/src/forum/components/NotificationList.tsx new file mode 100644 index 000000000..6c1bbbc80 --- /dev/null +++ b/js/src/forum/components/NotificationList.tsx @@ -0,0 +1,201 @@ +import Component from '../../common/Component'; +import listItems from '../../common/helpers/listItems'; +import Button from '../../common/components/Button'; +import LoadingIndicator from '../../common/components/LoadingIndicator'; +import Notification from '../../common/models/Notification'; +import Discussion from '../../common/models/Discussion'; + +/** + * The `NotificationList` component displays a list of the logged-in user's + * notifications, grouped by discussion. + */ +export default class NotificationList extends Component { + /** + * Whether or not the notifications are loading. + */ + loading: boolean = false; + + /** + * Whether or not there are more results that can be loaded. + */ + moreResults: boolean = false; + + private $scrollParent: ZeptoCollection; + private scrollHandler: () => void; + + view() { + const pages = app.cache.notifications || []; + + return ( +
+
+
+ {Button.component({ + className: 'Button Button--icon Button--link', + icon: 'fas fa-check', + title: app.translator.transText('core.forum.notifications.mark_all_as_read_tooltip'), + onclick: this.markAllAsRead.bind(this) + })} +
+ +

{app.translator.trans('core.forum.notifications.title')}

+
+ +
+ {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: any = 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 ( +
+ {group.discussion + ? ( + + {badges && badges.length ?
    {listItems(badges)}
: ''} + {group.discussion.title()} +
+ ) : ( +
+ {app.forum.attribute('title')} +
+ )} + +
    + {group.notifications.map(notification => { + const NotificationComponent = app.notificationComponents[notification.contentType()]; + return NotificationComponent ?
  • {NotificationComponent.component({notification})}
  • : ''; + })} +
+
+ ); + }); + }) : ''} + {this.loading + ? + : (pages.length ? '' :
{app.translator.trans('core.forum.notifications.empty_text')}
)} +
+
+ ); + } + + oncreate(vnode) { + super.oncreate(vnode); + + const $notifications = this.$('.NotificationList-content'); + const $scrollParent = this.$scrollParent = $notifications.css('overflow') === 'auto' ? $notifications : $(window); + + this.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', this.scrollHandler); + } + + onremove(vnode) { + super.onremove(vnode); + + this.$scrollParent.off('scroll', this.scrollHandler); + } + + /** + * Load notifications into the application's cache if they haven't already + * been loaded. + */ + load() { + if (app.session.user.newNotificationCount()) { + delete app.cache.notifications; + } + + if (app.cache.notifications) { + return; + } + + app.session.user.pushAttributes({newNotificationCount: 0}); + + this.loadMore(); + } + + /** + * Load the next page of notification results. + */ + loadMore() { + this.loading = true; + m.redraw(); + + 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; + m.redraw(); + }); + } + + /** + * Parse results and append them to the notification list. + */ + parseResults(results: Notification[]|any): Notification[]|any { + app.cache.notifications = app.cache.notifications || []; + + if (results.length) app.cache.notifications.push(results); + + this.moreResults = !!results.payload.links.next; + + return results; + } + + /** + * Mark all of the notifications as read. + */ + markAllAsRead() { + if (!app.cache.notifications) return; + + app.session.user.pushAttributes({unreadNotificationCount: 0}); + + app.cache.notifications.forEach(notifications => { + notifications.forEach(notification => notification.pushAttributes({isRead: true})) + }); + + app.request({ + url: app.forum.attribute('apiUrl') + '/notifications/read', + method: 'POST' + }); + } +} diff --git a/js/src/forum/components/NotificationsDropdown.tsx b/js/src/forum/components/NotificationsDropdown.tsx new file mode 100644 index 000000000..0491d3b18 --- /dev/null +++ b/js/src/forum/components/NotificationsDropdown.tsx @@ -0,0 +1,73 @@ +import Dropdown from '../../common/components/Dropdown'; +import icon from '../../common/helpers/icon'; +import NotificationList from './NotificationList'; + +export default class NotificationsDropdown extends Dropdown { + list = new NotificationList(); + + static initProps(props) { + props.className = props.className || 'NotificationsDropdown'; + props.buttonClassName = props.buttonClassName || 'Button Button--flat'; + props.menuClassName = props.menuClassName || 'Dropdown-menu--right'; + props.label = props.label || app.translator.trans('core.forum.notifications.tooltip'); + props.icon = props.icon || 'fas fa-bell'; + + super.initProps(props); + } + + getButton() { + const newNotifications = this.getNewCount(); + const vdom = super.getButton(); + + vdom.attrs.title = this.props.label; + + vdom.attrs.className += (newNotifications ? ' new' : ''); + vdom.attrs.onclick = this.onclick.bind(this); + + return vdom; + } + + getButtonContent() { + const unread = this.getUnreadCount(); + + return [ + icon(this.props.icon, {className: 'Button-icon'}), + unread ? {unread} : '', + {this.props.label} + ]; + } + + getMenu() { + return ( +
+ {this.showing ? m(this.list) : ''} +
+ ); + } + + onclick() { + if (app.drawer.isOpen()) { + this.goToRoute(); + } else { + this.list.load(); + } + } + + goToRoute() { + m.route(app.route('notifications')); + } + + getUnreadCount() { + return app.session.user.unreadNotificationCount(); + } + + getNewCount() { + return app.session.user.newNotificationCount(); + } + + menuClick(e) { + // Don't close the notifications dropdown if the user is opening a link in a + // new tab or window. + if (e.shiftKey || e.metaKey || e.ctrlKey || e.which === 2) e.stopPropagation(); + } +} diff --git a/js/src/forum/components/Search.tsx b/js/src/forum/components/Search.tsx index b0d621651..456b3edf4 100644 --- a/js/src/forum/components/Search.tsx +++ b/js/src/forum/components/Search.tsx @@ -7,6 +7,8 @@ import DiscussionsSearchSource from './DiscussionsSearchSource'; import UsersSearchSource from './UsersSearchSource'; import SearchSource from './SearchSource'; +import Stream from 'mithril/stream'; + /** * The `Search` component displays a menu of as-you-type results from a variety * of sources. @@ -20,7 +22,7 @@ export default class Search extends Component { /** * The value of the search input. */ - value: Function = m.prop(''); + value: Stream = m.prop(''); /** * Whether or not the search input has focus. @@ -72,8 +74,6 @@ export default class Search extends Component { // Hide the search view if no sources were loaded if (!this.sources.length) return
; - console.log('Search#view - loading:', this.loadingSources) - return (
{username(user)} + ]; + } + + /** + * Build an item list for the contents of the dropdown menu. + */ + items(): ItemList { + const items = new ItemList(); + const user = app.session.user; + + // items.add('profile', + // LinkButton.component({ + // icon: 'fas fa-user', + // children: app.translator.trans('core.forum.header.profile_button'), + // href: app.route.user(user) + // }), + // 100 + // ); + + // items.add('settings', + // LinkButton.component({ + // icon: 'fas fa-cog', + // children: app.translator.trans('core.forum.header.settings_button'), + // href: app.route('settings') + // }), + // 50 + // ); + + if (app.forum.attribute('adminUrl')) { + items.add('administration', + LinkButton.component({ + icon: 'fas fa-wrench', + children: app.translator.trans('core.forum.header.admin_button'), + href: app.forum.attribute('adminUrl'), + target: '_blank', + config: () => {} + }), + 0 + ); + } + + items.add('separator', Separator.component(), -90); + + items.add('logOut', + Button.component({ + icon: 'fas fa-sign-out-alt', + children: app.translator.trans('core.forum.header.log_out_button'), + onclick: app.session.logout.bind(app.session) + }), + -100 + ); + + return items; + } +}