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:
committed by
GitHub
parent
1321b8cc28
commit
71f3379fcc
@@ -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
|
||||
*/
|
||||
|
@@ -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,
|
||||
|
51
js/src/forum/components/AffixedSidebar.js
Normal file
51
js/src/forum/components/AffixedSidebar.js
Normal 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)),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@@ -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();
|
||||
|
@@ -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);
|
||||
|
@@ -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));
|
||||
}
|
||||
|
@@ -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',
|
||||
|
@@ -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) {
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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';
|
||||
}
|
||||
}
|
||||
|
54
js/src/forum/components/ComposerPostPreview.js
Normal file
54
js/src/forum/components/ComposerPostPreview.js
Normal 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);
|
||||
}
|
||||
}
|
@@ -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));
|
||||
}
|
||||
}
|
||||
|
@@ -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) {
|
||||
|
@@ -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()) {
|
||||
|
@@ -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(),
|
||||
})
|
||||
);
|
||||
|
67
js/src/forum/components/DiscussionListPane.js
Normal file
67
js/src/forum/components/DiscussionListPane.js
Normal 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');
|
||||
}
|
||||
}
|
@@ -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());
|
||||
|
@@ -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() });
|
||||
}
|
||||
}
|
||||
|
@@ -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];
|
||||
|
||||
|
@@ -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>
|
||||
|
@@ -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'));
|
||||
}
|
||||
|
@@ -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();
|
||||
|
@@ -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(() => {
|
||||
|
@@ -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>
|
||||
) : (
|
||||
|
@@ -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);
|
||||
|
@@ -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.
|
||||
*
|
||||
|
@@ -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
|
||||
);
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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() {
|
||||
|
@@ -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');
|
||||
|
||||
|
@@ -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');
|
||||
|
@@ -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;
|
||||
|
@@ -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 (
|
||||
|
@@ -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>
|
||||
|
@@ -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();
|
||||
|
@@ -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.
|
||||
|
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -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));
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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();
|
||||
|
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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
|
||||
);
|
||||
|
||||
|
@@ -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;
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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']();
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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) {
|
||||
|
@@ -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
|
||||
);
|
||||
}
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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');
|
||||
}
|
||||
|
||||
|
@@ -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 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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));
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
@@ -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('/');
|
||||
}
|
||||
}
|
||||
|
@@ -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();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
@@ -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 }));
|
||||
},
|
||||
|
||||
/**
|
||||
|
@@ -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);
|
||||
};
|
||||
}
|
@@ -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>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
Reference in New Issue
Block a user