From ab6c03c0cc1ffb44cfda1567c55472292de3cf21 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 15 Jul 2015 14:00:11 +0930 Subject: [PATCH] Massive JavaScript cleanup - Use JSX for templates - Docblock/comment everything - Mostly passes ESLint (still some work to do) - Lots of renaming, refactoring, etc. CSS hasn't been updated yet. --- .eslintrc | 3 - js/admin/src/components/header-secondary.js | 2 +- js/bower.json | 5 +- js/forum/Gulpfile.js | 12 +- js/forum/src/ForumApp.js | 86 +++ js/forum/src/app.js | 21 +- js/forum/src/components/Activity.js | 57 ++ js/forum/src/components/ActivityPage.js | 140 +++++ js/forum/src/components/AvatarEditor.js | 170 ++++++ js/forum/src/components/ChangeEmailModal.js | 91 +++ .../src/components/ChangePasswordModal.js | 43 ++ js/forum/src/components/CommentPost.js | 120 ++++ js/forum/src/components/ComposerBody.js | 107 ++++ js/forum/src/components/ComposerButton.js | 13 + js/forum/src/components/DeleteAccountModal.js | 67 +++ js/forum/src/components/DiscussionComposer.js | 114 ++++ js/forum/src/components/DiscussionHero.js | 41 ++ js/forum/src/components/DiscussionList.js | 219 +++++++ js/forum/src/components/DiscussionListItem.js | 178 ++++++ js/forum/src/components/DiscussionPage.js | 309 ++++++++++ .../DiscussionRenamedNotification.js | 26 + .../src/components/DiscussionRenamedPost.js | 23 + .../src/components/DiscussionsSearchSource.js | 55 ++ js/forum/src/components/EditPostComposer.js | 64 ++ js/forum/src/components/EventPost.js | 51 ++ js/forum/src/components/FooterPrimary.js | 31 + js/forum/src/components/FooterSecondary.js | 35 ++ .../src/components/ForgotPasswordModal.js | 99 ++++ js/forum/src/components/HeaderPrimary.js | 26 + js/forum/src/components/HeaderSecondary.js | 57 ++ .../{index-page.js => IndexPage.js} | 329 +++++------ js/forum/src/components/JoinedActivity.js | 11 + js/forum/src/components/LoadingPost.js | 25 + js/forum/src/components/LogInModal.js | 140 +++++ js/forum/src/components/Modal.js | 134 +++++ js/forum/src/components/NotificationGrid.js | 190 ++++++ js/forum/src/components/NotificationList.js | 141 +++++ .../src/components/NotificationsDropdown.js | 44 ++ js/forum/src/components/NotificationsPage.js | 20 + js/forum/src/components/PostEdited.js | 29 + js/forum/src/components/PostMeta.js | 45 ++ js/forum/src/components/PostPreview.js | 32 + js/forum/src/components/PostStream.js | 556 ++++++++++++++++++ js/forum/src/components/PostStreamScrubber.js | 463 +++++++++++++++ js/forum/src/components/PostUser.js | 100 ++++ js/forum/src/components/PostedActivity.js | 50 ++ js/forum/src/components/ReplyComposer.js | 93 +++ js/forum/src/components/ReplyPlaceholder.js | 33 ++ js/forum/src/components/Search.js | 291 +++++++++ js/forum/src/components/SearchSource.js | 32 + js/forum/src/components/SessionDropdown.js | 90 +++ js/forum/src/components/SettingsPage.js | 145 +++++ js/forum/src/components/SignUpModal.js | 172 ++++++ js/forum/src/components/TerminalPost.js | 29 + js/forum/src/components/TextEditor.js | 158 +++++ js/forum/src/components/UserBio.js | 112 ++++ js/forum/src/components/UserCard.js | 96 +++ js/forum/src/components/UserPage.js | 173 ++++++ js/forum/src/components/UsersSearchSource.js | 36 ++ js/forum/src/components/WelcomeHero.js | 46 ++ js/forum/src/components/activity-page.js | 86 --- js/forum/src/components/avatar-editor.js | 103 ---- js/forum/src/components/change-email-modal.js | 63 -- .../src/components/change-password-modal.js | 30 - js/forum/src/components/comment-post.js | 76 --- js/forum/src/components/composer-body.js | 52 -- js/forum/src/components/composer.js | 505 ++++++++++------ .../src/components/delete-account-modal.js | 34 -- .../src/components/discussion-composer.js | 96 --- js/forum/src/components/discussion-hero.js | 27 - .../src/components/discussion-list-item.js | 126 ---- js/forum/src/components/discussion-list.js | 133 ----- js/forum/src/components/discussion-page.js | 249 -------- .../discussion-renamed-notification.js | 14 - .../src/components/discussion-renamed-post.js | 11 - .../components/discussions-search-results.js | 38 -- js/forum/src/components/edit-composer.js | 55 -- js/forum/src/components/event-post.js | 23 - js/forum/src/components/footer-primary.js | 15 - js/forum/src/components/footer-secondary.js | 17 - .../src/components/forgot-password-modal.js | 67 --- js/forum/src/components/form-modal.js | 62 -- js/forum/src/components/header-primary.js | 15 - js/forum/src/components/header-secondary.js | 46 -- js/forum/src/components/index-nav-item.js | 12 - js/forum/src/components/joined-activity.js | 18 - js/forum/src/components/login-modal.js | 82 --- js/forum/src/components/notification-grid.js | 95 --- js/forum/src/components/notification-list.js | 97 --- js/forum/src/components/notification.js | 72 ++- js/forum/src/components/notifications-page.js | 16 - js/forum/src/components/post-header-edited.js | 20 - js/forum/src/components/post-header-meta.js | 42 -- js/forum/src/components/post-header-toggle.js | 13 - js/forum/src/components/post-header-user.js | 64 -- js/forum/src/components/post-loading.js | 11 - js/forum/src/components/post-preview.js | 38 -- js/forum/src/components/post-scrubber.js | 387 ------------ js/forum/src/components/post-stream.js | 463 --------------- js/forum/src/components/post.js | 78 ++- js/forum/src/components/posted-activity.js | 38 -- js/forum/src/components/reply-composer.js | 84 --- js/forum/src/components/reply-placeholder.js | 16 - js/forum/src/components/search-box.js | 229 -------- js/forum/src/components/settings-page.js | 135 ----- js/forum/src/components/signup-modal.js | 100 ---- js/forum/src/components/terminal-post.js | 28 - js/forum/src/components/user-bio.js | 77 --- js/forum/src/components/user-card.js | 73 --- js/forum/src/components/user-dropdown.js | 67 --- js/forum/src/components/user-notifications.js | 34 -- js/forum/src/components/user-page.js | 149 ----- .../src/components/users-search-results.js | 22 - js/forum/src/components/welcome-hero.js | 26 - js/forum/src/initializers/boot.js | 90 ++- js/forum/src/initializers/components.js | 36 +- .../src/initializers/discussion-controls.js | 120 ---- js/forum/src/initializers/post-controls.js | 74 --- js/forum/src/initializers/routes.js | 60 +- js/forum/src/initializers/state-helpers.js | 16 - js/forum/src/utils/DiscussionControls.js | 214 +++++++ js/forum/src/utils/PostControls.js | 140 +++++ js/forum/src/utils/UserControls.js | 105 ++++ js/forum/src/utils/affixSidebar.js | 25 + js/forum/src/utils/drawer.js | 41 ++ js/forum/src/utils/history.js | 78 ++- js/forum/src/utils/pane.js | 81 ++- js/forum/src/utils/slidable.js | 145 +++-- js/lib/App.js | 250 ++++++++ js/lib/Translator.js | 64 ++ js/lib/component.js | 194 +++++- js/lib/components/Button.js | 55 ++ js/lib/components/Checkbox.js | 69 +++ js/lib/components/Dropdown.js | 69 +++ js/lib/components/FieldSet.js | 22 + js/lib/components/LinkButton.js | 32 + js/lib/components/LoadingIndicator.js | 27 + js/lib/components/ModalManager.js | 82 +++ js/lib/components/Navigation.js | 96 +++ js/lib/components/Select.js | 25 + js/lib/components/SelectDropdown.js | 25 + js/lib/components/SplitDropdown.js | 50 ++ js/lib/components/Switch.js | 17 + js/lib/components/action-button.js | 27 - js/lib/components/alert.js | 63 +- js/lib/components/alerts.js | 52 +- js/lib/components/back-button.js | 31 - js/lib/components/badge.js | 51 +- js/lib/components/dropdown-button.js | 20 - js/lib/components/dropdown-select.js | 18 - js/lib/components/dropdown-split.js | 35 -- js/lib/components/field-set.js | 11 - js/lib/components/loading-indicator.js | 15 - js/lib/components/modal.js | 38 -- js/lib/components/nav-item.js | 21 - js/lib/components/select-input.js | 13 - js/lib/components/separator.js | 8 +- js/lib/components/switch-input.js | 30 - js/lib/components/text-editor.js | 90 --- js/lib/components/yesno-input.js | 35 -- js/lib/extend.js | 75 +++ js/lib/extension-utils.js | 17 - js/lib/helpers/avatar.js | 40 +- js/lib/helpers/full-time.js | 7 - js/lib/helpers/fullTime.js | 15 + js/lib/helpers/highlight.js | 36 +- js/lib/helpers/human-time.js | 11 - js/lib/helpers/humanTime.js | 19 + js/lib/helpers/icon.js | 13 +- js/lib/helpers/list-items.js | 21 - js/lib/helpers/listItems.js | 37 ++ js/lib/helpers/punctuate.js | 25 +- js/lib/helpers/username.js | 11 +- js/lib/initializers/humanTime.js | 18 + js/lib/initializers/preload.js | 22 +- js/lib/initializers/session.js | 5 - js/lib/initializers/store.js | 36 +- js/lib/initializers/timestamps.js | 10 - js/lib/model.js | 296 +++++++--- js/lib/models/activity.js | 19 +- js/lib/models/discussion.js | 98 +-- js/lib/models/forum.js | 6 +- js/lib/models/group.js | 15 +- js/lib/models/notification.js | 28 +- js/lib/models/post.js | 42 +- js/lib/models/user.js | 131 +++-- js/lib/session.js | 86 ++- js/lib/store.js | 136 ++++- js/lib/utils/ItemList.js | 61 ++ js/lib/utils/ScrollListener.js | 73 +++ js/lib/utils/SubtreeRetainer.js | 69 +++ js/lib/utils/abbreviate-number.js | 9 - js/lib/utils/abbreviateNumber.js | 20 + js/lib/utils/anchor-scroll.js | 7 - js/lib/utils/anchorScroll.js | 22 + js/lib/utils/app.js | 75 --- js/lib/utils/class-list.js | 12 - js/lib/utils/classList.js | 20 + js/lib/utils/computed.js | 47 +- js/lib/utils/evented.js | 54 +- js/lib/utils/extract.js | 15 + js/lib/utils/format-number.js | 3 - js/lib/utils/formatNumber.js | 14 + js/lib/utils/human-time.js | 22 - js/lib/utils/humanTime.js | 28 + js/lib/utils/item-list.js | 70 --- js/lib/utils/map-routes.js | 8 - js/lib/utils/mapRoutes.js | 21 + js/lib/utils/mixin.js | 23 +- js/lib/utils/scroll-listener.js | 43 -- js/lib/utils/server-error.js | 2 - js/lib/utils/string-to-color.js | 34 -- js/lib/utils/string.js | 41 +- js/lib/utils/stringToColor.js | 45 ++ js/lib/utils/subtree-retainer.js | 38 -- js/lib/utils/translator.js | 32 - js/lib/utils/truncate.js | 5 - src/Locale/JsCompiler.php | 5 +- views/app.blade.php | 19 +- views/forum.blade.php | 23 +- 220 files changed, 9785 insertions(+), 5919 deletions(-) create mode 100644 js/forum/src/ForumApp.js create mode 100644 js/forum/src/components/Activity.js create mode 100644 js/forum/src/components/ActivityPage.js create mode 100644 js/forum/src/components/AvatarEditor.js create mode 100644 js/forum/src/components/ChangeEmailModal.js create mode 100644 js/forum/src/components/ChangePasswordModal.js create mode 100644 js/forum/src/components/CommentPost.js create mode 100644 js/forum/src/components/ComposerBody.js create mode 100644 js/forum/src/components/ComposerButton.js create mode 100644 js/forum/src/components/DeleteAccountModal.js create mode 100644 js/forum/src/components/DiscussionComposer.js create mode 100644 js/forum/src/components/DiscussionHero.js create mode 100644 js/forum/src/components/DiscussionList.js create mode 100644 js/forum/src/components/DiscussionListItem.js create mode 100644 js/forum/src/components/DiscussionPage.js create mode 100644 js/forum/src/components/DiscussionRenamedNotification.js create mode 100644 js/forum/src/components/DiscussionRenamedPost.js create mode 100644 js/forum/src/components/DiscussionsSearchSource.js create mode 100644 js/forum/src/components/EditPostComposer.js create mode 100644 js/forum/src/components/EventPost.js create mode 100644 js/forum/src/components/FooterPrimary.js create mode 100644 js/forum/src/components/FooterSecondary.js create mode 100644 js/forum/src/components/ForgotPasswordModal.js create mode 100644 js/forum/src/components/HeaderPrimary.js create mode 100644 js/forum/src/components/HeaderSecondary.js rename js/forum/src/components/{index-page.js => IndexPage.js} (50%) create mode 100644 js/forum/src/components/JoinedActivity.js create mode 100644 js/forum/src/components/LoadingPost.js create mode 100644 js/forum/src/components/LogInModal.js create mode 100644 js/forum/src/components/Modal.js create mode 100644 js/forum/src/components/NotificationGrid.js create mode 100644 js/forum/src/components/NotificationList.js create mode 100644 js/forum/src/components/NotificationsDropdown.js create mode 100644 js/forum/src/components/NotificationsPage.js create mode 100644 js/forum/src/components/PostEdited.js create mode 100644 js/forum/src/components/PostMeta.js create mode 100644 js/forum/src/components/PostPreview.js create mode 100644 js/forum/src/components/PostStream.js create mode 100644 js/forum/src/components/PostStreamScrubber.js create mode 100644 js/forum/src/components/PostUser.js create mode 100644 js/forum/src/components/PostedActivity.js create mode 100644 js/forum/src/components/ReplyComposer.js create mode 100644 js/forum/src/components/ReplyPlaceholder.js create mode 100644 js/forum/src/components/Search.js create mode 100644 js/forum/src/components/SearchSource.js create mode 100644 js/forum/src/components/SessionDropdown.js create mode 100644 js/forum/src/components/SettingsPage.js create mode 100644 js/forum/src/components/SignUpModal.js create mode 100644 js/forum/src/components/TerminalPost.js create mode 100644 js/forum/src/components/TextEditor.js create mode 100644 js/forum/src/components/UserBio.js create mode 100644 js/forum/src/components/UserCard.js create mode 100644 js/forum/src/components/UserPage.js create mode 100644 js/forum/src/components/UsersSearchSource.js create mode 100644 js/forum/src/components/WelcomeHero.js delete mode 100644 js/forum/src/components/activity-page.js delete mode 100644 js/forum/src/components/avatar-editor.js delete mode 100644 js/forum/src/components/change-email-modal.js delete mode 100644 js/forum/src/components/change-password-modal.js delete mode 100644 js/forum/src/components/comment-post.js delete mode 100644 js/forum/src/components/composer-body.js delete mode 100644 js/forum/src/components/delete-account-modal.js delete mode 100644 js/forum/src/components/discussion-composer.js delete mode 100644 js/forum/src/components/discussion-hero.js delete mode 100644 js/forum/src/components/discussion-list-item.js delete mode 100644 js/forum/src/components/discussion-list.js delete mode 100644 js/forum/src/components/discussion-page.js delete mode 100644 js/forum/src/components/discussion-renamed-notification.js delete mode 100644 js/forum/src/components/discussion-renamed-post.js delete mode 100644 js/forum/src/components/discussions-search-results.js delete mode 100644 js/forum/src/components/edit-composer.js delete mode 100644 js/forum/src/components/event-post.js delete mode 100644 js/forum/src/components/footer-primary.js delete mode 100644 js/forum/src/components/footer-secondary.js delete mode 100644 js/forum/src/components/forgot-password-modal.js delete mode 100644 js/forum/src/components/form-modal.js delete mode 100644 js/forum/src/components/header-primary.js delete mode 100644 js/forum/src/components/header-secondary.js delete mode 100644 js/forum/src/components/index-nav-item.js delete mode 100644 js/forum/src/components/joined-activity.js delete mode 100644 js/forum/src/components/login-modal.js delete mode 100644 js/forum/src/components/notification-grid.js delete mode 100644 js/forum/src/components/notification-list.js delete mode 100644 js/forum/src/components/notifications-page.js delete mode 100644 js/forum/src/components/post-header-edited.js delete mode 100644 js/forum/src/components/post-header-meta.js delete mode 100644 js/forum/src/components/post-header-toggle.js delete mode 100644 js/forum/src/components/post-header-user.js delete mode 100644 js/forum/src/components/post-loading.js delete mode 100644 js/forum/src/components/post-preview.js delete mode 100644 js/forum/src/components/post-scrubber.js delete mode 100644 js/forum/src/components/post-stream.js delete mode 100644 js/forum/src/components/posted-activity.js delete mode 100644 js/forum/src/components/reply-composer.js delete mode 100644 js/forum/src/components/reply-placeholder.js delete mode 100644 js/forum/src/components/search-box.js delete mode 100644 js/forum/src/components/settings-page.js delete mode 100644 js/forum/src/components/signup-modal.js delete mode 100644 js/forum/src/components/terminal-post.js delete mode 100644 js/forum/src/components/user-bio.js delete mode 100644 js/forum/src/components/user-card.js delete mode 100644 js/forum/src/components/user-dropdown.js delete mode 100644 js/forum/src/components/user-notifications.js delete mode 100644 js/forum/src/components/user-page.js delete mode 100644 js/forum/src/components/users-search-results.js delete mode 100644 js/forum/src/components/welcome-hero.js delete mode 100644 js/forum/src/initializers/discussion-controls.js delete mode 100644 js/forum/src/initializers/post-controls.js delete mode 100644 js/forum/src/initializers/state-helpers.js create mode 100644 js/forum/src/utils/DiscussionControls.js create mode 100644 js/forum/src/utils/PostControls.js create mode 100644 js/forum/src/utils/UserControls.js create mode 100644 js/forum/src/utils/affixSidebar.js create mode 100644 js/lib/App.js create mode 100644 js/lib/Translator.js create mode 100644 js/lib/components/Button.js create mode 100644 js/lib/components/Checkbox.js create mode 100644 js/lib/components/Dropdown.js create mode 100644 js/lib/components/FieldSet.js create mode 100644 js/lib/components/LinkButton.js create mode 100644 js/lib/components/LoadingIndicator.js create mode 100644 js/lib/components/ModalManager.js create mode 100644 js/lib/components/Navigation.js create mode 100644 js/lib/components/Select.js create mode 100644 js/lib/components/SelectDropdown.js create mode 100644 js/lib/components/SplitDropdown.js create mode 100644 js/lib/components/Switch.js delete mode 100644 js/lib/components/action-button.js delete mode 100644 js/lib/components/back-button.js delete mode 100644 js/lib/components/dropdown-button.js delete mode 100644 js/lib/components/dropdown-select.js delete mode 100644 js/lib/components/dropdown-split.js delete mode 100644 js/lib/components/field-set.js delete mode 100644 js/lib/components/loading-indicator.js delete mode 100644 js/lib/components/modal.js delete mode 100644 js/lib/components/nav-item.js delete mode 100644 js/lib/components/select-input.js delete mode 100644 js/lib/components/switch-input.js delete mode 100644 js/lib/components/text-editor.js delete mode 100644 js/lib/components/yesno-input.js create mode 100644 js/lib/extend.js delete mode 100644 js/lib/extension-utils.js delete mode 100644 js/lib/helpers/full-time.js create mode 100644 js/lib/helpers/fullTime.js delete mode 100644 js/lib/helpers/human-time.js create mode 100644 js/lib/helpers/humanTime.js delete mode 100644 js/lib/helpers/list-items.js create mode 100644 js/lib/helpers/listItems.js create mode 100644 js/lib/initializers/humanTime.js delete mode 100644 js/lib/initializers/session.js delete mode 100644 js/lib/initializers/timestamps.js create mode 100644 js/lib/utils/ItemList.js create mode 100644 js/lib/utils/ScrollListener.js create mode 100644 js/lib/utils/SubtreeRetainer.js delete mode 100644 js/lib/utils/abbreviate-number.js create mode 100644 js/lib/utils/abbreviateNumber.js delete mode 100644 js/lib/utils/anchor-scroll.js create mode 100644 js/lib/utils/anchorScroll.js delete mode 100644 js/lib/utils/app.js delete mode 100644 js/lib/utils/class-list.js create mode 100644 js/lib/utils/classList.js create mode 100644 js/lib/utils/extract.js delete mode 100644 js/lib/utils/format-number.js create mode 100644 js/lib/utils/formatNumber.js delete mode 100644 js/lib/utils/human-time.js create mode 100644 js/lib/utils/humanTime.js delete mode 100644 js/lib/utils/item-list.js delete mode 100644 js/lib/utils/map-routes.js create mode 100644 js/lib/utils/mapRoutes.js delete mode 100644 js/lib/utils/scroll-listener.js delete mode 100644 js/lib/utils/server-error.js delete mode 100644 js/lib/utils/string-to-color.js create mode 100644 js/lib/utils/stringToColor.js delete mode 100644 js/lib/utils/subtree-retainer.js delete mode 100644 js/lib/utils/translator.js delete mode 100644 js/lib/utils/truncate.js diff --git a/.eslintrc b/.eslintrc index 8d44118bc..9cebc759d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -87,7 +87,6 @@ "allowKeywords": true }], "eqeqeq": 2, // http://eslint.org/docs/rules/eqeqeq - "guard-for-in": 2, // http://eslint.org/docs/rules/guard-for-in "no-caller": 2, // http://eslint.org/docs/rules/no-caller "no-else-return": 2, // http://eslint.org/docs/rules/no-else-return "no-eq-null": 2, // http://eslint.org/docs/rules/no-eq-null @@ -110,7 +109,6 @@ "no-proto": 2, // http://eslint.org/docs/rules/no-proto "no-redeclare": 2, // http://eslint.org/docs/rules/no-redeclare "no-return-assign": 2, // http://eslint.org/docs/rules/no-return-assign - "no-script-url": 2, // http://eslint.org/docs/rules/no-script-url "no-self-compare": 2, // http://eslint.org/docs/rules/no-self-compare "no-sequences": 2, // http://eslint.org/docs/rules/no-sequences "no-throw-literal": 2, // http://eslint.org/docs/rules/no-throw-literal @@ -151,7 +149,6 @@ "no-multiple-empty-lines": [2, { // http://eslint.org/docs/rules/no-multiple-empty-lines "max": 2 }], - "no-nested-ternary": 2, // http://eslint.org/docs/rules/no-nested-ternary "no-new-object": 2, // http://eslint.org/docs/rules/no-new-object "no-spaced-func": 2, // http://eslint.org/docs/rules/no-spaced-func "no-trailing-spaces": 2, // http://eslint.org/docs/rules/no-trailing-spaces diff --git a/js/admin/src/components/header-secondary.js b/js/admin/src/components/header-secondary.js index 8c7be583d..c48343c05 100644 --- a/js/admin/src/components/header-secondary.js +++ b/js/admin/src/components/header-secondary.js @@ -12,7 +12,7 @@ export default class HeaderSecondary extends Component { items() { var items = new ItemList(); - items.add('user', UserDropdown.component({ user: app.session.user() })); + items.add('user', UserDropdown.component({ user: app.session.user })); return items; } diff --git a/js/bower.json b/js/bower.json index 7e08b7763..a741d373b 100644 --- a/js/bower.json +++ b/js/bower.json @@ -1,14 +1,15 @@ { "name": "flarum", "dependencies": { - "jquery": "2.1.3", + "jquery": "~2.1.3", "jquery.hotkeys": "jeresig/jquery.hotkeys#0.2.0", "bootstrap": "~3.3.2", "spin.js": "~2.0.1", "moment": "~2.8.4", "color-thief": "v2.0", "mithril": "lhorie/mithril.js#next", - "loader.js": "~3.2.1", + "es6-micro-loader": "caridy/es6-micro-loader#v0.2.1", + "es6-promise-polyfill": "~1.0.0", "fastclick": "~1.0.6" } } diff --git a/js/forum/Gulpfile.js b/js/forum/Gulpfile.js index 1ca8aab61..46a77c40d 100644 --- a/js/forum/Gulpfile.js +++ b/js/forum/Gulpfile.js @@ -3,13 +3,21 @@ var gulp = require('flarum-gulp'); gulp({ files: [ 'node_modules/babel-core/external-helpers.js', - '../bower_components/loader.js/loader.js', + '../bower_components/es6-promise-polyfill/promise.js', + '../bower_components/es6-micro-loader/dist/system-polyfill.js', + '../bower_components/mithril/mithril.js', '../bower_components/jquery/dist/jquery.js', '../bower_components/jquery.hotkeys/jquery.hotkeys.js', '../bower_components/color-thief/js/color-thief.js', '../bower_components/moment/moment.js', - '../bower_components/bootstrap/dist/js/bootstrap.js', + + '../bower_components/bootstrap/js/affix.js', + '../bower_components/bootstrap/js/dropdown.js', + '../bower_components/bootstrap/js/modal.js', + '../bower_components/bootstrap/js/tooltip.js', + '../bower_components/bootstrap/js/transition.js', + '../bower_components/spin.js/spin.js', '../bower_components/spin.js/jquery.spin.js', '../bower_components/fastclick/lib/fastclick.js' diff --git a/js/forum/src/ForumApp.js b/js/forum/src/ForumApp.js new file mode 100644 index 000000000..4de2b40a5 --- /dev/null +++ b/js/forum/src/ForumApp.js @@ -0,0 +1,86 @@ +import History from 'flarum/utils/History'; +import App from 'flarum/App'; +import Search from 'flarum/components/Search'; +import Composer from 'flarum/components/Composer'; +import ReplyComposer from 'flarum/components/ReplyComposer'; +import DiscussionPage from 'flarum/components/DiscussionPage'; + +export default class ForumApp extends App { + constructor(...args) { + super(...args); + + /** + * The app's history stack, which keeps track of which routes the user visits + * so that they can easily navigate back to the previous route. + * + * @type {History} + */ + this.history = new History(); + + /** + * An object which controls the state of the page's side pane. + * + * @type {Pane} + */ + this.pane = null; + + /** + * The page's search component instance. + * + * @type {SearchBox} + */ + this.search = new Search(); + + /** + * An object which controls the state of the page's drawer. + * + * @type {Drawer} + */ + this.drawer = null; + + /** + * A map of post types to their components. + * + * @type {Object} + */ + this.postComponents = {}; + + /** + * A map of activity types to their components. + * + * @type {Object} + */ + this.activityComponents = {}; + + /** + * A map of notification types to their components. + * + * @type {Object} + */ + this.notificationComponents = {}; + } + + /** + * Check whether or not the user is currently composing a reply to a + * discussion. + * + * @param {Discussion} discussion + * @return {Boolean} + */ + composingReplyTo(discussion) { + return this.composer.component instanceof ReplyComposer && + this.composer.component.props.discussion === discussion && + this.composer.position !== Composer.PositionEnum.HIDDEN; + } + + /** + * Check whether or not the user is currently viewing a discussion. + * + * @param {Discussion} discussion + * @return {Boolean} + */ + viewingDiscussion(discussion) { + return this.current instanceof DiscussionPage && + this.current.discussion === discussion; + } +} diff --git a/js/forum/src/app.js b/js/forum/src/app.js index d68d374ef..9436a7725 100644 --- a/js/forum/src/app.js +++ b/js/forum/src/app.js @@ -1,26 +1,19 @@ -import App from 'flarum/utils/app'; +import ForumApp from 'flarum/ForumApp'; import store from 'flarum/initializers/store'; -import stateHelpers from 'flarum/initializers/state-helpers'; -import discussionControls from 'flarum/initializers/discussion-controls'; -import postControls from 'flarum/initializers/post-controls'; import preload from 'flarum/initializers/preload'; -import session from 'flarum/initializers/session'; import routes from 'flarum/initializers/routes'; import components from 'flarum/initializers/components'; -import timestamps from 'flarum/initializers/timestamps'; +import humanTime from 'flarum/initializers/humanTime'; import boot from 'flarum/initializers/boot'; -var app = new App(); +const app = new ForumApp(); app.initializers.add('store', store); -app.initializers.add('state-helpers', stateHelpers); -app.initializers.add('discussion-controls', discussionControls); -app.initializers.add('post-controls', postControls); -app.initializers.add('session', session); app.initializers.add('routes', routes); app.initializers.add('components', components); -app.initializers.add('timestamps', timestamps); -app.initializers.add('preload', preload, {last: true}); -app.initializers.add('boot', boot, {last: true}); +app.initializers.add('humanTime', humanTime); + +app.initializers.add('preload', preload, -100); +app.initializers.add('boot', boot, -100); export default app; diff --git a/js/forum/src/components/Activity.js b/js/forum/src/components/Activity.js new file mode 100644 index 000000000..8e246923b --- /dev/null +++ b/js/forum/src/components/Activity.js @@ -0,0 +1,57 @@ +import Component from 'flarum/Component'; +import humanTime from 'flarum/helpers/humanTime'; +import avatar from 'flarum/helpers/avatar'; + +/** + * The `Activity` component represents a piece of activity of a user's activity + * feed. Subclasses should implement the `description` and `content` methods. + * + * ### Props + * + * - `activity` + * + * @abstract + */ +export default class Activity extends Component { + view() { + const activity = this.props.activity; + + return ( +
+ {avatar(this.user(), {className: 'activity-icon'})} + +
+ {this.description()} + {humanTime(activity.time())} +
+ + {this.content()} +
+ ); + } + + /** + * Get the user whose avatar should be displayed. + * + * @return {User} + */ + user() { + return this.props.activity.user(); + } + + /** + * Get the description of the activity. + * + * @return {VirtualElement} + */ + description() { + } + + /** + * Get the content to show below the activity description. + * + * @return {VirtualElement} + */ + content() { + } +} diff --git a/js/forum/src/components/ActivityPage.js b/js/forum/src/components/ActivityPage.js new file mode 100644 index 000000000..79d22a6a3 --- /dev/null +++ b/js/forum/src/components/ActivityPage.js @@ -0,0 +1,140 @@ +import UserPage from 'flarum/components/UserPage'; +import LoadingIndicator from 'flarum/components/LoadingIndicator'; +import Button from 'flarum/components/Button'; + +/** + * The `ActivityPage` component shows a user's activity feed inside of their + * profile. + */ +export default class ActivityPage extends UserPage { + constructor(...args) { + super(...args); + + /** + * Whether or not the activity feed is currently loading. + * + * @type {Boolean} + */ + this.loading = true; + + /** + * Whether or not there are any more activity items that can be loaded. + * + * @type {Boolean} + */ + this.moreResults = false; + + /** + * The Activity models in the feed. + * @type {Activity[]} + */ + this.activity = []; + + /** + * The number of activity items to load per request. + * + * @type {Integer} + */ + this.loadLimit = 20; + + this.loadUser(m.route.param('username')); + } + + content() { + let footer; + + if (this.loading) { + footer = LoadingIndicator.component(); + } else if (this.moreResults) { + footer = ( +
+ {Button.component({ + children: 'Load More', + className: 'btn btn-default', + onclick: this.loadMore.bind(this) + })} +
+ ); + } + + return ( +
+ + {footer} +
+ ); + } + + /** + * Initialize the component with a user, and trigger the loading of their + * activity feed. + */ + init(user) { + super.init(user); + + this.refresh(); + } + + /** + * Clear and reload the user's activity feed. + * + * @public + */ + refresh() { + this.loading = true; + this.activity = []; + + m.redraw(); + + this.loadResults().then(this.parseResults.bind(this)); + } + + /** + * Load a new page of the user's activity feed. + * + * @param {Integer} [offset] The position to start getting results from. + * @return {Promise} + * @protected + */ + loadResults(offset) { + return app.store.find('activity', { + filter: { + user: this.user.id(), + type: this.props.filter + }, + page: {offset, limit: this.loadLimit} + }); + } + + /** + * Load the next page of results. + * + * @public + */ + loadMore() { + this.loading = true; + this.loadResults(this.activity.length).then(this.parseResults.bind(this)); + } + + /** + * Parse results and append them to the activity feed. + * + * @param {Activity[]} results + * @return {Activity[]} + */ + parseResults(results) { + this.loading = false; + + [].push.apply(this.activity, results); + + this.moreResults = results.length >= this.loadLimit; + m.redraw(); + + return results; + } +} diff --git a/js/forum/src/components/AvatarEditor.js b/js/forum/src/components/AvatarEditor.js new file mode 100644 index 000000000..77a6821a0 --- /dev/null +++ b/js/forum/src/components/AvatarEditor.js @@ -0,0 +1,170 @@ +import Component from 'flarum/Component'; +import avatar from 'flarum/helpers/avatar'; +import icon from 'flarum/helpers/icon'; +import listItems from 'flarum/helpers/listItems'; +import ItemList from 'flarum/utils/ItemList'; +import Button from 'flarum/components/Button'; +import LoadingIndicator from 'flarum/components/LoadingIndicator'; + +/** + * The `AvatarEditor` component displays a user's avatar along with a dropdown + * menu which allows the user to upload/remove the avatar. + * + * ### Props + * + * - `className` + * - `user` + */ +export default class AvatarEditor extends Component { + constructor(...args) { + super(...args); + + /** + * Whether or not an avatar upload is in progress. + * + * @type {Boolean} + */ + this.loading = false; + } + + static initProps(props) { + super.initProps(props); + + props.className = props.className || ''; + } + + view() { + const user = this.props.user; + + return ( +
+ {avatar(user)} + + {this.loading ? LoadingIndicator.component() : icon('pencil', {className: 'icon'})} + + +
+ ); + } + + /** + * Get the items in the edit avatar dropdown menu. + * + * @return {ItemList} + */ + controlItems() { + const items = new ItemList(); + + items.add('upload', + Button.component({ + icon: 'upload', + children: 'Upload', + onclick: this.upload.bind(this) + }) + ); + + items.add('remove', + Button.component({ + icon: 'times', + children: 'Remove', + onclick: this.remove.bind(this) + }) + ); + + return items; + } + + /** + * If the user doesn't have an avatar, there's no point in showing the + * controls dropdown, because only one option would be viable: uploading. + * Thus, when the avatar editor's dropdown toggle button is clicked, we prompt + * the user to upload an avatar immediately. + * + * @param {Event} e + */ + quickUpload(e) { + if (!this.props.user.avatarUrl()) { + e.preventDefault(); + e.stopPropagation(); + this.upload(); + } + } + + /** + * Prompt the user to upload a new avatar. + */ + upload() { + if (this.loading) return; + + // Create a hidden HTML input element and click on it so the user can select + // an avatar file. Once they have, we will upload it via the API. + const user = this.props.user; + const $input = $(''); + + $input.appendTo('body').hide().click().on('change', e => { + const data = new FormData(); + data.append('avatar', $(e.target)[0].files[0]); + + this.loading = true; + m.redraw(); + + app.request({ + method: 'POST', + url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar', + serialize: raw => raw, + data + }).then( + this.success.bind(this), + this.failure.bind(this) + ); + }); + } + + /** + * Remove the user's avatar. + */ + remove() { + const user = this.props.user; + + this.loading = true; + m.redraw(); + + app.request({ + method: 'DELETE', + url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar' + }).then( + this.success.bind(this), + this.failure.bind(this) + ); + } + + /** + * After a successful upload/removal, push the updated user data into the + * store, and force a recomputation of the user's avatar color. + * + * @param {Object} response + * @protected + */ + success(response) { + app.store.pushPayload(response); + delete this.props.user.avatarColor; + + this.loading = false; + m.redraw(); + } + + /** + * If avatar upload/removal fails, stop loading. + * + * @param {Object} response + * @protected + */ + failure() { + this.loading = false; + } +} diff --git a/js/forum/src/components/ChangeEmailModal.js b/js/forum/src/components/ChangeEmailModal.js new file mode 100644 index 000000000..0db5e0b38 --- /dev/null +++ b/js/forum/src/components/ChangeEmailModal.js @@ -0,0 +1,91 @@ +import Modal from 'flarum/components/Modal'; + +/** + * The `ChangeEmailModal` component shows a modal dialog which allows the user + * to change their email address. + */ +export default class ChangeEmailModal extends Modal { + constructor(...args) { + super(...args); + + /** + * Whether or not the email has been changed successfully. + * + * @type {Boolean} + */ + this.success = false; + + /** + * The value of the email input. + * + * @type {function} + */ + this.email = m.prop(app.session.user.email()); + } + + className() { + return 'modal-sm change-email-modal'; + } + + title() { + return 'Change Email'; + } + + content() { + if (this.success) { + const emailProviderName = this.email().split('@')[1]; + + return ( +
+
+

We've sent a confirmation email to {this.email()}. If it doesn't arrive soon, check your spam folder.

+ +
+
+ ); + } + + return ( +
+
+
+ +
+
+ +
+
+
+ ); + } + + 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 [ +
, +
{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 ( +
+ {avatar(this.props.user, {className: 'composer-avatar'})} +
+
    {listItems(this.headerItems().toArray())}
+
{this.editor.render()}
+
+ {LoadingIndicator.component({className: 'composer-loading' + (this.loading ? ' active' : '')})} +
+ ); + } + + /** + * 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', ); + } + + 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 = ( +
+ {Button.component({ + children: 'Load More', + className: 'btn btn-default', + onclick: this.loadMore.bind(this) + })} +
+ ); + } + + return ( +
+ + {loading} +
+ ); + } + + /** + * Get the parameters that should be passed in the API request to get + * discussion results. + * + * @return {Object} + */ + params() { + const params = Object.assign({include: ['startUser', 'lastUser']}, this.props.params); + + params.sort = this.sortMap()[params.sort]; + + if (params.q) { + params.filter = params.filter || {}; + params.filter.q = params.q; + delete params.q; + + params.include.push('relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user'); + } + + return params; + } + + /** + * Get a map of sort keys (which appear in the URL, and are used for + * translation) to the API sort value that they represent. + * + * @return {Object} + */ + sortMap() { + const map = {}; + + if (this.props.params.q) { + map.relevance = ''; + } + map.recent = '-lastTime'; + map.replies = '-commentsCount'; + map.newest = '-startTime'; + map.oldest = '+startTime'; + + return map; + } + + /** + * Clear and reload the discussion list. + * + * @public + */ + refresh() { + this.loading = true; + this.discussions = []; + + this.loadResults().then( + this.parseResults.bind(this), + () => { + this.loading = false; + m.redraw(); + } + ); + } + + /** + * Load a new page of discussion results. + * + * @param {Integer} offset The index to start the page at. + * @return {Promise} + */ + loadResults(offset) { + const preloadedDiscussions = app.preloadedDocument(); + + if (preloadedDiscussions) { + return m.deferred().resolve(preloadedDiscussions).promise; + } + + const params = this.params(); + params.page = {offset}; + params.include = params.include.join(','); + + return app.store.find('discussions', params); + } + + /** + * Load the next page of discussion results. + * + * @public + */ + loadMore() { + this.loading = true; + + this.loadResults(this.discussions.length) + .then(this.parseResults.bind(this)); + } + + /** + * Parse results and append them to the discussion list. + * + * @param {Discussion[]} results + * @return {Discussion[]} + */ + parseResults(results) { + [].push.apply(this.discussions, results); + + this.loading = false; + this.moreResults = !!results.payload.links.next; + + // 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(); + + return results; + } + + /** + * Remove a discussion from the list if it is present. + * + * @param {Discussion} discussion + * @public + */ + removeDiscussion(discussion) { + const index = this.discussions.indexOf(discussion); + + if (index !== -1) { + this.discussions.splice(index, 1); + } + } + + /** + * Add a discussion to the top of the list. + * + * @param {Discussion} discussion + * @public + */ + addDiscussion(discussion) { + this.discussions.unshift(discussion); + } +} diff --git a/js/forum/src/components/DiscussionListItem.js b/js/forum/src/components/DiscussionListItem.js new file mode 100644 index 000000000..2a2637d67 --- /dev/null +++ b/js/forum/src/components/DiscussionListItem.js @@ -0,0 +1,178 @@ +import Component from 'flarum/Component'; +import avatar from 'flarum/helpers/avatar'; +import listItems from 'flarum/helpers/listItems'; +import highlight from 'flarum/helpers/highlight'; +import icon from 'flarum/helpers/icon'; +import humanTime from 'flarum/utils/humanTime'; +import ItemList from 'flarum/utils/ItemList'; +import abbreviateNumber from 'flarum/utils/abbreviateNumber'; +import Dropdown from 'flarum/components/Dropdown'; +import TerminalPost from 'flarum/components/TerminalPost'; +import PostPreview from 'flarum/components/PostPreview'; +import SubtreeRetainer from 'flarum/utils/SubtreeRetainer'; +import DiscussionControls from 'flarum/utils/DiscussionControls'; +import slidable from 'flarum/utils/slidable'; + +/** + * The `DiscussionListItem` component shows a single discussion in the + * discussion list. + * + * ### Props + * + * - `discussion` + * - `params` + */ +export default class DiscussionListItem extends Component { + constructor(...args) { + super(...args); + + /** + * Set up a subtree retainer so that the discussion will not be redrawn + * unless new data comes in. + * + * @type {SubtreeRetainer} + */ + this.subtree = new SubtreeRetainer( + () => this.props.discussion.freshness, + () => app.session.user && app.session.user.readTime(), + () => this.active() + ); + } + + view() { + const discussion = this.props.discussion; + const startUser = discussion.startUser(); + const isUnread = discussion.isUnread(); + const showUnread = !this.showRepliesCount() && isUnread; + const jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1); + const relevantPosts = this.props.params.q ? discussion.relevantPosts() : ''; + const controls = DiscussionControls.controls(discussion, this).toArray(); + + return this.subtree.retain() || ( +
+ + {controls.length ? Dropdown.component({ + icon: 'ellipsis-v', + children: controls, + className: 'contextual-controls', + buttonClassName: 'btn btn-default btn-naked btn-controls slidable-underneath slidable-underneath-right', + menuClassName: 'dropdown-menu-right' + }) : ''} + + + {icon('check', {className: 'icon'})} + + +
+ + {avatar(startUser, {title: ''})} + + +
    {listItems(discussion.badges().toArray())}
+ + +

{highlight(discussion.title(), this.props.params.q)}

+
    {listItems(this.infoItems().toArray())}
+
+ + + {abbreviateNumber(discussion[showUnread ? 'unreadCount' : 'repliesCount']())} + + + {relevantPosts && relevantPosts.length + ?
+ {relevantPosts.map(post => PostPreview.component({post, highlight: this.props.params.q}))} +
+ : ''} + +
+
+ ); + } + + 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 ( +
+ {app.cache.discussionList + ?
+ {app.cache.discussionList.render()} +
+ : ''} + +
+ {discussion + ? [ + DiscussionHero.component({discussion}), +
+ + {this.stream.render()} +
+ ] + : LoadingIndicator.component({className: 'loading-indicator-block'})} +
+
+ ); + } + + 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 [ +
  • Discussions
  • , +
  • + {Button.component({ + icon: 'search', + children: 'Search all discussions for "' + query + '"', + href: app.route('index', {q: query}), + config: m.route + })} +
  • , + results.map(discussion => { + const relevantPosts = discussion.relevantPosts(); + const post = relevantPosts && relevantPosts[0]; + + return ( +
  • + +
    {highlight(discussion.title(), query)}
    + {post ?
    {highlight(post.contentPlain(), query, 100)}
    : ''} +
    +
  • + ); + }) + ]; + } +} 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', ( +

    + {icon('pencil')} + + Post #{post.number()} in {post.discussion().title()} + +

    + )); + + return items; + } + + /** + * Get the data to submit to the server when the post is saved. + * + * @return {Object} + */ + data() { + return { + content: this.content() + }; + } + + onsubmit() { + this.loading = true; + + const data = this.data(); + + this.props.post.save(data).then( + () => { + app.composer.hide(); + m.redraw(); + }, + () => this.loading = false + ); + } +} diff --git a/js/forum/src/components/EventPost.js b/js/forum/src/components/EventPost.js new file mode 100644 index 000000000..0f51cfe24 --- /dev/null +++ b/js/forum/src/components/EventPost.js @@ -0,0 +1,51 @@ +import Post from 'flarum/components/Post'; +import usernameHelper from 'flarum/helpers/username'; +import icon from 'flarum/helpers/icon'; + +/** + * The `EventPost` component displays a post which indicating a discussion + * event, like a discussion being renamed or stickied. Subclasses must implement + * the `icon` and `description` methods. + * + * ### Props + * + * - All of the props for `Post` + * + * @abstract + */ +export default class EventPost extends Post { + attrs() { + return { + className: 'event-post ' + this.props.post.contentType() + '-post' + }; + } + + content() { + const user = this.props.post.user(); + const username = usernameHelper(user); + + return [ + icon(this.icon(), {className: 'event-post-icon'}), +
    + {user ? {username} : username} + {this.description()} +
    + ]; + } + + /** + * 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 ( + + ); + } + + /** + * 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 ( + + ); + } + + /** + * 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.

    +
    + Go to {emailProviderName} +
    +
    + ); + } + + return ( +
    +

    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 ( + + ); + } + + /** + * 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 ( + + ); + } + + /** + * 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 ( +
    +
    + +
    + +
    + +
    + +
    + +
    +
    + ); + } + + footer() { + return [ +

    + Forgot password? +

    , +

    + Don't have an account? + Sign Up +

    + ]; + } + + /** + * Open the forgot password modal, prefilling it with an email if the user has + * entered one. + */ + forgotPassword() { + const email = this.email(); + const props = email.indexOf('@') !== -1 ? {email} : null; + + app.modal.show(new ForgotPasswordModal(props)); + } + + /** + * Open the sign up modal, prefilling it with an email/username/password if + * the user has entered one. + */ + signUp() { + const props = {password: this.password()}; + const email = this.email(); + props[email.indexOf('@') !== -1 ? 'email' : 'username'] = email; + + app.modal.show(new SignUpModal(props)); + } + + focus() { + this.$('[name=' + (this.email() ? 'password' : 'email') + ']').select(); + } + + onsubmit(e) { + e.preventDefault(); + + this.loading = true; + + const email = this.email(); + const password = this.password(); + + app.session.login(email, password).then( + () => { + this.hide(); + if (this.props.onlogin) this.props.onlogin(); + }, + response => { + this.loading = false; + + if (response && response.code === 'confirm_email') { + this.alert = Alert.component({ + message: ['You need to confirm your email before you can log in. We\'ve sent a confirmation email to ', {response.email}, '. If it doesn\'t arrive soon, check your spam folder.'] + }); + } else { + this.alert = Alert.component({ + type: 'warning', + message: 'Your login details were incorrect.' + }); + } + + m.redraw(); + this.focus(); + } + ); + } +} diff --git a/js/forum/src/components/Modal.js b/js/forum/src/components/Modal.js new file mode 100644 index 000000000..fd58699fd --- /dev/null +++ b/js/forum/src/components/Modal.js @@ -0,0 +1,134 @@ +import Component from 'flarum/Component'; +import LoadingIndicator from 'flarum/components/LoadingIndicator'; +import Alert from 'flarum/components/Alert'; +import icon from 'flarum/helpers/icon'; + +/** + * The `Modal` component displays a modal dialog, wrapped in a form. Subclasses + * should implement the `className`, `title`, and `content` methods. + * + * @abstract + */ +export default class Modal extends Component { + constructor(...args) { + super(...args); + + /** + * An alert component to show below the header. + * + * @type {Alert} + */ + this.alert = null; + + /** + * Whether or not the form is processing. + * + * @type {Boolean} + */ + this.loading = false; + } + + view() { + if (this.alert) { + this.alert.props.dismissible = false; + } + + return ( +
    +
    + + +
    +
    +

    {this.title()}

    +
    + + {alert ?
    {this.alert}
    : ''} + + {this.content()} +
    +
    + + {LoadingIndicator.component({ + className: 'modal-loading' + (this.loading ? ' active' : '') + })} +
    + ); + } + + /** + * Get the class name to apply to the modal. + * + * @return {String} + * @abstract + */ + className() { + } + + /** + * Get the title of the modal dialog. + * + * @return {String} + * @abstract + */ + title() { + } + + /** + * Get the content of the modal. + * + * @return {VirtualElement} + * @abstract + */ + content() { + } + + /** + * Handle the modal form's submit event. + * + * @param {Event} e + */ + onsubmit() { + } + + /** + * Focus on the first input when the modal is ready to be used. + */ + onready() { + this.$(':input:first').select(); + } + + /** + * Hide the modal. + */ + hide() { + app.modal.close(); + } + + /** + * Show an alert describing errors returned from the API, and give focus to + * the first relevant field. + * + * @param {Array} errors + */ + handleErrors(errors) { + if (errors) { + this.alert(new Alert({ + type: 'warning', + message: errors.map((error, k) => [error.detail, k < errors.length - 1 ? m('br') : '']) + })); + } + + m.redraw(); + + if (errors) { + this.$('[name=' + errors[0].path + ']').select(); + } else { + this.$(':input:first').select(); + } + } +} diff --git a/js/forum/src/components/NotificationGrid.js b/js/forum/src/components/NotificationGrid.js new file mode 100644 index 000000000..ca3cdb57f --- /dev/null +++ b/js/forum/src/components/NotificationGrid.js @@ -0,0 +1,190 @@ +import Component from 'flarum/Component'; +import Checkbox from 'flarum/components/Checkbox'; +import icon from 'flarum/helpers/icon'; +import ItemList from 'flarum/utils/ItemList'; + +/** + * The `NotificationGrid` component displays a table of notification types and + * methods, allowing the user to toggle each combination. + * + * ### Props + * + * - `user` + */ +export default class NotificationGrid extends Component { + constructor(...args) { + super(...args); + + /** + * Information about the available notification methods. + * + * @type {Array} + */ + this.methods = [ + {name: 'alert', icon: 'bell', label: 'Alert'}, + {name: 'email', icon: 'envelope-o', label: 'Email'} + ]; + + /** + * A map of notification type-method combinations to the checkbox instances + * that represent them. + * + * @type {Object} + */ + this.inputs = {}; + + /** + * Information about the available notification types. + * + * @type {Object} + */ + this.types = this.notificationTypes().toArray(); + + // For each of the notification type-method combinations, create and store a + // new checkbox component instance, which we will render in the view. + this.types.forEach(type => { + this.methods.forEach(method => { + const key = this.preferenceKey(type.name, method.name); + const preference = this.props.user.preferences()[key]; + + this.inputs[key] = new Checkbox({ + state: !!preference, + disabled: typeof preference === 'undefined', + onchange: () => this.toggle([key]) + }); + }); + }); + } + + view() { + return ( + + + + + ))} + + + + + {this.types.map(type => ( + + + {this.methods.map(method => ( + + ))} + + ))} + +
    + {this.methods.map(method => ( + + {icon(method.icon)} {method.label} +
    + {type.label} + + {this.inputs[this.preferenceKey(type.name, method.name)].render()} +
    + ); + } + + 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 ( +
    +
    +
    + {Button.component({ + className: 'btn btn-icon btn-link btn-sm', + icon: 'check', + title: 'Mark All as Read', + onclick: this.markAllAsRead.bind(this) + })} +
    + +

    Notifications

    +
    + +
    + {groups.length + ? groups.map(group => { + const badges = group.discussion && group.discussion.badges().toArray(); + + return ( +
    + {group.discussion + ? ( + + {badges && badges.length ?
      {listItems(badges)}
    : ''} + {group.discussion.title()} +
    + ) : ( +
    + {app.forum.attribute('title')} +
    + )} + +
      + {group.notifications.map(notification => { + const NotificationComponent = app.notificationComponents[notification.contentType()]; + return NotificationComponent ?
    • {NotificationComponent.component({notification})}
    • : ''; + })} +
    +
    + ); + }) + : !this.loading + ?
    No Notifications
    + : LoadingIndicator.component({className: 'loading-indicator-block'})} +
    +
    + ); + } + + /** + * Load notifications into the application's cache if they haven't already + * been loaded. + */ + load() { + if (app.cache.notifications && !app.session.user.unreadNotificationsCount()) { + return; + } + + this.loading = true; + m.redraw(); + + app.store.find('notifications').then(notifications => { + app.session.user.pushAttributes({unreadNotificationsCount: 0}); + app.cache.notifications = notifications.sort((a, b) => b.time() - a.time()); + + this.loading = false; + m.redraw(); + }); + } + + /** + * Mark all of the notifications as read. + */ + markAllAsRead() { + if (!app.cache.notifications) return; + + app.cache.notifications.forEach(notification => { + if (!notification.isRead()) { + notification.save({isRead: true}); + } + }); + } +} diff --git a/js/forum/src/components/NotificationsDropdown.js b/js/forum/src/components/NotificationsDropdown.js new file mode 100644 index 000000000..c9dda4636 --- /dev/null +++ b/js/forum/src/components/NotificationsDropdown.js @@ -0,0 +1,44 @@ +import Component from 'flarum/Component'; +import icon from 'flarum/helpers/icon'; +import NotificationList from 'flarum/components/NotificationList'; + +export default class NotificationsDropdown extends Component { + constructor(...args) { + super(...args); + + /** + * Whether or not the notifications dropdown is visible. + * + * @type {Boolean} + */ + this.showing = false; + } + + view() { + const user = app.session.user; + const unread = user.unreadNotificationsCount(); + + return ( +
    + + {unread || icon('bell')} + Notifications + +
    + {this.showing ? NotificationList.component() : ''} +
    +
    + ); + } + + onclick() { + if (app.drawer.isOpen()) { + m.route(app.route('notifications')); + } else { + this.showing = true; + } + } +} diff --git a/js/forum/src/components/NotificationsPage.js b/js/forum/src/components/NotificationsPage.js new file mode 100644 index 000000000..eca99175e --- /dev/null +++ b/js/forum/src/components/NotificationsPage.js @@ -0,0 +1,20 @@ +import Component from 'flarum/Component'; +import NotificationList from 'flarum/components/NotificationList'; + +/** + * The `NotificationsPage` component shows the notifications list. It is only + * used on mobile devices where the notifications dropdown is within the drawer. + */ +export default class NotificationsPage extends Component { + constructor(...args) { + super(...args); + + app.current = this; + app.history.push('notifications'); + app.drawer.hide(); + } + + view() { + return
    {NotificationList.component()}
    ; + } +} diff --git a/js/forum/src/components/PostEdited.js b/js/forum/src/components/PostEdited.js new file mode 100644 index 000000000..660daf0ef --- /dev/null +++ b/js/forum/src/components/PostEdited.js @@ -0,0 +1,29 @@ +import Component from 'flarum/Component'; +import icon from 'flarum/helpers/icon'; +import humanTime from 'flarum/utils/humanTime'; + +/** + * The `PostEdited` component displays information about when and by whom a post + * was edited. + * + * ### Props + * + * - `post` + */ +export default class PostEdited extends Component { + view() { + const post = this.props.post; + const editUser = post.editUser(); + const title = 'Edited ' + (editUser ? 'by ' + editUser.username() + ' ' : '') + humanTime(post.editTime()); + + return ( + {icon('pencil')} + ); + } + + config(isInitialized) { + if (isInitialized) return; + + this.$().tooltip(); + } +} diff --git a/js/forum/src/components/PostMeta.js b/js/forum/src/components/PostMeta.js new file mode 100644 index 000000000..c6139d706 --- /dev/null +++ b/js/forum/src/components/PostMeta.js @@ -0,0 +1,45 @@ +import Component from 'flarum/Component'; +import humanTime from 'flarum/helpers/humanTime'; +import fullTime from 'flarum/helpers/fullTime'; + +/** + * The `PostMeta` component displays the time of a post, and when clicked, shows + * a dropdown containing more information about the post (number, full time, + * permalink). + * + * ### Props + * + * - `post` + */ +export default class PostMeta extends Component { + view() { + const post = this.props.post; + const time = post.time(); + const permalink = window.location.origin + app.route.post(post); + const touch = 'ontouchstart' in document.documentElement; + + // When the dropdown menu is shown, select the contents of the permalink + // input so that the user can quickly copy the URL. + const selectPermalink = function() { + setTimeout(() => $(this).parent().find('.permalink').select()); + + m.redraw.strategy('none'); + }; + + return ( +
    + + {humanTime(time)} + + +
    + Post #{post.number()} + {fullTime(time)} + {touch + ? {permalink} + : e.stopPropagation()} />} +
    +
    + ); + } +} 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 = [ +
    + {moment.duration(dt).humanize()} later +
    , + content + ]; + } + + lastTime = time; + } else { + attrs.key = this.visibleStart + i; + + content = PostLoading.component(); + } + + return
    {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) + ? ( +
    + {ReplyPlaceholder.component({discussion: this.discussion})} +
    + ) : '' + } +
    + ); + } + + config(isInitialized, context) { + if (isInitialized) return; + + // This is wrapped in setTimeout due to the following Mithril issue: + // https://github.com/lhorie/mithril.js/issues/637 + setTimeout(() => this.scrollListener.start()); + + context.onunload = () => { + this.scrollListener.stop(); + clearTimeout(this.calculatePositionTimeout); + }; + } + + /** + * When the window is scrolled, check if either extreme of the post stream is + * in the viewport, and if so, trigger loading the next/previous page. + * + * @param {Integer} top + */ + onscroll(top) { + if (this.paused) return; + + const marginTop = this.getMarginTop(); + const viewportHeight = $(window).height() - marginTop; + const viewportTop = top + marginTop; + const loadAheadDistance = 500; + + if (this.visibleStart > 0) { + const $item = this.$('.post-stream-item[data-index=' + this.visibleStart + ']'); + + if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) { + this.loadPrevious(); + } + } + + if (this.visibleEnd < this.count()) { + const $item = this.$('.post-stream-item[data-index=' + (this.visibleEnd - 1) + ']'); + + if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) { + this.loadNext(); + } + } + + // Throttle calculation of our position (start/end numbers of posts in the + // viewport) to 100ms. + clearTimeout(this.calculatePositionTimeout); + this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 100); + } + + /** + * Load the next page of posts. + */ + loadNext() { + const start = this.visibleEnd; + const end = this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount); + + // Unload the posts which are two pages back from the page we're currently + // loading. + const twoPagesAway = start - this.constructor.loadCount * 2; + if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) { + this.visibleStart = twoPagesAway + this.constructor.loadCount + 1; + clearTimeout(this.loadPageTimeouts[twoPagesAway]); + } + + this.loadPage(start, end); + } + + /** + * Load the previous page of posts. + */ + loadPrevious() { + const end = this.visibleStart; + const start = this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount); + + // Unload the posts which are two pages back from the page we're currently + // loading. + const twoPagesAway = start + this.constructor.loadCount * 2; + if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) { + this.visibleEnd = twoPagesAway; + clearTimeout(this.loadPageTimeouts[twoPagesAway]); + } + + this.loadPage(start, end, true); + } + + /** + * Load a page of posts into the stream and redraw. + * + * @param {Integer} start + * @param {Integer} end + * @param {Boolean} backwards + */ + loadPage(start, end, backwards) { + const redraw = () => { + if (start < this.visibleStart || end > this.visibleEnd) return; + + const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart; + anchorScroll(`.post-stream-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. + * + * @param {Integer} start + * @param {Integer} end + * @return {Promise} + */ + loadRange(start, end) { + const loadIds = []; + const loaded = []; + + this.discussion.postIds().slice(start, end).forEach(id => { + const post = app.store.getById('posts', id); + + if (!post) { + 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. + * + * @param {Integer} number + * @return {Promise} + */ + loadNearNumber(number) { + if (this.posts().some(post => post && post.number() === number)) { + return m.deferred().resolve().promise; + } + + this.reset(); + + return app.store.find('posts', { + filter: {discussion: this.discussion.id()}, + page: {near: number} + }).then(this.init.bind(this)); + } + + /** + * Clear the stream and load posts near a certain index. A page of posts + * surrounding the given index will be loaded. Returns a promise. If the given + * index is already loaded, the promise will be resolved immediately. + * + * @param {Integer} index + * @return {Promise} + */ + loadNearIndex(index) { + if (index >= this.visibleStart && index <= this.visibleEnd) { + return m.deferred().resolve().promise; + } + + const start = this.sanitizeIndex(index - this.constructor.loadCount / 2); + const end = start + this.constructor.loadCount; + + this.reset(start, end); + + return this.loadRange(start, end).then(this.init.bind(this)); + } + + /** + * Work out which posts (by number) are currently visible in the viewport, and + * fire an event with the information. + */ + calculatePosition() { + const marginTop = this.getMarginTop(); + const $window = $(window); + const viewportHeight = $window.height() - marginTop; + const scrollTop = $window.scrollTop() + marginTop; + let startNumber; + let endNumber; + + this.$('.post-stream-item').each(function() { + const $item = $(this); + const top = $item.offset().top; + const 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. + * + * @return {Integer} + */ + getMarginTop() { + return this.$() && $('.global-header').outerHeight() + parseInt(this.$().css('margin-top'), 10); + } + + /** + * Scroll down to a certain post by number and 'flash' it. + * + * @param {Integer} number + * @param {Boolean} noAnimation + * @return {jQuery.Deferred} + */ + scrollToNumber(number, noAnimation) { + const $item = this.$(`.post-stream-item[data-number=${number}]`); + + return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item)); + } + + /** + * Scroll down to a certain post by index. + * + * @param {Integer} index + * @param {Boolean} noAnimation + * @param {Boolean} bottom Whether or not to scroll to the bottom of the post + * at the given index, instead of the top of it. + * @return {jQuery.Deferred} + */ + scrollToIndex(index, noAnimation, bottom) { + const $item = this.$(`.post-stream-item[data-index=${index}]`); + + return this.scrollToItem($item, noAnimation, true, bottom); + } + + /** + * Scroll down to the given post. + * + * @param {jQuery} $item + * @param {Boolean} noAnimation + * @param {Boolean} force Whether or not to force scrolling to the item, even + * if it is already in the viewport. + * @param {Boolean} bottom Whether or not to scroll to the bottom of the post + * at the given index, instead of the top of it. + * @return {jQuery.Deferred} + */ + scrollToItem($item, noAnimation, force, bottom) { + const $container = $('html, body').stop(true); + + if ($item.length) { + const itemTop = $item.offset().top - this.getMarginTop(); + const itemBottom = itemTop + $item.height(); + const scrollTop = $(document).scrollTop(); + const scrollBottom = scrollTop + $(window).height(); + + // If the item is already in the viewport, we may not need to scroll. + if (force || itemTop < scrollTop || itemBottom > scrollBottom) { + const top = bottom ? itemBottom : ($item.is(':first-child') ? 0 : itemTop); + + if (noAnimation) { + $container.scrollTop(top); + } else if (top !== scrollTop) { + $container.animate({scrollTop: top}, 'fast'); + } + } + } + + return $container.promise(); + } + + /** + * 'Flash' the given post, drawing the user's attention to it. + * + * @param {jQuery} $item + */ + flashItem($item) { + $item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash')); + } + + /** + * Resume the stream's ability to auto-load posts on scroll. + */ + unpause() { + this.paused = false; + this.scrollListener.update(true); + this.trigger('unpaused'); + } +} + +/** + * The number of posts to load per page. + * + * @type {Integer} + */ +PostStream.loadCount = 20; + +export default PostStream; diff --git a/js/forum/src/components/PostStreamScrubber.js b/js/forum/src/components/PostStreamScrubber.js new file mode 100644 index 000000000..8255a72d1 --- /dev/null +++ b/js/forum/src/components/PostStreamScrubber.js @@ -0,0 +1,463 @@ +import Component from 'flarum/Component'; +import icon from 'flarum/helpers/icon'; +import ScrollListener from 'flarum/utils/ScrollListener'; +import SubtreeRetainer from 'flarum/utils/SubtreeRetainer'; +import computed from 'flarum/utils/computed'; +import formatNumber from 'flarum/utils/formatNumber'; + +/** + * The `PostStreamScrubber` component displays a scrubber which can be used to + * navigate/scrub through a post stream. + * + * ### Props + * + * - `stream` + * - `className` + */ +export default class PostStreamScrubber extends Component { + constructor(...args) { + super(...args); + + this.handlers = {}; + + /** + * The index of the post that is currently at the top of the viewport. + * + * @type {Number} + */ + this.index = 0; + + /** + * The number of posts that are currently visible in the viewport. + * + * @type {Number} + */ + this.visible = 1; + + /** + * The description to render on the scrubber. + * + * @type {String} + */ + this.description = ''; + + /** + * The integer index of the last item that is visible in the viewport. This + * is displayed on the scrubber (i.e. X of 100 posts). + * + * @return {Integer} + */ + this.visibleIndex = computed('index', 'visible', 'count', function(index, visible, count) { + return Math.min(count, Math.ceil(Math.max(0, index) + visible)); + }); + + // When the post stream begins loading posts at a certain index, we want our + // scrubber scrollbar to jump to that position. + this.props.stream.on('unpaused', this.handlers.streamWasUnpaused = this.streamWasUnpaused.bind(this)); + + // Define a handler to update the state of the scrollbar to reflect the + // current scroll position of the page. + this.scrollListener = new ScrollListener(this.onscroll.bind(this)); + + // Create a subtree retainer that will always cache the subtree after the + // initial draw. We render parts of the scrubber using this because we + // modify their DOM directly, and do not want Mithril messing around with + // our changes. + this.subtree = new SubtreeRetainer(() => true); + } + + view() { + const retain = this.subtree.retain(); + const unreadCount = this.props.stream.discussion.unreadCount(); + const unreadPercent = Math.min(this.count() - this.index, unreadCount) / this.count(); + + const viewing = [ + {retain || formatNumber(this.visibleIndex())}, + ' of ', + {formatNumber(this.count())}, + ' posts ' + ]; + + function styleUnread(element, isInitialized, context) { + const $element = $(element); + const newStyle = { + top: (100 - unreadPercent * 100) + '%', + height: (unreadPercent * 100) + '%' + }; + + if (context.oldStyle) { + $element.stop(true).css(context.oldStyle).animate(newStyle); + } else { + $element.css(newStyle); + } + + context.oldStyle = newStyle; + } + + return ( +
    + + {viewing} {icon('sort')} + + +
    +
    + + {icon('angle-double-up')} Original Post + + +
    +
    +
    +
    +
    + {viewing} + {retain || this.description} +
    +
    +
    + +
    + {formatNumber(unreadCount)} unread +
    +
    + + + {icon('angle-double-down')} Now + +
    +
    +
    + ); + } + + /** + * 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 ( +
    +

    {avatar(user)} {username(user)}

    +
    + ); + } + + let card = ''; + + if (!post.isHidden() && this.cardVisible) { + card = UserCard.component({ + user, + className: 'user-card-popover fade', + controlsButtonClassName: 'btn btn-default btn-icon btn-controls btn-naked' + }); + } + + return ( +
    +

    + + {avatar(user)} {username(user)} + +

    +
      + {listItems(user.badges().toArray())} +
    + {card} +
    + ); + } + + config(isInitialized) { + if (isInitialized) return; + + let timeout; + + this.$() + .on('mouseover', 'h3 a, .user-card', () => { + clearTimeout(timeout); + timeout = setTimeout(this.showCard.bind(this), 500); + }) + .on('mouseout', 'h3 a, .user-card', () => { + clearTimeout(timeout); + timeout = setTimeout(this.hideCard.bind(this), 250); + }); + } + + /** + * Show the user card. + */ + showCard() { + this.cardVisible = true; + + m.redraw(); + + setTimeout(() => this.$('.user-card').addClass('in')); + } + + /** + * Hide the user card. + */ + hideCard() { + this.$('.user-card').removeClass('in') + .one('transitionend', () => { + this.cardVisible = false; + m.redraw(); + }); + } +} diff --git a/js/forum/src/components/PostedActivity.js b/js/forum/src/components/PostedActivity.js new file mode 100644 index 000000000..89c176479 --- /dev/null +++ b/js/forum/src/components/PostedActivity.js @@ -0,0 +1,50 @@ +import Activity from 'flarum/components/Activity'; +import listItems from 'flarum/helpers/listItems'; +import ItemList from 'flarum/utils/ItemList'; +import { truncate } from 'flarum/utils/string'; + +/** + * The `PostedActivity` component displays an activity feed item for when a user + * started or posted in a discussion. + * + * ### Props + * + * - All of the props for Activity + */ +export default class PostedActivity extends Activity { + description() { + const post = this.props.activity.subject(); + + return post.number() === 1 ? 'Started a discussion' : 'Posted a reply'; + } + + content() { + const post = this.props.activity.subject(); + + return ( + +
      + {listItems(this.headerItems().toArray())} +
    +
    + {m.trust(truncate(post.contentPlain(), 200))} +
    +
    + ); + } + + /** + * Build an item list for the header of the post preview. + * + * @return {[type]} + */ + headerItems() { + const items = new ItemList(); + + items.add('title',

    {this.props.activity.subject().discussion().title()}

    ); + + return items; + } +} diff --git a/js/forum/src/components/ReplyComposer.js b/js/forum/src/components/ReplyComposer.js new file mode 100644 index 000000000..5b70124db --- /dev/null +++ b/js/forum/src/components/ReplyComposer.js @@ -0,0 +1,93 @@ +import ComposerBody from 'flarum/components/ComposerBody'; +import Alert from 'flarum/components/Alert'; +import Button from 'flarum/components/Button'; +import icon from 'flarum/helpers/icon'; + +/** + * The `ReplyComposer` component displays the composer content for replying to a + * discussion. + * + * ### Props + * + * - All of the props of ComposerBody + * - `discussion` + */ +export default class ReplyComposer extends ComposerBody { + static initProps(props) { + super.initProps(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?'; + } + + headerItems() { + const items = super.headerItems(); + const discussion = this.props.discussion; + + items.add('title', ( +

    + {icon('reply')} {discussion.title()} +

    + )); + + 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 ( +
    +
    + this.hasFocus = true} + onblur={() => this.hasFocus = false}/> + {this.loadingSources + ? LoadingIndicator.component({size: 'tiny', className: 'btn btn-icon btn-link'}) + : currentSearch + ? + : ''} +
    +
      + {this.sources.map(source => source.view(this.value()))} +
    +
    + ); + } + + 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 = [( +
    +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    +
    + )]; + + if (this.welcomeUser) { + const user = this.welcomeUser; + const emailProviderName = user.email().split('@')[1]; + + const fadeIn = (element, isInitialized) => { + if (isInitialized) return; + $(element).hide().fadeIn(); + }; + + body.push( +
    +
    +
    + {avatar(user)} +

    Welcome, {user.username()}!

    + + {user.isConfirmed() ? [ +

    We've sent a confirmation email to {user.email()}. If it doesn't arrive soon, check your spam folder.

    , +

    Go to {emailProviderName}

    + ] : ( +

    + )} +
    +
    + ); + } + + return body; + } + + footer() { + return [ +

    + Already have an account? + Log In +

    + ]; + } + + /** + * Open the log in modal, prefilling it with an email/username/password if + * the user has entered one. + */ + logIn() { + const props = { + email: this.email() || this.username(), + password: this.password() + }; + + app.modal.show(new LogInModal(props)); + } + + onready() { + if (this.props.username) { + this.$('[name=email]').select(); + } else { + super.onready(); + } + } + + onsubmit(e) { + e.preventDefault(); + + this.loading = true; + + const data = { + username: this.username(), + email: this.email(), + password: this.password() + }; + + app.store.createRecord('users').save(data).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/TerminalPost.js b/js/forum/src/components/TerminalPost.js new file mode 100644 index 000000000..55bee5e8e --- /dev/null +++ b/js/forum/src/components/TerminalPost.js @@ -0,0 +1,29 @@ +import Component from 'flarum/Component'; +import humanTime from 'flarum/helpers/humanTime'; +import username from 'flarum/helpers/username'; + +/** + * Displays information about a the first or last post in a discussion. + * + * ### Props + * + * - `discussion` + * - `lastPost` + */ +export default class TerminalPost extends Component { + view() { + const discussion = this.props.discussion; + const lastPost = this.props.lastPost && discussion.repliesCount(); + + const user = discussion[lastPost ? 'lastUser' : 'startUser'](); + const time = discussion[lastPost ? 'lastTime' : 'startTime'](); + + return ( + + {username(user)} + {lastPost ? 'replied' : 'started'} + {humanTime(time)} + + ); + } +} diff --git a/js/forum/src/components/TextEditor.js b/js/forum/src/components/TextEditor.js new file mode 100644 index 000000000..d19f84672 --- /dev/null +++ b/js/forum/src/components/TextEditor.js @@ -0,0 +1,158 @@ +import Component from 'flarum/Component'; +import ItemList from 'flarum/utils/ItemList'; +import listItems from 'flarum/helpers/listItems'; +import Button from 'flarum/components/Button'; + +/** + * The `TextEditor` component displays a textarea with controls, including a + * submit button. + * + * ### Props + * + * - `submitLabel` + * - `value` + * - `placeholder` + * - `disabled` + */ +export default class TextEditor extends Component { + constructor(...args) { + super(...args); + + /** + * The value of the textarea. + * + * @type {[type]} + */ + this.value = m.prop(this.props.value || ''); + } + + static initProps(props) { + super.initProps(props); + + props.submitLabel = props.submitLabel || 'Submit'; + } + + view() { + return ( +
    +