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 ? : ''}
+ {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;
+ }
+}