1
0
mirror of https://github.com/flarum/core.git synced 2025-10-16 01:07:09 +02:00
* Replace gulp with webpack and npm scripts for JS compilation
* Set up Travis CI to commit compiled JS
* Restructure `js` directory; only one instance of npm, forum/admin are "submodules"
* Refactor JS initializers into Application subclasses
* Maintain partial compatibility API (importing from absolute paths) for extensions
* Remove minification responsibility from PHP asset compiler
* Restructure `less` directory
This commit is contained in:
Toby Zerner
2018-06-20 13:20:31 +09:30
committed by GitHub
parent d234badbb2
commit 3f683dd6ee
235 changed files with 9351 additions and 57639 deletions

View File

@@ -0,0 +1,227 @@
import Component from '../../common/Component';
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 Button from '../../common/components/Button';
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
*
* - `className`
* - `user`
*/
export default class AvatarEditor extends Component {
init() {
/**
* Whether or not an avatar upload is in progress.
*
* @type {Boolean}
*/
this.loading = false;
/**
* Whether or not an image has been dragged over the dropzone.
*
* @type {Boolean}
*/
this.isDraggedOver = false;
}
static initProps(props) {
super.initProps(props);
props.className = props.className || '';
}
view() {
const user = this.props.user;
return (
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '') + (this.isDraggedOver ? ' dragover' : '')}>
{avatar(user)}
<a className={ user.avatarUrl() ? "Dropdown-toggle" : "Dropdown-toggle AvatarEditor--noAvatar" }
title={app.translator.trans('core.forum.user.avatar_upload_tooltip')}
data-toggle="dropdown"
onclick={this.quickUpload.bind(this)}
ondragover={this.enableDragover.bind(this)}
ondragenter={this.enableDragover.bind(this)}
ondragleave={this.disableDragover.bind(this)}
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'))}
</a>
<ul className="Dropdown-menu Menu">
{listItems(this.controlItems().toArray())}
</ul>
</div>
);
}
/**
* Get the items in the edit avatar dropdown menu.
*
* @return {ItemList}
*/
controlItems() {
const items = new ItemList();
items.add('upload',
Button.component({
icon: 'fas fa-upload',
children: app.translator.trans('core.forum.user.avatar_upload_button'),
onclick: this.openPicker.bind(this)
})
);
items.add('remove',
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.user.avatar_remove_button'),
onclick: this.remove.bind(this)
})
);
return items;
}
/**
* Enable dragover style
*
* @param {Event} e
*/
enableDragover(e) {
e.preventDefault();
e.stopPropagation();
this.isDraggedOver = true;
}
/**
* Disable dragover style
*
* @param {Event} e
*/
disableDragover(e) {
e.preventDefault();
e.stopPropagation();
this.isDraggedOver = false;
}
/**
* Upload avatar when file is dropped into dropzone.
*
* @param {Event} e
*/
dropUpload(e) {
e.preventDefault();
e.stopPropagation();
this.isDraggedOver = false;
this.upload(e.dataTransfer.files[0]);
}
/**
* If the user doesn't have an avatar, there's no point in showing the
* controls dropdown, because only one option would be viable: uploading.
* Thus, when the avatar editor's dropdown toggle button is clicked, we prompt
* the user to upload an avatar immediately.
*
* @param {Event} e
*/
quickUpload(e) {
if (!this.props.user.avatarUrl()) {
e.preventDefault();
e.stopPropagation();
this.openPicker();
}
}
/**
* Upload avatar using file picker
*/
openPicker() {
if (this.loading) return;
// 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.appendTo('body').hide().click().on('change', e => {
this.upload($(e.target)[0].files[0]);
});
}
/**
* Upload avatar
*
* @param {File} file
*/
upload(file) {
if (this.loading) return;
const user = this.props.user;
const data = new FormData();
data.append('avatar', file);
this.loading = true;
m.redraw();
app.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
serialize: raw => raw,
data
}).then(
this.success.bind(this),
this.failure.bind(this)
);
}
/**
* Remove the user's avatar.
*/
remove() {
const user = this.props.user;
this.loading = true;
m.redraw();
app.request({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar'
}).then(
this.success.bind(this),
this.failure.bind(this)
);
}
/**
* After a successful upload/removal, push the updated user data into the
* store, and force a recomputation of the user's avatar color.
*
* @param {Object} response
* @protected
*/
success(response) {
app.store.pushPayload(response);
delete this.props.user.avatarColor;
this.loading = false;
m.redraw();
}
/**
* If avatar upload/removal fails, stop loading.
*
* @param {Object} response
* @protected
*/
failure(response) {
this.loading = false;
m.redraw();
}
}

View File

@@ -0,0 +1,116 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
/**
* The `ChangeEmailModal` component shows a modal dialog which allows the user
* to change their email address.
*/
export default class ChangeEmailModal extends Modal {
init() {
super.init();
/**
* Whether or not the email has been changed successfully.
*
* @type {Boolean}
*/
this.success = false;
/**
* The value of the email input.
*
* @type {function}
*/
this.email = m.prop(app.session.user.email());
/**
* The value of the password input.
*
* @type {function}
*/
this.password = m.prop('');
}
className() {
return 'ChangeEmailModal Modal--small';
}
title() {
return app.translator.trans('core.forum.change_email.title');
}
content() {
if (this.success) {
return (
<div className="Modal-body">
<div className="Form Form--centered">
<p className="helpText">{app.translator.trans('core.forum.change_email.confirmation_message', {email: <strong>{this.email()}</strong>})}</p>
<div className="Form-group">
<Button className="Button Button--primary Button--block" onclick={this.hide.bind(this)}>
{app.translator.trans('core.forum.change_email.dismiss_button')}
</Button>
</div>
</div>
</div>
);
}
return (
<div className="Modal-body">
<div className="Form Form--centered">
<div className="Form-group">
<input type="email" name="email" className="FormControl"
placeholder={app.session.user.email()}
bidi={this.email}
disabled={this.loading}/>
</div>
<div className="Form-group">
<input type="password" name="password" className="FormControl"
placeholder={app.translator.trans('core.forum.change_email.confirm_password_placeholder')}
bidi={this.password}
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.change_email.submit_button')
})}
</div>
</div>
</div>
);
}
onsubmit(e) {
e.preventDefault();
// If the user hasn't actually entered a different email address, we don't
// need to do anything. Woot!
if (this.email() === app.session.user.email()) {
this.hide();
return;
}
const oldEmail = app.session.user.email();
this.loading = true;
app.session.user.save({email: this.email()}, {
errorHandler: this.onerror.bind(this),
meta: {password: this.password()}
})
.then(() => this.success = true)
.catch(() => {})
.then(this.loaded.bind(this));
}
onerror(error) {
if (error.status === 401) {
error.alert.props.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
}
super.onerror(error);
}
}

View File

@@ -0,0 +1,49 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
/**
* The `ChangePasswordModal` component shows a modal dialog which allows the
* user to send themself a password reset email.
*/
export default class ChangePasswordModal extends Modal {
className() {
return 'ChangePasswordModal Modal--small';
}
title() {
return app.translator.trans('core.forum.change_password.title');
}
content() {
return (
<div className="Modal-body">
<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')
})}
</div>
</div>
</div>
);
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
app.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot',
data: {email: app.session.user.email()}
}).then(
this.hide.bind(this),
this.loaded.bind(this)
);
}
}

View File

@@ -0,0 +1,153 @@
/*global s9e, hljs*/
import Post from './Post';
import classList from '../../common/utils/classList';
import PostUser from './PostUser';
import PostMeta from './PostMeta';
import PostEdited from './PostEdited';
import EditPostComposer from './EditPostComposer';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
/**
* 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
*
* - `post`
*/
export default class CommentPost extends Post {
init() {
super.init();
/**
* If the post has been hidden, then this flag determines whether or not its
* content has been expanded.
*
* @type {Boolean}
*/
this.revealContent = false;
// Create an instance of the component that displays the post's author so
// that we can force the post to rerender when the user card is shown.
this.postUser = new PostUser({post: this.props.post});
this.subtree.check(
() => this.postUser.cardVisible,
() => this.isEditing()
);
}
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>
]);
}
config(isInitialized, context) {
super.config(...arguments);
const contentHtml = this.isEditing() ? '' : this.props.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) {
this.$('.Post-body script').each(function() {
eval.call(window, $(this).text());
});
}
context.contentHtml = contentHtml;
}
isEditing() {
return app.composer.component instanceof EditPostComposer &&
app.composer.component.props.post === this.props.post;
}
attrs() {
const post = this.props.post;
const attrs = super.attrs();
attrs.className += ' '+classList({
'CommentPost': true,
'Post--hidden': post.isHidden(),
'Post--edited': post.isEdited(),
'revealContent': this.revealContent,
'editing': this.isEditing()
});
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.component.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.
*/
toggleContent() {
this.revealContent = !this.revealContent;
}
/**
* Build an item list for the post's header.
*
* @return {ItemList}
*/
headerItems() {
const items = new ItemList();
const post = this.props.post;
const props = {post};
items.add('user', this.postUser.render(), 100);
items.add('meta', PostMeta.component(props));
if (post.isEdited() && !post.isHidden()) {
items.add('edited', PostEdited.component(props));
}
// If the post is hidden, add a button that allows toggling the visibility
// of the post's content.
if (post.isHidden()) {
items.add('toggle', (
Button.component({
className: 'Button Button--default Button--more',
icon: 'fas fa-ellipsis-h',
onclick: this.toggleContent.bind(this)
})
));
}
return items;
}
}

View File

@@ -0,0 +1,530 @@
import Component from '../../common/Component';
import ItemList from '../../common/utils/ItemList';
import ComposerButton from './ComposerButton';
import listItems from '../../common/helpers/listItems';
import classList from '../../common/utils/classList';
/**
* The `Composer` component displays the composer. It can be loaded with a
* content component with `load` and then its position/state can be altered with
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
*/
class Composer extends Component {
init() {
/**
* The composer's current position.
*
* @type {Composer.PositionEnum}
*/
this.position = Composer.PositionEnum.HIDDEN;
/**
* The composer's intended height, which can be modified by the user
* (by dragging the composer handle).
*
* @type {Integer}
*/
this.height = null;
/**
* Whether or not the composer currently has focus.
*
* @type {Boolean}
*/
this.active = false;
}
view() {
const classes = {
'normal': this.position === Composer.PositionEnum.NORMAL,
'minimized': this.position === Composer.PositionEnum.MINIMIZED,
'fullScreen': this.position === Composer.PositionEnum.FULLSCREEN,
'active': this.active
};
classes.visible = classes.normal || classes.minimized || classes.fullScreen;
// If the composer is minimized, tell the composer's content component that
// it shouldn't let the user interact with it. Set up a handler so that if
// the content IS clicked, the composer will be shown.
if (this.component) this.component.props.disabled = classes.minimized;
const showIfMinimized = this.position === Composer.PositionEnum.MINIMIZED ? this.show.bind(this) : undefined;
return (
<div className={'Composer ' + classList(classes)}>
<div className="Composer-handle" config={this.configHandle.bind(this)}/>
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
<div className="Composer-content" onclick={showIfMinimized}>
{this.component ? this.component.render() : ''}
</div>
</div>
);
}
config(isInitialized, context) {
// 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.
this.updateHeight();
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;
this.initializeHeight();
this.$().hide().css('bottom', -this.computedHeight());
// Whenever any of the inputs inside the composer are have focus, we want to
// add a class to the composer to draw attention to it.
this.$().on('focus blur', ':input', e => {
this.active = e.type === 'focusin';
m.redraw();
});
// When the escape key is pressed on any inputs, close the composer.
this.$().on('keydown', ':input', 'esc', () => this.close());
// Don't let the user leave the page without first giving the composer's
// component a chance to scream at the user to make sure they don't
// unintentionally lose any contnet.
window.onbeforeunload = () => {
return (this.component && this.component.preventExit()) || undefined;
};
const handlers = {};
$(window).on('resize', handlers.onresize = this.updateHeight.bind(this)).resize();
$(document)
.on('mousemove', handlers.onmousemove = this.onmousemove.bind(this))
.on('mouseup', handlers.onmouseup = this.onmouseup.bind(this));
context.onunload = () => {
$(window).off('resize', handlers.onresize);
$(document)
.off('mousemove', handlers.onmousemove)
.off('mouseup', 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;
const composer = this;
$(element).css('cursor', 'row-resize')
.bind('dragstart mousedown', e => e.preventDefault())
.mousedown(function(e) {
composer.mouseStart = e.clientY;
composer.heightStart = composer.$().height();
composer.handle = $(this);
$('body').css('cursor', 'row-resize');
});
}
/**
* Resize the composer according to mouse movement.
*
* @param {Event} e
*/
onmousemove(e) {
if (!this.handle) return;
// Work out how much the mouse has been moved, and set the height
// relative to the old one based on that. Then update the content's
// height so that it fills the height of the composer, and update the
// body's padding.
const deltaPixels = this.mouseStart - e.clientY;
this.changeHeight(this.heightStart + deltaPixels);
// Update the body's padding-bottom so that no content on the page will ever
// get permanently hidden behind the composer. If the user is already
// scrolled to the bottom of the page, then we will keep them scrolled to
// the bottom after the padding has been updated.
const scrollTop = $(window).scrollTop();
const anchorToBottom = scrollTop > 0 && scrollTop + $(window).height() >= $(document).height();
this.updateBodyPadding(anchorToBottom);
}
/**
* Finish resizing the composer when the mouse is released.
*/
onmouseup() {
if (!this.handle) return;
this.handle = null;
$('body').css('cursor', '');
}
/**
* Update the DOM to reflect the composer's current height. This involves
* setting the height of the composer's root element, and adjusting the height
* of any flexible elements inside the composer's body.
*/
updateHeight() {
const height = this.computedHeight();
const $flexible = this.$('.Composer-flexible');
this.$().height(height);
if ($flexible.length) {
const headerHeight = $flexible.offset().top - this.$().offset().top;
const paddingBottom = parseInt($flexible.css('padding-bottom'), 10);
const footerHeight = this.$('.Composer-footer').outerHeight(true);
$flexible.height(this.$().outerHeight() - headerHeight - paddingBottom - footerHeight);
}
}
/**
* Update the amount of padding-bottom on the body so that the page's
* content will still be visible above the composer when the page is
* scrolled right to the bottom.
*/
updateBodyPadding() {
const visible = this.position !== Composer.PositionEnum.HIDDEN &&
this.position !== Composer.PositionEnum.MINIMIZED &&
this.$().css('position') !== 'absolute';
const paddingBottom = visible
? this.computedHeight() - parseInt($('#app').css('padding-bottom'), 10)
: 0;
$('#content').css({paddingBottom});
}
/**
* Determine whether or not the Composer is covering the screen.
*
* This will be true if the Composer is in full-screen mode on desktop, or
* if the Composer is positioned absolutely as on mobile devices.
*
* @return {Boolean}
* @public
*/
isFullScreen() {
return this.position === Composer.PositionEnum.FULLSCREEN || this.$().css('position') === 'absolute';
}
/**
* Confirm with the user that they want to close the composer and lose their
* content.
*
* @return {Boolean} Whether or not the exit was cancelled.
*/
preventExit() {
if (this.component) {
const preventExit = this.component.preventExit();
if (preventExit) {
return !confirm(preventExit);
}
}
}
/**
* Load a content component into the composer.
*
* @param {Component} component
* @public
*/
load(component) {
if (this.preventExit()) return;
// If we load a similar component into the composer, then Mithril will be
// able to diff the old/new contents and some DOM-related state from the
// old composer will remain. To prevent this from happening, we clear the
// component and force a redraw, so that the new component will be working
// on a blank slate.
if (this.component) {
this.clear();
m.redraw(true);
}
this.component = component;
}
/**
* Clear the composer's content component.
*
* @public
*/
clear() {
this.component = null;
}
/**
* Animate the Composer into the given position.
*
* @param {Composer.PositionEnum} position
*/
animateToPosition(position) {
// Before we redraw the composer to its new state, we need to save the
// current height of the composer, as well as the page's scroll position, so
// that we can smoothly transition from the old to the new state.
const oldPosition = this.position;
const $composer = this.$().stop(true);
const oldHeight = $composer.outerHeight();
const scrollTop = $(window).scrollTop();
this.position = position;
m.redraw(true);
// Now that we've redrawn and the composer's DOM has been updated, we want
// to update the composer's height. Once we've done that, we'll capture the
// real value to use as the end point for our animation later on.
$composer.show();
this.updateHeight();
const newHeight = $composer.outerHeight();
if (oldPosition === Composer.PositionEnum.HIDDEN) {
$composer.css({bottom: -newHeight, height: newHeight});
} else {
$composer.css({height: oldHeight});
}
$composer.animate({bottom: 0, height: newHeight}, 'fast', () => this.component.focus());
this.updateBodyPadding();
$(window).scrollTop(scrollTop);
}
/**
* Show the Composer backdrop.
*/
showBackdrop() {
this.$backdrop = $('<div/>')
.addClass('composer-backdrop')
.appendTo('body');
}
/**
* Hide the Composer backdrop.
*/
hideBackdrop() {
if (this.$backdrop) this.$backdrop.remove();
}
/**
* Show the composer.
*
* @public
*/
show() {
if (this.position === Composer.PositionEnum.NORMAL || this.position === Composer.PositionEnum.FULLSCREEN) {
return;
}
this.animateToPosition(Composer.PositionEnum.NORMAL);
if (this.isFullScreen()) {
this.$().css('top', $(window).scrollTop());
this.showBackdrop();
this.component.focus();
}
}
/**
* Close the composer.
*
* @public
*/
hide() {
const $composer = this.$();
// Animate the composer sliding down off the bottom edge of the viewport.
// Only when the animation is completed, update the Composer state flag and
// other elements on the page.
$composer.stop(true).animate({bottom: -$composer.height()}, 'fast', () => {
this.position = Composer.PositionEnum.HIDDEN;
this.clear();
m.redraw();
$composer.hide();
this.hideBackdrop();
this.updateBodyPadding();
});
}
/**
* Confirm with the user so they don't lose their content, then close the
* composer.
*
* @public
*/
close() {
if (!this.preventExit()) {
this.hide();
}
}
/**
* Minimize the composer. Has no effect if the composer is hidden.
*
* @public
*/
minimize() {
if (this.position === Composer.PositionEnum.HIDDEN) return;
this.animateToPosition(Composer.PositionEnum.MINIMIZED);
this.$().css('top', 'auto');
this.hideBackdrop();
}
/**
* Take the composer into fullscreen mode. Has no effect if the composer is
* hidden.
*
* @public
*/
fullScreen() {
if (this.position !== Composer.PositionEnum.HIDDEN) {
this.position = Composer.PositionEnum.FULLSCREEN;
m.redraw();
this.updateHeight();
this.component.focus();
}
}
/**
* Exit fullscreen mode.
*
* @public
*/
exitFullScreen() {
if (this.position === Composer.PositionEnum.FULLSCREEN) {
this.position = Composer.PositionEnum.NORMAL;
m.redraw();
this.updateHeight();
this.component.focus();
}
}
/**
* Build an item list for the composer's controls.
*
* @return {ItemList}
*/
controlItems() {
const items = new ItemList();
if (this.position === Composer.PositionEnum.FULLSCREEN) {
items.add('exitFullScreen', ComposerButton.component({
icon: 'fas fa-compress',
title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),
onclick: this.exitFullScreen.bind(this)
}));
} else {
if (this.position !== Composer.PositionEnum.MINIMIZED) {
items.add('minimize', ComposerButton.component({
icon: 'fas fa-minus minimize',
title: app.translator.trans('core.forum.composer.minimize_tooltip'),
onclick: this.minimize.bind(this),
itemClassName: 'App-backControl'
}));
items.add('fullScreen', ComposerButton.component({
icon: 'fas fa-expand',
title: app.translator.trans('core.forum.composer.full_screen_tooltip'),
onclick: this.fullScreen.bind(this)
}));
}
items.add('close', ComposerButton.component({
icon: 'fas fa-times',
title: app.translator.trans('core.forum.composer.close_tooltip'),
onclick: this.close.bind(this)
}));
}
return items;
}
/**
* Initialize default Composer height.
*/
initializeHeight() {
this.height = localStorage.getItem('composerHeight');
if (!this.height) {
this.height = this.defaultHeight();
}
}
/**
* Default height of the Composer in case none is saved.
* @returns {Integer}
*/
defaultHeight() {
return this.$().height();
}
/**
* Minimum height of the Composer.
* @returns {Integer}
*/
minimumHeight() {
return 200;
}
/**
* Maxmimum height of the Composer.
* @returns {Integer}
*/
maximumHeight() {
return $(window).height() - $('#header').outerHeight();
}
/**
* Computed the composer's current height, based on the intended height, and
* the composer's current state. This will be applied to the composer's
* content's DOM element.
* @returns {Integer|String}
*/
computedHeight() {
// If the composer is minimized, then we don't want to set a height; we'll
// let the CSS decide how high it is. If it's fullscreen, then we need to
// make it as high as the window.
if (this.position === Composer.PositionEnum.MINIMIZED) {
return '';
} else if (this.position === Composer.PositionEnum.FULLSCREEN) {
return $(window).height();
}
// Otherwise, if it's normal or hidden, then we use the intended height.
// We don't let the composer get too small or too big, though.
return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight()));
}
/**
* Save a new Composer height and update the DOM.
* @param {Integer} height
*/
changeHeight(height) {
this.height = height;
this.updateHeight();
localStorage.setItem('composerHeight', this.height);
}
}
Composer.PositionEnum = {
HIDDEN: 'hidden',
NORMAL: 'normal',
MINIMIZED: 'minimized',
FULLSCREEN: 'fullScreen'
};
export default Composer;

View File

@@ -0,0 +1,113 @@
import Component from '../../common/Component';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import TextEditor from './TextEditor';
import avatar from '../../common/helpers/avatar';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
/**
* The `ComposerBody` component handles the body, or the content, of the
* composer. Subclasses should implement the `onsubmit` method and override
* `headerTimes`.
*
* ### Props
*
* - `originalContent`
* - `submitLabel`
* - `placeholder`
* - `user`
* - `confirmExit`
* - `disabled`
*
* @abstract
*/
export default class ComposerBody extends Component {
init() {
/**
* Whether or not the component is loading.
*
* @type {Boolean}
*/
this.loading = false;
/**
* The content of the text editor.
*
* @type {Function}
*/
this.content = m.prop(this.props.originalContent);
/**
* The text editor component instance.
*
* @type {TextEditor}
*/
this.editor = new TextEditor({
submitLabel: this.props.submitLabel,
placeholder: this.props.placeholder,
onchange: this.content,
onsubmit: this.onsubmit.bind(this),
value: this.content()
});
}
view() {
// If the component is loading, we should disable the text editor.
this.editor.props.disabled = this.loading;
return (
<div className={'ComposerBody ' + (this.props.className || '')}>
{avatar(this.props.user, {className: 'ComposerBody-avatar'})}
<div className="ComposerBody-content">
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
<div className="ComposerBody-editor">{this.editor.render()}</div>
</div>
{LoadingIndicator.component({className: 'ComposerBody-loading' + (this.loading ? ' active' : '')})}
</div>
);
}
/**
* Draw focus to the text editor.
*/
focus() {
this.$(':input:enabled:visible:first').focus();
}
/**
* Check if there is any unsaved data if there is, return a confirmation
* message to prompt the user with.
*
* @return {String}
*/
preventExit() {
const content = this.content();
return content && content !== this.props.originalContent && this.props.confirmExit;
}
/**
* Build an item list for the composer's header.
*
* @return {ItemList}
*/
headerItems() {
return new ItemList();
}
/**
* Handle the submit event of the text editor.
*
* @abstract
*/
onsubmit() {
}
/**
* Stop loading.
*/
loaded() {
this.loading = false;
m.redraw();
}
}

View File

@@ -0,0 +1,13 @@
import Button from '../../common/components/Button';
/**
* The `ComposerButton` component displays a button suitable for the composer
* controls.
*/
export default class ComposerButton extends Button {
static initProps(props) {
super.initProps(props);
props.className = props.className || 'Button Button--icon Button--link';
}
}

View File

@@ -0,0 +1,101 @@
import ComposerBody from './ComposerBody';
import extractText from '../../common/utils/extractText';
/**
* The `DiscussionComposer` component displays the composer content for starting
* a new discussion. It adds a text field as a header control so the user can
* enter the title of their discussion. It also overrides the `submit` and
* `willExit` actions to account for the title.
*
* ### Props
*
* - All of the props for ComposerBody
* - `titlePlaceholder`
*/
export default class DiscussionComposer extends ComposerBody {
init() {
super.init();
/**
* The value of the title input.
*
* @type {Function}
*/
this.title = m.prop('');
}
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();
items.add('title', <h3>{app.translator.trans('core.forum.composer_discussion.title')}</h3>, 100);
items.add('discussionTitle', (
<h3>
<input className="FormControl"
value={this.title()}
oninput={m.withAttr('value', this.title)}
placeholder={this.props.titlePlaceholder}
disabled={!!this.props.disabled}
onkeydown={this.onkeydown.bind(this)}/>
</h3>
));
return items;
}
/**
* Handle the title input's keydown event. When the return key is pressed,
* move the focus to the start of the text editor.
*
* @param {Event} e
*/
onkeydown(e) {
if (e.which === 13) { // Return
e.preventDefault();
this.editor.setSelectionRange(0, 0);
}
m.redraw.strategy('none');
}
preventExit() {
return (this.title() || this.content()) && this.props.confirmExit;
}
/**
* Get the data to submit to the server when the discussion is saved.
*
* @return {Object}
*/
data() {
return {
title: this.title(),
content: this.content()
};
}
onsubmit() {
this.loading = true;
const data = this.data();
app.store.createRecord('discussions').save(data).then(
discussion => {
app.composer.hide();
app.cache.discussionList.addDiscussion(discussion);
m.route(app.route.discussion(discussion));
},
this.loaded.bind(this)
);
}
}

View File

@@ -0,0 +1,41 @@
import Component from '../../common/Component';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
/**
* The `DiscussionHero` component displays the hero on a discussion page.
*
* ### Props
*
* - `discussion`
*/
export default class DiscussionHero extends Component {
view() {
return (
<header className="Hero DiscussionHero">
<div className="container">
<ul className="DiscussionHero-items">{listItems(this.items().toArray())}</ul>
</div>
</header>
);
}
/**
* Build an item list for the contents of the discussion hero.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
const discussion = this.props.discussion;
const badges = discussion.badges().toArray();
if (badges.length) {
items.add('badges', <ul className="DiscussionHero-badges badges">{listItems(badges)}</ul>, 10);
}
items.add('title', <h2 className="DiscussionHero-title">{discussion.title()}</h2>);
return items;
}
}

View File

@@ -0,0 +1,218 @@
import Component from '../../common/Component';
import DiscussionListItem from './DiscussionListItem';
import Button from '../../common/components/Button';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Placeholder from '../../common/components/Placeholder';
/**
* The `DiscussionList` component displays a list of discussions.
*
* ### Props
*
* - `params` A map of parameters used to construct a refined parameter object
* to send along in the API request to get discussion results.
*/
export default class DiscussionList extends Component {
init() {
/**
* Whether or not discussion results are loading.
*
* @type {Boolean}
*/
this.loading = true;
/**
* Whether or not there are more results that can be loaded.
*
* @type {Boolean}
*/
this.moreResults = false;
/**
* The discussions in the discussion list.
*
* @type {Discussion[]}
*/
this.discussions = [];
this.refresh();
}
view() {
const params = this.props.params;
let loading;
if (this.loading) {
loading = LoadingIndicator.component();
} else if (this.moreResults) {
loading = Button.component({
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
className: 'Button',
onclick: this.loadMore.bind(this)
});
}
if (this.discussions.length === 0 && !this.loading) {
const text = app.translator.trans('core.forum.discussion_list.empty_text');
return (
<div className="DiscussionList">
{Placeholder.component({text})}
</div>
);
}
return (
<div className={'DiscussionList'+(this.props.params.q ? ' DiscussionList--searchResults' : '')}>
<ul className="DiscussionList-discussions">
{this.discussions.map(discussion => {
return (
<li key={discussion.id()} data-id={discussion.id()}>
{DiscussionListItem.component({discussion, params})}
</li>
);
})}
</ul>
<div className="DiscussionList-loadMore">
{loading}
</div>
</div>
);
}
/**
* Get the parameters that should be passed in the API request to get
* discussion results.
*
* @return {Object}
* @api
*/
requestParams() {
const params = {include: ['startUser', 'lastUser'], filter: {}};
params.sort = this.sortMap()[this.props.params.sort];
if (this.props.params.q) {
params.filter.q = this.props.params.q;
params.include.push('mostRelevantPost', 'mostRelevantPost.user');
}
return params;
}
/**
* Get a map of sort keys (which appear in the URL, and are used for
* translation) to the API sort value that they represent.
*
* @return {Object}
*/
sortMap() {
const map = {};
if (this.props.params.q) {
map.relevance = '';
}
map.latest = '-lastTime';
map.top = '-commentsCount';
map.newest = '-startTime';
map.oldest = 'startTime';
return map;
}
/**
* Clear and reload the discussion list.
*
* @public
*/
refresh(clear = true) {
if (clear) {
this.loading = true;
this.discussions = [];
}
return this.loadResults().then(
results => {
this.discussions = [];
this.parseResults(results);
},
() => {
this.loading = false;
m.redraw();
}
);
}
/**
* Load a new page of discussion results.
*
* @param {Integer} offset The index to start the page at.
* @return {Promise}
*/
loadResults(offset) {
const preloadedDiscussions = app.preloadedDocument();
if (preloadedDiscussions) {
return m.deferred().resolve(preloadedDiscussions).promise;
}
const params = this.requestParams();
params.page = {offset};
params.include = params.include.join(',');
return app.store.find('discussions', params);
}
/**
* Load the next page of discussion results.
*
* @public
*/
loadMore() {
this.loading = true;
this.loadResults(this.discussions.length)
.then(this.parseResults.bind(this));
}
/**
* Parse results and append them to the discussion list.
*
* @param {Discussion[]} results
* @return {Discussion[]}
*/
parseResults(results) {
[].push.apply(this.discussions, results);
this.loading = false;
this.moreResults = !!results.payload.links.next;
m.lazyRedraw();
return results;
}
/**
* Remove a discussion from the list if it is present.
*
* @param {Discussion} discussion
* @public
*/
removeDiscussion(discussion) {
const index = this.discussions.indexOf(discussion);
if (index !== -1) {
this.discussions.splice(index, 1);
}
}
/**
* Add a discussion to the top of the list.
*
* @param {Discussion} discussion
* @public
*/
addDiscussion(discussion) {
this.discussions.unshift(discussion);
}
}

View File

@@ -0,0 +1,212 @@
import Component from '../../common/Component';
import avatar from '../../common/helpers/avatar';
import listItems from '../../common/helpers/listItems';
import highlight from '../../common/helpers/highlight';
import icon from '../../common/helpers/icon';
import humanTime from '../../common/utils/humanTime';
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';
import extractText from '../../common/utils/extractText';
import classList from '../../common/utils/classList';
/**
* The `DiscussionListItem` component shows a single discussion in the
* discussion list.
*
* ### Props
*
* - `discussion`
* - `params`
*/
export default class DiscussionListItem extends Component {
init() {
/**
* Set up a subtree retainer so that the discussion will not be redrawn
* unless new data comes in.
*
* @type {SubtreeRetainer}
*/
this.subtree = new SubtreeRetainer(
() => this.props.discussion.freshness,
() => {
const time = app.session.user && app.session.user.readTime();
return time && time.getTime();
},
() => this.active()
);
}
attrs() {
return {
className: classList([
'DiscussionListItem',
this.active() ? 'active' : '',
this.props.discussion.isHidden() ? 'DiscussionListItem--hidden' : ''
])
};
}
view() {
const retain = this.subtree.retain();
if (retain) return retain;
const discussion = this.props.discussion;
const startUser = discussion.startUser();
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();
if (this.props.params.q) {
const post = discussion.mostRelevantPost();
if (post) {
jumpTo = post.number();
}
const phrase = this.props.params.q;
this.highlightRegExp = new RegExp(phrase+'|'+phrase.trim().replace(/\s+/g, '|'), 'gi');
} else {
jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1);
}
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'
}) : ''}
<a className={'Slidable-underneath Slidable-underneath--left Slidable-underneath--elastic' + (isUnread ? '' : ' disabled')}
onclick={this.markAsRead.bind(this)}>
{icon('fas fa-check')}
</a>
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
<a href={startUser ? app.route.user(startUser) : '#'}
className="DiscussionListItem-author"
title={extractText(app.translator.trans('core.forum.discussion_list.started_text', {user: startUser, ago: humanTime(discussion.startTime())}))}
config={function(element) {
$(element).tooltip({placement: 'right'});
m.route.apply(this, arguments);
}}>
{avatar(startUser, {title: ''})}
</a>
<ul className="DiscussionListItem-badges badges">
{listItems(discussion.badges().toArray())}
</ul>
<a href={app.route.discussion(discussion, jumpTo)}
config={m.route}
className="DiscussionListItem-main">
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3>
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
</a>
<span className="DiscussionListItem-count"
onclick={this.markAsRead.bind(this)}
title={showUnread ? app.translator.trans('core.forum.discussion_list.mark_as_read_tooltip') : ''}>
{abbreviateNumber(discussion[showUnread ? 'unreadCount' : 'repliesCount']())}
</span>
</div>
</div>
);
}
config(isInitialized) {
if (isInitialized) return;
// 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
// reveal controls.
if ('ontouchstart' in window) {
const slidableInstance = slidable(this.$().addClass('Slidable'));
this.$('.DiscussionListItem-controls')
.on('hidden.bs.dropdown', () => slidableInstance.reset());
}
}
/**
* Determine whether or not the discussion is currently being viewed.
*
* @return {Boolean}
*/
active() {
const idParam = m.route.param('id');
return idParam && idParam.split('-')[0] === this.props.discussion.id();
}
/**
* Determine whether or not information about who started the discussion
* should be displayed instead of information about the most recent reply to
* the discussion.
*
* @return {Boolean}
*/
showStartPost() {
return ['newest', 'oldest'].indexOf(this.props.params.sort) !== -1;
}
/**
* Determine whether or not the number of replies should be shown instead of
* the number of unread posts.
*
* @return {Boolean}
*/
showRepliesCount() {
return this.props.params.sort === 'replies';
}
/**
* Mark the discussion as read.
*/
markAsRead() {
const discussion = this.props.discussion;
if (discussion.isUnread()) {
discussion.save({readNumber: discussion.lastPostNumber()});
m.redraw();
}
}
/**
* Build an item list of info for a discussion listing. By default this is
* just the first/last post indicator.
*
* @return {ItemList}
*/
infoItems() {
const items = new ItemList();
if (this.props.params.q) {
const post = this.props.discussion.mostRelevantPost() || this.props.discussion.startPost();
if (post && post.contentType() === 'comment') {
const excerpt = highlight(post.contentPlain(), this.highlightRegExp, 175);
items.add('excerpt', excerpt, -100);
}
} else {
items.add('terminalPost',
TerminalPost.component({
discussion: this.props.discussion,
lastPost: !this.showStartPost()
})
);
}
return items;
}
}

View File

@@ -0,0 +1,291 @@
import Page from './Page';
import ItemList from '../../common/utils/ItemList';
import DiscussionHero from './DiscussionHero';
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';
/**
* The `DiscussionPage` component displays a whole discussion page, including
* the discussion list pane, the hero, the posts, and the sidebar.
*/
export default class DiscussionPage extends Page {
init() {
super.init();
/**
* The discussion that is being viewed.
*
* @type {Discussion}
*/
this.discussion = null;
/**
* The number of the first post that is currently visible in the viewport.
*
* @type {Integer}
*/
this.near = null;
this.refresh();
// If the discussion list has been loaded, then we'll enable the pane (and
// hide it by default). Also, if we've just come from another discussion
// page, then we don't want Mithril to redraw the whole page if it did,
// then the pane would which would be slow and would cause problems with
// event handlers.
if (app.cache.discussionList) {
app.pane.enable();
app.pane.hide();
if (app.previous instanceof DiscussionPage) {
m.redraw.strategy('diff');
}
}
app.history.push('discussion');
this.bodyClass = 'App--discussion';
}
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;
}
}
// 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
// we'll just close it.
app.pane.disable();
if (app.composingReplyTo(this.discussion) && !app.composer.component.content()) {
app.composer.hide();
} else {
app.composer.minimize();
}
}
view() {
const discussion = this.discussion;
return (
<div className="DiscussionPage">
{app.cache.discussionList
? <div className="DiscussionPage-list" config={this.configPane.bind(this)}>
{!$('.App-navigation').is(':visible') ? app.cache.discussionList.render() : ''}
</div>
: ''}
<div className="DiscussionPage-discussion">
{discussion
? [
DiscussionHero.component({discussion}),
<div className="container">
<nav className="DiscussionPage-nav">
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
<div className="DiscussionPage-stream">
{this.stream.render()}
</div>
</div>
]
: LoadingIndicator.component({className: 'LoadingIndicator--block'})}
</div>
</div>
);
}
/**
* Clear and reload the discussion.
*/
refresh() {
this.near = m.route.param('near') || 0;
this.discussion = null;
const preloadedDiscussion = app.preloadedDocument();
if (preloadedDiscussion) {
// We must wrap this in a setTimeout because if we are mounting this
// component for the first time on page load, then any calls to m.redraw
// will be ineffective and thus any configs (scroll code) will be run
// before stuff is drawn to the page.
setTimeout(this.show.bind(this, preloadedDiscussion), 0);
} else {
const params = this.requestParams();
app.store.find('discussions', m.route.param('id').split('-')[0], params)
.then(this.show.bind(this));
}
m.lazyRedraw();
}
/**
* Get the parameters that should be passed in the API request to get the
* discussion.
*
* @return {Object}
*/
requestParams() {
return {
page: {near: this.near}
};
}
/**
* Initialize the component to display the given discussion.
*
* @param {Discussion} discussion
*/
show(discussion) {
this.discussion = discussion;
app.history.push('discussion', discussion.title());
app.setTitle(discussion.title());
app.setTitleCount(0);
// When the API responds with a discussion, it will also include a number of
// posts. Some of these posts are included because they are on the first
// page of posts we want to display (determined by the `near` parameter)
// others may be included because due to other relationships introduced by
// extensions. We need to distinguish the two so we don't end up displaying
// the wrong posts. We do so by filtering out the posts that don't have
// the 'discussion' relationship linked, then sorting and splicing.
let includedPosts = [];
if (discussion.payload && discussion.payload.included) {
const discussionId = discussion.id();
includedPosts = discussion.payload.included
.filter(record => record.type === 'posts'
&& record.relationships
&& record.relationships.discussion
&& record.relationships.discussion.data.id === discussionId)
.map(record => app.store.getById('posts', record.id))
.sort((a, b) => a.id() - b.id())
.slice(0, 20);
}
// Set up the post stream for this discussion, along with the first page of
// posts we want to display. Tell the stream to scroll down and highlight
// the specific post that was routed to.
this.stream = new PostStream({discussion, includedPosts});
this.stream.on('positionChanged', this.positionChanged.bind(this));
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true);
}
/**
* 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.
*
* @return {ItemList}
*/
sidebarItems() {
const items = new ItemList();
items.add('controls',
SplitDropdown.component({
children: DiscussionControls.controls(this.discussion, this).toArray(),
icon: 'fas fa-ellipsis-v',
className: 'App-primaryControl',
buttonClassName: 'Button--primary'
})
);
items.add('scrubber',
PostStreamScrubber.component({
stream: this.stream,
className: 'App-titleControl'
}),
-100
);
return items;
}
/**
* When the posts that are visible in the post stream change (i.e. the user
* scrolls up or down), then we update the URL and mark the posts as read.
*
* @param {Integer} startNumber
* @param {Integer} endNumber
*/
positionChanged(startNumber, endNumber) {
const discussion = this.discussion;
// Construct a URL to this discussion with the updated position, then
// 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);
window.history.replaceState(null, document.title, url);
app.history.push('discussion', discussion.title());
// If the user hasn't read past here before, then we'll update their read
// state and redraw.
if (app.session.user && endNumber > (discussion.readNumber() || 0)) {
discussion.save({readNumber: endNumber});
m.redraw();
}
}
}

View File

@@ -0,0 +1,25 @@
import Notification from './Notification';
/**
* The `DiscussionRenamedNotification` component displays a notification which
* indicates that a discussion has had its title changed.
*
* ### Props
*
* - All of the props for Notification
*/
export default class DiscussionRenamedNotification extends Notification {
icon() {
return 'fas fa-pencil-alt';
}
href() {
const notification = this.props.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.sender()});
}
}

View File

@@ -0,0 +1,34 @@
import EventPost from './EventPost';
import extractText from '../../common/utils/extractText';
/**
* The `DiscussionRenamedPost` component displays a discussion event post
* indicating that the discussion has been renamed.
*
* ### Props
*
* - All of the props for EventPost
*/
export default class DiscussionRenamedPost extends EventPost {
icon() {
return 'fas fa-pencil-alt';
}
description(data) {
const renamed = app.translator.trans('core.forum.post_stream.discussion_renamed_text', data);
const oldName = app.translator.trans('core.forum.post_stream.discussion_renamed_old_tooltip', data);
return <span title={extractText(oldName)}>{renamed}</span>;
}
descriptionData() {
const post = this.props.post;
const oldTitle = post.content()[0];
const newTitle = post.content()[1];
return {
'old': oldTitle,
'new': <strong className="DiscussionRenamedPost-new">{newTitle}</strong>
};
}
}

View File

@@ -0,0 +1,57 @@
import highlight from '../../common/helpers/highlight';
import LinkButton from '../../common/components/LinkButton';
/**
* The `DiscussionsSearchSource` finds and displays discussion search results in
* the search dropdown.
*
* @implements SearchSource
*/
export default class DiscussionsSearchSource {
constructor() {
this.results = {};
}
search(query) {
query = query.toLowerCase();
this.results[query] = [];
const params = {
filter: {q: query},
page: {limit: 3},
include: 'mostRelevantPost'
};
return app.store.find('discussions', params).then(results => this.results[query] = results);
}
view(query) {
query = query.toLowerCase();
const results = this.results[query] || [];
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})
})}
</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}>
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
</a>
</li>
);
})
];
}
}

View File

@@ -0,0 +1,26 @@
import UserPage from './UserPage';
import DiscussionList from './DiscussionList';
/**
* The `DiscussionsUserPage` component shows a discussion list inside of a user
* page.
*/
export default class DiscussionsUserPage extends UserPage {
init() {
super.init();
this.loadUser(m.route.param('username'));
}
content() {
return (
<div className="DiscussionsUserPage">
{DiscussionList.component({
params: {
q: 'author:' + this.user.username()
}
})}
</div>
);
}
}

View File

@@ -0,0 +1,86 @@
import ComposerBody from './ComposerBody';
import icon from '../../common/helpers/icon';
function minimizeComposerIfFullScreen(e) {
if (app.composer.isFullScreen()) {
app.composer.minimize();
e.stopPropagation();
}
}
/**
* The `EditPostComposer` component displays the composer content for editing a
* 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
*
* - All of the props for ComposerBody
* - `post`
*/
export default class EditPostComposer extends ComposerBody {
init() {
super.init();
this.editor.props.preview = e => {
minimizeComposerIfFullScreen(e);
m.route(app.route.post(this.props.post));
};
}
static initProps(props) {
super.initProps(props);
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();
props.post.editedContent = props.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);
};
items.add('title', (
<h3>
{icon('fas fa-pencil-alt')} {' '}
<a href={app.route.discussion(post.discussion(), post.number())} config={routeAndMinimize}>
{app.translator.trans('core.forum.composer_edit.post_link', {number: post.number(), discussion: post.discussion().title()})}
</a>
</h3>
));
return items;
}
/**
* Get the data to submit to the server when the post is saved.
*
* @return {Object}
*/
data() {
return {
content: this.content()
};
}
onsubmit() {
this.loading = true;
const data = this.data();
this.props.post.save(data).then(
() => app.composer.hide(),
this.loaded.bind(this)
);
}
}

View File

@@ -0,0 +1,165 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import GroupBadge from '../../common/components/GroupBadge';
import Group from '../../common/models/Group';
import extractText from '../../common/utils/extractText';
/**
* The `EditUserModal` component displays a modal dialog with a login form.
*/
export default class EditUserModal extends Modal {
init() {
super.init();
const user = this.props.user;
this.username = m.prop(user.username() || '');
this.email = m.prop(user.email() || '');
this.isActivated = m.prop(user.isActivated() || false);
this.setPassword = m.prop(false);
this.password = m.prop(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));
}
className() {
return 'EditUserModal Modal--small';
}
title() {
return app.translator.trans('core.forum.edit_user.title');
}
content() {
return (
<div className="Modal-body">
<div className="Form">
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
<input className="FormControl" placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))}
bidi={this.username} />
</div>
{app.session.user !== this.props.user ? [
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
<div>
<input className="FormControl" placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))}
bidi={this.email} />
</div>
{!this.isActivated() ? (
<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)
})}
</div>
) : ''}
</div>,
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
<div>
<label className="checkbox">
<input type="checkbox" checked={this.setPassword()} onchange={e => {
this.setPassword(e.target.checked);
m.redraw(true);
if (e.target.checked) this.$('[name=password]').select();
m.redraw.strategy('none');
}}/>
{app.translator.trans('core.forum.edit_user.set_password_label')}
</label>
{this.setPassword() ? (
<input className="FormControl" type="password" name="password" placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
bidi={this.password} />
) : ''}
</div>
</div>
] : ''}
<div className="Form-group EditUserModal-groups">
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
<div>
{Object.keys(this.groups)
.map(id => app.store.getById('groups', id))
.map(group => (
<label className="checkbox">
<input type="checkbox"
bidi={this.groups[group.id()]}
disabled={this.props.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID} />
{GroupBadge.component({group, label: ''})} {group.nameSingular()}
</label>
))}
</div>
</div>
<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')
})}
</div>
</div>
</div>
);
}
activate() {
this.loading = true;
const data = {
username: this.username(),
isActivated: true,
};
this.props.user.save(data, {errorHandler: this.onerror.bind(this)})
.then(() => {
this.isActivated(true);
this.loading = false;
m.redraw();
})
.catch(() => {
this.loading = false;
m.redraw();
});
}
data() {
const groups = Object.keys(this.groups)
.filter(id => this.groups[id]())
.map(id => app.store.getById('groups', id));
const data = {
username: this.username(),
relationships: {groups}
};
if (app.session.user !== this.props.user) {
data.email = this.email();
}
if (this.setPassword()) {
data.password = this.password();
}
return data;
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
this.props.user.save(this.data(), {errorHandler: this.onerror.bind(this)})
.then(this.hide.bind(this))
.catch(() => {
this.loading = false;
m.redraw();
});
}
}

View File

@@ -0,0 +1,80 @@
import Post from './Post';
import { ucfirst } from '../../common/utils/string';
import usernameHelper from '../../common/helpers/username';
import icon from '../../common/helpers/icon';
/**
* The `EventPost` component displays a post which indicating a discussion
* event, like a discussion being renamed or stickied. Subclasses must implement
* the `icon` and `description` methods.
*
* ### Props
*
* - All of the props for `Post`
*
* @abstract
*/
export default class EventPost extends Post {
attrs() {
const attrs = super.attrs();
attrs.className += ' EventPost ' + ucfirst(this.props.post.contentType()) + 'Post';
return attrs;
}
content() {
const user = this.props.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}>{username}</a>
: username
});
return super.content().concat([
icon(this.icon(), {className: 'EventPost-icon'}),
<div class="EventPost-info">
{this.description(data)}
</div>
]);
}
/**
* Get the name of the event icon.
*
* @return {String}
*/
icon() {
return '';
}
/**
* Get the description text for the event.
*
* @param {Object} data
* @return {String|Object} The description to render in the DOM
*/
description(data) {
return app.translator.transChoice(this.descriptionKey(), data.count, data);
}
/**
* Get the translation key for the description of the event.
*
* @return {String}
*/
descriptionKey() {
return '';
}
/**
* Get the translation data for the description of the event.
*
* @return {Object}
*/
descriptionData() {
return {};
}
}

View File

@@ -0,0 +1,106 @@
import Modal from '../../common/components/Modal';
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
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
*
* - `email`
*/
export default class ForgotPasswordModal extends Modal {
init() {
super.init();
/**
* The value of the email input.
*
* @type {Function}
*/
this.email = m.prop(this.props.email || '');
/**
* Whether or not the password reset email was sent successfully.
*
* @type {Boolean}
*/
this.success = false;
}
className() {
return 'ForgotPasswordModal Modal--small';
}
title() {
return app.translator.trans('core.forum.forgot_password.title');
}
content() {
if (this.success) {
return (
<div className="Modal-body">
<div className="Form Form--centered">
<p className="helpText">{app.translator.trans('core.forum.forgot_password.email_sent_message')}</p>
<div className="Form-group">
<Button className="Button Button--primary Button--block" onclick={this.hide.bind(this)}>
{app.translator.trans('core.forum.forgot_password.dismiss_button')}
</Button>
</div>
</div>
</div>
);
}
return (
<div className="Modal-body">
<div className="Form Form--centered">
<p className="helpText">{app.translator.trans('core.forum.forgot_password.text')}</p>
<div className="Form-group">
<input className="FormControl" name="email" type="email" placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))}
value={this.email()}
onchange={m.withAttr('value', 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')
})}
</div>
</div>
</div>
);
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
app.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot',
data: {email: this.email()},
errorHandler: this.onerror.bind(this)
})
.then(() => {
this.success = true;
this.alert = null;
})
.catch(() => {})
.then(this.loaded.bind(this));
}
onerror(error) {
if (error.status === 404) {
error.alert.props.children = app.translator.trans('core.forum.forgot_password.not_found_message');
}
super.onerror(error);
}
}

View File

@@ -0,0 +1,33 @@
import Component from '../../common/Component';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
/**
* The `HeaderPrimary` component displays primary header controls. On the
* default skin, these are shown just to the right of the forum title.
*/
export default class HeaderPrimary extends Component {
view() {
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.
*
* @return {ItemList}
*/
items() {
return new ItemList();
}
}

View File

@@ -0,0 +1,92 @@
import Component from '../../common/Component';
import Button from '../../common/components/Button';
import LogInModal from './LogInModal';
import SignUpModal from './SignUpModal';
import SessionDropdown from './SessionDropdown';
import SelectDropdown from '../../common/components/SelectDropdown';
import NotificationsDropdown from './NotificationsDropdown';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
/**
* The `HeaderSecondary` component displays secondary header controls, such as
* the search box and the user menu. On the default skin, these are shown on the
* right side of the header.
*/
export default class HeaderSecondary extends Component {
view() {
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.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
items.add('search', app.search.render(), 30);
if (app.forum.attribute("showLanguageSelector") && Object.keys(app.data.locales).length > 1) {
const locales = [];
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();
}
}
}));
}
items.add('locale', SelectDropdown.component({
children: locales,
buttonClassName: 'Button Button--link'
}), 20);
}
if (app.session.user) {
items.add('notifications', NotificationsDropdown.component(), 10);
items.add('session', SessionDropdown.component(), 0);
} else {
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(new SignUpModal())
}), 10
);
}
items.add('logIn',
Button.component({
children: app.translator.trans('core.forum.header.log_in_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(new LogInModal())
}), 0
);
}
return items;
}
}

View File

@@ -0,0 +1,389 @@
import { extend } from '../../common/extend';
import Page from './Page';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import icon from '../../common/helpers/icon';
import DiscussionList from './DiscussionList';
import WelcomeHero from './WelcomeHero';
import DiscussionComposer from './DiscussionComposer';
import LogInModal from './LogInModal';
import DiscussionPage from './DiscussionPage';
import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import LinkButton from '../../common/components/LinkButton';
import SelectDropdown from '../../common/components/SelectDropdown';
/**
* The `IndexPage` component displays the index page, including the welcome
* hero, the sidebar, and the discussion list.
*/
export default class IndexPage extends Page {
init() {
super.init();
// 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
// scroll down so that this discussion is in view.
if (app.previous instanceof DiscussionPage) {
this.lastDiscussion = app.previous.discussion;
}
// If the user is coming from the discussion list, then they have either
// just switched one of the parameters (filter, sort, search) or they
// probably want to refresh the results. We will clear the discussion list
// cache so that results are reloaded.
if (app.previous instanceof IndexPage) {
app.cache.discussionList = null;
}
const params = this.params();
if (app.cache.discussionList) {
// Compare the requested parameters (sort, search query) to the ones that
// are currently present in the cached discussion list. If they differ, we
// will clear the cache and set up a new discussion list component with
// the new parameters.
Object.keys(params).some(key => {
if (app.cache.discussionList.props.params[key] !== params[key]) {
app.cache.discussionList = null;
return true;
}
});
}
if (!app.cache.discussionList) {
app.cache.discussionList = new DiscussionList({params});
}
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
this.bodyClass = 'App--index';
}
onunload() {
// Save the scroll position so we can restore it when we return to the
// discussion list.
app.cache.scrollTop = $(window).scrollTop();
}
view() {
return (
<div className="IndexPage">
{this.hero()}
<div className="container">
<nav className="IndexPage-nav sideNav">
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
<div className="IndexPage-results sideNavOffset">
<div className="IndexPage-toolbar">
<ul className="IndexPage-toolbar-view">{listItems(this.viewItems().toArray())}</ul>
<ul className="IndexPage-toolbar-action">{listItems(this.actionItems().toArray())}</ul>
</div>
{app.cache.discussionList.render()}
</div>
</div>
</div>
);
}
config(isInitialized, context) {
super.config(...arguments);
if (isInitialized) return;
extend(context, 'onunload', () => $('#app').css('min-height', ''));
app.setTitle('');
app.setTitleCount(0);
// 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
// of the hero so that the sidebar doesn't jump around.
const oldHeroHeight = app.cache.heroHeight;
const heroHeight = app.cache.heroHeight = this.$('.Hero').outerHeight();
const scrollTop = app.cache.scrollTop;
$('#app').css('min-height', $(window).height() + heroHeight);
// Scroll to the remembered position. We do this after a short delay so that
// it happens after the browser has done its own "back button" scrolling,
// which isn't right. https://github.com/flarum/core/issues/835
const scroll = () => $(window).scrollTop(scrollTop - oldHeroHeight + heroHeight);
scroll();
setTimeout(scroll, 1);
// If we've just returned from a discussion page, then the constructor will
// have set the `lastDiscussion` property. If this is the case, we want to
// scroll down to that discussion so that it's in view.
if (this.lastDiscussion) {
const $discussion = this.$(`.DiscussionListItem[data-id="${this.lastDiscussion.id()}"]`);
if ($discussion.length) {
const indexTop = $('#header').outerHeight();
const indexBottom = $(window).height();
const discussionTop = $discussion.offset().top;
const discussionBottom = discussionTop + $discussion.outerHeight();
if (discussionTop < scrollTop + indexTop || discussionBottom > scrollTop + indexBottom) {
$(window).scrollTop(discussionTop - indexTop);
}
}
}
}
/**
* Get the component to display as the hero.
*
* @return {MithrilComponent}
*/
hero() {
return WelcomeHero.component();
}
/**
* Build an item list for the sidebar of the index page. By default this is a
* "New Discussion" button, and then a DropdownSelect component containing a
* list of navigation items.
*
* @return {ItemList}
*/
sidebarItems() {
const items = new ItemList();
const canStartDiscussion = app.forum.attribute('canStartDiscussion') || !app.session.user;
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.newDiscussion.bind(this),
disabled: !canStartDiscussion
})
);
items.add('nav',
SelectDropdown.component({
children: this.navItems(this).toArray(),
buttonClassName: 'Button',
className: 'App-titleControl'
})
);
return items;
}
/**
* Build an item list for the navigation in the sidebar of the index page. By
* default this is just the 'All Discussions' link.
*
* @return {ItemList}
*/
navItems() {
const items = new ItemList();
const params = this.stickyParams();
items.add('allDiscussions',
LinkButton.component({
href: app.route('index', params),
children: app.translator.trans('core.forum.index.all_discussions_link'),
icon: 'far fa-comments'
}),
100
);
return items;
}
/**
* Build an item list for the part of the toolbar which is concerned with how
* the results are displayed. By default this is just a select box to change
* the way discussions are sorted.
*
* @return {ItemList}
*/
viewItems() {
const items = new ItemList();
const sortMap = app.cache.discussionList.sortMap();
const sortOptions = {};
for (const i in sortMap) {
sortOptions[i] = app.translator.trans('core.forum.index_sort.' + i + '_button');
}
items.add('sort',
Dropdown.component({
buttonClassName: 'Button',
label: sortOptions[this.params().sort] || Object.keys(sortMap).map(key => sortOptions[key])[0],
children: Object.keys(sortOptions).map(value => {
const label = sortOptions[value];
const active = (this.params().sort || Object.keys(sortMap)[0]) === value;
return Button.component({
children: label,
icon: active ? 'fas fa-check' : true,
onclick: this.changeSort.bind(this, value),
active: active,
})
}),
})
);
return items;
}
/**
* Build an item list for the part of the toolbar which is about taking action
* on the results. By default this is just a "mark all as read" button.
*
* @return {ItemList}
*/
actionItems() {
const items = new ItemList();
items.add('refresh',
Button.component({
title: app.translator.trans('core.forum.index.refresh_tooltip'),
icon: 'fas fa-sync',
className: 'Button Button--icon',
onclick: () => {
app.cache.discussionList.refresh();
if (app.session.user) {
app.store.find('users', app.session.user.id());
m.redraw();
}
}
})
);
if (app.session.user) {
items.add('markAllAsRead',
Button.component({
title: app.translator.trans('core.forum.index.mark_all_as_read_tooltip'),
icon: 'fas fa-check',
className: 'Button Button--icon',
onclick: this.markAllAsRead.bind(this)
})
);
}
return items;
}
/**
* Return the current search query, if any. This is implemented to activate
* the search box in the header.
*
* @see Search
* @return {String}
*/
searching() {
return this.params().q;
}
/**
* Redirect to the index page without a search filter. This is called when the
* 'x' is clicked in the search box in the header.
*
* @see Search
*/
clearSearch() {
const params = this.params();
delete params.q;
m.route(app.route(this.props.routeName, params));
}
/**
* Redirect to the index page using the given sort parameter.
*
* @param {String} sort
*/
changeSort(sort) {
const params = this.params();
if (sort === Object.keys(app.cache.discussionList.sortMap())[0]) {
delete params.sort;
} else {
params.sort = sort;
}
m.route(app.route(this.props.routeName, params));
}
/**
* Get URL parameters that stick between filter changes.
*
* @return {Object}
*/
stickyParams() {
return {
sort: m.route.param('sort'),
q: m.route.param('q')
};
}
/**
* Get parameters to pass to the DiscussionList component.
*
* @return {Object}
*/
params() {
const params = this.stickyParams();
params.filter = m.route.param('filter');
return params;
}
/**
* Log the user in and then open the composer for a new discussion.
*
* @return {Promise}
*/
newDiscussion() {
const deferred = m.deferred();
if (app.session.user) {
this.composeNewDiscussion(deferred);
} else {
app.modal.show(
new LogInModal({
onlogin: this.composeNewDiscussion.bind(this, deferred)
})
);
}
return deferred.promise;
}
/**
* Initialize the composer for a new discussion.
*
* @param {Deferred} deferred
* @return {Promise}
*/
composeNewDiscussion(deferred) {
const component = new DiscussionComposer({user: app.session.user});
app.composer.load(component);
app.composer.show();
deferred.resolve(component);
return deferred.promise;
}
/**
* Mark all discussions as read.
*
* @return void
*/
markAllAsRead() {
const confirmation = confirm(app.translator.trans('core.forum.index.mark_all_as_read_confirmation'));
if (confirmation) {
app.session.user.save({readTime: new Date()});
}
}
}

View File

@@ -0,0 +1,25 @@
import Component from '../../common/Component';
import avatar from '../../common/helpers/avatar';
/**
* The `LoadingPost` component shows a placeholder that looks like a post,
* indicating that the post is loading.
*/
export default class LoadingPost extends Component {
view() {
return (
<div className="Post CommentPost LoadingPost">
<header className="Post-header">
{avatar(null, {className: 'PostUser-avatar'})}
<div className="fakeText"/>
</header>
<div className="Post-body">
<div className="fakeText"/>
<div className="fakeText"/>
<div className="fakeText"/>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,30 @@
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
*
* - `path`
*/
export default class LogInButton extends Button {
static initProps(props) {
props.className = (props.className || '') + ' LogInButton';
props.onclick = function() {
const width = 600;
const height = 400;
const $window = $(window);
window.open(app.forum.attribute('baseUrl') + props.path, 'logInPopup',
`width=${width},` +
`height=${height},` +
`top=${$window.height() / 2 - height / 2},` +
`left=${$window.width() / 2 - width / 2},` +
'status=no,scrollbars=no,resizable=no');
};
super.initProps(props);
}
}

View File

@@ -0,0 +1,25 @@
import Component from '../../common/Component';
import ItemList from '../../common/utils/ItemList';
/**
* The `LogInButtons` component displays a collection of social login buttons.
*/
export default class LogInButtons extends Component {
view() {
return (
<div className="LogInButtons">
{this.items().toArray()}
</div>
);
}
/**
* Build a list of LogInButton components.
*
* @return {ItemList}
* @public
*/
items() {
return new ItemList();
}
}

View File

@@ -0,0 +1,176 @@
import Modal from '../../common/components/Modal';
import ForgotPasswordModal from './ForgotPasswordModal';
import SignUpModal from './SignUpModal';
import Button from '../../common/components/Button';
import LogInButtons from './LogInButtons';
import extractText from '../../common/utils/extractText';
import ItemList from '../../common/utils/ItemList';
/**
* The `LogInModal` component displays a modal dialog with a login form.
*
* ### Props
*
* - `identification`
* - `password`
*/
export default class LogInModal extends Modal {
init() {
super.init();
/**
* The value of the identification input.
*
* @type {Function}
*/
this.identification = m.prop(this.props.identification || '');
/**
* The value of the password input.
*
* @type {Function}
*/
this.password = m.prop(this.props.password || '');
/**
* The value of the remember me input.
*
* @type {Function}
*/
this.remember = m.prop(!!this.props.remember);
}
className() {
return 'LogInModal Modal--small';
}
title() {
return app.translator.trans('core.forum.log_in.title');
}
content() {
return [
<div className="Modal-body">
{this.body()}
</div>,
<div className="Modal-footer">
{this.footer()}
</div>
];
}
body() {
return [
<LogInButtons/>,
<div className="Form Form--centered">
{this.fields().toArray()}
</div>
];
}
fields() {
const items = new ItemList();
items.add('identification', <div className="Form-group">
<input className="FormControl" name="identification" type="text" placeholder={extractText(app.translator.trans('core.forum.log_in.username_or_email_placeholder'))}
bidi={this.identification}
disabled={this.loading} />
</div>, 30);
items.add('password', <div className="Form-group">
<input className="FormControl" name="password" type="password" placeholder={extractText(app.translator.trans('core.forum.log_in.password_placeholder'))}
bidi={this.password}
disabled={this.loading} />
</div>, 20);
items.add('remember', <div className="Form-group">
<div>
<label className="checkbox">
<input type="checkbox" bidi={this.remember} disabled={this.loading} />
{app.translator.trans('core.forum.log_in.remember_me_label')}
</label>
</div>
</div>, 10);
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')
})}
</div>, -10);
return items;
}
footer() {
return [
<p className="LogInModal-forgotPassword">
<a onclick={this.forgotPassword.bind(this)}>{app.translator.trans('core.forum.log_in.forgot_password_link')}</a>
</p>,
app.forum.attribute('allowSignUp') ? (
<p className="LogInModal-signUp">
{app.translator.trans('core.forum.log_in.sign_up_text', {a: <a onclick={this.signUp.bind(this)}/>})}
</p>
) : ''
];
}
/**
* Open the forgot password modal, prefilling it with an email if the user has
* entered one.
*
* @public
*/
forgotPassword() {
const email = this.identification();
const props = email.indexOf('@') !== -1 ? {email} : undefined;
app.modal.show(new ForgotPasswordModal(props));
}
/**
* Open the sign up modal, prefilling it with an email/username/password if
* the user has entered one.
*
* @public
*/
signUp() {
const props = {password: this.password()};
const identification = this.identification();
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
app.modal.show(new SignUpModal(props));
}
onready() {
this.$('[name=' + (this.identification() ? 'password' : 'identification') + ']').select();
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
const identification = this.identification();
const password = this.password();
const remember = this.remember();
app.session.login({identification, password, remember}, {errorHandler: this.onerror.bind(this)})
.then(
() => window.location.reload(),
this.loaded.bind(this)
);
}
onerror(error) {
if (error.status === 401) {
error.alert.props.children = app.translator.trans('core.forum.log_in.invalid_login_message');
}
super.onerror(error);
}
}

View File

@@ -0,0 +1,86 @@
import Component from '../../common/Component';
import avatar from '../../common/helpers/avatar';
import icon from '../../common/helpers/icon';
import humanTime from '../../common/helpers/humanTime';
/**
* The `Notification` component abstract displays a single notification.
* Subclasses should implement the `icon`, `href`, and `content` methods.
*
* ### Props
*
* - `notification`
*
* @abstract
*/
export default class Notification extends Component {
view() {
const notification = this.props.notification;
const href = this.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));
}}>
{avatar(notification.sender())}
{icon(this.icon(), {className: 'Notification-icon'})}
<span className="Notification-content">{this.content()}</span>
{humanTime(notification.time())}
<div className="Notification-excerpt">
{this.excerpt()}
</div>
</a>
);
}
/**
* Get the name of the icon that should be displayed in the notification.
*
* @return {String}
* @abstract
*/
icon() {
}
/**
* Get the URL that the notification should link to.
*
* @return {String}
* @abstract
*/
href() {
}
/**
* Get the content of the notification.
*
* @return {VirtualElement}
* @abstract
*/
content() {
}
/**
* Get the excerpt of the notification.
*
* @return {VirtualElement}
* @abstract
*/
excerpt() {
}
/**
* Mark the notification as read.
*/
markAsRead() {
if (this.props.notification.isRead()) return;
app.session.user.pushAttributes({unreadNotificationsCount: app.session.user.unreadNotificationsCount() - 1});
this.props.notification.save({isRead: true});
}
}

View File

@@ -0,0 +1,215 @@
import Component from '../../common/Component';
import Checkbox from '../../common/components/Checkbox';
import icon from '../../common/helpers/icon';
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
*
* - `user`
*/
export default class NotificationGrid extends Component {
init() {
/**
* Information about the available notification methods.
*
* @type {Array}
*/
this.methods = this.notificationMethods().toArray();
/**
* A map of notification type-method combinations to the checkbox instances
* that represent them.
*
* @type {Object}
*/
this.inputs = {};
/**
* Information about the available notification types.
*
* @type {Array}
*/
this.types = this.notificationTypes().toArray();
// For each of the notification type-method combinations, create and store a
// new checkbox component instance, which we will render in the view.
this.types.forEach(type => {
this.methods.forEach(method => {
const key = this.preferenceKey(type.name, method.name);
const preference = this.props.user.preferences()[key];
this.inputs[key] = new Checkbox({
state: !!preference,
disabled: typeof preference === 'undefined',
onchange: () => this.toggle([key])
});
});
});
}
view() {
return (
<table className="NotificationGrid">
<thead>
<tr>
<td/>
{this.methods.map(method => (
<th className="NotificationGrid-groupToggle" onclick={this.toggleMethod.bind(this, method.name)}>
{icon(method.icon)} {method.label}
</th>
))}
</tr>
</thead>
<tbody>
{this.types.map(type => (
<tr>
<td className="NotificationGrid-groupToggle" onclick={this.toggleType.bind(this, type.name)}>
{icon(type.icon)} {type.label}
</td>
{this.methods.map(method => (
<td className="NotificationGrid-checkbox">
{this.inputs[this.preferenceKey(type.name, method.name)].render()}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
config(isInitialized) {
if (isInitialized) return;
this.$('thead .NotificationGrid-groupToggle').bind('mouseenter mouseleave', function(e) {
const i = parseInt($(this).index(), 10) + 1;
$(this).parents('table').find('td:nth-child(' + i + ')').toggleClass('highlighted', e.type === 'mouseenter');
});
this.$('tbody .NotificationGrid-groupToggle').bind('mouseenter mouseleave', function(e) {
$(this).parent().find('td').toggleClass('highlighted', e.type === 'mouseenter');
});
}
/**
* Toggle the state of the given preferences, based on the value of the first
* one.
*
* @param {Array} keys
*/
toggle(keys) {
const user = this.props.user;
const preferences = user.preferences();
const enabled = !preferences[keys[0]];
keys.forEach(key => {
const control = this.inputs[key];
control.loading = true;
preferences[key] = control.props.state = enabled;
});
m.redraw();
user.save({preferences}).then(() => {
keys.forEach(key => this.inputs[key].loading = false);
m.redraw();
});
}
/**
* Toggle all notification types for the given method.
*
* @param {String} method
*/
toggleMethod(method) {
const keys = this.types
.map(type => this.preferenceKey(type.name, method))
.filter(key => !this.inputs[key].props.disabled);
this.toggle(keys);
}
/**
* Toggle all notification methods for the given type.
*
* @param {String} type
*/
toggleType(type) {
const keys = this.methods
.map(method => this.preferenceKey(type, method.name))
.filter(key => !this.inputs[key].props.disabled);
this.toggle(keys);
}
/**
* Get the name of the preference key for the given notification type-method
* combination.
*
* @param {String} type
* @param {String} method
* @return {String}
*/
preferenceKey(type, method) {
return 'notify_' + type + '_' + method;
}
/**
* Build an item list for the notification methods to display in the grid.
*
* Each notification method is an object which has the following properties:
*
* - `name` The name of the notification method.
* - `icon` The icon to display in the column header.
* - `label` The label to display in the column header.
*
* @return {ItemList}
*/
notificationMethods() {
const items = new ItemList();
items.add('alert', {
name: 'alert',
icon: 'fas fa-bell',
label: app.translator.trans('core.forum.settings.notify_by_web_heading'),
});
items.add('email', {
name: 'email',
icon: 'far fa-envelope',
label: app.translator.trans('core.forum.settings.notify_by_email_heading'),
});
return items;
}
/**
* Build an item list for the notification types to display in the grid.
*
* Each notification type is an object which has the following properties:
*
* - `name` The name of the notification type.
* - `icon` The icon to display in the notification grid row.
* - `label` The label to display in the notification grid row.
*
* @return {ItemList}
*/
notificationTypes() {
const items = new ItemList();
items.add('discussionRenamed', {
name: 'discussionRenamed',
icon: 'fas fa-pencil-alt',
label: app.translator.trans('core.forum.settings.notify_discussion_renamed_label')
});
return items;
}
}

View File

@@ -0,0 +1,205 @@
import Component from '../../common/Component';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Discussion from '../../common/models/Discussion';
/**
* The `NotificationList` component displays a list of the logged-in user's
* notifications, grouped by discussion.
*/
export default class NotificationList extends Component {
init() {
/**
* Whether or not the notifications are loading.
*
* @type {Boolean}
*/
this.loading = false;
/**
* Whether or not there are more results that can be loaded.
*
* @type {Boolean}
*/
this.moreResults = false;
}
view() {
const pages = app.cache.notifications || [];
return (
<div className="NotificationList">
<div className="NotificationList-header">
<div className="App-primaryControl">
{Button.component({
className: 'Button Button--icon Button--link',
icon: 'fas fa-check',
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
onclick: this.markAllAsRead.bind(this)
})}
</div>
<h4 className="App-titleControl App-titleControl--text">{app.translator.trans('core.forum.notifications.title')}</h4>
</div>
<div className="NotificationList-content">
{pages.length ? pages.map(notifications => {
const groups = [];
const discussions = {};
notifications.forEach(notification => {
const subject = notification.subject();
if (typeof subject === 'undefined') return;
// Get the discussion that this notification is related to. If it's not
// directly related to a discussion, it may be related to a post or
// other entity which is related to a discussion.
let discussion = false;
if (subject instanceof Discussion) discussion = subject;
else if (subject && subject.discussion) discussion = subject.discussion();
// If the notification is not related to a discussion directly or
// indirectly, then we will assign it to a neutral group.
const key = discussion ? discussion.id() : 0;
discussions[key] = discussions[key] || {discussion: discussion, notifications: []};
discussions[key].notifications.push(notification);
if (groups.indexOf(discussions[key]) === -1) {
groups.push(discussions[key]);
}
});
return groups.map(group => {
const badges = group.discussion && group.discussion.badges().toArray();
return (
<div className="NotificationGroup">
{group.discussion
? (
<a className="NotificationGroup-header"
href={app.route.discussion(group.discussion)}
config={m.route}>
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
{group.discussion.title()}
</a>
) : (
<div className="NotificationGroup-header">
{app.forum.attribute('title')}
</div>
)}
<ul className="NotificationGroup-content">
{group.notifications.map(notification => {
const NotificationComponent = app.notificationComponents[notification.contentType()];
return NotificationComponent ? <li>{NotificationComponent.component({notification})}</li> : '';
})}
</ul>
</div>
);
});
}) : ''}
{this.loading
? <LoadingIndicator className="LoadingIndicator--block" />
: (pages.length ? '' : <div className="NotificationList-empty">{app.translator.trans('core.forum.notifications.empty_text')}</div>)}
</div>
</div>
);
}
config(isInitialized, context) {
if (isInitialized) return;
const $notifications = this.$('.NotificationList-content');
const $scrollParent = $notifications.css('overflow') === 'auto' ? $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;
if (this.moreResults && !this.loading && scrollTop + viewportHeight >= contentTop + contentHeight) {
this.loadMore();
}
};
$scrollParent.on('scroll', scrollHandler);
context.onunload = () => {
$scrollParent.off('scroll', scrollHandler);
};
}
/**
* Load notifications into the application's cache if they haven't already
* been loaded.
*/
load() {
if (app.session.user.newNotificationsCount()) {
delete app.cache.notifications;
}
if (app.cache.notifications) {
return;
}
app.session.user.pushAttributes({newNotificationsCount: 0});
this.loadMore();
}
/**
* Load the next page of notification results.
*
* @public
*/
loadMore() {
this.loading = true;
m.redraw();
const params = app.cache.notifications ? {page: {offset: app.cache.notifications.length * 10}} : null;
return app.store.find('notifications', params)
.then(this.parseResults.bind(this))
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
/**
* Parse results and append them to the notification list.
*
* @param {Notification[]} results
* @return {Notification[]}
*/
parseResults(results) {
app.cache.notifications = app.cache.notifications || [];
app.cache.notifications.push(results);
this.moreResults = !!results.payload.links.next;
return results;
}
/**
* Mark all of the notifications as read.
*/
markAllAsRead() {
if (!app.cache.notifications) return;
app.session.user.pushAttributes({unreadNotificationsCount: 0});
app.cache.notifications.forEach(notifications => {
notifications.forEach(notification => notification.pushAttributes({isRead: true}))
});
app.request({
url: app.forum.attribute('apiUrl') + '/notifications/read',
method: 'POST'
});
}
}

View File

@@ -0,0 +1,77 @@
import Dropdown from '../../common/components/Dropdown';
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';
super.initProps(props);
}
init() {
super.init();
this.list = new NotificationList();
}
getButton() {
const newNotifications = this.getNewCount();
const vdom = super.getButton();
vdom.attrs.title = this.props.label;
vdom.attrs.className += (newNotifications ? ' new' : '');
vdom.attrs.onclick = this.onclick.bind(this);
return vdom;
}
getButtonContent() {
const unread = this.getUnreadCount();
return [
icon(this.props.icon, {className: 'Button-icon'}),
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
<span className="Button-label">{this.props.label}</span>
];
}
getMenu() {
return (
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? this.list.render() : ''}
</div>
);
}
onclick() {
if (app.drawer.isOpen()) {
this.goToRoute();
} else {
this.list.load();
}
}
goToRoute() {
m.route(app.route('notifications'));
}
getUnreadCount() {
return app.session.user.unreadNotificationsCount();
}
getNewCount() {
return app.session.user.newNotificationsCount();
}
menuClick(e) {
// Don't close the notifications dropdown if the user is opening a link in a
// new tab or window.
if (e.shiftKey || e.metaKey || e.ctrlKey || e.which === 2) e.stopPropagation();
}
}

View File

@@ -0,0 +1,23 @@
import Page from './Page';
import NotificationList from './NotificationList';
/**
* The `NotificationsPage` component shows the notifications list. It is only
* used on mobile devices where the notifications dropdown is within the drawer.
*/
export default class NotificationsPage extends Page {
init() {
super.init();
app.history.push('notifications');
this.list = new NotificationList();
this.list.load();
this.bodyClass = 'App--notifications';
}
view() {
return <div className="NotificationsPage">{this.list.render()}</div>;
}
}

View File

@@ -0,0 +1,33 @@
import Component from '../../common/Component';
/**
* The `Page` component
*
* @abstract
*/
export default class Page extends Component {
init() {
app.previous = app.current;
app.current = this;
app.drawer.hide();
app.modal.close();
/**
* A class name to apply to the body while the route is active.
*
* @type {String}
*/
this.bodyClass = '';
}
config(isInitialized, context) {
if (isInitialized) return;
if (this.bodyClass) {
$('#app').addClass(this.bodyClass);
context.onunload = () => $('#app').removeClass(this.bodyClass);
}
}
}

View File

@@ -0,0 +1,118 @@
import Component from '../../common/Component';
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
import Dropdown from '../../common/components/Dropdown';
import PostControls from '../utils/PostControls';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
/**
* The `Post` component displays a single post. The basic post template just
* includes a controls dropdown; subclasses must implement `content` and `attrs`
* methods.
*
* ### Props
*
* - `post`
*
* @abstract
*/
export default class Post extends Component {
init() {
this.loading = false;
/**
* Set up a subtree retainer so that the post will not be redrawn
* unless new data comes in.
*
* @type {SubtreeRetainer}
*/
this.subtree = new SubtreeRetainer(
() => this.props.post.freshness,
() => {
const user = this.props.post.user();
return user && user.freshness;
},
() => this.controlsOpen
);
}
view() {
const attrs = this.attrs();
attrs.className = 'Post ' + (this.loading ? 'Post--loading ' : '') + (attrs.className || '');
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>
);
})()}
</article>
);
}
config(isInitialized) {
const $actions = this.$('.Post-actions');
const $controls = this.$('.Post-controls');
$actions.toggleClass('open', $controls.hasClass('open'));
}
/**
* Get attributes for the post element.
*
* @return {Object}
*/
attrs() {
return {};
}
/**
* Get the post's content.
*
* @return {Array}
*/
content() {
return [];
}
/**
* Build an item list for the post's actions.
*
* @return {ItemList}
*/
actionItems() {
return new ItemList();
}
/**
* Build an item list for the post's footer.
*
* @return {ItemList}
*/
footerItems() {
return new ItemList();
}
}

View File

@@ -0,0 +1,44 @@
import Component from '../../common/Component';
import humanTime from '../../common/utils/humanTime';
import extractText from '../../common/utils/extractText';
/**
* The `PostEdited` component displays information about when and by whom a post
* was edited.
*
* ### Props
*
* - `post`
*/
export default class PostEdited extends Component {
init() {
this.shouldUpdateTooltip = false;
this.oldEditedInfo = null;
}
view() {
const post = this.props.post;
const editUser = post.editUser();
const editedInfo = extractText(app.translator.trans(
'core.forum.post.edited_tooltip',
{user: editUser, ago: humanTime(post.editTime())}
));
if (editedInfo !== this.oldEditedInfo) {
this.shouldUpdateTooltip = true;
this.oldEditedInfo = editedInfo;
}
return (
<span className="PostEdited" title={editedInfo}>
{app.translator.trans('core.forum.post.edited_text')}
</span>
);
}
config(isInitialized) {
if (this.shouldUpdateTooltip) {
this.$().tooltip('destroy').tooltip();
this.shouldUpdateTooltip = false;
}
}
}

View File

@@ -0,0 +1,56 @@
import Component from '../../common/Component';
import humanTime from '../../common/helpers/humanTime';
import fullTime from '../../common/helpers/fullTime';
/**
* The `PostMeta` component displays the time of a post, and when clicked, shows
* a dropdown containing more information about the post (number, full time,
* permalink).
*
* ### Props
*
* - `post`
*/
export default class PostMeta extends Component {
view() {
const post = this.props.post;
const time = post.time();
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() {
setTimeout(() => $(this).parent().find('.PostMeta-permalink').select());
m.redraw.strategy('none');
};
return (
<div className="Dropdown PostMeta">
<a className="Dropdown-toggle" onclick={selectPermalink} data-toggle="dropdown">
{humanTime(time)}
</a>
<div className="Dropdown-menu dropdown-menu">
<span className="PostMeta-number">{app.translator.trans('core.forum.post.number_tooltip', {number: post.number()})}</span>{' '}
<span className="PostMeta-time">{fullTime(time)}</span>{' '}
<span className="PostMeta-ip">{post.data.attributes.ipAddress}</span>
{touch
? <a className="Button PostMeta-permalink" href={permalink}>{permalink}</a>
: <input className="FormControl PostMeta-permalink" value={permalink} onclick={e => e.stopPropagation()} />}
</div>
</div>
);
}
/**
* Get the permalink for the given post.
*
* @param {Post} post
* @returns {String}
*/
getPermalink(post) {
return window.location.origin + app.route.post(post);
}
}

View File

@@ -0,0 +1,30 @@
import Component from '../../common/Component';
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
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
*
* - `post`
*/
export default class PostPreview extends Component {
view() {
const post = this.props.post;
const user = post.user();
const excerpt = highlight(post.contentPlain(), this.props.highlight, 300);
return (
<a className="PostPreview" href={app.route.post(post)} config={m.route} onclick={this.props.onclick}>
<span className="PostPreview-content">
{avatar(user)}
{username(user)}{' '}
<span className="PostPreview-excerpt">{excerpt}</span>
</span>
</a>
);
}
}

View File

@@ -0,0 +1,603 @@
import Component from '../../common/Component';
import ScrollListener from '../../common/utils/ScrollListener';
import PostLoading from './LoadingPost';
import anchorScroll from '../../common/utils/anchorScroll';
import mixin from '../../common/utils/mixin';
import evented from '../../common/utils/evented';
import ReplyPlaceholder from './ReplyPlaceholder';
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
*
* - `discussion`
* - `includedPosts`
*/
class PostStream extends Component {
init() {
/**
* The discussion to display the post stream for.
*
* @type {Discussion}
*/
this.discussion = this.props.discussion;
/**
* Whether or not the infinite-scrolling auto-load functionality is
* disabled.
*
* @type {Boolean}
*/
this.paused = false;
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
this.loadPageTimeouts = {};
this.pagesLoading = 0;
this.show(this.props.includedPosts);
}
/**
* Load and scroll to a post with a certain number.
*
* @param {Integer|String} number The post number to go to. If 'reply', go to
* the last post and scroll the reply preview into view.
* @param {Boolean} noAnimation
* @return {Promise}
*/
goToNumber(number, noAnimation) {
// If we want to go to the reply preview, then we will go to the end of the
// discussion and then scroll to the very bottom of the page.
if (number === 'reply') {
return this.goToLast().then(() => {
$('html,body').stop(true).animate({
scrollTop: $(document).height() - $(window).height()
}, 'fast', () => {
this.flashItem(this.$('.PostStream-item:last-child'));
});
});
}
this.paused = true;
const promise = this.loadNearNumber(number);
m.redraw(true);
return promise.then(() => {
m.redraw(true);
this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
});
}
/**
* Load and scroll to a certain index within the discussion.
*
* @param {Integer} index
* @param {Boolean} backwards Whether or not to load backwards from the given
* index.
* @param {Boolean} noAnimation
* @return {Promise}
*/
goToIndex(index, backwards, noAnimation) {
this.paused = true;
const promise = this.loadNearIndex(index);
m.redraw(true);
return promise.then(() => {
anchorScroll(this.$('.PostStream-item:' + (backwards ? 'last' : 'first')), () => m.redraw(true));
this.scrollToIndex(index, noAnimation, backwards).done(this.unpause.bind(this));
});
}
/**
* Load and scroll up to the first post in the discussion.
*
* @return {Promise}
*/
goToFirst() {
return this.goToIndex(0);
}
/**
* Load and scroll down to the last post in the discussion.
*
* @return {Promise}
*/
goToLast() {
return this.goToIndex(this.count() - 1, true);
}
/**
* Update the stream so that it loads and includes the latest posts in the
* discussion, if the end is being viewed.
*
* @public
*/
update() {
if (!this.viewingEnd) return m.deferred().resolve().promise;
this.visibleEnd = this.count();
return this.loadRange(this.visibleStart, this.visibleEnd).then(() => m.redraw());
}
/**
* Get the total number of posts in the discussion.
*
* @return {Integer}
*/
count() {
return this.discussion.postIds().length;
}
/**
* Make sure that the given index is not outside of the possible range of
* indexes in the discussion.
*
* @param {Integer} index
* @protected
*/
sanitizeIndex(index) {
return Math.max(0, Math.min(this.count(), index));
}
/**
* Set up the stream with the given array of posts.
*
* @param {Post[]} posts
*/
show(posts) {
this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
this.visibleEnd = this.visibleStart + posts.length;
}
/**
* Reset the stream so that a specific range of posts is displayed. If a range
* is not specified, the first page of posts will be displayed.
*
* @param {Integer} [start]
* @param {Integer} [end]
*/
reset(start, end) {
this.visibleStart = start || 0;
this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount);
}
/**
* Get the visible page of posts.
*
* @return {Post[]}
*/
posts() {
return this.discussion.postIds()
.slice(this.visibleStart, this.visibleEnd)
.map(id => {
const post = app.store.getById('posts', id);
return post && post.discussion() && typeof post.canEdit() !== 'undefined' ? post : null;
});
}
view() {
function fadeIn(element, isInitialized, context) {
if (!context.fadedIn) $(element).hide().fadeIn();
context.fadedIn = true;
}
let lastTime;
this.visibleEnd = this.sanitizeIndex(this.visibleEnd);
this.viewingEnd = this.visibleEnd === this.count();
const posts = this.posts();
const postIds = this.discussion.postIds();
const items = posts.map((post, i) => {
let content;
const attrs = {'data-index': this.visibleStart + i};
if (post) {
const time = post.time();
const PostComponent = app.postComponents[post.contentType()];
content = PostComponent ? PostComponent.component({post}) : '';
attrs.key = 'post' + post.id();
attrs.config = fadeIn;
attrs['data-time'] = time.toISOString();
attrs['data-number'] = post.number();
attrs['data-id'] = post.id();
attrs['data-type'] = post.contentType();
// If the post before this one was more than 4 hours ago, we will
// display a 'time gap' indicating how long it has been in between
// the posts.
const dt = time - lastTime;
if (dt > 1000 * 60 * 60 * 24 * 4) {
content = [
<div className="PostStream-timeGap">
<span>{app.translator.trans('core.forum.post_stream.time_lapsed_text', {period: moment.duration(dt).humanize()})}</span>
</div>,
content
];
}
lastTime = time;
} else {
attrs.key = 'post' + postIds[this.visibleStart + i];
content = PostLoading.component();
}
return <div className="PostStream-item" {...attrs}>{content}</div>;
});
if (!this.viewingEnd && posts[this.visibleEnd - this.visibleStart - 1]) {
items.push(
<div className="PostStream-loadMore" key="loadMore">
<Button className="Button" onclick={this.loadNext.bind(this)}>
{app.translator.trans('core.forum.post_stream.load_more_button')}
</Button>
</div>
);
}
// If we're viewing the end of the discussion, the user can reply, and
// is not already doing so, then show a 'write a reply' placeholder.
if (this.viewingEnd && (!app.session.user || this.discussion.canReply())) {
items.push(
<div className="PostStream-item" key="reply">
{ReplyPlaceholder.component({discussion: this.discussion})}
</div>
);
}
return (
<div className="PostStream">
{items}
</div>
);
}
config(isInitialized, context) {
if (isInitialized) return;
// 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);
};
}
/**
* When the window is scrolled, check if either extreme of the post stream is
* in the viewport, and if so, trigger loading the next/previous page.
*
* @param {Integer} top
*/
onscroll(top) {
if (this.paused) return;
const marginTop = this.getMarginTop();
const viewportHeight = $(window).height() - marginTop;
const viewportTop = top + marginTop;
const loadAheadDistance = 300;
if (this.visibleStart > 0) {
const $item = this.$('.PostStream-item[data-index=' + this.visibleStart + ']');
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
this.loadPrevious();
}
}
if (this.visibleEnd < this.count()) {
const $item = this.$('.PostStream-item[data-index=' + (this.visibleEnd - 1) + ']');
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
this.loadNext();
}
}
// Throttle calculation of our position (start/end numbers of posts in the
// viewport) to 100ms.
clearTimeout(this.calculatePositionTimeout);
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 100);
}
/**
* Load the next page of posts.
*/
loadNext() {
const start = this.visibleEnd;
const end = this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount);
// Unload the posts which are two pages back from the page we're currently
// loading.
const twoPagesAway = start - this.constructor.loadCount * 2;
if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) {
this.visibleStart = twoPagesAway + this.constructor.loadCount + 1;
if (this.loadPageTimeouts[twoPagesAway]) {
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
this.loadPageTimeouts[twoPagesAway] = null;
this.pagesLoading--;
}
}
this.loadPage(start, end);
}
/**
* Load the previous page of posts.
*/
loadPrevious() {
const end = this.visibleStart;
const start = this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount);
// Unload the posts which are two pages back from the page we're currently
// loading.
const twoPagesAway = start + this.constructor.loadCount * 2;
if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) {
this.visibleEnd = twoPagesAway;
if (this.loadPageTimeouts[twoPagesAway]) {
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
this.loadPageTimeouts[twoPagesAway] = null;
this.pagesLoading--;
}
}
this.loadPage(start, end, true);
}
/**
* Load a page of posts into the stream and redraw.
*
* @param {Integer} start
* @param {Integer} end
* @param {Boolean} backwards
*/
loadPage(start, end, backwards) {
const redraw = () => {
if (start < this.visibleStart || end > this.visibleEnd) return;
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw(true));
this.unpause();
};
redraw();
this.loadPageTimeouts[start] = setTimeout(() => {
this.loadRange(start, end).then(() => {
redraw();
this.pagesLoading--;
});
this.loadPageTimeouts[start] = null;
}, this.pagesLoading ? 1000 : 0);
this.pagesLoading++;
}
/**
* Load and inject the specified range of posts into the stream, without
* clearing it.
*
* @param {Integer} start
* @param {Integer} end
* @return {Promise}
*/
loadRange(start, end) {
const loadIds = [];
const loaded = [];
this.discussion.postIds().slice(start, end).forEach(id => {
const post = app.store.getById('posts', id);
if (post && post.discussion() && typeof post.canEdit() !== 'undefined') {
loaded.push(post);
} else {
loadIds.push(id);
}
});
return loadIds.length
? app.store.find('posts', loadIds)
: m.deferred().resolve(loaded).promise;
}
/**
* Clear the stream and load posts near a certain number. Returns a promise.
* If the post with the given number is already loaded, the promise will be
* resolved immediately.
*
* @param {Integer} number
* @return {Promise}
*/
loadNearNumber(number) {
if (this.posts().some(post => post && Number(post.number()) === Number(number))) {
return m.deferred().resolve().promise;
}
this.reset();
return app.store.find('posts', {
filter: {discussion: this.discussion.id()},
page: {near: number}
}).then(this.show.bind(this));
}
/**
* Clear the stream and load posts near a certain index. A page of posts
* surrounding the given index will be loaded. Returns a promise. If the given
* index is already loaded, the promise will be resolved immediately.
*
* @param {Integer} index
* @return {Promise}
*/
loadNearIndex(index) {
if (index >= this.visibleStart && index <= this.visibleEnd) {
return m.deferred().resolve().promise;
}
const start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
const end = start + this.constructor.loadCount;
this.reset(start, end);
return this.loadRange(start, end).then(this.show.bind(this));
}
/**
* Work out which posts (by number) are currently visible in the viewport, and
* fire an event with the information.
*/
calculatePosition() {
const marginTop = this.getMarginTop();
const $window = $(window);
const viewportHeight = $window.height() - marginTop;
const scrollTop = $window.scrollTop() + marginTop;
let startNumber;
let endNumber;
this.$('.PostStream-item').each(function() {
const $item = $(this);
const top = $item.offset().top;
const height = $item.outerHeight(true);
if (top + height > scrollTop) {
if (!startNumber) {
startNumber = endNumber = $item.data('number');
}
if (top + height < scrollTop + viewportHeight) {
if ($item.data('number')) {
endNumber = $item.data('number');
}
} else return false;
}
});
if (startNumber) {
this.trigger('positionChanged', startNumber || 1, endNumber);
}
}
/**
* Get the distance from the top of the viewport to the point at which we
* would consider a post to be the first one visible.
*
* @return {Integer}
*/
getMarginTop() {
return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
}
/**
* Scroll down to a certain post by number and 'flash' it.
*
* @param {Integer} number
* @param {Boolean} noAnimation
* @return {jQuery.Deferred}
*/
scrollToNumber(number, noAnimation) {
const $item = this.$(`.PostStream-item[data-number=${number}]`);
return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item));
}
/**
* Scroll down to a certain post by index.
*
* @param {Integer} index
* @param {Boolean} noAnimation
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
* at the given index, instead of the top of it.
* @return {jQuery.Deferred}
*/
scrollToIndex(index, noAnimation, bottom) {
const $item = this.$(`.PostStream-item[data-index=${index}]`);
return this.scrollToItem($item, noAnimation, true, bottom);
}
/**
* Scroll down to the given post.
*
* @param {jQuery} $item
* @param {Boolean} noAnimation
* @param {Boolean} force Whether or not to force scrolling to the item, even
* if it is already in the viewport.
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
* at the given index, instead of the top of it.
* @return {jQuery.Deferred}
*/
scrollToItem($item, noAnimation, force, bottom) {
const $container = $('html, body').stop(true);
if ($item.length) {
const itemTop = $item.offset().top - this.getMarginTop();
const itemBottom = $item.offset().top + $item.height();
const scrollTop = $(document).scrollTop();
const scrollBottom = scrollTop + $(window).height();
// If the item is already in the viewport, we may not need to scroll.
// If we're scrolling to the bottom of an item, then we'll make sure the
// bottom will line up with the top of the composer.
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
const top = bottom
? itemBottom - $(window).height() + app.composer.computedHeight()
: ($item.is(':first-child') ? 0 : itemTop);
if (noAnimation) {
$container.scrollTop(top);
} else if (top !== scrollTop) {
$container.animate({scrollTop: top}, 'fast');
}
}
}
return $container.promise();
}
/**
* 'Flash' the given post, drawing the user's attention to it.
*
* @param {jQuery} $item
*/
flashItem($item) {
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
}
/**
* Resume the stream's ability to auto-load posts on scroll.
*/
unpause() {
this.paused = false;
this.scrollListener.update(true);
this.trigger('unpaused');
}
}
/**
* The number of posts to load per page.
*
* @type {Integer}
*/
PostStream.loadCount = 20;
Object.assign(PostStream.prototype, evented);
export default PostStream;

View File

@@ -0,0 +1,447 @@
import Component from '../../common/Component';
import icon from '../../common/helpers/icon';
import ScrollListener from '../../common/utils/ScrollListener';
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
import computed from '../../common/utils/computed';
import formatNumber from '../../common/utils/formatNumber';
/**
* The `PostStreamScrubber` component displays a scrubber which can be used to
* navigate/scrub through a post stream.
*
* ### Props
*
* - `stream`
* - `className`
*/
export default class PostStreamScrubber extends Component {
init() {
this.handlers = {};
/**
* The index of the post that is currently at the top of the viewport.
*
* @type {Number}
*/
this.index = 0;
/**
* The number of posts that are currently visible in the viewport.
*
* @type {Number}
*/
this.visible = 1;
/**
* The description to render on the scrubber.
*
* @type {String}
*/
this.description = '';
// When the post stream begins loading posts at a certain index, we want our
// scrubber scrollbar to jump to that position.
this.props.stream.on('unpaused', this.handlers.streamWasUnpaused = this.streamWasUnpaused.bind(this));
// Define a handler to update the state of the scrollbar to reflect the
// current scroll position of the page.
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
// Create a subtree retainer that will always cache the subtree after the
// initial draw. We render parts of the scrubber using this because we
// modify their DOM directly, and do not want Mithril messing around with
// our changes.
this.subtree = new SubtreeRetainer(() => true);
}
view() {
const retain = this.subtree.retain();
const count = this.count();
const unreadCount = this.props.stream.discussion.unreadCount();
const unreadPercent = count ? Math.min(count - this.index, unreadCount) / count : 0;
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
index: <span className="Scrubber-index">{retain || formatNumber(Math.min(Math.ceil(this.index + this.visible), count))}</span>,
count: <span className="Scrubber-count">{formatNumber(count)}</span>
});
function styleUnread(element, isInitialized, context) {
const $element = $(element);
const newStyle = {
top: (100 - unreadPercent * 100) + '%',
height: (unreadPercent * 100) + '%'
};
if (context.oldStyle) {
$element.stop(true).css(context.oldStyle).animate(newStyle);
} else {
$element.css(newStyle);
}
context.oldStyle = newStyle;
}
return (
<div className={'PostStreamScrubber Dropdown ' + (this.disabled() ? 'disabled ' : '') + (this.props.className || '')}>
<button className="Button Dropdown-toggle" data-toggle="dropdown">
{viewing} {icon('fas fa-sort')}
</button>
<div className="Dropdown-menu dropdown-menu">
<div className="Scrubber">
<a className="Scrubber-first" onclick={this.goToFirst.bind(this)}>
{icon('fas fa-angle-double-up')} {app.translator.trans('core.forum.post_scrubber.original_post_link')}
</a>
<div className="Scrubber-scrollbar">
<div className="Scrubber-before"/>
<div className="Scrubber-handle">
<div className="Scrubber-bar"/>
<div className="Scrubber-info">
<strong>{viewing}</strong>
<span class="Scrubber-description">{retain || this.description}</span>
</div>
</div>
<div className="Scrubber-after"/>
<div className="Scrubber-unread" config={styleUnread}>
{app.translator.trans('core.forum.post_scrubber.unread_text', {count: unreadCount})}
</div>
</div>
<a className="Scrubber-last" onclick={this.goToLast.bind(this)}>
{icon('fas fa-angle-double-down')} {app.translator.trans('core.forum.post_scrubber.now_link')}
</a>
</div>
</div>
</div>
);
}
/**
* Go to the first post in the discussion.
*/
goToFirst() {
this.props.stream.goToFirst();
this.index = 0;
this.renderScrollbar(true);
}
/**
* Go to the last post in the discussion.
*/
goToLast() {
this.props.stream.goToLast();
this.index = this.props.stream.count();
this.renderScrollbar(true);
}
/**
* Get the number of posts in the discussion.
*
* @return {Integer}
*/
count() {
return this.props.stream.count();
}
/**
* When the stream is unpaused, update the scrubber to reflect its position.
*/
streamWasUnpaused() {
this.update(window.pageYOffset);
this.renderScrollbar(true);
}
/**
* Check whether or not the scrubber should be disabled, i.e. if all of the
* posts are visible in the viewport.
*
* @return {Boolean}
*/
disabled() {
return this.visible >= this.count();
}
/**
* When the page is scrolled, update the scrollbar to reflect the visible
* posts.
*
* @param {Integer} top
*/
onscroll(top) {
const stream = this.props.stream;
if (stream.paused || !stream.$()) return;
this.update(top);
this.renderScrollbar();
}
/**
* Update the index/visible/description properties according to the window's
* current scroll position.
*
* @param {Integer} scrollTop
*/
update(scrollTop) {
const stream = this.props.stream;
const marginTop = stream.getMarginTop();
const viewportTop = scrollTop + marginTop;
const viewportHeight = $(window).height() - marginTop;
const viewportBottom = viewportTop + viewportHeight;
// Before looping through all of the posts, we reset the scrollbar
// properties to a 'default' state. These values reflect what would be
// seen if the browser were scrolled right up to the top of the page,
// and the viewport had a height of 0.
const $items = stream.$('> .PostStream-item[data-index]');
let index = $items.first().data('index') || 0;
let visible = 0;
let period = '';
// Now loop through each of the items in the discussion. An 'item' is
// either a single post or a 'gap' of one or more posts that haven't
// been loaded yet.
$items.each(function() {
const $this = $(this);
const top = $this.offset().top;
const height = $this.outerHeight(true);
// If this item is above the top of the viewport, skip to the next
// one. If it's below the bottom of the viewport, break out of the
// loop.
if (top + height < viewportTop) {
return true;
}
if (top > viewportTop + viewportHeight) {
return false;
}
// Work out how many pixels of this item are visible inside the viewport.
// Then add the proportion of this item's total height to the index.
const visibleTop = Math.max(0, viewportTop - top);
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
const visiblePost = visibleBottom - visibleTop;
if (top <= viewportTop) {
index = parseFloat($this.data('index')) + visibleTop / height;
}
if (visiblePost > 0) {
visible += visiblePost / height;
}
// If this item has a time associated with it, then set the
// scrollbar's current period to a formatted version of this time.
const time = $this.data('time');
if (time) period = time;
});
this.index = index;
this.visible = visible;
this.description = period ? moment(period).format('MMMM YYYY') : '';
}
config(isInitialized, context) {
if (isInitialized) return;
context.onunload = this.ondestroy.bind(this);
this.scrollListener.start();
// Whenever the window is resized, adjust the height of the scrollbar
// so that it fills the height of the sidebar.
$(window).on('resize', this.handlers.onresize = this.onresize.bind(this)).resize();
// When any part of the whole scrollbar is clicked, we want to jump to
// that position.
this.$('.Scrubber-scrollbar')
.bind('click', this.onclick.bind(this))
// Now we want to make the scrollbar handle draggable. Let's start by
// preventing default browser events from messing things up.
.css({ cursor: 'pointer', 'user-select': 'none' })
.bind('dragstart mousedown touchstart', e => e.preventDefault());
// When the mouse is pressed on the scrollbar handle, we capture some
// information about its current position. We will store this
// information in an object and pass it on to the document's
// mousemove/mouseup events later.
this.dragging = false;
this.mouseStart = 0;
this.indexStart = 0;
this.$('.Scrubber-handle')
.css('cursor', 'move')
.bind('mousedown touchstart', this.onmousedown.bind(this))
// Exempt the scrollbar handle from the 'jump to' click event.
.click(e => e.stopPropagation());
// When the mouse moves and when it is released, we pass the
// information that we captured when the mouse was first pressed onto
// some event handlers. These handlers will move the scrollbar/stream-
// content as appropriate.
$(document)
.on('mousemove touchmove', this.handlers.onmousemove = this.onmousemove.bind(this))
.on('mouseup touchend', this.handlers.onmouseup = this.onmouseup.bind(this));
}
ondestroy() {
this.scrollListener.stop();
this.props.stream.off('unpaused', this.handlers.streamWasUnpaused);
$(window)
.off('resize', this.handlers.onresize);
$(document)
.off('mousemove touchmove', this.handlers.onmousemove)
.off('mouseup touchend', this.handlers.onmouseup);
}
/**
* Update the scrollbar's position to reflect the current values of the
* index/visible properties.
*
* @param {Boolean} animate
*/
renderScrollbar(animate) {
const percentPerPost = this.percentPerPost();
const index = this.index;
const count = this.count();
const visible = this.visible || 1;
const $scrubber = this.$();
$scrubber.find('.Scrubber-index').text(formatNumber(Math.ceil(index + visible)));
$scrubber.find('.Scrubber-description').text(this.description);
$scrubber.toggleClass('disabled', this.disabled());
const heights = {};
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
heights.handle = Math.min(100 - heights.before, percentPerPost.visible * visible);
heights.after = 100 - heights.before - heights.handle;
const func = animate ? 'animate' : 'css';
for (const part in heights) {
const $part = $scrubber.find(`.Scrubber-${part}`);
$part.stop(true, true)[func]({height: heights[part] + '%'}, 'fast');
// jQuery likes to put overflow:hidden, but because the scrollbar handle
// has a negative margin-left, we need to override.
if (func === 'animate') $part.css('overflow', 'visible');
}
}
/**
* Get the percentage of the height of the scrubber that should be allocated
* to each post.
*
* @return {Object}
* @property {Number} index The percent per post for posts on either side of
* the visible part of the scrubber.
* @property {Number} visible The percent per post for the visible part of the
* scrubber.
*/
percentPerPost() {
const count = this.count() || 1;
const visible = this.visible || 1;
// To stop the handle of the scrollbar from getting too small when there
// are many posts, we define a minimum percentage height for the handle
// calculated from a 50 pixel limit. From this, we can calculate the
// minimum percentage per visible post. If this is greater than the actual
// percentage per post, then we need to adjust the 'before' percentage to
// account for it.
const minPercentVisible = 50 / this.$('.Scrubber-scrollbar').outerHeight() * 100;
const percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible);
const percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible);
return {
index: percentPerPost,
visible: percentPerVisiblePost
};
}
onresize() {
this.scrollListener.update(true);
// Adjust the height of the scrollbar so that it fills the height of
// the sidebar and doesn't overlap the footer.
const scrubber = this.$();
const scrollbar = this.$('.Scrubber-scrollbar');
scrollbar.css('max-height', $(window).height() -
scrubber.offset().top + $(window).scrollTop() -
parseInt($('#app').css('padding-bottom'), 10) -
(scrubber.outerHeight() - scrollbar.outerHeight()));
}
onmousedown(e) {
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
this.indexStart = this.index;
this.dragging = true;
this.props.stream.paused = true;
$('body').css('cursor', 'move');
}
onmousemove(e) {
if (!this.dragging) return;
// Work out how much the mouse has moved by - first in pixels, then
// convert it to a percentage of the scrollbar's height, and then
// finally convert it into an index. Add this delta index onto
// the index at which the drag was started, and then scroll there.
const deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
const deltaPercent = deltaPixels / this.$('.Scrubber-scrollbar').outerHeight() * 100;
const deltaIndex = (deltaPercent / this.percentPerPost().index) || 0;
const newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1);
this.index = Math.max(0, newIndex);
this.renderScrollbar();
}
onmouseup() {
if (!this.dragging) return;
this.mouseStart = 0;
this.indexStart = 0;
this.dragging = false;
$('body').css('cursor', '');
this.$().removeClass('open');
// If the index we've landed on is in a gap, then tell the stream-
// content that we want to load those posts.
const intIndex = Math.floor(this.index);
this.props.stream.goToIndex(intIndex);
this.renderScrollbar(true);
}
onclick(e) {
// Calculate the index which we want to jump to based on the click position.
// 1. Get the offset of the click from the top of the scrollbar, as a
// percentage of the scrollbar's height.
const $scrollbar = this.$('.Scrubber-scrollbar');
const offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $scrollbar.offset().top + $('body').scrollTop();
let offsetPercent = offsetPixels / $scrollbar.outerHeight() * 100;
// 2. We want the handle of the scrollbar to end up centered on the click
// position. Thus, we calculate the height of the handle in percent and
// use that to find a new offset percentage.
offsetPercent = offsetPercent - parseFloat($scrollbar.find('.Scrubber-handle')[0].style.height) / 2;
// 3. Now we can convert the percentage into an index, and tell the stream-
// content component to jump to that index.
let offsetIndex = offsetPercent / this.percentPerPost().index;
offsetIndex = Math.max(0, Math.min(this.count() - 1, offsetIndex));
this.props.stream.goToIndex(Math.floor(offsetIndex));
this.index = offsetIndex;
this.renderScrollbar(true);
this.$().removeClass('open');
}
}

View File

@@ -0,0 +1,101 @@
import Component from '../../common/Component';
import UserCard from './UserCard';
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import userOnline from '../../common/helpers/userOnline';
import listItems from '../../common/helpers/listItems';
/**
* The `PostUser` component shows the avatar and username of a post's author.
*
* ### Props
*
* - `post`
*/
export default class PostUser extends Component {
init() {
/**
* Whether or not the user hover card is visible.
*
* @type {Boolean}
*/
this.cardVisible = false;
}
view() {
const post = this.props.post;
const user = post.user();
if (!user) {
return (
<div className="PostUser">
<h3>{avatar(user, {className: 'PostUser-avatar'})} {username(user)}</h3>
</div>
);
}
let card = '';
if (!post.isHidden() && this.cardVisible) {
card = UserCard.component({
user,
className: 'UserCard--popover',
controlsButtonClassName: 'Button Button--icon Button--flat'
});
}
return (
<div className="PostUser">
<h3>
<a href={app.route.user(user)} config={m.route}>
{avatar(user, {className: 'PostUser-avatar'})}
{userOnline(user)}
{username(user)}
</a>
</h3>
<ul className="PostUser-badges badges">
{listItems(user.badges().toArray())}
</ul>
{card}
</div>
);
}
config(isInitialized) {
if (isInitialized) return;
let timeout;
this.$()
.on('mouseover', 'h3 a, .UserCard', () => {
clearTimeout(timeout);
timeout = setTimeout(this.showCard.bind(this), 500);
})
.on('mouseout', 'h3 a, .UserCard', () => {
clearTimeout(timeout);
timeout = setTimeout(this.hideCard.bind(this), 250);
});
}
/**
* Show the user card.
*/
showCard() {
this.cardVisible = true;
m.redraw();
setTimeout(() => this.$('.UserCard').addClass('in'));
}
/**
* Hide the user card.
*/
hideCard() {
this.$('.UserCard').removeClass('in')
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
this.cardVisible = false;
m.redraw();
});
}
}

View File

@@ -0,0 +1,147 @@
import UserPage from './UserPage';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Button from '../../common/components/Button';
import CommentPost from './CommentPost';
/**
* The `PostsUserPage` component shows a user's activity feed inside of their
* profile.
*/
export default class PostsUserPage extends UserPage {
init() {
super.init();
/**
* Whether or not the activity feed is currently loading.
*
* @type {Boolean}
*/
this.loading = true;
/**
* Whether or not there are any more activity items that can be loaded.
*
* @type {Boolean}
*/
this.moreResults = false;
/**
* The Post models in the feed.
*
* @type {Post[]}
*/
this.posts = [];
/**
* The number of activity items to load per request.
*
* @type {Integer}
*/
this.loadLimit = 20;
this.loadUser(m.route.param('username'));
}
content() {
let footer;
if (this.loading) {
footer = LoadingIndicator.component();
} 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)
})}
</div>
);
}
return (
<div className="PostsUserPage">
<ul className="PostsUserPage-list">
{this.posts.map(post => (
<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>})}
</div>
{CommentPost.component({post})}
</li>
))}
</ul>
{footer}
</div>
);
}
/**
* Initialize the component with a user, and trigger the loading of their
* activity feed.
*/
show(user) {
super.show(user);
this.refresh();
}
/**
* Clear and reload the user's activity feed.
*
* @public
*/
refresh() {
this.loading = true;
this.posts = [];
m.lazyRedraw();
this.loadResults().then(this.parseResults.bind(this));
}
/**
* Load a new page of the user's activity feed.
*
* @param {Integer} [offset] The position to start getting results from.
* @return {Promise}
* @protected
*/
loadResults(offset) {
return app.store.find('posts', {
filter: {
user: this.user.id(),
type: 'comment'
},
page: {offset, limit: this.loadLimit},
sort: '-time'
});
}
/**
* Load the next page of results.
*
* @public
*/
loadMore() {
this.loading = true;
this.loadResults(this.posts.length).then(this.parseResults.bind(this));
}
/**
* Parse results and append them to the activity feed.
*
* @param {Post[]} results
* @return {Post[]}
*/
parseResults(results) {
this.loading = false;
[].push.apply(this.posts, results);
this.moreResults = results.length >= this.loadLimit;
m.redraw();
return results;
}
}

View File

@@ -0,0 +1,70 @@
import Modal from '../../common/components/Modal';
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();
this.discussion = this.props.discussion;
this.currentTitle = this.props.currentTitle;
this.newTitle = m.prop(this.currentTitle);
}
className() {
return 'RenameDiscussionModal Modal--small';
}
title() {
return app.translator.trans('core.forum.rename_discussion.title');
}
content() {
return (
<div className="Modal-body">
<div className="Form Form--centered">
<div className="Form-group">
<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')
})}
</div>
</div>
</div>
)
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
const title = this.newTitle;
const currentTitle = this.currentTitle;
// If the title is different to what it was before, then save it. After the
// save has completed, update the post stream as there will be a new post
// indicating that the discussion was renamed.
if (title && title !== currentTitle) {
return this.discussion.save({title}).then(() => {
if (app.viewingDiscussion(this.discussion)) {
app.current.stream.update();
}
m.redraw();
this.hide();
}).catch(() => {
this.loading = false;
m.redraw();
});
} else {
this.hide();
}
}
}

View File

@@ -0,0 +1,116 @@
import ComposerBody from './ComposerBody';
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
import icon from '../../common/helpers/icon';
import extractText from '../../common/utils/extractText';
function minimizeComposerIfFullScreen(e) {
if (app.composer.isFullScreen()) {
app.composer.minimize();
e.stopPropagation();
}
}
/**
* The `ReplyComposer` component displays the composer content for replying to a
* discussion.
*
* ### Props
*
* - All of the props of ComposerBody
* - `discussion`
*/
export default class ReplyComposer extends ComposerBody {
init() {
super.init();
this.editor.props.preview = e => {
minimizeComposerIfFullScreen(e);
m.route(app.route.discussion(this.props.discussion, 'reply'));
};
}
static initProps(props) {
super.initProps(props);
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'));
}
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);
};
items.add('title', (
<h3>
{icon('fas fa-reply')} {' '}
<a href={app.route.discussion(discussion)} config={routeAndMinimize}>{discussion.title()}</a>
</h3>
));
return items;
}
/**
* Get the data to submit to the server when the reply is saved.
*
* @return {Object}
*/
data() {
return {
content: this.content(),
relationships: {discussion: this.props.discussion}
};
}
onsubmit() {
const discussion = this.props.discussion;
this.loading = true;
m.redraw();
const data = this.data();
app.store.createRecord('posts').save(data).then(
post => {
// If we're currently viewing the discussion which this reply was made
// in, then we can update the post stream and scroll to the post.
if (app.viewingDiscussion(discussion)) {
app.current.stream.update().then(() => app.current.stream.goToNumber(post.number()));
} else {
// Otherwise, we'll create an alert message to inform the user that
// 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);
}
});
app.alerts.show(
alert = new Alert({
type: 'success',
message: app.translator.trans('core.forum.composer_reply.posted_message'),
controls: [viewButton]
})
);
}
app.composer.hide();
},
this.loaded.bind(this)
);
}
}

View File

@@ -0,0 +1,72 @@
/*global s9e*/
import Component from '../../common/Component';
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import DiscussionControls from '../utils/DiscussionControls';
/**
* The `ReplyPlaceholder` component displays a placeholder for a reply, which,
* when clicked, opens the reply composer.
*
* ### Props
*
* - `discussion`
*/
export default class ReplyPlaceholder extends Component {
view() {
if (app.composingReplyTo(this.props.discussion)) {
return (
<article className="Post CommentPost editing">
<header className="Post-header">
<div className="PostUser">
<h3>
{avatar(app.session.user, {className: 'PostUser-avatar'})}
{username(app.session.user)}
</h3>
</div>
</header>
<div className="Post-body" config={this.configPreview.bind(this)}/>
</article>
);
}
const reply = () => {
DiscussionControls.replyAction.call(this.props.discussion, true);
};
return (
<article className="Post ReplyPlaceholder" onclick={reply}>
<header className="Post-header">
{avatar(app.session.user, {className: 'PostUser-avatar'})}{' '}
{app.translator.trans('core.forum.post_stream.reply_placeholder')}
</header>
</article>
);
}
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 updateInterval = setInterval(() => {
const content = app.composer.component.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);
}
}

View File

@@ -0,0 +1,298 @@
import Component from '../../common/Component';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import ItemList from '../../common/utils/ItemList';
import classList from '../../common/utils/classList';
import extractText from '../../common/utils/extractText';
import KeyboardNavigatable from '../utils/KeyboardNavigatable';
import icon from '../../common/helpers/icon';
import DiscussionsSearchSource from './DiscussionsSearchSource';
import UsersSearchSource from './UsersSearchSource';
/**
* The `Search` component displays a menu of as-you-type results from a variety
* of sources.
*
* The search box will be 'activated' if the app's current controller implements
* a `searching` method that returns a truthy value. If this is the case, an 'x'
* button will be shown next to the search field, and clicking it will call the
* `clearSearch` method on the controller.
*/
export default class Search extends Component {
init() {
/**
* The value of the search input.
*
* @type {Function}
*/
this.value = m.prop('');
/**
* Whether or not the search input has focus.
*
* @type {Boolean}
*/
this.hasFocus = false;
/**
* An array of SearchSources.
*
* @type {SearchSource[]}
*/
this.sources = this.sourceItems().toArray();
/**
* The number of sources that are still loading results.
*
* @type {Integer}
*/
this.loadingSources = 0;
/**
* A list of queries that have been searched for.
*
* @type {Array}
*/
this.searched = [];
/**
* The index of the currently-selected <li> in the results list. This can be
* a unique string (to account for the fact that an item's position may jump
* around as new results load), but otherwise it will be numeric (the
* sequential position within the list).
*
* @type {String|Integer}
*/
this.index = 0;
}
view() {
const currentSearch = this.getCurrentSearch();
// Initialize search input value in the view rather than the constructor so
// that we have access to app.current.
if (typeof this.value() === 'undefined') {
this.value(currentSearch || '');
}
return (
<div className={'Search ' + classList({
open: this.value() && this.hasFocus,
focused: this.hasFocus,
active: !!currentSearch,
loading: !!this.loadingSources
})}>
<div className="Search-input">
<input className="FormControl"
type="search"
placeholder={extractText(app.translator.trans('core.forum.header.search_placeholder'))}
value={this.value()}
oninput={m.withAttr('value', this.value)}
onfocus={() => this.hasFocus = true}
onblur={() => this.hasFocus = false}/>
{this.loadingSources
? LoadingIndicator.component({size: 'tiny', className: 'Button Button--icon Button--link'})
: currentSearch
? <button className="Search-clear Button Button--icon Button--link" onclick={this.clear.bind(this)}>{icon('fas fa-times-circle')}</button>
: ''}
</div>
<ul className="Dropdown-menu Search-results">
{this.value() && this.hasFocus
? this.sources.map(source => source.view(this.value()))
: ''}
</ul>
</div>
);
}
config(isInitialized) {
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
if (isInitialized) return;
const search = this;
this.$('.Search-results')
.on('mousedown', e => e.preventDefault())
.on('click', () => this.$('input').blur())
// Whenever the mouse is hovered over a search result, highlight it.
.on('mouseenter', '> li:not(.Dropdown-header)', function() {
search.setIndex(
search.selectableItems().index(this)
);
});
const $input = this.$('input');
this.navigator = new KeyboardNavigatable();
this.navigator
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
.onSelect(this.selectResult.bind(this))
.onCancel(this.clear.bind(this))
.bindTo($input);
// Handle input key events on the search input, triggering results to load.
$input
.on('input focus', function() {
const query = this.value.toLowerCase();
if (!query) return;
clearTimeout(search.searchTimeout);
search.searchTimeout = setTimeout(() => {
if (search.searched.indexOf(query) !== -1) return;
if (query.length >= 3) {
search.sources.map(source => {
if (!source.search) return;
search.loadingSources++;
source.search(query).then(() => {
search.loadingSources--;
m.redraw();
});
});
}
search.searched.push(query);
m.redraw();
}, 250);
})
.on('focus', function() {
$(this).one('mouseup', e => e.preventDefault()).select();
});
}
/**
* Get the active search in the app's current controller.
*
* @return {String}
*/
getCurrentSearch() {
return app.current && typeof app.current.searching === 'function' && app.current.searching();
}
/**
* Navigate to the currently selected search result and close the list.
*/
selectResult() {
if (this.value()) {
m.route(this.getItem(this.index).find('a').attr('href'));
} else {
this.clear();
}
this.$('input').blur();
}
/**
* Clear the search input and the current controller's active search.
*/
clear() {
this.value('');
if (this.getCurrentSearch()) {
app.current.clearSearch();
} else {
m.redraw();
}
}
/**
* Build an item list of SearchSources.
*
* @return {ItemList}
*/
sourceItems() {
const items = new ItemList();
items.add('discussions', new DiscussionsSearchSource());
items.add('users', new UsersSearchSource());
return items;
}
/**
* Get all of the search result items that are selectable.
*
* @return {jQuery}
*/
selectableItems() {
return this.$('.Search-results > li:not(.Dropdown-header)');
}
/**
* Get the position of the currently selected search result item.
*
* @return {Integer}
*/
getCurrentNumericIndex() {
return this.selectableItems().index(
this.getItem(this.index)
);
}
/**
* Get the <li> in the search results with the given index (numeric or named).
*
* @param {String} index
* @return {DOMElement}
*/
getItem(index) {
const $items = this.selectableItems();
let $item = $items.filter(`[data-index="${index}"]`);
if (!$item.length) {
$item = $items.eq(index);
}
return $item;
}
/**
* Set the currently-selected search result item to the one with the given
* index.
*
* @param {Integer} index
* @param {Boolean} scrollToItem Whether or not to scroll the dropdown so that
* the item is in view.
*/
setIndex(index, scrollToItem) {
const $items = this.selectableItems();
const $dropdown = $items.parent();
let fixedIndex = index;
if (index < 0) {
fixedIndex = $items.length - 1;
} else if (index >= $items.length) {
fixedIndex = 0;
}
const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');
this.index = $item.attr('data-index') || fixedIndex;
if (scrollToItem) {
const dropdownScroll = $dropdown.scrollTop();
const dropdownTop = $dropdown.offset().top;
const dropdownBottom = dropdownTop + $dropdown.outerHeight();
const itemTop = $item.offset().top;
const itemBottom = itemTop + $item.outerHeight();
let scrollTop;
if (itemTop < dropdownTop) {
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
} else if (itemBottom > dropdownBottom) {
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
}
if (typeof scrollTop !== 'undefined') {
$dropdown.stop(true).animate({scrollTop}, 100);
}
}
}
}

View File

@@ -0,0 +1,32 @@
/**
* The `SearchSource` interface defines a section of search results in the
* search dropdown.
*
* Search sources should be registered with the `Search` component instance
* (app.search) by extending the `sourceItems` method. When the user types a
* query, each search source will be prompted to load search results via the
* `search` method. When the dropdown is redrawn, it will be constructed by
* putting together the output from the `view` method of each source.
*
* @interface
*/
export default class SearchSource {
/**
* Make a request to get results for the given query.
*
* @param {String} query
* @return {Promise}
*/
search() {
}
/**
* Get an array of virtual <li>s that list the search results for the given
* query.
*
* @param {String} query
* @return {Object}
*/
view() {
}
}

View File

@@ -0,0 +1,91 @@
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import Dropdown from '../../common/components/Dropdown';
import LinkButton from '../../common/components/LinkButton';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
import Separator from '../../common/components/Separator';
import Group from '../../common/models/Group';
/**
* The `SessionDropdown` component shows a button with the current user's
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
props.className = 'SessionDropdown';
props.buttonClassName = 'Button Button--user Button--flat';
props.menuClassName = 'Dropdown-menu--right';
}
view() {
this.props.children = this.items().toArray();
return super.view();
}
getButtonContent() {
const user = app.session.user;
return [
avatar(user), ' ',
<span className="Button-label">{username(user)}</span>
];
}
/**
* Build an item list for the contents of the dropdown menu.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
const user = app.session.user;
items.add('profile',
LinkButton.component({
icon: 'fas fa-user',
children: app.translator.trans('core.forum.header.profile_button'),
href: app.route.user(user)
}),
100
);
items.add('settings',
LinkButton.component({
icon: 'fas fa-cog',
children: app.translator.trans('core.forum.header.settings_button'),
href: app.route('settings')
}),
50
);
if (app.forum.attribute('adminUrl')) {
items.add('administration',
LinkButton.component({
icon: 'fas fa-wrench',
children: app.translator.trans('core.forum.header.admin_button'),
href: app.forum.attribute('adminUrl'),
target: '_blank',
config: () => {}
}),
0
);
}
items.add('separator', Separator.component(), -90);
items.add('logOut',
Button.component({
icon: 'fas fa-sign-out-alt',
children: app.translator.trans('core.forum.header.log_out_button'),
onclick: app.session.logout.bind(app.session)
}),
-100
);
return items;
}
}

View File

@@ -0,0 +1,145 @@
import UserPage from './UserPage';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import Button from '../../common/components/Button';
import FieldSet from '../../common/components/FieldSet';
import NotificationGrid from './NotificationGrid';
import ChangePasswordModal from './ChangePasswordModal';
import ChangeEmailModal from './ChangeEmailModal';
import listItems from '../../common/helpers/listItems';
/**
* The `SettingsPage` component displays the user's settings control panel, in
* the context of their user profile.
*/
export default class SettingsPage extends UserPage {
init() {
super.init();
this.show(app.session.user);
app.setTitle(app.translator.trans('core.forum.settings.title'));
}
content() {
return (
<div className="SettingsPage">
<ul>{listItems(this.settingsItems().toArray())}</ul>
</div>
);
}
/**
* Build an item list for the user's settings controls.
*
* @return {ItemList}
*/
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()
})
);
return items;
}
/**
* Build an item list for the user's account settings.
*
* @return {ItemList}
*/
accountItems() {
const items = new ItemList();
items.add('changePassword',
Button.component({
children: app.translator.trans('core.forum.settings.change_password_button'),
className: 'Button',
onclick: () => app.modal.show(new ChangePasswordModal())
})
);
items.add('changeEmail',
Button.component({
children: app.translator.trans('core.forum.settings.change_email_button'),
className: 'Button',
onclick: () => app.modal.show(new ChangeEmailModal())
})
);
return items;
}
/**
* Build an item list for the user's notification settings.
*
* @return {ItemList}
*/
notificationsItems() {
const items = new ItemList();
items.add('notificationGrid', NotificationGrid.component({user: this.user}));
return items;
}
/**
* 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.loading = true;
m.redraw();
this.user.savePreferences({[key]: value}).then(() => {
if (component) component.loading = false;
m.redraw();
});
};
}
/**
* Build an item list for the user's privacy settings.
*
* @return {ItemList}
*/
privacyItems() {
const items = new ItemList();
items.add('discloseOnline',
Switch.component({
children: app.translator.trans('core.forum.settings.privacy_disclose_online_label'),
state: this.user.preferences().discloseOnline,
onchange: (value, component) => {
this.user.pushAttributes({lastSeenTime: null});
this.preferenceSaver('discloseOnline')(value, component);
}
})
);
return items;
}
}

View File

@@ -0,0 +1,188 @@
import Modal from '../../common/components/Modal';
import LogInModal from './LogInModal';
import Button from '../../common/components/Button';
import LogInButtons from './LogInButtons';
import extractText from '../../common/utils/extractText';
import ItemList from '../../common/utils/ItemList';
/**
* The `SignUpModal` component displays a modal dialog with a singup form.
*
* ### Props
*
* - `username`
* - `email`
* - `password`
* - `token` An email token to sign up with.
*/
export default class SignUpModal extends Modal {
init() {
super.init();
/**
* The value of the username input.
*
* @type {Function}
*/
this.username = m.prop(this.props.username || '');
/**
* The value of the email input.
*
* @type {Function}
*/
this.email = m.prop(this.props.email || '');
/**
* The value of the password input.
*
* @type {Function}
*/
this.password = m.prop(this.props.password || '');
}
className() {
return 'Modal--small SignUpModal' + (this.welcomeUser ? ' SignUpModal--success' : '');
}
title() {
return app.translator.trans('core.forum.sign_up.title');
}
content() {
return [
<div className="Modal-body">
{this.body()}
</div>,
<div className="Modal-footer">
{this.footer()}
</div>
];
}
isProvided(field) {
return this.props.identificationFields && this.props.identificationFields.indexOf(field) !== -1;
}
body() {
return [
this.props.token ? '' : <LogInButtons/>,
<div className="Form Form--centered">
{this.fields().toArray()}
</div>
];
}
fields() {
const items = new ItemList();
items.add('username', <div className="Form-group">
<input className="FormControl" name="username" type="text" placeholder={extractText(app.translator.trans('core.forum.sign_up.username_placeholder'))}
value={this.username()}
onchange={m.withAttr('value', this.username)}
disabled={this.loading || this.isProvided('username')} />
</div>, 30);
items.add('email', <div className="Form-group">
<input className="FormControl" name="email" type="email" placeholder={extractText(app.translator.trans('core.forum.sign_up.email_placeholder'))}
value={this.email()}
onchange={m.withAttr('value', this.email)}
disabled={this.loading || this.isProvided('email')} />
</div>, 20);
if (!this.props.token) {
items.add('password', <div className="Form-group">
<input className="FormControl" name="password" type="password" placeholder={extractText(app.translator.trans('core.forum.sign_up.password_placeholder'))}
value={this.password()}
onchange={m.withAttr('value', this.password)}
disabled={this.loading} />
</div>, 10);
}
items.add('submit', <div className="Form-group">
<Button
className="Button Button--primary Button--block"
type="submit"
loading={this.loading}>
{app.translator.trans('core.forum.sign_up.submit_button')}
</Button>
</div>, -10);
return items;
}
footer() {
return [
<p className="SignUpModal-logIn">
{app.translator.trans('core.forum.sign_up.log_in_text', {a: <a onclick={this.logIn.bind(this)}/>})}
</p>
];
}
/**
* Open the log in modal, prefilling it with an email/username/password if
* the user has entered one.
*
* @public
*/
logIn() {
const props = {
identification: this.email() || this.username(),
password: this.password()
};
app.modal.show(new LogInModal(props));
}
onready() {
if (this.props.username && !this.props.email) {
this.$('[name=email]').select();
} else {
this.$('[name=username]').select();
}
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
const data = this.submitData();
app.request({
url: app.forum.attribute('baseUrl') + '/register',
method: 'POST',
data,
errorHandler: this.onerror.bind(this)
}).then(
() => window.location.reload(),
this.loaded.bind(this)
);
}
/**
* Get the data that should be submitted in the sign-up request.
*
* @return {Object}
* @public
*/
submitData() {
const data = {
username: this.username(),
email: this.email()
};
if (this.props.token) {
data.token = this.props.token;
} else {
data.password = this.password();
}
if (this.props.avatarUrl) {
data.avatarUrl = this.props.avatarUrl;
}
return data;
}
}

View File

@@ -0,0 +1,31 @@
import Component from '../../common/Component';
import humanTime from '../../common/helpers/humanTime';
import icon from '../../common/helpers/icon';
/**
* Displays information about a the first or last post in a discussion.
*
* ### Props
*
* - `discussion`
* - `lastPost`
*/
export default class TerminalPost extends Component {
view() {
const discussion = this.props.discussion;
const lastPost = this.props.lastPost && discussion.repliesCount();
const user = discussion[lastPost ? 'lastUser' : 'startUser']();
const time = discussion[lastPost ? 'lastTime' : 'startTime']();
return (
<span>
{lastPost ? icon('fas fa-reply') : ''}{' '}
{app.translator.trans('core.forum.discussion_list.' + (lastPost ? 'replied' : 'started') + '_text', {
user,
ago: humanTime(time)
})}
</span>
);
}
}

View File

@@ -0,0 +1,165 @@
import Component from '../../common/Component';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
/**
* The `TextEditor` component displays a textarea with controls, including a
* submit button.
*
* ### Props
*
* - `submitLabel`
* - `value`
* - `placeholder`
* - `disabled`
*/
export default class TextEditor extends Component {
init() {
/**
* The value of the textarea.
*
* @type {String}
*/
this.value = m.prop(this.props.value || '');
}
view() {
return (
<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}
value={this.value()}/>
<ul className="TextEditor-controls Composer-footer">
{listItems(this.controlItems().toArray())}
</ul>
</div>
);
}
/**
* Configure the textarea element.
*
* @param {DOMElement} element
* @param {Boolean} isInitialized
*/
configTextarea(element, isInitialized) {
if (isInitialized) return;
const handler = () => {
this.onsubmit();
m.redraw();
};
$(element).bind('keydown', 'meta+return', handler);
$(element).bind('keydown', 'ctrl+return', handler);
}
/**
* Build an item list for the text editor controls.
*
* @return {ItemList}
*/
controlItems() {
const items = new ItemList();
items.add('submit',
Button.component({
children: this.props.submitLabel,
icon: 'fas fa-check',
className: 'Button Button--primary',
itemClassName: 'App-primaryControl',
onclick: this.onsubmit.bind(this)
})
);
if (this.props.preview) {
items.add('preview',
Button.component({
icon: 'fas fa-eye',
className: 'Button Button--icon',
onclick: this.props.preview,
title: app.translator.trans('core.forum.composer.preview_tooltip')
})
);
}
return items;
}
/**
* Set the value of the text editor.
*
* @param {String} value
*/
setValue(value) {
this.$('textarea').val(value).trigger('input');
}
/**
* Set the selected range of the textarea.
*
* @param {Integer} start
* @param {Integer} end
*/
setSelectionRange(start, end) {
const $textarea = this.$('textarea');
$textarea[0].setSelectionRange(start, end);
$textarea.focus();
}
/**
* Get the selected range of the textarea.
*
* @return {Array}
*/
getSelectionRange() {
const $textarea = this.$('textarea');
return [$textarea[0].selectionStart, $textarea[0].selectionEnd];
}
/**
* Insert content into the textarea at the position of the cursor.
*
* @param {String} insert
*/
insertAtCursor(insert) {
const textarea = this.$('textarea')[0];
const value = this.value();
const index = textarea ? textarea.selectionStart : value.length;
this.setValue(value.slice(0, index) + insert + value.slice(index));
// Move the textarea cursor to the end of the content we just inserted.
if (textarea) {
const pos = index + insert.length;
this.setSelectionRange(pos, pos);
}
}
/**
* Handle input into the textarea.
*
* @param {String} value
*/
oninput(value) {
this.value(value);
this.props.onchange(this.value());
m.redraw.strategy('none');
}
/**
* Handle the submit button being clicked.
*/
onsubmit() {
this.props.onsubmit(this.value());
}
}

View File

@@ -0,0 +1,100 @@
import Component from '../../common/Component';
import humanTime from '../../common/utils/humanTime';
import ItemList from '../../common/utils/ItemList';
import UserControls from '../utils/UserControls';
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import icon from '../../common/helpers/icon';
import Dropdown from '../../common/components/Dropdown';
import AvatarEditor from './AvatarEditor';
import listItems from '../../common/helpers/listItems';
/**
* The `UserCard` component displays a user's profile card. This is used both on
* the `UserPage` (in the hero) and in discussions, shown when hovering over a
* post author.
*
* ### Props
*
* - `user`
* - `className`
* - `editable`
* - `controlsButtonClassName`
*/
export default class UserCard extends Component {
view() {
const user = this.props.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="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'
}) : ''}
<div className="UserCard-profile">
<h2 className="UserCard-identity">
{this.props.editable
? [AvatarEditor.component({user, className: 'UserCard-avatar'}), username(user)]
: (
<a href={app.route.user(user)} config={m.route}>
<div className="UserCard-avatar">{avatar(user)}</div>
{username(user)}
</a>
)}
</h2>
{badges.length ? (
<ul className="UserCard-badges badges">
{listItems(badges)}
</ul>
) : ''}
<ul className="UserCard-info">
{listItems(this.infoItems().toArray())}
</ul>
</div>
</div>
</div>
</div>
);
}
/**
* Build an item list of tidbits of info to show on this user's profile.
*
* @return {ItemList}
*/
infoItems() {
const items = new ItemList();
const user = this.props.user;
const lastSeenTime = user.lastSeenTime();
if (lastSeenTime) {
const online = user.isOnline();
items.add('lastSeen', (
<span className={'UserCard-lastSeen' + (online ? ' online' : '')}>
{online
? [icon('fas fa-circle'), ' ', app.translator.trans('core.forum.user.online_text')]
: [icon('far fa-clock'), ' ', humanTime(lastSeenTime)]}
</span>
));
}
items.add('joined', app.translator.trans('core.forum.user.joined_date_text', {ago: humanTime(user.joinTime())}));
return items;
}
}

View File

@@ -0,0 +1,161 @@
import Page from './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';
/**
* The `UserPage` component shows a user's profile. It can be extended to show
* content inside of the content area. See `ActivityPage` and `SettingsPage` for
* examples.
*
* @abstract
*/
export default class UserPage extends Page {
init() {
super.init();
/**
* The user this page is for.
*
* @type {User}
*/
this.user = null;
this.bodyClass = 'App--user';
}
view() {
return (
<div className="UserPage">
{this.user ? [
UserCard.component({
user: this.user,
className: 'Hero UserHero',
editable: this.user.canEdit() || this.user === app.session.user,
controlsButtonClassName: 'Button'
}),
<div className="container">
<nav className="sideNav UserPage-nav" config={affixSidebar}>
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
<div className="sideNavOffset UserPage-content">
{this.content()}
</div>
</div>
] : [
LoadingIndicator.component({className: 'LoadingIndicator--block'})
]}
</div>
);
}
/**
* Get the content to display in the user page.
*
* @return {VirtualElement}
*/
content() {
}
/**
* Initialize the component with a user, and trigger the loading of their
* activity feed.
*
* @param {User} user
* @protected
*/
show(user) {
this.user = user;
app.setTitle(user.displayName());
m.redraw();
}
/**
* Given a username, load the user's profile from the store, or make a request
* if we don't have it yet. Then initialize the profile page with that user.
*
* @param {String} username
*/
loadUser(username) {
const lowercaseUsername = username.toLowerCase();
app.store.all('users').some(user => {
if (user.username().toLowerCase() === lowercaseUsername && user.joinTime()) {
this.show(user);
return true;
}
});
if (!this.user) {
app.store.find('users', username).then(this.show.bind(this));
}
}
/**
* Build an item list for the content of the sidebar.
*
* @return {ItemList}
*/
sidebarItems() {
const items = new ItemList();
items.add('nav',
SelectDropdown.component({
children: this.navItems().toArray(),
className: 'App-titleControl',
buttonClassName: 'Button'
})
);
return items;
}
/**
* Build an item list for the navigation in the sidebar.
*
* @return {ItemList}
*/
navItems() {
const items = new ItemList();
const user = this.user;
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.commentsCount()}</span>],
icon: 'far fa-comment'
}),
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.discussionsCount()}</span>],
icon: 'fas fa-bars'
}),
90
);
if (app.session.user === user) {
items.add('separator', Separator.component(), -90);
items.add('settings',
LinkButton.component({
href: app.route('settings'),
children: app.translator.trans('core.forum.user.settings_link'),
icon: 'fas fa-cog'
}),
-100
);
}
return items;
}
}

View File

@@ -0,0 +1,53 @@
import highlight from '../../common/helpers/highlight';
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
/**
* The `UsersSearchSource` finds and displays user search results in the search
* dropdown.
*
* @implements SearchSource
*/
export default class UsersSearchResults {
constructor() {
this.results = {};
}
search(query) {
return app.store.find('users', {
filter: {q: query},
page: {limit: 5}
}).then(results => {
this.results[query] = results;
m.redraw();
});
}
view(query) {
query = query.toLowerCase();
const results = (this.results[query] || [])
.concat(app.store.all('users').filter(user => [user.username(), user.displayName()].some(value => value.toLowerCase().substr(0, query.length) === query)))
.filter((e, i, arr) => arr.lastIndexOf(e) === i)
.sort((a, b) => a.displayName().localeCompare(b.displayName()));
if (!results.length) return '';
return [
<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);
return (
<li className="UserSearchResult" data-index={'users' + user.id()}>
<a href={app.route.user(user)} config={m.route}>
{avatar(user)}
{name}
</a>
</li>
);
})
];
}
}

View File

@@ -0,0 +1,46 @@
import Component from '../../common/Component';
import Button from '../../common/components/Button';
/**
* The `WelcomeHero` component displays a hero that welcomes the user to the
* forum.
*/
export default class WelcomeHero extends Component {
init() {
this.hidden = localStorage.getItem('welcomeHidden');
}
view() {
if (this.hidden) return <div/>;
const slideUp = () => {
this.$().slideUp(this.hide.bind(this));
};
return (
<header className="Hero WelcomeHero">
<div class="container">
{Button.component({
icon: 'fas fa-times',
onclick: slideUp,
className: 'Hero-close Button Button--icon Button--link'
})}
<div className="containerNarrow">
<h2 className="Hero-title">{app.forum.attribute('welcomeTitle')}</h2>
<div className="Hero-subtitle">{m.trust(app.forum.attribute('welcomeMessage'))}</div>
</div>
</div>
</header>
);
}
/**
* Hide the welcome hero.
*/
hide() {
localStorage.setItem('welcomeHidden', 'true');
this.hidden = true;
}
}