1
0
mirror of https://github.com/flarum/core.git synced 2025-08-04 15:37:51 +02:00

Add notifications, and frontend framework rewrite changes changelog file

This commit is contained in:
David Sevilla Martin
2019-10-22 18:19:32 -04:00
parent 46eab64f41
commit c6bcb79541
12 changed files with 482 additions and 30 deletions

View File

@@ -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`

View File

@@ -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'), <ModalManager />);
// this.alerts = m.mount(document.getElementById('alerts'), <AlertManager />);
// 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);

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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 = $('<div/>')
.addClass('drawer-backdrop fade')
.appendTo('body')
.click(() => this.hide());
setTimeout(() => this.$backdrop.addClass('in'));
}
}

View File

@@ -1,5 +1,5 @@
class Item {
content: any;
class Item<T> {
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<T = any> {
private items: { [key: string]: Item<T> } = {};
/**
* 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>(): T[] {
const items: Item[] = [];
toArray(): T[] {
const items: Item<T>[] = [];
for (const i in this.items) {
if (this.items.hasOwnProperty(i)) {

View File

@@ -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();

View File

@@ -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',

View File

@@ -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 (
<div className="NotificationList">
<div className="NotificationList-header">
<div className="App-primaryControl">
{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)
})}
</div>
<h4 className="App-titleControl App-titleControl--text">{app.translator.trans('core.forum.notifications.title')}</h4>
</div>
<div className="NotificationList-content">
{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 (
<div className="NotificationGroup">
{group.discussion
? (
<a className="NotificationGroup-header"
href={app.route.discussion(group.discussion)}
config={m.route}>
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
{group.discussion.title()}
</a>
) : (
<div className="NotificationGroup-header">
{app.forum.attribute('title')}
</div>
)}
<ul className="NotificationGroup-content">
{group.notifications.map(notification => {
const NotificationComponent = app.notificationComponents[notification.contentType()];
return NotificationComponent ? <li>{NotificationComponent.component({notification})}</li> : '';
})}
</ul>
</div>
);
});
}) : ''}
{this.loading
? <LoadingIndicator className="LoadingIndicator--block" />
: (pages.length ? '' : <div className="NotificationList-empty">{app.translator.trans('core.forum.notifications.empty_text')}</div>)}
</div>
</div>
);
}
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'
});
}
}

View File

@@ -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 ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
<span className="Button-label">{this.props.label}</span>
];
}
getMenu() {
return (
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? m(this.list) : ''}
</div>
);
}
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();
}
}

View File

@@ -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<string> = 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 <div/>;
console.log('Search#view - loading:', this.loadingSources)
return (
<div className={'Search ' + classNames({
open: this.value() && this.hasFocus,

View File

@@ -0,0 +1,89 @@
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import Dropdown from '../../common/components/Dropdown';
import LinkButton from '../../common/components/LinkButton';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
import Separator from '../../common/components/Separator';
import Group from '../../common/models/Group';
/**
* The `SessionDropdown` component shows a button with the current user's
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
props.className = 'SessionDropdown';
props.buttonClassName = 'Button Button--user Button--flat';
props.menuClassName = 'Dropdown-menu--right';
}
view() {
this.props.children = this.items().toArray();
return super.view();
}
getButtonContent() {
const user = app.session.user;
return [
avatar(user), ' ',
<span className="Button-label">{username(user)}</span>
];
}
/**
* 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;
}
}