+ );
+ }
+
+ 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;
+ }
+
+ this.loading = true;
+
+ app.session.user.save({email: this.email()}).then(
+ () => {
+ this.loading = false;
+ this.success = true;
+ m.redraw();
+ },
+ () => {
+ this.loading = false;
+ }
+ );
+ }
+}
diff --git a/js/forum/src/components/ChangePasswordModal.js b/js/forum/src/components/ChangePasswordModal.js
new file mode 100644
index 000000000..d143065a6
--- /dev/null
+++ b/js/forum/src/components/ChangePasswordModal.js
@@ -0,0 +1,43 @@
+import Modal from 'flarum/components/Modal';
+
+/**
+ * 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 'modal-sm change-password-modal';
+ }
+
+ title() {
+ return 'Change Password';
+ }
+
+ content() {
+ return (
+
+
+
Click the button below and check your email for a link to change your password.
+
+
+
+
+
+ );
+ }
+
+ 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(),
+ () => this.loading = false
+ );
+ }
+}
diff --git a/js/forum/src/components/CommentPost.js b/js/forum/src/components/CommentPost.js
new file mode 100644
index 000000000..50efa2904
--- /dev/null
+++ b/js/forum/src/components/CommentPost.js
@@ -0,0 +1,120 @@
+import Post from 'flarum/components/Post';
+import classList from 'flarum/utils/classList';
+import PostUser from 'flarum/components/PostUser';
+import PostMeta from 'flarum/components/PostMeta';
+import PostEdited from 'flarum/components/PostEdited';
+import EditPostComposer from 'flarum/components/EditPostComposer';
+import Composer from 'flarum/components/Composer';
+import ItemList from 'flarum/utils/ItemList';
+import listItems from 'flarum/helpers/listItems';
+import icon from 'flarum/helpers/icon';
+
+/**
+ * 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 {
+ constructor(...args) {
+ super(...args);
+
+ /**
+ * 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);
+ }
+
+ content() {
+ return [
+
{listItems(this.headerItems().toArray())}
,
+
{m.trust(this.props.post.contentHtml())}
,
+ ,
+
+ ];
+ }
+
+ attrs() {
+ const post = this.props.post;
+
+ return {
+ className: classList({
+ 'comment-post': true,
+ 'is-hidden': post.isHidden(),
+ 'is-edited': post.isEdited(),
+ 'reveal-content': this.revealContent,
+ 'editing': app.composer.component instanceof EditPostComposer &&
+ app.composer.component.props.post === post &&
+ app.composer.position !== Composer.PositionEnum.MINIMIZED
+ })
+ };
+ }
+
+ /**
+ * 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', (
+
+ ));
+ }
+
+ return items;
+ }
+
+ /**
+ * Build an item list for the post's footer.
+ *
+ * @return {ItemList}
+ */
+ footerItems() {
+ return new ItemList();
+ }
+
+ /**
+ * Build an item list for the post's actions.
+ *
+ * @return {ItemList}
+ */
+ actionItems() {
+ return new ItemList();
+ }
+}
diff --git a/js/forum/src/components/ComposerBody.js b/js/forum/src/components/ComposerBody.js
new file mode 100644
index 000000000..c4c080e7f
--- /dev/null
+++ b/js/forum/src/components/ComposerBody.js
@@ -0,0 +1,107 @@
+import Component from 'flarum/Component';
+import LoadingIndicator from 'flarum/components/LoadingIndicator';
+import TextEditor from 'flarum/components/TextEditor';
+import avatar from 'flarum/helpers/avatar';
+import listItems from 'flarum/helpers/listItems';
+import ItemList from 'flarum/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 {
+ constructor(props) {
+ super(props);
+
+ /**
+ * 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 (
+
+ );
+ }
+
+ /**
+ * 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() {
+ }
+}
diff --git a/js/forum/src/components/ComposerButton.js b/js/forum/src/components/ComposerButton.js
new file mode 100644
index 000000000..dd7d1cc88
--- /dev/null
+++ b/js/forum/src/components/ComposerButton.js
@@ -0,0 +1,13 @@
+import Button from 'flarum/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 || 'btn btn-icon btn-link';
+ }
+}
diff --git a/js/forum/src/components/DeleteAccountModal.js b/js/forum/src/components/DeleteAccountModal.js
new file mode 100644
index 000000000..308b80285
--- /dev/null
+++ b/js/forum/src/components/DeleteAccountModal.js
@@ -0,0 +1,67 @@
+import Modal from 'flarum/components/Modal';
+
+/**
+ * The `DeleteAccountModal` component shows a modal dialog which allows the user
+ * to delete their account.
+ *
+ * @todo require typing password instead of DELETE
+ */
+export default class DeleteAccountModal extends Modal {
+ constructor(props) {
+ super(props);
+
+ /**
+ * The value of the confirmation input.
+ *
+ * @type {Function}
+ */
+ this.confirmation = m.prop();
+ }
+
+ className() {
+ return 'modal-sm delete-account-modal';
+ }
+
+ title() {
+ return 'Delete Account';
+ }
+
+ content() {
+ return (
+
+
+
+
Hold up! If you delete your account, there's no going back. Keep in mind:
+
+
Your username will be released, so someone else will be able to sign up with your name.
+
All of your posts will remain, but no longer associated with your account.
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ onsubmit(e) {
+ e.preventDefault();
+
+ if (this.confirmation() !== 'DELETE') return;
+
+ this.loading = true;
+
+ app.session.user.delete().then(() => app.session.logout());
+ }
+}
diff --git a/js/forum/src/components/DiscussionComposer.js b/js/forum/src/components/DiscussionComposer.js
new file mode 100644
index 000000000..6d3fcf9d0
--- /dev/null
+++ b/js/forum/src/components/DiscussionComposer.js
@@ -0,0 +1,114 @@
+import ComposerBody from 'flarum/components/ComposerBody';
+
+/**
+ * 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 {
+ constructor(...args) {
+ super(...args);
+
+ /**
+ * The value of the title input.
+ *
+ * @type {Function}
+ */
+ this.title = m.prop('');
+ }
+
+ static initProps(props) {
+ super.initProps(props);
+
+ props.placeholder = props.placeholder || 'Write a Post...';
+ props.submitLabel = props.submitLabel || 'Post Discussion';
+ props.confirmExit = props.confirmExit || 'You have not posted your discussion. Do you wish to discard it?';
+ props.titlePlaceholder = props.titlePlaceholder || 'Discussion Title';
+ }
+
+ headerItems() {
+ const items = super.headerItems();
+
+ items.add('title', (
+
+
+
+ ));
+
+ 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');
+ }
+
+ config(isInitialized, context) {
+ super.config(isInitialized, context);
+
+ // If the user presses the backspace key in the text editor, and the cursor
+ // is already at the start, then we'll move the focus back into the title
+ // input.
+ this.editor.$('textarea').keydown((e) => {
+ if (e.which === 8 && e.target.selectionStart === 0 && e.target.selectionEnd === 0) {
+ e.preventDefault();
+
+ const $title = this.$(':input:enabled:visible:first')[0];
+ $title.focus();
+ $title.selectionStart = $title.selectionEnd = $title.value.length;
+ }
+ });
+ }
+
+ 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.loading = false
+ );
+ }
+}
diff --git a/js/forum/src/components/DiscussionHero.js b/js/forum/src/components/DiscussionHero.js
new file mode 100644
index 000000000..b08d08e63
--- /dev/null
+++ b/js/forum/src/components/DiscussionHero.js
@@ -0,0 +1,41 @@
+import Component from 'flarum/Component';
+import ItemList from 'flarum/utils/ItemList';
+import listItems from 'flarum/helpers/listItems';
+
+/**
+ * The `DiscussionHero` component displays the hero on a discussion page.
+ *
+ * ### Props
+ *
+ * - `discussion`
+ */
+export default class DiscussionHero extends Component {
+ view() {
+ return (
+
+
+
{listItems(this.items().toArray())}
+
+
+ );
+ }
+
+ /**
+ * 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',
{listItems(badges)}
);
+ }
+
+ items.add('title',
{discussion.title()}
);
+
+ return items;
+ }
+}
diff --git a/js/forum/src/components/DiscussionList.js b/js/forum/src/components/DiscussionList.js
new file mode 100644
index 000000000..e3d886666
--- /dev/null
+++ b/js/forum/src/components/DiscussionList.js
@@ -0,0 +1,219 @@
+import Component from 'flarum/Component';
+import DiscussionListItem from 'flarum/components/DiscussionListItem';
+import Button from 'flarum/components/Button';
+import LoadingIndicator from 'flarum/components/LoadingIndicator';
+
+/**
+ * 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 {
+ constructor(...args) {
+ super(...args);
+
+ /**
+ * 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();
+
+ app.session.on('loggedIn', this.loggedInHandler = this.refresh.bind(this));
+ }
+
+ onunload() {
+ app.session.off('loggedIn', this.loggedInHandler);
+ }
+
+ view() {
+ const params = this.props.params;
+ let loading;
+
+ if (this.loading) {
+ loading = LoadingIndicator.component();
+ } else if (this.moreResults) {
+ loading = (
+
+ );
+ }
+
+ 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.$('.contextual-controls')
+ .on('hidden.bs.dropdown', () => slidableInstance.reset());
+ }
+ }
+
+ /**
+ * Determine whether or not the discussion is currently being viewed.
+ *
+ * @return {Boolean}
+ */
+ active() {
+ return m.route.param('id') === 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();
+
+ items.add('terminalPost',
+ TerminalPost.component({
+ discussion: this.props.discussion,
+ lastPost: !this.showStartPost()
+ })
+ );
+
+ return items;
+ }
+}
diff --git a/js/forum/src/components/DiscussionPage.js b/js/forum/src/components/DiscussionPage.js
new file mode 100644
index 000000000..03e011c67
--- /dev/null
+++ b/js/forum/src/components/DiscussionPage.js
@@ -0,0 +1,309 @@
+import Component from 'flarum/Component';
+import ItemList from 'flarum/utils/ItemList';
+import DiscussionHero from 'flarum/components/DiscussionHero';
+import PostStream from 'flarum/components/PostStream';
+import PostStreamScrubber from 'flarum/components/PostStreamScrubber';
+import LoadingIndicator from 'flarum/components/LoadingIndicator';
+import SplitDropdown from 'flarum/components/SplitDropdown';
+import listItems from 'flarum/helpers/listItems';
+import mixin from 'flarum/utils/mixin';
+import evented from 'flarum/utils/evented';
+import DiscussionControls from 'flarum/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 mixin(Component, evented) {
+ constructor(...args) {
+ super(...args);
+
+ /**
+ * 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.current instanceof DiscussionPage) {
+ m.redraw.strategy('diff');
+ }
+ }
+
+ // Push onto the history stack, but use a generalised key so that navigating
+ // to a few different discussions won't override the behaviour of the back
+ // button.
+ app.history.push('discussion');
+ app.current = this;
+
+ app.session.on('loggedIn', this.loggedInHandler = this.refresh.bind(this));
+ }
+
+ 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) {
+ if (m.route.param('id') === this.discussion.id()) {
+ e.preventDefault();
+
+ if (Number(m.route.param('near')) !== Number(this.near)) {
+ this.stream.goToNumber(m.route.param('near') || 1);
+ }
+
+ 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();
+ app.session.off('loggedIn', this.loggedInHandler);
+
+ if (app.composingReplyTo(this.discussion) && !app.composer.component.content()) {
+ app.composer.hide();
+ } else {
+ app.composer.minimize();
+ }
+ }
+
+ view() {
+ const discussion = this.discussion;
+
+ return (
+
+ );
+ }
+
+ config(isInitialized, context) {
+ if (isInitialized) return;
+
+ context.retain = true;
+
+ $('body').addClass('discussion-page');
+ context.onunload = () => $('body').removeClass('discussion-page');
+ }
+
+ /**
+ * 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.init.bind(this, preloadedDiscussion));
+ } else {
+ const params = this.params();
+ params.include = params.include.join(',');
+
+ app.store.find('discussions', m.route.param('id'), params)
+ .then(this.init.bind(this));
+ }
+
+ // Since this may be called during the component's constructor, i.e. in the
+ // middle of a redraw, forcing another redraw would not bode well. Instead
+ // we start/end a computation so Mithril will only redraw if it isn't
+ // already doing so.
+ m.startComputation();
+ m.endComputation();
+ }
+
+ /**
+ * Get the parameters that should be passed in the API request to get the
+ * discussion.
+ *
+ * @return {Object}
+ */
+ params() {
+ return {
+ page: {near: this.near},
+ include: ['posts', 'posts.user', 'posts.user.groups']
+ };
+ }
+
+ /**
+ * Initialize the component to display the given discussion.
+ *
+ * @param {Discussion} discussion
+ */
+ init(discussion) {
+ // If the slug in the URL doesn't match up, we'll redirect so we have the
+ // correct one.
+ if (m.route.param('id') === discussion.id() && m.route.param('slug') !== discussion.slug()) {
+ m.route(app.route.discussion(discussion, m.route.param('near')), null, true);
+ return;
+ }
+
+ this.discussion = discussion;
+
+ app.setTitle(discussion.title());
+
+ // 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) {
+ includedPosts = discussion.payload.included
+ .filter(record => record.type === 'posts' && record.relationships && record.relationships.discussion)
+ .map(record => app.store.getById('posts', record.id))
+ .sort((a, b) => a.id() - b.id())
+ .splice(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') || 1, true);
+
+ this.trigger('loaded', discussion);
+ }
+
+ /**
+ * 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('.discussion-list-item.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: 'ellipsis-v',
+ className: 'primary-control',
+ buttonClassName: 'btn btn-primary'
+ })
+ );
+
+ items.add('scrubber',
+ PostStreamScrubber.component({
+ stream: this.stream,
+ className: 'title-control'
+ })
+ );
+
+ 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');
+
+ // 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();
+ }
+ }
+}
diff --git a/js/forum/src/components/DiscussionRenamedNotification.js b/js/forum/src/components/DiscussionRenamedNotification.js
new file mode 100644
index 000000000..61713a697
--- /dev/null
+++ b/js/forum/src/components/DiscussionRenamedNotification.js
@@ -0,0 +1,26 @@
+import Notification from 'flarum/components/Notification';
+import username from 'flarum/helpers/username';
+
+/**
+ * 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 'pencil';
+ }
+
+ href() {
+ const notification = this.props.notification;
+
+ return app.route.discussion(notification.subject(), notification.content().postNumber);
+ }
+
+ content() {
+ return [username(this.props.notification.sender()), ' changed the title'];
+ }
+}
diff --git a/js/forum/src/components/DiscussionRenamedPost.js b/js/forum/src/components/DiscussionRenamedPost.js
new file mode 100644
index 000000000..576db0d4e
--- /dev/null
+++ b/js/forum/src/components/DiscussionRenamedPost.js
@@ -0,0 +1,23 @@
+import EventPost from 'flarum/components/EventPost';
+
+/**
+ * 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 'pencil';
+ }
+
+ description() {
+ const post = this.props.post;
+ const oldTitle = post.content()[0];
+ const newTitle = post.content()[1];
+
+ return ['changed the title from ', m('strong.old-title', oldTitle), ' to ', m('strong.new-title', newTitle), '.'];
+ }
+}
diff --git a/js/forum/src/components/DiscussionsSearchSource.js b/js/forum/src/components/DiscussionsSearchSource.js
new file mode 100644
index 000000000..9988fdb13
--- /dev/null
+++ b/js/forum/src/components/DiscussionsSearchSource.js
@@ -0,0 +1,55 @@
+import highlight from 'flarum/helpers/highlight';
+import Button from 'flarum/components/Button';
+
+/**
+ * The `DiscussionsSearchSource` finds and displays discussion search results in
+ * the search dropdown.
+ *
+ * @implements SearchSource
+ */
+export default class DiscussionsSearchSource {
+ constructor() {
+ this.results = {};
+ }
+
+ search(query) {
+ this.results[query] = [];
+
+ const params = {
+ filter: {q: query},
+ page: {limit: 3},
+ include: 'relevantPosts,relevantPosts.discussion,relevantPosts.user'
+ };
+
+ return app.store.find('discussions', params).then(results => this.results[query] = results);
+ }
+
+ view(query) {
+ const results = this.results[query] || [];
+
+ return [
+
+ );
+ })
+ ];
+ }
+}
diff --git a/js/forum/src/components/EditPostComposer.js b/js/forum/src/components/EditPostComposer.js
new file mode 100644
index 000000000..e0b013dae
--- /dev/null
+++ b/js/forum/src/components/EditPostComposer.js
@@ -0,0 +1,64 @@
+import ComposerBody from 'flarum/components/ComposerBody';
+import icon from 'flarum/helpers/icon';
+
+/**
+ * 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 EditComposer extends ComposerBody {
+ static initProps(props) {
+ super.initProps(props);
+
+ props.submitLabel = props.submitLabel || 'Save Changes';
+ props.confirmExit = props.confirmExit || 'You have not saved your changes. Do you wish to discard them?';
+ props.originalContent = props.originalContent || props.post.content();
+ props.user = props.user || props.post.user();
+ }
+
+ headerItems() {
+ const items = super.headerItems();
+ const post = this.props.post;
+
+ items.add('title', (
+
+ ];
+ }
+
+ /**
+ * Get the name of the event icon.
+ *
+ * @return {String}
+ */
+ icon() {
+ }
+
+ /**
+ * Get the description of the event.
+ *
+ * @return {VirtualElement}
+ */
+ description() {
+ }
+}
diff --git a/js/forum/src/components/FooterPrimary.js b/js/forum/src/components/FooterPrimary.js
new file mode 100644
index 000000000..62457737b
--- /dev/null
+++ b/js/forum/src/components/FooterPrimary.js
@@ -0,0 +1,31 @@
+import Component from 'flarum/Component';
+import ItemList from 'flarum/utils/ItemList';
+import listItems from 'flarum/helpers/listItems';
+
+/**
+ * The `FooterPrimary` component displays primary footer controls, such as the
+ * forum statistics. On the default skin, these are shown on the left side of
+ * the footer.
+ */
+export default class FooterPrimary extends Component {
+ view() {
+ return (
+
+ {listItems(this.items().toArray())}
+
+ );
+ }
+
+ /**
+ * Build an item list for the controls.
+ *
+ * @return {ItemList}
+ */
+ items() {
+ const items = new ItemList();
+
+ // TODO: add forum statistics
+
+ return items;
+ }
+}
diff --git a/js/forum/src/components/FooterSecondary.js b/js/forum/src/components/FooterSecondary.js
new file mode 100644
index 000000000..ca0963787
--- /dev/null
+++ b/js/forum/src/components/FooterSecondary.js
@@ -0,0 +1,35 @@
+import Component from 'flarum/Component';
+import ItemList from 'flarum/utils/ItemList';
+import listItems from 'flarum/helpers/listItems';
+
+/**
+ * The `FooterSecondary` component displays secondary footer controls, such as
+ * the 'Powered by Flarum' message. On the default skin, these are shown on the
+ * right side of the footer.
+ */
+export default class FooterSecondary extends Component {
+ view() {
+ return (
+
+ {listItems(this.items().toArray())}
+
+ );
+ }
+
+ /**
+ * Build an item list for the controls.
+ *
+ * @return {ItemList}
+ */
+ items() {
+ const items = new ItemList();
+
+ items.add('poweredBy', (
+
+ Powered by Flarum
+
+ ));
+
+ return items;
+ }
+}
diff --git a/js/forum/src/components/ForgotPasswordModal.js b/js/forum/src/components/ForgotPasswordModal.js
new file mode 100644
index 000000000..56cfa63b4
--- /dev/null
+++ b/js/forum/src/components/ForgotPasswordModal.js
@@ -0,0 +1,99 @@
+import Modal from 'flarum/components/Modal';
+import Alert from 'flarum/components/Alert';
+
+/**
+ * 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 {
+ constructor(...args) {
+ super(...args);
+
+ /**
+ * 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 'modal-sm forgot-password';
+ }
+
+ title() {
+ return 'Forgot Password';
+ }
+
+ body() {
+ if (this.success) {
+ const emailProviderName = this.email().split('@')[1];
+
+ return (
+
+
We've sent you an email containing a link to reset your password. Check your spam folder if you don't receive it within the next minute or two.
Enter your email address and we will send you a link to reset your password.
+
+
+
+
+
+
+
+ );
+ }
+
+ onsubmit(e) {
+ e.preventDefault();
+
+ this.loading = true;
+
+ app.request({
+ method: 'POST',
+ url: app.forum.attribute('apiUrl') + '/forgot',
+ data: {email: this.email()},
+ handlers: {
+ 404: () => {
+ this.alert = new Alert({type: 'warning', message: 'That email wasn\'t found in our database.'});
+ throw new Error();
+ }
+ }
+ }).then(
+ () => {
+ this.loading = false;
+ this.success = true;
+ this.alert = null;
+ m.redraw();
+ },
+ response => {
+ this.loading = false;
+ this.handleErrors(response.errors);
+ }
+ );
+ }
+}
diff --git a/js/forum/src/components/HeaderPrimary.js b/js/forum/src/components/HeaderPrimary.js
new file mode 100644
index 000000000..28bcee5c1
--- /dev/null
+++ b/js/forum/src/components/HeaderPrimary.js
@@ -0,0 +1,26 @@
+import Component from 'flarum/Component';
+import ItemList from 'flarum/utils/ItemList';
+import listItems from 'flarum/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 (
+
+ {listItems(this.items().toArray())}
+
+ );
+ }
+
+ /**
+ * Build an item list for the controls.
+ *
+ * @return {ItemList}
+ */
+ items() {
+ return new ItemList();
+ }
+}
diff --git a/js/forum/src/components/HeaderSecondary.js b/js/forum/src/components/HeaderSecondary.js
new file mode 100644
index 000000000..cd2bc6f3e
--- /dev/null
+++ b/js/forum/src/components/HeaderSecondary.js
@@ -0,0 +1,57 @@
+import Component from 'flarum/Component';
+import Button from 'flarum/components/Button';
+import LogInModal from 'flarum/components/LogInModal';
+import SignUpModal from 'flarum/components/SignUpModal';
+import SessionDropdown from 'flarum/components/SessionDropdown';
+import NotificationsDropdown from 'flarum/components/NotificationsDropdown';
+import ItemList from 'flarum/utils/ItemList';
+import listItems from 'flarum/helpers/listItems';
+
+/**
+ * The `HeaderSecondary` component displays secondary footer 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 (
+
+ {listItems(this.items().toArray())}
+
+ );
+ }
+
+ /**
+ * Build an item list for the controls.
+ *
+ * @return {ItemList}
+ */
+ items() {
+ const items = new ItemList();
+
+ items.add('search', app.search.render());
+
+ if (app.session.user) {
+ items.add('notifications', NotificationsDropdown.component());
+ items.add('session', SessionDropdown.component());
+ } else {
+ items.add('signUp',
+ Button.component({
+ children: 'Sign Up',
+ className: 'btn btn-link',
+ onclick: () => app.modal.show(new SignUpModal())
+ })
+ );
+
+ items.add('logIn',
+ Button.component({
+ children: 'Log In',
+ className: 'btn btn-link',
+ onclick: () => app.modal.show(new LogInModal())
+ })
+ );
+ }
+
+ return items;
+ }
+}
diff --git a/js/forum/src/components/index-page.js b/js/forum/src/components/IndexPage.js
similarity index 50%
rename from js/forum/src/components/index-page.js
rename to js/forum/src/components/IndexPage.js
index 5d41f2d44..86d10fa5b 100644
--- a/js/forum/src/components/index-page.js
+++ b/js/forum/src/components/IndexPage.js
@@ -1,89 +1,127 @@
-import Component from 'flarum/component';
-import ItemList from 'flarum/utils/item-list';
-import listItems from 'flarum/helpers/list-items';
-import Discussion from 'flarum/models/discussion';
-import mixin from 'flarum/utils/mixin';
-
-import DiscussionList from 'flarum/components/discussion-list';
-import WelcomeHero from 'flarum/components/welcome-hero';
-import DiscussionComposer from 'flarum/components/discussion-composer';
-import LoginModal from 'flarum/components/login-modal';
-import DiscussionPage from 'flarum/components/discussion-page';
-
-import SelectInput from 'flarum/components/select-input';
-import ActionButton from 'flarum/components/action-button';
-import IndexNavItem from 'flarum/components/index-nav-item';
-import LoadingIndicator from 'flarum/components/loading-indicator';
-import DropdownSelect from 'flarum/components/dropdown-select';
+import Component from 'flarum/Component';
+import ItemList from 'flarum/utils/ItemList';
+import affixSidebar from 'flarum/utils/affixSidebar';
+import listItems from 'flarum/helpers/listItems';
+import DiscussionList from 'flarum/components/DiscussionList';
+import WelcomeHero from 'flarum/components/WelcomeHero';
+import DiscussionComposer from 'flarum/components/DiscussionComposer';
+import LogInModal from 'flarum/components/LogInModal';
+import DiscussionPage from 'flarum/components/DiscussionPage';
+import Select from 'flarum/components/Select';
+import Button from 'flarum/components/Button';
+import LinkButton from 'flarum/components/LinkButton';
+import SelectDropdown from 'flarum/components/SelectDropdown';
+/**
+ * The `IndexPage` component displays the index page, including the welcome
+ * hero, the sidebar, and the discussion list.
+ */
export default class IndexPage extends Component {
- /**
- * @param {Object} props
- */
- constructor(props) {
- super(props);
+ constructor(...args) {
+ super(...args);
// 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.current instanceof DiscussionPage) {
- this.lastDiscussion = app.current.discussion();
+ this.lastDiscussion = app.current.discussion;
}
- var params = this.params();
+ 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.
- if (app.cache.discussionList.forceReload) {
- app.cache.discussionList = null;
- } else {
- Object.keys(params).some(key => {
- if (app.cache.discussionList.props.params[key] !== params[key]) {
- app.cache.discussionList = null;
- return true;
- }
- });
- }
+ 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.cache.discussionList = new DiscussionList({params});
}
app.history.push('index');
app.current = this;
}
- /**
- * Render the component.
- *
- * @return {Object}
- */
+ onunload() {
+ // Save the scroll position so we can restore it when we return to the
+ // discussion list.
+ app.cache.scrollTop = $(window).scrollTop();
+ app.composer.minimize();
+ }
+
view() {
- return m('div.index-area', {config: this.onload.bind(this)}, [
- this.hero(),
- m('div.container', [
- m('nav.side-nav.index-nav', {config: this.affixSidebar}, [
- m('ul', listItems(this.sidebarItems().toArray()))
- ]),
- m('div.offset-content.index-results', [
- m('div.index-toolbar', [
- m('ul.index-toolbar-view', listItems(this.viewItems().toArray())),
- m('ul.index-toolbar-action', listItems(this.actionItems().toArray()))
- ]),
- app.cache.discussionList.render()
- ])
- ])
- ]);
+ return (
+
+ {this.hero()}
+
+
+
+
+
{listItems(this.viewItems().toArray())}
+
{listItems(this.actionItems().toArray())}
+
+ {app.cache.discussionList.render()}
+
+
+
+ );
+ }
+
+ config(isInitialized, context) {
+ if (isInitialized) return;
+
+ $('body').addClass('index-page');
+ context.onunload = () => {
+ $('body').removeClass('index-page');
+ $('.global-page').css('min-height', '');
+ };
+
+ app.setTitle('');
+
+ // Work out the difference between the height of this hero and that of the
+ // previous hero. Maintain the same scroll position relative to the bottom
+ // of the hero so that the 'fixed' sidebar doesn't jump around.
+ const heroHeight = this.$('.hero').outerHeight();
+ const scrollTop = app.cache.scrollTop;
+
+ $('.global-page').css('min-height', $(window).height() + heroHeight);
+ $(window).scrollTop(scrollTop - (app.cache.heroHeight - heroHeight));
+
+ app.cache.heroHeight = heroHeight;
+
+ // 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.$('.discussion-summary[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 {Object}
+ * @return {MithrilComponent}
*/
hero() {
return WelcomeHero.component();
@@ -92,27 +130,27 @@ export default class IndexPage extends 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 (see this.navItems).
+ * list of navigation items.
*
* @return {ItemList}
*/
sidebarItems() {
- var items = new ItemList();
+ const items = new ItemList();
items.add('newDiscussion',
- ActionButton.component({
- label: 'Start a Discussion',
+ Button.component({
+ children: 'Start a Discussion',
icon: 'edit',
className: 'btn btn-primary new-discussion',
- wrapperClass: 'primary-control',
+ itemClassName: 'primary-control',
onclick: this.newDiscussion.bind(this)
})
);
items.add('nav',
- DropdownSelect.component({
- items: this.navItems(this).toArray(),
- wrapperClass: 'title-control'
+ SelectDropdown.component({
+ children: this.navItems(this).toArray(),
+ itemClassName: 'title-control'
})
);
@@ -126,13 +164,13 @@ export default class IndexPage extends Component {
* @return {ItemList}
*/
navItems() {
- var items = new ItemList();
- var params = this.stickyParams();
+ const items = new ItemList();
+ const params = this.stickyParams();
items.add('allDiscussions',
- IndexNavItem.component({
+ LinkButton.component({
href: app.route('index', params),
- label: 'All Discussions',
+ children: 'All Discussions',
icon: 'comments-o'
})
);
@@ -148,23 +186,23 @@ export default class IndexPage extends Component {
* @return {ItemList}
*/
viewItems() {
- var items = new ItemList();
+ const items = new ItemList();
- var sortOptions = {};
- for (var i in app.cache.discussionList.sortMap()) {
- sortOptions[i] = i.substr(0, 1).toUpperCase()+i.substr(1);
+ const sortOptions = {};
+ for (const i in app.cache.discussionList.sortMap()) {
+ sortOptions[i] = i.substr(0, 1).toUpperCase() + i.substr(1);
}
items.add('sort',
- SelectInput.component({
+ Select.component({
options: sortOptions,
value: this.params().sort,
- onchange: this.reorder.bind(this)
+ onchange: this.changeSort.bind(this)
})
);
items.add('refresh',
- ActionButton.component({
+ Button.component({
title: 'Refresh',
icon: 'refresh',
className: 'btn btn-default btn-icon',
@@ -182,14 +220,14 @@ export default class IndexPage extends Component {
* @return {ItemList}
*/
actionItems() {
- var items = new ItemList();
+ const items = new ItemList();
- if (app.session.user()) {
+ if (app.session.user) {
items.add('markAllAsRead',
- ActionButton.component({
+ Button.component({
title: 'Mark All as Read',
icon: 'check',
- className: 'control-markAllAsRead btn btn-default btn-icon',
+ className: 'btn btn-default btn-icon',
onclick: this.markAllAsRead.bind(this)
})
);
@@ -202,7 +240,7 @@ export default class IndexPage extends Component {
* Return the current search query, if any. This is implemented to activate
* the search box in the header.
*
- * @see module:flarum/components/search-box
+ * @see Search
* @return {String}
*/
searching() {
@@ -213,27 +251,29 @@ export default class IndexPage extends Component {
* 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 module:flarum/components/search-box
- * @return void
+ * @see Search
*/
clearSearch() {
- var params = this.params();
+ const params = this.params();
delete params.q;
+
m.route(app.route(this.props.routeName, params));
}
/**
- * Redirect to
- * @param {[type]} sort [description]
- * @return {[type]}
+ * Redirect to the index page using the given sort parameter.
+ *
+ * @param {String} sort
*/
- reorder(sort) {
- var params = this.params();
+ 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));
}
@@ -246,7 +286,7 @@ export default class IndexPage extends Component {
return {
sort: m.route.param('sort'),
q: m.route.param('q')
- }
+ };
}
/**
@@ -255,7 +295,7 @@ export default class IndexPage extends Component {
* @return {Object}
*/
params() {
- var params = this.stickyParams();
+ const params = this.stickyParams();
params.filter = m.route.param('filter');
@@ -263,111 +303,40 @@ export default class IndexPage extends Component {
}
/**
- * Initialize the DOM.
- *
- * @param {DOMElement} element
- * @param {Boolean} isInitialized
- * @param {Object} context
- * @return {void}
- */
- onload(element, isInitialized, context) {
- if (isInitialized) return;
-
- this.element(element);
-
- $('body').addClass('index-page');
- context.onunload = function() {
- $('body').removeClass('index-page');
- $('.global-page').css('min-height', '');
- };
-
- app.setTitle('');
-
- // Work out the difference between the height of this hero and that of the
- // previous hero. Maintain the same scroll position relative to the bottom
- // of the hero so that the 'fixed' sidebar doesn't jump around.
- var heroHeight = this.$('.hero').outerHeight();
- var scrollTop = app.cache.scrollTop;
-
- $('.global-page').css('min-height', $(window).height() + heroHeight);
- $(window).scrollTop(scrollTop - (app.cache.heroHeight - heroHeight));
-
- app.cache.heroHeight = heroHeight;
-
- // 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) {
- var $discussion = this.$('.discussion-summary[data-id='+this.lastDiscussion.id()+']');
- if ($discussion.length) {
- var indexTop = $('#header').outerHeight();
- var discussionTop = $discussion.offset().top;
- if (discussionTop < scrollTop + indexTop || discussionTop + $discussion.outerHeight() > scrollTop + $(window).height()) {
- $(window).scrollTop(discussionTop - indexTop);
- }
- }
- }
- }
-
- /**
- * Mithril hook, called when the controller is destroyed. Save the scroll
- * position, and minimize the composer.
- *
- * @return void
- */
- onunload() {
- app.cache.scrollTop = $(window).scrollTop();
- app.composer.minimize();
- }
-
- /**
- * Setup the sidebar DOM element to be affixed to the top of the viewport
- * using Bootstrap's affix plugin.
- *
- * @param {DOMElement} element
- * @param {Boolean} isInitialized
- * @return {void}
- */
- affixSidebar(element, isInitialized) {
- if (isInitialized) { return; }
- var $sidebar = $(element);
-
- // Don't affix the sidebar if it is taller than the viewport (otherwise
- // there would be no way to scroll through its content).
- if ($sidebar.outerHeight(true) > $(window).height() - $('.global-header').outerHeight(true)) return;
-
- $sidebar.find('> ul').affix({
- offset: {
- top: () => $sidebar.offset().top - $('.global-header').outerHeight(true) - parseInt($sidebar.css('margin-top')),
- bottom: () => (this.bottom = $('.global-footer').outerHeight(true))
- }
- });
- }
-
- /**
- * Initialize the composer for a new discussion.
+ * Log the user in and then open the composer for a new discussion.
*
* @return {Promise}
*/
newDiscussion() {
- var deferred = m.deferred();
+ const deferred = m.deferred();
- if (app.session.user()) {
+ if (app.session.user) {
this.composeNewDiscussion(deferred);
} else {
app.modal.show(
- new LoginModal({ onlogin: this.composeNewDiscussion.bind(this, deferred) })
+ 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) {
- // @todo check global permissions
- var component = new DiscussionComposer({ user: app.session.user() });
+ // TODO: check global permissions
+
+ const component = new DiscussionComposer({user: app.session.user});
+
app.composer.load(component);
app.composer.show();
+
deferred.resolve(component);
return deferred.promise;
@@ -379,6 +348,6 @@ export default class IndexPage extends Component {
* @return void
*/
markAllAsRead() {
- app.session.user().save({ readTime: new Date() });
+ app.session.user.save({readTime: new Date()});
}
-};
+}
diff --git a/js/forum/src/components/JoinedActivity.js b/js/forum/src/components/JoinedActivity.js
new file mode 100644
index 000000000..08e4121d5
--- /dev/null
+++ b/js/forum/src/components/JoinedActivity.js
@@ -0,0 +1,11 @@
+import Activity from 'flarum/components/Activity';
+
+/**
+ * The `JoinedActivity` component displays an activity feed item for when a user
+ * joined the forum.
+ */
+export default class JoinedActivity extends Activity {
+ description() {
+ return 'Joined the forum';
+ }
+}
diff --git a/js/forum/src/components/LoadingPost.js b/js/forum/src/components/LoadingPost.js
new file mode 100644
index 000000000..6e3888d5c
--- /dev/null
+++ b/js/forum/src/components/LoadingPost.js
@@ -0,0 +1,25 @@
+import Component from 'flarum/Component';
+import avatar from 'flarum/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 (
+
+
+ {avatar()}
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/js/forum/src/components/LogInModal.js b/js/forum/src/components/LogInModal.js
new file mode 100644
index 000000000..d0585f411
--- /dev/null
+++ b/js/forum/src/components/LogInModal.js
@@ -0,0 +1,140 @@
+import Modal from 'flarum/components/Modal';
+import ForgotPasswordModal from 'flarum/components/ForgotPasswordModal';
+import SignUpModal from 'flarum/components/SignUpModal';
+import Alert from 'flarum/components/Alert';
+
+/**
+ * The `LogInModal` component displays a modal dialog with a login form.
+ *
+ * ### Props
+ *
+ * - `email`
+ * - `password`
+ */
+export default class LogInModal extends Modal {
+ constructor(...args) {
+ super(...args);
+
+ /**
+ * 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-sm login-modal';
+ }
+
+ title() {
+ return 'Log In';
+ }
+
+ body() {
+ return (
+
+ );
+ }
+
+ config(isInitialized) {
+ if (isInitialized) return;
+
+ var self = this;
+ this.$('thead .toggle-group').bind('mouseenter mouseleave', function(e) {
+ var i = parseInt($(this).index()) + 1;
+ self.$('table').find('td:nth-child('+i+')').toggleClass('highlighted', e.type === 'mouseenter');
+ });
+
+ this.$('tbody .toggle-group').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 types to display in the grid.
+ *
+ * Each notification type is an object which has the following properties:
+ *
+ * - `name` The name of the notification type.
+ * - `label` The label to display in the notification grid row.
+ *
+ * @return {ItemList}
+ */
+ notificationTypes() {
+ const items = new ItemList();
+
+ items.add('discussionRenamed', {
+ name: 'discussionRenamed',
+ label: [icon('pencil'), ' Someone renames a discussion I started']
+ });
+
+ return items;
+ }
+}
diff --git a/js/forum/src/components/NotificationList.js b/js/forum/src/components/NotificationList.js
new file mode 100644
index 000000000..ab357fb4a
--- /dev/null
+++ b/js/forum/src/components/NotificationList.js
@@ -0,0 +1,141 @@
+import Component from 'flarum/Component';
+import listItems from 'flarum/helpers/listItems';
+import Button from 'flarum/components/Button';
+import LoadingIndicator from 'flarum/components/LoadingIndicator';
+import Discussion from 'flarum/models/Discussion';
+
+/**
+ * The `NotificationList` component displays a list of the logged-in user's
+ * notifications, grouped by discussion.
+ */
+export default class NotificationList extends Component {
+ constructor(...args) {
+ super(...args);
+
+ /**
+ * Whether or not the notifications are loading.
+ *
+ * @type {Boolean}
+ */
+ this.loading = false;
+
+ this.load();
+ }
+
+ view() {
+ const groups = [];
+
+ if (app.cache.notifications) {
+ const discussions = {};
+
+ // Build an array of discussions which the notifications are related to,
+ // and add the notifications as children.
+ app.cache.notifications.forEach(notification => {
+ const subject = notification.subject();
+
+ // 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;
+ if (subject instanceof Discussion) discussion = subject;
+ else if (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 (
+
+ );
+ }
+}
diff --git a/js/forum/src/components/PostPreview.js b/js/forum/src/components/PostPreview.js
new file mode 100644
index 000000000..2057b9e75
--- /dev/null
+++ b/js/forum/src/components/PostPreview.js
@@ -0,0 +1,32 @@
+import Component from 'flarum/Component';
+import avatar from 'flarum/helpers/avatar';
+import username from 'flarum/helpers/username';
+import humanTime from 'flarum/helpers/humanTime';
+import highlight from 'flarum/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, 200);
+
+ return (
+
+
+ {avatar(user)}
+ {username(user)}
+ {humanTime(post.time())}
+ {excerpt}
+
+
+ );
+ }
+}
diff --git a/js/forum/src/components/PostStream.js b/js/forum/src/components/PostStream.js
new file mode 100644
index 000000000..1d696522b
--- /dev/null
+++ b/js/forum/src/components/PostStream.js
@@ -0,0 +1,556 @@
+import Component from 'flarum/Component';
+import ScrollListener from 'flarum/utils/ScrollListener';
+import PostLoading from 'flarum/components/LoadingPost';
+import anchorScroll from 'flarum/utils/anchorScroll';
+import mixin from 'flarum/utils/mixin';
+import evented from 'flarum/utils/evented';
+import ReplyPlaceholder from 'flarum/components/ReplyPlaceholder';
+
+/**
+ * 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 mixin(Component, evented) {
+ constructor(...args) {
+ super(...args);
+
+ /**
+ * 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.init(this.props.includedPosts);
+ }
+
+ /**
+ * Load and scroll to a post with a certain number.
+ *
+ * @param {Integer} number
+ * @param {Boolean} noAnimation
+ * @return {Promise}
+ */
+ goToNumber(number, noAnimation) {
+ 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.$('.post-stream-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;
+
+ this.visibleEnd = this.count();
+
+ this.loadRange(this.visibleStart, this.visibleEnd);
+ }
+
+ /**
+ * 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
+ */
+ init(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 => app.store.getById('posts', id));
+ }
+
+ 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();
+
+ return (
+
+ {this.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();
+
+ // 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 = [
+
;
+ })}
+
+ {
+ // 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.
+ this.viewingEnd &&
+ (!app.session.user || this.discussion.canReply()) &&
+ !app.composingReplyTo(this.discussion)
+ ? (
+
+ );
+ }
+
+ /**
+ * 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;
+
+ // 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.$('> .post-stream-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
+ // post. If it's below the bottom of the viewport, break out of the
+ // loop.
+ if (top + height < viewportTop) {
+ visible = (top + height - viewportTop) / height;
+ index = parseFloat($this.data('index')) + 1 - visible;
+ return true;
+ }
+ if (top > viewportTop + viewportHeight) {
+ return false;
+ }
+
+ // If the bottom half of this item is visible at the top of the
+ // viewport, then set the start of the visible proportion as our index.
+ if (top <= viewportTop && top + height > viewportTop) {
+ visible = (top + height - viewportTop) / height;
+ index = parseFloat($this.data('index')) + 1 - visible;
+ //
+ // If the top half of this item is visible at the bottom of the
+ // viewport, then add the visible proportion to the visible
+ // counter.
+ } else if (top + height >= viewportTop + viewportHeight) {
+ visible += (viewportTop + viewportHeight - top) / height;
+ //
+ // If the whole item is visible in the viewport, then increment the
+ // visible counter.
+ } else visible++;
+
+ // 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('.index').text(formatNumber(this.visibleIndex()));
+ $scrubber.find('.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($('.global-page').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;
+ 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');
+ }
+}
diff --git a/js/forum/src/components/PostUser.js b/js/forum/src/components/PostUser.js
new file mode 100644
index 000000000..f5a1e464d
--- /dev/null
+++ b/js/forum/src/components/PostUser.js
@@ -0,0 +1,100 @@
+import Component from 'flarum/Component';
+import UserCard from 'flarum/components/UserCard';
+import avatar from 'flarum/helpers/avatar';
+import username from 'flarum/helpers/username';
+import listItems from 'flarum/helpers/listItems';
+
+/**
+ * The `PostUser` component shows the avatar and username of a post's author.
+ *
+ * ### Props
+ *
+ * - `post`
+ */
+export default class PostHeaderUser extends Component {
+ constructor(...args) {
+ super(...args);
+
+ /**
+ * 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 (
+
+ ));
+
+ 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.
+ if (app.viewingDiscussion(discussion)) {
+ app.current.stream.update();
+ } 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({
+ children: 'View',
+ onclick: () => {
+ m.route(app.route.post(post));
+ app.alerts.dismiss(alert);
+ }
+ });
+ app.alerts.show(
+ alert = new Alert({
+ type: 'success',
+ message: 'Your reply was posted.',
+ controls: [viewButton]
+ })
+ );
+ }
+
+ app.composer.hide();
+ },
+ errors => {
+ this.loading = false;
+ m.redraw();
+ app.alertErrors(errors);
+ }
+ );
+ }
+}
diff --git a/js/forum/src/components/ReplyPlaceholder.js b/js/forum/src/components/ReplyPlaceholder.js
new file mode 100644
index 000000000..aaa1b3c3c
--- /dev/null
+++ b/js/forum/src/components/ReplyPlaceholder.js
@@ -0,0 +1,33 @@
+import Component from 'flarum/Component';
+import avatar from 'flarum/helpers/avatar';
+import DiscussionControls from 'flarum/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() {
+ function triggerClick(e) {
+ $(this).trigger('click');
+ e.preventDefault();
+ }
+
+ const reply = () => {
+ DiscussionControls.replyAction.call(this.props.discussion, true);
+ };
+
+ return (
+
+
+ {avatar(app.session.user)}
+ Write a Reply...
+
+
+ );
+ }
+}
diff --git a/js/forum/src/components/Search.js b/js/forum/src/components/Search.js
new file mode 100644
index 000000000..0cee8495e
--- /dev/null
+++ b/js/forum/src/components/Search.js
@@ -0,0 +1,291 @@
+import Component from 'flarum/Component';
+import LoadingIndicator from 'flarum/components/LoadingIndicator';
+import ItemList from 'flarum/utils/ItemList';
+import classList from 'flarum/utils/classList';
+import icon from 'flarum/helpers/icon';
+import DiscussionsSearchSource from 'flarum/components/DiscussionsSearchSource';
+import UsersSearchSource from 'flarum/components/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 {
+ constructor(...args) {
+ super(...args);
+
+ /**
+ * 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
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 (
+
+ );
+ }
+
+ 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)
+ );
+ });
+
+ // Handle navigation key events on the search input.
+ this.$('input')
+ .on('keydown', e => {
+ switch (e.which) {
+ case 40: case 38: // Down/Up
+ this.setIndex(this.getCurrentNumericIndex() + (e.which === 40 ? 1 : -1), true);
+ e.preventDefault();
+ break;
+
+ case 13: // Return
+ this.$('input').blur();
+ m.route(this.getItem(this.index).find('a').attr('href'));
+ app.drawer.hide();
+ break;
+
+ case 27: // Escape
+ this.clear();
+ break;
+
+ default:
+ // no default
+ }
+ })
+
+ // Handle input key events on the search input, triggering results to
+ // load.
+ .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();
+ }, 500);
+ });
+ }
+
+ /**
+ * Get the active search in the app's current controller.
+ *
+ * @return {String}
+ */
+ getCurrentSearch() {
+ return app.current && typeof app.current.searching === 'function' && app.current.searching();
+ }
+
+ /**
+ * 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
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);
+ }
+ }
+ }
+}
diff --git a/js/forum/src/components/SearchSource.js b/js/forum/src/components/SearchSource.js
new file mode 100644
index 000000000..c5c0069da
--- /dev/null
+++ b/js/forum/src/components/SearchSource.js
@@ -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
s that list the search results for the given
+ * query.
+ *
+ * @param {String} query
+ * @return {Object}
+ */
+ view() {
+ }
+}
diff --git a/js/forum/src/components/SessionDropdown.js b/js/forum/src/components/SessionDropdown.js
new file mode 100644
index 000000000..9408a9950
--- /dev/null
+++ b/js/forum/src/components/SessionDropdown.js
@@ -0,0 +1,90 @@
+import avatar from 'flarum/helpers/avatar';
+import username from 'flarum/helpers/username';
+import Dropdown from 'flarum/components/Dropdown';
+import Button from 'flarum/components/Button';
+import ItemList from 'flarum/utils/ItemList';
+import Separator from 'flarum/components/Separator';
+import Group from 'flarum/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.buttonClassName = 'btn btn-default btn-naked btn-rounded btn-user';
+ props.menuClassName = 'dropdown-menu-right';
+ }
+
+ view() {
+ this.props.children = this.items().toArray();
+
+ return super.view();
+ }
+
+ getButtonContent() {
+ const user = app.session.user;
+
+ return [
+ avatar(user), ' ',
+ {username(user)}
+ ];
+ }
+
+ /**
+ * 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',
+ Button.component({
+ icon: 'user',
+ children: 'Profile',
+ href: app.route.user(user),
+ config: m.route
+ }),
+ 100
+ );
+
+ items.add('settings',
+ Button.component({
+ icon: 'cog',
+ children: 'Settings',
+ href: app.route('settings'),
+ config: m.route
+ }),
+ 50
+ );
+
+ if (user.groups().some(group => Number(group.id()) === Group.ADMINISTRATOR_ID)) {
+ items.add('administration',
+ Button.component({
+ icon: 'wrench',
+ children: 'Administration',
+ href: app.forum.attribute('baseUrl') + '/admin',
+ target: '_blank'
+ }),
+ 0
+ );
+ }
+
+ items.add('separator', Separator.component(), -90);
+
+ items.add('logOut',
+ Button.component({
+ icon: 'sign-out',
+ children: 'Log Out',
+ onclick: app.session.logout.bind(app.session)
+ }),
+ -100
+ );
+
+ return items;
+ }
+}
diff --git a/js/forum/src/components/SettingsPage.js b/js/forum/src/components/SettingsPage.js
new file mode 100644
index 000000000..39476095c
--- /dev/null
+++ b/js/forum/src/components/SettingsPage.js
@@ -0,0 +1,145 @@
+import UserPage from 'flarum/components/UserPage';
+import ItemList from 'flarum/utils/ItemList';
+import Switch from 'flarum/components/Switch';
+import Button from 'flarum/components/Button';
+import FieldSet from 'flarum/components/FieldSet';
+import NotificationGrid from 'flarum/components/NotificationGrid';
+import ChangePasswordModal from 'flarum/components/ChangePasswordModal';
+import ChangeEmailModal from 'flarum/components/ChangeEmailModal';
+import DeleteAccountModal from 'flarum/components/DeleteAccountModal';
+import listItems from 'flarum/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 {
+ constructor(...args) {
+ super(...args);
+
+ this.init(app.session.user);
+ app.setTitle('Settings');
+ app.drawer.hide();
+ }
+
+ content() {
+ return (
+
+
{listItems(this.settingsItems().toArray())}
+
+ );
+ }
+
+ /**
+ * Build an item list for the user's settings controls.
+ *
+ * @return {ItemList}
+ */
+ settingsItems() {
+ const items = new ItemList();
+
+ items.add('account',
+ FieldSet.component({
+ label: 'Account',
+ className: 'settings-account',
+ children: this.accountItems().toArray()
+ })
+ );
+
+ items.add('notifications',
+ FieldSet.component({
+ label: 'Notifications',
+ className: 'settings-account',
+ children: [NotificationGrid.component({user: this.user})]
+ })
+ );
+
+ items.add('privacy',
+ FieldSet.component({
+ label: 'Privacy',
+ 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: 'Change Password',
+ className: 'btn btn-default',
+ onclick: () => app.modal.show(new ChangePasswordModal())
+ })
+ );
+
+ items.add('changeEmail',
+ Button.component({
+ children: 'Change Email',
+ className: 'btn btn-default',
+ onclick: () => app.modal.show(new ChangeEmailModal())
+ })
+ );
+
+ items.add('deleteAccount',
+ Button.component({
+ children: 'Delete Account',
+ className: 'btn btn-default btn-danger',
+ onclick: () => app.modal.show(new DeleteAccountModal())
+ })
+ );
+
+ return items;
+ }
+
+ /**
+ * Generate a callback that will save a value to the given preference.
+ *
+ * @param {String} key
+ * @return {Function}
+ */
+ preferenceSaver(key) {
+ return (value, component) => {
+ const preferences = this.user.preferences();
+ preferences[key] = value;
+
+ if (component) component.loading = true;
+ m.redraw();
+
+ this.user.save({preferences}).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: 'Allow others to see when I am online',
+ state: this.user.preferences().discloseOnline,
+ onchange: (value, component) => {
+ this.user.pushAttributes({lastSeenTime: null});
+ this.preferenceSaver('discloseOnline')(value, component);
+ }
+ })
+ );
+
+ return items;
+ }
+}
diff --git a/js/forum/src/components/SignUpModal.js b/js/forum/src/components/SignUpModal.js
new file mode 100644
index 000000000..cf3e21fd2
--- /dev/null
+++ b/js/forum/src/components/SignUpModal.js
@@ -0,0 +1,172 @@
+import Modal from 'flarum/components/Modal';
+import LogInModal from 'flarum/components/LogInModal';
+import avatar from 'flarum/helpers/avatar';
+
+/**
+ * The `SignUpModal` component displays a modal dialog with a singup form.
+ *
+ * ### Props
+ *
+ * - `username`
+ * - `email`
+ * - `password`
+ */
+export default class SignUpModal extends Modal {
+ constructor(...args) {
+ super(...args);
+
+ /**
+ * 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 || '');
+
+ /**
+ * The user that has been signed up and that should be welcomed.
+ *
+ * @type {null|User}
+ */
+ this.welcomeUser = null;
+ }
+
+ className() {
+ return 'modal-sm signup-modal' + (this.welcomeUser ? ' signup-modal-success' : '');
+ }
+
+ title() {
+ return 'Sign Up';
+ }
+
+ body() {
+ const body = [(
+
+ );
}
- read() {
+ /**
+ * 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() {
+ }
+
+ /**
+ * Mark the notification as read.
+ */
+ markAsRead() {
this.props.notification.save({isRead: true});
}
}
diff --git a/js/forum/src/components/notifications-page.js b/js/forum/src/components/notifications-page.js
deleted file mode 100644
index cb02ad01a..000000000
--- a/js/forum/src/components/notifications-page.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Component from 'flarum/component';
-import NotificationList from 'flarum/components/notification-list';
-
-export default class NotificationsPage extends Component {
- constructor(props) {
- super(props);
-
- app.current = this;
- app.history.push('notifications');
- app.drawer.hide();
- }
-
- view() {
- return m('div', NotificationList.component());
- }
-}
diff --git a/js/forum/src/components/post-header-edited.js b/js/forum/src/components/post-header-edited.js
deleted file mode 100644
index 381c0d11d..000000000
--- a/js/forum/src/components/post-header-edited.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Component from 'flarum/component';
-import icon from 'flarum/helpers/icon';
-import humanTime from 'flarum/utils/human-time';
-
-/**
- Component for the edited pencil icon in a post header. Shows a tooltip on
- hover which details who edited the post and when.
- */
-export default class PostHeaderEdited extends Component {
- view() {
- var post = this.props.post;
-
- var title = 'Edited '+(post.editUser() ? 'by '+post.editUser().username()+' ' : '')+humanTime(post.editTime());
-
- return m('span.post-edited', {
- title: title,
- config: (element) => $(element).tooltip()
- }, icon('pencil'));
- }
-}
diff --git a/js/forum/src/components/post-header-meta.js b/js/forum/src/components/post-header-meta.js
deleted file mode 100644
index 22dbd5a15..000000000
--- a/js/forum/src/components/post-header-meta.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import Component from 'flarum/component';
-import humanTime from 'flarum/helpers/human-time';
-import fullTime from 'flarum/helpers/full-time';
-
-/**
- Component for the meta part of a post header. Displays the time, and when
- clicked, shows a dropdown containing more information about the post
- (number, full time, permalink).
- */
-export default class PostHeaderMeta extends Component {
- view() {
- var post = this.props.post;
- var discussion = post.discussion();
-
- var params = {
- id: discussion.id(),
- slug: discussion.slug(),
- near: post.number()
- };
- var permalink = window.location.origin+app.route('discussion.near', params);
- var 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.
- var selectPermalink = function() {
- var input = $(this).parent().find('.permalink');
- setTimeout(() => input.select());
- m.redraw.strategy('none');
- }
-
- return m('span.dropdown',
- m('a.dropdown-toggle[href=javascript:;][data-toggle=dropdown]', {onclick: selectPermalink}, humanTime(post.time())),
- m('div.dropdown-menu.post-meta', [
- m('span.number', 'Post #'+post.number()),
- m('span.time', fullTime(post.time())),
- touch
- ? m('a.btn.btn-default.permalink', {href: permalink}, permalink)
- : m('input.form-control.permalink', {value: permalink, onclick: (e) => e.stopPropagation()})
- ])
- );
- }
-}
diff --git a/js/forum/src/components/post-header-toggle.js b/js/forum/src/components/post-header-toggle.js
deleted file mode 100644
index 541c150d9..000000000
--- a/js/forum/src/components/post-header-toggle.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import Component from 'flarum/component';
-import icon from 'flarum/helpers/icon';
-
-/**
- Component for the toggle button in a post header. Toggles the
- `parent.revealContent` property when clicked. Only displays if the supplied
- post is not hidden.
- */
-export default class PostHeaderToggle extends Component {
- view() {
- return m('a.btn.btn-default.btn-more[href=javascript:;]', {onclick: this.props.toggle}, icon('ellipsis-h'));
- }
-}
diff --git a/js/forum/src/components/post-header-user.js b/js/forum/src/components/post-header-user.js
deleted file mode 100644
index 849cbad54..000000000
--- a/js/forum/src/components/post-header-user.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import Component from 'flarum/component';
-import UserCard from 'flarum/components/user-card';
-import avatar from 'flarum/helpers/avatar';
-import username from 'flarum/helpers/username';
-import listItems from 'flarum/helpers/list-items';
-
-/**
- Component for the username/avatar in a post header.
- */
-export default class PostHeaderUser extends Component {
- constructor(props) {
- super(props);
-
- this.showCard = m.prop(false);
- }
-
- view() {
- var post = this.props.post;
- var user = post.user();
-
- return m('div.post-user', {config: this.onload.bind(this)}, [
- m('h3',
- user ? [
- m('a', {href: app.route('user', {username: user.username()}), config: m.route}, [
- avatar(user), ' ',
- username(user)
- ]),
- m('ul.badges', listItems(user.badges().toArray().reverse()))
- ] : [
- avatar(), ' ',
- username()
- ]
- ),
- user && !post.isHidden() && this.showCard()
- ? UserCard.component({user, className: 'user-card-popover fade', controlsButtonClass: 'btn btn-default btn-icon btn-sm btn-naked'})
- : ''
- ]);
- }
-
- onload(element, isInitialized) {
- if (isInitialized) { return; }
-
- this.element(element);
-
- var component = this;
- var timeout;
- this.$().on('mouseover', 'h3 a, .user-card', function() {
- clearTimeout(timeout);
- timeout = setTimeout(function() {
- component.showCard(true);
- m.redraw();
- setTimeout(() => component.$('.user-card').addClass('in'));
- }, 500);
- }).on('mouseout', 'h3 a, .user-card', function() {
- clearTimeout(timeout);
- timeout = setTimeout(function() {
- component.$('.user-card').removeClass('in').one('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function() {
- component.showCard(false);
- m.redraw();
- });
- }, 250);
- });
- }
-}
diff --git a/js/forum/src/components/post-loading.js b/js/forum/src/components/post-loading.js
deleted file mode 100644
index 534b55568..000000000
--- a/js/forum/src/components/post-loading.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import Component from 'flarum/component';
-import avatar from 'flarum/helpers/avatar';
-
-export default class PostLoadingComponent extends Component {
- view() {
- return m('div.post.comment-post.loading-post.fake-post',
- m('header.post-header', avatar(), ' ', m('div.fake-text')),
- m('div.post-body', m('div.fake-text'), m('div.fake-text'), m('div.fake-text'))
- );
- }
-}
diff --git a/js/forum/src/components/post-preview.js b/js/forum/src/components/post-preview.js
deleted file mode 100644
index ec94e721e..000000000
--- a/js/forum/src/components/post-preview.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Component from 'flarum/component';
-import avatar from 'flarum/helpers/avatar';
-import username from 'flarum/helpers/username';
-import humanTime from 'flarum/helpers/human-time';
-import highlight from 'flarum/helpers/highlight';
-import truncate from 'flarum/utils/truncate';
-
-export default class PostPreview extends Component {
- view() {
- var post = this.props.post;
- var user = post.user();
-
- var excerpt = post.contentPlain();
- var start = 0;
-
- if (this.props.highlight) {
- var regexp = new RegExp(this.props.highlight, 'gi');
- start = Math.max(0, excerpt.search(regexp) - 100);
- }
-
- excerpt = truncate(excerpt, 200, start);
-
- if (this.props.highlight) {
- excerpt = highlight(excerpt, regexp);
- }
-
- return m('a.post-preview', {
- href: app.route.post(post),
- config: m.route,
- onclick: this.props.onclick
- }, m('div.post-preview-content', [
- avatar(user), ' ',
- username(user), ' ',
- humanTime(post.time()), ' ',
- excerpt
- ]));
- }
-}
diff --git a/js/forum/src/components/post-scrubber.js b/js/forum/src/components/post-scrubber.js
deleted file mode 100644
index 26261fc06..000000000
--- a/js/forum/src/components/post-scrubber.js
+++ /dev/null
@@ -1,387 +0,0 @@
-import Component from 'flarum/component';
-import icon from 'flarum/helpers/icon';
-import ScrollListener from 'flarum/utils/scroll-listener';
-import SubtreeRetainer from 'flarum/utils/subtree-retainer';
-import computed from 'flarum/utils/computed';
-import formatNumber from 'flarum/utils/format-number';
-
-/**
-
- */
-export default class PostScrubber extends Component {
- /**
-
- */
- constructor(props) {
- super(props);
-
- var stream = this.props.stream;
- this.handlers = {};
-
- // When the stream-content component begins loading posts at a certain
- // index, we want our scrubber scrollbar to jump to that position.
- stream.on('unpaused', this.handlers.unpaused = this.unpaused.bind(this));
-
- /**
- The integer index of the last item that is visible in the viewport. This
- is display on the scrubber (i.e. X of 100 posts).
- */
- this.visibleIndex = computed('index', 'visible', 'count', function(index, visible, count) {
- return Math.min(count, Math.ceil(Math.max(0, index) + visible));
- });
-
- this.count = () => this.props.stream.count();
- this.index = m.prop(0);
- this.visible = m.prop(1);
- this.description = m.prop();
-
- // 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));
-
- this.subtree = new SubtreeRetainer(() => true);
- }
-
- unpaused() {
- this.update(window.pageYOffset);
- this.renderScrollbar(true);
- }
-
- /**
- Disable the scrubber if the stream's initial content isn't loaded, or
- if all of the posts in the discussion are visible in the viewport.
- */
- disabled() {
- return this.visible() >= this.count();
- }
-
- /**
-
- */
- view() {
- var retain = this.subtree.retain();
- var stream = this.props.stream;
- var unreadCount = this.props.stream.discussion.unreadCount();
- var unreadPercent = Math.min(this.count() - this.index(), unreadCount) / this.count();
-
- // @todo clean up duplication
- return m('div.stream-scrubber.dropdown'+(this.disabled() ? '.disabled' : ''), {config: this.onload.bind(this), className: this.props.className}, [
- m('a.btn.btn-default.dropdown-toggle[href=javascript:;][data-toggle=dropdown]', [
- m('span.index', retain || formatNumber(this.visibleIndex())), ' of ', m('span.count', formatNumber(this.count())), ' posts ',
- icon('sort icon-glyph')
- ]),
- m('div.dropdown-menu', [
- m('div.scrubber', [
- m('a.scrubber-first[href=javascript:;]', {onclick: () => {
- stream.goToFirst();
- this.index(0);
- this.renderScrollbar(true);
- }}, [icon('angle-double-up'), ' Original Post']),
- m('div.scrubber-scrollbar', [
- m('div.scrubber-before'),
- m('div.scrubber-slider', [
- m('div.scrubber-handle'),
- m('div.scrubber-info', [
- m('strong', [m('span.index', retain || formatNumber(this.visibleIndex())), ' of ', m('span.count', formatNumber(this.count())), ' posts']),
- m('span.description', retain || this.description())
- ])
- ]),
- m('div.scrubber-after'),
- (app.session.user() && unreadPercent) ? m('div.scrubber-unread', {
- style: {top: (100 - unreadPercent * 100)+'%', height: (unreadPercent * 100)+'%'},
- config: function(element, isInitialized, context) {
- var $element = $(element);
- var newStyle = {top: (100 - unreadPercent * 100)+'%', height: (unreadPercent * 100)+'%'};
- if (context.oldStyle) {
- $element.stop(true).css(context.oldStyle).animate(newStyle);
- }
- context.oldStyle = newStyle;
- }
- }, formatNumber(unreadCount)+' unread') : ''
- ]),
- m('a.scrubber-last[href=javascript:;]', {onclick: () => {
- stream.goToLast();
- this.index(stream.count());
- this.renderScrollbar(true);
- }}, [icon('angle-double-down'), ' Now'])
- ])
- ])
- ])
- }
-
- onscroll(top) {
- var 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.
- */
- update(top) {
- var stream = this.props.stream;
-
- var $window = $(window);
- var marginTop = stream.getMarginTop();
- var scrollTop = $window.scrollTop() + marginTop;
- var windowHeight = $window.height() - marginTop;
-
- // 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.
- var $items = stream.$('> .item[data-index]');
- var index = $items.first().data('index') || 0;
- var visible = 0;
- var 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() {
- var $this = $(this);
- var top = $this.offset().top;
- var height = $this.outerHeight(true);
-
- // If this item is above the top of the viewport, skip to the next
- // post. If it's below the bottom of the viewport, break out of the
- // loop.
- if (top + height < scrollTop) {
- visible = (top + height - scrollTop) / height;
- index = parseFloat($this.data('index')) + 1 - visible;
- return;
- }
- if (top > scrollTop + windowHeight) {
- return false;
- }
-
- // If the bottom half of this item is visible at the top of the
- // viewport
- if (top <= scrollTop && top + height > scrollTop) {
- visible = (top + height - scrollTop) / height;
- index = parseFloat($this.data('index')) + 1 - visible;
- }
-
- // If the top half of this item is visible at the bottom of the
- // viewport, then add the visible proportion to the visible
- // counter.
- else if (top + height >= scrollTop + windowHeight) {
- visible += (scrollTop + windowHeight - top) / height;
- }
-
- // If the whole item is visible in the viewport, then increment the
- // visible counter.
- else {
- visible++;
- }
-
- // If this item has a time associated with it, then set the
- // scrollbar's current period to a formatted version of this time.
- if ($this.data('time')) {
- period = $this.data('time');
- }
- });
-
- this.index(index);
- this.visible(visible);
- this.description(period ? moment(period).format('MMMM YYYY') : '');
- }
-
- /**
-
- */
- onload(element, isInitialized, context) {
- this.element(element);
-
- 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-slider')
- .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.unpaused);
-
- $(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.
- */
- renderScrollbar(animate) {
- var percentPerPost = this.percentPerPost();
- var index = this.index();
- var count = this.count();
- var visible = this.visible() || 1;
-
- var $scrubber = this.$();
- $scrubber.find('.index').text(formatNumber(this.visibleIndex()));
- $scrubber.find('.description').text(this.description());
- $scrubber.toggleClass('disabled', this.disabled());
-
- var heights = {};
- heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
- heights.slider = Math.min(100 - heights.before, percentPerPost.visible * visible);
- heights.after = 100 - heights.before - heights.slider;
-
- var func = animate ? 'animate' : 'css';
- for (var part in heights) {
- var $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');
- }
- }
- }
-
- /**
-
- */
- percentPerPost() {
- var count = this.count() || 1;
- var visible = this.visible() || 1;
-
- // To stop the slider of the scrollbar from getting too small when there
- // are many posts, we define a minimum percentage height for the slider
- // 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.
- var minPercentVisible = 50 / this.$('.scrubber-scrollbar').outerHeight() * 100;
- var percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible);
- var 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.
- var scrubber = this.$();
- var scrollbar = this.$('.scrubber-scrollbar');
- scrollbar.css('max-height', $(window).height() - scrubber.offset().top + $(window).scrollTop() - parseInt($('.global-page').css('padding-bottom')) - (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.
- var deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
- var deltaPercent = deltaPixels / this.$('.scrubber-scrollbar').outerHeight() * 100;
- var deltaIndex = deltaPercent / this.percentPerPost().index;
- var newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1);
-
- this.index(Math.max(0, newIndex));
- this.renderScrollbar();
- }
-
- onmouseup(e) {
- 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.
- var 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.
- var $scrollbar = this.$('.scrubber-scrollbar');
- var offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $scrollbar.offset().top + $('body').scrollTop();
- var 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-slider')[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.
- var 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');
- }
-}
diff --git a/js/forum/src/components/post-stream.js b/js/forum/src/components/post-stream.js
deleted file mode 100644
index 2a1a5ed0e..000000000
--- a/js/forum/src/components/post-stream.js
+++ /dev/null
@@ -1,463 +0,0 @@
-import Component from 'flarum/component';
-import ScrollListener from 'flarum/utils/scroll-listener';
-import PostLoading from 'flarum/components/post-loading';
-import anchorScroll from 'flarum/utils/anchor-scroll';
-import mixin from 'flarum/utils/mixin';
-import evented from 'flarum/utils/evented';
-import ReplyPlaceholder from 'flarum/components/reply-placeholder';
-
-class PostStream extends mixin(Component, evented) {
- constructor(props) {
- super(props);
-
- this.discussion = this.props.discussion;
- this.setup(this.props.includedPosts);
-
- this.scrollListener = new ScrollListener(this.onscroll.bind(this));
-
- this.paused = m.prop(false);
-
- this.loadPageTimeouts = {};
- this.pagesLoading = 0;
- }
-
- /**
- Load and scroll to a post with a certain number.
- */
- goToNumber(number, noAnimation) {
- this.paused(true);
-
- var 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.
- */
- goToIndex(index, backwards, noAnimation) {
- this.paused(true);
-
- var promise = this.loadNearIndex(index);
-
- m.redraw(true);
-
- return promise.then(() => {
- anchorScroll(this.$('.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.
- */
- goToFirst() {
- return this.goToIndex(0);
- }
-
- /**
- Load and scroll down to the last post in the discussion.
- */
- goToLast() {
- return this.goToIndex(this.count() - 1, true);
- }
-
- /**
- Add a post to the end of the stream. Nothing will be done if the end of the
- stream is not visible.
- */
- update() {
- if (this.viewingEnd) {
- this.visibleEnd = this.count();
-
- this.loadRange(this.visibleStart, this.visibleEnd);
- }
- }
-
- /**
- Get the total number of posts in the discussion.
- */
- count() {
- return this.discussion.postIds().length;
- }
-
- /**
- Make sure that the given index is not outside of the possible range of
- indexes in the discussion.
- */
- sanitizeIndex(index) {
- return Math.max(0, Math.min(this.count(), index));
- }
-
- /**
- Set up the stream with the given array of posts.
- */
- setup(posts) {
- this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
- this.visibleEnd = this.visibleStart + posts.length;
- }
-
- /**
- Clear the stream and fill it with placeholder posts.
- */
- clear(start, end) {
- this.visibleStart = start || 0;
- this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount);
- }
-
- posts() {
- return this.discussion.postIds()
- .slice(this.visibleStart, this.visibleEnd)
- .map(id => app.store.getById('posts', id));
- }
-
- /**
- Construct a vDOM containing an element for each post that is visible in the
- stream. Posts that have not been loaded will be rendered as placeholders.
- */
- view() {
- function fadeIn(element, isInitialized, context) {
- if (!context.fadedIn) $(element).hide().fadeIn();
- context.fadedIn = true;
- }
-
- var lastTime;
-
- this.visibleEnd = this.sanitizeIndex(this.visibleEnd);
- this.viewingEnd = this.visibleEnd === this.count();
-
- return m('div.discussion-posts.posts', {config: this.onload.bind(this)},
- this.posts().map((post, i) => {
- var content;
- var attributes = {};
- attributes['data-index'] = this.visibleStart + i;
-
- if (post) {
- attributes.key = 'post'+post.id();
-
- var PostComponent = app.postComponentRegistry[post.contentType()];
- content = PostComponent ? PostComponent.component({post}) : '';
- attributes.config = fadeIn;
- attributes['data-time'] = post.time().toISOString();
- attributes['data-number'] = post.number();
-
- var dt = post.time() - lastTime;
- if (dt > 1000 * 60 * 60 * 24 * 4) { // 4 hours
- content = [
- m('div.time-gap', m('span', moment.duration(dt).humanize(), ' later')),
- content
- ];
- }
- lastTime = post.time();
- } else {
- attributes.key = this.visibleStart + i;
-
- content = PostLoading.component();
- }
-
- return m('div.item', attributes, content);
- }),
-
- // 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.
- this.viewingEnd &&
- (!app.session.user() || this.discussion.canReply()) &&
- !app.composingReplyTo(this.discussion)
- ? m('div.item', {key: 'reply'}, ReplyPlaceholder.component({discussion: this.discussion}))
- : ''
- );
- }
-
- /**
- Store a reference to the component's DOM and begin listening for the
- window's scroll event.
- */
- onload(element, isInitialized, context) {
- this.element(element);
-
- if (isInitialized) { return; }
-
- context.onunload = this.ondestroy.bind(this);
-
- // This is wrapped in setTimeout due to the following Mithril issue:
- // https://github.com/lhorie/mithril.js/issues/637
- setTimeout(() => this.scrollListener.start());
- }
-
- /**
- Stop listening for the window's scroll event, and cancel outstanding
- timeouts.
- */
- ondestroy() {
- 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.
- */
- onscroll(top) {
- if (this.paused()) return;
-
- var marginTop = this.getMarginTop();
- var viewportHeight = $(window).height() - marginTop;
- var viewportTop = top + marginTop;
- var loadAheadDistance = 500;
-
- if (this.visibleStart > 0) {
- var $item = this.$('.item[data-index='+this.visibleStart+']');
-
- if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
- this.loadPrevious();
- }
- }
-
- if (this.visibleEnd < this.count()) {
- var $item = this.$('.item[data-index='+(this.visibleEnd - 1)+']');
-
- if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
- this.loadNext();
- }
- }
-
- clearTimeout(this.calculatePositionTimeout);
- this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 100);
- }
-
- /**
- Load the next page of posts.
- */
- loadNext() {
- var start = this.visibleEnd;
- var 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.
- var twoPagesAway = start - this.constructor.loadCount * 2;
- if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) {
- this.visibleStart = twoPagesAway + this.constructor.loadCount + 1;
- clearTimeout(this.loadPageTimeouts[twoPagesAway]);
- }
-
- this.loadPage(start, end);
- }
-
- /**
- Load the previous page of posts.
- */
- loadPrevious() {
- var end = this.visibleStart;
- var 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.
- var twoPagesAway = start + this.constructor.loadCount * 2;
- if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) {
- this.visibleEnd = twoPagesAway;
- clearTimeout(this.loadPageTimeouts[twoPagesAway]);
- }
-
- this.loadPage(start, end, true);
- }
-
- /**
- Load a page of posts into the stream and redraw.
- */
- loadPage(start, end, backwards) {
- var redraw = () => {
- if (start < this.visibleStart || end > this.visibleEnd) return;
-
- var anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
- anchorScroll(`.item[data-index=${anchorIndex}]`, () => m.redraw(true));
-
- this.unpause();
- };
- redraw();
-
- this.pagesLoading++;
-
- this.loadPageTimeouts[start] = setTimeout(() => {
- this.loadRange(start, end).then(() => {
- redraw();
- this.pagesLoading--;
- });
- }, this.pagesLoading ? 1000 : 0);
- }
-
- /**
- Load and inject the specified range of posts into the stream, without
- clearing it.
- */
- loadRange(start, end) {
- const loadIds = [];
- const loaded = [];
-
- this.discussion.postIds().slice(start, end).forEach(id => {
- const post = app.store.getById('posts', id);
-
- if (!post) {
- loadIds.push(id);
- } else {
- loaded.push(post);
- }
- });
-
- 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.
- */
- loadNearNumber(number) {
- if (this.posts().some(post => post && post.number() == number)) {
- return m.deferred().resolve().promise;
- }
-
- this.clear();
-
- return app.store.find('posts', {
- filter: {discussion: this.discussion.id()},
- page: {near: number}
- }).then(this.setup.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.
- */
- loadNearIndex(index) {
- if (index >= this.visibleStart && index <= this.visibleEnd) {
- return m.deferred().resolve().promise;
- }
-
- var start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
- var end = start + this.constructor.loadCount;
-
- this.clear(start, end);
-
- return this.loadRange(start, end).then(this.setup.bind(this));
- }
-
- /**
- Work out which posts (by number) are currently visible in the viewport, and
- fire an event with the information.
- */
- calculatePosition() {
- var marginTop = this.getMarginTop();
- var $window = $(window);
- var viewportHeight = $window.height() - marginTop;
- var scrollTop = $window.scrollTop() + marginTop;
- var startNumber;
- var endNumber;
-
- this.$('.item').each(function() {
- var $item = $(this);
- var top = $item.offset().top;
- var height = $item.outerHeight(true);
-
- if (top + height > scrollTop) {
- if (!startNumber) {
- startNumber = $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.
- */
- getMarginTop() {
- return this.$() && $('.global-header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
- }
-
- /**
- Scroll down to a certain post by number and 'flash' it.
- */
- scrollToNumber(number, noAnimation) {
- var $item = this.$('.item[data-number='+number+']');
-
- return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item));
- }
-
- /**
- Scroll down to a certain post by index.
- */
- scrollToIndex(index, noAnimation, bottom) {
- var $item = this.$('.item[data-index='+index+']');
-
- return this.scrollToItem($item, noAnimation, true, bottom);
- }
-
- /**
- Scroll down to the given post.
- */
- scrollToItem($item, noAnimation, force, bottom) {
- var $container = $('html, body').stop(true);
-
- if ($item.length) {
- var itemTop = $item.offset().top - this.getMarginTop();
- var itemBottom = itemTop + $item.height();
- var scrollTop = $(document).scrollTop();
- var scrollBottom = scrollTop + $(window).height();
-
- // If the item is already in the viewport, we may not need to scroll.
- if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
- var scrollTop = bottom ? itemBottom : ($item.is(':first-child') ? 0 : itemTop);
-
- if (noAnimation) {
- $container.scrollTop(scrollTop);
- } else if (scrollTop !== $(document).scrollTop()) {
- $container.animate({scrollTop: scrollTop}, 'fast');
- }
- }
- }
-
- return $container.promise();
- }
-
- /**
- 'Flash' the given post, drawing the user's attention to it.
- */
- 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');
- }
-}
-
-PostStream.loadCount = 20;
-
-export default PostStream;
diff --git a/js/forum/src/components/post.js b/js/forum/src/components/post.js
index f3b8abce7..aebeab2c2 100644
--- a/js/forum/src/components/post.js
+++ b/js/forum/src/components/post.js
@@ -1,31 +1,75 @@
-import Component from 'flarum/component';
-import SubtreeRetainer from 'flarum/utils/subtree-retainer';
-import DropdownButton from 'flarum/components/dropdown-button';
+import Component from 'flarum/Component';
+import SubtreeRetainer from 'flarum/utils/SubtreeRetainer';
+import Dropdown from 'flarum/components/Dropdown';
+import PostControls from 'flarum/utils/PostControls';
+/**
+ * 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 {
- constructor(props) {
- super(props);
+ constructor(...args) {
+ super(...args);
+ /**
+ * 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,
() => {
- var user = this.props.post.user();
+ const user = this.props.post.user();
return user && user.freshness;
}
);
}
- view(content, attrs) {
- var controls = this.props.post.controls(this).toArray();
+ view() {
+ const controls = PostControls.controls(this.props.post, this).toArray();
+ const attrs = this.attrs();
- return m('article.post', attrs, this.subtree.retain() || m('div', [
- controls.length ? DropdownButton.component({
- items: controls,
- className: 'contextual-controls',
- buttonClass: 'btn btn-default btn-icon btn-sm btn-naked',
- menuClass: 'pull-right'
- }) : '',
- content
- ]));
+ attrs.className = 'post ' + (attrs.className || '');
+
+ return (
+
+ {this.subtree.retain() || (
+
+ )}
+
+ );
+ }
+
+ /**
+ * Get attributes for the post element.
+ *
+ * @return {Object}
+ */
+ attrs() {
+ }
+
+ /**
+ * Get the post's content.
+ *
+ * @return {Object}
+ */
+ content() {
}
}
diff --git a/js/forum/src/components/posted-activity.js b/js/forum/src/components/posted-activity.js
deleted file mode 100644
index e2e65ab68..000000000
--- a/js/forum/src/components/posted-activity.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Component from 'flarum/component';
-import humanTime from 'flarum/helpers/human-time';
-import avatar from 'flarum/helpers/avatar';
-import listItems from 'flarum/helpers/list-items';
-import ItemList from 'flarum/utils/item-list';
-
-export default class PostedActivity extends Component {
- view() {
- var activity = this.props.activity;
- var user = activity.user();
- var post = activity.subject();
- var discussion = post.discussion();
-
- return m('div', [
- avatar(user, {className: 'activity-icon'}),
- m('div.activity-info', [
- m('strong', post.number() == 1 ? 'Started a discussion' : 'Posted a reply'),
- humanTime(activity.time())
- ]),
- m('a.activity-content.post-activity', {href: app.route('discussion.near', {
- id: discussion.id(),
- slug: discussion.slug(),
- near: post.number()
- }), config: m.route}, [
- m('ul.list-inline', listItems(this.headerItems().toArray())),
- m('div.body', m.trust(post.contentPlain().substring(0, 200)))
- ])
- ]);
- }
-
- headerItems() {
- var items = new ItemList();
-
- items.add('title', m('h3.title', this.props.activity.subject().discussion().title()));
-
- return items;
- }
-}
diff --git a/js/forum/src/components/reply-composer.js b/js/forum/src/components/reply-composer.js
deleted file mode 100644
index 5c3059bdd..000000000
--- a/js/forum/src/components/reply-composer.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import ItemList from 'flarum/utils/item-list';
-import ComposerBody from 'flarum/components/composer-body';
-import Alert from 'flarum/components/alert';
-import ActionButton from 'flarum/components/action-button';
-import icon from 'flarum/helpers/icon';
-
-export default class ReplyComposer extends ComposerBody {
- constructor(props) {
- props.placeholder = props.placeholder || 'Write a Reply...';
- props.submitLabel = props.submitLabel || 'Post Reply';
- props.confirmExit = props.confirmExit || 'You have not posted your reply. Do you wish to discard it?';
-
- super(props);
- }
-
- view() {
- return super.view('reply-composer');
- }
-
- headerItems() {
- var items = new ItemList();
-
- items.add('title', m('h3', [
- icon('reply'), ' ',
- m('a', {href: app.route.discussion(this.props.discussion), config: m.route}, this.props.discussion.title())
- ]));
-
- return items;
- }
-
- data() {
- return {
- content: this.content(),
- relationships: {discussion: this.props.discussion}
- };
- }
-
- onsubmit() {
- var discussion = this.props.discussion;
-
- this.loading(true);
- m.redraw();
-
- var 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 add the post to the end of the post stream.
- if (app.viewingDiscussion(discussion)) {
- app.current.stream.update();
- m.route(app.route('discussion.near', {
- id: discussion.id(),
- slug: discussion.slug(),
- near: 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.
- var alert;
- var viewButton = ActionButton.component({
- label: 'View',
- onclick: () => {
- m.route(app.route('discussion.near', { id: discussion.id(), slug: discussion.slug(), near: post.number() }));
- app.alerts.dismiss(alert);
- }
- });
- app.alerts.show(
- alert = new Alert({
- type: 'success',
- message: 'Your reply was posted.',
- controls: [viewButton]
- })
- );
- }
-
- app.composer.hide();
- }, errors => {
- this.loading(false);
- m.redraw();
- app.handleApiErrors(errors);
- });
- }
-}
diff --git a/js/forum/src/components/reply-placeholder.js b/js/forum/src/components/reply-placeholder.js
deleted file mode 100644
index 52dfed9b3..000000000
--- a/js/forum/src/components/reply-placeholder.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Component from 'flarum/component';
-import avatar from 'flarum/helpers/avatar';
-
-export default class ReplyPlaceholder extends Component {
- view() {
- return m('article.post.reply-post', {
- onclick: () => this.props.discussion.replyAction(true),
- onmousedown: (e) => {
- $(e.target).trigger('click');
- e.preventDefault();
- }
- }, [
- m('header.post-header', avatar(app.session.user()), ' Write a Reply...'),
- ]);
- }
-}
diff --git a/js/forum/src/components/search-box.js b/js/forum/src/components/search-box.js
deleted file mode 100644
index 395431955..000000000
--- a/js/forum/src/components/search-box.js
+++ /dev/null
@@ -1,229 +0,0 @@
-import Component from 'flarum/component';
-import DiscussionPage from 'flarum/components/discussion-page';
-import IndexPage from 'flarum/components/index-page';
-import ActionButton from 'flarum/components/action-button';
-import LoadingIndicator from 'flarum/components/loading-indicator';
-import ItemList from 'flarum/utils/item-list';
-import classList from 'flarum/utils/class-list';
-import listItems from 'flarum/helpers/list-items';
-import icon from 'flarum/helpers/icon';
-import DiscussionsSearchResults from 'flarum/components/discussions-search-results';
-import UsersSearchResults from 'flarum/components/users-search-results';
-
-/**
- * A search box, which 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 SearchBox extends Component {
- constructor(props) {
- super(props);
-
- this.value = m.prop();
- this.hasFocus = m.prop(false);
-
- this.sources = this.sourceItems().toArray();
- this.loadingSources = 0;
- this.searched = [];
-
- /**
- * The index of the currently-selected
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).
- */
- this.index = m.prop(0);
- }
-
- getCurrentSearch() {
- return app.current && typeof app.current.searching === 'function' && app.current.searching();
- }
-
- view() {
- // Initialize value in the view rather than the constructor so that we have
- // access to app.current.
- if (typeof this.value() === 'undefined') {
- this.value(this.getCurrentSearch() || '');
- }
-
- var currentSearch = this.getCurrentSearch();
-
- return m('div.search-box.dropdown', {
- config: this.onload.bind(this),
- className: classList({
- open: this.value() && this.hasFocus(),
- active: !!currentSearch,
- loading: !!this.loadingSources,
- })
- },
- m('div.search-input',
- m('input.form-control', {
- placeholder: 'Search Forum',
- value: this.value(),
- oninput: m.withAttr('value', this.value),
- onfocus: () => this.hasFocus(true),
- onblur: () => this.hasFocus(false)
- }),
- this.loadingSources
- ? LoadingIndicator.component({size: 'tiny', className: 'btn btn-icon btn-link'})
- : currentSearch
- ? m('button.clear.btn.btn-icon.btn-link', {onclick: this.clear.bind(this)}, icon('times-circle'))
- : ''
- ),
- m('ul.dropdown-menu.dropdown-menu-right.search-results', this.sources.map(source => source.view(this.value())))
- );
- }
-
- onload(element, isInitialized, context) {
- this.element(element);
-
- // Highlight the item that is currently selected.
- this.setIndex(this.getCurrentNumericIndex());
-
- if (isInitialized) return;
-
- var self = 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(e) {
- self.setIndex(
- self.selectableItems().index(this)
- );
- });
-
- // Handle navigation key events on the search input.
- this.$('input')
- .on('keydown', e => {
- switch (e.which) {
- case 40: case 38: // Down/Up
- this.setIndex(this.getCurrentNumericIndex() + (e.which === 40 ? 1 : -1), true);
- e.preventDefault();
- break;
-
- case 13: // Return
- this.$('input').blur();
- m.route(this.getItem(this.index()).find('a').attr('href'));
- app.drawer.hide();
- break;
-
- case 27: // Escape
- this.clear();
- break;
- }
- })
-
- // Handle input key events on the search input, triggering results to
- // load.
- .on('input focus', function(e) {
- var value = this.value.toLowerCase();
-
- if (value) {
- clearTimeout(self.searchTimeout);
- self.searchTimeout = setTimeout(() => {
- if (self.searched.indexOf(value) === -1) {
- if (value.length >= 3) {
- self.sources.map(source => {
- if (source.search) {
- self.loadingSources++;
- source.search(value).then(() => {
- self.loadingSources--;
- m.redraw();
- });
- }
- });
- }
- self.searched.push(value);
- m.redraw();
- }
- }, 500);
- }
- });
- }
-
- clear() {
- this.value('');
- if (this.getCurrentSearch()) {
- app.current.clearSearch();
- } else {
- m.redraw();
- }
- }
-
- sourceItems() {
- var items = new ItemList();
-
- items.add('discussions', new DiscussionsSearchResults());
- items.add('users', new UsersSearchResults());
-
- return items;
- }
-
- selectableItems() {
- return this.$('.search-results > li:not(.dropdown-header)');
- }
-
- getCurrentNumericIndex() {
- return this.selectableItems().index(
- this.getItem(this.index())
- );
- }
-
- /**
- * Get the
in the search results with the given index (numeric or named).
- *
- * @param {String} index
- * @return {DOMElement}
- */
- getItem(index) {
- var $items = this.selectableItems();
- var $item = $items.filter('[data-index='+index+']');
-
- if (!$item.length) {
- $item = $items.eq(index);
- }
-
- return $item;
- }
-
- setIndex(index, scrollToItem) {
- var $items = this.selectableItems();
- var $dropdown = $items.parent();
-
- if (index < 0) {
- index = $items.length - 1;
- } else if (index >= $items.length) {
- index = 0;
- }
-
- var $item = $items.removeClass('active').eq(index).addClass('active');
-
- this.index($item.attr('data-index') || index);
-
- if (scrollToItem) {
- var dropdownScroll = $dropdown.scrollTop();
- var dropdownTop = $dropdown.offset().top;
- var dropdownBottom = dropdownTop + $dropdown.outerHeight();
- var itemTop = $item.offset().top;
- var itemBottom = itemTop + $item.outerHeight();
-
- var scrollTop;
- if (itemTop < dropdownTop) {
- scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'));
- } else if (itemBottom > dropdownBottom) {
- scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'));
- }
-
- if (typeof scrollTop !== 'undefined') {
- $dropdown.stop(true).animate({scrollTop}, 100);
- }
- }
- }
-}
diff --git a/js/forum/src/components/settings-page.js b/js/forum/src/components/settings-page.js
deleted file mode 100644
index 14a5a2ece..000000000
--- a/js/forum/src/components/settings-page.js
+++ /dev/null
@@ -1,135 +0,0 @@
-import UserPage from 'flarum/components/user-page';
-import ItemList from 'flarum/utils/item-list';
-import SwitchInput from 'flarum/components/switch-input';
-import ActionButton from 'flarum/components/action-button';
-import FieldSet from 'flarum/components/field-set';
-import NotificationGrid from 'flarum/components/notification-grid';
-import ChangePasswordModal from 'flarum/components/change-password-modal';
-import ChangeEmailModal from 'flarum/components/change-email-modal';
-import DeleteAccountModal from 'flarum/components/delete-account-modal';
-import listItems from 'flarum/helpers/list-items';
-import icon from 'flarum/helpers/icon';
-
-export default class SettingsPage extends UserPage {
- /**
-
- */
- constructor(props) {
- super(props);
-
- this.setupUser(app.session.user());
- app.setTitle('Settings');
- app.drawer.hide();
- }
-
- content() {
- return m('div.settings', [
- m('ul', listItems(this.settingsItems().toArray()))
- ]);
- }
-
- settingsItems() {
- var items = new ItemList();
-
- items.add('account',
- FieldSet.component({
- label: 'Account',
- className: 'settings-account',
- fields: this.accountItems().toArray()
- })
- );
-
- items.add('notifications',
- FieldSet.component({
- label: 'Notifications',
- className: 'settings-account',
- fields: [NotificationGrid.component({
- types: this.notificationTypes().toArray(),
- user: this.user()
- })]
- })
- );
-
- items.add('privacy',
- FieldSet.component({
- label: 'Privacy',
- fields: this.privacyItems().toArray()
- })
- );
-
- return items;
- }
-
- accountItems() {
- var items = new ItemList();
-
- items.add('changePassword',
- ActionButton.component({
- label: 'Change Password',
- className: 'btn btn-default',
- onclick: () => app.modal.show(new ChangePasswordModal())
- })
- );
-
- items.add('changeEmail',
- ActionButton.component({
- label: 'Change Email',
- className: 'btn btn-default',
- onclick: () => app.modal.show(new ChangeEmailModal())
- })
- );
-
- items.add('deleteAccount',
- ActionButton.component({
- label: 'Delete Account',
- className: 'btn btn-default btn-danger',
- onclick: () => app.modal.show(new DeleteAccountModal())
- })
- );
-
- return items;
- }
-
- save(key) {
- return (value, control) => {
- var preferences = this.user().preferences();
- preferences[key] = value;
-
- control.loading(true);
- m.redraw();
-
- this.user().save({preferences}).then(() => {
- control.loading(false);
- m.redraw();
- });
- };
- }
-
- privacyItems() {
- var items = new ItemList();
-
- items.add('discloseOnline',
- SwitchInput.component({
- label: 'Allow others to see when I am online',
- state: this.user().preferences().discloseOnline,
- onchange: (value, component) => {
- this.user().pushAttributes({lastSeenTime: null});
- this.save('discloseOnline')(value, component);
- }
- })
- );
-
- return items;
- }
-
- notificationTypes() {
- var items = new ItemList();
-
- items.add('discussionRenamed', {
- name: 'discussionRenamed',
- label: [icon('pencil'), ' Someone renames a discussion I started']
- });
-
- return items;
- }
-}
diff --git a/js/forum/src/components/signup-modal.js b/js/forum/src/components/signup-modal.js
deleted file mode 100644
index 4884462a4..000000000
--- a/js/forum/src/components/signup-modal.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import FormModal from 'flarum/components/form-modal';
-import LoadingIndicator from 'flarum/components/loading-indicator';
-import LoginModal from 'flarum/components/login-modal';
-import Alert from 'flarum/components/alert';
-import icon from 'flarum/helpers/icon';
-import avatar from 'flarum/helpers/avatar';
-
-export default class SignupModal extends FormModal {
- constructor(props) {
- super(props);
-
- this.username = m.prop(this.props.username || '');
- this.email = m.prop(this.props.email || '');
- this.password = m.prop(this.props.password || '');
- this.welcomeUser = m.prop();
- }
-
- view() {
- var welcomeUser = this.welcomeUser();
- var emailProviderName = welcomeUser && welcomeUser.email().split('@')[1];
-
- var vdom = super.view({
- className: 'modal-sm signup-modal'+(welcomeUser ? ' signup-modal-success' : ''),
- title: 'Sign Up',
- body: m('div.form-centered', [
- m('div.form-group', [
- m('input.form-control[name=username][placeholder=Username]', {value: this.username(), onchange: m.withAttr('value', this.username), disabled: this.loading()})
- ]),
- m('div.form-group', [
- m('input.form-control[name=email][placeholder=Email]', {value: this.email(), onchange: m.withAttr('value', this.email), disabled: this.loading()})
- ]),
- m('div.form-group', [
- m('input.form-control[type=password][name=password][placeholder=Password]', {value: this.password(), onchange: m.withAttr('value', this.password), disabled: this.loading()})
- ]),
- m('div.form-group', [
- m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Sign Up')
- ])
- ]),
- footer: [
- m('p.log-in-link', [
- 'Already have an account? ',
- m('a[href=javascript:;]', {onclick: () => app.modal.show(new LoginModal({email: this.email() || this.username(), password: this.password()}))}, 'Log In')
- ])
- ]
- });
-
- if (welcomeUser) {
- vdom.children.push(
- m('div.signup-welcome', {style: 'background: '+this.welcomeUser().color(), config: this.fadeIn}, [
- m('div.darken-overlay'),
- m('div.container', [
- avatar(welcomeUser),
- m('h3', 'Welcome, '+welcomeUser.username()+'!'),
- !welcomeUser.isConfirmed()
- ? [
- m('p', ['We\'ve sent a confirmation email to ', m('strong', welcomeUser.email()), '. If it doesn\'t arrive soon, check your spam folder.']),
- m('p', m('a.btn.btn-default', {href: 'http://'+emailProviderName}, 'Go to '+emailProviderName))
- ]
- : [
- m('p', m('a.btn.btn-default', {onclick: this.hide.bind(this)}, 'Dismiss'))
- ]
- ])
- ])
- )
- }
-
- return vdom;
- }
-
- ready() {
- if (this.props.username) {
- this.$('[name=email]').select();
- } else {
- super.ready();
- }
- }
-
- fadeIn(element, isInitialized) {
- if (isInitialized) { return; }
- $(element).hide().fadeIn();
- }
-
- onsubmit(e) {
- e.preventDefault();
- this.loading(true);
-
- app.store.createRecord('users').save({
- username: this.username(),
- email: this.email(),
- password: this.password()
- }).then(user => {
- this.welcomeUser(user);
- this.loading(false);
- m.redraw();
- }, response => {
- this.loading(false);
- this.handleErrors(response.errors);
- });
- }
-}
diff --git a/js/forum/src/components/terminal-post.js b/js/forum/src/components/terminal-post.js
deleted file mode 100644
index fdabecd6d..000000000
--- a/js/forum/src/components/terminal-post.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import Component from 'flarum/component';
-import humanTime from 'flarum/helpers/human-time';
-import username from 'flarum/helpers/username';
-
-/**
- Displays information about a the first or last post in a discussion.
-
- @prop discussion {Discussion} The discussion to display the post for
- @prop lastPost {Boolean} Whether or not to display the last/start post
- @class TerminalPost
- @constructor
- @extends Component
- */
-export default class TerminalPost extends Component {
- view() {
- var discussion = this.props.discussion;
- var lastPost = this.props.lastPost && discussion.repliesCount();
-
- var user = discussion[lastPost ? 'lastUser' : 'startUser']();
- var time = discussion[lastPost ? 'lastTime' : 'startTime']();
-
- return m('span', [
- username(user),
- lastPost ? ' replied ' : ' started ',
- humanTime(time)
- ])
- }
-}
diff --git a/js/forum/src/components/user-bio.js b/js/forum/src/components/user-bio.js
deleted file mode 100644
index c72246c14..000000000
--- a/js/forum/src/components/user-bio.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import Component from 'flarum/component';
-import humanTime from 'flarum/utils/human-time';
-import ItemList from 'flarum/utils/item-list';
-import classList from 'flarum/utils/class-list';
-import avatar from 'flarum/helpers/avatar';
-import username from 'flarum/helpers/username';
-import icon from 'flarum/helpers/icon';
-import DropdownButton from 'flarum/components/dropdown-button';
-import ActionButton from 'flarum/components/action-button';
-import listItems from 'flarum/helpers/list-items';
-
-export default class UserBio extends Component {
- constructor(props) {
- super(props);
-
- this.editing = m.prop(false);
- this.loading = m.prop(false);
- }
-
- view() {
- var user = this.props.user;
-
- return m('div.user-bio', {
- className: classList({editable: this.isEditable(), editing: this.editing()}),
- onclick: this.edit.bind(this)
- }, [
- this.editing()
- ? m('textarea.form-control', {
- value: user.bio(),
- placeholder: 'Write something about yourself...',
- rows: 3
- })
- : m('div.bio-content', this.loading()
- ? m('p.placeholder', 'Saving...')
- : [
- user.bioHtml()
- ? m.trust(user.bioHtml())
- : (this.props.editable ? m('p.placeholder', 'Write something about yourself...') : '')
- ]
- )
- ]);
- }
-
- isEditable() {
- return this.props.user.canEdit() && this.props.editable;
- }
-
- edit() {
- if (!this.isEditable()) { return; }
-
- this.editing(true);
-
- m.redraw();
-
- var self = this;
- var save = function(e) {
- if (e.shiftKey) { return; }
- e.preventDefault();
- self.save($(this).val());
- };
- this.$('textarea').focus().bind('blur', save).bind('keydown', 'return', save);
- }
-
- save(value) {
- this.editing(false);
-
- if (this.props.user.bio() !== value) {
- this.loading(true);
- this.props.user.save({bio: value}).then(() => {
- this.loading(false);
- m.redraw();
- });
- }
-
- m.redraw();
- }
-}
diff --git a/js/forum/src/components/user-card.js b/js/forum/src/components/user-card.js
deleted file mode 100644
index c7bdb9983..000000000
--- a/js/forum/src/components/user-card.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import Component from 'flarum/component';
-import humanTime from 'flarum/utils/human-time';
-import ItemList from 'flarum/utils/item-list';
-import avatar from 'flarum/helpers/avatar';
-import username from 'flarum/helpers/username';
-import icon from 'flarum/helpers/icon';
-import DropdownButton from 'flarum/components/dropdown-button';
-import ActionButton from 'flarum/components/action-button';
-import UserBio from 'flarum/components/user-bio';
-import AvatarEditor from 'flarum/components/avatar-editor';
-import listItems from 'flarum/helpers/list-items';
-
-export default class UserCard extends Component {
- view() {
- var user = this.props.user;
- var controls = this.controlItems().toArray();
-
- return m('div.user-card', {className: this.props.className, style: 'background-color: '+user.color()}, [
- m('div.darken-overlay'),
- m('div.container', [
- controls.length ? DropdownButton.component({
- items: controls,
- className: 'contextual-controls',
- menuClass: 'pull-right',
- buttonClass: this.props.controlsButtonClass
- }) : '',
- m('div.user-profile', [
- m('h2.user-identity', this.props.editable
- ? [AvatarEditor.component({user, className: 'user-avatar'}), username(user)]
- : m('a', {href: app.route('user', {username: user.username()}), config: m.route}, [
- avatar(user, {className: 'user-avatar'}),
- username(user)
- ])
- ),
- m('ul.user-badges.badges', listItems(user.badges().toArray())),
- m('ul.user-info', listItems(this.infoItems().toArray()))
- ])
- ])
- ]);
- }
-
- controlItems() {
- var items = new ItemList();
-
- return items;
- }
-
- infoItems() {
- var items = new ItemList();
- var user = this.props.user;
- var online = user.online();
-
- items.add('bio',
- UserBio.component({
- user,
- editable: this.props.editable,
- wrapperClass: 'block-item'
- })
- );
-
- if (user.lastSeenTime()) {
- items.add('lastSeen',
- m('span.user-last-seen', {className: online ? 'online' : ''}, online
- ? [icon('circle'), ' Online']
- : [icon('clock-o'), ' ', humanTime(user.lastSeenTime())])
- );
- }
-
- items.add('joined', ['Joined ', humanTime(user.joinTime())]);
-
- return items;
- }
-}
diff --git a/js/forum/src/components/user-dropdown.js b/js/forum/src/components/user-dropdown.js
deleted file mode 100644
index 970a404d8..000000000
--- a/js/forum/src/components/user-dropdown.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import Component from 'flarum/component';
-import avatar from 'flarum/helpers/avatar';
-import username from 'flarum/helpers/username';
-import DropdownButton from 'flarum/components/dropdown-button';
-import ActionButton from 'flarum/components/action-button';
-import ItemList from 'flarum/utils/item-list';
-import Separator from 'flarum/components/separator';
-import Group from 'flarum/models/group';
-
-export default class UserDropdown extends Component {
- view() {
- var user = this.props.user;
-
- return DropdownButton.component({
- buttonClass: 'btn btn-default btn-naked btn-rounded btn-user',
- menuClass: 'pull-right',
- buttonContent: [avatar(user), ' ', m('span.label', username(user))],
- items: this.items().toArray()
- });
- }
-
- items() {
- var items = new ItemList();
- var user = this.props.user;
-
- items.add('profile',
- ActionButton.component({
- icon: 'user',
- label: 'Profile',
- href: app.route('user', {username: user.username()}),
- config: m.route
- })
- );
-
- items.add('settings',
- ActionButton.component({
- icon: 'cog',
- label: 'Settings',
- href: app.route('settings'),
- config: m.route
- })
- );
-
- if (user.groups().some((group) => Number(group.id()) === Group.ADMINISTRATOR_ID)) {
- items.add('administration',
- ActionButton.component({
- icon: 'wrench',
- label: 'Administration',
- href: app.forum.attribute('baseUrl') + '/admin',
- target: '_blank'
- })
- );
- }
-
- items.add('separator', Separator.component());
-
- items.add('logOut',
- ActionButton.component({
- icon: 'sign-out',
- label: 'Log Out',
- onclick: app.session.logout.bind(app.session)
- })
- );
-
- return items;
- }
-}
diff --git a/js/forum/src/components/user-notifications.js b/js/forum/src/components/user-notifications.js
deleted file mode 100644
index cd2e4fb12..000000000
--- a/js/forum/src/components/user-notifications.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import Component from 'flarum/component';
-import icon from 'flarum/helpers/icon';
-import DropdownButton from 'flarum/components/dropdown-button';
-import NotificationList from 'flarum/components/notification-list';
-
-export default class UserNotifications extends Component {
- constructor(props) {
- super(props);
-
- this.showing = m.prop(false);
- }
-
- view() {
- var user = this.props.user;
-
- return DropdownButton.component({
- className: 'notifications',
- buttonClass: 'btn btn-default btn-rounded btn-naked btn-icon'+(user.unreadNotificationsCount() ? ' unread' : ''),
- menuClass: 'pull-right',
- buttonContent: [
- m('span.notifications-icon', user.unreadNotificationsCount() || icon('bell icon-glyph')),
- m('span.label', 'Notifications')
- ],
- buttonClick: (e) => {
- if ($('body').hasClass('drawer-open')) {
- m.route(app.route('notifications'));
- } else {
- this.showing(true);
- }
- },
- menuContent: this.showing() ? NotificationList.component() : []
- });
- }
-}
diff --git a/js/forum/src/components/user-page.js b/js/forum/src/components/user-page.js
deleted file mode 100644
index 38d860679..000000000
--- a/js/forum/src/components/user-page.js
+++ /dev/null
@@ -1,149 +0,0 @@
-import Component from 'flarum/component';
-import ItemList from 'flarum/utils/item-list';
-import IndexPage from 'flarum/components/index-page';
-import DiscussionList from 'flarum/components/discussion-list';
-import UserCard from 'flarum/components/user-card';
-import ReplyComposer from 'flarum/components/reply-composer';
-import ActionButton from 'flarum/components/action-button';
-import LoadingIndicator from 'flarum/components/loading-indicator';
-import DropdownSplit from 'flarum/components/dropdown-split';
-import DropdownSelect from 'flarum/components/dropdown-select';
-import NavItem from 'flarum/components/nav-item';
-import Separator from 'flarum/components/separator';
-import listItems from 'flarum/helpers/list-items';
-
-export default class UserPage extends Component {
- /**
-
- */
- constructor(props) {
- super(props);
-
- this.user = m.prop();
-
- app.history.push('user');
- app.current = this;
- app.drawer.hide();
- }
-
- /*
-
- */
- setupUser(user) {
- this.user(user);
-
- app.setTitle(user.username());
- }
-
- onload(element, isInitialized, context) {
- if (isInitialized) { return; }
-
- $('body').addClass('user-page');
- context.onunload = function() {
- $('body').removeClass('user-page');
- }
- }
-
- /**
-
- */
- view() {
- var user = this.user();
-
- return m('div', {config: this.onload.bind(this)}, user ? [
- UserCard.component({user, className: 'hero user-hero', editable: user.canEdit(), controlsButtonClass: 'btn btn-default'}),
- m('div.container', [
- m('nav.side-nav.user-nav', {config: this.affixSidebar}, [
- m('ul', listItems(this.sidebarItems().toArray()))
- ]),
- m('div.offset-content.user-content', this.content())
- ])
- ] : LoadingIndicator.component({className: 'loading-indicator-block'}));
- }
-
- /**
-
- */
- sidebarItems() {
- var items = new ItemList();
-
- items.add('nav',
- DropdownSelect.component({
- items: this.navItems().toArray(),
- wrapperClass: 'title-control'
- })
- );
-
- 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() {
- var items = new ItemList();
- var user = this.user();
-
- items.add('activity',
- NavItem.component({
- href: app.route('user.activity', {username: user.username()}),
- label: 'Activity',
- icon: 'user'
- })
- );
-
- items.add('discussions',
- NavItem.component({
- href: app.route('user.discussions', {username: user.username()}),
- label: 'Discussions',
- icon: 'reorder',
- badge: user.discussionsCount()
- })
- );
-
- items.add('posts',
- NavItem.component({
- href: app.route('user.posts', {username: user.username()}),
- label: 'Posts',
- icon: 'comment-o',
- badge: user.commentsCount()
- })
- );
-
- if (app.session.user() === user) {
- items.add('separator', Separator.component());
- items.add('settings',
- NavItem.component({
- href: app.route('settings'),
- label: 'Settings',
- icon: 'cog'
- })
- );
- }
-
- return items;
- }
-
- /**
- Setup the sidebar DOM element to be affixed to the top of the viewport
- using Bootstrap's affix plugin.
-
- @param {DOMElement} element
- @param {Boolean} isInitialized
- @return {void}
- */
- affixSidebar(element, isInitialized, context) {
- if (isInitialized) { return; }
-
- var $sidebar = $(element);
- $sidebar.find('> ul').affix({
- offset: {
- top: $sidebar.offset().top - $('.global-header').outerHeight(true) - parseInt($sidebar.css('margin-top')),
- bottom: $('.global-footer').outerHeight(true)
- }
- });
- }
-}
diff --git a/js/forum/src/components/users-search-results.js b/js/forum/src/components/users-search-results.js
deleted file mode 100644
index 2cc38504a..000000000
--- a/js/forum/src/components/users-search-results.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import highlight from 'flarum/helpers/highlight';
-import avatar from 'flarum/helpers/avatar';
-
-export default class UsersSearchResults {
- search(string) {
- return app.store.find('users', {filter: {q: string}, page: {limit: 5}});
- }
-
- view(string) {
- var results = app.store.all('users').filter(user => user.username().toLowerCase().substr(0, string.length) === string);
-
- return results.length ? [
- m('li.dropdown-header', 'Users'),
- results.map(user => m('li.user-search-result', {'data-index': 'users'+user.id()},
- m('a', {
- href: app.route.user(user),
- config: m.route
- }, avatar(user), highlight(user.username(), string))
- ))
- ] : '';
- }
-}
diff --git a/js/forum/src/components/welcome-hero.js b/js/forum/src/components/welcome-hero.js
deleted file mode 100644
index 7bf290d82..000000000
--- a/js/forum/src/components/welcome-hero.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import Component from 'flarum/component';
-
-export default class WelcomeHero extends Component {
- constructor(props) {
- super(props);
-
- this.hidden = m.prop(localStorage.getItem('welcomeHidden'));
- }
-
- hide() {
- localStorage.setItem('welcomeHidden', 'true');
- this.hidden(true);
- }
-
- view() {
- return this.hidden() ? m('') : m('header.hero.welcome-hero', {config: this.element}, [
- m('div.container', [
- m('button.close.btn.btn-icon.btn-link', {onclick: () => this.$().slideUp(this.hide.bind(this))}, m('i.fa.fa-times')),
- m('div.container-narrow', [
- m('h2', app.forum.attribute('welcomeTitle')),
- m('div.subtitle', m.trust(app.forum.attribute('welcomeMessage')))
- ])
- ])
- ])
- }
-}
diff --git a/js/forum/src/initializers/boot.js b/js/forum/src/initializers/boot.js
index cbfed3123..e63c964e0 100644
--- a/js/forum/src/initializers/boot.js
+++ b/js/forum/src/initializers/boot.js
@@ -1,65 +1,61 @@
-import ScrollListener from 'flarum/utils/scroll-listener';
-import History from 'flarum/utils/history';
-import Pane from 'flarum/utils/pane';
-import Drawer from 'flarum/utils/drawer';
-import mapRoutes from 'flarum/utils/map-routes';
+/*global FastClick*/
-import BackButton from 'flarum/components/back-button';
-import HeaderPrimary from 'flarum/components/header-primary';
-import HeaderSecondary from 'flarum/components/header-secondary';
-import FooterPrimary from 'flarum/components/footer-primary';
-import FooterSecondary from 'flarum/components/footer-secondary';
-import Composer from 'flarum/components/composer';
-import Modal from 'flarum/components/modal';
-import Alerts from 'flarum/components/alerts';
-import SearchBox from 'flarum/components/search-box';
+import ScrollListener from 'flarum/utils/ScrollListener';
+import Pane from 'flarum/utils/Pane';
+import Drawer from 'flarum/utils/Drawer';
+import mapRoutes from 'flarum/utils/mapRoutes';
-export default function(app) {
- var id = id => document.getElementById(id);
-
- app.history = new History();
- app.pane = new Pane(id('page'));
- app.search = new SearchBox();
- app.drawer = new Drawer();
- app.cache = {};
+import Navigation from 'flarum/components/Navigation';
+import HeaderPrimary from 'flarum/components/HeaderPrimary';
+import HeaderSecondary from 'flarum/components/HeaderSecondary';
+import FooterPrimary from 'flarum/components/FooterPrimary';
+import FooterSecondary from 'flarum/components/FooterSecondary';
+import Composer from 'flarum/components/Composer';
+import ModalManager from 'flarum/components/ModalManager';
+import Alerts from 'flarum/components/Alerts';
+/**
+ * The `boot` initializer boots up the forum app. It initializes some app
+ * globals, mounts components to the page, and begins routing.
+ *
+ * @param {ForumApp} app
+ */
+export default function boot(app) {
m.startComputation();
- m.mount(id('back-control'), BackButton.component({ className: 'back-control', drawer: true }));
- m.mount(id('back-button'), BackButton.component());
+ m.mount(document.getElementById('page-navigation'), Navigation.component({className: 'back-control', drawer: true}));
+ m.mount(document.getElementById('header-navigation'), Navigation.component());
+ m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
+ m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
+ m.mount(document.getElementById('footer-primary'), FooterPrimary.component());
+ m.mount(document.getElementById('footer-secondary'), FooterSecondary.component());
- $('.global-content').click(e => {
- if ($('body').hasClass('drawer-open')) {
- e.preventDefault();
- $('body').removeClass('drawer-open');
- }
- });
+ app.pane = new Pane(document.getElementById('page'));
+ app.drawer = new Drawer();
+ app.composer = m.mount(document.getElementById('composer'), Composer.component());
+ app.modal = m.mount(document.getElementById('modal'), ModalManager.component());
+ app.alerts = m.mount(document.getElementById('alerts'), Alerts.component());
+ m.route.mode = 'pathname';
+ m.route(document.getElementById('content'), '/', mapRoutes(app.routes));
+
+ m.endComputation();
+
+ // Route the home link back home when clicked. We do not want it to register
+ // if the user is opening it in a new tab, however.
$('#home-link').click(e => {
if (e.ctrlKey || e.metaKey || e.which === 2) return;
e.preventDefault();
app.history.home();
});
- m.mount(id('header-primary'), HeaderPrimary.component());
- m.mount(id('header-secondary'), HeaderSecondary.component());
- m.mount(id('footer-primary'), FooterPrimary.component());
- m.mount(id('footer-secondary'), FooterSecondary.component());
-
- app.composer = m.mount(id('composer'), Composer.component());
- app.modal = m.mount(id('modal'), Modal.component());
- app.alerts = m.mount(id('alerts'), Alerts.component());
-
- m.route.mode = 'pathname';
- m.route(id('content'), '/', mapRoutes(app.routes));
-
- m.endComputation();
-
+ // Add a class to the body which indicates that the page has been scrolled
+ // down.
new ScrollListener(top => $('body').toggleClass('scrolled', top > 0)).start();
- $(function() {
- FastClick.attach(document.body);
- });
+ // Initialize FastClick, which makes links and buttons much more responsive on
+ // touch devices.
+ $(() => FastClick.attach(document.body));
app.booted = true;
}
diff --git a/js/forum/src/initializers/components.js b/js/forum/src/initializers/components.js
index f71a69909..896de17dd 100644
--- a/js/forum/src/initializers/components.js
+++ b/js/forum/src/initializers/components.js
@@ -1,22 +1,22 @@
-import CommentPost from 'flarum/components/comment-post';
-import DiscussionRenamedPost from 'flarum/components/discussion-renamed-post';
-import PostedActivity from 'flarum/components/posted-activity';
-import JoinedActivity from 'flarum/components/joined-activity';
-import DiscussionRenamedNotification from 'flarum/components/discussion-renamed-notification';
+import CommentPost from 'flarum/components/CommentPost';
+import DiscussionRenamedPost from 'flarum/components/DiscussionRenamedPost';
+import PostedActivity from 'flarum/components/PostedActivity';
+import JoinedActivity from 'flarum/components/JoinedActivity';
+import DiscussionRenamedNotification from 'flarum/components/DiscussionRenamedNotification';
-export default function(app) {
- app.postComponentRegistry = {
- 'comment': CommentPost,
- 'discussionRenamed': DiscussionRenamedPost
- };
+/**
+ * The `components` initializer registers components to display the default post
+ * types, activity types, and notifications type with the application.
+ *
+ * @param {ForumApp} app
+ */
+export default function components(app) {
+ app.postComponents.comment = CommentPost;
+ app.postComponents.discussionRenamed = DiscussionRenamedPost;
- app.activityComponentRegistry = {
- 'posted': PostedActivity,
- 'startedDiscussion': PostedActivity,
- 'joined': JoinedActivity
- };
+ app.activityComponents.posted = PostedActivity;
+ app.activityComponents.startedDiscussion = PostedActivity;
+ app.activityComponents.joined = JoinedActivity;
- app.notificationComponentRegistry = {
- 'discussionRenamed': DiscussionRenamedNotification
- };
+ app.notificationComponents.discussionRenamed = DiscussionRenamedNotification;
}
diff --git a/js/forum/src/initializers/discussion-controls.js b/js/forum/src/initializers/discussion-controls.js
deleted file mode 100644
index e436a9ec3..000000000
--- a/js/forum/src/initializers/discussion-controls.js
+++ /dev/null
@@ -1,120 +0,0 @@
-import Discussion from 'flarum/models/discussion';
-import DiscussionPage from 'flarum/components/discussion-page';
-import ReplyComposer from 'flarum/components/reply-composer';
-import LoginModal from 'flarum/components/login-modal';
-import ActionButton from 'flarum/components/action-button';
-import Separator from 'flarum/components/separator';
-import ItemList from 'flarum/utils/item-list';
-
-export default function(app) {
- Discussion.prototype.replyAction = function(goToLast, forceRefresh) {
- var deferred = m.deferred();
-
- var reply = () => {
- if (this.canReply()) {
- if (goToLast && app.viewingDiscussion(this)) {
- app.current.stream.goToLast();
- }
-
- var component = app.composer.component;
- if (!app.composingReplyTo(this) || forceRefresh) {
- component = new ReplyComposer({
- user: app.session.user(),
- discussion: this
- });
- app.composer.load(component);
- }
- app.composer.show(goToLast);
-
- deferred.resolve(component);
- } else {
- deferred.reject();
- }
- };
-
- if (app.session.user()) {
- reply();
- } else {
- app.modal.show(
- new LoginModal({
- onlogin: () => app.current.one('loaded', reply)
- })
- );
- }
-
- return deferred.promise;
- }
-
- Discussion.prototype.deleteAction = function() {
- if (confirm('Are you sure you want to delete this discussion?')) {
- this.delete();
- if (app.cache.discussionList) {
- app.cache.discussionList.removeDiscussion(this);
- }
- if (app.current instanceof DiscussionPage && app.current.discussion().id() === this.id()) {
- app.history.back();
- }
- }
- };
-
- Discussion.prototype.renameAction = function() {
- const currentTitle = this.title();
- const title = prompt('Enter a new title for this discussion:', currentTitle);
-
- if (title && title !== currentTitle) {
- this.save({title}).then(() => {
- if (app.viewingDiscussion(this)) {
- app.current.stream.update();
- }
- m.redraw();
- });
- }
- };
-
- Discussion.prototype.userControls = function(context) {
- var items = new ItemList();
-
- if (context instanceof DiscussionPage) {
- items.add('reply', !app.session.user() || this.canReply()
- ? ActionButton.component({ icon: 'reply', label: app.session.user() ? 'Reply' : 'Log In to Reply', onclick: this.replyAction.bind(this, true, false) })
- : ActionButton.component({ icon: 'reply', label: 'Can\'t Reply', className: 'disabled', title: 'You don\'t have permission to reply to this discussion.' })
- );
- }
-
- return items;
- };
-
- Discussion.prototype.moderationControls = function(context) {
- var items = new ItemList();
-
- if (this.canRename()) {
- items.add('rename', ActionButton.component({ icon: 'pencil', label: 'Rename', onclick: this.renameAction.bind(this) }));
- }
-
- return items;
- };
-
- Discussion.prototype.destructiveControls = function(context) {
- var items = new ItemList();
-
- if (this.canDelete()) {
- items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete', onclick: this.deleteAction.bind(this) }));
- }
-
- return items;
- };
-
- Discussion.prototype.controls = function(context) {
- var items = new ItemList();
-
- ['user', 'moderation', 'destructive'].forEach(section => {
- var controls = this[section+'Controls'](context).toArray();
- if (controls.length) {
- items.add(section, controls);
- items.add(section+'Separator', Separator.component());
- }
- });
-
- return items;
- }
-};
diff --git a/js/forum/src/initializers/post-controls.js b/js/forum/src/initializers/post-controls.js
deleted file mode 100644
index fcc2b149a..000000000
--- a/js/forum/src/initializers/post-controls.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import Post from 'flarum/models/post';
-import DiscussionPage from 'flarum/components/discussion-page';
-import EditComposer from 'flarum/components/edit-composer';
-import ActionButton from 'flarum/components/action-button';
-import Separator from 'flarum/components/separator';
-import ItemList from 'flarum/utils/item-list';
-
-export default function(app) {
- function editAction() {
- app.composer.load(new EditComposer({ post: this }));
- app.composer.show();
- }
-
- function hideAction() {
- this.save({ isHidden: true });
- this.pushAttributes({ hideTime: new Date(), hideUser: app.session.user() });
- }
-
- function restoreAction() {
- this.save({ isHidden: false });
- this.pushAttributes({ hideTime: null, hideUser: null });
- }
-
- function deleteAction() {
- this.delete();
- this.discussion().removePost(this.id());
- }
-
- Post.prototype.userControls = function(context) {
- return new ItemList();
- };
-
- Post.prototype.moderationControls = function(context) {
- var items = new ItemList();
-
- if (this.contentType() === 'comment' && this.canEdit()) {
- if (this.isHidden()) {
- items.add('restore', ActionButton.component({ icon: 'reply', label: 'Restore', onclick: restoreAction.bind(this) }));
- } else {
- items.add('edit', ActionButton.component({ icon: 'pencil', label: 'Edit', onclick: editAction.bind(this) }));
- }
- }
-
- return items;
- };
-
- Post.prototype.destructiveControls = function(context) {
- var items = new ItemList();
-
- if (this.number() != 1) {
- if (this.contentType() === 'comment' && !this.isHidden() && this.canEdit()) {
- items.add('hide', ActionButton.component({ icon: 'times', label: 'Delete', onclick: hideAction.bind(this) }));
- } else if ((this.contentType() !== 'comment' || this.isHidden()) && this.canDelete()) {
- items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete Forever', onclick: deleteAction.bind(this) }));
- }
- }
-
- return items;
- };
-
- Post.prototype.controls = function(context) {
- var items = new ItemList();
-
- ['user', 'moderation', 'destructive'].forEach(section => {
- var controls = this[section+'Controls'](context).toArray();
- if (controls.length) {
- items.add(section, controls);
- items.add(section+'Separator', Separator.component());
- }
- });
-
- return items;
- }
-};
diff --git a/js/forum/src/initializers/routes.js b/js/forum/src/initializers/routes.js
index b48f708e0..8579cbd48 100644
--- a/js/forum/src/initializers/routes.js
+++ b/js/forum/src/initializers/routes.js
@@ -1,27 +1,39 @@
-import IndexPage from 'flarum/components/index-page';
-import DiscussionPage from 'flarum/components/discussion-page';
-import ActivityPage from 'flarum/components/activity-page';
-import SettingsPage from 'flarum/components/settings-page';
-import NotificationsPage from 'flarum/components/notifications-page';
+import IndexPage from 'flarum/components/IndexPage';
+import DiscussionPage from 'flarum/components/DiscussionPage';
+import ActivityPage from 'flarum/components/ActivityPage';
+import SettingsPage from 'flarum/components/SettingsPage';
+import NotificationsPage from 'flarum/components/NotificationsPage';
+/**
+ * The `routes` initializer defines the forum app's routes.
+ *
+ * @param {App} app
+ */
export default function(app) {
app.routes = {
- 'index': ['/', IndexPage.component()],
- 'index.filter': ['/:filter', IndexPage.component()],
+ 'index': { path: '/', component: IndexPage.component() },
+ 'index.filter': { path: '/:filter', component: IndexPage.component() },
- 'discussion': ['/d/:id/:slug', DiscussionPage.component()],
- 'discussion.near': ['/d/:id/:slug/:near', DiscussionPage.component()],
+ 'discussion': { path: '/d/:id/:slug', component: DiscussionPage.component() },
+ 'discussion.near': { path: '/d/:id/:slug/:near', component: DiscussionPage.component() },
- 'user': ['/u/:username', ActivityPage.component()],
- 'user.activity': ['/u/:username', ActivityPage.component()],
- 'user.discussions': ['/u/:username/discussions', ActivityPage.component({filter: 'startedDiscussion'})],
- 'user.posts': ['/u/:username/posts', ActivityPage.component({filter: 'posted'})],
+ 'user': { path: '/u/:username', component: ActivityPage.component() },
+ 'user.activity': { path: '/u/:username', component: ActivityPage.component() },
+ 'user.discussions': { path: '/u/:username/discussions', component: ActivityPage.component({filter: 'startedDiscussion'}) },
+ 'user.posts': { path: '/u/:username/posts', component: ActivityPage.component({filter: 'posted'}) },
- 'settings': ['/settings', SettingsPage.component()],
- 'notifications': ['/notifications', NotificationsPage.component()]
+ 'settings': { path: '/settings', component: SettingsPage.component() },
+ 'notifications': { path: '/notifications', component: NotificationsPage.component() }
};
- app.route.discussion = function(discussion, near) {
+ /**
+ * Generate a URL to a discussion.
+ *
+ * @param {Discussion} discussion
+ * @param {Integer} [near]
+ * @return {String}
+ */
+ app.route.discussion = (discussion, near) => {
return app.route(near ? 'discussion.near' : 'discussion', {
id: discussion.id(),
slug: discussion.slug(),
@@ -29,7 +41,13 @@ export default function(app) {
});
};
- app.route.post = function(post) {
+ /**
+ * Generate a URL to a post.
+ *
+ * @param {Post} post
+ * @return {String}
+ */
+ app.route.post = post => {
return app.route('discussion.near', {
id: post.discussion().id(),
slug: post.discussion().slug(),
@@ -37,7 +55,13 @@ export default function(app) {
});
};
- app.route.user = function(user) {
+ /**
+ * Generate a URL to a user.
+ *
+ * @param {User} user
+ * @return {String}
+ */
+ app.route.user = user => {
return app.route('user', {
username: user.username()
});
diff --git a/js/forum/src/initializers/state-helpers.js b/js/forum/src/initializers/state-helpers.js
deleted file mode 100644
index 643b86a06..000000000
--- a/js/forum/src/initializers/state-helpers.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Composer from 'flarum/components/composer';
-import ReplyComposer from 'flarum/components/reply-composer';
-import DiscussionPage from 'flarum/components/discussion-page';
-
-export default function(app) {
- app.composingReplyTo = function(discussion) {
- return this.composer.component instanceof ReplyComposer &&
- this.composer.component.props.discussion === discussion &&
- this.composer.position() !== Composer.PositionEnum.HIDDEN;
- };
-
- app.viewingDiscussion = function(discussion) {
- return this.current instanceof DiscussionPage &&
- this.current.discussion() === discussion;
- };
-};
diff --git a/js/forum/src/utils/DiscussionControls.js b/js/forum/src/utils/DiscussionControls.js
new file mode 100644
index 000000000..c21e02dd2
--- /dev/null
+++ b/js/forum/src/utils/DiscussionControls.js
@@ -0,0 +1,214 @@
+import DiscussionPage from 'flarum/components/DiscussionPage';
+import ReplyComposer from 'flarum/components/ReplyComposer';
+import LogInModal from 'flarum/components/LogInModal';
+import Button from 'flarum/components/Button';
+import Separator from 'flarum/components/Separator';
+import ItemList from 'flarum/utils/ItemList';
+
+/**
+ * The `DiscussionControls` utility constructs a list of buttons for a
+ * discussion which perform actions on it.
+ */
+export default {
+ /**
+ * Get a list of controls for a discussion.
+ *
+ * @param {Discussion} discussion
+ * @param {*} context The parent component under which the controls menu will
+ * be displayed.
+ * @return {ItemList}
+ * @public
+ */
+ controls(discussion, context) {
+ const items = new ItemList();
+
+ ['user', 'moderation', 'destructive'].forEach(section => {
+ const controls = this[section + 'Controls'](discussion, context).toArray();
+ if (controls.length) {
+ items.add(section, controls);
+ items.add(section + 'Separator', Separator.component());
+ }
+ });
+
+ return items;
+ },
+
+ /**
+ * Get controls for a discussion pertaining to the current user (e.g. reply,
+ * follow).
+ *
+ * @param {Discussion} discussion
+ * @param {*} context The parent component under which the controls menu will
+ * be displayed.
+ * @return {ItemList}
+ * @protected
+ */
+ userControls(discussion, context) {
+ const items = new ItemList();
+
+ // Only add a reply control if this is the discussion's controls dropdown
+ // for the discussion page itself. We don't want it to show up for
+ // discussions in the discussion list, etc.
+ if (context instanceof DiscussionPage) {
+ items.add('reply',
+ !app.session.user || discussion.canReply()
+ ? Button.component({
+ icon: 'reply',
+ children: app.session.user ? 'Reply' : 'Log In to Reply',
+ onclick: this.replyAction.bind(discussion, true, false)
+ })
+ : Button.component({
+ icon: 'reply',
+ children: 'Can\'t Reply',
+ className: 'disabled',
+ title: 'You don\'t have permission to reply to this discussion.'
+ })
+ );
+ }
+
+ return items;
+ },
+
+ /**
+ * Get controls for a discussion pertaining to moderation (e.g. rename, lock).
+ *
+ * @param {Discussion} discussion
+ * @param {*} context The parent component under which the controls menu will
+ * be displayed.
+ * @return {ItemList}
+ * @protected
+ */
+ moderationControls(discussion) {
+ const items = new ItemList();
+
+ if (discussion.canRename()) {
+ items.add('rename', Button.component({
+ icon: 'pencil',
+ children: 'Rename',
+ onclick: this.renameAction.bind(discussion)
+ }));
+ }
+
+ return items;
+ },
+
+ /**
+ * Get controls for a discussion which are destructive (e.g. delete).
+ *
+ * @param {Discussion} discussion
+ * @param {*} context The parent component under which the controls menu will
+ * be displayed.
+ * @return {ItemList}
+ * @protected
+ */
+ destructiveControls(discussion) {
+ const items = new ItemList();
+
+ if (discussion.canDelete()) {
+ items.add('delete', Button.component({
+ icon: 'times',
+ children: 'Delete',
+ onclick: this.deleteAction.bind(discussion)
+ }));
+ }
+
+ return items;
+ },
+
+ /**
+ * Open the reply composer for the discussion. A promise will be returned,
+ * which resolves when the composer opens successfully. If the user is not
+ * logged in, they will be prompted and then the reply composer will open (and
+ * the promise will resolve) after they do. If they don't have permission to
+ * reply, the promise will be rejected.
+ *
+ * @param {Boolean} goToLast Whether or not to scroll down to the last post if
+ * the discussion is being viewed.
+ * @param {Boolean} forceRefresh Whether or not to force a reload of the
+ * composer component, even if it is already open for this discussion.
+ * @return {Promise}
+ */
+ replyAction(goToLast, forceRefresh) {
+ const deferred = m.deferred();
+
+ // Define a function that will check the user's permission to reply, and
+ // either open the reply composer for this discussion and resolve the
+ // promise, or reject it.
+ const reply = () => {
+ if (this.canReply()) {
+ if (goToLast && app.viewingDiscussion(this)) {
+ app.current.stream.goToLast();
+ }
+
+ let component = app.composer.component;
+ if (!app.composingReplyTo(this) || forceRefresh) {
+ component = new ReplyComposer({
+ user: app.session.user,
+ discussion: this
+ });
+ app.composer.load(component);
+ }
+ app.composer.show();
+
+ deferred.resolve(component);
+ } else {
+ deferred.reject();
+ }
+ };
+
+ // If the user is logged in, then we can run that function right away. But
+ // if they're not, we'll prompt them to log in and then run the function
+ // after the discussion has reloaded.
+ if (app.session.user) {
+ reply();
+ } else {
+ app.modal.show(
+ new LogInModal({
+ onlogin: () => app.current.one('loaded', reply)
+ })
+ );
+ }
+
+ return deferred.promise;
+ },
+
+ /**
+ * Delete the discussion after confirming with the user.
+ */
+ deleteAction() {
+ if (confirm('Are you sure you want to delete this discussion?')) {
+ this.delete();
+
+ // If there is a discussion list in the cache, remove this discussion.
+ if (app.cache.discussionList) {
+ app.cache.discussionList.removeDiscussion(this);
+ }
+
+ // If we're currently viewing the discussion that was deleted, go back
+ // to the previous page.
+ if (app.viewingDiscussion(this)) {
+ app.history.back();
+ }
+ }
+ },
+
+ /**
+ * Rename the discussion.
+ */
+ renameAction() {
+ const currentTitle = this.title();
+ const title = prompt('Enter a new title for this discussion:', 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) {
+ this.save({title}).then(() => {
+ if (app.viewingDiscussion(this)) {
+ app.current.stream.update();
+ }
+ m.redraw();
+ });
+ }
+ }
+};
diff --git a/js/forum/src/utils/PostControls.js b/js/forum/src/utils/PostControls.js
new file mode 100644
index 000000000..c9cd1b4f7
--- /dev/null
+++ b/js/forum/src/utils/PostControls.js
@@ -0,0 +1,140 @@
+import EditPostComposer from 'flarum/components/EditPostComposer';
+import Button from 'flarum/components/Button';
+import Separator from 'flarum/components/Separator';
+import ItemList from 'flarum/utils/ItemList';
+
+/**
+ * The `PostControls` utility constructs a list of buttons for a post which
+ * perform actions on it.
+ */
+export default {
+ /**
+ * Get a list of controls for a post.
+ *
+ * @param {Post} post
+ * @param {*} context The parent component under which the controls menu will
+ * be displayed.
+ * @return {ItemList}
+ * @public
+ */
+ controls(post, context) {
+ const items = new ItemList();
+
+ ['user', 'moderation', 'destructive'].forEach(section => {
+ const controls = this[section + 'Controls'](post, context).toArray();
+ if (controls.length) {
+ items.add(section, controls);
+ items.add(section + 'Separator', Separator.component());
+ }
+ });
+
+ return items;
+ },
+
+ /**
+ * Get controls for a post pertaining to the current user (e.g. report).
+ *
+ * @param {Post} post
+ * @param {*} context The parent component under which the controls menu will
+ * be displayed.
+ * @return {ItemList}
+ * @protected
+ */
+ userControls() {
+ return new ItemList();
+ },
+
+ /**
+ * Get controls for a post pertaining to moderation (e.g. edit).
+ *
+ * @param {Post} post
+ * @param {*} context The parent component under which the controls menu will
+ * be displayed.
+ * @return {ItemList}
+ * @protected
+ */
+ moderationControls(post) {
+ const items = new ItemList();
+
+ if (post.contentType() === 'comment' && post.canEdit()) {
+ if (post.isHidden()) {
+ items.add('restore', Button.component({
+ icon: 'reply',
+ children: 'Restore',
+ onclick: this.restoreAction.bind(post)
+ }));
+ } else {
+ items.add('edit', Button.component({
+ icon: 'pencil',
+ children: 'Edit',
+ onclick: this.editAction.bind(post)
+ }));
+ }
+ }
+
+ return items;
+ },
+
+ /**
+ * Get controls for a post that are destructive (e.g. delete).
+ *
+ * @param {Post} post
+ * @param {*} context The parent component under which the controls menu will
+ * be displayed.
+ * @return {ItemList}
+ * @protected
+ */
+ destructiveControls(post) {
+ const items = new ItemList();
+
+ if (post.number() !== 1) {
+ if (post.contentType() === 'comment' && !post.isHidden() && post.canEdit()) {
+ items.add('hide', Button.component({
+ icon: 'times',
+ children: 'Delete',
+ onclick: this.hideAction.bind(post)
+ }));
+ } else if ((post.contentType() !== 'comment' || post.isHidden()) && post.canDelete()) {
+ items.add('delete', Button.component({
+ icon: 'times',
+ children: 'Delete Forever',
+ onclick: this.deleteAction.bind(post)
+ }));
+ }
+ }
+
+ return items;
+ },
+
+ /**
+ * Open the composer to edit a post.
+ */
+ editAction() {
+ app.composer.load(new EditPostComposer({ post: this }));
+ app.composer.show();
+ },
+
+ /**
+ * Hide a post.
+ */
+ hideAction() {
+ this.save({ isHidden: true });
+ this.pushAttributes({ hideTime: new Date(), hideUser: app.session.user });
+ },
+
+ /**
+ * Restore a post.
+ */
+ restoreAction() {
+ this.save({ isHidden: false });
+ this.pushAttributes({ hideTime: null, hideUser: null });
+ },
+
+ /**
+ * Delete a post.
+ */
+ deleteAction() {
+ this.delete();
+ this.discussion().removePost(this.id());
+ }
+};
diff --git a/js/forum/src/utils/UserControls.js b/js/forum/src/utils/UserControls.js
new file mode 100644
index 000000000..a58e5f016
--- /dev/null
+++ b/js/forum/src/utils/UserControls.js
@@ -0,0 +1,105 @@
+import Button from 'flarum/components/Button';
+import Separator from 'flarum/components/Separator';
+import ItemList from 'flarum/utils/ItemList';
+
+/**
+ * The `UserControls` utility constructs a list of buttons for a user which
+ * perform actions on it.
+ */
+export default {
+ /**
+ * Get a list of controls for a user.
+ *
+ * @param {User} user
+ * @param {*} context The parent component under which the controls menu will
+ * be displayed.
+ * @return {ItemList}
+ * @public
+ */
+ controls(discussion, context) {
+ const items = new ItemList();
+
+ ['user', 'moderation', 'destructive'].forEach(section => {
+ const controls = this[section + 'Controls'](discussion, context).toArray();
+ if (controls.length) {
+ items.add(section, controls);
+ items.add(section + 'Separator', Separator.component());
+ }
+ });
+
+ return items;
+ },
+
+ /**
+ * Get controls for a user pertaining to the current user (e.g. poke, follow).
+ *
+ * @param {User} user
+ * @param {*} context The parent component under which the controls menu will
+ * be displayed.
+ * @return {ItemList}
+ * @protected
+ */
+ userControls() {
+ return new ItemList();
+ },
+
+ /**
+ * Get controls for a user pertaining to moderation (e.g. suspend, edit).
+ *
+ * @param {User} user
+ * @param {*} context The parent component under which the controls menu will
+ * be displayed.
+ * @return {ItemList}
+ * @protected
+ */
+ moderationControls(user) {
+ const items = new ItemList();
+
+ if (user.canEdit()) {
+ items.add('edit', Button.component({
+ icon: 'pencil',
+ children: 'Edit',
+ onclick: this.editAction.bind(user)
+ }));
+ }
+
+ return items;
+ },
+
+ /**
+ * Get controls for a user which are destructive (e.g. delete).
+ *
+ * @param {User} user
+ * @param {*} context The parent component under which the controls menu will
+ * be displayed.
+ * @return {ItemList}
+ * @protected
+ */
+ destructiveControls(user) {
+ const items = new ItemList();
+
+ if (user.canDelete()) {
+ items.add('delete', Button.component({
+ icon: 'times',
+ children: 'Delete',
+ onclick: this.deleteAction.bind(user)
+ }));
+ }
+
+ return items;
+ },
+
+ /**
+ * Delete the user.
+ */
+ deleteAction() {
+ // TODO
+ },
+
+ /**
+ * Edit the user.
+ */
+ editAction() {
+ // TODO
+ }
+};
diff --git a/js/forum/src/utils/affixSidebar.js b/js/forum/src/utils/affixSidebar.js
new file mode 100644
index 000000000..79d563817
--- /dev/null
+++ b/js/forum/src/utils/affixSidebar.js
@@ -0,0 +1,25 @@
+/**
+ * Setup the sidebar DOM element to be affixed to the top of the viewport
+ * using Bootstrap's affix plugin.
+ *
+ * @param {DOMElement} element
+ * @param {Boolean} isInitialized
+ */
+export default function affixSidebar(element, isInitialized) {
+ if (isInitialized) return;
+
+ const $sidebar = $(element);
+ const $header = $('.global-header');
+ const $footer = $('.global-footer');
+
+ // Don't affix the sidebar if it is taller than the viewport (otherwise
+ // there would be no way to scroll through its content).
+ if ($sidebar.outerHeight(true) > $(window).height() - $header.outerHeight(true)) return;
+
+ $sidebar.find('> ul').affix({
+ offset: {
+ top: () => $sidebar.offset().top - $header.outerHeight(true) - parseInt($sidebar.css('margin-top'), 10),
+ bottom: () => this.bottom = $footer.outerHeight(true)
+ }
+ });
+}
diff --git a/js/forum/src/utils/drawer.js b/js/forum/src/utils/drawer.js
index 88c1883aa..668820621 100644
--- a/js/forum/src/utils/drawer.js
+++ b/js/forum/src/utils/drawer.js
@@ -1,12 +1,53 @@
+/**
+ * The `Drawer` class controls the page's drawer. The drawer is the area the
+ * slides out from the left on mobile devices; it contains the header and the
+ * footer.
+ */
export default class Drawer {
+ constructor() {
+ // Set up an event handler so that whenever the content area is tapped,
+ // the drawer will close.
+ $('.global-content').click(e => {
+ if (this.isOpen()) {
+ e.preventDefault();
+ this.hide();
+ }
+ });
+ }
+
+ /**
+ * Check whether or not the drawer is currently open.
+ *
+ * @return {Boolean}
+ * @public
+ */
+ isOpen() {
+ return $('body').hasClass('drawer-open');
+ }
+
+ /**
+ * Hide the drawer.
+ *
+ * @public
+ */
hide() {
$('body').removeClass('drawer-open');
}
+ /**
+ * Show the drawer.
+ *
+ * @public
+ */
show() {
$('body').addClass('drawer-open');
}
+ /**
+ * Toggle the drawer.
+ *
+ * @public
+ */
toggle() {
$('body').toggleClass('drawer-open');
}
diff --git a/js/forum/src/utils/history.js b/js/forum/src/utils/history.js
index cac63144d..36a5c2d40 100644
--- a/js/forum/src/utils/history.js
+++ b/js/forum/src/utils/history.js
@@ -1,42 +1,98 @@
+/**
+ * The `History` class keeps track and manages a stack of routes that the user
+ * has navigated to in their session.
+ *
+ * An item can be pushed to the top of the stack using the `push` method. An
+ * item in the stack has a name and a URL. The name need not be unique; if it is
+ * the same as the item before it, that will be overwritten with the new URL. In
+ * this way, if a user visits a discussion, and then visits another discussion,
+ * popping the history stack will still take them back to the discussion list
+ * rather than the previous discussion.
+ */
export default class History {
constructor() {
+ /**
+ * The stack of routes that have been navigated to.
+ *
+ * @type {Array}
+ * @protected
+ */
this.stack = [];
+
+ // Push the homepage as the first route, so that the user will always be
+ // able to click on the 'back' button to go home, regardless of which page
+ // they started on.
this.push('index', '/');
}
- top() {
+ /**
+ * Get the item on the top of the stack.
+ *
+ * @return {Object}
+ * @protected
+ */
+ getTop() {
return this.stack[this.stack.length - 1];
}
- push(name, url) {
- var url = url || m.route();
-
- // maybe? prevents browser back button from breaking history
- var secondTop = this.stack[this.stack.length - 2];
+ /**
+ * Push an item to the top of the stack.
+ *
+ * @param {String} name The name of the route.
+ * @param {String} [url] The URL of the route. The current URL will be used if
+ * not provided.
+ * @public
+ */
+ push(name, url = m.route()) {
+ // If we're pushing an item with the same name as second-to-top item in the
+ // stack, we will assume that the user has clicked the 'back' button in
+ // their browser. In this case, we don't want to push a new item, so we will
+ // pop off the top item, and then the second-to-top item will be overwritten
+ // below.
+ const secondTop = this.stack[this.stack.length - 2];
if (secondTop && secondTop.name === name) {
this.stack.pop();
}
- var top = this.top();
+ // If we're pushing an item with the same name as the top item in the stack,
+ // then we'll overwrite it with the new URL.
+ const top = this.getTop();
if (top && top.name === name) {
top.url = url;
} else {
- this.stack.push({name: name, url: url});
+ this.stack.push({name, url});
}
}
+ /**
+ * Check whether or not the history stack is able to be popped.
+ *
+ * @return {Boolean}
+ * @public
+ */
canGoBack() {
return this.stack.length > 1;
}
+ /**
+ * Go back to the previous route in the history stack.
+ *
+ * @public
+ */
back() {
this.stack.pop();
- var top = this.top();
- m.route(top.url);
+
+ m.route(this.getTop().url);
}
+ /**
+ * Go to the first route in the history stack.
+ *
+ * @public
+ */
home() {
this.stack.splice(1);
- m.route('/');
+
+ m.route(this.stack[0].url);
}
}
diff --git a/js/forum/src/utils/pane.js b/js/forum/src/utils/pane.js
index 5e7014c5a..5bd0b25ad 100644
--- a/js/forum/src/utils/pane.js
+++ b/js/forum/src/utils/pane.js
@@ -1,46 +1,125 @@
+/**
+ * The `Pane` class manages the page's discussion list sidepane. The pane is a
+ * part of the content view (DiscussionPage component), but its visibility is
+ * determined by CSS classes applied to the outer page element. This class
+ * manages the application of those CSS classes.
+ */
export default class Pane {
constructor(element) {
+ /**
+ * The localStorage key to store the pane's pinned state with.
+ *
+ * @type {String}
+ * @protected
+ */
this.pinnedKey = 'panePinned';
+ /**
+ * The page element.
+ *
+ * @type {jQuery}
+ * @protected
+ */
this.$element = $(element);
+ /**
+ * Whether or not the pane is currently pinned.
+ *
+ * @type {Boolean}
+ * @protected
+ */
this.pinned = localStorage.getItem(this.pinnedKey) === 'true';
+
+ /**
+ * Whether or not the pane is currently exists.
+ *
+ * @type {Boolean}
+ * @protected
+ */
this.active = false;
+
+ /**
+ * Whether or not the pane is currently showing, or is hidden off the edge
+ * of the screen.
+ *
+ * @type {Boolean}
+ * @protected
+ */
this.showing = false;
+
this.render();
}
+ /**
+ * Enable the pane.
+ *
+ * @public
+ */
enable() {
this.active = true;
this.render();
}
+ /**
+ * Disable the pane.
+ *
+ * @public
+ */
disable() {
this.active = false;
this.showing = false;
this.render();
}
+ /**
+ * Show the pane.
+ *
+ * @public
+ */
show() {
clearTimeout(this.hideTimeout);
this.showing = true;
this.render();
}
+ /**
+ * Hide the pane.
+ *
+ * @public
+ */
hide() {
this.showing = false;
this.render();
}
+ /**
+ * Begin a timeout to hide the pane, which can be cancelled by showing the
+ * pane.
+ *
+ * @public
+ */
onmouseleave() {
this.hideTimeout = setTimeout(this.hide.bind(this), 250);
}
+ /**
+ * Toggle whether or not the pane is pinned.
+ *
+ * @public
+ */
togglePinned() {
- localStorage.setItem(this.pinnedKey, (this.pinned = !this.pinned) ? 'true' : 'false');
+ this.pinned = !this.pinned;
+
+ localStorage.setItem(this.pinnedKey, this.pinned ? 'true' : 'false');
+
this.render();
}
+ /**
+ * Apply the appropriate CSS classes to the page element.
+ *
+ * @protected
+ */
render() {
this.$element
.toggleClass('pane-pinned', this.pinned)
diff --git a/js/forum/src/utils/slidable.js b/js/forum/src/utils/slidable.js
index 73846d4eb..5ff83da29 100644
--- a/js/forum/src/utils/slidable.js
+++ b/js/forum/src/utils/slidable.js
@@ -1,41 +1,68 @@
+/**
+ * The `slidable` utility adds touch gestures to an element so that it can be
+ * slid away to reveal controls underneath, and then released to activate those
+ * controls.
+ *
+ * It relies on the element having children with particular CSS classes.
+ * TODO: document
+ *
+ * @param {DOMElement} element
+ * @return {Object}
+ * @property {function} reset Revert the slider to its original position. This
+ * should be called, for example, when a controls dropdown is closed.
+ */
export default function slidable(element) {
- var $slidable = $(element);
+ const $element = $(element);
+ const threshold = 50;
- var startX;
- var startY;
- var couldBeSliding = false;
- var isSliding = false;
- var threshold = 50;
- var pos = 0;
+ let $underneathLeft;
+ let $underneathRight;
- var underneathLeft;
- var underneathRight;
+ let startX;
+ let startY;
+ let couldBeSliding = false;
+ let isSliding = false;
+ let pos = 0;
- var animatePos = function(pos, options) {
- options = options || {};
+ /**
+ * Animate the slider to a new position.
+ *
+ * @param {Integer} newPos
+ * @param {Object} [options]
+ */
+ const animatePos = (newPos, options = {}) => {
+ // Since we can't animate the transform property with jQuery, we'll use a
+ // bit of a workaround. We set up the animation with a step function that
+ // will set the transform property, but then we animate an unused property
+ // (background-position-x) with jQuery.
options.duration = options.duration || 'fast';
- options.step = function(pos) {
- $(this).css('transform', 'translate('+pos+'px, 0)');
+ options.step = function(x) {
+ $(this).css('transform', 'translate(' + x + 'px, 0)');
};
- $slidable.find('.slidable-slider').animate({'background-position-x': pos}, options);
+ $element.find('.slidable-slider').animate({'background-position-x': newPos}, options);
};
- var reset = function() {
+ /**
+ * Revert the slider to its original position.
+ */
+ const reset = () => {
animatePos(0, {
complete: function() {
- $slidable.removeClass('sliding');
- underneathLeft.hide();
- underneathRight.hide();
+ $element.removeClass('sliding');
+ $underneathLeft.hide();
+ $underneathRight.hide();
isSliding = false;
}
});
};
- $slidable.find('.slidable-slider')
+ $element.find('.slidable-slider')
.on('touchstart', function(e) {
- underneathLeft = $slidable.find('.slidable-underneath-left:not(.disabled)');
- underneathRight = $slidable.find('.slidable-underneath-right:not(.disabled)');
+ // Update the references to the elements underneath the slider, provided
+ // they're not disabled.
+ $underneathLeft = $element.find('.slidable-underneath-left:not(.disabled)');
+ $underneathRight = $element.find('.slidable-underneath-right:not(.disabled)');
startX = e.originalEvent.targetTouches[0].clientX;
startY = e.originalEvent.targetTouches[0].clientY;
@@ -44,9 +71,13 @@ export default function slidable(element) {
})
.on('touchmove', function(e) {
- var newX = e.originalEvent.targetTouches[0].clientX;
- var newY = e.originalEvent.targetTouches[0].clientY;
+ const newX = e.originalEvent.targetTouches[0].clientX;
+ const newY = e.originalEvent.targetTouches[0].clientY;
+ // Once the user moves their touch in a direction that's more up/down than
+ // left/right, we'll assume they're scrolling the page. But if they do
+ // move in a horizontal direction at first, then we'll lock their touch
+ // into the slider.
if (couldBeSliding && Math.abs(newX - startX) > Math.abs(newY - startY)) {
isSliding = true;
}
@@ -55,45 +86,59 @@ export default function slidable(element) {
if (isSliding) {
pos = newX - startX;
- if (underneathLeft.length) {
- if (pos > 0 && underneathLeft.hasClass('elastic')) {
- pos -= pos * 0.5;
+ // If there are controls underneath the either side, then we'll show/hide
+ // them depending on the slider's position. We also make the controls
+ // icon get a bit bigger the further they slide.
+ const toggle = ($underneath, active) => {
+ if ($underneath.length) {
+ if (active && $underneath.hasClass('elastic')) {
+ pos -= pos * 0.5;
+ }
+ $underneath.toggle(active);
+
+ const scale = Math.max(0, Math.min(1, (Math.abs(pos) - 25) / threshold));
+ $underneath.find('.icon').css('transform', 'scale(' + scale + ')');
+ } else {
+ pos = Math.min(0, pos);
}
- underneathLeft.toggle(pos > 0);
- underneathLeft.find('.icon').css('transform', 'scale('+Math.max(0, Math.min(1, (Math.abs(pos) - 25) / threshold))+')');
- } else {
- pos = Math.min(0, pos);
- }
+ };
- if (underneathRight.length) {
- if (pos < 0 && underneathRight.hasClass('elastic')) {
- pos -= pos * 0.5;
- }
- underneathRight.toggle(pos < 0);
- underneathRight.find('.icon').css('transform', 'scale('+Math.max(0, Math.min(1, (Math.abs(pos) - 25) / threshold))+')');
- } else {
- pos = Math.max(0, pos);
- }
+ toggle($underneathLeft, pos > 0);
+ toggle($underneathRight, pos < 0);
- $(this).css('transform', 'translate('+pos+'px, 0)');
- $(this).css('background-position-x', pos+'px');
+ $(this).css('transform', 'translate(' + pos + 'px, 0)');
+ $(this).css('background-position-x', pos + 'px');
- $slidable.toggleClass('sliding', !!pos);
+ $element.toggleClass('sliding', !!pos);
e.preventDefault();
}
})
- .on('touchend', function(e) {
- if (underneathRight.length && pos < -threshold) {
- underneathRight.click();
- underneathRight.hasClass('elastic') ? reset() : animatePos(-$slidable.width());
- } else if (underneathLeft.length && pos > threshold) {
- underneathLeft.click();
- underneathLeft.hasClass('elastic') ? reset() : animatePos(-$slidable.width());
+ .on('touchend', function() {
+ // If the user releases the touch and the slider is past the threshold
+ // position on either side, then we will activate the control for that
+ // side. We will also animate the slider's position all the way to the
+ // other side, or back to its original position, depending on whether or
+ // not the side is 'elastic'.
+ const activate = $underneath => {
+ $underneath.click();
+
+ if ($underneath.hasClass('elastic')) {
+ reset();
+ } else {
+ animatePos((pos > 0 ? 1 : -1) * $element.width());
+ }
+ };
+
+ if ($underneathRight.length && pos < -threshold) {
+ activate($underneathRight);
+ } else if ($underneathLeft.length && pos > threshold) {
+ activate($underneathLeft);
} else {
reset();
}
+
couldBeSliding = false;
});
diff --git a/js/lib/App.js b/js/lib/App.js
new file mode 100644
index 000000000..1e10f3718
--- /dev/null
+++ b/js/lib/App.js
@@ -0,0 +1,250 @@
+import ItemList from 'flarum/utils/ItemList';
+import Alert from 'flarum/components/Alert';
+import Translator from 'flarum/Translator';
+import extract from 'flarum/utils/extract';
+
+/**
+ * The `App` class provides a container for an application, as well as various
+ * utilities for the rest of the app to use.
+ */
+export default class App {
+ constructor() {
+ /**
+ * The forum model for this application.
+ *
+ * @type {Forum}
+ * @public
+ */
+ this.forum = null;
+
+ /**
+ * A map of routes, keyed by a unique route name. Each route is an object
+ * containing the following properties:
+ *
+ * - `path` The path that the route is accessed at.
+ * - `component` The Mithril component to render when this route is active.
+ *
+ * @example
+ * app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()};
+ *
+ * @type {Object}
+ * @public
+ */
+ this.routes = {};
+
+ /**
+ * An object containing data to preload into the application.
+ *
+ * @type {Object}
+ * @property {Object} preload.data An array of resource objects to preload
+ * into the data store.
+ * @property {Object} preload.document An API response document to be used
+ * by the route that is first activated.
+ * @property {Object} preload.session A response from the /api/token
+ * endpoint containing the session's authentication token and user ID.
+ * @public
+ */
+ this.preload = {
+ data: null,
+ document: null,
+ session: null
+ };
+
+ /**
+ * An ordered list of initializers to bootstrap the application.
+ *
+ * @type {ItemList}
+ * @public
+ */
+ this.initializers = new ItemList();
+
+ /**
+ * The app's session.
+ *
+ * @type {Session}
+ * @public
+ */
+ this.session = null;
+
+ /**
+ * The app's translator.
+ *
+ * @type {Translator}
+ * @public
+ */
+ this.translator = new Translator();
+
+ /**
+ * The app's data store.
+ *
+ * @type {Store}
+ * @public
+ */
+ this.store = null;
+
+ /**
+ * A local cache that can be used to store data at the application level, so
+ * that is persists between different routes.
+ *
+ * @type {Object}
+ * @public
+ */
+ this.cache = {};
+
+ /**
+ * Whether or not the app has been booted.
+ *
+ * @type {Boolean}
+ * @public
+ */
+ this.booted = false;
+
+ /**
+ * An Alert that was shown as a result of an AJAX request error. If present,
+ * it will be dismissed on the next successful request.
+ *
+ * @type {null|Alert}
+ * @private
+ */
+ this.requestError = null;
+ }
+
+ /**
+ * Boot the application by running all of the registered initializers.
+ *
+ * @public
+ */
+ boot() {
+ this.initializers.toArray().forEach(initializer => initializer(this));
+ }
+
+ /**
+ * Get the API response document that has been preloaded into the application.
+ *
+ * @return {Object|null}
+ * @public
+ */
+ preloadedDocument() {
+ if (app.preload.document) {
+ const results = app.store.pushPayload(app.preload.document);
+ app.preload.document = null;
+
+ return results;
+ }
+
+ return null;
+ }
+
+ /**
+ * Set the of the page.
+ *
+ * @param {String} title
+ * @public
+ */
+ setTitle(title) {
+ document.title = (title ? title + ' - ' : '') + this.forum.attribute('title');
+ }
+
+ /**
+ * Make an AJAX request, handling any low-level errors that may occur.
+ *
+ * @see https://lhorie.github.io/mithril/mithril.request.html
+ * @param {Object} options
+ * @return {Promise}
+ * @public
+ */
+ request(options) {
+ // Set some default options if they haven't been overridden. We want to
+ // authenticate all requests with the session token. We also want all
+ // requests to run asynchronously in the background, so that they don't
+ // prevent redraws from occurring.
+ options.config = options.config || this.session.authorize.bind(this.session);
+ options.background = options.background || true;
+
+ // When we deserialize JSON data, if for some reason the server has provided
+ // a dud response, we don't want the application to crash. We'll show an
+ // error message to the user instead.
+ options.deserialize = options.deserialize || (responseText => {
+ try {
+ return JSON.parse(responseText);
+ } catch (e) {
+ throw new Error('Oops! Something went wrong on the server. Please reload the page and try again.');
+ }
+ });
+
+ // When extracting the data from the response, we can check the server
+ // response code and show an error message to the user if something's gone
+ // awry.
+ const original = options.extract;
+ options.extract = xhr => {
+ const status = xhr.status;
+
+ if (status >= 500 && status <= 599) {
+ throw new Error('Oops! Something went wrong on the server. Please reload the page and try again.');
+ }
+
+ if (original) {
+ return original(xhr.responseText);
+ }
+
+ return xhr.responseText.length > 0 ? xhr.responseText : null;
+ };
+
+ this.alerts.dismiss(this.requestError);
+
+ // Now make the request. If it's a failure, inspect the error that was
+ // returned and show an alert containing its contents.
+ return m.request(options).then(null, response => {
+ if (response instanceof Error) {
+ this.alerts.show(this.requestError = new Alert({
+ type: 'warning',
+ message: response.message
+ }));
+ }
+
+ throw response;
+ });
+ }
+
+ /**
+ * Show alert error messages for each error returned in an API response.
+ *
+ * @param {Array} errors
+ * @public
+ */
+ alertErrors(errors) {
+ errors.forEach(error => {
+ this.alerts.show(new Alert({
+ type: 'warning',
+ message: error.detail
+ }));
+ });
+ }
+
+ /**
+ * Construct a URL to the route with the given name.
+ *
+ * @param {String} name
+ * @param {Object} params
+ * @return {String}
+ * @public
+ */
+ route(name, params = {}) {
+ const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
+ const queryString = m.route.buildQueryString(params);
+
+ return url + (queryString ? '?' + queryString : '');
+ }
+
+ /**
+ * Shortcut to translate the given key.
+ *
+ * @param {String} key
+ * @param {Object} input
+ * @return {String}
+ * @public
+ */
+ trans(key, input) {
+ return this.translator.trans(key, input);
+ }
+}
diff --git a/js/lib/Translator.js b/js/lib/Translator.js
new file mode 100644
index 000000000..79140f6dc
--- /dev/null
+++ b/js/lib/Translator.js
@@ -0,0 +1,64 @@
+/**
+ * The `Translator` class translates strings using the loaded localization.
+ */
+export default class Translator {
+ constructor() {
+ /**
+ * A map of translation keys to their translated values.
+ *
+ * @type {Object}
+ * @public
+ */
+ this.translations = {};
+ }
+
+ /**
+ * Determine the key of a translation that should be used for the given count.
+ * The default implementation is for English plurals. It should be overridden
+ * by a locale's JavaScript file if necessary.
+ *
+ * @param {Integer} count
+ * @return {String}
+ * @public
+ */
+ plural(count) {
+ return count === 1 ? 'one' : 'other';
+ }
+
+ /**
+ * Translate a string.
+ *
+ * @param {String} key
+ * @param {Object} input
+ * @return {String}
+ */
+ trans(key, input = {}) {
+ const parts = key.split('.');
+ let translation = this.translations;
+
+ // Drill down into the translation tree to find the translation for this
+ // key.
+ parts.forEach(part => {
+ translation = translation && translation[part];
+ });
+
+ // If this translation has multiple options and a 'count' has been provided
+ // in the input, we'll work out which option to choose using the `plural`
+ // method.
+ if (typeof translation === 'object' && typeof input.count !== 'undefined') {
+ translation = translation[this.plural(input.count)];
+ }
+
+ // If we've found the appropriate translation string, then we'll sub in the
+ // input.
+ if (typeof translation === 'string') {
+ for (const i in input) {
+ translation = translation.replace(new RegExp('{' + i + '}', 'gi'), input[i]);
+ }
+
+ return translation;
+ }
+
+ return key;
+ }
+}
diff --git a/js/lib/component.js b/js/lib/component.js
index b99bf7469..fabd74da2 100644
--- a/js/lib/component.js
+++ b/js/lib/component.js
@@ -1,65 +1,195 @@
/**
-
+ * The `Component` class defines a user interface 'building block'. A component
+ * can generate a virtual DOM to be rendered on each redraw.
+ *
+ * An instance's virtual DOM can be retrieved directly using the {@link
+ * Component#render} method.
+ *
+ * @example
+ * this.myComponentInstance = new MyComponent({foo: 'bar'});
+ * return m('div', this.myComponentInstance.render());
+ *
+ * Alternatively, components can be nested, letting Mithril take care of
+ * instance persistence. For this, the static {@link Component.component} method
+ * can be used.
+ *
+ * @example
+ * return m('div', MyComponent.component({foo: 'bar'));
+ *
+ * @see https://lhorie.github.io/mithril/mithril.component.html
+ * @abstract
*/
export default class Component {
/**
-
+ * @param {Object} props
+ * @param {Array|Object} children
+ * @public
*/
- constructor(props) {
- this.props = props || {};
+ constructor(props = {}, children) {
+ if (children) props.children = children;
- this.element = m.prop();
+ this.constructor.initProps(props);
+
+ /**
+ * The properties passed into the component.
+ *
+ * @type {Object}
+ */
+ this.props = props;
+
+ /**
+ * The root DOM element for the component.
+ *
+ * @type DOMElement
+ * @public
+ */
+ this.element = null;
}
/**
-
+ * Called when the component is destroyed, i.e. after a redraw where it is no
+ * longer a part of the view.
+ *
+ * @see https://lhorie.github.io/mithril/mithril.component.html#unloading-components
+ * @param {Object} e
+ * @public
*/
- $(selector) {
- return selector ? $(this.element()).find(selector) : $(this.element());
- }
-
- config(element, isInitialized, context, vdom) {
- //
+ onunload() {
}
+ /**
+ * Get the renderable virtual DOM that represents the component's view.
+ *
+ * This should NOT be overridden by subclasses. Subclasses wishing to define
+ * their virtual DOM should override Component#view instead.
+ *
+ * @example
+ * this.myComponentInstance = new MyComponent({foo: 'bar'});
+ * return m('div', this.myComponentInstance.render());
+ *
+ * @returns {Object}
+ * @final
+ * @public
+ */
render() {
- var vdom = this.view();
+ const vdom = this.view();
+
+ // Override the root element's config attribute with our own function, which
+ // will set the component instance's element property to the root DOM
+ // element, and then run the component class' config method.
vdom.attrs = vdom.attrs || {};
- if (!vdom.attrs.config) {
- var component = this;
- vdom.attrs.config = function() {
- var args = [].slice.apply(arguments);
- component.element(args[0]);
- component.config.apply(component, args);
- };
- }
+ const originalConfig = vdom.attrs.config;
+
+ vdom.attrs.config = (...args) => {
+ this.element = args[0];
+ this.config.apply(this, args.slice(1));
+ if (originalConfig) originalConfig.apply(this, args);
+ };
return vdom;
}
/**
-
+ * Returns a jQuery object for this component's element. If you pass in a
+ * selector string, this method will return a jQuery object, using the current
+ * element as its buffer.
+ *
+ * For example, calling `component.$('li')` will return a jQuery object
+ * containing all of the `li` elements inside the DOM element of this
+ * component.
+ *
+ * @param {String} [selector] a jQuery-compatible selector string
+ * @returns {jQuery} the jQuery object for the DOM node
+ * @final
+ * @public
*/
- static component(props) {
- props = props || {};
- if (this.props) {
- this.props(props);
- }
- var view = function(component) {
+ $(selector) {
+ const $element = $(this.element);
+
+ return selector ? $element.find(selector) : $element;
+ }
+
+ /**
+ * Called after the component's root element is redrawn. This hook can be used
+ * to perform any actions on the DOM, both on the initial draw and any
+ * subsequent redraws. See Mithril's documentation for more information.
+ *
+ * @see https://lhorie.github.io/mithril/mithril.html#the-config-attribute
+ * @param {Boolean} isInitialized
+ * @param {Object} context
+ * @param {Object} vdom
+ * @public
+ */
+ config() {
+ }
+
+ /**
+ * Get the virtual DOM that represents the component's view.
+ *
+ * @return {Object} The virtual DOM
+ * @protected
+ */
+ view() {
+ throw new Error('Component#view must be implemented by subclass');
+ }
+
+ /**
+ * Get a Mithril component object for this component, preloaded with props.
+ *
+ * @see https://lhorie.github.io/mithril/mithril.component.html
+ * @param {Object} [props] Properties to set on the component
+ * @return {Object} The Mithril component object
+ * @property {function} controller
+ * @property {function} view
+ * @property {Object} component The class of this component
+ * @property {Object} props The props that were passed to the component
+ * @public
+ */
+ static component(props = {}, children) {
+ if (children) props.children = children;
+
+ this.initProps(props);
+
+ // Set up a function for Mithril to get the component's view. It will accept
+ // the component's controller (which happens to be the component itself, in
+ // our case), update its props with the ones supplied, and then render the view.
+ const view = (component) => {
component.props = props;
return component.render();
};
+
+ // Mithril uses this property on the view function to cache component
+ // controllers between redraws, thus persisting component state.
view.$original = this.prototype.view;
- var output = {
- props: props,
- component: this,
+
+ // Our output object consists of a controller constructor + a view function
+ // which Mithril will use to instantiate and render the component. We also
+ // attach a reference to the props that were passed through and the
+ // component's class for reference.
+ const output = {
controller: this.bind(undefined, props),
- view: view
+ view: view,
+ props: props,
+ component: this
};
+
+ // If a `key` prop was set, then we'll assume that we want that to actually
+ // show up as an attribute on the component object so that Mithril's key
+ // algorithm can be applied.
if (props.key) {
output.attrs = {key: props.key};
}
+
return output;
}
+
+ /**
+ * Initialize the component's props.
+ *
+ * @param {Object} props
+ * @public
+ */
+ static initProps(props) {
+ }
}
diff --git a/js/lib/components/Button.js b/js/lib/components/Button.js
new file mode 100644
index 000000000..b98c7e492
--- /dev/null
+++ b/js/lib/components/Button.js
@@ -0,0 +1,55 @@
+import Component from 'flarum/Component';
+import icon from 'flarum/helpers/icon';
+import extract from 'flarum/utils/extract';
+
+/**
+ * The `Button` component defines an element which, when clicked, performs an
+ * action. The button may have the following special props:
+ *
+ * - `icon` The name of the icon class. If specified, the button will be given a
+ * 'has-icon' class name.
+ * - `disabled` Whether or not the button is disabled. If truthy, the button
+ * will be given a 'disabled' class name, and any `onclick` handler will be
+ * removed.
+ *
+ * All other props will be assigned as attributes on the button element.
+ *
+ * Note that a Button has no default class names. This is because a Button can
+ * be used to represent any generic clickable control, like a menu item.
+ */
+export default class Button extends Component {
+ view() {
+ const attrs = Object.assign({}, this.props);
+
+ delete attrs.children;
+
+ attrs.className = (attrs.className || '');
+ attrs.href = attrs.href || 'javascript:;';
+
+ const iconName = extract(attrs, 'icon');
+ if (iconName) attrs.className += ' has-icon';
+
+ const disabled = extract(attrs, 'disabled');
+ if (disabled) {
+ attrs.className += ' disabled';
+ delete attrs.onclick;
+ }
+
+ return {this.getButtonContent()};
+ }
+
+ /**
+ * Get the template for the button's content.
+ *
+ * @return {*}
+ * @protected
+ */
+ getButtonContent() {
+ const iconName = this.props.icon;
+
+ return [
+ iconName ? icon(iconName) : '',
+ {this.props.children}
+ ];
+ }
+}
diff --git a/js/lib/components/Checkbox.js b/js/lib/components/Checkbox.js
new file mode 100644
index 000000000..93ceff76e
--- /dev/null
+++ b/js/lib/components/Checkbox.js
@@ -0,0 +1,69 @@
+import Component from 'flarum/Component';
+import LoadingIndicator from 'flarum/components/LoadingIndicator';
+import icon from 'flarum/helpers/icon';
+
+/**
+ * The `Checkbox` component defines a checkbox input.
+ *
+ * ### Props
+ *
+ * - `state` Whether or not the checkbox is checked.
+ * - `className` The class name for the root element.
+ * - `disabled` Whether or not the checkbox is disabled.
+ * - `onchange` A callback to run when the checkbox is checked/unchecked.
+ * - `children` A text label to display next to the checkbox.
+ */
+export default class Checkbox extends Component {
+ constructor(...args) {
+ super(...args);
+
+ /**
+ * Whether or not the checkbox's value is in the process of being saved.
+ *
+ * @type {Boolean}
+ * @public
+ */
+ this.loading = false;
+ }
+
+ view() {
+ let className = 'checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
+ if (this.loading) className += ' loading';
+ if (this.props.disabled) className += ' disabled';
+
+ return (
+
+ );
+ }
+
+ /**
+ * Get the template for the checkbox's display (tick/cross icon).
+ *
+ * @return {*}
+ * @protected
+ */
+ getDisplay() {
+ return this.loading
+ ? LoadingIndicator.component({size: 'tiny'})
+ : icon(this.props.state ? 'check' : 'times');
+ }
+
+ /**
+ * Run a callback when the state of the checkbox is changed.
+ *
+ * @param {Boolean} checked
+ * @protected
+ */
+ onchange(checked) {
+ if (this.props.onchange) this.props.onchange(checked, this);
+ }
+}
diff --git a/js/lib/components/Dropdown.js b/js/lib/components/Dropdown.js
new file mode 100644
index 000000000..4fe976d15
--- /dev/null
+++ b/js/lib/components/Dropdown.js
@@ -0,0 +1,69 @@
+import Component from 'flarum/Component';
+import icon from 'flarum/helpers/icon';
+import listItems from 'flarum/helpers/listItems';
+
+/**
+ * The `Dropdown` component displays a button which, when clicked, shows a
+ * dropdown menu beneath it.
+ *
+ * ### Props
+ *
+ * - `buttonClassName` A class name to apply to the dropdown toggle button.
+ * - `menuClassName` A class name to apply to the dropdown menu.
+ * - `icon` The name of an icon to show in the dropdown toggle button. Defaults
+ * to 'ellipsis-v'.
+ * - `label` The label of the dropdown toggle button. Defaults to 'Controls'.
+ *
+ * The children will be displayed as a list inside of the dropdown menu.
+ */
+export default class Dropdown extends Component {
+ static initProps(props) {
+ props.className = props.className || '';
+ props.buttonClassName = props.buttonClassName || '';
+ props.contentClassName = props.contentClassName || '';
+ props.icon = props.icon || 'ellipsis-v';
+ props.label = props.label || app.trans('controls');
+ }
+
+ view() {
+ return (
+
+ {this.getButton()}
+
+ {listItems(this.props.children)}
+
+
+ );
+ }
+
+ /**
+ * Get the template for the button.
+ *
+ * @return {*}
+ * @protected
+ */
+ getButton() {
+ return (
+
+ {this.getButtonContent()}
+
+ );
+ }
+
+ /**
+ * Get the template for the button's content.
+ *
+ * @return {*}
+ * @protected
+ */
+ getButtonContent() {
+ return [
+ icon(this.props.icon),
+ {this.props.label},
+ icon('caret-down', {className: 'caret'})
+ ];
+ }
+}
diff --git a/js/lib/components/FieldSet.js b/js/lib/components/FieldSet.js
new file mode 100644
index 000000000..27425ad5f
--- /dev/null
+++ b/js/lib/components/FieldSet.js
@@ -0,0 +1,22 @@
+import Component from 'flarum/Component';
+import listItems from 'flarum/helpers/listItems';
+
+/**
+ * The `FieldSet` component defines a collection of fields, displayed in a list
+ * underneath a title. Accepted properties are:
+ *
+ * - `className` The class name for the fieldset.
+ * - `label` The title of this group of fields.
+ *
+ * The children should be an array of items to show in the fieldset.
+ */
+export default class FieldSet extends Component {
+ view() {
+ return (
+
+ );
+ }
+}
diff --git a/js/lib/components/LinkButton.js b/js/lib/components/LinkButton.js
new file mode 100644
index 000000000..fe1f26dce
--- /dev/null
+++ b/js/lib/components/LinkButton.js
@@ -0,0 +1,32 @@
+import Button from 'flarum/components/Button';
+
+/**
+ * The `LinkButton` component defines a `Button` which links to a route.
+ *
+ * ### Props
+ *
+ * All of the props accepted by `Button`, plus:
+ *
+ * - `active` Whether or not the page that this button links to is currently
+ * active.
+ * - `href` The URL to link to. If the current URL `m.route()` matches this,
+ * the `active` prop will automatically be set to true.
+ */
+export default class LinkButton extends Button {
+ static initProps(props) {
+ props.active = this.isActive(props);
+ props.config = props.config || m.route;
+ }
+
+ /**
+ * Determine whether a component with the given props is 'active'.
+ *
+ * @param {Object} props
+ * @return {Boolean}
+ */
+ static isActive(props) {
+ return typeof props.active !== 'undefined'
+ ? props.active
+ : m.route() === props.href;
+ }
+}
diff --git a/js/lib/components/LoadingIndicator.js b/js/lib/components/LoadingIndicator.js
new file mode 100644
index 000000000..3e0d3c28f
--- /dev/null
+++ b/js/lib/components/LoadingIndicator.js
@@ -0,0 +1,27 @@
+import Component from 'flarum/Component';
+
+/**
+ * The `LoadingIndicator` component displays a loading spinner with spin.js. It
+ * may have the following special props:
+ *
+ * - `size` The spin.js size preset to use. Defaults to 'small'.
+ *
+ * All other props will be assigned as attributes on the element.
+ */
+export default class LoadingIndicator extends Component {
+ view() {
+ const attrs = Object.assign({}, this.props);
+
+ attrs.className = 'loading-indicator ' + (attrs.className || '');
+ delete attrs.size;
+
+ return
{m.trust(' ')}
;
+ }
+
+ config() {
+ const size = this.props.size || 'small';
+
+ $.fn.spin.presets[size].zIndex = 'auto';
+ this.$().spin(size);
+ }
+}
diff --git a/js/lib/components/ModalManager.js b/js/lib/components/ModalManager.js
new file mode 100644
index 000000000..50631cf24
--- /dev/null
+++ b/js/lib/components/ModalManager.js
@@ -0,0 +1,82 @@
+import Component from 'flarum/Component';
+import Modal from 'flarum/components/Modal';
+
+/**
+ * The `ModalManager` component manages a modal dialog. Only one modal dialog
+ * can be shown at once; loading a new component into the ModalManager will
+ * overwrite the previous one.
+ */
+export default class ModalManager extends Component {
+ view() {
+ return (
+
+ {this.component && this.component.render()}
+
+ );
+ }
+
+ config(isInitialized) {
+ if (isInitialized) return;
+
+ this.$()
+ .on('hidden.bs.modal', this.clear.bind(this))
+ .on('shown.bs.modal', this.onready.bind(this));
+ }
+
+ /**
+ * Show a modal dialog.
+ *
+ * @param {Modal} component
+ * @public
+ */
+ show(component) {
+ if (!(component instanceof Modal)) {
+ throw new Error('The ModalManager component can only show Modal components');
+ }
+
+ clearTimeout(this.hideTimeout);
+
+ this.component = component;
+
+ m.redraw(true);
+
+ this.$().modal('show');
+ this.onready();
+ }
+
+ /**
+ * Close the modal dialog.
+ *
+ * @public
+ */
+ close() {
+ // Don't hide the modal immediately, because if the consumer happens to call
+ // the `show` method straight after to show another modal dialog, it will
+ // cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
+ // bit to give the `show` method the opportunity to prevent this from going
+ // ahead.
+ this.hideTimeout = setTimeout(() => this.$().modal('hide'));
+ }
+
+ /**
+ * Clear content from the modal area.
+ *
+ * @protected
+ */
+ clear() {
+ this.component = null;
+
+ m.redraw();
+ }
+
+ /**
+ * When the modal dialog is ready to be used, tell it!
+ *
+ * @protected
+ */
+ onready() {
+ if (this.component && this.component.onready) {
+ this.component.onready(this.$());
+ }
+ }
+}
diff --git a/js/lib/components/Navigation.js b/js/lib/components/Navigation.js
new file mode 100644
index 000000000..f5b3994b1
--- /dev/null
+++ b/js/lib/components/Navigation.js
@@ -0,0 +1,96 @@
+import Component from 'flarum/Component';
+import Button from 'flarum/components/Button';
+
+/**
+ * The `Navigation` component displays a set of navigation buttons. Typically
+ * this is just a back button which pops the app's History. If the user is on
+ * the root page and there is no history to pop, then in some instances it may
+ * show a button that toggles the app's drawer.
+ *
+ * If the app has a pane, it will also include a 'pin' button which toggles the
+ * pinned state of the pane.
+ *
+ * Accepts the following props:
+ *
+ * - `className` The name of a class to set on the root element.
+ * - `drawer` Whether or not to show a button to toggle the app's drawer if
+ * there is no more history to pop.
+ */
+export default class Navigation extends Component {
+ view() {
+ const {history, pane} = app;
+
+ return (
+