1
0
mirror of https://github.com/flarum/core.git synced 2025-10-22 20:26:15 +02:00

Mithril 2 update (#2255)

* Update frontend to Mithril 2

- Update Mithril version to v2.0.4
- Add Typescript typings for Mithril
- Rename "props" to "attrs"; "initProps" to "initAttrs"; "m.prop" to "m.stream"; "m.withAttr" to "utils/withAttr".
- Use Mithril 2's new lifecycle hooks
- SubtreeRetainer has been rewritten to be more useful for the new system
- Utils for forcing page re-initializations have been added (force attr in links, setRouteWithForcedRefresh util)
- Other mechanical changes, following the upgrade guide
- Remove some of the custom stuff in our Component base class
- Introduce "fragments" for non-components that control their own DOM
- Remove Mithril patches, introduce a few new ones (route attrs in <a>; 
- Redesign AlertManagerState `show` with 3 overloads: `show(children)`, `show(attrs, children)`, `show(componentClass, attrs, children)`
- The `affixedSidebar` util has been replaced with an `AffixedSidebar` component

Challenges:
- `children` and `tag` are now reserved, and can not be used as attr names
- Behavior of links to current page changed in Mithril. If moving to a page that is handled by the same component, the page component WILL NOT be re-initialized by default. Additional code to keep track of the current url is needed (See IndexPage, DiscussionPage, and UserPage for examples)
- Native Promise rejections are shown on console when not handled
- Instances of components can no longer be stored. The state pattern should be used instead.

Refs #1821.

Co-authored-by: Alexander Skvortsov <sasha.skvortsov109@gmail.com>
Co-authored-by: Matthew Kilgore <tankerkiller125@gmail.com>
Co-authored-by: Franz Liedke <franz@develophp.org>
This commit is contained in:
David Sevilla Martín
2020-09-23 22:40:37 -04:00
committed by GitHub
parent 1321b8cc28
commit 71f3379fcc
127 changed files with 2411 additions and 2074 deletions

View File

@@ -115,15 +115,15 @@ export default class ForumApplication extends Application {
this.routes[defaultAction].path = '/';
this.history.push(defaultAction, this.translator.trans('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());
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
m.mount(document.getElementById('composer'), Composer.component({ state: this.composer }));
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
this.pane = new Pane(document.getElementById('app'));
m.route.mode = 'pathname';
m.route.prefix = '';
super.mount(this.forum.attribute('basePath'));
alertEmailConfirmation(this);
@@ -161,8 +161,8 @@ export default class ForumApplication extends Application {
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
* with the provided details.
*
* @param {Object} payload A dictionary of props to pass into the sign up
* modal. A truthy `loggedIn` prop indicates that the user has logged
* @param {Object} payload A dictionary of attrs to pass into the sign up
* modal. A truthy `loggedIn` attr indicates that the user has logged
* in, and thus the page is reloaded.
* @public
*/

View File

@@ -3,7 +3,6 @@ import compat from '../common/compat';
import PostControls from './utils/PostControls';
import KeyboardNavigatable from './utils/KeyboardNavigatable';
import slidable from './utils/slidable';
import affixSidebar from './utils/affixSidebar';
import History from './utils/History';
import DiscussionControls from './utils/DiscussionControls';
import alertEmailConfirmation from './utils/alertEmailConfirmation';
@@ -15,6 +14,7 @@ import GlobalSearchState from './states/GlobalSearchState';
import NotificationListState from './states/NotificationListState';
import PostStreamState from './states/PostStreamState';
import SearchState from './states/SearchState';
import AffixedSidebar from './components/AffixedSidebar';
import DiscussionPage from './components/DiscussionPage';
import LogInModal from './components/LogInModal';
import ComposerBody from './components/ComposerBody';
@@ -61,6 +61,7 @@ import NotificationList from './components/NotificationList';
import WelcomeHero from './components/WelcomeHero';
import SignUpModal from './components/SignUpModal';
import CommentPost from './components/CommentPost';
import ComposerPostPreview from './components/ComposerPostPreview';
import ReplyComposer from './components/ReplyComposer';
import NotificationsPage from './components/NotificationsPage';
import PostStreamScrubber from './components/PostStreamScrubber';
@@ -77,7 +78,6 @@ export default Object.assign(compat, {
'utils/PostControls': PostControls,
'utils/KeyboardNavigatable': KeyboardNavigatable,
'utils/slidable': slidable,
'utils/affixSidebar': affixSidebar,
'utils/History': History,
'utils/DiscussionControls': DiscussionControls,
'utils/alertEmailConfirmation': alertEmailConfirmation,
@@ -89,6 +89,7 @@ export default Object.assign(compat, {
'states/NotificationListState': NotificationListState,
'states/PostStreamState': PostStreamState,
'states/SearchState': SearchState,
'components/AffixedSidebar': AffixedSidebar,
'components/DiscussionPage': DiscussionPage,
'components/LogInModal': LogInModal,
'components/ComposerBody': ComposerBody,
@@ -135,6 +136,7 @@ export default Object.assign(compat, {
'components/WelcomeHero': WelcomeHero,
'components/SignUpModal': SignUpModal,
'components/CommentPost': CommentPost,
'components/ComposerPostPreview': ComposerPostPreview,
'components/ReplyComposer': ReplyComposer,
'components/NotificationsPage': NotificationsPage,
'components/PostStreamScrubber': PostStreamScrubber,

View File

@@ -0,0 +1,51 @@
import Component from '../../common/Component';
/**
* The `AffixedSidebar` component uses Bootstrap's "affix" plugin to keep a
* sidebar navigation at the top of the viewport when scrolling.
*
* ### Children
*
* The component must wrap an element that itself wraps an <ul> element, which
* will be "affixed".
*
* @see https://getbootstrap.com/docs/3.4/javascript/#affix
*/
export default class AffixedSidebar extends Component {
view(vnode) {
return vnode.children[0];
}
oncreate(vnode) {
super.oncreate(vnode);
// Register the affix plugin to execute on every window resize (and trigger)
this.boundOnresize = this.onresize.bind(this);
$(window).on('resize', this.boundOnresize).resize();
}
onremove() {
$(window).off('resize', this.boundOnresize);
}
onresize() {
const $sidebar = this.$();
const $header = $('#header');
const $footer = $('#footer');
const $affixElement = $sidebar.find('> ul');
$(window).off('.affix');
$affixElement.removeClass('affix affix-top affix-bottom').removeData('bs.affix');
// Don't affix the sidebar if it is taller than the viewport (otherwise
// there would be no way to scroll through its content).
if ($sidebar.outerHeight(true) > $(window).height() - $header.outerHeight(true)) return;
$affixElement.affix({
offset: {
top: () => $sidebar.offset().top - $header.outerHeight(true) - parseInt($sidebar.css('margin-top'), 10),
bottom: () => (this.bottom = $footer.outerHeight(true)),
},
});
}
}

View File

@@ -3,6 +3,7 @@ import avatar from '../../common/helpers/avatar';
import icon from '../../common/helpers/icon';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import classList from '../../common/utils/classList';
import Button from '../../common/components/Button';
import LoadingIndicator from '../../common/components/LoadingIndicator';
@@ -10,13 +11,15 @@ import LoadingIndicator from '../../common/components/LoadingIndicator';
* The `AvatarEditor` component displays a user's avatar along with a dropdown
* menu which allows the user to upload/remove the avatar.
*
* ### Props
* ### Attrs
*
* - `className`
* - `user`
*/
export default class AvatarEditor extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* Whether or not an avatar upload is in progress.
*
@@ -32,17 +35,11 @@ export default class AvatarEditor extends Component {
this.isDraggedOver = false;
}
static initProps(props) {
super.initProps(props);
props.className = props.className || '';
}
view() {
const user = this.props.user;
const user = this.attrs.user;
return (
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '') + (this.isDraggedOver ? ' dragover' : '')}>
<div className={classList(['AvatarEditor', 'Dropdown', this.attrs.className, this.loading && 'loading', this.isDraggedOver && 'dragover'])}>
{avatar(user)}
<a
className={user.avatarUrl() ? 'Dropdown-toggle' : 'Dropdown-toggle AvatarEditor--noAvatar'}
@@ -55,7 +52,7 @@ export default class AvatarEditor extends Component {
ondragend={this.disableDragover.bind(this)}
ondrop={this.dropUpload.bind(this)}
>
{this.loading ? LoadingIndicator.component() : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
{this.loading ? <LoadingIndicator /> : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
</a>
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
</div>
@@ -72,20 +69,16 @@ export default class AvatarEditor extends Component {
items.add(
'upload',
Button.component({
icon: 'fas fa-upload',
children: app.translator.trans('core.forum.user.avatar_upload_button'),
onclick: this.openPicker.bind(this),
})
<Button icon="fas fa-upload" onclick={this.openPicker.bind(this)}>
{app.translator.trans('core.forum.user.avatar_upload_button')}
</Button>
);
items.add(
'remove',
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.user.avatar_remove_button'),
onclick: this.remove.bind(this),
})
<Button icon="fas fa-times" onclick={this.remove.bind(this)}>
{app.translator.trans('core.forum.user.avatar_remove_button')}
</Button>
);
return items;
@@ -134,7 +127,7 @@ export default class AvatarEditor extends Component {
* @param {Event} e
*/
quickUpload(e) {
if (!this.props.user.avatarUrl()) {
if (!this.attrs.user.avatarUrl()) {
e.preventDefault();
e.stopPropagation();
this.openPicker();
@@ -149,7 +142,6 @@ export default class AvatarEditor extends Component {
// Create a hidden HTML input element and click on it so the user can select
// an avatar file. Once they have, we will upload it via the API.
const user = this.props.user;
const $input = $('<input type="file">');
$input
@@ -169,7 +161,7 @@ export default class AvatarEditor extends Component {
upload(file) {
if (this.loading) return;
const user = this.props.user;
const user = this.attrs.user;
const data = new FormData();
data.append('avatar', file);
@@ -179,9 +171,9 @@ export default class AvatarEditor extends Component {
app
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
serialize: (raw) => raw,
data,
body: data,
})
.then(this.success.bind(this), this.failure.bind(this));
}
@@ -190,7 +182,7 @@ export default class AvatarEditor extends Component {
* Remove the user's avatar.
*/
remove() {
const user = this.props.user;
const user = this.attrs.user;
this.loading = true;
m.redraw();
@@ -198,7 +190,7 @@ export default class AvatarEditor extends Component {
app
.request({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
})
.then(this.success.bind(this), this.failure.bind(this));
}
@@ -212,7 +204,7 @@ export default class AvatarEditor extends Component {
*/
success(response) {
app.store.pushPayload(response);
delete this.props.user.avatarColor;
delete this.attrs.user.avatarColor;
this.loading = false;
m.redraw();

View File

@@ -6,8 +6,8 @@ import Button from '../../common/components/Button';
* to change their email address.
*/
export default class ChangeEmailModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* Whether or not the email has been changed successfully.
@@ -21,14 +21,14 @@ export default class ChangeEmailModal extends Modal {
*
* @type {function}
*/
this.email = m.prop(app.session.user.email());
this.email = m.stream(app.session.user.email());
/**
* The value of the password input.
*
* @type {function}
*/
this.password = m.prop('');
this.password = m.stream('');
}
className() {
@@ -81,12 +81,14 @@ export default class ChangeEmailModal extends Modal {
/>
</div>
<div className="Form-group">
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.change_email.submit_button'),
})}
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.change_email.submit_button')
)}
</div>
</div>
</div>
@@ -122,7 +124,7 @@ export default class ChangeEmailModal extends Modal {
onerror(error) {
if (error.status === 401) {
error.alert.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
error.alert.content = app.translator.trans('core.forum.change_email.incorrect_password_message');
}
super.onerror(error);

View File

@@ -20,12 +20,14 @@ export default class ChangePasswordModal extends Modal {
<div className="Form Form--centered">
<p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p>
<div className="Form-group">
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.change_password.send_button'),
})}
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.change_password.send_button')
)}
</div>
</div>
</div>
@@ -41,7 +43,7 @@ export default class ChangePasswordModal extends Modal {
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot',
data: { email: app.session.user.email() },
body: { email: app.session.user.email() },
})
.then(this.hide.bind(this), this.loaded.bind(this));
}

View File

@@ -1,5 +1,3 @@
/*global s9e, hljs*/
import Post from './Post';
import classList from '../../common/utils/classList';
import PostUser from './PostUser';
@@ -9,19 +7,20 @@ import EditPostComposer from './EditPostComposer';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
import ComposerPostPreview from './ComposerPostPreview';
/**
* The `CommentPost` component displays a standard `comment`-typed post. This
* includes a number of item lists (controls, header, and footer) surrounding
* the post's HTML content.
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class CommentPost extends Post {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* If the post has been hidden, then this flag determines whether or not its
@@ -41,48 +40,46 @@ export default class CommentPost extends Post {
this.subtree.check(
() => this.cardVisible,
() => this.isEditing()
() => this.isEditing(),
() => this.revealContent
);
}
content() {
// Note: we avoid using JSX for the <ul> below because it results in some
// weirdness in Mithril.js 0.1.x (see flarum/core#975). This workaround can
// be reverted when we upgrade to Mithril 1.0.
return super
.content()
.concat([
<header className="Post-header">{m('ul', listItems(this.headerItems().toArray()))}</header>,
<div className="Post-body">
{this.isEditing() ? <div className="Post-preview" config={this.configPreview.bind(this)} /> : m.trust(this.props.post.contentHtml())}
</div>,
]);
return super.content().concat([
<header className="Post-header">
<ul>{listItems(this.headerItems().toArray())}</ul>
</header>,
<div className="Post-body">
{this.isEditing() ? <ComposerPostPreview className="Post-preview" composer={app.composer} /> : m.trust(this.attrs.post.contentHtml())}
</div>,
]);
}
config(isInitialized, context) {
super.config(...arguments);
onupdate(vnode) {
super.onupdate();
const contentHtml = this.isEditing() ? '' : this.props.post.contentHtml();
const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
// If the post content has changed since the last render, we'll run through
// all of the <script> tags in the content and evaluate them. This is
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
if (context.contentHtml !== contentHtml) {
if (this.contentHtml !== contentHtml) {
this.$('.Post-body script').each(function () {
eval.call(window, $(this).text());
});
}
context.contentHtml = contentHtml;
this.contentHtml = contentHtml;
}
isEditing() {
return app.composer.bodyMatches(EditPostComposer, { post: this.props.post });
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
}
attrs() {
const post = this.props.post;
const attrs = super.attrs();
elementAttrs() {
const post = this.attrs.post;
const attrs = super.elementAttrs();
attrs.className =
(attrs.className || '') +
@@ -98,27 +95,6 @@ export default class CommentPost extends Post {
return attrs;
}
configPreview(element, isInitialized, context) {
if (isInitialized) return;
// Every 50ms, if the composer content has changed, then update the post's
// body with a preview.
let preview;
const updatePreview = () => {
const content = app.composer.fields.content();
if (preview === content) return;
preview = content;
s9e.TextFormatter.preview(preview || '', element);
};
updatePreview();
const updateInterval = setInterval(updatePreview, 50);
context.onunload = () => clearInterval(updateInterval);
}
/**
* Toggle the visibility of a hidden post's content.
*/
@@ -133,7 +109,7 @@ export default class CommentPost extends Post {
*/
headerItems() {
const items = new ItemList();
const post = this.props.post;
const post = this.attrs.post;
items.add(
'user',

View File

@@ -11,13 +11,15 @@ import ComposerState from '../states/ComposerState';
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
*/
export default class Composer extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* The composer's "state".
*
* @type {ComposerState}
*/
this.state = this.props.state;
this.state = this.attrs.state;
/**
* Whether or not the composer currently has focus.
@@ -45,7 +47,7 @@ export default class Composer extends Component {
return (
<div className={'Composer ' + classList(classes)}>
<div className="Composer-handle" config={this.configHandle.bind(this)} />
<div className="Composer-handle" oncreate={this.configHandle.bind(this)} />
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
<div className="Composer-content" onclick={showIfMinimized}>
{body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''}
@@ -54,7 +56,7 @@ export default class Composer extends Component {
);
}
config(isInitialized, context) {
onupdate() {
if (this.state.position === this.prevPosition) {
// Set the height of the Composer element and its contents on each redraw,
// so that they do not lose it if their DOM elements are recreated.
@@ -64,12 +66,10 @@ export default class Composer extends Component {
this.prevPosition = this.state.position;
}
}
if (isInitialized) return;
// Since this component is a part of the global UI that persists between
// routes, we will flag the DOM to be retained across route changes.
context.retain = true;
oncreate(vnode) {
super.oncreate(vnode);
this.initializeHeight();
this.$().hide().css('bottom', -this.state.computedHeight());
@@ -84,36 +84,31 @@ export default class Composer extends Component {
// When the escape key is pressed on any inputs, close the composer.
this.$().on('keydown', ':input', 'esc', () => this.state.close());
const handlers = {};
this.handlers = {};
$(window)
.on('resize', (handlers.onresize = this.updateHeight.bind(this)))
.on('resize', (this.handlers.onresize = this.updateHeight.bind(this)))
.resize();
$(document)
.on('mousemove', (handlers.onmousemove = this.onmousemove.bind(this)))
.on('mouseup', (handlers.onmouseup = this.onmouseup.bind(this)));
.on('mousemove', (this.handlers.onmousemove = this.onmousemove.bind(this)))
.on('mouseup', (this.handlers.onmouseup = this.onmouseup.bind(this)));
}
context.onunload = () => {
$(window).off('resize', handlers.onresize);
onremove() {
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove', handlers.onmousemove).off('mouseup', handlers.onmouseup);
};
$(document).off('mousemove', this.handlers.onmousemove).off('mouseup', this.handlers.onmouseup);
}
/**
* Add the necessary event handlers to the composer's handle so that it can
* be used to resize the composer.
*
* @param {DOMElement} element
* @param {Boolean} isInitialized
*/
configHandle(element, isInitialized) {
if (isInitialized) return;
configHandle(vnode) {
const composer = this;
$(element)
$(vnode.dom)
.css('cursor', 'row-resize')
.bind('dragstart mousedown', (e) => e.preventDefault())
.mousedown(function (e) {

View File

@@ -11,7 +11,7 @@ import ItemList from '../../common/utils/ItemList';
* composer. Subclasses should implement the `onsubmit` method and override
* `headerTimes`.
*
* ### Props
* ### Attrs
*
* - `composer`
* - `originalContent`
@@ -24,8 +24,10 @@ import ItemList from '../../common/utils/ItemList';
* @abstract
*/
export default class ComposerBody extends Component {
init() {
this.composer = this.props.composer;
oninit(vnode) {
super.oninit(vnode);
this.composer = this.attrs.composer;
/**
* Whether or not the component is loading.
@@ -37,11 +39,11 @@ export default class ComposerBody extends Component {
// Let the composer state know to ask for confirmation under certain
// circumstances, if the body supports / requires it and has a corresponding
// confirmation question to ask.
if (this.props.confirmExit) {
this.composer.preventClosingWhen(() => this.hasChanges(), this.props.confirmExit);
if (this.attrs.confirmExit) {
this.composer.preventClosingWhen(() => this.hasChanges(), this.attrs.confirmExit);
}
this.composer.fields.content(this.props.originalContent || '');
this.composer.fields.content(this.attrs.originalContent || '');
/**
* @deprecated BC layer, remove in Beta 15.
@@ -53,15 +55,15 @@ export default class ComposerBody extends Component {
view() {
return (
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
<div className={'ComposerBody ' + (this.props.className || '')}>
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
<div className={'ComposerBody ' + (this.attrs.className || '')}>
{avatar(this.attrs.user, { className: 'ComposerBody-avatar' })}
<div className="ComposerBody-content">
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
<div className="ComposerBody-editor">
{TextEditor.component({
submitLabel: this.props.submitLabel,
placeholder: this.props.placeholder,
disabled: this.loading || this.props.disabled,
submitLabel: this.attrs.submitLabel,
placeholder: this.attrs.placeholder,
disabled: this.loading || this.attrs.disabled,
composer: this.composer,
preview: this.jumpToPreview && this.jumpToPreview.bind(this),
onchange: this.composer.fields.content,
@@ -84,7 +86,7 @@ export default class ComposerBody extends Component {
hasChanges() {
const content = this.composer.fields.content();
return content && content !== this.props.originalContent;
return content && content !== this.attrs.originalContent;
}
/**

View File

@@ -5,9 +5,9 @@ import Button from '../../common/components/Button';
* controls.
*/
export default class ComposerButton extends Button {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = props.className || 'Button Button--icon Button--link';
attrs.className = attrs.className || 'Button Button--icon Button--link';
}
}

View File

@@ -0,0 +1,54 @@
/*global s9e*/
import Component from '../../common/Component';
/**
* The `ComposerPostPreview` component renders Markdown as HTML using the
* TextFormatter library, polling a data source for changes every 50ms. This is
* done to prevent expensive redraws on e.g. every single keystroke, while
* still retaining the perception of live updates for the user.
*
* ### Attrs
*
* - `composer` The state of the composer controlling this preview.
* - `className` A CSS class for the element surrounding the preview.
* - `surround` A callback that can execute code before and after re-render, e.g. for scroll anchoring.
*/
export default class ComposerPostPreview extends Component {
static initAttrs(attrs) {
attrs.className = attrs.className || '';
attrs.surround = attrs.surround || ((preview) => preview());
}
view() {
return <div className={this.attrs.className} />;
}
oncreate(vnode) {
super.oncreate(vnode);
// Every 50ms, if the composer content has changed, then update the post's
// body with a preview.
let preview;
const updatePreview = () => {
// Since we're polling, the composer may have been closed in the meantime,
// so we bail in that case.
if (!this.attrs.composer.isVisible()) return;
const content = this.attrs.composer.fields.content();
if (preview === content) return;
preview = content;
this.attrs.surround(() => s9e.TextFormatter.preview(preview || '', vnode.dom));
};
updatePreview();
this.updateInterval = setInterval(updatePreview, 50);
}
onremove() {
clearInterval(this.updateInterval);
}
}

View File

@@ -7,16 +7,26 @@ import extractText from '../../common/utils/extractText';
* enter the title of their discussion. It also overrides the `submit` and
* `willExit` actions to account for the title.
*
* ### Props
* ### Attrs
*
* - All of the props for ComposerBody
* - All of the attrs for ComposerBody
* - `titlePlaceholder`
*/
export default class DiscussionComposer extends ComposerBody {
init() {
super.init();
static initAttrs(attrs) {
super.initAttrs(attrs);
this.composer.fields.title = this.composer.fields.title || m.prop('');
attrs.placeholder = attrs.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
attrs.confirmExit = attrs.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
attrs.titlePlaceholder = attrs.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
attrs.className = 'ComposerBody--discussion';
}
oninit(vnode) {
super.oninit(vnode);
this.composer.fields.title = this.composer.fields.title || m.stream('');
/**
* The value of the title input.
@@ -26,16 +36,6 @@ export default class DiscussionComposer extends ComposerBody {
this.title = this.composer.fields.title;
}
static initProps(props) {
super.initProps(props);
props.placeholder = props.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
props.confirmExit = props.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
props.titlePlaceholder = props.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
props.className = 'ComposerBody--discussion';
}
headerItems() {
const items = super.headerItems();
@@ -46,10 +46,9 @@ export default class DiscussionComposer extends ComposerBody {
<h3>
<input
className="FormControl"
value={this.title()}
oninput={m.withAttr('value', this.title)}
placeholder={this.props.titlePlaceholder}
disabled={!!this.props.disabled}
bidi={this.title}
placeholder={this.attrs.titlePlaceholder}
disabled={!!this.attrs.disabled}
onkeydown={this.onkeydown.bind(this)}
/>
</h3>
@@ -71,7 +70,7 @@ export default class DiscussionComposer extends ComposerBody {
this.composer.editor.moveCursorTo(0);
}
m.redraw.strategy('none');
e.redraw = false;
}
hasChanges() {
@@ -101,7 +100,7 @@ export default class DiscussionComposer extends ComposerBody {
.then((discussion) => {
this.composer.hide();
app.discussions.refresh();
m.route(app.route.discussion(discussion));
m.route.set(app.route.discussion(discussion));
}, this.loaded.bind(this));
}
}

View File

@@ -5,7 +5,7 @@ import listItems from '../../common/helpers/listItems';
/**
* The `DiscussionHero` component displays the hero on a discussion page.
*
* ### Props
* ### attrs
*
* - `discussion`
*/
@@ -27,7 +27,7 @@ export default class DiscussionHero extends Component {
*/
items() {
const items = new ItemList();
const discussion = this.props.discussion;
const discussion = this.attrs.discussion;
const badges = discussion.badges().toArray();
if (badges.length) {

View File

@@ -7,17 +7,13 @@ import Placeholder from '../../common/components/Placeholder';
/**
* The `DiscussionList` component displays a list of discussions.
*
* ### Props
* ### Attrs
*
* - `state` A DiscussionListState object that represents the discussion lists's state.
*/
export default class DiscussionList extends Component {
init() {
this.state = this.props.state;
}
view() {
const state = this.state;
const state = this.attrs.state;
const params = state.getParams();
let loading;
@@ -25,11 +21,13 @@ export default class DiscussionList extends Component {
if (state.isLoading()) {
loading = LoadingIndicator.component();
} else if (state.moreResults) {
loading = Button.component({
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
className: 'Button',
onclick: state.loadMore.bind(state),
});
loading = Button.component(
{
className: 'Button',
onclick: state.loadMore.bind(state),
},
app.translator.trans('core.forum.discussion_list.load_more_button')
);
}
if (state.empty()) {

View File

@@ -8,7 +8,6 @@ import ItemList from '../../common/utils/ItemList';
import abbreviateNumber from '../../common/utils/abbreviateNumber';
import Dropdown from '../../common/components/Dropdown';
import TerminalPost from './TerminalPost';
import PostPreview from './PostPreview';
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
import DiscussionControls from '../utils/DiscussionControls';
import slidable from '../utils/slidable';
@@ -20,13 +19,15 @@ import { escapeRegExp } from 'lodash-es';
* The `DiscussionListItem` component shows a single discussion in the
* discussion list.
*
* ### Props
* ### Attrs
*
* - `discussion`
* - `params`
*/
export default class DiscussionListItem extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* Set up a subtree retainer so that the discussion will not be redrawn
* unless new data comes in.
@@ -34,7 +35,7 @@ export default class DiscussionListItem extends Component {
* @type {SubtreeRetainer}
*/
this.subtree = new SubtreeRetainer(
() => this.props.discussion.freshness,
() => this.attrs.discussion.freshness,
() => {
const time = app.session.user && app.session.user.markedAllAsReadAt();
return time && time.getTime();
@@ -43,37 +44,33 @@ export default class DiscussionListItem extends Component {
);
}
attrs() {
elementAttrs() {
return {
className: classList([
'DiscussionListItem',
this.active() ? 'active' : '',
this.props.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
this.attrs.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
]),
};
}
view() {
const retain = this.subtree.retain();
if (retain) return retain;
const discussion = this.props.discussion;
const discussion = this.attrs.discussion;
const user = discussion.user();
const isUnread = discussion.isUnread();
const isRead = discussion.isRead();
const showUnread = !this.showRepliesCount() && isUnread;
let jumpTo = 0;
const controls = DiscussionControls.controls(discussion, this).toArray();
const attrs = this.attrs();
const attrs = this.elementAttrs();
if (this.props.params.q) {
if (this.attrs.params.q) {
const post = discussion.mostRelevantPost();
if (post) {
jumpTo = post.number();
}
const phrase = escapeRegExp(this.props.params.q);
const phrase = escapeRegExp(this.attrs.params.q);
this.highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi');
} else {
jumpTo = Math.min(discussion.lastPostNumber(), (discussion.lastReadPostNumber() || 0) + 1);
@@ -82,12 +79,14 @@ export default class DiscussionListItem extends Component {
return (
<div {...attrs}>
{controls.length
? Dropdown.component({
icon: 'fas fa-ellipsis-v',
children: controls,
className: 'DiscussionListItem-controls',
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
})
? Dropdown.component(
{
icon: 'fas fa-ellipsis-v',
className: 'DiscussionListItem-controls',
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
},
controls
)
: ''}
<a
@@ -99,14 +98,13 @@ export default class DiscussionListItem extends Component {
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
<a
href={user ? app.route.user(user) : '#'}
route={user ? app.route.user(user) : '#'}
className="DiscussionListItem-author"
title={extractText(
app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) })
)}
config={function (element) {
$(element).tooltip({ placement: 'right' });
m.route.apply(this, arguments);
oncreate={function (vnode) {
$(vnode.dom).tooltip({ placement: 'right' });
}}
>
{avatar(user, { title: '' })}
@@ -114,7 +112,7 @@ export default class DiscussionListItem extends Component {
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>
<a href={app.route.discussion(discussion, jumpTo)} config={m.route} className="DiscussionListItem-main">
<a route={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3>
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
</a>
@@ -131,8 +129,8 @@ export default class DiscussionListItem extends Component {
);
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
// If we're on a touch device, set up the discussion row to be slidable.
// This allows the user to drag the row to either side of the screen to
@@ -144,6 +142,12 @@ export default class DiscussionListItem extends Component {
}
}
onbeforeupdate(vnode, old) {
super.onbeforeupdate(vnode, old);
return this.subtree.needsRebuild();
}
/**
* Determine whether or not the discussion is currently being viewed.
*
@@ -152,7 +156,7 @@ export default class DiscussionListItem extends Component {
active() {
const idParam = m.route.param('id');
return idParam && idParam.split('-')[0] === this.props.discussion.id();
return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
}
/**
@@ -163,7 +167,7 @@ export default class DiscussionListItem extends Component {
* @return {Boolean}
*/
showFirstPost() {
return ['newest', 'oldest'].indexOf(this.props.params.sort) !== -1;
return ['newest', 'oldest'].indexOf(this.attrs.params.sort) !== -1;
}
/**
@@ -173,14 +177,14 @@ export default class DiscussionListItem extends Component {
* @return {Boolean}
*/
showRepliesCount() {
return this.props.params.sort === 'replies';
return this.attrs.params.sort === 'replies';
}
/**
* Mark the discussion as read.
*/
markAsRead() {
const discussion = this.props.discussion;
const discussion = this.attrs.discussion;
if (discussion.isUnread()) {
discussion.save({ lastReadPostNumber: discussion.lastPostNumber() });
@@ -197,8 +201,8 @@ export default class DiscussionListItem extends Component {
infoItems() {
const items = new ItemList();
if (this.props.params.q) {
const post = this.props.discussion.mostRelevantPost() || this.props.discussion.firstPost();
if (this.attrs.params.q) {
const post = this.attrs.discussion.mostRelevantPost() || this.attrs.discussion.firstPost();
if (post && post.contentType() === 'comment') {
const excerpt = highlight(post.contentPlain(), this.highlightRegExp, 175);
@@ -208,7 +212,7 @@ export default class DiscussionListItem extends Component {
items.add(
'terminalPost',
TerminalPost.component({
discussion: this.props.discussion,
discussion: this.attrs.discussion,
lastPost: !this.showFirstPost(),
})
);

View File

@@ -0,0 +1,67 @@
import DiscussionList from './DiscussionList';
import Component from '../../common/Component';
const hotEdge = (e) => {
if (e.pageX < 10) app.pane.show();
};
/**
* The `DiscussionListPane` component displays the list of previously viewed
* discussions in a panel that can be displayed by moving the mouse to the left
* edge of the screen, where it can also be pinned in place.
*
* ### Attrs
*
* - `state` A DiscussionListState object that represents the discussion lists's state.
*/
export default class DiscussionListPane extends Component {
view() {
if (!this.attrs.state.hasDiscussions()) {
return;
}
return <div className="DiscussionPage-list">{this.enoughSpace() && <DiscussionList state={this.attrs.state} />}</div>;
}
oncreate(vnode) {
super.oncreate(vnode);
const $list = $(vnode.dom);
// When the mouse enters and leaves the discussions pane, we want to show
// and hide the pane respectively. We also create a 10px 'hot edge' on the
// left of the screen to activate the pane.
const pane = app.pane;
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
$(document).on('mousemove', hotEdge);
// If the discussion we are viewing is listed in the discussion list, then
// we will make sure it is visible in the viewport if it is not we will
// scroll the list down to it.
const $discussion = $list.find('.DiscussionListItem.active');
if ($discussion.length) {
const listTop = $list.offset().top;
const listBottom = listTop + $list.outerHeight();
const discussionTop = $discussion.offset().top;
const discussionBottom = discussionTop + $discussion.outerHeight();
if (discussionTop < listTop || discussionBottom > listBottom) {
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
}
}
}
onremove() {
$(document).off('mousemove', hotEdge);
}
/**
* Are we on a device that's larger than we consider "mobile"?
*
* @returns {boolean}
*/
enoughSpace() {
return !$('.App-navigation').is(':visible');
}
}

View File

@@ -1,13 +1,13 @@
import Page from '../../common/components/Page';
import ItemList from '../../common/utils/ItemList';
import DiscussionHero from './DiscussionHero';
import DiscussionListPane from './DiscussionListPane';
import PostStream from './PostStream';
import PostStreamScrubber from './PostStreamScrubber';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import SplitDropdown from '../../common/components/SplitDropdown';
import listItems from '../../common/helpers/listItems';
import DiscussionControls from '../utils/DiscussionControls';
import DiscussionList from './DiscussionList';
import PostStreamState from '../states/PostStreamState';
/**
@@ -15,8 +15,8 @@ import PostStreamState from '../states/PostStreamState';
* the discussion list pane, the hero, the posts, and the sidebar.
*/
export default class DiscussionPage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* The discussion that is being viewed.
@@ -42,38 +42,16 @@ export default class DiscussionPage extends Page {
if (app.discussions.hasDiscussions()) {
app.pane.enable();
app.pane.hide();
if (app.previous.matches(DiscussionPage)) {
m.redraw.strategy('diff');
}
}
app.history.push('discussion');
this.bodyClass = 'App--discussion';
this.prevRoute = m.route.get();
}
onunload(e) {
// If we have routed to the same discussion as we were viewing previously,
// cancel the unloading of this controller and instead prompt the post
// stream to jump to the new 'near' param.
if (this.discussion) {
const idParam = m.route.param('id');
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
e.preventDefault();
const near = m.route.param('near') || '1';
if (near !== String(this.near)) {
this.stream.goToNumber(near);
}
this.near = null;
return;
}
}
onremove() {
// If we are indeed navigating away from this discussion, then disable the
// discussion list pane. Also, if we're composing a reply to this
// discussion, minimize the composer unless it's empty, in which case
@@ -92,14 +70,7 @@ export default class DiscussionPage extends Page {
return (
<div className="DiscussionPage">
{app.discussions.hasDiscussions() ? (
<div className="DiscussionPage-list" config={this.configPane.bind(this)}>
{!$('.App-navigation').is(':visible') && <DiscussionList state={app.discussions} />}
</div>
) : (
''
)}
<DiscussionListPane state={app.discussions} />
<div className="DiscussionPage-discussion">
{discussion
? [
@@ -124,11 +95,30 @@ export default class DiscussionPage extends Page {
);
}
config(...args) {
super.config(...args);
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
if (this.discussion) {
app.setTitle(this.discussion.title());
if (m.route.get() !== this.prevRoute) {
this.prevRoute = m.route.get();
// If we have routed to the same discussion as we were viewing previously,
// cancel the unloading of this controller and instead prompt the post
// stream to jump to the new 'near' param.
if (this.discussion) {
const idParam = m.route.param('id');
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
const near = m.route.param('near') || '1';
if (near !== String(this.near)) {
this.stream.goToNumber(near);
}
this.near = near;
} else {
this.oninit(vnode);
}
}
}
}
@@ -149,7 +139,7 @@ export default class DiscussionPage extends Page {
app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this));
}
m.lazyRedraw();
m.redraw();
}
/**
@@ -173,6 +163,7 @@ export default class DiscussionPage extends Page {
this.discussion = discussion;
app.history.push('discussion', discussion.title());
app.setTitle(this.discussion.title());
app.setTitleCount(0);
// When the API responds with a discussion, it will also include a number of
@@ -209,48 +200,6 @@ export default class DiscussionPage extends Page {
app.current.set('stream', this.stream);
}
/**
* Configure the discussion list pane.
*
* @param {DOMElement} element
* @param {Boolean} isInitialized
* @param {Object} context
*/
configPane(element, isInitialized, context) {
if (isInitialized) return;
context.retain = true;
const $list = $(element);
// When the mouse enters and leaves the discussions pane, we want to show
// and hide the pane respectively. We also create a 10px 'hot edge' on the
// left of the screen to activate the pane.
const pane = app.pane;
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
const hotEdge = (e) => {
if (e.pageX < 10) pane.show();
};
$(document).on('mousemove', hotEdge);
context.onunload = () => $(document).off('mousemove', hotEdge);
// If the discussion we are viewing is listed in the discussion list, then
// we will make sure it is visible in the viewport if it is not we will
// scroll the list down to it.
const $discussion = $list.find('.DiscussionListItem.active');
if ($discussion.length) {
const listTop = $list.offset().top;
const listBottom = listTop + $list.outerHeight();
const discussionTop = $discussion.offset().top;
const discussionBottom = discussionTop + $discussion.outerHeight();
if (discussionTop < listTop || discussionBottom > listBottom) {
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
}
}
}
/**
* Build an item list for the contents of the sidebar.
*
@@ -261,12 +210,14 @@ export default class DiscussionPage extends Page {
items.add(
'controls',
SplitDropdown.component({
children: DiscussionControls.controls(this.discussion, this).toArray(),
icon: 'fas fa-ellipsis-v',
className: 'App-primaryControl',
buttonClassName: 'Button--primary',
})
SplitDropdown.component(
{
icon: 'fas fa-ellipsis-v',
className: 'App-primaryControl',
buttonClassName: 'Button--primary',
},
DiscussionControls.controls(this.discussion, this).toArray()
)
);
items.add(
@@ -295,7 +246,8 @@ export default class DiscussionPage extends Page {
// replace it into the window's history and our own history stack.
const url = app.route.discussion(discussion, (this.near = startNumber));
m.route(url, true);
this.prevRoute = url;
m.route.set(url, null, { replace: true });
window.history.replaceState(null, document.title, url);
app.history.push('discussion', discussion.title());

View File

@@ -4,9 +4,9 @@ import Notification from './Notification';
* The `DiscussionRenamedNotification` component displays a notification which
* indicates that a discussion has had its title changed.
*
* ### Props
* ### Attrs
*
* - All of the props for Notification
* - All of the attrs for Notification
*/
export default class DiscussionRenamedNotification extends Notification {
icon() {
@@ -14,12 +14,12 @@ export default class DiscussionRenamedNotification extends Notification {
}
href() {
const notification = this.props.notification;
const notification = this.attrs.notification;
return app.route.discussion(notification.subject(), notification.content().postNumber);
}
content() {
return app.translator.trans('core.forum.notifications.discussion_renamed_text', { user: this.props.notification.fromUser() });
return app.translator.trans('core.forum.notifications.discussion_renamed_text', { user: this.attrs.notification.fromUser() });
}
}

View File

@@ -5,9 +5,9 @@ import extractText from '../../common/utils/extractText';
* The `DiscussionRenamedPost` component displays a discussion event post
* indicating that the discussion has been renamed.
*
* ### Props
* ### Attrs
*
* - All of the props for EventPost
* - All of the attrs for EventPost
*/
export default class DiscussionRenamedPost extends EventPost {
icon() {
@@ -22,7 +22,7 @@ export default class DiscussionRenamedPost extends EventPost {
}
descriptionData() {
const post = this.props.post;
const post = this.attrs.post;
const oldTitle = post.content()[0];
const newTitle = post.content()[1];

View File

@@ -34,18 +34,20 @@ export default class DiscussionsSearchSource {
return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
<li>
{LinkButton.component({
icon: 'fas fa-search',
children: app.translator.trans('core.forum.search.all_discussions_button', { query }),
href: app.route('index', { q: query }),
})}
{LinkButton.component(
{
icon: 'fas fa-search',
href: app.route('index', { q: query }),
},
app.translator.trans('core.forum.search.all_discussions_button', { query })
)}
</li>,
results.map((discussion) => {
const mostRelevantPost = discussion.mostRelevantPost();
return (
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
<a href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())} config={m.route}>
<a route={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
</a>

View File

@@ -7,8 +7,8 @@ import DiscussionListState from '../states/DiscussionListState';
* page.
*/
export default class DiscussionsUserPage extends UserPage {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
this.loadUser(m.route.param('username'));
}

View File

@@ -14,38 +14,32 @@ function minimizeComposerIfFullScreen(e) {
* post. It sets the initial content to the content of the post that is being
* edited, and adds a header control to indicate which post is being edited.
*
* ### Props
* ### Attrs
*
* - All of the props for ComposerBody
* - All of the attrs for ComposerBody
* - `post`
*/
export default class EditPostComposer extends ComposerBody {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button');
props.confirmExit = props.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation');
props.originalContent = props.originalContent || props.post.content();
props.user = props.user || props.post.user();
attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button');
attrs.confirmExit = attrs.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation');
attrs.originalContent = attrs.originalContent || attrs.post.content();
attrs.user = attrs.user || attrs.post.user();
props.post.editedContent = props.originalContent;
attrs.post.editedContent = attrs.originalContent;
}
headerItems() {
const items = super.headerItems();
const post = this.props.post;
const routeAndMinimize = function (element, isInitialized) {
if (isInitialized) return;
$(element).on('click', minimizeComposerIfFullScreen);
m.route.apply(this, arguments);
};
const post = this.attrs.post;
items.add(
'title',
<h3>
{icon('fas fa-pencil-alt')}{' '}
<a href={app.route.discussion(post.discussion(), post.number())} config={routeAndMinimize}>
<a route={app.route.discussion(post.discussion(), post.number())} onclick={minimizeComposerIfFullScreen}>
{app.translator.trans('core.forum.composer_edit.post_link', { number: post.number(), discussion: post.discussion().title() })}
</a>
</h3>
@@ -60,7 +54,7 @@ export default class EditPostComposer extends ComposerBody {
jumpToPreview(e) {
minimizeComposerIfFullScreen(e);
m.route(app.route.post(this.props.post));
m.route.set(app.route.post(this.attrs.post));
}
/**
@@ -75,13 +69,13 @@ export default class EditPostComposer extends ComposerBody {
}
onsubmit() {
const discussion = this.props.post.discussion();
const discussion = this.attrs.post.discussion();
this.loading = true;
const data = this.data();
this.props.post.save(data).then((post) => {
this.attrs.post.save(data).then((post) => {
// If we're currently viewing the discussion which this edit was made
// in, then we can scroll to the post.
if (app.viewingDiscussion(discussion)) {
@@ -91,19 +85,23 @@ export default class EditPostComposer extends ComposerBody {
// their edit has been made, containing a button which will
// transition to their edited post when clicked.
let alert;
const viewButton = Button.component({
className: 'Button Button--link',
children: app.translator.trans('core.forum.composer_edit.view_button'),
onclick: () => {
m.route(app.route.post(post));
app.alerts.dismiss(alert);
const viewButton = Button.component(
{
className: 'Button Button--link',
onclick: () => {
m.route.set(app.route.post(post));
app.alerts.dismiss(alert);
},
},
});
alert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.forum.composer_edit.edited_message'),
controls: [viewButton],
});
app.translator.trans('core.forum.composer_edit.view_button')
);
alert = app.alerts.show(
{
type: 'success',
controls: [viewButton],
},
app.translator.trans('core.forum.composer_edit.edited_message')
);
}
this.composer.hide();

View File

@@ -9,22 +9,22 @@ import ItemList from '../../common/utils/ItemList';
* The `EditUserModal` component displays a modal dialog with a login form.
*/
export default class EditUserModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
const user = this.props.user;
const user = this.attrs.user;
this.username = m.prop(user.username() || '');
this.email = m.prop(user.email() || '');
this.isEmailConfirmed = m.prop(user.isEmailConfirmed() || false);
this.setPassword = m.prop(false);
this.password = m.prop(user.password() || '');
this.username = m.stream(user.username() || '');
this.email = m.stream(user.email() || '');
this.isEmailConfirmed = m.stream(user.isEmailConfirmed() || false);
this.setPassword = m.stream(false);
this.password = m.stream(user.password() || '');
this.groups = {};
app.store
.all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.forEach((group) => (this.groups[group.id()] = m.prop(user.groups().indexOf(group) !== -1)));
.forEach((group) => (this.groups[group.id()] = m.stream(user.groups().indexOf(group) !== -1)));
}
className() {
@@ -55,7 +55,7 @@ export default class EditUserModal extends Modal {
40
);
if (app.session.user !== this.props.user) {
if (app.session.user !== this.attrs.user) {
items.add(
'email',
<div className="Form-group">
@@ -65,12 +65,14 @@ export default class EditUserModal extends Modal {
</div>
{!this.isEmailConfirmed() ? (
<div>
{Button.component({
className: 'Button Button--block',
children: app.translator.trans('core.forum.edit_user.activate_button'),
loading: this.loading,
onclick: this.activate.bind(this),
})}
{Button.component(
{
className: 'Button Button--block',
loading: this.loading,
onclick: this.activate.bind(this),
},
app.translator.trans('core.forum.edit_user.activate_button')
)}
</div>
) : (
''
@@ -89,9 +91,9 @@ export default class EditUserModal extends Modal {
type="checkbox"
onchange={(e) => {
this.setPassword(e.target.checked);
m.redraw(true);
m.redraw.sync();
if (e.target.checked) this.$('[name=password]').select();
m.redraw.strategy('none');
e.redraw = false;
}}
/>
{app.translator.trans('core.forum.edit_user.set_password_label')}
@@ -125,7 +127,7 @@ export default class EditUserModal extends Modal {
<input
type="checkbox"
bidi={this.groups[group.id()]}
disabled={this.props.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
disabled={this.attrs.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
/>
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
</label>
@@ -138,12 +140,14 @@ export default class EditUserModal extends Modal {
items.add(
'submit',
<div className="Form-group">
{Button.component({
className: 'Button Button--primary',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.edit_user.submit_button'),
})}
{Button.component(
{
className: 'Button Button--primary',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.edit_user.submit_button')
)}
</div>,
-10
);
@@ -157,7 +161,7 @@ export default class EditUserModal extends Modal {
username: this.username(),
isEmailConfirmed: true,
};
this.props.user
this.attrs.user
.save(data, { errorHandler: this.onerror.bind(this) })
.then(() => {
this.isEmailConfirmed(true);
@@ -180,7 +184,7 @@ export default class EditUserModal extends Modal {
relationships: { groups },
};
if (app.session.user !== this.props.user) {
if (app.session.user !== this.attrs.user) {
data.email = this.email();
}
@@ -196,7 +200,7 @@ export default class EditUserModal extends Modal {
this.loading = true;
this.props.user
this.attrs.user
.save(this.data(), { errorHandler: this.onerror.bind(this) })
.then(this.hide.bind(this))
.catch(() => {

View File

@@ -8,28 +8,28 @@ import icon from '../../common/helpers/icon';
* event, like a discussion being renamed or stickied. Subclasses must implement
* the `icon` and `description` methods.
*
* ### Props
* ### Attrs
*
* - All of the props for `Post`
* - All of the attrs for `Post`
*
* @abstract
*/
export default class EventPost extends Post {
attrs() {
const attrs = super.attrs();
elementAttrs() {
const attrs = super.elementAttrs();
attrs.className = (attrs.className || '') + ' EventPost ' + ucfirst(this.props.post.contentType()) + 'Post';
attrs.className = (attrs.className || '') + ' EventPost ' + ucfirst(this.attrs.post.contentType()) + 'Post';
return attrs;
}
content() {
const user = this.props.post.user();
const user = this.attrs.post.user();
const username = usernameHelper(user);
const data = Object.assign(this.descriptionData(), {
user,
username: user ? (
<a className="EventPost-user" href={app.route.user(user)} config={m.route}>
<a className="EventPost-user" route={app.route.user(user)}>
{username}
</a>
) : (

View File

@@ -1,5 +1,4 @@
import Modal from '../../common/components/Modal';
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
import extractText from '../../common/utils/extractText';
@@ -7,20 +6,20 @@ import extractText from '../../common/utils/extractText';
* The `ForgotPasswordModal` component displays a modal which allows the user to
* enter their email address and request a link to reset their password.
*
* ### Props
* ### Attrs
*
* - `email`
*/
export default class ForgotPasswordModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* The value of the email input.
*
* @type {Function}
*/
this.email = m.prop(this.props.email || '');
this.email = m.stream(this.attrs.email || '');
/**
* Whether or not the password reset email was sent successfully.
@@ -64,18 +63,19 @@ export default class ForgotPasswordModal extends Modal {
name="email"
type="email"
placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))}
value={this.email()}
onchange={m.withAttr('value', this.email)}
bidi={this.email}
disabled={this.loading}
/>
</div>
<div className="Form-group">
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.forgot_password.submit_button'),
})}
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.forgot_password.submit_button')
)}
</div>
</div>
</div>
@@ -91,7 +91,7 @@ export default class ForgotPasswordModal extends Modal {
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot',
data: { email: this.email() },
body: { email: this.email() },
errorHandler: this.onerror.bind(this),
})
.then(() => {
@@ -104,7 +104,7 @@ export default class ForgotPasswordModal extends Modal {
onerror(error) {
if (error.status === 404) {
error.alert.children = app.translator.trans('core.forum.forgot_password.not_found_message');
error.alert.content = app.translator.trans('core.forum.forgot_password.not_found_message');
}
super.onerror(error);

View File

@@ -11,13 +11,6 @@ export default class HeaderPrimary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Build an item list for the controls.
*

View File

@@ -19,13 +19,6 @@ export default class HeaderSecondary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Build an item list for the controls.
*
@@ -41,28 +34,32 @@ export default class HeaderSecondary extends Component {
for (const locale in app.data.locales) {
locales.push(
Button.component({
active: app.data.locale === locale,
children: app.data.locales[locale],
icon: app.data.locale === locale ? 'fas fa-check' : true,
onclick: () => {
if (app.session.user) {
app.session.user.savePreferences({ locale }).then(() => window.location.reload());
} else {
document.cookie = `locale=${locale}; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT`;
window.location.reload();
}
Button.component(
{
active: app.data.locale === locale,
icon: app.data.locale === locale ? 'fas fa-check' : true,
onclick: () => {
if (app.session.user) {
app.session.user.savePreferences({ locale }).then(() => window.location.reload());
} else {
document.cookie = `locale=${locale}; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT`;
window.location.reload();
}
},
},
})
app.data.locales[locale]
)
);
}
items.add(
'locale',
SelectDropdown.component({
children: locales,
buttonClassName: 'Button Button--link',
}),
SelectDropdown.component(
{
buttonClassName: 'Button Button--link',
},
locales
),
20
);
}
@@ -74,22 +71,26 @@ export default class HeaderSecondary extends Component {
if (app.forum.attribute('allowSignUp')) {
items.add(
'signUp',
Button.component({
children: app.translator.trans('core.forum.header.sign_up_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(SignUpModal),
}),
Button.component(
{
className: 'Button Button--link',
onclick: () => app.modal.show(SignUpModal),
},
app.translator.trans('core.forum.header.sign_up_link')
),
10
);
}
items.add(
'logIn',
Button.component({
children: app.translator.trans('core.forum.header.log_in_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(LogInModal),
}),
Button.component(
{
className: 'Button Button--link',
onclick: () => app.modal.show(LogInModal),
},
app.translator.trans('core.forum.header.log_in_link')
),
0
);
}

View File

@@ -19,8 +19,8 @@ import SelectDropdown from '../../common/components/SelectDropdown';
export default class IndexPage extends Page {
static providesInitialSearch = true;
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
// If the user is returning from a discussion page, then take note of which
// discussion they have just visited. After the view is rendered, we will
@@ -42,12 +42,26 @@ export default class IndexPage extends Page {
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
this.bodyClass = 'App--index';
this.currentPath = m.route.get();
}
onunload() {
// Save the scroll position so we can restore it when we return to the
// discussion list.
app.cache.scrollTop = $(window).scrollTop();
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
const curPath = m.route.get();
if (this.currentPath !== curPath) {
this.onNewRoute();
app.discussions.clear();
app.discussions.refreshParams(app.search.params());
this.currentPath = curPath;
this.setTitle();
}
}
view() {
@@ -72,15 +86,15 @@ export default class IndexPage extends Page {
);
}
config(isInitialized, context) {
super.config(...arguments);
if (isInitialized) return;
extend(context, 'onunload', () => $('#app').css('min-height', ''));
setTitle() {
app.setTitle(app.translator.trans('core.forum.index.meta_title_text'));
app.setTitleCount(0);
}
oncreate(vnode) {
super.oncreate(vnode);
this.setTitle();
// Work out the difference between the height of this hero and that of the
// previous hero. Maintain the same scroll position relative to the bottom
@@ -117,6 +131,16 @@ export default class IndexPage extends Page {
}
}
onremove() {
super.onremove();
$('#app').css('min-height', '');
// Save the scroll position so we can restore it when we return to the
// discussion list.
app.cache.scrollTop = $(window).scrollTop();
}
/**
* Get the component to display as the hero.
*
@@ -139,25 +163,31 @@ export default class IndexPage extends Page {
items.add(
'newDiscussion',
Button.component({
children: app.translator.trans(
canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button'
),
icon: 'fas fa-edit',
className: 'Button Button--primary IndexPage-newDiscussion',
itemClassName: 'App-primaryControl',
onclick: this.newDiscussionAction.bind(this),
disabled: !canStartDiscussion,
})
Button.component(
{
icon: 'fas fa-edit',
className: 'Button Button--primary IndexPage-newDiscussion',
itemClassName: 'App-primaryControl',
onclick: () => {
// If the user is not logged in, the promise rejects, and a login modal shows up.
// Since that's already handled, we dont need to show an error message in the console.
return this.newDiscussionAction().catch(() => {});
},
disabled: !canStartDiscussion,
},
app.translator.trans(canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button')
)
);
items.add(
'nav',
SelectDropdown.component({
children: this.navItems(this).toArray(),
buttonClassName: 'Button',
className: 'App-titleControl',
})
SelectDropdown.component(
{
buttonClassName: 'Button',
className: 'App-titleControl',
},
this.navItems(this).toArray()
)
);
return items;
@@ -175,11 +205,13 @@ export default class IndexPage extends Page {
items.add(
'allDiscussions',
LinkButton.component({
href: app.route('index', params),
children: app.translator.trans('core.forum.index.all_discussions_link'),
icon: 'far fa-comments',
}),
LinkButton.component(
{
href: app.route('index', params),
icon: 'far fa-comments',
},
app.translator.trans('core.forum.index.all_discussions_link')
),
100
);
@@ -204,21 +236,25 @@ export default class IndexPage extends Page {
items.add(
'sort',
Dropdown.component({
buttonClassName: 'Button',
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
children: Object.keys(sortOptions).map((value) => {
Dropdown.component(
{
buttonClassName: 'Button',
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
},
Object.keys(sortOptions).map((value) => {
const label = sortOptions[value];
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
return Button.component({
children: label,
icon: active ? 'fas fa-check' : true,
onclick: app.search.changeSort.bind(app.search, value),
active: active,
});
}),
})
return Button.component(
{
icon: active ? 'fas fa-check' : true,
onclick: app.search.changeSort.bind(app.search, value),
active: active,
},
label
);
})
)
);
return items;
@@ -270,20 +306,18 @@ export default class IndexPage extends Page {
* @return {Promise}
*/
newDiscussionAction() {
const deferred = m.deferred();
return new Promise((resolve, reject) => {
if (app.session.user) {
app.composer.load(DiscussionComposer, { user: app.session.user });
app.composer.show();
if (app.session.user) {
app.composer.load(DiscussionComposer, { user: app.session.user });
app.composer.show();
return resolve(app.composer);
} else {
app.modal.show(LogInModal);
deferred.resolve(app.composer);
} else {
deferred.reject();
app.modal.show(LogInModal);
}
return deferred.promise;
return reject();
}
});
}
/**

View File

@@ -4,21 +4,21 @@ import Button from '../../common/components/Button';
* The `LogInButton` component displays a social login button which will open
* a popup window containing the specified path.
*
* ### Props
* ### Attrs
*
* - `path`
*/
export default class LogInButton extends Button {
static initProps(props) {
props.className = (props.className || '') + ' LogInButton';
static initAttrs(attrs) {
attrs.className = (attrs.className || '') + ' LogInButton';
props.onclick = function () {
attrs.onclick = function () {
const width = 580;
const height = 400;
const $window = $(window);
window.open(
app.forum.attribute('baseUrl') + props.path,
app.forum.attribute('baseUrl') + attrs.path,
'logInPopup',
`width=${width},` +
`height=${height},` +
@@ -28,6 +28,6 @@ export default class LogInButton extends Button {
);
};
super.initProps(props);
super.initAttrs(attrs);
}
}

View File

@@ -9,35 +9,35 @@ import ItemList from '../../common/utils/ItemList';
/**
* The `LogInModal` component displays a modal dialog with a login form.
*
* ### Props
* ### Attrs
*
* - `identification`
* - `password`
*/
export default class LogInModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* The value of the identification input.
*
* @type {Function}
*/
this.identification = m.prop(this.props.identification || '');
this.identification = m.stream(this.attrs.identification || '');
/**
* The value of the password input.
*
* @type {Function}
*/
this.password = m.prop(this.props.password || '');
this.password = m.stream(this.attrs.password || '');
/**
* The value of the remember me input.
*
* @type {Function}
*/
this.remember = m.prop(!!this.props.remember);
this.remember = m.stream(!!this.attrs.remember);
}
className() {
@@ -105,12 +105,14 @@ export default class LogInModal extends Modal {
items.add(
'submit',
<div className="Form-group">
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.log_in.submit_button'),
})}
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.log_in.submit_button')
)}
</div>,
-10
);
@@ -140,9 +142,9 @@ export default class LogInModal extends Modal {
*/
forgotPassword() {
const email = this.identification();
const props = email.indexOf('@') !== -1 ? { email } : undefined;
const attrs = email.indexOf('@') !== -1 ? { email } : undefined;
app.modal.show(ForgotPasswordModal, props);
app.modal.show(ForgotPasswordModal, attrs);
}
/**
@@ -152,11 +154,11 @@ export default class LogInModal extends Modal {
* @public
*/
signUp() {
const props = { password: this.password() };
const attrs = { password: this.password() };
const identification = this.identification();
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
attrs[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
app.modal.show(SignUpModal, props);
app.modal.show(SignUpModal, attrs);
}
onready() {
@@ -179,7 +181,7 @@ export default class LogInModal extends Modal {
onerror(error) {
if (error.status === 401) {
error.alert.children = app.translator.trans('core.forum.log_in.invalid_login_message');
error.alert.content = app.translator.trans('core.forum.log_in.invalid_login_message');
}
super.onerror(error);

View File

@@ -8,7 +8,7 @@ import Button from '../../common/components/Button';
* The `Notification` component abstract displays a single notification.
* Subclasses should implement the `icon`, `href`, and `content` methods.
*
* ### Props
* ### Attrs
*
* - `notification`
*
@@ -16,18 +16,17 @@ import Button from '../../common/components/Button';
*/
export default class Notification extends Component {
view() {
const notification = this.props.notification;
const notification = this.attrs.notification;
const href = this.href();
const linkAttrs = {};
linkAttrs[href.indexOf('://') === -1 ? 'route' : 'href'] = href;
return (
<a
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
href={href}
config={function (element, isInitialized) {
if (href.indexOf('://') === -1) m.route.apply(this, arguments);
if (!isInitialized) $(element).click(this.markAsRead.bind(this));
}}
{...linkAttrs}
onclick={this.markAsRead.bind(this)}
>
{!notification.isRead() &&
Button.component({
@@ -86,10 +85,10 @@ export default class Notification extends Component {
* Mark the notification as read.
*/
markAsRead() {
if (this.props.notification.isRead()) return;
if (this.attrs.notification.isRead()) return;
app.session.user.pushAttributes({ unreadNotificationCount: app.session.user.unreadNotificationCount() - 1 });
this.props.notification.save({ isRead: true });
this.attrs.notification.save({ isRead: true });
}
}

View File

@@ -7,12 +7,14 @@ import ItemList from '../../common/utils/ItemList';
* The `NotificationGrid` component displays a table of notification types and
* methods, allowing the user to toggle each combination.
*
* ### Props
* ### Attrs
*
* - `user`
*/
export default class NotificationGrid extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* Information about the available notification methods.
*
@@ -36,7 +38,7 @@ export default class NotificationGrid extends Component {
}
view() {
const preferences = this.props.user.preferences();
const preferences = this.attrs.user.preferences();
return (
<table className="NotificationGrid">
@@ -62,12 +64,12 @@ export default class NotificationGrid extends Component {
return (
<td className="NotificationGrid-checkbox">
{Checkbox.component({
state: !!preferences[key],
loading: this.loading[key],
disabled: !(key in preferences),
onchange: () => this.toggle([key]),
})}
<Checkbox
state={!!preferences[key]}
loading={this.loading[key]}
disabled={!(key in preferences)}
onchange={this.toggle.bind(this, [key])}
/>
</td>
);
})}
@@ -78,8 +80,8 @@ export default class NotificationGrid extends Component {
);
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
this.$('thead .NotificationGrid-groupToggle').bind('mouseenter mouseleave', function (e) {
const i = parseInt($(this).index(), 10) + 1;
@@ -104,7 +106,7 @@ export default class NotificationGrid extends Component {
* @param {Array} keys
*/
toggle(keys) {
const user = this.props.user;
const user = this.attrs.user;
const preferences = user.preferences();
const enabled = !preferences[keys[0]];
@@ -128,7 +130,7 @@ export default class NotificationGrid extends Component {
* @param {String} method
*/
toggleMethod(method) {
const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => key in this.props.user.preferences());
const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => key in this.attrs.user.preferences());
this.toggle(keys);
}
@@ -139,7 +141,7 @@ export default class NotificationGrid extends Component {
* @param {String} type
*/
toggleType(type) {
const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => key in this.props.user.preferences());
const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => key in this.attrs.user.preferences());
this.toggle(keys);
}

View File

@@ -9,12 +9,9 @@ import Discussion from '../../common/models/Discussion';
* notifications, grouped by discussion.
*/
export default class NotificationList extends Component {
init() {
this.state = this.props.state;
}
view() {
const pages = this.state.getNotificationPages();
const state = this.attrs.state;
const pages = state.getNotificationPages();
return (
<div className="NotificationList">
@@ -24,7 +21,7 @@ export default class NotificationList extends Component {
className: 'Button Button--icon Button--link',
icon: 'fas fa-check',
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
onclick: this.state.markAllAsRead.bind(this.state),
onclick: state.markAllAsRead.bind(state),
})}
</div>
@@ -66,7 +63,7 @@ export default class NotificationList extends Component {
return (
<div className="NotificationGroup">
{group.discussion ? (
<a className="NotificationGroup-header" href={app.route.discussion(group.discussion)} config={m.route}>
<a className="NotificationGroup-header" route={app.route.discussion(group.discussion)}>
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
{group.discussion.title()}
</a>
@@ -85,7 +82,7 @@ export default class NotificationList extends Component {
});
})
: ''}
{this.state.isLoading() ? (
{state.isLoading() ? (
<LoadingIndicator className="LoadingIndicator--block" />
) : pages.length ? (
''
@@ -97,27 +94,31 @@ export default class NotificationList extends Component {
);
}
config(isInitialized, context) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
const $notifications = this.$('.NotificationList-content');
const $scrollParent = $notifications.css('overflow') === 'auto' ? $notifications : $(window);
this.$notifications = this.$('.NotificationList-content');
this.$scrollParent = this.$notifications.css('overflow') === 'auto' ? this.$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;
this.boundScrollHandler = this.scrollHandler.bind(this);
this.$scrollParent.on('scroll', this.boundScrollHandler);
}
if (this.state.hasMoreResults() && !this.state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
this.state.loadMore();
}
};
onremove() {
this.$scrollParent.off('scroll', this.boundScrollHandler);
}
$scrollParent.on('scroll', scrollHandler);
scrollHandler() {
const state = this.attrs.state;
context.onunload = () => {
$scrollParent.off('scroll', scrollHandler);
};
const scrollTop = this.$scrollParent.scrollTop();
const viewportHeight = this.$scrollParent.height();
const contentTop = this.$scrollParent === this.$notifications ? 0 : this.$notifications.offset().top;
const contentHeight = this.$notifications[0].scrollHeight;
if (state.hasMoreResults() && !state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
state.loadMore();
}
}
}

View File

@@ -3,21 +3,21 @@ import icon from '../../common/helpers/icon';
import NotificationList from './NotificationList';
export default class NotificationsDropdown extends Dropdown {
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';
static initAttrs(attrs) {
attrs.className = attrs.className || 'NotificationsDropdown';
attrs.buttonClassName = attrs.buttonClassName || 'Button Button--flat';
attrs.menuClassName = attrs.menuClassName || 'Dropdown-menu--right';
attrs.label = attrs.label || app.translator.trans('core.forum.notifications.tooltip');
attrs.icon = attrs.icon || 'fas fa-bell';
super.initProps(props);
super.initAttrs(attrs);
}
getButton() {
const newNotifications = this.getNewCount();
const vdom = super.getButton();
vdom.attrs.title = this.props.label;
vdom.attrs.title = this.attrs.label;
vdom.attrs.className += newNotifications ? ' new' : '';
vdom.attrs.onclick = this.onclick.bind(this);
@@ -29,16 +29,16 @@ export default class NotificationsDropdown extends Dropdown {
const unread = this.getUnreadCount();
return [
icon(this.props.icon, { className: 'Button-icon' }),
icon(this.attrs.icon, { className: 'Button-icon' }),
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
<span className="Button-label">{this.props.label}</span>,
<span className="Button-label">{this.attrs.label}</span>,
];
}
getMenu() {
return (
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? NotificationList.component({ state: this.props.state }) : ''}
<div className={'Dropdown-menu ' + this.attrs.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? NotificationList.component({ state: this.attrs.state }) : ''}
</div>
);
}
@@ -47,12 +47,12 @@ export default class NotificationsDropdown extends Dropdown {
if (app.drawer.isOpen()) {
this.goToRoute();
} else {
this.props.state.load();
this.attrs.state.load();
}
}
goToRoute() {
m.route(app.route('notifications'));
m.route.set(app.route('notifications'));
}
getUnreadCount() {

View File

@@ -6,8 +6,8 @@ import NotificationList from './NotificationList';
* used on mobile devices where the notifications dropdown is within the drawer.
*/
export default class NotificationsPage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
app.history.push('notifications');

View File

@@ -10,14 +10,16 @@ import ItemList from '../../common/utils/ItemList';
* includes a controls dropdown; subclasses must implement `content` and `attrs`
* methods.
*
* ### Props
* ### Attrs
*
* - `post`
*
* @abstract
*/
export default class Post extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
this.loading = false;
/**
@@ -27,9 +29,9 @@ export default class Post extends Component {
* @type {SubtreeRetainer}
*/
this.subtree = new SubtreeRetainer(
() => this.props.post.freshness,
() => this.attrs.post.freshness,
() => {
const user = this.props.post.user();
const user = this.attrs.post.user();
return user && user.freshness;
},
() => this.controlsOpen
@@ -37,51 +39,52 @@ export default class Post extends Component {
}
view() {
const attrs = this.attrs();
const attrs = this.elementAttrs();
attrs.className = this.classes(attrs.className).join(' ');
const controls = PostControls.controls(this.attrs.post, this).toArray();
return (
<article {...attrs}>
{this.subtree.retain() ||
(() => {
const controls = PostControls.controls(this.props.post, this).toArray();
return (
<div>
{this.content()}
<aside className="Post-actions">
<ul>
{listItems(this.actionItems().toArray())}
{controls.length ? (
<li>
<Dropdown
className="Post-controls"
buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
onshow={() => this.$('.Post-actions').addClass('open')}
onhide={() => this.$('.Post-actions').removeClass('open')}
>
{controls}
</Dropdown>
</li>
) : (
''
)}
</ul>
</aside>
<footer className="Post-footer">
<ul>{listItems(this.footerItems().toArray())}</ul>
</footer>
</div>
);
})()}
<div>
{this.content()}
<aside className="Post-actions">
<ul>
{listItems(this.actionItems().toArray())}
{controls.length ? (
<li>
<Dropdown
className="Post-controls"
buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
onshow={() => this.$('.Post-actions').addClass('open')}
onhide={() => this.$('.Post-actions').removeClass('open')}
>
{controls}
</Dropdown>
</li>
) : (
''
)}
</ul>
</aside>
<footer className="Post-footer">
<ul>{listItems(this.footerItems().toArray())}</ul>
</footer>
</div>
</article>
);
}
config(isInitialized) {
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
return this.subtree.needsRebuild();
}
onupdate() {
const $actions = this.$('.Post-actions');
const $controls = this.$('.Post-controls');
@@ -93,7 +96,7 @@ export default class Post extends Component {
*
* @return {Object}
*/
attrs() {
elementAttrs() {
return {};
}
@@ -115,8 +118,8 @@ export default class Post extends Component {
classes(existing) {
let classes = (existing || '').split(' ').concat(['Post']);
const user = this.props.post.user();
const discussion = this.props.post.discussion();
const user = this.attrs.post.user();
const discussion = this.attrs.post.discussion();
if (this.loading) {
classes.push('Post--loading');

View File

@@ -6,18 +6,20 @@ import extractText from '../../common/utils/extractText';
* The `PostEdited` component displays information about when and by whom a post
* was edited.
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class PostEdited extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
this.shouldUpdateTooltip = false;
this.oldEditedInfo = null;
}
view() {
const post = this.props.post;
const post = this.attrs.post;
const editedUser = post.editedUser();
const editedInfo = extractText(app.translator.trans('core.forum.post.edited_tooltip', { user: editedUser, ago: humanTime(post.editedAt()) }));
if (editedInfo !== this.oldEditedInfo) {
@@ -32,7 +34,17 @@ export default class PostEdited extends Component {
);
}
config(isInitialized) {
oncreate(vnode) {
super.oncreate(vnode);
this.rebuildTooltip();
}
onupdate() {
this.rebuildTooltip();
}
rebuildTooltip() {
if (this.shouldUpdateTooltip) {
this.$().tooltip('destroy').tooltip();
this.shouldUpdateTooltip = false;

View File

@@ -7,23 +7,23 @@ import fullTime from '../../common/helpers/fullTime';
* a dropdown containing more information about the post (number, full time,
* permalink).
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class PostMeta extends Component {
view() {
const post = this.props.post;
const post = this.attrs.post;
const time = post.createdAt();
const permalink = this.getPermalink(post);
const touch = 'ontouchstart' in document.documentElement;
// When the dropdown menu is shown, select the contents of the permalink
// input so that the user can quickly copy the URL.
const selectPermalink = function () {
const selectPermalink = function (e) {
setTimeout(() => $(this).parent().find('.PostMeta-permalink').select());
m.redraw.strategy('none');
e.redraw = false;
};
return (

View File

@@ -7,18 +7,18 @@ import highlight from '../../common/helpers/highlight';
* The `PostPreview` component shows a link to a post containing the avatar and
* username of the author, and a short excerpt of the post's content.
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class PostPreview extends Component {
view() {
const post = this.props.post;
const post = this.attrs.post;
const user = post.user();
const excerpt = highlight(post.contentPlain(), this.props.highlight, 300);
const excerpt = highlight(post.contentPlain(), this.attrs.highlight, 300);
return (
<a className="PostPreview" href={app.route.post(post)} config={m.route} onclick={this.props.onclick}>
<a className="PostPreview" route={app.route.post(post)} onclick={this.attrs.onclick}>
<span className="PostPreview-content">
{avatar(user)}
{username(user)} <span className="PostPreview-excerpt">{excerpt}</span>

View File

@@ -8,7 +8,7 @@ import Button from '../../common/components/Button';
* The `PostStream` component displays an infinitely-scrollable wall of posts in
* a discussion. Posts that have not loaded will be displayed as placeholders.
*
* ### Props
* ### Attrs
*
* - `discussion`
* - `stream`
@@ -16,9 +16,11 @@ import Button from '../../common/components/Button';
* - `onPositionChange`
*/
export default class PostStream extends Component {
init() {
this.discussion = this.props.discussion;
this.stream = this.props.stream;
oninit(vnode) {
super.oninit(vnode);
this.discussion = this.attrs.discussion;
this.stream = this.attrs.stream;
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
}
@@ -96,29 +98,33 @@ export default class PostStream extends Component {
return <div className="PostStream">{items}</div>;
}
config(isInitialized, context) {
onupdate() {
this.triggerScroll();
}
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
this.triggerScroll();
// This is wrapped in setTimeout due to the following Mithril issue:
// https://github.com/lhorie/mithril.js/issues/637
setTimeout(() => this.scrollListener.start());
}
context.onunload = () => {
this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout);
};
onremove() {
this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout);
}
/**
* Start scrolling, if appropriate, to a newly-targeted post.
*/
triggerScroll() {
if (!this.props.targetPost) return;
if (!this.attrs.targetPost) return;
const oldTarget = this.prevTarget;
const newTarget = this.props.targetPost;
const newTarget = this.attrs.targetPost;
if (oldTarget) {
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
@@ -265,7 +271,7 @@ export default class PostStream extends Component {
});
if (startNumber) {
this.props.onPositionChange(startNumber || 1, endNumber, startNumber);
this.attrs.onPositionChange(startNumber || 1, endNumber, startNumber);
}
}
@@ -348,7 +354,7 @@ export default class PostStream extends Component {
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
this.updateScrubber();
const index = $item.data('index');
m.redraw(true);
m.redraw.sync();
const scroll = index == 0 ? 0 : $(`.PostStream-item[data-index=${$item.data('index')}]`).offset().top - this.getMarginTop();
$(window).scrollTop(scroll);
this.calculatePosition();

View File

@@ -7,14 +7,16 @@ import ScrollListener from '../../common/utils/ScrollListener';
* The `PostStreamScrubber` component displays a scrubber which can be used to
* navigate/scrub through a post stream.
*
* ### Props
* ### Attrs
*
* - `stream`
* - `className`
*/
export default class PostStreamScrubber extends Component {
init() {
this.stream = this.props.stream;
oninit(vnode) {
super.oninit(vnode);
this.stream = this.attrs.stream;
this.handlers = {};
this.scrollListener = new ScrollListener(this.updateScrubberValues.bind(this, { fromScroll: true, forceHeightChange: true }));
@@ -32,23 +34,23 @@ export default class PostStreamScrubber extends Component {
const unreadCount = this.stream.discussion.unreadCount();
const unreadPercent = count ? Math.min(count - this.stream.index, unreadCount) / count : 0;
function styleUnread(element, isInitialized, context) {
const $element = $(element);
function styleUnread(vnode) {
const $element = $(vnode.dom);
const newStyle = {
top: 100 - unreadPercent * 100 + '%',
height: unreadPercent * 100 + '%',
};
if (context.oldStyle) {
$element.stop(true).css(context.oldStyle).animate(newStyle);
if (vnode.state.oldStyle) {
$element.stop(true).css(vnode.state.oldStyle).animate(newStyle);
} else {
$element.css(newStyle);
}
context.oldStyle = newStyle;
vnode.state.oldStyle = newStyle;
}
const classNames = ['PostStreamScrubber', 'Dropdown'];
if (this.props.className) classNames.push(this.props.className);
if (this.attrs.className) classNames.push(this.attrs.className);
return (
<div className={classNames.join(' ')}>
@@ -68,12 +70,12 @@ export default class PostStreamScrubber extends Component {
<div className="Scrubber-bar" />
<div className="Scrubber-info">
<strong>{viewing}</strong>
<span className="Scrubber-description">{this.stream.description}</span>
<span className="Scrubber-description"></span>
</div>
</div>
<div className="Scrubber-after" />
<div className="Scrubber-unread" config={styleUnread}>
<div className="Scrubber-unread" oncreate={styleUnread} onupdate={styleUnread}>
{app.translator.trans('core.forum.post_scrubber.unread_text', { count: unreadCount })}
</div>
</div>
@@ -87,11 +89,12 @@ export default class PostStreamScrubber extends Component {
);
}
config(isInitialized, context) {
onupdate() {
this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true }));
if (isInitialized) return;
}
context.onunload = this.ondestroy.bind(this);
oncreate(vnode) {
super.oncreate(vnode);
// Whenever the window is resized, adjust the height of the scrollbar
// so that it fills the height of the sidebar.
@@ -133,6 +136,15 @@ export default class PostStreamScrubber extends Component {
.on('mouseup touchend', (this.handlers.onmouseup = this.onmouseup.bind(this)));
setTimeout(() => this.scrollListener.start());
this.updateScrubberValues({ animate: true, forceHeightChange: true });
}
onremove() {
this.scrollListener.stop();
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
}
/**
@@ -196,13 +208,6 @@ export default class PostStreamScrubber extends Component {
this.updateScrubberValues({ animate: true, forceHeightChange: true });
}
ondestroy() {
this.scrollListener.stop();
$(window).off('resize', this.handlers.onresize);
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
}
onresize() {
// Adjust the height of the scrollbar so that it fills the height of
// the sidebar and doesn't overlap the footer.

View File

@@ -8,13 +8,13 @@ import listItems from '../../common/helpers/listItems';
/**
* The `PostUser` component shows the avatar and username of a post's author.
*
* ### Props
* ### Attrs
*
* - `post`
*/
export default class PostUser extends Component {
view() {
const post = this.props.post;
const post = this.attrs.post;
const user = post.user();
if (!user) {
@@ -29,7 +29,7 @@ export default class PostUser extends Component {
let card = '';
if (!post.isHidden() && this.props.cardVisible) {
if (!post.isHidden() && this.attrs.cardVisible) {
card = UserCard.component({
user,
className: 'UserCard--popover',
@@ -40,7 +40,7 @@ export default class PostUser extends Component {
return (
<div className="PostUser">
<h3>
<a href={app.route.user(user)} config={m.route}>
<a route={app.route.user(user)}>
{avatar(user, { className: 'PostUser-avatar' })}
{userOnline(user)}
{username(user)}
@@ -52,8 +52,8 @@ export default class PostUser extends Component {
);
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
let timeout;
@@ -72,7 +72,7 @@ export default class PostUser extends Component {
* Show the user card.
*/
showCard() {
this.props.oncardshow();
this.attrs.oncardshow();
setTimeout(() => this.$('.UserCard').addClass('in'));
}
@@ -84,7 +84,7 @@ export default class PostUser extends Component {
this.$('.UserCard')
.removeClass('in')
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
this.props.oncardhide();
this.attrs.oncardhide();
});
}
}

View File

@@ -9,8 +9,8 @@ import CommentPost from './CommentPost';
* profile.
*/
export default class PostsUserPage extends UserPage {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* Whether or not the activity feed is currently loading.
@@ -55,15 +55,13 @@ export default class PostsUserPage extends UserPage {
let footer;
if (this.loading) {
footer = LoadingIndicator.component();
footer = <LoadingIndicator />;
} else if (this.moreResults) {
footer = (
<div className="PostsUserPage-loadMore">
{Button.component({
children: app.translator.trans('core.forum.user.posts_load_more_button'),
className: 'Button',
onclick: this.loadMore.bind(this),
})}
<Button className="Button" onclick={this.loadMore.bind(this)}>
{app.translator.trans('core.forum.user.posts_load_more_button')}
</Button>
</div>
);
}
@@ -75,14 +73,11 @@ export default class PostsUserPage extends UserPage {
<li>
<div className="PostsUserPage-discussion">
{app.translator.trans('core.forum.user.in_discussion_text', {
discussion: (
<a href={app.route.post(post)} config={m.route}>
{post.discussion().title()}
</a>
),
discussion: <a route={app.route.post(post)}>{post.discussion().title()}</a>,
})}
</div>
{CommentPost.component({ post })}
<CommentPost post={post} />
</li>
))}
</ul>
@@ -110,7 +105,7 @@ export default class PostsUserPage extends UserPage {
this.loading = true;
this.posts = [];
m.lazyRedraw();
m.redraw();
this.loadResults().then(this.parseResults.bind(this));
}

View File

@@ -5,12 +5,12 @@ import Button from '../../common/components/Button';
* The 'RenameDiscussionModal' displays a modal dialog with an input to rename a discussion
*/
export default class RenameDiscussionModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
this.discussion = this.props.discussion;
this.currentTitle = this.props.currentTitle;
this.newTitle = m.prop(this.currentTitle);
this.discussion = this.attrs.discussion;
this.currentTitle = this.attrs.currentTitle;
this.newTitle = m.stream(this.currentTitle);
}
className() {
@@ -29,12 +29,14 @@ export default class RenameDiscussionModal extends Modal {
<input className="FormControl" bidi={this.newTitle} type="text" />
</div>
<div className="Form-group">
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.rename_discussion.submit_button'),
})}
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.rename_discussion.submit_button')
)}
</div>
</div>
</div>

View File

@@ -14,35 +14,29 @@ function minimizeComposerIfFullScreen(e) {
* The `ReplyComposer` component displays the composer content for replying to a
* discussion.
*
* ### Props
* ### Attrs
*
* - All of the props of ComposerBody
* - All of the attrs of ComposerBody
* - `discussion`
*/
export default class ReplyComposer extends ComposerBody {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.placeholder = props.placeholder || extractText(app.translator.trans('core.forum.composer_reply.body_placeholder'));
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_reply.submit_button');
props.confirmExit = props.confirmExit || extractText(app.translator.trans('core.forum.composer_reply.discard_confirmation'));
attrs.placeholder = attrs.placeholder || extractText(app.translator.trans('core.forum.composer_reply.body_placeholder'));
attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_reply.submit_button');
attrs.confirmExit = attrs.confirmExit || extractText(app.translator.trans('core.forum.composer_reply.discard_confirmation'));
}
headerItems() {
const items = super.headerItems();
const discussion = this.props.discussion;
const routeAndMinimize = function (element, isInitialized) {
if (isInitialized) return;
$(element).on('click', minimizeComposerIfFullScreen);
m.route.apply(this, arguments);
};
const discussion = this.attrs.discussion;
items.add(
'title',
<h3>
{icon('fas fa-reply')}{' '}
<a href={app.route.discussion(discussion)} config={routeAndMinimize}>
<a route={app.route.discussion(discussion)} onclick={minimizeComposerIfFullScreen}>
{discussion.title()}
</a>
</h3>
@@ -57,7 +51,7 @@ export default class ReplyComposer extends ComposerBody {
jumpToPreview(e) {
minimizeComposerIfFullScreen(e);
m.route(app.route.discussion(this.props.discussion, 'reply'));
m.route.set(app.route.discussion(this.attrs.discussion, 'reply'));
}
/**
@@ -68,12 +62,12 @@ export default class ReplyComposer extends ComposerBody {
data() {
return {
content: this.composer.fields.content(),
relationships: { discussion: this.props.discussion },
relationships: { discussion: this.attrs.discussion },
};
}
onsubmit() {
const discussion = this.props.discussion;
const discussion = this.attrs.discussion;
this.loading = true;
m.redraw();
@@ -94,19 +88,23 @@ export default class ReplyComposer extends ComposerBody {
// their reply has been posted, containing a button which will
// transition to their new post when clicked.
let alert;
const viewButton = Button.component({
className: 'Button Button--link',
children: app.translator.trans('core.forum.composer_reply.view_button'),
onclick: () => {
m.route(app.route.post(post));
app.alerts.dismiss(alert);
const viewButton = Button.component(
{
className: 'Button Button--link',
onclick: () => {
m.route.set(app.route.post(post));
app.alerts.dismiss(alert);
},
},
});
alert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.forum.composer_reply.posted_message'),
controls: [viewButton],
});
app.translator.trans('core.forum.composer_reply.view_button')
);
alert = app.alerts.show(
{
type: 'success',
controls: [viewButton],
},
app.translator.trans('core.forum.composer_reply.posted_message')
);
}
this.composer.hide();

View File

@@ -4,18 +4,19 @@ import Component from '../../common/Component';
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import DiscussionControls from '../utils/DiscussionControls';
import ComposerPostPreview from './ComposerPostPreview';
/**
* The `ReplyPlaceholder` component displays a placeholder for a reply, which,
* when clicked, opens the reply composer.
*
* ### Props
* ### Attrs
*
* - `discussion`
*/
export default class ReplyPlaceholder extends Component {
view() {
if (app.composer.composingReplyTo(this.props.discussion)) {
if (app.composer.composingReplyTo(this.attrs.discussion)) {
return (
<article className="Post CommentPost editing">
<header className="Post-header">
@@ -26,13 +27,13 @@ export default class ReplyPlaceholder extends Component {
</h3>
</div>
</header>
<div className="Post-body" config={this.configPreview.bind(this)} />
<ComposerPostPreview className="Post-body" composer={app.composer} surround={this.anchorPreview.bind(this)} />
</article>
);
}
const reply = () => {
DiscussionControls.replyAction.call(this.props.discussion, true);
DiscussionControls.replyAction.call(this.attrs.discussion, true);
};
return (
@@ -44,32 +45,13 @@ export default class ReplyPlaceholder extends Component {
);
}
configPreview(element, isInitialized, context) {
if (isInitialized) return;
anchorPreview(preview) {
const anchorToBottom = $(window).scrollTop() + $(window).height() >= $(document).height();
// Every 50ms, if the composer content has changed, then update the post's
// body with a preview.
let preview;
const updateInterval = setInterval(() => {
// Since we're polling, the composer may have been closed in the meantime,
// so we bail in that case.
if (!app.composer.isVisible()) return;
preview();
const content = app.composer.fields.content();
if (preview === content) return;
preview = content;
const anchorToBottom = $(window).scrollTop() + $(window).height() >= $(document).height();
s9e.TextFormatter.preview(preview || '', element);
if (anchorToBottom) {
$(window).scrollTop($(document).height());
}
}, 50);
context.onunload = () => clearInterval(updateInterval);
if (anchorToBottom) {
$(window).scrollTop($(document).height());
}
}
}

View File

@@ -16,13 +16,14 @@ import UsersSearchSource from './UsersSearchSource';
* getInitialSearch() value is a truthy value. If this is the case, an 'x'
* button will be shown next to the search field, and clicking it will clear the search.
*
* PROPS:
* ATTRS:
*
* - state: SearchState instance.
*/
export default class Search extends Component {
init() {
this.state = this.props.state;
oninit(vnode) {
super.oninit(vnode);
this.state = this.attrs.state;
/**
* Whether or not the search input has focus.
@@ -86,7 +87,7 @@ export default class Search extends Component {
type="search"
placeholder={extractText(app.translator.trans('core.forum.header.search_placeholder'))}
value={this.state.getValue()}
oninput={m.withAttr('value', this.state.setValue.bind(this.state))}
oninput={(e) => this.state.setValue(e.target.value)}
onfocus={() => (this.hasFocus = true)}
onblur={() => (this.hasFocus = false)}
/>
@@ -107,15 +108,20 @@ export default class Search extends Component {
);
}
config(isInitialized) {
onupdate() {
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
}
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
const search = this;
const state = this.state;
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
this.$('.Search-results')
.on('mousedown', (e) => e.preventDefault())
.on('click', () => this.$('input').blur())
@@ -179,7 +185,7 @@ export default class Search extends Component {
this.loadingSources = 0;
if (this.state.getValue()) {
m.route(this.getItem(this.index).find('a').attr('href'));
m.route.set(this.getItem(this.index).find('a').attr('href'));
} else {
this.clear();
}

View File

@@ -5,25 +5,22 @@ 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);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = 'SessionDropdown';
props.buttonClassName = 'Button Button--user Button--flat';
props.menuClassName = 'Dropdown-menu--right';
attrs.className = 'SessionDropdown';
attrs.buttonClassName = 'Button Button--user Button--flat';
attrs.menuClassName = 'Dropdown-menu--right';
}
view() {
this.props.children = this.items().toArray();
return super.view();
view(vnode) {
return super.view({ ...vnode, children: this.items().toArray() });
}
getButtonContent() {
@@ -43,34 +40,39 @@ export default class SessionDropdown extends Dropdown {
items.add(
'profile',
LinkButton.component({
icon: 'fas fa-user',
children: app.translator.trans('core.forum.header.profile_button'),
href: app.route.user(user),
}),
LinkButton.component(
{
icon: 'fas fa-user',
href: app.route.user(user),
},
app.translator.trans('core.forum.header.profile_button')
),
100
);
items.add(
'settings',
LinkButton.component({
icon: 'fas fa-cog',
children: app.translator.trans('core.forum.header.settings_button'),
href: app.route('settings'),
}),
LinkButton.component(
{
icon: 'fas fa-cog',
href: app.route('settings'),
},
app.translator.trans('core.forum.header.settings_button')
),
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: () => {},
}),
LinkButton.component(
{
icon: 'fas fa-wrench',
href: app.forum.attribute('adminUrl'),
target: '_blank',
},
app.translator.trans('core.forum.header.admin_button')
),
0
);
}
@@ -79,11 +81,13 @@ export default class SessionDropdown extends Dropdown {
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),
}),
Button.component(
{
icon: 'fas fa-sign-out-alt',
onclick: app.session.logout.bind(app.session),
},
app.translator.trans('core.forum.header.log_out_button')
),
-100
);

View File

@@ -13,10 +13,11 @@ import listItems from '../../common/helpers/listItems';
* the context of their user profile.
*/
export default class SettingsPage extends UserPage {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
this.show(app.session.user);
app.setTitle(app.translator.trans('core.forum.settings.title'));
}
@@ -36,32 +37,14 @@ export default class SettingsPage extends UserPage {
settingsItems() {
const items = new ItemList();
items.add(
'account',
FieldSet.component({
label: app.translator.trans('core.forum.settings.account_heading'),
className: 'Settings-account',
children: this.accountItems().toArray(),
})
);
items.add(
'notifications',
FieldSet.component({
label: app.translator.trans('core.forum.settings.notifications_heading'),
className: 'Settings-notifications',
children: this.notificationsItems().toArray(),
})
);
items.add(
'privacy',
FieldSet.component({
label: app.translator.trans('core.forum.settings.privacy_heading'),
className: 'Settings-privacy',
children: this.privacyItems().toArray(),
})
);
['account', 'notifications', 'privacy'].forEach((section) => {
items.add(
section,
<FieldSet className={`Settings-${section}`} label={app.translator.trans(`core.forum.settings.${section}_heading`)}>
{this[`${section}Items`]().toArray()}
</FieldSet>
);
});
return items;
}
@@ -76,20 +59,16 @@ export default class SettingsPage extends UserPage {
items.add(
'changePassword',
Button.component({
children: app.translator.trans('core.forum.settings.change_password_button'),
className: 'Button',
onclick: () => app.modal.show(ChangePasswordModal),
})
<Button className="Button" onclick={() => app.modal.show(ChangePasswordModal)}>
{app.translator.trans('core.forum.settings.change_password_button')}
</Button>
);
items.add(
'changeEmail',
Button.component({
children: app.translator.trans('core.forum.settings.change_email_button'),
className: 'Button',
onclick: () => app.modal.show(ChangeEmailModal),
})
<Button className="Button" onclick={() => app.modal.show(ChangeEmailModal)}>
{app.translator.trans('core.forum.settings.change_email_button')}
</Button>
);
return items;
@@ -103,31 +82,11 @@ export default class SettingsPage extends UserPage {
notificationsItems() {
const items = new ItemList();
items.add('notificationGrid', NotificationGrid.component({ user: this.user }));
items.add('notificationGrid', <NotificationGrid user={this.user} />);
return items;
}
/**
* @deprecated beta 14, remove in beta 15.
*
* Generate a callback that will save a value to the given preference.
*
* @param {String} key
* @return {Function}
*/
preferenceSaver(key) {
return (value, component) => {
if (component) component.props.loading = true;
m.redraw();
this.user.savePreferences({ [key]: value }).then(() => {
if (component) component.props.loading = false;
m.redraw();
});
};
}
/**
* Build an item list for the user's privacy settings.
*
@@ -138,19 +97,20 @@ export default class SettingsPage extends UserPage {
items.add(
'discloseOnline',
Switch.component({
children: app.translator.trans('core.forum.settings.privacy_disclose_online_label'),
state: this.user.preferences().discloseOnline,
onchange: (value) => {
<Switch
state={this.user.preferences().discloseOnline}
onchange={(value) => {
this.discloseOnlineLoading = true;
this.user.savePreferences({ discloseOnline: value }).then(() => {
this.discloseOnlineLoading = false;
m.redraw();
});
},
loading: this.discloseOnlineLoading,
})
}}
loading={this.discloseOnlineLoading}
>
{app.translator.trans('core.forum.settings.privacy_disclose_online_label')}
</Switch>
);
return items;

View File

@@ -8,7 +8,7 @@ import ItemList from '../../common/utils/ItemList';
/**
* The `SignUpModal` component displays a modal dialog with a singup form.
*
* ### Props
* ### Attrs
*
* - `username`
* - `email`
@@ -16,29 +16,29 @@ import ItemList from '../../common/utils/ItemList';
* - `token` An email token to sign up with.
*/
export default class SignUpModal extends Modal {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* The value of the username input.
*
* @type {Function}
*/
this.username = m.prop(this.props.username || '');
this.username = m.stream(this.attrs.username || '');
/**
* The value of the email input.
*
* @type {Function}
*/
this.email = m.prop(this.props.email || '');
this.email = m.stream(this.attrs.email || '');
/**
* The value of the password input.
*
* @type {Function}
*/
this.password = m.prop(this.props.password || '');
this.password = m.stream(this.attrs.password || '');
}
className() {
@@ -54,11 +54,11 @@ export default class SignUpModal extends Modal {
}
isProvided(field) {
return this.props.provided && this.props.provided.indexOf(field) !== -1;
return this.attrs.provided && this.attrs.provided.indexOf(field) !== -1;
}
body() {
return [this.props.token ? '' : <LogInButtons />, <div className="Form Form--centered">{this.fields().toArray()}</div>];
return [this.attrs.token ? '' : <LogInButtons />, <div className="Form Form--centered">{this.fields().toArray()}</div>];
}
fields() {
@@ -72,8 +72,7 @@ export default class SignUpModal extends Modal {
name="username"
type="text"
placeholder={extractText(app.translator.trans('core.forum.sign_up.username_placeholder'))}
value={this.username()}
onchange={m.withAttr('value', this.username)}
bidi={this.username}
disabled={this.loading || this.isProvided('username')}
/>
</div>,
@@ -88,15 +87,14 @@ export default class SignUpModal extends Modal {
name="email"
type="email"
placeholder={extractText(app.translator.trans('core.forum.sign_up.email_placeholder'))}
value={this.email()}
onchange={m.withAttr('value', this.email)}
bidi={this.email}
disabled={this.loading || this.isProvided('email')}
/>
</div>,
20
);
if (!this.props.token) {
if (!this.attrs.token) {
items.add(
'password',
<div className="Form-group">
@@ -105,8 +103,7 @@ export default class SignUpModal extends Modal {
name="password"
type="password"
placeholder={extractText(app.translator.trans('core.forum.sign_up.password_placeholder'))}
value={this.password()}
onchange={m.withAttr('value', this.password)}
bidi={this.password}
disabled={this.loading}
/>
</div>,
@@ -140,16 +137,16 @@ export default class SignUpModal extends Modal {
* @public
*/
logIn() {
const props = {
const attrs = {
identification: this.email() || this.username(),
password: this.password(),
};
app.modal.show(LogInModal, props);
app.modal.show(LogInModal, attrs);
}
onready() {
if (this.props.username && !this.props.email) {
if (this.attrs.username && !this.attrs.email) {
this.$('[name=email]').select();
} else {
this.$('[name=username]').select();
@@ -161,13 +158,13 @@ export default class SignUpModal extends Modal {
this.loading = true;
const data = this.submitData();
const body = this.submitData();
app
.request({
url: app.forum.attribute('baseUrl') + '/register',
method: 'POST',
data,
body,
errorHandler: this.onerror.bind(this),
})
.then(() => window.location.reload(), this.loaded.bind(this));
@@ -185,8 +182,8 @@ export default class SignUpModal extends Modal {
email: this.email(),
};
if (this.props.token) {
data.token = this.props.token;
if (this.attrs.token) {
data.token = this.attrs.token;
} else {
data.password = this.password();
}

View File

@@ -5,15 +5,15 @@ import icon from '../../common/helpers/icon';
/**
* Displays information about a the first or last post in a discussion.
*
* ### Props
* ### Attrs
*
* - `discussion`
* - `lastPost`
*/
export default class TerminalPost extends Component {
view() {
const discussion = this.props.discussion;
const lastPost = this.props.lastPost && discussion.replyCount();
const discussion = this.attrs.discussion;
const lastPost = this.attrs.lastPost && discussion.replyCount();
const user = discussion[lastPost ? 'lastPostedUser' : 'user']();
const time = discussion[lastPost ? 'lastPostedAt' : 'createdAt']();

View File

@@ -8,7 +8,7 @@ import Button from '../../common/components/Button';
* The `TextEditor` component displays a textarea with controls, including a
* submit button.
*
* ### Props
* ### Attrs
*
* - `composer`
* - `submitLabel`
@@ -18,13 +18,15 @@ import Button from '../../common/components/Button';
* - `preview`
*/
export default class TextEditor extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
/**
* The value of the textarea.
*
* @type {String}
*/
this.value = this.props.value || '';
this.value = this.attrs.value || '';
}
view() {
@@ -32,10 +34,11 @@ export default class TextEditor extends Component {
<div className="TextEditor">
<textarea
className="FormControl Composer-flexible"
config={this.configTextarea.bind(this)}
oninput={m.withAttr('value', this.oninput.bind(this))}
placeholder={this.props.placeholder || ''}
disabled={!!this.props.disabled}
oninput={(e) => {
this.oninput(e.target.value, e);
}}
placeholder={this.attrs.placeholder || ''}
disabled={!!this.attrs.disabled}
value={this.value}
/>
@@ -47,24 +50,18 @@ export default class TextEditor extends Component {
);
}
/**
* Configure the textarea element.
*
* @param {HTMLTextAreaElement} element
* @param {Boolean} isInitialized
*/
configTextarea(element, isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
const handler = () => {
this.onsubmit();
m.redraw();
};
$(element).bind('keydown', 'meta+return', handler);
$(element).bind('keydown', 'ctrl+return', handler);
this.$('textarea').bind('keydown', 'meta+return', handler);
this.$('textarea').bind('keydown', 'ctrl+return', handler);
this.props.composer.editor = new SuperTextarea(element);
this.attrs.composer.editor = new SuperTextarea(this.$('textarea')[0]);
}
/**
@@ -77,24 +74,26 @@ export default class TextEditor extends Component {
items.add(
'submit',
Button.component({
children: this.props.submitLabel,
icon: 'fas fa-paper-plane',
className: 'Button Button--primary',
itemClassName: 'App-primaryControl',
onclick: this.onsubmit.bind(this),
})
Button.component(
{
icon: 'fas fa-paper-plane',
className: 'Button Button--primary',
itemClassName: 'App-primaryControl',
onclick: this.onsubmit.bind(this),
},
this.attrs.submitLabel
)
);
if (this.props.preview) {
if (this.attrs.preview) {
items.add(
'preview',
Button.component({
icon: 'far fa-eye',
className: 'Button Button--icon',
onclick: this.props.preview,
onclick: this.attrs.preview,
title: app.translator.trans('core.forum.composer.preview_tooltip'),
config: (elm) => $(elm).tooltip(),
oncreate: (vnode) => $(vnode.dom).tooltip(),
})
);
}
@@ -116,18 +115,18 @@ export default class TextEditor extends Component {
*
* @param {String} value
*/
oninput(value) {
oninput(value, e) {
this.value = value;
this.props.onchange(this.value);
this.attrs.onchange(this.value);
m.redraw.strategy('none');
e.redraw = false;
}
/**
* Handle the submit button being clicked.
*/
onsubmit() {
this.props.onsubmit(this.value);
this.attrs.onsubmit(this.value);
}
}

View File

@@ -5,16 +5,14 @@ import Button from '../../common/components/Button';
* editor toolbar.
*/
export default class TextEditorButton extends Button {
static initProps(props) {
super.initProps(props);
static initAttrs(attrs) {
super.initAttrs(attrs);
props.className = props.className || 'Button Button--icon Button--link';
attrs.className = attrs.className || 'Button Button--icon Button--link';
}
config(isInitialized, context) {
super.config(isInitialized, context);
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
this.$().tooltip();
}

View File

@@ -14,7 +14,7 @@ import listItems from '../../common/helpers/listItems';
* the `UserPage` (in the hero) and in discussions, shown when hovering over a
* post author.
*
* ### Props
* ### Attrs
*
* - `user`
* - `className`
@@ -23,32 +23,34 @@ import listItems from '../../common/helpers/listItems';
*/
export default class UserCard extends Component {
view() {
const user = this.props.user;
const user = this.attrs.user;
const controls = UserControls.controls(user, this).toArray();
const color = user.color();
const badges = user.badges().toArray();
return (
<div className={'UserCard ' + (this.props.className || '')} style={color ? { backgroundColor: color } : ''}>
<div className={'UserCard ' + (this.attrs.className || '')} style={color ? { backgroundColor: color } : ''}>
<div className="darkenBackground">
<div className="container">
{controls.length
? Dropdown.component({
children: controls,
className: 'UserCard-controls App-primaryControl',
menuClassName: 'Dropdown-menu--right',
buttonClassName: this.props.controlsButtonClassName,
label: app.translator.trans('core.forum.user_controls.button'),
icon: 'fas fa-ellipsis-v',
})
? Dropdown.component(
{
className: 'UserCard-controls App-primaryControl',
menuClassName: 'Dropdown-menu--right',
buttonClassName: this.attrs.controlsButtonClassName,
label: app.translator.trans('core.forum.user_controls.button'),
icon: 'fas fa-ellipsis-v',
},
controls
)
: ''}
<div className="UserCard-profile">
<h2 className="UserCard-identity">
{this.props.editable ? (
{this.attrs.editable ? (
[AvatarEditor.component({ user, className: 'UserCard-avatar' }), username(user)]
) : (
<a href={app.route.user(user)} config={m.route}>
<a route={app.route.user(user)}>
<div className="UserCard-avatar">{avatar(user)}</div>
{username(user)}
</a>
@@ -72,7 +74,7 @@ export default class UserCard extends Component {
*/
infoItems() {
const items = new ItemList();
const user = this.props.user;
const user = this.attrs.user;
const lastSeenAt = user.lastSeenAt();
if (lastSeenAt) {

View File

@@ -1,12 +1,12 @@
import Page from '../../common/components/Page';
import ItemList from '../../common/utils/ItemList';
import affixSidebar from '../utils/affixSidebar';
import UserCard from './UserCard';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import SelectDropdown from '../../common/components/SelectDropdown';
import LinkButton from '../../common/components/LinkButton';
import Separator from '../../common/components/Separator';
import listItems from '../../common/helpers/listItems';
import AffixedSidebar from './AffixedSidebar';
/**
* The `UserPage` component shows a user's profile. It can be extended to show
@@ -16,8 +16,8 @@ import listItems from '../../common/helpers/listItems';
* @abstract
*/
export default class UserPage extends Page {
init() {
super.init();
oninit(vnode) {
super.oninit(vnode);
/**
* The user this page is for.
@@ -27,6 +27,17 @@ export default class UserPage extends Page {
this.user = null;
this.bodyClass = 'App--user';
this.prevUsername = m.route.param('username');
}
onbeforeupdate() {
const currUsername = m.route.param('username');
if (currUsername !== this.prevUsername) {
this.prevUsername = currUsername;
this.loadUser(currUsername);
}
}
view() {
@@ -34,22 +45,24 @@ export default class UserPage extends Page {
<div className="UserPage">
{this.user
? [
UserCard.component({
user: this.user,
className: 'Hero UserHero',
editable: this.user.canEdit() || this.user === app.session.user,
controlsButtonClassName: 'Button',
}),
<UserCard
user={this.user}
className="Hero UserHero"
editable={this.user.canEdit() || this.user === app.session.user}
controlsButtonClassName="Button"
/>,
<div className="container">
<div className="sideNavContainer">
<nav className="sideNav UserPage-nav" config={affixSidebar}>
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
<AffixedSidebar>
<nav className="sideNav UserPage-nav">
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
</AffixedSidebar>
<div className="sideNavOffset UserPage-content">{this.content()}</div>
</div>
</div>,
]
: [LoadingIndicator.component({ className: 'LoadingIndicator--block' })]}
: [<LoadingIndicator className="LoadingIndicator--block" />]}
</div>
);
}
@@ -114,11 +127,9 @@ export default class UserPage extends Page {
items.add(
'nav',
SelectDropdown.component({
children: this.navItems().toArray(),
className: 'App-titleControl',
buttonClassName: 'Button',
})
<SelectDropdown className="App-titleControl" buttonClassName="Button">
{this.navItems().toArray()}
</SelectDropdown>
);
return items;
@@ -135,33 +146,27 @@ export default class UserPage extends Page {
items.add(
'posts',
LinkButton.component({
href: app.route('user.posts', { username: user.username() }),
children: [app.translator.trans('core.forum.user.posts_link'), <span className="Button-badge">{user.commentCount()}</span>],
icon: 'far fa-comment',
}),
<LinkButton href={app.route('user.posts', { username: user.username() })} force icon="far fa-comment">
{app.translator.trans('core.forum.user.posts_link')} <span className="Button-badge">{user.commentCount()}</span>
</LinkButton>,
100
);
items.add(
'discussions',
LinkButton.component({
href: app.route('user.discussions', { username: user.username() }),
children: [app.translator.trans('core.forum.user.discussions_link'), <span className="Button-badge">{user.discussionCount()}</span>],
icon: 'fas fa-bars',
}),
<LinkButton href={app.route('user.discussions', { username: user.username() })} force icon="fas fa-bars">
{app.translator.trans('core.forum.user.discussions_link')} <span className="Button-badge">{user.discussionCount()}</span>
</LinkButton>,
90
);
if (app.session.user === user) {
items.add('separator', Separator.component(), -90);
items.add('separator', <Separator />, -90);
items.add(
'settings',
LinkButton.component({
href: app.route('settings'),
children: app.translator.trans('core.forum.user.settings_link'),
icon: 'fas fa-cog',
}),
<LinkButton href={app.route('settings')} icon="fas fa-cog">
{app.translator.trans('core.forum.user.settings_link')}
</LinkButton>,
-100
);
}

View File

@@ -43,13 +43,14 @@ export default class UsersSearchResults {
<li className="Dropdown-header">{app.translator.trans('core.forum.search.users_heading')}</li>,
results.map((user) => {
const name = username(user);
name.children[0] = highlight(name.children[0], query);
const children = [highlight(name.text, query)];
return (
<li className="UserSearchResult" data-index={'users' + user.id()}>
<a href={app.route.user(user)} config={m.route}>
<a route={app.route.user(user)}>
{avatar(user)}
{name}
{{ ...name, text: undefined, children }}
</a>
</li>
);

View File

@@ -6,7 +6,9 @@ import Button from '../../common/components/Button';
* forum.
*/
export default class WelcomeHero extends Component {
init() {
oninit(vnode) {
super.oninit(vnode);
this.hidden = localStorage.getItem('welcomeHidden');
}

View File

@@ -12,18 +12,17 @@ import NotificationsPage from './components/NotificationsPage';
*/
export default function (app) {
app.routes = {
index: { path: '/all', component: IndexPage.component() },
'index.filter': { path: '/:filter', component: IndexPage.component() },
index: { path: '/all', component: IndexPage },
discussion: { path: '/d/:id', component: DiscussionPage.component() },
'discussion.near': { path: '/d/:id/:near', component: DiscussionPage.component() },
discussion: { path: '/d/:id', component: DiscussionPage },
'discussion.near': { path: '/d/:id/:near', component: DiscussionPage },
user: { path: '/u/:username', component: PostsUserPage.component() },
'user.posts': { path: '/u/:username', component: PostsUserPage.component() },
'user.discussions': { path: '/u/:username/discussions', component: DiscussionsUserPage.component() },
user: { path: '/u/:username', component: PostsUserPage },
'user.posts': { path: '/u/:username', component: PostsUserPage },
'user.discussions': { path: '/u/:username/discussions', component: DiscussionsUserPage },
settings: { path: '/settings', component: SettingsPage.component() },
notifications: { path: '/notifications', component: NotificationsPage.component() },
settings: { path: '/settings', component: SettingsPage },
notifications: { path: '/notifications', component: NotificationsPage },
};
/**

View File

@@ -58,7 +58,7 @@ class ComposerState {
// on a blank slate.
if (this.isVisible()) {
this.clear();
m.redraw(true);
m.redraw.sync();
}
this.body = body;
@@ -74,7 +74,7 @@ class ComposerState {
this.onExit = null;
this.fields = {
content: m.prop(''),
content: m.stream(''),
};
/**
@@ -93,7 +93,7 @@ class ComposerState {
if (this.position === ComposerState.Position.NORMAL || this.position === ComposerState.Position.FULLSCREEN) return;
this.position = ComposerState.Position.NORMAL;
m.redraw(true);
m.redraw.sync();
}
/**

View File

@@ -1,3 +1,4 @@
import setRouteWithForcedRefresh from '../../common/utils/setRouteWithForcedRefresh';
import SearchState from './SearchState';
export default class GlobalSearchState extends SearchState {
@@ -66,7 +67,7 @@ export default class GlobalSearchState extends SearchState {
params.sort = sort;
}
m.route(app.route(this.searchRoute, params));
setRouteWithForcedRefresh(app.route(app.current.get('routeName'), params));
}
/**
@@ -77,7 +78,7 @@ export default class GlobalSearchState extends SearchState {
* @return {String}
*/
getInitialSearch() {
return app.current.type.providesInitialSearch && this.params().q;
return app.current.type && app.current.type.providesInitialSearch && this.params().q;
}
/**
@@ -90,6 +91,6 @@ export default class GlobalSearchState extends SearchState {
const params = this.params();
delete params.q;
m.route(app.route(this.searchRoute, params));
setRouteWithForcedRefresh(app.route(this.searchRoute, params));
}
}

View File

@@ -47,7 +47,7 @@ class PostStreamState {
* @public
*/
update() {
if (!this.viewingEnd()) return m.deferred().resolve().promise;
if (!this.viewingEnd()) return Promise.resolve();
this.visibleEnd = this.count();
@@ -134,7 +134,7 @@ class PostStreamState {
*/
loadNearNumber(number) {
if (this.posts().some((post) => post && Number(post.number()) === Number(number))) {
return m.deferred().resolve().promise;
return Promise.resolve();
}
this.reset();
@@ -157,7 +157,7 @@ class PostStreamState {
*/
loadNearIndex(index) {
if (index >= this.visibleStart && index <= this.visibleEnd) {
return m.deferred().resolve().promise;
return Promise.resolve();
}
const start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
@@ -229,7 +229,7 @@ class PostStreamState {
this.loadRange(start, end).then(() => {
if (start >= this.visibleStart && end <= this.visibleEnd) {
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw(true));
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw.sync());
}
this.pagesLoading--;
});
@@ -266,7 +266,7 @@ class PostStreamState {
}
});
return loadIds.length ? app.store.find('posts', loadIds) : m.deferred().resolve(loaded).promise;
return loadIds.length ? app.store.find('posts', loadIds) : Promise.resolve(loaded);
}
/**

View File

@@ -55,19 +55,29 @@ export default {
items.add(
'reply',
!app.session.user || discussion.canReply()
? Button.component({
icon: 'fas fa-reply',
children: app.translator.trans(
? Button.component(
{
icon: 'fas fa-reply',
onclick: () => {
// If the user is not logged in, the promise rejects, and a login modal shows up.
// Since that's already handled, we dont need to show an error message in the console.
return this.replyAction
.bind(discussion)(true, false)
.catch(() => {});
},
},
app.translator.trans(
app.session.user ? 'core.forum.discussion_controls.reply_button' : 'core.forum.discussion_controls.log_in_to_reply_button'
),
onclick: this.replyAction.bind(discussion, true, false),
})
: Button.component({
icon: 'fas fa-reply',
children: app.translator.trans('core.forum.discussion_controls.cannot_reply_button'),
className: 'disabled',
title: app.translator.trans('core.forum.discussion_controls.cannot_reply_text'),
})
)
)
: Button.component(
{
icon: 'fas fa-reply',
className: 'disabled',
title: app.translator.trans('core.forum.discussion_controls.cannot_reply_text'),
},
app.translator.trans('core.forum.discussion_controls.cannot_reply_button')
)
);
}
@@ -89,11 +99,13 @@ export default {
if (discussion.canRename()) {
items.add(
'rename',
Button.component({
icon: 'fas fa-pencil-alt',
children: app.translator.trans('core.forum.discussion_controls.rename_button'),
onclick: this.renameAction.bind(discussion),
})
Button.component(
{
icon: 'fas fa-pencil-alt',
onclick: this.renameAction.bind(discussion),
},
app.translator.trans('core.forum.discussion_controls.rename_button')
)
);
}
@@ -116,33 +128,39 @@ export default {
if (discussion.canHide()) {
items.add(
'hide',
Button.component({
icon: 'far fa-trash-alt',
children: app.translator.trans('core.forum.discussion_controls.delete_button'),
onclick: this.hideAction.bind(discussion),
})
Button.component(
{
icon: 'far fa-trash-alt',
onclick: this.hideAction.bind(discussion),
},
app.translator.trans('core.forum.discussion_controls.delete_button')
)
);
}
} else {
if (discussion.canHide()) {
items.add(
'restore',
Button.component({
icon: 'fas fa-reply',
children: app.translator.trans('core.forum.discussion_controls.restore_button'),
onclick: this.restoreAction.bind(discussion),
})
Button.component(
{
icon: 'fas fa-reply',
onclick: this.restoreAction.bind(discussion),
},
app.translator.trans('core.forum.discussion_controls.restore_button')
)
);
}
if (discussion.canDelete()) {
items.add(
'delete',
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.discussion_controls.delete_forever_button'),
onclick: this.deleteAction.bind(discussion),
})
Button.component(
{
icon: 'fas fa-times',
onclick: this.deleteAction.bind(discussion),
},
app.translator.trans('core.forum.discussion_controls.delete_forever_button')
)
);
}
}
@@ -163,33 +181,31 @@ export default {
* @return {Promise}
*/
replyAction(goToLast, forceRefresh) {
const deferred = m.deferred();
return new Promise((resolve, reject) => {
if (app.session.user) {
if (this.canReply()) {
if (!app.composer.composingReplyTo(this) || forceRefresh) {
app.composer.load(ReplyComposer, {
user: app.session.user,
discussion: this,
});
}
app.composer.show();
if (app.session.user) {
if (this.canReply()) {
if (!app.composer.composingReplyTo(this) || forceRefresh) {
app.composer.load(ReplyComposer, {
user: app.session.user,
discussion: this,
});
if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) {
app.current.get('stream').goToNumber('reply');
}
return resolve(app.composer);
} else {
return reject();
}
app.composer.show();
if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) {
app.current.get('stream').goToNumber('reply');
}
deferred.resolve(app.composer);
} else {
deferred.reject();
}
} else {
deferred.reject();
app.modal.show(LogInModal);
}
return deferred.promise;
return reject();
});
},
/**

View File

@@ -1,3 +1,5 @@
import setRouteWithForcedRefresh from '../../common/utils/setRouteWithForcedRefresh';
/**
* The `History` class keeps track and manages a stack of routes that the user
* has navigated to in their session.
@@ -49,7 +51,7 @@ export default class History {
* not provided.
* @public
*/
push(name, title, url = m.route()) {
push(name, title, url = m.route.get()) {
// If we're pushing an item with the same name as second-to-top item in the
// stack, we will assume that the user has clicked the 'back' button in
// their browser. In this case, we don't want to push a new item, so we will
@@ -92,7 +94,7 @@ export default class History {
this.stack.pop();
m.route(this.getCurrent().url);
m.route.set(this.getCurrent().url);
}
/**
@@ -114,6 +116,6 @@ export default class History {
home() {
this.stack.splice(0);
m.route('/');
setRouteWithForcedRefresh('/');
}
}

View File

@@ -61,11 +61,13 @@ export default {
if (!post.isHidden()) {
items.add(
'edit',
Button.component({
icon: 'fas fa-pencil-alt',
children: app.translator.trans('core.forum.post_controls.edit_button'),
onclick: this.editAction.bind(post),
})
Button.component(
{
icon: 'fas fa-pencil-alt',
onclick: this.editAction.bind(post),
},
app.translator.trans('core.forum.post_controls.edit_button')
)
);
}
}
@@ -89,32 +91,38 @@ export default {
if (post.canHide()) {
items.add(
'hide',
Button.component({
icon: 'far fa-trash-alt',
children: app.translator.trans('core.forum.post_controls.delete_button'),
onclick: this.hideAction.bind(post),
})
Button.component(
{
icon: 'far fa-trash-alt',
onclick: this.hideAction.bind(post),
},
app.translator.trans('core.forum.post_controls.delete_button')
)
);
}
} else {
if (post.contentType() === 'comment' && post.canHide()) {
items.add(
'restore',
Button.component({
icon: 'fas fa-reply',
children: app.translator.trans('core.forum.post_controls.restore_button'),
onclick: this.restoreAction.bind(post),
})
Button.component(
{
icon: 'fas fa-reply',
onclick: this.restoreAction.bind(post),
},
app.translator.trans('core.forum.post_controls.restore_button')
)
);
}
if (post.canDelete()) {
items.add(
'delete',
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.post_controls.delete_forever_button'),
onclick: this.deleteAction.bind(post, context),
})
Button.component(
{
icon: 'fas fa-times',
onclick: this.deleteAction.bind(post, context),
},
app.translator.trans('core.forum.post_controls.delete_forever_button')
)
);
}
}
@@ -128,14 +136,12 @@ export default {
* @return {Promise}
*/
editAction() {
const deferred = m.deferred();
return new Promise((resolve) => {
app.composer.load(EditPostComposer, { post: this });
app.composer.show();
app.composer.load(EditPostComposer, { post: this });
app.composer.show();
deferred.resolve(app.composer);
return deferred.promise;
return resolve();
});
},
/**

View File

@@ -25,7 +25,7 @@ export default {
const controls = this[section + 'Controls'](user, context).toArray();
if (controls.length) {
controls.forEach((item) => items.add(item.itemName, item));
items.add(section + 'Separator', Separator.component());
items.add(section + 'Separator', <Separator />);
}
});
@@ -60,11 +60,9 @@ export default {
if (user.canEdit()) {
items.add(
'edit',
Button.component({
icon: 'fas fa-pencil-alt',
children: app.translator.trans('core.forum.user_controls.edit_button'),
onclick: this.editAction.bind(this, user),
})
<Button icon="fas fa-pencil-alt" onclick={this.editAction.bind(this, user)}>
{app.translator.trans('core.forum.user_controls.edit_button')}
</Button>
);
}
@@ -86,11 +84,9 @@ export default {
if (user.id() !== '1' && user.canDelete()) {
items.add(
'delete',
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.user_controls.delete_button'),
onclick: this.deleteAction.bind(this, user),
})
<Button icon="fas fa-times" onclick={this.deleteAction.bind(this, user)}>
{app.translator.trans('core.forum.user_controls.delete_button')}
</Button>
);
}
@@ -133,10 +129,7 @@ export default {
error: 'core.forum.user_controls.delete_error_message',
}[type];
app.alerts.show({
type,
children: app.translator.trans(message, { username, email }),
});
app.alerts.show({ type }, app.translator.trans(message, { username, email }));
},
/**

View File

@@ -1,39 +0,0 @@
/**
* Setup the sidebar DOM element to be affixed to the top of the viewport
* using Bootstrap's affix plugin.
*
* @param {DOMElement} element
* @param {Boolean} isInitialized
* @param {Object} context
*/
export default function affixSidebar(element, isInitialized, context) {
if (isInitialized) return;
const onresize = () => {
const $sidebar = $(element);
const $header = $('#header');
const $footer = $('#footer');
const $affixElement = $sidebar.find('> ul');
$(window).off('.affix');
$affixElement.removeClass('affix affix-top affix-bottom').removeData('bs.affix');
// Don't affix the sidebar if it is taller than the viewport (otherwise
// there would be no way to scroll through its content).
if ($sidebar.outerHeight(true) > $(window).height() - $header.outerHeight(true)) return;
$affixElement.affix({
offset: {
top: () => $sidebar.offset().top - $header.outerHeight(true) - parseInt($sidebar.css('margin-top'), 10),
bottom: () => (this.bottom = $footer.outerHeight(true)),
},
});
};
// Register the affix plugin to execute on every window resize (and trigger)
$(window).on('resize', onresize).resize();
context.onunload = () => {
$(window).off('resize', onresize);
};
}

View File

@@ -1,22 +1,38 @@
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
import icon from '../../common/helpers/icon';
import Component from '../../common/Component';
/**
* Shows an alert if the user has not yet confirmed their email address.
*
* @param {ForumApp} app
* @param {ForumApplication} app
*/
export default function alertEmailConfirmation(app) {
const user = app.session.user;
if (!user || user.isEmailConfirmed()) return;
const resendButton = Button.component({
className: 'Button Button--link',
children: app.translator.trans('core.forum.user_email_confirmation.resend_button'),
onclick: function () {
resendButton.props.loading = true;
class ResendButton extends Component {
oninit(vnode) {
super.oninit(vnode);
this.loading = false;
this.sent = false;
}
view() {
return (
<Button class="Button Button--link" onclick={this.onclick.bind(this)} loading={this.loading} disabled={this.sent}>
{this.sent
? [icon('fas fa-check'), ' ', app.translator.trans('core.forum.user_email_confirmation.sent_message')]
: app.translator.trans('core.forum.user_email_confirmation.resend_button')}
</Button>
);
}
onclick() {
this.loading = true;
m.redraw();
app
@@ -25,34 +41,24 @@ export default function alertEmailConfirmation(app) {
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/send-confirmation',
})
.then(() => {
resendButton.props.loading = false;
resendButton.props.children = [icon('fas fa-check'), ' ', app.translator.trans('core.forum.user_email_confirmation.sent_message')];
resendButton.props.disabled = true;
this.loading = false;
this.sent = true;
m.redraw();
})
.catch(() => {
resendButton.props.loading = false;
this.loading = false;
m.redraw();
});
},
});
class ContainedAlert extends Alert {
view() {
const vdom = super.view();
vdom.children = [<div className="container">{vdom.children}</div>];
return vdom;
}
}
m.mount(
$('<div/>').insertBefore('#content')[0],
ContainedAlert.component({
dismissible: false,
children: app.translator.trans('core.forum.user_email_confirmation.alert_message', { email: <strong>{user.email()}</strong> }),
controls: [resendButton],
})
);
m.mount($('<div/>').insertBefore('#content')[0], {
view: () => (
<Alert dismissible={false} controls={[<ResendButton />]}>
<div className="container">
{app.translator.trans('core.forum.user_email_confirmation.alert_message', { email: <strong>{user.email()}</strong> })}
</div>
</Alert>
),
});
}