From cb0a47d5a1d7954fad83dcd59994c3a6fb0329c6 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 27 May 2015 16:18:21 +0930 Subject: [PATCH 01/51] Add unread indicator to scrubber. closes #94 --- .../forum/src/components/stream-scrubber.js | 20 ++++++++++++++++++- framework/core/less/forum/discussion.less | 17 ++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/framework/core/js/forum/src/components/stream-scrubber.js b/framework/core/js/forum/src/components/stream-scrubber.js index b29af44b2..727a599a4 100644 --- a/framework/core/js/forum/src/components/stream-scrubber.js +++ b/framework/core/js/forum/src/components/stream-scrubber.js @@ -41,6 +41,11 @@ export default class StreamScrubber extends Component { this.visible = m.prop(1); this.description = m.prop(); + this.unreadCount = () => { + var discussion = this.props.streamContent.props.stream.discussion; + return discussion.lastPostNumber() - discussion.readNumber(); + }; + // 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)); @@ -59,6 +64,8 @@ export default class StreamScrubber extends Component { view() { var retain = this.subtree.retain(); var streamContent = this.props.streamContent; + var unreadCount = this.unreadCount(); + var unreadPercent = unreadCount / this.count(); return m('div.stream-scrubber.dropdown'+(this.disabled() ? '.disabled' : ''), {config: this.onload.bind(this)}, [ m('a.btn.btn-default.dropdown-toggle[href=javascript:;][data-toggle=dropdown]', [ @@ -77,7 +84,18 @@ export default class StreamScrubber extends Component { m('span.description', retain || this.description()) ]) ]), - m('div.scrubber-after') + m('div.scrubber-after'), + (app.session.user() && unreadPercent) ? m('div.scrubber-unread', { + style: {top: (100 - unreadPercent * 100)+'%', height: (unreadPercent * 100)+'%'}, + config: function(element, isInitialized, context) { + var $element = $(element); + var newStyle = {top: $element.css('top'), height: $element.css('height')}; + if (context.oldStyle) { + $element.stop(true).css(context.oldStyle).animate(newStyle); + } + context.oldStyle = newStyle; + } + }, unreadCount+' unread') : '' ]), m('a.scrubber-last[href=javascript:;]', {onclick: streamContent.goToLast.bind(streamContent)}, [icon('angle-double-down'), ' Now']) ]) diff --git a/framework/core/less/forum/discussion.less b/framework/core/less/forum/discussion.less index 8c4132afa..7ceec871a 100644 --- a/framework/core/less/forum/discussion.less +++ b/framework/core/less/forum/discussion.less @@ -509,8 +509,23 @@ .scrubber-before, .scrubber-after { border-left: 1px solid @fl-body-secondary-color; } +.scrubber-unread { + position: absolute; + border-left: 1px solid lighten(@fl-body-muted-color, 10%); + width: 100%; + background-image: linear-gradient(to right, @fl-body-secondary-color, fade(@fl-body-secondary-color, 0) 10px, fade(@fl-body-secondary-color, 0)); + display: flex; + align-items: center; + color: @fl-body-muted-color; + text-transform: uppercase; + font-size: 11px; + font-weight: bold; + padding-left: 13px; +} .scrubber-slider { position: relative; + z-index: 1; + background: @fl-body-bg; width: 100%; padding: 5px 0; } @@ -522,6 +537,7 @@ float: left; margin-left: -2px; transition: background 0.2s; + .disabled & { background: @fl-body-secondary-color; } @@ -533,6 +549,7 @@ top: 50%; width: 100%; left: 15px; + & strong { display: block; } From fab2146a31690777d132df1bdbcc549146a68e86 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 27 May 2015 16:19:40 +0930 Subject: [PATCH 02/51] Hide "mark all as read" button from guests --- framework/core/js/forum/src/components/index-page.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework/core/js/forum/src/components/index-page.js b/framework/core/js/forum/src/components/index-page.js index bd6356bf5..4b0f2838d 100644 --- a/framework/core/js/forum/src/components/index-page.js +++ b/framework/core/js/forum/src/components/index-page.js @@ -105,12 +105,12 @@ export default class IndexPage extends Component { }), ]), m('div.index-toolbar-action', [ - ActionButton.component({ + app.session.user() ? ActionButton.component({ title: 'Mark All as Read', icon: 'check', className: 'control-markAllAsRead btn btn-default btn-icon', onclick: this.markAllAsRead.bind(this) - }) + }) : '' ]) ]), app.cache.discussionList.view() From 102c794a2ceabc03a4f3830558bb89b0e8dbdba5 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 27 May 2015 16:21:15 +0930 Subject: [PATCH 03/51] Allow ActionButtons to be disabled --- framework/core/js/lib/components/action-button.js | 6 ++++++ framework/core/less/lib/alerts.less | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/framework/core/js/lib/components/action-button.js b/framework/core/js/lib/components/action-button.js index 505d600cb..6971ddc92 100644 --- a/framework/core/js/lib/components/action-button.js +++ b/framework/core/js/lib/components/action-button.js @@ -12,6 +12,12 @@ export default class ActionButton extends Component { var label = attrs.label; delete attrs.label; + if (attrs.disabled) { + attrs.className = (attrs.className || '')+' disabled'; + delete attrs.onclick; + delete attrs.disabled; + } + attrs.href = attrs.href || 'javascript:;'; return m('a'+(iconName ? '.has-icon' : ''), attrs, [ iconName ? icon(iconName+' icon') : '', diff --git a/framework/core/less/lib/alerts.less b/framework/core/less/lib/alerts.less index 78da2b2b2..e97169b3f 100644 --- a/framework/core/less/lib/alerts.less +++ b/framework/core/less/lib/alerts.less @@ -38,6 +38,12 @@ text-transform: uppercase; font-size: 12px; font-weight: bold; + + &.disabled { + cursor: default; + text-decoration: none; + opacity: 0.5; + } } & .btn { margin: -10px; From f4dc1b5d04a12d19b8abc939f325497f9fa981f6 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 27 May 2015 16:22:02 +0930 Subject: [PATCH 04/51] Various appearance tweaks --- framework/core/less/forum/composer.less | 2 +- framework/core/less/forum/notifications.less | 2 +- framework/core/less/forum/signup.less | 2 +- framework/core/less/lib/alerts.less | 2 ++ framework/core/less/lib/components.less | 5 ++++- framework/core/less/lib/modals.less | 4 ++++ 6 files changed, 13 insertions(+), 4 deletions(-) diff --git a/framework/core/less/forum/composer.less b/framework/core/less/forum/composer.less index fe1169b36..feeac1ebf 100644 --- a/framework/core/less/forum/composer.less +++ b/framework/core/less/forum/composer.less @@ -118,7 +118,7 @@ background: @fl-body-bg; } &.active:not(.full-screen) { - box-shadow: 0 2px 6px @fl-shadow-color, 0 0 0 2px @fl-body-primary-color; + box-shadow: 0 0 0 2px @fl-body-primary-color, 0 2px 6px @fl-shadow-color; } &.minimized { height: 50px; diff --git a/framework/core/less/forum/notifications.less b/framework/core/less/forum/notifications.less index 3c02b0183..8453540d6 100644 --- a/framework/core/less/forum/notifications.less +++ b/framework/core/less/forum/notifications.less @@ -32,7 +32,7 @@ margin: -2px -3px; } &.unread .notifications-icon { - background: #e7562e; + background: @fl-body-primary-color; color: #fff; } .notifications-header { diff --git a/framework/core/less/forum/signup.less b/framework/core/less/forum/signup.less index 5f50a0e51..342a22ec9 100644 --- a/framework/core/less/forum/signup.less +++ b/framework/core/less/forum/signup.less @@ -5,7 +5,7 @@ right: 0; bottom: 0; border-radius: @border-radius-base; - padding: 40px 30px; + padding: 50px 30px; text-align: center; color: #fff; font-size: 14px; diff --git a/framework/core/less/lib/alerts.less b/framework/core/less/lib/alerts.less index e97169b3f..4c04bb7d1 100644 --- a/framework/core/less/lib/alerts.less +++ b/framework/core/less/lib/alerts.less @@ -14,6 +14,8 @@ padding: 12px 16px; border-radius: @border-radius-base; background: #FFF2AE; + line-height: 1.5; + &, & a, & a:hover { color: #AD6C00; } diff --git a/framework/core/less/lib/components.less b/framework/core/less/lib/components.less index f600174be..6ed347d00 100644 --- a/framework/core/less/lib/components.less +++ b/framework/core/less/lib/components.less @@ -10,7 +10,10 @@ .loading-indicator { position: relative; - color: @fl-secondary-color; +} +.loading-indicator-inline { + display: inline-block; + width: 25px; } .loading-indicator-block { height: 100px; diff --git a/framework/core/less/lib/modals.less b/framework/core/less/lib/modals.less index c3004bbb4..8312404d3 100644 --- a/framework/core/less/lib/modals.less +++ b/framework/core/less/lib/modals.less @@ -14,6 +14,10 @@ & .alert { border-radius: 0; } + & .alert-controls { + margin: 0; + display: block; + } } .modal-body { background-color: @fl-body-secondary-color; From b6a8416dafbcfd0234b5bf342420ab14086a7408 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 27 May 2015 16:24:54 +0930 Subject: [PATCH 05/51] Improve email changing/confirmation stuff --- .../src/components/change-email-modal.js | 43 +++++++++---- .../js/forum/src/components/login-modal.js | 25 +++++++- ...02_24_000000_create_email_tokens_table.php | 32 ++++++++++ .../2015_02_24_000000_create_users_table.php | 2 - .../core/src/Api/Actions/TokenAction.php | 6 ++ .../src/Core/Commands/ConfirmEmailCommand.php | 5 +- .../Events/UserEmailChangeWasRequested.php | 16 +++++ .../Commands/ConfirmEmailCommandHandler.php | 14 ++++- .../Commands/EditUserCommandHandler.php | 3 +- .../Events/EmailConfirmationMailer.php | 50 +++++++++++----- framework/core/src/Core/Models/EmailToken.php | 46 ++++++++++++++ framework/core/src/Core/Models/Model.php | 15 +++-- framework/core/src/Core/Models/User.php | 60 +++++++++---------- ...nfirmAction.php => ConfirmEmailAction.php} | 8 +-- framework/core/src/Forum/routes.php | 6 +- .../views/emails/activateAccount.blade.php | 8 +++ framework/core/views/emails/confirm.blade.php | 9 --- .../core/views/emails/confirmEmail.blade.php | 8 +++ 18 files changed, 262 insertions(+), 94 deletions(-) create mode 100644 framework/core/migrations/2015_02_24_000000_create_email_tokens_table.php create mode 100644 framework/core/src/Core/Events/UserEmailChangeWasRequested.php create mode 100644 framework/core/src/Core/Models/EmailToken.php rename framework/core/src/Forum/Actions/{ConfirmAction.php => ConfirmEmailAction.php} (69%) create mode 100644 framework/core/views/emails/activateAccount.blade.php delete mode 100644 framework/core/views/emails/confirm.blade.php create mode 100644 framework/core/views/emails/confirmEmail.blade.php diff --git a/framework/core/js/forum/src/components/change-email-modal.js b/framework/core/js/forum/src/components/change-email-modal.js index 8cd89d073..99da605d2 100644 --- a/framework/core/js/forum/src/components/change-email-modal.js +++ b/framework/core/js/forum/src/components/change-email-modal.js @@ -5,21 +5,39 @@ export default class ChangeEmailModal extends FormModal { constructor(props) { super(props); + this.success = m.prop(false); this.email = m.prop(app.session.user().email()); } view() { + if (this.success()) { + var emailProviderName = this.email().split('@')[1]; + } + var disabled = this.loading(); + return super.view({ className: 'modal-sm change-email-modal', title: 'Change Email', - body: [ - m('div.form-group', [ - m('input.form-control[type=email][name=email][placeholder=Email]', {value: this.email(), onchange: m.withAttr('value', this.email)}) - ]), - m('div.form-group', [ - m('button.btn.btn-primary.btn-block[type=submit]', 'Save Changes') - ]) - ] + body: this.success() + ? [ + m('p.help-text', 'We\'ve sent a confirmation email to ', m('strong', this.email()), '. If it doesn\'t arrive soon, check your spam folder.'), + m('div.form-group', [ + m('a.btn.btn-primary.btn-block', {href: 'http://'+emailProviderName}, 'Go to '+emailProviderName) + ]) + ] + : [ + m('div.form-group', [ + m('input.form-control[type=email][name=email]', { + placeholder: app.session.user().email(), + value: this.email(), + onchange: m.withAttr('value', this.email), + disabled + }) + ]), + m('div.form-group', [ + m('button.btn.btn-primary.btn-block[type=submit]', {disabled}, 'Save Changes') + ]) + ] }); } @@ -33,12 +51,13 @@ export default class ChangeEmailModal extends FormModal { this.loading(true); app.session.user().save({ email: this.email() }).then(() => { - this.hide(); + this.loading(false); + this.success(true); + this.alert(null); + m.redraw(); }, response => { this.loading(false); - this.alert = new Alert({ type: 'warning', message: response.errors.map((error, k) => [error.detail, k < response.errors.length - 1 ? m('br') : '']) }); - m.redraw(); - this.$('[name='+response.errors[0].path+']').select(); + this.handleErrors(response.errors); }); } } diff --git a/framework/core/js/forum/src/components/login-modal.js b/framework/core/js/forum/src/components/login-modal.js index e298787b2..cbecd24d1 100644 --- a/framework/core/js/forum/src/components/login-modal.js +++ b/framework/core/js/forum/src/components/login-modal.js @@ -3,6 +3,7 @@ import LoadingIndicator from 'flarum/components/loading-indicator'; import ForgotPasswordModal from 'flarum/components/forgot-password-modal'; import SignupModal from 'flarum/components/signup-modal'; import Alert from 'flarum/components/alert'; +import ActionButton from 'flarum/components/action-button'; import icon from 'flarum/helpers/icon'; export default class LoginModal extends FormModal { @@ -29,7 +30,11 @@ export default class LoginModal extends FormModal { ]) ], footer: [ - m('p.forgot-password-link', m('a[href=javascript:;]', {onclick: () => app.modal.show(new ForgotPasswordModal({email: this.email()}))}, 'Forgot password?')), + m('p.forgot-password-link', m('a[href=javascript:;]', {onclick: () => { + var email = this.email(); + var props = email.indexOf('@') !== -1 ? {email} : null; + app.modal.show(new ForgotPasswordModal(props)); + }}, 'Forgot password?')), m('p.sign-up-link', [ 'Don\'t have an account? ', m('a[href=javascript:;]', {onclick: () => { @@ -50,12 +55,26 @@ export default class LoginModal extends FormModal { onsubmit(e) { e.preventDefault(); this.loading(true); - app.session.login(this.email(), this.password()).then(() => { + var email = this.email(); + var password = this.password(); + + app.session.login(email, password).then(() => { this.hide(); this.props.callback && this.props.callback(); }, response => { this.loading(false); - this.alert = new Alert({ type: 'warning', message: 'Your login details were incorrect.' }); + if (response && response.code === 'confirm_email') { + var state; + + this.alert(Alert.component({ + message: ['You need to confirm your email before you can log in. We\'ve sent a confirmation email to ', m('strong', 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.ready(); }); diff --git a/framework/core/migrations/2015_02_24_000000_create_email_tokens_table.php b/framework/core/migrations/2015_02_24_000000_create_email_tokens_table.php new file mode 100644 index 000000000..2ea56ec32 --- /dev/null +++ b/framework/core/migrations/2015_02_24_000000_create_email_tokens_table.php @@ -0,0 +1,32 @@ +string('id', 100)->primary(); + $table->integer('user_id')->unsigned(); + $table->string('email', 150); + $table->timestamp('created_at'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('email_tokens'); + } +} diff --git a/framework/core/migrations/2015_02_24_000000_create_users_table.php b/framework/core/migrations/2015_02_24_000000_create_users_table.php index d44d5ab08..ec637bfc0 100644 --- a/framework/core/migrations/2015_02_24_000000_create_users_table.php +++ b/framework/core/migrations/2015_02_24_000000_create_users_table.php @@ -17,8 +17,6 @@ class CreateUsersTable extends Migration $table->increments('id'); $table->string('username', 100)->unique(); $table->string('email', 150)->unique(); - $table->boolean('is_confirmed')->default(0); - $table->string('confirmation_token', 50)->nullable(); $table->boolean('is_activated')->default(0); $table->string('password', 100); $table->text('bio')->nullable(); diff --git a/framework/core/src/Api/Actions/TokenAction.php b/framework/core/src/Api/Actions/TokenAction.php index 1836b8cb2..932095c9b 100644 --- a/framework/core/src/Api/Actions/TokenAction.php +++ b/framework/core/src/Api/Actions/TokenAction.php @@ -4,6 +4,7 @@ use Flarum\Api\Request; use Flarum\Core\Commands\GenerateAccessTokenCommand; use Flarum\Core\Repositories\UserRepositoryInterface; use Flarum\Core\Exceptions\PermissionDeniedException; +use Flarum\Core\Events\UserEmailChangeWasRequested; use Illuminate\Http\JsonResponse; use Illuminate\Contracts\Bus\Dispatcher; @@ -36,6 +37,11 @@ class TokenAction extends JsonApiAction throw new PermissionDeniedException; } + if (! $user->is_activated) { + event(new UserEmailChangeWasRequested($user, $user->email)); + return new JsonResponse(['code' => 'confirm_email', 'email' => $user->email], 401); + } + $token = $this->bus->dispatch( new GenerateAccessTokenCommand($user->id) ); diff --git a/framework/core/src/Core/Commands/ConfirmEmailCommand.php b/framework/core/src/Core/Commands/ConfirmEmailCommand.php index f1ffbf591..b792263a2 100644 --- a/framework/core/src/Core/Commands/ConfirmEmailCommand.php +++ b/framework/core/src/Core/Commands/ConfirmEmailCommand.php @@ -2,13 +2,10 @@ class ConfirmEmailCommand { - public $userId; - public $token; - public function __construct($userId, $token) + public function __construct($token) { - $this->userId = $userId; $this->token = $token; } } diff --git a/framework/core/src/Core/Events/UserEmailChangeWasRequested.php b/framework/core/src/Core/Events/UserEmailChangeWasRequested.php new file mode 100644 index 000000000..e83e249ab --- /dev/null +++ b/framework/core/src/Core/Events/UserEmailChangeWasRequested.php @@ -0,0 +1,16 @@ +user = $user; + $this->email = $email; + } +} diff --git a/framework/core/src/Core/Handlers/Commands/ConfirmEmailCommandHandler.php b/framework/core/src/Core/Handlers/Commands/ConfirmEmailCommandHandler.php index 76e80ddf9..ad92d5e5e 100644 --- a/framework/core/src/Core/Handlers/Commands/ConfirmEmailCommandHandler.php +++ b/framework/core/src/Core/Handlers/Commands/ConfirmEmailCommandHandler.php @@ -3,6 +3,8 @@ use Flarum\Core\Repositories\UserRepositoryInterface as UserRepository; use Flarum\Core\Events\UserWillBeSaved; use Flarum\Core\Support\DispatchesEvents; +use Flarum\Core\Exceptions\InvalidConfirmationTokenException; +use Flarum\Core\Models\EmailToken; class ConfirmEmailCommandHandler { @@ -17,10 +19,14 @@ class ConfirmEmailCommandHandler public function handle($command) { - $user = $this->users->findOrFail($command->userId); + $token = EmailToken::find($command->token)->first(); - $user->assertConfirmationTokenValid($command->token); - $user->confirmEmail(); + if (! $token) { + throw new InvalidConfirmationTokenException; + } + + $user = $token->user; + $user->changeEmail($token->email); if (! $user->is_activated) { $user->activate(); @@ -31,6 +37,8 @@ class ConfirmEmailCommandHandler $user->save(); $this->dispatchEventsFor($user); + $token->delete(); + return $user; } } diff --git a/framework/core/src/Core/Handlers/Commands/EditUserCommandHandler.php b/framework/core/src/Core/Handlers/Commands/EditUserCommandHandler.php index 85754f77e..76b19fb74 100644 --- a/framework/core/src/Core/Handlers/Commands/EditUserCommandHandler.php +++ b/framework/core/src/Core/Handlers/Commands/EditUserCommandHandler.php @@ -23,11 +23,12 @@ class EditUserCommandHandler $userToEdit->assertCan($user, 'edit'); if (isset($command->data['username'])) { + $userToEdit->assertCan($user, 'rename'); $userToEdit->rename($command->data['username']); } if (isset($command->data['email'])) { - $userToEdit->changeEmail($command->data['email']); + $userToEdit->requestEmailChange($command->data['email']); } if (isset($command->data['password'])) { diff --git a/framework/core/src/Core/Handlers/Events/EmailConfirmationMailer.php b/framework/core/src/Core/Handlers/Events/EmailConfirmationMailer.php index c0c11aafa..40ad7912a 100755 --- a/framework/core/src/Core/Handlers/Events/EmailConfirmationMailer.php +++ b/framework/core/src/Core/Handlers/Events/EmailConfirmationMailer.php @@ -2,8 +2,9 @@ use Illuminate\Mail\Mailer; use Flarum\Core\Events\UserWasRegistered; -use Flarum\Core\Events\EmailWasChanged; -use Config; +use Flarum\Core\Events\UserEmailChangeWasRequested; +use Flarum\Core; +use Flarum\Core\Models\EmailToken; use Illuminate\Contracts\Events\Dispatcher; class EmailConfirmationMailer @@ -23,28 +24,47 @@ class EmailConfirmationMailer public function subscribe(Dispatcher $events) { $events->listen('Flarum\Core\Events\UserWasRegistered', __CLASS__.'@whenUserWasRegistered'); - $events->listen('Flarum\Core\Events\EmailWasChanged', __CLASS__.'@whenEmailWasChanged'); + $events->listen('Flarum\Core\Events\UserEmailChangeWasRequested', __CLASS__.'@whenUserEmailChangeWasRequested'); } public function whenUserWasRegistered(UserWasRegistered $event) { $user = $event->user; + $data = $this->getPayload($user, $user->email); - $forumTitle = Config::get('flarum::forum_title'); - - $data = [ - 'username' => $user->username, - 'forumTitle' => $forumTitle, - 'url' => route('flarum.forum.confirm', ['id' => $user->id, 'token' => $user->confirmation_token]) - ]; - - $this->mailer->send(['text' => 'flarum::emails.confirm'], $data, function ($message) use ($user) { - $message->to($user->email); - $message->subject('Confirm Your Email Address'); + $this->mailer->send(['text' => 'flarum::emails.activateAccount'], $data, function ($message) use ($email) { + $message->to($email); + $message->subject('Activate Your New Account'); }); } - public function whenEmailWasChanged(EmailWasChanged $event) + public function whenUserEmailChangeWasRequested(UserEmailChangeWasRequested $event) { + $email = $event->email; + $data = $this->getPayload($event->user, $email); + + $this->mailer->send(['text' => 'flarum::emails.confirmEmail'], $data, function ($message) use ($email) { + $message->to($email); + $message->subject('Confirm Your New Email Address'); + }); + } + + protected function generateToken($user, $email) + { + $token = EmailToken::generate($user->id, $email); + $token->save(); + + return $token; + } + + protected function getPayload($user, $email) + { + $token = $this->generateToken($user, $email); + + return [ + 'username' => $user->username, + 'url' => route('flarum.forum.confirmEmail', ['token' => $token->id]), + 'forumTitle' => Core::config('forum_title') + ]; } } diff --git a/framework/core/src/Core/Models/EmailToken.php b/framework/core/src/Core/Models/EmailToken.php new file mode 100644 index 000000000..aeca16d70 --- /dev/null +++ b/framework/core/src/Core/Models/EmailToken.php @@ -0,0 +1,46 @@ +id = str_random(40); + $token->user_id = $userId; + $token->email = $email; + $token->created_at = time(); + + return $token; + } + + /** + * Define the relationship with the owner of this reset token. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo('Flarum\Core\Models\User'); + } +} diff --git a/framework/core/src/Core/Models/Model.php b/framework/core/src/Core/Models/Model.php index 0aae45949..18580ba69 100755 --- a/framework/core/src/Core/Models/Model.php +++ b/framework/core/src/Core/Models/Model.php @@ -110,14 +110,19 @@ class Model extends Eloquent */ public function assertValid() { - $validation = $this->makeValidator(); - if ($validation->fails()) { - throw (new ValidationFailureException) - ->setErrors($validation->errors()) - ->setInput($validation->getData()); + $validator = $this->makeValidator(); + if ($validator->fails()) { + $this->throwValidationFailureException($validator); } } + protected function throwValidationFailureException($validator) + { + throw (new ValidationFailureException) + ->setErrors($validator->errors()) + ->setInput($validator->getData()); + } + /** * Make a new validator instance for this model. * diff --git a/framework/core/src/Core/Models/User.php b/framework/core/src/Core/Models/User.php index 0bedb6ef9..fc80c87ab 100755 --- a/framework/core/src/Core/Models/User.php +++ b/framework/core/src/Core/Models/User.php @@ -13,6 +13,7 @@ use Flarum\Core\Events\UserBioWasChanged; use Flarum\Core\Events\UserAvatarWasChanged; use Flarum\Core\Events\UserWasActivated; use Flarum\Core\Events\UserEmailWasConfirmed; +use Flarum\Core\Events\UserEmailChangeWasRequested; class User extends Model { @@ -94,8 +95,6 @@ class User extends Model $user->password = $password; $user->join_time = time(); - $user->refreshConfirmationToken(); - $user->raise(new UserWasRegistered($user)); return $user; @@ -111,6 +110,7 @@ class User extends Model { if ($username !== $this->username) { $this->username = $username; + $this->raise(new UserWasRenamed($this)); } @@ -127,12 +127,31 @@ class User extends Model { if ($email !== $this->email) { $this->email = $email; + $this->raise(new UserEmailWasChanged($this)); } return $this; } + public function requestEmailChange($email) + { + if ($email !== $this->email) { + $validator = static::$validator->make( + compact('email'), + $this->expandUniqueRules(array_only(static::$rules, 'email')) + ); + + if ($validator->fails()) { + $this->throwValidationFailureException($validator); + } + + $this->raise(new UserEmailChangeWasRequested($this, $email)); + } + + return $this; + } + /** * Change the user's password. * @@ -257,41 +276,12 @@ class User extends Model public function activate() { $this->is_activated = true; - $this->groups()->sync([3]); $this->raise(new UserWasActivated($this)); return $this; } - /** - * Check if a given confirmation token is valid for this user. - * - * @param string $token - * @return boolean - */ - public function assertConfirmationTokenValid($token) - { - if ($this->is_confirmed || - ! $token || - $this->confirmation_token !== $token) { - throw new InvalidConfirmationTokenException; - } - } - - /** - * Generate a new confirmation token for the user. - * - * @return $this - */ - public function refreshConfirmationToken() - { - $this->is_confirmed = false; - $this->confirmation_token = str_random(30); - - return $this; - } - /** * Confirm the user's email. * @@ -461,7 +451,13 @@ class User extends Model */ public function permissions() { - return Permission::whereIn('group_id', array_merge([Group::GUEST_ID], $this->groups->lists('id'))); + $groupIds = [Group::GUEST_ID]; + + if ($this->is_activated) { + $groupIds = array_merge($groupIds, [Group::MEMBER_ID], $this->groups->lists('id')); + } + + return Permission::whereIn('group_id', $groupIds); } /** diff --git a/framework/core/src/Forum/Actions/ConfirmAction.php b/framework/core/src/Forum/Actions/ConfirmEmailAction.php similarity index 69% rename from framework/core/src/Forum/Actions/ConfirmAction.php rename to framework/core/src/Forum/Actions/ConfirmEmailAction.php index 61eecf602..cfc78c444 100644 --- a/framework/core/src/Forum/Actions/ConfirmAction.php +++ b/framework/core/src/Forum/Actions/ConfirmEmailAction.php @@ -5,16 +5,15 @@ use Flarum\Core\Commands\ConfirmEmailCommand; use Flarum\Core\Commands\GenerateAccessTokenCommand; use Flarum\Core\Exceptions\InvalidConfirmationTokenException; -class ConfirmAction extends BaseAction +class ConfirmEmailAction extends BaseAction { use MakesRememberCookie; public function handle(Request $request, $routeParams = []) { try { - $userId = array_get($routeParams, 'id'); $token = array_get($routeParams, 'token'); - $command = new ConfirmEmailCommand($userId, $token); + $command = new ConfirmEmailCommand($token); $user = $this->dispatch($command); } catch (InvalidConfirmationTokenException $e) { return 'Invalid confirmation token'; @@ -24,7 +23,6 @@ class ConfirmAction extends BaseAction $token = $this->dispatch($command); return redirect('/') - ->withCookie($this->makeRememberCookie($token->id)) - ->with('alert', ['type' => 'success', 'message' => 'Thanks for confirming!']); + ->withCookie($this->makeRememberCookie($token->id)); } } diff --git a/framework/core/src/Forum/routes.php b/framework/core/src/Forum/routes.php index 19d4ed31f..bf705b2b5 100755 --- a/framework/core/src/Forum/routes.php +++ b/framework/core/src/Forum/routes.php @@ -28,9 +28,9 @@ Route::post('login', [ 'uses' => $action('Flarum\Forum\Actions\LoginAction') ]); -Route::get('confirm/{id}/{token}', [ - 'as' => 'flarum.forum.confirm', - 'uses' => $action('Flarum\Forum\Actions\ConfirmAction') +Route::get('confirm/{token}', [ + 'as' => 'flarum.forum.confirmEmail', + 'uses' => $action('Flarum\Forum\Actions\ConfirmEmailAction') ]); Route::get('reset/{token}', [ diff --git a/framework/core/views/emails/activateAccount.blade.php b/framework/core/views/emails/activateAccount.blade.php new file mode 100644 index 000000000..10a235f77 --- /dev/null +++ b/framework/core/views/emails/activateAccount.blade.php @@ -0,0 +1,8 @@ +Hey {{ $username }}! + +Someone (hopefully you!) has signed up to {{ $forumTitle }} with this email address. + +If this was you, simply click the following link and your account will be activated: +{{ $url }} + +If you did not sign up, please ignore this email. diff --git a/framework/core/views/emails/confirm.blade.php b/framework/core/views/emails/confirm.blade.php deleted file mode 100644 index 5daff5fef..000000000 --- a/framework/core/views/emails/confirm.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -Hey {{ $username }}! - -Someone (hopefully you!) has signed up to the forum '{{ $forumTitle }}' with this email address. - -If this was you, simply visit the following link and your account will be activated: -{{ $url }} - -If you did not sign up, please ignore this email. - diff --git a/framework/core/views/emails/confirmEmail.blade.php b/framework/core/views/emails/confirmEmail.blade.php new file mode 100644 index 000000000..667366ad3 --- /dev/null +++ b/framework/core/views/emails/confirmEmail.blade.php @@ -0,0 +1,8 @@ +Hey {{ $username }}! + +Someone (hopefully you!) has changed their email address on {{ $forumTitle }} to this one. + +If this was you, simply click the following link and your email will be confirmed: +{{ $url }} + +If this was not you, please ignore this email. From 87f84f0614a833160beec6e4e4165744d95bd814 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 27 May 2015 16:25:44 +0930 Subject: [PATCH 06/51] Improvements to change/forgot password --- .../src/components/change-password-modal.js | 2 +- .../src/components/forgot-password-modal.js | 9 ++++--- .../js/forum/src/components/form-modal.js | 24 +++++++++++++++---- .../js/forum/src/components/signup-modal.js | 12 +++++++--- framework/core/js/lib/model.js | 18 ++++++++++++++ ...2_24_000000_create_access_tokens_table.php | 4 ++-- ...4_000000_create_password_tokens_table.php} | 9 +++---- framework/core/src/Console/SeedCommand.php | 2 +- framework/core/src/Core.php | 4 ++++ .../RequestPasswordResetCommandHandler.php | 10 ++++---- .../core/src/Core/Models/AccessToken.php | 12 +++++++++- .../{ResetToken.php => PasswordToken.php} | 5 ++-- .../src/Forum/Actions/ResetPasswordAction.php | 4 ++-- .../src/Forum/Actions/SavePasswordAction.php | 4 ++-- framework/core/views/emails/reset.blade.php | 3 --- .../core/views/emails/resetPassword.blade.php | 8 +++++++ 16 files changed, 96 insertions(+), 34 deletions(-) rename framework/core/migrations/{2015_02_24_000000_create_reset_tokens_table.php => 2015_02_24_000000_create_password_tokens_table.php} (59%) rename framework/core/src/Core/Models/{ResetToken.php => PasswordToken.php} (87%) delete mode 100644 framework/core/views/emails/reset.blade.php create mode 100644 framework/core/views/emails/resetPassword.blade.php diff --git a/framework/core/js/forum/src/components/change-password-modal.js b/framework/core/js/forum/src/components/change-password-modal.js index b77591dd8..d8103466b 100644 --- a/framework/core/js/forum/src/components/change-password-modal.js +++ b/framework/core/js/forum/src/components/change-password-modal.js @@ -8,7 +8,7 @@ export default class ChangePasswordModal extends FormModal { body: [ m('p.help-text', 'Click the button below and check your email for a link to change your password.'), m('div.form-group', [ - m('button.btn.btn-primary.btn-block[type=submit]', 'Send Password Reset Email') + m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Send Password Reset Email') ]) ] }); diff --git a/framework/core/js/forum/src/components/forgot-password-modal.js b/framework/core/js/forum/src/components/forgot-password-modal.js index cdfa74593..92a7949e0 100644 --- a/framework/core/js/forum/src/components/forgot-password-modal.js +++ b/framework/core/js/forum/src/components/forgot-password-modal.js @@ -21,13 +21,13 @@ export default class ForgotPasswordModal extends FormModal { title: 'Forgot Password', body: this.success() ? [ - m('p.help-text', 'OK, 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. Yeah, sometimes we get put through to spam - can you believe it?!'), + m('p.help-text', '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.'), m('div.form-group', [ m('a.btn.btn-primary.btn-block', {href: 'http://'+emailProviderName}, 'Go to '+emailProviderName) ]) ] : [ - m('p.help-text', 'Forgot your password? Don\'t worry, it happens all the time. Simply enter your email address and we\'ll send you instructions on how to set up a new one.'), + m('p.help-text', 'Enter your email address and we\'ll send you a link to reset your password.'), m('div.form-group', [ m('input.form-control[name=email][placeholder=Email]', {value: this.email(), onchange: m.withAttr('value', this.email), disabled: this.loading()}) ]), @@ -57,12 +57,11 @@ export default class ForgotPasswordModal extends FormModal { }).then(response => { this.loading(false); this.success(true); - this.alert = null; + this.alert(null); m.redraw(); }, response => { this.loading(false); - m.redraw(); - this.ready(); + this.handleErrors(response.errors); }); } } diff --git a/framework/core/js/forum/src/components/form-modal.js b/framework/core/js/forum/src/components/form-modal.js index b32babd2c..bfae1bbf7 100644 --- a/framework/core/js/forum/src/components/form-modal.js +++ b/framework/core/js/forum/src/components/form-modal.js @@ -7,13 +7,14 @@ export default class FormModal extends Component { constructor(props) { super(props); - this.alert = null; + this.alert = m.prop(); this.loading = m.prop(false); } view(options) { - if (this.alert) { - this.alert.props.dismissible = false; + var alert = this.alert(); + if (alert) { + alert.props.dismissible = false; } return m('div.modal-dialog', {className: options.className, config: this.element}, [ @@ -21,7 +22,7 @@ export default class FormModal extends Component { m('a[href=javascript:;].btn.btn-icon.btn-link.close.back-control', {onclick: this.hide.bind(this)}, icon('times')), m('form', {onsubmit: this.onsubmit.bind(this)}, [ m('div.modal-header', m('h3.title-control', options.title)), - this.alert ? m('div.modal-alert', this.alert.view()) : '', + alert ? m('div.modal-alert', alert) : '', m('div.modal-body', [ m('div.form-centered', options.body) ]), @@ -39,4 +40,19 @@ export default class FormModal extends Component { hide() { app.modal.close(); } + + 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(); + } + } } diff --git a/framework/core/js/forum/src/components/signup-modal.js b/framework/core/js/forum/src/components/signup-modal.js index 8777b991e..6b8ddd545 100644 --- a/framework/core/js/forum/src/components/signup-modal.js +++ b/framework/core/js/forum/src/components/signup-modal.js @@ -67,6 +67,14 @@ export default class SignupModal extends FormModal { return vdom; } + ready() { + if (this.props.username) { + this.$('[name=email]').select(); + } else { + super.ready(); + } + } + fadeIn(element, isInitialized) { if (isInitialized) { return; } $(element).hide().fadeIn(); @@ -86,9 +94,7 @@ export default class SignupModal extends FormModal { m.redraw(); }, response => { this.loading(false); - this.alert = new Alert({ type: 'warning', message: response.errors.map((error, k) => [error.detail, k < response.errors.length - 1 ? m('br') : '']) }); - m.redraw(); - this.$('[name='+response.errors[0].path+']').select(); + this.handleErrors(response.errors); }); } } diff --git a/framework/core/js/lib/model.js b/framework/core/js/lib/model.js index 376a1742e..ecfbb5bc3 100644 --- a/framework/core/js/lib/model.js +++ b/framework/core/js/lib/model.js @@ -34,6 +34,21 @@ export default class Model { } } + // clone the relevant parts of the model's old data so that we can revert + // back if the save fails + var oldData = {}; + var currentData = this.data(); + for (var i in data) { + if (i === 'links') { + oldData[i] = oldData[i] || {}; + for (var j in newData[i]) { + oldData[i][j] = currentData[i][j]; + } + } else { + oldData[i] = currentData[i]; + } + } + this.pushData(data); return app.request({ @@ -45,6 +60,9 @@ export default class Model { }).then(payload => { this.store.data[payload.data.type][payload.data.id] = this; return this.store.pushPayload(payload); + }, response => { + this.pushData(oldData); + throw response; }); } diff --git a/framework/core/migrations/2015_02_24_000000_create_access_tokens_table.php b/framework/core/migrations/2015_02_24_000000_create_access_tokens_table.php index 24b942eb6..08a123745 100644 --- a/framework/core/migrations/2015_02_24_000000_create_access_tokens_table.php +++ b/framework/core/migrations/2015_02_24_000000_create_access_tokens_table.php @@ -5,7 +5,6 @@ use Illuminate\Database\Migrations\Migration; class CreateAccessTokensTable extends Migration { - /** * Run the migrations. * @@ -14,9 +13,10 @@ class CreateAccessTokensTable extends Migration public function up() { Schema::create('access_tokens', function (Blueprint $table) { - $table->string('id', 100)->primary(); $table->integer('user_id')->unsigned(); + $table->timestamp('created_at'); + $table->timestamp('expires_at'); }); } diff --git a/framework/core/migrations/2015_02_24_000000_create_reset_tokens_table.php b/framework/core/migrations/2015_02_24_000000_create_password_tokens_table.php similarity index 59% rename from framework/core/migrations/2015_02_24_000000_create_reset_tokens_table.php rename to framework/core/migrations/2015_02_24_000000_create_password_tokens_table.php index 63c2c5b5d..5d3ec844f 100644 --- a/framework/core/migrations/2015_02_24_000000_create_reset_tokens_table.php +++ b/framework/core/migrations/2015_02_24_000000_create_password_tokens_table.php @@ -3,7 +3,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; -class CreateResetTokensTable extends Migration +class CreatePasswordTokensTable extends Migration { /** * Run the migrations. @@ -12,9 +12,10 @@ class CreateResetTokensTable extends Migration */ public function up() { - Schema::create('reset_tokens', function (Blueprint $table) { - $table->string('id'); + Schema::create('password_tokens', function (Blueprint $table) { + $table->string('id', 100)->primary(); $table->integer('user_id')->unsigned(); + $table->timestamp('created_at'); }); } @@ -25,6 +26,6 @@ class CreateResetTokensTable extends Migration */ public function down() { - Schema::drop('reset_tokens'); + Schema::drop('password_tokens'); } } diff --git a/framework/core/src/Console/SeedCommand.php b/framework/core/src/Console/SeedCommand.php index f8db5eee4..3b5957479 100644 --- a/framework/core/src/Console/SeedCommand.php +++ b/framework/core/src/Console/SeedCommand.php @@ -41,8 +41,8 @@ class SeedCommand extends Command */ public function fire() { - $this->call('db:seed', ['--class' => 'Flarum\Core\Seeders\DiscussionsTableSeeder']); $this->call('db:seed', ['--class' => 'Flarum\Core\Seeders\UsersTableSeeder']); + $this->call('db:seed', ['--class' => 'Flarum\Core\Seeders\DiscussionsTableSeeder']); } /** diff --git a/framework/core/src/Core.php b/framework/core/src/Core.php index 83262ba9a..bf77ed751 100644 --- a/framework/core/src/Core.php +++ b/framework/core/src/Core.php @@ -11,6 +11,10 @@ class Core public static function config($key, $default = null) { + if (! static::isInstalled()) { + return $default; + } + if (is_null($value = DB::table('config')->where('key', $key)->pluck('value'))) { $value = $default; } diff --git a/framework/core/src/Core/Handlers/Commands/RequestPasswordResetCommandHandler.php b/framework/core/src/Core/Handlers/Commands/RequestPasswordResetCommandHandler.php index ad636d8f2..b8e7e3c2b 100644 --- a/framework/core/src/Core/Handlers/Commands/RequestPasswordResetCommandHandler.php +++ b/framework/core/src/Core/Handlers/Commands/RequestPasswordResetCommandHandler.php @@ -1,10 +1,11 @@ id); + $token = PasswordToken::generate($user->id); $token->save(); $data = [ 'username' => $user->username, - 'url' => route('flarum.forum.resetPassword', ['token' => $token->id]) + 'url' => route('flarum.forum.resetPassword', ['token' => $token->id]), + 'forumTitle' => Core::config('forum_title') ]; - $this->mailer->send(['text' => 'flarum::emails.reset'], $data, function ($message) use ($user) { + $this->mailer->send(['text' => 'flarum::emails.resetPassword'], $data, function ($message) use ($user) { $message->to($user->email); $message->subject('Reset Your Password'); }); diff --git a/framework/core/src/Core/Models/AccessToken.php b/framework/core/src/Core/Models/AccessToken.php index 195f5ed84..7adb8f652 100644 --- a/framework/core/src/Core/Models/AccessToken.php +++ b/framework/core/src/Core/Models/AccessToken.php @@ -16,18 +16,28 @@ class AccessToken extends Model */ public $incrementing = false; + /** + * The attributes that should be mutated to dates. + * + * @var array + */ + protected $dates = ['created_at', 'expires_at']; + /** * Generate an access token for the specified user. * * @param int $userId + * @param int $minutes * @return static */ - public static function generate($userId) + public static function generate($userId, $minutes = 60) { $token = new static; $token->id = str_random(40); $token->user_id = $userId; + $token->created_at = time(); + $token->expires_at = time() + $minutes * 60; return $token; } diff --git a/framework/core/src/Core/Models/ResetToken.php b/framework/core/src/Core/Models/PasswordToken.php similarity index 87% rename from framework/core/src/Core/Models/ResetToken.php rename to framework/core/src/Core/Models/PasswordToken.php index 964f2799d..6896a395e 100644 --- a/framework/core/src/Core/Models/ResetToken.php +++ b/framework/core/src/Core/Models/PasswordToken.php @@ -1,13 +1,13 @@ id = str_random(40); $token->user_id = $userId; + $token->created_at = time(); return $token; } diff --git a/framework/core/src/Forum/Actions/ResetPasswordAction.php b/framework/core/src/Forum/Actions/ResetPasswordAction.php index 7344c4128..bc88f5b43 100644 --- a/framework/core/src/Forum/Actions/ResetPasswordAction.php +++ b/framework/core/src/Forum/Actions/ResetPasswordAction.php @@ -1,6 +1,6 @@ with('token', $token->id); } diff --git a/framework/core/src/Forum/Actions/SavePasswordAction.php b/framework/core/src/Forum/Actions/SavePasswordAction.php index 137a9eddc..69666cdb3 100644 --- a/framework/core/src/Forum/Actions/SavePasswordAction.php +++ b/framework/core/src/Forum/Actions/SavePasswordAction.php @@ -1,6 +1,6 @@ get('token')); + $token = PasswordToken::findOrFail($request->get('token')); $password = $request->get('password'); $confirmation = $request->get('password_confirmation'); diff --git a/framework/core/views/emails/reset.blade.php b/framework/core/views/emails/reset.blade.php deleted file mode 100644 index 75b2bcb2d..000000000 --- a/framework/core/views/emails/reset.blade.php +++ /dev/null @@ -1,3 +0,0 @@ -Hey {{ $username }}! - -Click here to reset your password: {{ $url }} diff --git a/framework/core/views/emails/resetPassword.blade.php b/framework/core/views/emails/resetPassword.blade.php new file mode 100644 index 000000000..1f808d99e --- /dev/null +++ b/framework/core/views/emails/resetPassword.blade.php @@ -0,0 +1,8 @@ +Hey {{ $username }}! + +Someone (hopefully you!) has submitted a forgotten password request for your account on the {{ $forumTitle }}. + +If this was you, click the following link to reset your password: +{{ $url }} + +If you do not wish to change your password, just ignore this email and nothing will happen. From 1bb5ef2d723cb3301f902b8f866f4b5873de0c5c Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 29 May 2015 18:17:50 +0930 Subject: [PATCH 07/51] New and improved post stream. --- .../forum/src/components/discussion-page.js | 59 +-- .../js/forum/src/components/post-loading.js | 11 + .../{stream-scrubber.js => post-scrubber.js} | 225 +++------ .../js/forum/src/components/post-stream.js | 463 ++++++++++++++++++ .../js/forum/src/components/reply-composer.js | 2 +- .../js/forum/src/components/stream-content.js | 360 -------------- .../js/forum/src/components/stream-item.js | 112 ----- .../core/js/forum/src/components/user-page.js | 2 - .../src/initializers/discussion-controls.js | 4 +- .../core/js/forum/src/utils/post-stream.js | 170 ------- framework/core/js/lib/model.js | 2 +- framework/core/js/lib/models/discussion.js | 1 + framework/core/js/lib/utils/anchor-scroll.js | 7 + framework/core/less/forum/discussion.less | 86 +--- framework/core/less/lib/variables.less | 4 +- 15 files changed, 609 insertions(+), 899 deletions(-) create mode 100644 framework/core/js/forum/src/components/post-loading.js rename framework/core/js/forum/src/components/{stream-scrubber.js => post-scrubber.js} (61%) create mode 100644 framework/core/js/forum/src/components/post-stream.js delete mode 100644 framework/core/js/forum/src/components/stream-content.js delete mode 100644 framework/core/js/forum/src/components/stream-item.js delete mode 100644 framework/core/js/forum/src/utils/post-stream.js create mode 100644 framework/core/js/lib/utils/anchor-scroll.js diff --git a/framework/core/js/forum/src/components/discussion-page.js b/framework/core/js/forum/src/components/discussion-page.js index f92ffe7b6..8ba62ccda 100644 --- a/framework/core/js/forum/src/components/discussion-page.js +++ b/framework/core/js/forum/src/components/discussion-page.js @@ -1,10 +1,9 @@ import Component from 'flarum/component'; import ItemList from 'flarum/utils/item-list'; -import PostStream from 'flarum/utils/post-stream'; import DiscussionList from 'flarum/components/discussion-list'; import DiscussionHero from 'flarum/components/discussion-hero'; -import StreamContent from 'flarum/components/stream-content'; -import StreamScrubber from 'flarum/components/stream-scrubber'; +import PostStream from 'flarum/components/post-stream'; +import PostScrubber from 'flarum/components/post-scrubber'; import ReplyComposer from 'flarum/components/reply-composer'; import ActionButton from 'flarum/components/action-button'; import LoadingIndicator from 'flarum/components/loading-indicator'; @@ -22,24 +21,13 @@ export default class DiscussionPage extends mixin(Component, evented) { super(props); this.discussion = m.prop(); - - // Set up the stream. The stream is an object that represents the posts in - // a discussion, as they're displayed on the screen (i.e. missing posts - // are condensed into "load more" gaps). - this.stream = m.prop(); - - // Get the discussion. We may already have a copy of it in our store, so - // we'll start off with that. If we do have a copy of the discussion, and - // its posts relationship has been loaded (i.e. we've viewed this - // discussion before), then we can proceed with displaying it immediately. - // If not, we'll make an API request first. this.refresh(); if (app.cache.discussionList) { if (!(app.current instanceof DiscussionPage)) { app.cache.discussionList.subtrees.map(subtree => subtree.invalidate()); } else { - m.redraw.strategy('diff'); // otherwise pane redraws (killing retained subtrees) and mouseenter even is triggered so it doesn't hide + m.redraw.strategy('diff'); // otherwise pane redraws (killing retained subtrees) and mouseenter event is triggered so it doesn't hide } app.pane.enable(); app.pane.hide(); @@ -74,25 +62,6 @@ export default class DiscussionPage extends mixin(Component, evented) { */ setupDiscussion(discussion) { - this.discussion(discussion); - - var includedPosts = []; - discussion.payload.included && discussion.payload.included.forEach(record => { - if (record.type === 'posts' && (record.contentType !== 'comment' || record.contentHtml)) { - includedPosts.push(record.id); - } - }); - - // Set up the post stream for this discussion, and add all of the posts we - // have loaded so far. - this.stream(new PostStream(discussion)); - this.stream().addPosts(discussion.posts().filter(value => value && includedPosts.indexOf(value.id()) !== -1)); - this.streamContent = new StreamContent({ - stream: this.stream(), - className: 'discussion-posts posts', - positionChanged: this.positionChanged.bind(this) - }); - // Hold up there skippy! If the slug in the URL doesn't match up, we'll // redirect so we have the correct one. // Waiting on https://github.com/lhorie/mithril.js/issues/539 @@ -104,11 +73,19 @@ export default class DiscussionPage extends mixin(Component, evented) { // return; // } - this.streamContent.goToNumber(this.currentNear, true); - + this.discussion(discussion); app.setTitle(discussion.title()); - this.trigger('loaded'); + var includedPosts = []; + discussion.payload.included && discussion.payload.included.forEach(record => { + if (record.type === 'posts' && (record.contentType !== 'comment' || record.contentHtml)) { + includedPosts.push(app.store.getById('posts', record.id)); + } + }); + + this.stream = new PostStream({ discussion, includedPosts }); + this.stream.on('positionChanged', this.positionChanged.bind(this)); + this.stream.goToNumber(m.route.param('near') || 1, true); } onload(element, isInitialized, context) { @@ -134,7 +111,7 @@ export default class DiscussionPage extends mixin(Component, evented) { if (m.route.param('id') == discussion.id()) { e.preventDefault(); if (m.route.param('near') != this.currentNear) { - this.streamContent.goToNumber(m.route.param('near')); + this.stream.goToNumber(m.route.param('near') || 1); } this.currentNear = null; return; @@ -160,7 +137,7 @@ export default class DiscussionPage extends mixin(Component, evented) { m('nav.discussion-nav', [ m('ul', listItems(this.sidebarItems().toArray())) ]), - this.streamContent.view() + this.stream.view() ]) ] : LoadingIndicator.component({className: 'loading-indicator-block'})) ]); @@ -219,8 +196,8 @@ export default class DiscussionPage extends mixin(Component, evented) { ); items.add('scrubber', - StreamScrubber.component({ - streamContent: this.streamContent, + PostScrubber.component({ + stream: this.stream, wrapperClass: 'title-control' }) ); diff --git a/framework/core/js/forum/src/components/post-loading.js b/framework/core/js/forum/src/components/post-loading.js new file mode 100644 index 000000000..2a9d1d868 --- /dev/null +++ b/framework/core/js/forum/src/components/post-loading.js @@ -0,0 +1,11 @@ +import Component from 'flarum/component'; +import avatar from 'flarum/helpers/avatar'; + +export default class PostLoadingComponent extends Component { + view() { + return m('div.post.comment-post.loading-post.fake-post', + m('header.post-header', avatar(), m('div.fake-text')), + m('div.post-body', m('div.fake-text'), m('div.fake-text'), m('div.fake-text')) + ); + } +} diff --git a/framework/core/js/forum/src/components/stream-scrubber.js b/framework/core/js/forum/src/components/post-scrubber.js similarity index 61% rename from framework/core/js/forum/src/components/stream-scrubber.js rename to framework/core/js/forum/src/components/post-scrubber.js index 727a599a4..3ec1ae9ef 100644 --- a/framework/core/js/forum/src/components/stream-scrubber.js +++ b/framework/core/js/forum/src/components/post-scrubber.js @@ -7,26 +7,19 @@ import computed from 'flarum/utils/computed'; /** */ -export default class StreamScrubber extends Component { +export default class PostScrubber extends Component { /** */ constructor(props) { super(props); - var streamContent = this.props.streamContent; + var stream = this.props.stream; this.handlers = {}; // When the stream-content component begins loading posts at a certain // index, we want our scrubber scrollbar to jump to that position. - streamContent.on('loadingIndex', this.handlers.loadingIndex = this.loadingIndex.bind(this)); - streamContent.on('unpaused', this.handlers.unpaused = this.unpaused.bind(this)); - - /** - Disable the scrubber if the stream's initial content isn't loaded, or - if all of the posts in the discussion are visible in the viewport. - */ - this.disabled = () => !streamContent.loaded() || this.visible() >= this.count(); + stream.on('unpaused', this.handlers.unpaused = this.unpaused.bind(this)); /** The integer index of the last item that is visible in the viewport. This @@ -36,16 +29,11 @@ export default class StreamScrubber extends Component { return Math.min(count, Math.ceil(Math.max(0, index) + visible)); }); - this.count = () => this.props.streamContent.props.stream.count(); + this.count = () => this.props.stream.count(); this.index = m.prop(-1); this.visible = m.prop(1); this.description = m.prop(); - this.unreadCount = () => { - var discussion = this.props.streamContent.props.stream.discussion; - return discussion.lastPostNumber() - discussion.readNumber(); - }; - // 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)); @@ -58,13 +46,21 @@ export default class StreamScrubber extends Component { this.renderScrollbar(true); } + /** + Disable the scrubber if the stream's initial content isn't loaded, or + if all of the posts in the discussion are visible in the viewport. + */ + disabled() { + return this.visible() >= this.count(); + } + /** */ view() { var retain = this.subtree.retain(); - var streamContent = this.props.streamContent; - var unreadCount = this.unreadCount(); + var stream = this.props.stream; + var unreadCount = this.props.stream.discussion.unreadCount(); var unreadPercent = unreadCount / this.count(); return m('div.stream-scrubber.dropdown'+(this.disabled() ? '.disabled' : ''), {config: this.onload.bind(this)}, [ @@ -74,7 +70,11 @@ export default class StreamScrubber extends Component { ]), m('div.dropdown-menu', [ m('div.scrubber', [ - m('a.scrubber-first[href=javascript:;]', {onclick: streamContent.goToFirst.bind(streamContent)}, [icon('angle-double-up'), ' Original Post']), + m('a.scrubber-first[href=javascript:;]', {onclick: () => { + stream.goToFirst(); + this.index(0); + this.renderScrollbar(true); + }}, [icon('angle-double-up'), ' Original Post']), m('div.scrubber-scrollbar', [ m('div.scrubber-before'), m('div.scrubber-slider', [ @@ -89,7 +89,7 @@ export default class StreamScrubber extends Component { style: {top: (100 - unreadPercent * 100)+'%', height: (unreadPercent * 100)+'%'}, config: function(element, isInitialized, context) { var $element = $(element); - var newStyle = {top: $element.css('top'), height: $element.css('height')}; + var newStyle = {top: (100 - unreadPercent * 100)+'%', height: (unreadPercent * 100)+'%'}; if (context.oldStyle) { $element.stop(true).css(context.oldStyle).animate(newStyle); } @@ -97,16 +97,20 @@ export default class StreamScrubber extends Component { } }, unreadCount+' unread') : '' ]), - m('a.scrubber-last[href=javascript:;]', {onclick: streamContent.goToLast.bind(streamContent)}, [icon('angle-double-down'), ' Now']) + m('a.scrubber-last[href=javascript:;]', {onclick: () => { + stream.goToLast(); + this.index(stream.count()); + this.renderScrollbar(true); + }}, [icon('angle-double-down'), ' Now']) ]) ]) ]) } onscroll(top) { - var streamContent = this.props.streamContent; + var stream = this.props.stream; - if (!streamContent.active() || !streamContent.$()) { return; } + if (stream.paused() || !stream.$()) { return; } this.update(top); this.renderScrollbar(); @@ -117,10 +121,10 @@ export default class StreamScrubber extends Component { current scroll position. */ update(top) { - var streamContent = this.props.streamContent; + var stream = this.props.stream; var $window = $(window); - var marginTop = streamContent.getMarginTop(); + var marginTop = stream.getMarginTop(); var scrollTop = $window.scrollTop() + marginTop; var windowHeight = $window.height() - marginTop; @@ -128,8 +132,8 @@ export default class StreamScrubber extends Component { // properties to a 'default' state. These values reflect what would be // seen if the browser were scrolled right up to the top of the page, // and the viewport had a height of 0. - var $items = streamContent.$('.item'); - var index = $items.first().data('end') - 1; + var $items = stream.$('> .item'); + var index = $items.first().data('index'); var visible = 0; var period = ''; @@ -146,7 +150,7 @@ export default class StreamScrubber extends Component { // loop. if (top + height < scrollTop) { visible = (top + height - scrollTop) / height; - index = parseFloat($this.data('end')) + 1 - visible; + index = parseFloat($this.data('index')) + 1 - visible; return; } if (top > scrollTop + windowHeight) { @@ -154,14 +158,10 @@ export default class StreamScrubber extends Component { } // If the bottom half of this item is visible at the top of the - // viewport, then add the visible proportion to the visible - // counter, and set the scrollbar index to whatever the visible - // proportion represents. For example, if a gap represents indexes - // 0-9, and the bottom 50% of the gap is visible in the viewport, - // then the scrollbar index will be 5. + // viewport if (top <= scrollTop && top + height > scrollTop) { visible = (top + height - scrollTop) / height; - index = parseFloat($this.data('end')) + 1 - visible; + index = parseFloat($this.data('index')) + 1 - visible; } // If the top half of this item is visible at the bottom of the @@ -206,69 +206,30 @@ export default class StreamScrubber extends Component { // so that it fills the height of the sidebar. $(window).on('resize', this.handlers.onresize = this.onresize.bind(this)).resize(); - var self = this; - // When any part of the whole scrollbar is clicked, we want to jump to // that position. this.$('.scrubber-scrollbar') - .bind('click touchstart', function(e) { - if (!self.props.streamContent.active()) { return; } + .bind('click touchstart', this.onclick.bind(this)) - // Calculate the index which we want to jump to based on the - // click position. - // 1. Get the offset of the click from the top of the - // scrollbar, as a percentage of the scrollbar's height. - var $this = $(this); - var offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $this.offset().top + $('body').scrollTop(); - var offsetPercent = offsetPixels / $this.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($this.find('.scrubber-slider')[0].style.height) / 2; - - // 3. Now we can convert the percentage into an index, and - // tell the stream-content component to jump to that index. - var offsetIndex = offsetPercent / self.percentPerPost().index; - offsetIndex = Math.max(0, Math.min(self.count() - 1, offsetIndex)); - self.props.streamContent.goToIndex(Math.floor(offsetIndex)); - - self.$().removeClass('open'); - }); - - // Now we want to make the scrollbar handle draggable. Let's start by - // preventing default browser events from messing things up. - this.$('.scrubber-scrollbar') - .css({ - cursor: 'pointer', - 'user-select': 'none' - }) - .bind('dragstart mousedown touchstart', function(e) { - e.preventDefault(); - }); + // 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.handle = null; this.$('.scrubber-slider') .css('cursor', 'move') - .bind('mousedown touchstart', function(e) { - self.mouseStart = e.clientY || e.originalEvent.touches[0].clientY; - self.indexStart = self.index(); - self.handle = $(this); - self.props.streamContent.paused(true); - $('body').css('cursor', 'move'); - }) + .bind('mousedown touchstart', this.onmousedown.bind(this)) + // Exempt the scrollbar handle from the 'jump to' click event. - .click(function(e) { - e.stopPropagation(); - }); + .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 @@ -282,8 +243,7 @@ export default class StreamScrubber extends Component { ondestroy() { this.scrollListener.stop(); - this.props.streamContent.off('loadingIndex', this.handlers.loadingIndex); - this.props.streamContent.off('unpaused', this.handlers.unpaused); + this.props.stream.off('unpaused', this.handlers.unpaused); $(window) .off('resize', this.handlers.onresize); @@ -305,7 +265,6 @@ export default class StreamScrubber extends Component { var $scrubber = this.$(); $scrubber.find('.index').text(this.visibleIndex()); - // $scrubber.find('.count').text(count); $scrubber.find('.description').text(this.description()); $scrubber.toggleClass('disabled', this.disabled()); @@ -350,16 +309,7 @@ export default class StreamScrubber extends Component { }; } - /* - When the stream-content component begins loading posts at a certain - index, we want our scrubber scrollbar to jump to that position. - */ - loadingIndex(index) { - this.index(index); - this.renderScrollbar(true); - } - - onresize(event) { + onresize() { this.scrollListener.update(true); // Adjust the height of the scrollbar so that it fills the height of @@ -368,81 +318,68 @@ export default class StreamScrubber extends Component { scrollbar.css('max-height', $(window).height() - scrollbar.offset().top + $(window).scrollTop() - parseInt($('.global-page').css('padding-bottom'))); } - onmousemove(event) { - if (! this.handle) { return; } + onmousedown() { + 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() { + if (! this.dragging) { return; } // Work out how much the mouse has moved by - first in pixels, then // convert it to a percentage of the scrollbar's height, and then // finally convert it into an index. Add this delta index onto // the index at which the drag was started, and then scroll there. - var deltaPixels = (event.clientY || event.originalEvent.touches[0].clientY) - this.mouseStart; + var deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart; var deltaPercent = deltaPixels / this.$('.scrubber-scrollbar').outerHeight() * 100; var deltaIndex = deltaPercent / this.percentPerPost().index; var newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1); this.index(Math.max(0, newIndex)); this.renderScrollbar(); - - if (! this.$().is('.open')) { - this.scrollToIndex(newIndex); - } } - onmouseup(event) { - if (!this.handle) { return; } + onmouseup() { + if (!this.dragging) { return; } this.mouseStart = 0; this.indexStart = 0; - this.handle = null; + this.dragging = false; $('body').css('cursor', ''); - if (this.$().is('.open')) { - this.scrollToIndex(this.index()); - this.$().removeClass('open'); - } + this.$().removeClass('open'); // If the index we've landed on is in a gap, then tell the stream- // content that we want to load those posts. var intIndex = Math.floor(this.index()); - if (!this.props.streamContent.props.stream.findNearestToIndex(intIndex).post) { - this.props.streamContent.goToIndex(intIndex); - } else { - this.props.streamContent.paused(false); - } + this.props.stream.goToIndex(intIndex); + this.renderScrollbar(true); } - /** - Instantly scroll to a certain index in the discussion. The index doesn't - have to be an integer; any fraction of a post will be scrolled to. - */ - scrollToIndex(index) { - var streamContent = this.props.streamContent; + onclick(e) { + // Calculate the index which we want to jump to based on the click position. - index = Math.min(index, this.count() - 1); + // 1. Get the offset of the click from the top of the scrollbar, as a + // percentage of the scrollbar's height. + var $scrollbar = this.$('.scrubber-scrollbar'); + var offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $scrollbar.offset().top + $('body').scrollTop(); + var offsetPercent = offsetPixels / $scrollbar.outerHeight() * 100; - // Find the item for this index, whether it's a post corresponding to - // the index, or a gap which the index is within. - var indexFloor = Math.max(0, Math.floor(index)); - var $nearestItem = streamContent.findNearestToIndex(indexFloor); + // 2. We want the handle of the scrollbar to end up centered on the click + // position. Thus, we calculate the height of the handle in percent and + // use that to find a new offset percentage. + offsetPercent = offsetPercent - parseFloat($scrollbar.find('.scrubber-slider')[0].style.height) / 2; - // Calculate the position of this item so that we can scroll to it. If - // the item is a gap, then we will mark it as 'active' to indicate to - // the user that it will expand if they release their mouse. - // Otherwise, we will add a proportion of the item's height onto the - // scroll position. - var pos = $nearestItem.offset().top - streamContent.getMarginTop(); - if ($nearestItem.is('.gap')) { - $nearestItem.addClass('active'); - } else { - if (index >= 0) { - pos += $nearestItem.outerHeight(true) * (index - indexFloor); - } else { - pos += $nearestItem.offset().top * index; - } - } + // 3. Now we can convert the percentage into an index, and tell the stream- + // content component to jump to that index. + var offsetIndex = offsetPercent / this.percentPerPost().index; + offsetIndex = Math.max(0, Math.min(this.count() - 1, offsetIndex)); + this.props.stream.goToIndex(Math.floor(offsetIndex)); + this.index(offsetIndex); + this.renderScrollbar(true); - // Remove the 'active' class from other gaps. - streamContent.$().find('.gap').not($nearestItem).removeClass('active'); - - $('html, body').scrollTop(pos); + this.$().removeClass('open'); } } diff --git a/framework/core/js/forum/src/components/post-stream.js b/framework/core/js/forum/src/components/post-stream.js new file mode 100644 index 000000000..02536f2eb --- /dev/null +++ b/framework/core/js/forum/src/components/post-stream.js @@ -0,0 +1,463 @@ +import Component from 'flarum/component'; +import ScrollListener from 'flarum/utils/scroll-listener'; +import PostLoading from 'flarum/components/post-loading'; +import anchorScroll from 'flarum/utils/anchor-scroll'; +import mixin from 'flarum/utils/mixin'; +import evented from 'flarum/utils/evented'; + +class PostStream extends mixin(Component, evented) { + constructor(props) { + super(props); + + this.discussion = this.props.discussion; + this.setup(this.props.includedPosts); + + this.scrollListener = new ScrollListener(this.onscroll.bind(this)); + + this.paused = m.prop(false); + + this.loadPageTimeouts = {}; + this.pagesLoading = 0; + } + + /** + Load and scroll to a post with a certain number. + */ + goToNumber(number, noAnimation) { + this.paused(true); + + var promise = this.loadNearNumber(number); + + m.redraw(true); + + return promise.then(() => { + m.redraw(true); + + this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this)); + }); + } + + /** + Load and scroll to a certain index within the discussion. + */ + goToIndex(index, backwards, noAnimation) { + this.paused(true); + + var promise = this.loadNearIndex(index); + + m.redraw(true); + + return promise.then(() => { + anchorScroll(this.$('.item:'+(backwards ? 'last' : 'first')), () => m.redraw(true)); + + this.scrollToIndex(index, noAnimation).done(this.unpause.bind(this)); + }); + } + + /** + Load and scroll up to the first post in the discussion. + */ + goToFirst() { + return this.goToIndex(0); + } + + /** + Load and scroll down to the last post in the discussion. + */ + goToLast() { + return this.goToIndex(this.count() - 1); + } + + /** + Update the stream to reflect any posts that have been added/removed from the + discussion. + */ + sync() { + var addedPosts = this.discussion.addedPosts(); + if (addedPosts) addedPosts.forEach(this.pushPost.bind(this)); + this.discussion.pushData({links: {addedPosts: null}}); + + var removedPosts = this.discussion.removedPosts(); + if (removedPosts) removedPosts.forEach(this.removePost.bind(this)); + this.discussion.pushData({removedPosts: null}); + } + + /** + Add a post to the end of the stream. Nothing will be done if the end of the + stream is not visible. + */ + pushPost(post) { + if (this.visibleEnd == this.count() - 1) { + this.posts.push(post); + this.visibleEnd++; + } + } + + /** + Search for and remove a specific post from the stream. Nothing will be done + if the post is not visible. + */ + removePost(id) { + this.posts.some((item, i) => { + if (item && item.id() === id) { + this.posts.splice(i, 1); + this.visibleEnd--; + return true; + } + }); + } + + /** + Get the total number of posts in the discussion. + */ + count() { + return this.discussion.postIds().length; + } + + /** + Make sure that the given index is not outside of the possible range of + indexes in the discussion. + */ + sanitizeIndex(index) { + return Math.max(0, Math.min(this.count(), index)); + } + + /** + Set up the stream with the given array of posts. + */ + setup(posts) { + this.posts = posts; + this.visibleStart = this.discussion.postIds().indexOf(posts[0].id()); + this.visibleEnd = this.visibleStart + posts.length; + } + + /** + Clear the stream and fill it with placeholder posts. + */ + clear(start, end) { + this.visibleStart = start || 0; + this.visibleEnd = end || this.constructor.loadCount; + this.posts = []; + for (var i = this.visibleStart; i < this.visibleEnd; i++) { + this.posts.push(null); + } + } + + /** + Construct a vDOM containing an element for each post that is visible in the + stream. Posts that have not been loaded will be rendered as placeholders. + */ + view() { + function fadeIn(element, isInitialized, context) { + if (!context.fadedIn) $(element).hide().fadeIn(); + context.fadedIn = true; + } + + return m('div.discussion-posts.posts', {config: this.onload.bind(this)}, + this.posts.map((post, i) => { + var content; + var attributes = {}; + attributes['data-index'] = attributes.key = this.visibleStart + i; + + if (post) { + var PostComponent = app.postComponentRegistry[post.contentType()]; + content = PostComponent ? PostComponent.component({post}) : ''; + attributes.config = fadeIn; + attributes['data-time'] = post.time().toISOString(); + attributes['data-number'] = post.number(); + } else { + content = PostLoading.component(); + } + + return m('div.item', attributes, content); + }) + ); + } + + /** + Store a reference to the component's DOM and begin listening for the + window's scroll event. + */ + onload(element, isInitialized, context) { + this.element(element); + + if (isInitialized) { return; } + + context.onunload = this.ondestroy.bind(this); + + // This is wrapped in setTimeout due to the following Mithril issue: + // https://github.com/lhorie/mithril.js/issues/637 + setTimeout(() => this.scrollListener.start()); + } + + /** + Stop listening for the window's scroll event, and cancel outstanding + timeouts. + */ + ondestroy() { + this.scrollListener.stop(); + clearTimeout(this.calculatePositionTimeout); + } + + /** + When the window is scrolled, check if either extreme of the post stream is + in the viewport, and if so, trigger loading the next/previous page. + */ + onscroll(top) { + if (this.paused()) return; + + var marginTop = this.getMarginTop(); + var viewportHeight = $(window).height() - marginTop; + var viewportTop = top + marginTop; + var loadAheadDistance = viewportHeight; + + if (this.visibleStart > 0) { + var $item = this.$('.item[data-index='+this.visibleStart+']'); + + if ($item.offset().top > viewportTop - loadAheadDistance) { + this.loadPrevious(); + } + } + + if (this.visibleEnd < this.count()) { + var $item = this.$('.item[data-index='+(this.visibleEnd - 1)+']'); + + if ($item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) { + this.loadNext(); + } + } + + clearTimeout(this.calculatePositionTimeout); + this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 500); + } + + /** + Load the next page of posts. + */ + loadNext() { + var start = this.visibleEnd; + var end = this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount); + + for (var i = start; i < end; i++) { + this.posts.push(null); + } + + // If the posts which are two pages back from the page we're currently + // loading still haven't loaded, we can assume that the user is scrolling + // pretty fast. Thus, we will unload them. + var twoPagesAway = start - this.constructor.loadCount * 2; + if (twoPagesAway >= 0 && !this.posts[twoPagesAway - this.visibleStart]) { + this.posts.splice(0, twoPagesAway + this.constructor.loadCount - this.visibleStart); + this.visibleStart = twoPagesAway + this.constructor.loadCount; + clearTimeout(this.loadPageTimeouts[twoPagesAway]); + } + + this.loadPage(start, end); + } + + /** + Load the previous page of posts. + */ + loadPrevious() { + var end = this.visibleStart; + var start = this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount); + + for (var i = start; i < end; i++) { + this.posts.unshift(null); + } + + // If the posts which are two pages back from the page we're currently + // loading still haven't loaded, we can assume that the user is scrolling + // pretty fast. Thus, we will unload them. + var twoPagesAway = start + this.constructor.loadCount * 2; + if (twoPagesAway <= this.count() && !this.posts[twoPagesAway - this.visibleStart]) { + this.posts.splice(twoPagesAway - this.visibleStart); + this.visibleEnd = twoPagesAway; + clearTimeout(this.loadPageTimeouts[twoPagesAway]); + } + + this.loadPage(start, end, true); + } + + /** + Load a page of posts into the stream and redraw. + */ + loadPage(start, end, backwards) { + var redraw = () => { + if (start < this.visibleStart || end > this.visibleEnd) return; + + var anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart; + anchorScroll(this.$('.item[data-index='+anchorIndex+']'), () => m.redraw(true)); + + this.unpause(); + }; + redraw(); + + this.pagesLoading++; + + this.loadPageTimeouts[start] = setTimeout(() => { + this.loadRange(start, end).then(() => { + redraw(); + this.pagesLoading--; + }); + }, this.pagesLoading ? 1000 : 0); + } + + /** + Load and inject the specified range of posts into the stream, without + clearing it. + */ + loadRange(start, end) { + return app.store.find('posts', this.discussion.postIds().slice(start, end)).then(posts => { + if (start < this.visibleStart || end > this.visibleEnd) return; + + this.posts.splice.apply(this.posts, [start - this.visibleStart, end - start].concat(posts)); + }); + } + + /** + Clear the stream and load posts near a certain number. Returns a promise. If + the post with the given number is already loaded, the promise will be + resolved immediately. + */ + loadNearNumber(number) { + if (this.posts.some(post => post.number() == number)) { + return m.deferred().resolve().promise; + } + + this.clear(); + + return app.store.find('posts', { + discussions: this.discussion.id(), + near: number + }).then(this.setup.bind(this)); + } + + /** + Clear the stream and load posts near a certain index. A page of posts + surrounding the given index will be loaded. Returns a promise. If the given + index is already loaded, the promise will be resolved immediately. + */ + loadNearIndex(index) { + if (index >= this.visibleStart && index <= this.visibleEnd) { + return m.deferred().resolve().promise; + } + + var start = this.sanitizeIndex(index - this.constructor.loadCount / 2); + var end = start + this.constructor.loadCount; + + this.clear(start, end); + + var ids = this.discussion.postIds().slice(start, end); + + return app.store.find('posts', ids).then(this.setup.bind(this)); + } + + /** + Work out which posts (by number) are currently visible in the viewport, and + fire an event with the information. + */ + calculatePosition() { + var marginTop = this.getMarginTop(); + var $window = $(window); + var viewportHeight = $window.height() - marginTop; + var scrollTop = $window.scrollTop() + marginTop; + var startNumber; + var endNumber; + + this.$('.item').each(function() { + var $item = $(this); + var top = $item.offset().top; + var height = $item.outerHeight(true); + + if (top + height > scrollTop) { + if (!startNumber) { + startNumber = $item.data('number'); + } + + if (top + height < scrollTop + viewportHeight) { + endNumber = $item.data('number'); + } else { + return false; + } + } + }); + + if (startNumber) { + this.trigger('positionChanged', startNumber || 1, endNumber); + } + } + + /** + Get the distance from the top of the viewport to the point at which we + would consider a post to be the first one visible. + */ + getMarginTop() { + return this.$() && $('.global-header').outerHeight() + parseInt(this.$().css('margin-top')); + } + + /** + Scroll down to a certain post by number and 'flash' it. + */ + scrollToNumber(number, noAnimation) { + var $item = this.$('.item[data-number='+number+']'); + + return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item)); + } + + /** + Scroll down to a certain post by index. + */ + scrollToIndex(index, noAnimation) { + var $item = this.$('.item[data-index='+index+']'); + + return this.scrollToItem($item, noAnimation, true); + } + + /** + Scroll down to the given post. + */ + scrollToItem($item, noAnimation, force) { + var $container = $('html, body').stop(true); + + if ($item.length) { + var itemTop = $item.offset().top - this.getMarginTop(); + var itemBottom = itemTop + $item.height(); + var scrollTop = $(document).scrollTop(); + var scrollBottom = scrollTop + $(window).height(); + + // If the item is already in the viewport, we may not need to scroll. + if (force || itemTop < scrollTop || itemBottom > scrollBottom) { + var scrollTop = $item.is(':first-child') ? 0 : itemTop; + + if (noAnimation) { + $container.scrollTop(scrollTop); + } else if (scrollTop !== $(document).scrollTop()) { + $container.animate({scrollTop: scrollTop}, 'fast'); + } + } + } + + return $container.promise(); + } + + /** + 'Flash' the given post, drawing the user's attention to it. + */ + flashItem($item) { + $item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash')); + } + + /** + Resume the stream's ability to auto-load posts on scroll. + */ + unpause() { + this.paused(false); + this.scrollListener.update(true); + this.trigger('unpaused'); + } +} + +PostStream.loadCount = 20; + +export default PostStream; diff --git a/framework/core/js/forum/src/components/reply-composer.js b/framework/core/js/forum/src/components/reply-composer.js index 55039bc57..4e19ad502 100644 --- a/framework/core/js/forum/src/components/reply-composer.js +++ b/framework/core/js/forum/src/components/reply-composer.js @@ -67,7 +67,7 @@ export default class ReplyComposer extends ComposerBody { // If we're currently viewing the discussion which this reply was made // in, then we can add the post to the end of the post stream. if (app.current && app.current.discussion && app.current.discussion().id() === discussion.id()) { - app.current.stream().addPostToEnd(post); + app.current.stream.pushPost(post); m.route(app.route('discussion.near', { id: discussion.id(), slug: discussion.slug(), diff --git a/framework/core/js/forum/src/components/stream-content.js b/framework/core/js/forum/src/components/stream-content.js deleted file mode 100644 index 767e8a657..000000000 --- a/framework/core/js/forum/src/components/stream-content.js +++ /dev/null @@ -1,360 +0,0 @@ -import Component from 'flarum/component'; -import StreamItem from 'flarum/components/stream-item'; -import LoadingIndicator from 'flarum/components/loading-indicator'; -import ScrollListener from 'flarum/utils/scroll-listener'; -import mixin from 'flarum/utils/mixin'; -import evented from 'flarum/utils/evented'; - -/** - - */ -export default class StreamContent extends mixin(Component, evented) { - /** - - */ - constructor(props) { - super(props); - - this.loaded = () => this.props.stream.loadedCount(); - this.paused = m.prop(false); - this.active = () => this.loaded() && !this.paused(); - - this.scrollListener = new ScrollListener(this.onscroll.bind(this)); - - this.on('loadingIndex', this.loadingIndex.bind(this)); - this.on('loadedIndex', this.loadedIndex.bind(this)); - - this.on('loadingNumber', this.loadingNumber.bind(this)); - this.on('loadedNumber', this.loadedNumber.bind(this)); - } - - /** - - */ - view() { - var stream = this.props.stream; - - return m('div', {className: 'stream '+(this.props.className || ''), config: this.onload.bind(this)}, - stream ? stream.content.map(item => StreamItem.component({ - key: item.start+'-'+item.end, - item: item, - loadRange: stream.loadRange.bind(stream), - ondelete: this.ondelete.bind(this) - })) - : LoadingIndicator.component()); - } - - /** - - */ - onload(element, isInitialized, context) { - this.element(element); - - if (isInitialized) { return; } - - context.onunload = this.ondestroy.bind(this); - this.scrollListener.start(); - } - - ondelete(post) { - this.props.stream.removePost(post.id()); - } - - /** - - */ - ondestroy() { - this.scrollListener.stop(); - clearTimeout(this.positionChangedTimeout); - } - - /** - - */ - onscroll(top) { - if (!this.active()) { return; } - - var $items = this.$('.item'); - - var marginTop = this.getMarginTop(); - var $window = $(window); - var viewportHeight = $window.height() - marginTop; - var scrollTop = top + marginTop; - var loadAheadDistance = 300; - var startNumber; - var endNumber; - - // Loop through each of the items in the stream. An 'item' is either a - // single post or a 'gap' of one or more posts that haven't been loaded - // yet. - $items.each(function() { - var $this = $(this); - var top = $this.offset().top; - var height = $this.outerHeight(); - - // If this item is above the top of the viewport (plus a bit of leeway - // for loading-ahead gaps), skip to the next one. If it's below the - // bottom of the viewport, break out of the loop. - if (top + height < scrollTop - loadAheadDistance) { return; } - if (top > scrollTop + viewportHeight + loadAheadDistance) { return false; } - - // If this item is a gap, then we may proceed to check if it's a - // *terminal* gap and trigger its loading mechanism. - if ($this.hasClass('gap')) { - var first = $this.is(':first-child'); - var last = $this.is(':last-child'); - var item = $this[0].instance.props.item; - if ((first || last) && !item.loading) { - item.direction = first ? 'up' : 'down'; - $this[0].instance.load(); - } - } else { - if (top + height < scrollTop + viewportHeight) { - endNumber = $this.data('number'); - } - - // Check if this item is in the viewport, minus the distance we allow - // for load-ahead gaps. If we haven't yet stored a post's number, then - // this item must be the FIRST item in the viewport. Therefore, we'll - // grab its post number so we can update the controller's state later. - if (top + height > scrollTop && !startNumber) { - startNumber = $this.data('number'); - } - } - }); - - - // Finally, we want to update the controller's state with regards to the - // current viewing position of the discussion. However, we don't want to - // do this on every single scroll event as it will slow things down. So, - // let's do it at a minimum of 250ms by clearing and setting a timeout. - clearTimeout(this.positionChangedTimeout); - this.positionChangedTimeout = setTimeout(() => this.props.positionChanged(startNumber || 1, endNumber), 500); - } - - /** - Get the distance from the top of the viewport to the point at which we - would consider a post to be the first one visible. - */ - getMarginTop() { - return this.$() && $('.global-header').outerHeight() + parseInt(this.$().css('margin-top')); - } - - /** - Scroll down to a certain post by number (or the gap which we think the - post is in) and highlight it. - */ - scrollToNumber(number, noAnimation) { - // Clear the highlight class from all posts, and attempt to find and - // highlight a post with the specified number. However, we don't apply - // the highlight to the first post in the stream because it's pretty - // obvious that it's the top one. - var $item = this.$('.item').removeClass('highlight').filter('[data-number='+number+']'); - if (!$item.is(':first-child')) { - $item.addClass('highlight'); - } - - // If we didn't have any luck, then a post with this number either - // doesn't exist, or it hasn't been loaded yet. We'll find the item - // that's closest to the post with this number and scroll to that - // instead. - if (!$item.length) { - $item = this.findNearestToNumber(number); - } - - return this.scrollToItem($item, noAnimation); - } - - /** - Scroll down to a certain post by index (or the gap the post is in.) - */ - scrollToIndex(index, noAnimation) { - var $item = this.findNearestToIndex(index); - return this.scrollToItem($item, noAnimation); - } - - /** - - */ - scrollToItem($item, noAnimation) { - var $container = $('html, body').stop(true); - if ($item.length) { - var itemTop = $item.offset().top - this.getMarginTop(); - var itemBottom = itemTop + $item.height(); - var scrollTop = $(document).scrollTop(); - var scrollBottom = scrollTop + $(window).height(); - - // If the item is already in the viewport, just flash it, we don't need to - // scroll anywhere. - if (itemTop > scrollTop && itemBottom < scrollBottom) { - this.flashItem($item); - } else { - var scrollTop = $item.is(':first-child') ? 0 : itemTop; - if (noAnimation) { - $container.scrollTop(scrollTop); - } else if (scrollTop !== $(document).scrollTop()) { - $container.animate({scrollTop: scrollTop}, 'fast', this.flashItem.bind(this, $item)); - } else { - this.flashItem($item); - } - } - } - return $container.promise(); - } - - flashItem($item) { - $item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash')); - } - - /** - Find the DOM element of the item that is nearest to a post with a certain - number. This will either be another post (if the requested post doesn't - exist,) or a gap presumed to contain the requested post. - */ - findNearestToNumber(number) { - var $nearestItem = $(); - this.$('.item').each(function() { - var $this = $(this); - if ($this.data('number') > number) { - return false; - } - $nearestItem = $this; - }); - return $nearestItem; - } - - /** - - */ - findNearestToIndex(index) { - var $nearestItem = this.$('.item[data-start='+index+'][data-end='+index+']'); - if (!$nearestItem.length) { - this.$('.item').each(function() { - $nearestItem = $(this); - if ($nearestItem.data('end') >= index) { - return false; - } - }); - } - return $nearestItem; - } - - /** - - */ - loadingIndex(index, noAnimation) { - // The post at this index is being loaded. We want to scroll to where we - // think it will appear. We may be scrolling to the edge of the page, - // but we don't want to trigger any terminal post gaps to load by doing - // that. So, we'll disable the window's scroll handler for now. - this.paused(true); - this.scrollToIndex(index, noAnimation); - } - - /** - - */ - loadedIndex(index, noAnimation) { - m.redraw(true); - - // The post at this index has been loaded. After we scroll to this post, - // we want to resume scroll events. - this.scrollToIndex(index, noAnimation).done(this.unpause.bind(this)); - } - - /** - - */ - loadingNumber(number, noAnimation) { - // The post with this number is being loaded. We want to scroll to where - // we think it will appear. We may be scrolling to the edge of the page, - // but we don't want to trigger any terminal post gaps to load by doing - // that. So, we'll disable the window's scroll handler for now. - this.paused(true); - if (this.$()) { - this.scrollToNumber(number, noAnimation); - } - } - - /** - - */ - loadedNumber(number, noAnimation) { - m.redraw(true); - - // The post with this number has been loaded. After we scroll to this - // post, we want to resume scroll events. - this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this)); - } - - /** - - */ - unpause() { - this.paused(false); - this.scrollListener.update(true); - this.trigger('unpaused'); - } - - /** - - */ - goToNumber(number, noAnimation) { - number = Math.max(number, 1); - - // Let's start by telling our listeners that we're going to load - // posts near this number. Elsewhere we will listen and - // consequently scroll down to the appropriate position. - this.trigger('loadingNumber', number, noAnimation); - - // Now we have to actually make sure the posts around this new start - // position are loaded. We will tell our listeners when they are. - // Again, a listener will scroll down to the appropriate post. - var promise = this.props.stream.loadNearNumber(number); - m.redraw(); - - return promise.then(() => this.trigger('loadedNumber', number, noAnimation)); - } - - /** - - */ - goToIndex(index, backwards, noAnimation) { - // Let's start by telling our listeners that we're going to load - // posts at this index. Elsewhere we will listen and consequently - // scroll down to the appropriate position. - this.trigger('loadingIndex', index, noAnimation); - - // Now we have to actually make sure the posts around this index - // are loaded. We will tell our listeners when they are. Again, a - // listener will scroll down to the appropriate post. - var promise = this.props.stream.loadNearIndex(index, backwards); - m.redraw(); - - return promise.then(() => this.trigger('loadedIndex', index, noAnimation)); - } - - /** - - */ - goToFirst() { - return this.goToIndex(0); - } - - /** - - */ - goToLast() { - var promise = this.goToIndex(this.props.stream.count() - 1, true); - - // If the post stream is loading some new posts, then after it's - // done we'll want to immediately scroll down to the bottom of the - // page. - var items = this.props.stream.content; - if (!items[items.length - 1].post) { - promise.then(() => $('html, body').stop(true).scrollTop($('body').height())); - } - - return promise; - } -} diff --git a/framework/core/js/forum/src/components/stream-item.js b/framework/core/js/forum/src/components/stream-item.js deleted file mode 100644 index 7a6512e27..000000000 --- a/framework/core/js/forum/src/components/stream-item.js +++ /dev/null @@ -1,112 +0,0 @@ -import Component from 'flarum/component'; -import classList from 'flarum/utils/class-list'; -import LoadingIndicator from 'flarum/components/loading-indicator'; - -export default class StreamItem extends Component { - /** - - */ - constructor(props) { - super(props); - - this.element = m.prop(); - } - - /** - - */ - view() { - var component = this; - var item = this.props.item; - - var gap = !item.post; - var direction = item.direction; - var loading = item.loading; - var count = item.end - item.start + 1; - var classes = { item: true, gap, loading, direction }; - - var attributes = { - className: classList(classes), - config: this.element, - 'data-start': item.start, - 'data-end': item.end - }; - if (!gap) { - attributes['data-time'] = item.post.time().toISOString(); - attributes['data-number'] = item.post.number(); - } else { - attributes['config'] = (element) => { - this.element(element); - element.instance = this; - }; - attributes['onclick'] = this.load.bind(this); - attributes['onmouseenter'] = function(e) { - if (!item.loading) { - var $this = $(this); - var up = e.clientY > $this.offset().top - $(document).scrollTop() + $this.outerHeight(true) / 2; - $this.removeClass('up down').addClass(item.direction = up ? 'up' : 'down'); - } - m.redraw.strategy('none'); - }; - } - - var content; - if (gap) { - content = m('span', loading ? LoadingIndicator.component() : count+' more post'+(count !== 1 ? 's' : '')); - } else { - var PostComponent = app.postComponentRegistry[item.post.contentType()]; - if (PostComponent) { - content = PostComponent.component({post: item.post, ondelete: this.props.ondelete}); - } - } - - return m('div', attributes, content); - } - - /** - - */ - load() { - var item = this.props.item; - - // If this item is not a gap, or if we're already loading its posts, - // then we don't need to do anything. - if (item.post || item.loading) { - return false; - } - - // If new posts are being loaded in an upwards direction, then when - // they are rendered, the rest of the posts will be pushed down the - // page. If loaded in a downwards direction from the end of a - // discussion, the terminal gap will disappear and the page will - // scroll up a bit before the new posts are rendered. In order to - // maintain the current scroll position relative to the content - // before/after the gap, we need to find item directly after the gap - // and use it as an anchor. - var siblingFunc = item.direction === 'up' ? 'nextAll' : 'prevAll'; - var anchor = this.$()[siblingFunc]('.item:first'); - - // Tell the controller that we want to load the range of posts that this - // gap represents. We also specify which direction we want to load the - // posts from. - this.props.loadRange(item.start, item.end, item.direction === 'up').then(function() { - // Immediately after the posts have been loaded (but before they - // have been rendered,) we want to grab the distance from the top of - // the viewport to the top of the anchor element. - if (anchor.length) { - var scrollOffset = anchor.offset().top - $(document).scrollTop(); - } - - m.redraw(true); - - // After they have been rendered, we scroll back to a position - // so that the distance from the top of the viewport to the top - // of the anchor element is the same as before. If there is no - // anchor (i.e. this gap is terminal,) then we'll scroll to the - // bottom of the document. - $('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height()); - }); - - m.redraw(); - } -} diff --git a/framework/core/js/forum/src/components/user-page.js b/framework/core/js/forum/src/components/user-page.js index e3be133a4..ce7aa54eb 100644 --- a/framework/core/js/forum/src/components/user-page.js +++ b/framework/core/js/forum/src/components/user-page.js @@ -2,8 +2,6 @@ import Component from 'flarum/component'; import ItemList from 'flarum/utils/item-list'; import IndexPage from 'flarum/components/index-page'; import DiscussionList from 'flarum/components/discussion-list'; -import StreamContent from 'flarum/components/stream-content'; -import StreamScrubber from 'flarum/components/stream-scrubber'; import UserCard from 'flarum/components/user-card'; import ReplyComposer from 'flarum/components/reply-composer'; import ActionButton from 'flarum/components/action-button'; diff --git a/framework/core/js/forum/src/initializers/discussion-controls.js b/framework/core/js/forum/src/initializers/discussion-controls.js index cd52f19ed..755bbc44d 100644 --- a/framework/core/js/forum/src/initializers/discussion-controls.js +++ b/framework/core/js/forum/src/initializers/discussion-controls.js @@ -10,7 +10,7 @@ export default function(app) { Discussion.prototype.replyAction = function(goToLast, forceRefresh) { if (app.session.user() && this.canReply()) { if (goToLast && app.current.discussion && app.current.discussion().id() === this.id()) { - app.current.streamContent.goToLast(); + app.current.stream.goToLast(); } var component = app.composer.component; if (!(component instanceof ReplyComposer) || component.props.discussion !== this || component.props.user !== app.session.user() || forceRefresh) { @@ -47,7 +47,7 @@ export default function(app) { if (title && title !== currentTitle) { this.save({title}).then(discussion => { if (app.current instanceof DiscussionPage) { - app.current.stream().sync(); + app.current.stream.sync(); } m.redraw(); }); diff --git a/framework/core/js/forum/src/utils/post-stream.js b/framework/core/js/forum/src/utils/post-stream.js deleted file mode 100644 index 309adc0d8..000000000 --- a/framework/core/js/forum/src/utils/post-stream.js +++ /dev/null @@ -1,170 +0,0 @@ -export default class PostStream { - constructor(discussion) { - this.discussion = discussion - this.ids = this.discussion.data().links.posts.linkage.map((link) => link.id) - - var item = this.makeItem(0, this.ids.length - 1) - item.loading = true - this.content = [item] - - this.postLoadCount = 20 - } - - count() { - return this.ids.length; - } - - loadedCount() { - return this.content.filter((item) => item.post).length; - } - - loadRange(start, end, backwards) { - // Find the appropriate gap objects in the post stream. When we find - // one, we will turn on its loading flag. - this.content.forEach(function(item) { - if (!item.post && ((item.start >= start && item.start <= end) || (item.end >= start && item.end <= end))) { - item.loading = true - item.direction = backwards ? 'up' : 'down' - } - }); - - // Get a list of post numbers that we'll want to retrieve. If there are - // more post IDs than the number of posts we want to load, then take a - // slice of the array in the appropriate direction. - var ids = this.ids.slice(start, end + 1); - var limit = this.postLoadCount - ids = backwards ? ids.slice(-limit) : ids.slice(0, limit) - - return this.loadPosts(ids) - } - - loadPosts(ids) { - if (!ids.length) { - return m.deferred().resolve().promise; - } - - return app.store.find('posts', ids).then(this.addPosts.bind(this)); - } - - loadNearNumber(number) { - // Find the item in the post stream which is nearest to this number. If - // it turns out the be the actual post we're trying to load, then we can - // return a resolved promise (i.e. we don't need to make an API - // request.) Or, if it's a gap, we'll switch on its loading flag. - var item = this.findNearestToNumber(number) - if (item) { - if (item.post && item.post.number() === number) { - return m.deferred().resolve([item.post]).promise; - } else if (!item.post) { - item.direction = 'down' - item.loading = true; - } - } - - var stream = this - return app.store.find('posts', { - discussions: this.discussion.id(), - near: number, - count: this.postLoadCount - }).then(this.addPosts.bind(this)) - } - - loadNearIndex(index, backwards) { - // Find the item in the post stream which is nearest to this index. If - // it turns out the be the actual post we're trying to load, then we can - // return a resolved promise (i.e. we don't need to make an API - // request.) Or, if it's a gap, we'll switch on its loading flag. - var item = this.findNearestToIndex(index) - if (item) { - if (item.post) { - return m.deferred().resolve([item.post]).promise; - } - return this.loadRange(Math.max(item.start, index - this.postLoadCount / 2), item.end, backwards); - } - } - - addPosts(posts) { - posts.forEach(this.addPost.bind(this)) - } - - addPost(post) { - var index = this.ids.indexOf(post.id()) - var content = this.content - var makeItem = this.makeItem - - // Here we loop through each item in the post stream, and find the gap - // in which this post should be situated. When we find it, we can replace - // it with the post, and new gaps either side if appropriate. - content.some(function(item, i) { - if (item.start <= index && item.end >= index) { - var newItems = [] - if (item.start < index) { - newItems.push(makeItem(item.start, index - 1)) - } - newItems.push(makeItem(index, index, post)) - if (item.end > index) { - newItems.push(makeItem(index + 1, item.end)) - } - var args = [i, 1].concat(newItems); - [].splice.apply(content, args) - return true - } - }) - } - - // @todo rename to pushPost - addPostToEnd(post) { - if (this.ids.indexOf(post.id()) === -1) { - var index = this.ids.length; - this.ids.push(post.id()); - this.content.push(this.makeItem(index, index, post)); - } - } - - removePost(id) { - this.ids.splice(this.ids.indexOf(id), 1); - this.content.some((item, i) => { - if (item.post && item.post.id() === id) { - this.content.splice(i, 1); - return true; - } - }); - } - - sync() { - var discussion = this.discussion; - - var addedPosts = discussion.addedPosts(); - addedPosts && addedPosts.forEach(this.addPostToEnd.bind(this)); - discussion.pushData({links: {addedPosts: null}}); - - var removedPosts = discussion.removedPosts(); - removedPosts && removedPosts.forEach(this.removePost.bind(this)); - discussion.pushData({removedPosts: null}); - } - - makeItem(start, end, post) { - var item = {start, end} - if (post) { - item.post = post - } - return item - } - - findNearestTo(index, property) { - var nearestItem - this.content.some(function(item) { - if (property(item) > index) { return true } - nearestItem = item - }) - return nearestItem - } - - findNearestToNumber(number) { - return this.findNearestTo(number, (item) => item.post && item.post.number()) - } - - findNearestToIndex(index) { - return this.findNearestTo(index, (item) => item.start) - } -} diff --git a/framework/core/js/lib/model.js b/framework/core/js/lib/model.js index ecfbb5bc3..88217b9df 100644 --- a/framework/core/js/lib/model.js +++ b/framework/core/js/lib/model.js @@ -41,7 +41,7 @@ export default class Model { for (var i in data) { if (i === 'links') { oldData[i] = oldData[i] || {}; - for (var j in newData[i]) { + for (var j in currentData[i]) { oldData[i][j] = currentData[i][j]; } } else { diff --git a/framework/core/js/lib/models/discussion.js b/framework/core/js/lib/models/discussion.js index cfa11e4e9..f0d533f45 100644 --- a/framework/core/js/lib/models/discussion.js +++ b/framework/core/js/lib/models/discussion.js @@ -37,6 +37,7 @@ Discussion.prototype.commentsCount = Model.prop('commentsCount'); Discussion.prototype.repliesCount = computed('commentsCount', commentsCount => commentsCount - 1); Discussion.prototype.posts = Model.many('posts'); +Discussion.prototype.postIds = function() { return this.data().links.posts.linkage.map((link) => link.id); }; Discussion.prototype.relevantPosts = Model.many('relevantPosts'); Discussion.prototype.addedPosts = Model.many('addedPosts'); Discussion.prototype.removedPosts = Model.prop('removedPosts'); diff --git a/framework/core/js/lib/utils/anchor-scroll.js b/framework/core/js/lib/utils/anchor-scroll.js new file mode 100644 index 000000000..f224170dd --- /dev/null +++ b/framework/core/js/lib/utils/anchor-scroll.js @@ -0,0 +1,7 @@ +export default function anchorScroll(element, callback) { + var scrollAnchor = $(element).offset().top - $(window).scrollTop(); + + callback(); + + $(window).scrollTop($(element).offset().top - scrollAnchor); +} diff --git a/framework/core/less/forum/discussion.less b/framework/core/less/forum/discussion.less index 7ceec871a..4d92ecca6 100644 --- a/framework/core/less/forum/discussion.less +++ b/framework/core/less/forum/discussion.less @@ -73,67 +73,25 @@ margin-bottom: 40px; } } -.gap { - padding: 30px 0; - text-align: center; - color: #aaa; - cursor: pointer; - border: 2px dashed @fl-body-bg; - background: #f2f2f2; - text-transform: uppercase; - font-size: 12px; - font-weight: bold; - overflow: hidden; - position: relative; - .transition(padding 0.2s); - - &:hover, &.loading, &.active { - padding: 50px 0; - - &.up:before, &.down:after { - opacity: 1; - } - } - &.loading { - .transition(none); - } - &:before, &:after { - font-family: 'FontAwesome'; - display: block; - opacity: 0; - transition: opacity 0.2s; - height: 15px; - color: #aaa; - } - &.up:before { - content: '\f077'; - margin-top: -25px; - margin-bottom: 10px; - } - &.down:after { - content: '\f078'; - margin-bottom: -25px; - margin-top: 10px; - } - &:only-child { - background: none; - border: 0; - color: @fl-primary-color; - &:before, &:after { - display: none; - } - } - & .loading-indicator { - color: #aaa; - } +@keyframes blink { + 0% {opacity: 0.5} + 50% {opacity: 1} + 100% {opacity: 0.5} } +.loading-post { + animation: blink 1s linear; + animation-iteration-count: infinite; +} +.fake-text { + background: @fl-body-secondary-color; + height: 12px; + width: 100%; + margin-bottom: 20px; + border-radius: @border-radius-base; -@media @phone { - .gap { - margin-left: -15px; - margin-right: -15px; - border-left: 0; - border-right: 0; + .post-header & { + height: 16px; + width: 150px; } } @@ -175,18 +133,18 @@ background: @fl-primary-color; } } -@-webkit-keyframes pulsate { +@keyframes pulsate { 0% {transform: scale(1)} 50% {transform: scale(1.02)} 100% {transform: scale(1)} } .item.pulsate { - -webkit-animation: pulsate 1s ease-in-out; - -webkit-animation-iteration-count: infinite; + animation: pulsate 1s ease-in-out; + animation-iteration-count: infinite; } .item.flash { - -webkit-animation: pulsate 0.2s ease-in-out; - -webkit-animation-iteration-count: 1; + animation: pulsate 0.2s ease-in-out; + animation-iteration-count: 1; } .post-header { margin-bottom: 10px; diff --git a/framework/core/less/lib/variables.less b/framework/core/less/lib/variables.less index 25b06b218..eb9ea4c05 100644 --- a/framework/core/less/lib/variables.less +++ b/framework/core/less/lib/variables.less @@ -13,11 +13,11 @@ .define-body-variables(@fl-dark-mode); .define-body-variables(false) { @fl-body-primary-color: @fl-primary-color; - @fl-body-secondary-color: hsl(@fl-secondary-hue, min(50%, @fl-secondary-sat), 95%); + @fl-body-secondary-color: hsl(@fl-secondary-hue, min(50%, @fl-secondary-sat), 93%); @fl-body-bg: #fff; @fl-body-color: #444; - @fl-body-muted-color: hsl(@fl-secondary-hue, min(25%, @fl-secondary-sat), 68%); + @fl-body-muted-color: hsl(@fl-secondary-hue, min(25%, @fl-secondary-sat), 66%); @fl-body-muted-more-color: #bbb; @fl-shadow-color: rgba(0, 0, 0, 0.35); } From 5314d2b512a66177919d6cc47937ce89298b3adb Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 29 May 2015 18:31:17 +0930 Subject: [PATCH 08/51] Refactor discussion list styles, fix loading indicator height in pane --- .../forum/src/components/discussion-list.js | 4 +-- framework/core/less/forum/index.less | 32 ++++++++++--------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/framework/core/js/forum/src/components/discussion-list.js b/framework/core/js/forum/src/components/discussion-list.js index 9fc8f3f04..47e755d19 100644 --- a/framework/core/js/forum/src/components/discussion-list.js +++ b/framework/core/js/forum/src/components/discussion-list.js @@ -117,8 +117,8 @@ export default class DiscussionList extends Component { } view() { - return m('div', [ - m('ul.discussions-list', [ + return m('div.discussion-list', [ + m('ul', [ this.discussions().map(discussion => { var startUser = discussion.startUser(); var isUnread = discussion.isUnread(); diff --git a/framework/core/less/forum/index.less b/framework/core/less/forum/index.less index 7ed9672fa..2d2989221 100644 --- a/framework/core/less/forum/index.less +++ b/framework/core/less/forum/index.less @@ -26,9 +26,6 @@ .index-toolbar-action { float: right; } -.index-results .loading-indicator { - height: 46px; -} @media @phone, @tablet { .offset-content { @@ -84,10 +81,11 @@ & .hero, & .index-nav, & .index-toolbar { display: none; } - & .discussions-list > li { + & .discussion-list > ul > li { margin: 0; padding-left: 57px + 15px; padding-right: 65px + 15px; + &.active { background: @fl-body-control-bg; } @@ -132,15 +130,21 @@ // ------------------------------------ // Discussions List -.discussions-list { - margin: 0; - padding: 0; - list-style-type: none; - position: relative; +.discussion-list { + & .loading-indicator { + height: 46px; + } + + & > ul { + margin: 0; + padding: 0; + list-style-type: none; + position: relative; + } } @media @phone { - .discussions-list > li { + .discussion-list > ul > li { padding-right: 45px; & .contextual-controls { @@ -150,11 +154,9 @@ } @media @tablet, @desktop, @desktop-hd { - .discussions-list { - & > li { - margin-right: -25px; - padding-right: 65px + 25px; - } + .discussion-list > ul > li { + margin-right: -25px; + padding-right: 65px + 25px; } } From 741da52ccd18bc3a465ba4248687054543dc1b3d Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 29 May 2015 18:55:19 +0930 Subject: [PATCH 09/51] Sort included posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They can be out of order in the payload due to relationship loading, e.g. post #1 includes post #14 that has mentioned it, therefore #14 will be the first post in the payload. The new post stream doesn’t take kindly to out of order posts. --- framework/core/js/forum/src/components/discussion-page.js | 1 + 1 file changed, 1 insertion(+) diff --git a/framework/core/js/forum/src/components/discussion-page.js b/framework/core/js/forum/src/components/discussion-page.js index 8ba62ccda..27c516ae6 100644 --- a/framework/core/js/forum/src/components/discussion-page.js +++ b/framework/core/js/forum/src/components/discussion-page.js @@ -82,6 +82,7 @@ export default class DiscussionPage extends mixin(Component, evented) { includedPosts.push(app.store.getById('posts', record.id)); } }); + includedPosts.sort((a, b) => a.id() - b.id()); this.stream = new PostStream({ discussion, includedPosts }); this.stream.on('positionChanged', this.positionChanged.bind(this)); From 57df38e85acad8b779fe04de31281ae30eefbc8e Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 29 May 2015 18:55:29 +0930 Subject: [PATCH 10/51] Fix incorrect class name --- framework/core/js/forum/src/components/event-post.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/core/js/forum/src/components/event-post.js b/framework/core/js/forum/src/components/event-post.js index 0d944d60d..00dd63b6d 100644 --- a/framework/core/js/forum/src/components/event-post.js +++ b/framework/core/js/forum/src/components/event-post.js @@ -10,7 +10,7 @@ export default class EventPost extends Post { var user = post.user(); attrs = attrs || {}; - attrs.className = 'event-post post-'+dasherize(post.contentType())+' '+(attrs.className || ''); + attrs.className = 'event-post '+dasherize(post.contentType())+'-post '+(attrs.className || ''); return super.view([ icon(iconName+' post-icon'), From 026e6361e569edc14909b964294e6e55c328aabf Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 29 May 2015 18:55:53 +0930 Subject: [PATCH 11/51] Fix edge cases where posts would not be added/removed --- framework/core/js/forum/src/components/post-stream.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework/core/js/forum/src/components/post-stream.js b/framework/core/js/forum/src/components/post-stream.js index 02536f2eb..378e9873d 100644 --- a/framework/core/js/forum/src/components/post-stream.js +++ b/framework/core/js/forum/src/components/post-stream.js @@ -87,7 +87,7 @@ class PostStream extends mixin(Component, evented) { stream is not visible. */ pushPost(post) { - if (this.visibleEnd == this.count() - 1) { + if (this.visibleEnd >= this.count() - 1) { this.posts.push(post); this.visibleEnd++; } @@ -99,7 +99,7 @@ class PostStream extends mixin(Component, evented) { */ removePost(id) { this.posts.some((item, i) => { - if (item && item.id() === id) { + if (item && item.id() == id) { this.posts.splice(i, 1); this.visibleEnd--; return true; From 2ef2457c57c387956a27794b4f0f2677280351e2 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 29 May 2015 18:56:29 +0930 Subject: [PATCH 12/51] Sync the discussion model/post stream when posts are added/removed --- .../forum/src/initializers/post-controls.js | 3 ++- framework/core/js/lib/models/discussion.js | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/framework/core/js/forum/src/initializers/post-controls.js b/framework/core/js/forum/src/initializers/post-controls.js index 384df6ecc..dfa378235 100644 --- a/framework/core/js/forum/src/initializers/post-controls.js +++ b/framework/core/js/forum/src/initializers/post-controls.js @@ -23,8 +23,9 @@ export default function(app) { function deleteAction() { this.delete(); + this.discussion().pushData({removedPosts: [this.id()]}); if (app.current instanceof DiscussionPage) { - app.current.stream().removePost(this.id()); + app.current.stream.removePost(this.id()); } } diff --git a/framework/core/js/lib/models/discussion.js b/framework/core/js/lib/models/discussion.js index f0d533f45..ed2cdbeaf 100644 --- a/framework/core/js/lib/models/discussion.js +++ b/framework/core/js/lib/models/discussion.js @@ -3,6 +3,25 @@ import computed from 'flarum/utils/computed'; import ItemList from 'flarum/utils/item-list'; class Discussion extends Model { + pushData(newData) { + super.pushData(newData); + + var posts = this.data().links.posts; + if (posts) { + if (newData.removedPosts) { + posts.linkage.forEach((linkage, i) => { + if (newData.removedPosts.indexOf(linkage.id) !== -1) { + posts.linkage.splice(i, 1); + } + }); + } + + if (newData.links && newData.links.addedPosts) { + [].push.apply(posts.linkage, newData.links.addedPosts.linkage); + } + } + } + unreadCount() { var user = app.session.user(); if (user && user.readTime() < this.lastTime()) { From 2dbd73c11e366daf0b837dea8532e231ed0537db Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sat, 30 May 2015 08:08:57 +0930 Subject: [PATCH 13/51] Implement abbreviate-number helper. closes flarum/core#96 --- framework/core/js/lib/utils/abbreviate-number.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/framework/core/js/lib/utils/abbreviate-number.js b/framework/core/js/lib/utils/abbreviate-number.js index c7aa47963..04d989541 100644 --- a/framework/core/js/lib/utils/abbreviate-number.js +++ b/framework/core/js/lib/utils/abbreviate-number.js @@ -1,3 +1,9 @@ export default function(number) { - return ''+number; // todo + if (number >= 1000000) { + return Math.floor(number / 1000000)+'M'; + } else if (number >= 1000) { + return Math.floor(number / 1000)+'K'; + } else { + return ''+number; + } } From 8859e49241b36dfabadb7b593584f600fcc4fcfe Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sat, 30 May 2015 08:40:29 +0930 Subject: [PATCH 14/51] Add helper to format number with commas --- .../js/forum/src/components/post-scrubber.js | 16 +++++++++------- framework/core/js/lib/utils/abbreviate-number.js | 2 +- framework/core/js/lib/utils/format-number.js | 3 +++ 3 files changed, 13 insertions(+), 8 deletions(-) create mode 100644 framework/core/js/lib/utils/format-number.js diff --git a/framework/core/js/forum/src/components/post-scrubber.js b/framework/core/js/forum/src/components/post-scrubber.js index 3ec1ae9ef..272e69708 100644 --- a/framework/core/js/forum/src/components/post-scrubber.js +++ b/framework/core/js/forum/src/components/post-scrubber.js @@ -3,6 +3,7 @@ import icon from 'flarum/helpers/icon'; import ScrollListener from 'flarum/utils/scroll-listener'; import SubtreeRetainer from 'flarum/utils/subtree-retainer'; import computed from 'flarum/utils/computed'; +import formatNumber from 'flarum/utils/format-number'; /** @@ -63,9 +64,10 @@ export default class PostScrubber extends Component { var unreadCount = this.props.stream.discussion.unreadCount(); var unreadPercent = unreadCount / this.count(); + // @todo clean up duplication return m('div.stream-scrubber.dropdown'+(this.disabled() ? '.disabled' : ''), {config: this.onload.bind(this)}, [ m('a.btn.btn-default.dropdown-toggle[href=javascript:;][data-toggle=dropdown]', [ - m('span.index', retain || this.visibleIndex()), ' of ', m('span.count', this.count()), ' posts ', + m('span.index', retain || formatNumber(this.visibleIndex())), ' of ', m('span.count', formatNumber(this.count())), ' posts ', icon('sort icon-glyph') ]), m('div.dropdown-menu', [ @@ -80,7 +82,7 @@ export default class PostScrubber extends Component { m('div.scrubber-slider', [ m('div.scrubber-handle'), m('div.scrubber-info', [ - m('strong', [m('span.index', retain || this.visibleIndex()), ' of ', m('span.count', this.count()), ' posts']), + m('strong', [m('span.index', retain || formatNumber(this.visibleIndex())), ' of ', m('span.count', formatNumber(this.count())), ' posts']), m('span.description', retain || this.description()) ]) ]), @@ -95,7 +97,7 @@ export default class PostScrubber extends Component { } context.oldStyle = newStyle; } - }, unreadCount+' unread') : '' + }, formatNumber(unreadCount)+' unread') : '' ]), m('a.scrubber-last[href=javascript:;]', {onclick: () => { stream.goToLast(); @@ -264,7 +266,7 @@ export default class PostScrubber extends Component { var visible = this.visible(); var $scrubber = this.$(); - $scrubber.find('.index').text(this.visibleIndex()); + $scrubber.find('.index').text(formatNumber(this.visibleIndex())); $scrubber.find('.description').text(this.description()); $scrubber.toggleClass('disabled', this.disabled()); @@ -318,7 +320,7 @@ export default class PostScrubber extends Component { scrollbar.css('max-height', $(window).height() - scrollbar.offset().top + $(window).scrollTop() - parseInt($('.global-page').css('padding-bottom'))); } - onmousedown() { + onmousedown(e) { this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY; this.indexStart = this.index(); this.dragging = true; @@ -326,7 +328,7 @@ export default class PostScrubber extends Component { $('body').css('cursor', 'move'); } - onmousemove() { + onmousemove(e) { if (! this.dragging) { return; } // Work out how much the mouse has moved by - first in pixels, then @@ -342,7 +344,7 @@ export default class PostScrubber extends Component { this.renderScrollbar(); } - onmouseup() { + onmouseup(e) { if (!this.dragging) { return; } this.mouseStart = 0; this.indexStart = 0; diff --git a/framework/core/js/lib/utils/abbreviate-number.js b/framework/core/js/lib/utils/abbreviate-number.js index 04d989541..44c2a2d38 100644 --- a/framework/core/js/lib/utils/abbreviate-number.js +++ b/framework/core/js/lib/utils/abbreviate-number.js @@ -4,6 +4,6 @@ export default function(number) { } else if (number >= 1000) { return Math.floor(number / 1000)+'K'; } else { - return ''+number; + return number.toString(); } } diff --git a/framework/core/js/lib/utils/format-number.js b/framework/core/js/lib/utils/format-number.js new file mode 100644 index 000000000..4a8738c66 --- /dev/null +++ b/framework/core/js/lib/utils/format-number.js @@ -0,0 +1,3 @@ +export default function(number) { + return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} From bd3bc6b2749e15d2bf4d9e0e4ddbcfebfc5208c4 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sat, 30 May 2015 12:06:48 +0930 Subject: [PATCH 15/51] Re-add event after a discussion has loaded replyAction uses it --- framework/core/js/forum/src/components/discussion-page.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/framework/core/js/forum/src/components/discussion-page.js b/framework/core/js/forum/src/components/discussion-page.js index 27c516ae6..dcb60d951 100644 --- a/framework/core/js/forum/src/components/discussion-page.js +++ b/framework/core/js/forum/src/components/discussion-page.js @@ -87,6 +87,8 @@ export default class DiscussionPage extends mixin(Component, evented) { 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'); } onload(element, isInitialized, context) { From 731b00571c95e5a29c7b9036223cf8afecbeb55b Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sat, 30 May 2015 13:57:39 +0930 Subject: [PATCH 16/51] Eager load notification relationships --- framework/core/src/Api/Actions/Notifications/IndexAction.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/framework/core/src/Api/Actions/Notifications/IndexAction.php b/framework/core/src/Api/Actions/Notifications/IndexAction.php index 4a23c05aa..c7cfd6011 100644 --- a/framework/core/src/Api/Actions/Notifications/IndexAction.php +++ b/framework/core/src/Api/Actions/Notifications/IndexAction.php @@ -73,6 +73,7 @@ class IndexAction extends SerializeCollectionAction $user->markNotificationsAsRead()->save(); - return $this->notifications->findByUser($user, $request->limit, $request->offset); + return $this->notifications->findByUser($user, $request->limit, $request->offset) + ->load($request->include); } } From a3b029accc17d7c9c3b068f5eb223378b3f83901 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sat, 30 May 2015 13:58:21 +0930 Subject: [PATCH 17/51] Prevent error when trying to get relationship and no links have been loaded --- framework/core/js/lib/model.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/framework/core/js/lib/model.js b/framework/core/js/lib/model.js index 88217b9df..156bafc9c 100644 --- a/framework/core/js/lib/model.js +++ b/framework/core/js/lib/model.js @@ -80,23 +80,29 @@ export default class Model { static prop(name, transform) { return function() { var data = this.data()[name]; - return transform ? transform(data) : data + return transform ? transform(data) : data; } } static one(name) { return function() { - var link = this.data().links[name]; - return link && app.store.getById(link.linkage.type, link.linkage.id) + var data = this.data(); + if (data.links) { + var link = data.links[name]; + return link && app.store.getById(link.linkage.type, link.linkage.id); + } } } static many(name) { return function() { - var link = this.data().links[name]; - return link && link.linkage.map(function(link) { - return app.store.getById(link.type, link.id) - }) + var data = this.data(); + if (data.links) { + var link = this.data().links[name]; + return link && link.linkage.map(function(link) { + return app.store.getById(link.type, link.id) + }); + } } } From 4a3f8d2aa8a1fc8e0330ca3ffd202828889c2d94 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sat, 30 May 2015 14:58:47 +0930 Subject: [PATCH 18/51] Padding tweak --- framework/core/less/forum/index.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/core/less/forum/index.less b/framework/core/less/forum/index.less index 2d2989221..207c29c6b 100644 --- a/framework/core/less/forum/index.less +++ b/framework/core/less/forum/index.less @@ -205,7 +205,7 @@ text-decoration: underline; } & .title { - margin: 0 0 7px; + margin: 0 0 6px; line-height: 1.3; color: @fl-secondary-color; } From 6e1bf0d3de467ef8dc898e79fc858f469fb7528a Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sun, 31 May 2015 11:17:41 +0930 Subject: [PATCH 19/51] Fix post scrubber closing on mobile --- framework/core/js/forum/src/components/post-scrubber.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/core/js/forum/src/components/post-scrubber.js b/framework/core/js/forum/src/components/post-scrubber.js index 272e69708..d11cb116b 100644 --- a/framework/core/js/forum/src/components/post-scrubber.js +++ b/framework/core/js/forum/src/components/post-scrubber.js @@ -211,7 +211,7 @@ export default class PostScrubber extends Component { // When any part of the whole scrollbar is clicked, we want to jump to // that position. this.$('.scrubber-scrollbar') - .bind('click touchstart', this.onclick.bind(this)) + .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. From 6b7632cda3dc09d3ad45ca61df6f633f13b12de5 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sun, 31 May 2015 11:18:19 +0930 Subject: [PATCH 20/51] Move theme config to database --- framework/core/less/forum/app.less | 2 -- framework/core/less/lib/config.less | 11 ----------- framework/core/less/lib/variables.less | 7 ++++++- .../core/src/Core/Seeders/ConfigTableSeeder.php | 16 ++++++++++------ framework/core/src/Forum/Actions/IndexAction.php | 9 ++++++++- 5 files changed, 24 insertions(+), 21 deletions(-) delete mode 100644 framework/core/less/lib/config.less diff --git a/framework/core/less/forum/app.less b/framework/core/less/forum/app.less index d71f6f25c..9fd60924b 100644 --- a/framework/core/less/forum/app.less +++ b/framework/core/less/forum/app.less @@ -1,5 +1,3 @@ -@import "@{lib-path}/config.less"; - @lib-path: "../lib"; @import "@{lib-path}/bootstrap.less"; diff --git a/framework/core/less/lib/config.less b/framework/core/less/lib/config.less deleted file mode 100644 index e80bbd00e..000000000 --- a/framework/core/less/lib/config.less +++ /dev/null @@ -1,11 +0,0 @@ -// --------------------------------- -// CONFIG - -// Color palette: -// #8F3B3B #9E5541 #99793F #8F8A49 #778F53 #638F53 #537F8F #536F90 #76538F #8F5373 #797979 - -@fl-primary-color: #536F90; -@fl-secondary-color: #536F90; - -@fl-dark-mode: false; -@fl-colored-hdr: false; diff --git a/framework/core/less/lib/variables.less b/framework/core/less/lib/variables.less index eb9ea4c05..d85161fd7 100644 --- a/framework/core/less/lib/variables.less +++ b/framework/core/less/lib/variables.less @@ -1,3 +1,8 @@ +@fl-primary-color: #536F90; +@fl-secondary-color: #536F90; +@fl-dark-mode: false; +@fl-colored-hdr: false; + // --------------------------------- // HELPERS @@ -22,7 +27,7 @@ @fl-shadow-color: rgba(0, 0, 0, 0.35); } .define-body-variables(true) { - @fl-body-primary-color: mix(@fl-primary-color, #000); + @fl-body-primary-color: mix(@fl-primary-color, #000, 80%); @fl-body-secondary-color: hsl(@fl-secondary-hue, min(20%, @fl-secondary-sat), 13%); @fl-body-bg: hsl(@fl-secondary-hue, min(20%, @fl-secondary-sat), 10%); diff --git a/framework/core/src/Core/Seeders/ConfigTableSeeder.php b/framework/core/src/Core/Seeders/ConfigTableSeeder.php index 27daca92d..96d58846e 100644 --- a/framework/core/src/Core/Seeders/ConfigTableSeeder.php +++ b/framework/core/src/Core/Seeders/ConfigTableSeeder.php @@ -14,12 +14,16 @@ class ConfigTableSeeder extends Seeder public function run() { $config = [ - 'api_url' => 'http://flarum.dev/api', - 'base_url' => 'http://flarum.dev', - 'forum_title' => 'Flarum Demo Forum', - 'welcome_message' => 'Flarum is now at a point where you can have basic conversations, so here is a little demo for you to break.', - 'welcome_title' => 'Welcome to Flarum Demo Forum', - 'extensions_enabled' => '[]', + 'api_url' => 'http://flarum.dev/api', + 'base_url' => 'http://flarum.dev', + 'forum_title' => 'Flarum Demo Forum', + 'welcome_message' => 'Flarum is now at a point where you can have basic conversations, so here is a little demo for you to break.', + 'welcome_title' => 'Welcome to Flarum Demo Forum', + 'extensions_enabled' => '[]', + 'theme_primary_color' => '#536F90', + 'theme_secondary_color' => '#536F90', + 'theme_dark_mode' => false, + 'theme_colored_header' => false, ]; DB::table('config')->insert(array_map(function ($key, $value) { diff --git a/framework/core/src/Forum/Actions/IndexAction.php b/framework/core/src/Forum/Actions/IndexAction.php index 30ba095c6..18f70afec 100644 --- a/framework/core/src/Forum/Actions/IndexAction.php +++ b/framework/core/src/Forum/Actions/IndexAction.php @@ -9,6 +9,7 @@ use View; use DB; use Flarum\Forum\Events\RenderView; use Flarum\Api\Request as ApiRequest; +use Flarum\Core; class IndexAction extends BaseAction { @@ -36,7 +37,7 @@ class IndexAction extends BaseAction } $view = View::make('flarum.forum::index') - ->with('title', Config::get('flarum::forum_title', 'Flarum Demo Forum')) + ->with('title', Core::config('forum_title')) ->with('config', $config) ->with('layout', 'flarum.forum::forum') ->with('data', $data) @@ -49,6 +50,12 @@ class IndexAction extends BaseAction $root.'/js/forum/dist/app.js', $root.'/less/forum/app.less' ]); + $assetManager->addLess(' + @fl-primary-color: '.Core::config('theme_primary_color').'; + @fl-secondary-color: '.Core::config('theme_secondary_color').'; + @fl-dark-mode: '.(Core::config('theme_dark_mode') ? 'true' : 'false').'; + @fl-colored_header: '.(Core::config('theme_colored_header') ? 'true' : 'false').'; + '); event(new RenderView($view, $assetManager, $this)); From b36e3f10538aaeb392b6bea4a0fd2f18fd74f81b Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sun, 31 May 2015 13:53:02 +0930 Subject: [PATCH 21/51] Simplify active discussion detection --- framework/core/js/forum/src/components/discussion-list.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/framework/core/js/forum/src/components/discussion-list.js b/framework/core/js/forum/src/components/discussion-list.js index 47e755d19..3b8ab3bdb 100644 --- a/framework/core/js/forum/src/components/discussion-list.js +++ b/framework/core/js/forum/src/components/discussion-list.js @@ -127,8 +127,7 @@ export default class DiscussionList extends Component { var controls = discussion.controls(this).toArray(); - var discussionRoute = app.route('discussion', { id: discussion.id(), slug: discussion.slug() }); - var active = m.route().substr(0, discussionRoute.length) === discussionRoute; + var active = m.route.param('id') === discussion.id(); var subtree = this.subtrees[discussion.id()]; return m('li.discussion-summary'+(isUnread ? '.unread' : '')+(active ? '.active' : ''), { From ae09240a3df6222b2c88aa35ed0c46a5dd4b370c Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 08:48:10 +0930 Subject: [PATCH 22/51] Add some missing post content styles --- framework/core/less/forum/discussion.less | 19 +++++++++++++++++++ framework/core/less/lib/components.less | 4 ++++ 2 files changed, 23 insertions(+) diff --git a/framework/core/less/forum/discussion.less b/framework/core/less/forum/discussion.less index 4d92ecca6..4df1959df 100644 --- a/framework/core/less/forum/discussion.less +++ b/framework/core/less/forum/discussion.less @@ -237,6 +237,25 @@ color: #666; font-size: 90%; } + & h1 { + font-size: 160%; + } + & h2 { + font-size: 120%; + font-weight: bold; + } + & h3 { + font-size: 100%; + font-weight: bold; + text-transform: uppercase; + } + & h4, & h5, & h6 { + font-size: 100%; + font-weight: bold; + } + & img { + max-width: 100%; + } } .post.is-hidden { & .post-header, & .post-header a, & .post-user h3, & .post-user h3 a { diff --git a/framework/core/less/lib/components.less b/framework/core/less/lib/components.less index 6ed347d00..dd6500113 100644 --- a/framework/core/less/lib/components.less +++ b/framework/core/less/lib/components.less @@ -18,3 +18,7 @@ .loading-indicator-block { height: 100px; } + +hr { + border-top: 2px solid @fl-body-secondary-color; +} From 0b1ff2216886367e5cb469f59a48666fc80b6aaa Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 08:48:50 +0930 Subject: [PATCH 23/51] Tweak composer full screen styles. closes flarum/core#102 --- framework/core/less/forum/composer.less | 50 ++++++++++++++++--------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/framework/core/less/forum/composer.less b/framework/core/less/forum/composer.less index feeac1ebf..30c0dc127 100644 --- a/framework/core/less/forum/composer.less +++ b/framework/core/less/forum/composer.less @@ -17,27 +17,24 @@ & > li { display: inline-block; - margin-right: 10px; + margin-right: -4px; } & h3 { margin: 0 0 10px; line-height: 1.5em; &, & input, & a { - color: @fl-body-muted-color; - font-size: 15px; + color: @fl-secondary-color; + font-size: 16px; font-weight: normal; - transition: color 0.2s; - - .active & { - color: @fl-body-primary-color; - } } - & input, & input[disabled] { - background: none; - border: 0; - padding: 0; - height: auto; + & input { + &, &[disabled] { + background: none; + border: 0; + padding: 0 20px 0 0; + height: auto; + } } } } @@ -134,6 +131,15 @@ height: auto; } } + .composer-controls { + .full-screen & .btn { + padding: 13px; + + & .fa { + font-size: 20px; + } + } + } .composer-header { .minimized & { pointer-events: none; @@ -167,14 +173,14 @@ float: left; .avatar-size(64px); - .minimized & { + .minimized &, .full-screen & { display: none; } } .composer-body { margin-left: 90px; - .minimized & { + .minimized &, .full-screen & { margin-left: 0; } } @@ -182,15 +188,19 @@ .minimized & { visibility: hidden; } + + .full-screen & textarea { + font-size: 16px; + } } } @media @desktop, @desktop-hd { - .composer { + .composer:not(.full-screen) { margin-left: -20px; margin-right: 180px; - .index-page &:not(.full-screen) { + .index-page & { margin-left: 205px; margin-right: -20px; } @@ -237,6 +247,12 @@ padding: 15px 20px; border-top: 1px solid @fl-body-secondary-color; + .full-screen & { + margin: 0; + border-top: 0; + padding: 20px 0; + } + & .btn-primary { padding-left: 20px; padding-right: 20px; From 78efdc1d0956f3b03515b5f81b4efd285d543579 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 08:49:23 +0930 Subject: [PATCH 24/51] Fix bug where switching composer component would lead to incorrect rendering --- framework/core/js/forum/src/components/composer.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/framework/core/js/forum/src/components/composer.js b/framework/core/js/forum/src/components/composer.js index 2db6dd777..d9c6b4519 100644 --- a/framework/core/js/forum/src/components/composer.js +++ b/framework/core/js/forum/src/components/composer.js @@ -133,8 +133,6 @@ class Composer extends Component { } render(anchorToBottom) { - if (this.position() === this.oldPosition) { this.component.focus(); return; } - var $composer = this.$().stop(true); var oldHeight = $composer.is(':visible') ? $composer.outerHeight() : 0; From 605eaa6ffc02abc7cb9950c921d1b123246c0015 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 08:49:46 +0930 Subject: [PATCH 25/51] Add text-editor API to get selection range --- framework/core/js/lib/components/text-editor.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/framework/core/js/lib/components/text-editor.js b/framework/core/js/lib/components/text-editor.js index b81cca45d..87e0665d8 100644 --- a/framework/core/js/lib/components/text-editor.js +++ b/framework/core/js/lib/components/text-editor.js @@ -61,6 +61,11 @@ export default class TextEditor extends Component { $textarea.focus(); } + getSelectionRange() { + var $textarea = this.$('textarea'); + return [$textarea[0].selectionStart, $textarea[0].selectionEnd]; + } + insertAtCursor(insert) { var textarea = this.$('textarea')[0]; var content = this.value(); From 220190cc53732df9f654287368d668529fdac873 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 08:52:04 +0930 Subject: [PATCH 26/51] Add NotificationWillBeSent event --- .../src/Core/Events/NotificationWillBeSent.php | 16 ++++++++++++++++ .../Core/Notifications/NotificationSyncer.php | 3 +++ 2 files changed, 19 insertions(+) create mode 100644 framework/core/src/Core/Events/NotificationWillBeSent.php diff --git a/framework/core/src/Core/Events/NotificationWillBeSent.php b/framework/core/src/Core/Events/NotificationWillBeSent.php new file mode 100644 index 000000000..968e147ae --- /dev/null +++ b/framework/core/src/Core/Events/NotificationWillBeSent.php @@ -0,0 +1,16 @@ +notification = $notification; + $this->users = $users; + } +} diff --git a/framework/core/src/Core/Notifications/NotificationSyncer.php b/framework/core/src/Core/Notifications/NotificationSyncer.php index 7ca70e389..b9a18f011 100644 --- a/framework/core/src/Core/Notifications/NotificationSyncer.php +++ b/framework/core/src/Core/Notifications/NotificationSyncer.php @@ -2,6 +2,7 @@ use Flarum\Core\Repositories\NotificationRepositoryInterface; use Flarum\Core\Models\Notification; +use Flarum\Core\Events\NotificationWillBeSent; use Carbon\Carbon; use Closure; @@ -66,6 +67,8 @@ class NotificationSyncer if (count($newRecipients)) { $now = Carbon::now('utc')->toDateTimeString(); + event(new NotificationWillBeSent($notification, $newRecipients)); + Notification::insert( array_map(function ($user) use ($attributes, $notification, $now) { return $attributes + ['user_id' => $user->id, 'time' => $now]; From b1693f9537e91c940deaddabe8eeb5657185d29a Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 10:26:23 +0930 Subject: [PATCH 27/51] Add 'state helpers', shortcuts to make querying app state easier --- framework/core/js/forum/src/app.js | 2 ++ .../js/forum/src/initializers/state-helpers.js | 14 ++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 framework/core/js/forum/src/initializers/state-helpers.js diff --git a/framework/core/js/forum/src/app.js b/framework/core/js/forum/src/app.js index 9aa36238f..d68d374ef 100644 --- a/framework/core/js/forum/src/app.js +++ b/framework/core/js/forum/src/app.js @@ -1,5 +1,6 @@ import App from 'flarum/utils/app'; 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'; @@ -12,6 +13,7 @@ import boot from 'flarum/initializers/boot'; var app = new App(); 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); diff --git a/framework/core/js/forum/src/initializers/state-helpers.js b/framework/core/js/forum/src/initializers/state-helpers.js new file mode 100644 index 000000000..3363a6ff6 --- /dev/null +++ b/framework/core/js/forum/src/initializers/state-helpers.js @@ -0,0 +1,14 @@ +import ReplyComposer from 'flarum/components/reply-composer'; +import DiscussionPage from 'flarum/components/discussion-page'; + +export default function(app) { + app.composingReplyTo = function(discussion) { + return this.composer.component instanceof ReplyComposer && + this.composer.component.props.discussion === discussion; + }; + + app.viewingDiscussion = function(discussion) { + return this.current instanceof DiscussionPage && + this.current.discussion() === discussion; + }; +}; From 8d7a6985babd17f7f57c7e849fc8cc2ea0fb63e9 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 10:28:24 +0930 Subject: [PATCH 28/51] Add reply placeholder to bottom of post stream --- .../forum/src/components/discussion-composer.js | 2 +- .../core/js/forum/src/components/post-stream.js | 15 +++++++++++++-- .../js/forum/src/components/reply-composer.js | 2 +- .../forum/src/components/reply-placeholder.js | 10 ++++++++++ framework/core/less/forum/discussion.less | 17 +++++++++++++++++ 5 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 framework/core/js/forum/src/components/reply-placeholder.js diff --git a/framework/core/js/forum/src/components/discussion-composer.js b/framework/core/js/forum/src/components/discussion-composer.js index 99a9ad0ea..eb987b48f 100644 --- a/framework/core/js/forum/src/components/discussion-composer.js +++ b/framework/core/js/forum/src/components/discussion-composer.js @@ -10,7 +10,7 @@ import ActionButton from 'flarum/components/action-button'; */ export default class DiscussionComposer extends ComposerBody { constructor(props) { - props.placeholder = props.placeholder || 'Write a post...'; + 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'; diff --git a/framework/core/js/forum/src/components/post-stream.js b/framework/core/js/forum/src/components/post-stream.js index 378e9873d..2f48354ab 100644 --- a/framework/core/js/forum/src/components/post-stream.js +++ b/framework/core/js/forum/src/components/post-stream.js @@ -4,6 +4,7 @@ import PostLoading from 'flarum/components/post-loading'; import anchorScroll from 'flarum/utils/anchor-scroll'; import mixin from 'flarum/utils/mixin'; import evented from 'flarum/utils/evented'; +import ReplyPlaceholder from 'flarum/components/reply-placeholder'; class PostStream extends mixin(Component, evented) { constructor(props) { @@ -170,7 +171,15 @@ class PostStream extends mixin(Component, evented) { } return m('div.item', attributes, content); - }) + }), + + // If we're viewing the end of the discussion, the user can reply, and + // is not already doing so, then show a 'write a reply' placeholder. + this.visibleEnd === this.count() && + (!app.session.user() || this.discussion.canReply()) && + !app.composingReplyTo(this.discussion) + ? m('div.item', ReplyPlaceholder.component({discussion: this.discussion})) + : '' ); } @@ -376,7 +385,9 @@ class PostStream extends mixin(Component, evented) { } if (top + height < scrollTop + viewportHeight) { - endNumber = $item.data('number'); + if ($item.data('number')) { + endNumber = $item.data('number'); + } } else { return false; } diff --git a/framework/core/js/forum/src/components/reply-composer.js b/framework/core/js/forum/src/components/reply-composer.js index 4e19ad502..3fdde161f 100644 --- a/framework/core/js/forum/src/components/reply-composer.js +++ b/framework/core/js/forum/src/components/reply-composer.js @@ -6,7 +6,7 @@ import Composer from 'flarum/components/composer'; export default class ReplyComposer extends ComposerBody { constructor(props) { - props.placeholder = props.placeholder || 'Write your reply...'; + 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?'; diff --git a/framework/core/js/forum/src/components/reply-placeholder.js b/framework/core/js/forum/src/components/reply-placeholder.js new file mode 100644 index 000000000..5b569a7ac --- /dev/null +++ b/framework/core/js/forum/src/components/reply-placeholder.js @@ -0,0 +1,10 @@ +import Component from 'flarum/component'; +import avatar from 'flarum/helpers/avatar'; + +export default class ReplyPlaceholder extends Component { + view() { + return m('article.post.reply-post', {onclick: () => this.props.discussion.replyAction(true)}, [ + m('header.post-header', avatar(app.session.user()), 'Write a Reply...'), + ]); + } +} diff --git a/framework/core/less/forum/discussion.less b/framework/core/less/forum/discussion.less index 4df1959df..f1b744abd 100644 --- a/framework/core/less/forum/discussion.less +++ b/framework/core/less/forum/discussion.less @@ -459,6 +459,23 @@ font-size: 22px; } } +.reply-post { + font-size: 15px; + cursor: text; + overflow: hidden; + margin: -15px; + padding: 15px 15px 15px 90px + 15px; + + & .avatar { + opacity: 0.25; + } + + &:hover { + border: 1px dashed @fl-body-secondary-color; + border-radius: 64px; + margin: -16px; + } +} // ------------------------------------ // Scrubber From c3c2978fc12f629941f3cc0fad5b53017246379e Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 10:29:01 +0930 Subject: [PATCH 29/51] Make replyAction into a promise. closes #100 --- .../forum/src/components/discussion-page.js | 2 +- .../js/forum/src/components/login-modal.js | 2 +- .../src/initializers/discussion-controls.js | 50 ++++++++++++------- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/framework/core/js/forum/src/components/discussion-page.js b/framework/core/js/forum/src/components/discussion-page.js index dcb60d951..f4356e3fd 100644 --- a/framework/core/js/forum/src/components/discussion-page.js +++ b/framework/core/js/forum/src/components/discussion-page.js @@ -88,7 +88,7 @@ export default class DiscussionPage extends mixin(Component, evented) { this.stream.on('positionChanged', this.positionChanged.bind(this)); this.stream.goToNumber(m.route.param('near') || 1, true); - this.trigger('loaded'); + this.trigger('loaded', discussion); } onload(element, isInitialized, context) { diff --git a/framework/core/js/forum/src/components/login-modal.js b/framework/core/js/forum/src/components/login-modal.js index cbecd24d1..3e0897100 100644 --- a/framework/core/js/forum/src/components/login-modal.js +++ b/framework/core/js/forum/src/components/login-modal.js @@ -60,7 +60,7 @@ export default class LoginModal extends FormModal { app.session.login(email, password).then(() => { this.hide(); - this.props.callback && this.props.callback(); + this.props.onlogin && this.props.onlogin(); }, response => { this.loading(false); if (response && response.code === 'confirm_email') { diff --git a/framework/core/js/forum/src/initializers/discussion-controls.js b/framework/core/js/forum/src/initializers/discussion-controls.js index 755bbc44d..1b1a031fd 100644 --- a/framework/core/js/forum/src/initializers/discussion-controls.js +++ b/framework/core/js/forum/src/initializers/discussion-controls.js @@ -8,25 +8,41 @@ import ItemList from 'flarum/utils/item-list'; export default function(app) { Discussion.prototype.replyAction = function(goToLast, forceRefresh) { - if (app.session.user() && this.canReply()) { - if (goToLast && app.current.discussion && app.current.discussion().id() === this.id()) { - app.current.stream.goToLast(); + var deferred = m.deferred(); + + var reply = () => { + if (this.canReply()) { + if (goToLast && app.viewingDiscussion(this)) { + app.current.stream.goToLast(); + } + + var component = app.composer.component; + if (!app.composingReplyTo(this) || forceRefresh) { + component = new ReplyComposer({ + user: app.session.user(), + discussion: this + }); + app.composer.load(component); + } + app.composer.show(goToLast); + + deferred.resolve(component); + } else { + deferred.reject(); } - var component = app.composer.component; - if (!(component instanceof ReplyComposer) || component.props.discussion !== this || component.props.user !== app.session.user() || forceRefresh) { - component = new ReplyComposer({ - user: app.session.user(), - discussion: this - }); - app.composer.load(component); - } - app.composer.show(goToLast); - return component; - } else if (!app.session.user()) { - app.modal.show(new LoginModal({ - callback: () => app.current.one('loaded', this.replyAction.bind(this, goToLast, forceRefresh)) - })); + }; + + if (app.session.user()) { + reply(); + } else { + app.modal.show( + new LoginModal({ + onlogin: () => app.current.one('loaded', reply) + }) + ); } + + return deferred.promise; } Discussion.prototype.deleteAction = function() { From 0a0c50e1bb8b9e9effd7ebf7c59d5f2013633a72 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 10:29:11 +0930 Subject: [PATCH 30/51] Add icon to post edit composer --- framework/core/js/forum/src/components/edit-composer.js | 3 ++- framework/core/less/forum/composer.less | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/framework/core/js/forum/src/components/edit-composer.js b/framework/core/js/forum/src/components/edit-composer.js index bd1bb495c..cb67e7346 100644 --- a/framework/core/js/forum/src/components/edit-composer.js +++ b/framework/core/js/forum/src/components/edit-composer.js @@ -2,6 +2,7 @@ import ItemList from 'flarum/utils/item-list'; import ComposerBody from 'flarum/components/composer-body'; import Alert from 'flarum/components/alert'; import ActionButton from 'flarum/components/action-button'; +import icon from 'flarum/helpers/icon'; /** The composer body for editing a post. Sets the initial content to the @@ -23,7 +24,7 @@ export default class EditComposer extends ComposerBody { var post = this.props.post; items.add('title', m('h3', [ - 'Editing ', + icon('pencil'), ' ', m('a', {href: app.route.discussion(post.discussion(), post.number()), config: m.route}, 'Post #'+post.number()), ' in ', post.discussion().title() ])); diff --git a/framework/core/less/forum/composer.less b/framework/core/less/forum/composer.less index 30c0dc127..4df486a36 100644 --- a/framework/core/less/forum/composer.less +++ b/framework/core/less/forum/composer.less @@ -36,6 +36,9 @@ height: auto; } } + & .fa { + font-size: 14px; + } } } .composer-controls { From eed809e6e86dea38d272f74f2a4e46b3904c95c0 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 10:43:16 +0930 Subject: [PATCH 31/51] Force redraw to ensure focusing works --- framework/core/js/forum/src/components/composer-body.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/core/js/forum/src/components/composer-body.js b/framework/core/js/forum/src/components/composer-body.js index 5484727d9..a37c1ceda 100644 --- a/framework/core/js/forum/src/components/composer-body.js +++ b/framework/core/js/forum/src/components/composer-body.js @@ -41,7 +41,7 @@ export default class ComposerBody extends Component { focus() { this.ready(true); - m.redraw(); + m.redraw(true); this.$(':input:enabled:visible:first').focus(); } From d96c5c284fdfe1a0806a60a02f52f670cdd62f23 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 11:09:39 +0930 Subject: [PATCH 32/51] Scroll to the bottom of the last post when jumping to last --- .../core/js/forum/src/components/post-stream.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/framework/core/js/forum/src/components/post-stream.js b/framework/core/js/forum/src/components/post-stream.js index 2f48354ab..6a3e6049d 100644 --- a/framework/core/js/forum/src/components/post-stream.js +++ b/framework/core/js/forum/src/components/post-stream.js @@ -51,7 +51,7 @@ class PostStream extends mixin(Component, evented) { return promise.then(() => { anchorScroll(this.$('.item:'+(backwards ? 'last' : 'first')), () => m.redraw(true)); - this.scrollToIndex(index, noAnimation).done(this.unpause.bind(this)); + this.scrollToIndex(index, noAnimation, backwards).done(this.unpause.bind(this)); }); } @@ -66,7 +66,7 @@ class PostStream extends mixin(Component, evented) { Load and scroll down to the last post in the discussion. */ goToLast() { - return this.goToIndex(this.count() - 1); + return this.goToIndex(this.count() - 1, true); } /** @@ -419,16 +419,16 @@ class PostStream extends mixin(Component, evented) { /** Scroll down to a certain post by index. */ - scrollToIndex(index, noAnimation) { + scrollToIndex(index, noAnimation, bottom) { var $item = this.$('.item[data-index='+index+']'); - return this.scrollToItem($item, noAnimation, true); + return this.scrollToItem($item, noAnimation, true, true); } /** Scroll down to the given post. */ - scrollToItem($item, noAnimation, force) { + scrollToItem($item, noAnimation, force, bottom) { var $container = $('html, body').stop(true); if ($item.length) { @@ -439,7 +439,7 @@ class PostStream extends mixin(Component, evented) { // If the item is already in the viewport, we may not need to scroll. if (force || itemTop < scrollTop || itemBottom > scrollBottom) { - var scrollTop = $item.is(':first-child') ? 0 : itemTop; + var scrollTop = bottom ? itemBottom : ($item.is(':first-child') ? 0 : itemTop); if (noAnimation) { $container.scrollTop(scrollTop); From 71a5b1f49c1bd7ea80e40ffbece43adc86b89df2 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 11:10:15 +0930 Subject: [PATCH 33/51] Fix/simplify timestamp live updating. closes flarum/core#101 --- .../core/js/lib/initializers/timestamps.js | 142 +----------------- 1 file changed, 6 insertions(+), 136 deletions(-) diff --git a/framework/core/js/lib/initializers/timestamps.js b/framework/core/js/lib/initializers/timestamps.js index dabfe318f..67b7f43d2 100644 --- a/framework/core/js/lib/initializers/timestamps.js +++ b/framework/core/js/lib/initializers/timestamps.js @@ -1,140 +1,10 @@ import humanTime from 'flarum/utils/human-time'; export default function(app) { - // perhaps get rid of this and just m.redraw every minute? - - // Livestamp.js / v1.1.2 / (c) 2012 Matt Bradley / MIT License - // @todo rewrite this to be simpler and cleaner - (function($, moment) { - var updateInterval = 1e3, - paused = false, - $livestamps = $([]), - - init = function() { - livestampGlobal.resume(); - }, - - prep = function($el, timestamp) { - var oldData = $el.data('livestampdata'); - if (typeof timestamp == 'number') - timestamp *= 1e3; - - $el.removeAttr('data-humantime') - .removeData('humantime'); - - timestamp = moment(timestamp); - if (moment().diff(timestamp) > 60 * 60) { - return; - } - if (moment.isMoment(timestamp) && !isNaN(+timestamp)) { - var newData = $.extend({ }, { 'original': $el.contents() }, oldData); - newData.moment = moment(timestamp); - - $el.data('livestampdata', newData).empty(); - $livestamps.push($el[0]); - } - }, - - run = function() { - if (paused) return; - livestampGlobal.update(); - setTimeout(run, updateInterval); - }, - - livestampGlobal = { - update: function() { - $('[data-humantime]').each(function() { - var $this = $(this); - prep($this, $this.attr('datetime')); - }); - - var toRemove = []; - $livestamps.each(function() { - var $this = $(this), - data = $this.data('livestampdata'); - - if (data === undefined) - toRemove.push(this); - else if (moment.isMoment(data.moment)) { - var from = $this.html(), - to = humanTime(data.moment); - // to = data.moment.fromNow(); - - if (from != to) { - var e = $.Event('change.livestamp'); - $this.trigger(e, [from, to]); - if (!e.isDefaultPrevented()) - $this.html(to); - } - } - }); - - $livestamps = $livestamps.not(toRemove); - }, - - pause: function() { - paused = true; - }, - - resume: function() { - paused = false; - run(); - }, - - interval: function(interval) { - if (interval === undefined) - return updateInterval; - updateInterval = interval; - } - }, - - livestampLocal = { - add: function($el, timestamp) { - if (typeof timestamp == 'number') - timestamp *= 1e3; - timestamp = moment(timestamp); - - if (moment.isMoment(timestamp) && !isNaN(+timestamp)) { - $el.each(function() { - prep($(this), timestamp); - }); - livestampGlobal.update(); - } - - return $el; - }, - - destroy: function($el) { - $livestamps = $livestamps.not($el); - $el.each(function() { - var $this = $(this), - data = $this.data('livestampdata'); - - if (data === undefined) - return $el; - - $this - .html(data.original ? data.original : '') - .removeData('livestampdata'); - }); - - return $el; - }, - - isLivestamp: function($el) { - return $el.data('livestampdata') !== undefined; - } - }; - - $.livestamp = livestampGlobal; - $(init); - $.fn.livestamp = function(method, options) { - if (!livestampLocal[method]) { - options = method; - method = 'add'; - } - - return livestampLocal[method](this, options); - }; - })(jQuery, moment); + setInterval(function() { + $('[data-humantime]').each(function() { + var $this = $(this); + $this.html(humanTime($this.attr('datetime'))); + }); + }, 1000); } From 39e1b8e0082d8de0341e1b80f3584f0f492dd295 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 12:24:06 +0930 Subject: [PATCH 34/51] Remove default relationships from serializers --- .../js/forum/src/components/discussion-page.js | 2 +- .../src/Api/Actions/Discussions/ShowAction.php | 2 +- .../core/src/Api/Serializers/BaseSerializer.php | 6 +++--- .../src/Api/Serializers/DiscussionSerializer.php | 7 ------- .../src/Api/Serializers/PostBasicSerializer.php | 14 -------------- .../core/src/Api/Serializers/PostSerializer.php | 7 ------- .../core/src/Api/Serializers/UserSerializer.php | 7 ------- 7 files changed, 5 insertions(+), 40 deletions(-) diff --git a/framework/core/js/forum/src/components/discussion-page.js b/framework/core/js/forum/src/components/discussion-page.js index f4356e3fd..cf29a599c 100644 --- a/framework/core/js/forum/src/components/discussion-page.js +++ b/framework/core/js/forum/src/components/discussion-page.js @@ -54,7 +54,7 @@ export default class DiscussionPage extends mixin(Component, evented) { params() { return { near: this.currentNear, - include: ['posts', 'posts.user'] + include: ['posts', 'posts.user', 'posts.user.groups'] }; } diff --git a/framework/core/src/Api/Actions/Discussions/ShowAction.php b/framework/core/src/Api/Actions/Discussions/ShowAction.php index dbbbc2302..3c59872d7 100644 --- a/framework/core/src/Api/Actions/Discussions/ShowAction.php +++ b/framework/core/src/Api/Actions/Discussions/ShowAction.php @@ -51,7 +51,7 @@ class ShowAction extends SerializeResourceAction * * @var array */ - public static $link = ['posts']; + public static $link = ['posts', 'posts.discussion']; /** * The fields that are available to be sorted by. diff --git a/framework/core/src/Api/Serializers/BaseSerializer.php b/framework/core/src/Api/Serializers/BaseSerializer.php index 6fe6a1487..b62a256f9 100644 --- a/framework/core/src/Api/Serializers/BaseSerializer.php +++ b/framework/core/src/Api/Serializers/BaseSerializer.php @@ -49,12 +49,12 @@ abstract class BaseSerializer extends SerializerAbstract $relation = $caller['function']; } - return function ($model, $include, $links) use ($serializer, $many, $relation) { + return function ($model, $include, $included, $links) use ($serializer, $many, $relation) { if ($relation instanceof Closure) { $data = $relation($model, $include); } else { if ($include) { - $data = !is_null($model->$relation) ? $model->$relation : ($many ? $model->$relation()->get() : $model->$relation()->first()); + $data = !is_null($model->$relation) ? $model->$relation : $model->$relation()->getResults(); } elseif ($many) { $relationIds = $relation.'_ids'; $data = $model->$relationIds ?: $model->$relation()->get(['id'])->fetch('id')->all(); @@ -67,7 +67,7 @@ abstract class BaseSerializer extends SerializerAbstract if ($serializer instanceof Closure) { $serializer = $serializer($model, $data); } - $serializer = new $serializer($this->actor, $links); + $serializer = new $serializer($this->actor, $included, $links); return $many ? $serializer->collection($data) : $serializer->resource($data); }; } diff --git a/framework/core/src/Api/Serializers/DiscussionSerializer.php b/framework/core/src/Api/Serializers/DiscussionSerializer.php index d5c0205b7..e5f56edaf 100644 --- a/framework/core/src/Api/Serializers/DiscussionSerializer.php +++ b/framework/core/src/Api/Serializers/DiscussionSerializer.php @@ -2,13 +2,6 @@ class DiscussionSerializer extends DiscussionBasicSerializer { - /** - * Default relations to include. - * - * @var array - */ - protected $include = ['startUser', 'lastUser']; - /** * Serialize attributes of a Discussion model for JSON output. * diff --git a/framework/core/src/Api/Serializers/PostBasicSerializer.php b/framework/core/src/Api/Serializers/PostBasicSerializer.php index 5d8cc6aa8..92370541e 100644 --- a/framework/core/src/Api/Serializers/PostBasicSerializer.php +++ b/framework/core/src/Api/Serializers/PostBasicSerializer.php @@ -9,20 +9,6 @@ class PostBasicSerializer extends BaseSerializer */ protected $type = 'posts'; - /** - * Default relations to link. - * - * @var array - */ - protected $link = ['discussion']; - - /** - * Default relations to include. - * - * @var array - */ - protected $include = ['user']; - /** * Serialize attributes of a Post model for JSON output. * diff --git a/framework/core/src/Api/Serializers/PostSerializer.php b/framework/core/src/Api/Serializers/PostSerializer.php index a85938149..491622ddc 100644 --- a/framework/core/src/Api/Serializers/PostSerializer.php +++ b/framework/core/src/Api/Serializers/PostSerializer.php @@ -2,13 +2,6 @@ class PostSerializer extends PostBasicSerializer { - /** - * Default relations to include. - * - * @var array - */ - protected $include = ['user', 'editUser', 'hideUser']; - /** * Serialize attributes of a Post model for JSON output. * diff --git a/framework/core/src/Api/Serializers/UserSerializer.php b/framework/core/src/Api/Serializers/UserSerializer.php index 95df80b96..2b6b912e5 100644 --- a/framework/core/src/Api/Serializers/UserSerializer.php +++ b/framework/core/src/Api/Serializers/UserSerializer.php @@ -2,13 +2,6 @@ class UserSerializer extends UserBasicSerializer { - /** - * Default relations to include. - * - * @var array - */ - protected $include = ['groups']; - /** * Serialize attributes of a User model for JSON output. * From 3eed9a99b6b89ba1beab4477820644a611698aa0 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 12:25:40 +0930 Subject: [PATCH 35/51] Extract current user attributes into a separate serializer This prevents the unread notifications count query being run for every post by the currently authenticated user --- .../core/src/Api/Actions/Users/ShowAction.php | 2 +- .../Api/Serializers/CurrentUserSerializer.php | 21 +++++++++++++++++++ .../src/Api/Serializers/UserSerializer.php | 14 +++---------- 3 files changed, 25 insertions(+), 12 deletions(-) create mode 100644 framework/core/src/Api/Serializers/CurrentUserSerializer.php diff --git a/framework/core/src/Api/Actions/Users/ShowAction.php b/framework/core/src/Api/Actions/Users/ShowAction.php index 4350c5f6d..768016cd3 100644 --- a/framework/core/src/Api/Actions/Users/ShowAction.php +++ b/framework/core/src/Api/Actions/Users/ShowAction.php @@ -17,7 +17,7 @@ class ShowAction extends SerializeResourceAction * * @var string */ - public static $serializer = 'Flarum\Api\Serializers\UserSerializer'; + public static $serializer = 'Flarum\Api\Serializers\CurrentUserSerializer'; /** * The relationships that are available to be included, and which ones are diff --git a/framework/core/src/Api/Serializers/CurrentUserSerializer.php b/framework/core/src/Api/Serializers/CurrentUserSerializer.php new file mode 100644 index 000000000..a34d24be4 --- /dev/null +++ b/framework/core/src/Api/Serializers/CurrentUserSerializer.php @@ -0,0 +1,21 @@ +actor->getUser(); + + if ($user->id === $actingUser->id) { + $attributes += [ + 'readTime' => $user->read_time ? $user->read_time->toRFC3339String() : null, + 'unreadNotificationsCount' => $user->getUnreadNotificationsCount(), + 'preferences' => $user->preferences + ]; + } + + return $this->extendAttributes($user, $attributes); + } +} diff --git a/framework/core/src/Api/Serializers/UserSerializer.php b/framework/core/src/Api/Serializers/UserSerializer.php index 2b6b912e5..b4c1bfa3a 100644 --- a/framework/core/src/Api/Serializers/UserSerializer.php +++ b/framework/core/src/Api/Serializers/UserSerializer.php @@ -12,8 +12,8 @@ class UserSerializer extends UserBasicSerializer { $attributes = parent::attributes($user); - $actorUser = $this->actor->getUser(); - $canEdit = $user->can($actorUser, 'edit'); + $actingUser = $this->actor->getUser(); + $canEdit = $user->can($actingUser, 'edit'); $attributes += [ 'bioHtml' => $user->bio_html, @@ -21,7 +21,7 @@ class UserSerializer extends UserBasicSerializer 'discussionsCount' => (int) $user->discussions_count, 'commentsCount' => (int) $user->comments_count, 'canEdit' => $canEdit, - 'canDelete' => $user->can($actorUser, 'delete'), + 'canDelete' => $user->can($actingUser, 'delete'), ]; if ($user->preference('discloseOnline')) { @@ -39,14 +39,6 @@ class UserSerializer extends UserBasicSerializer ]; } - if ($user->id === $actorUser->id) { - $attributes += [ - 'readTime' => $user->read_time ? $user->read_time->toRFC3339String() : null, - 'unreadNotificationsCount' => $user->getUnreadNotificationsCount(), - 'preferences' => $user->preferences - ]; - } - return $this->extendAttributes($user, $attributes); } } From 761b76539dee873ed3db0ee09c1df1cd0c87be7c Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 12:26:11 +0930 Subject: [PATCH 36/51] Use pre-loaded state if applicable. closes flarum/core#89 --- framework/core/src/Core/Models/Discussion.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/framework/core/src/Core/Models/Discussion.php b/framework/core/src/Core/Models/Discussion.php index 1dd4f0523..eb3c72116 100755 --- a/framework/core/src/Core/Models/Discussion.php +++ b/framework/core/src/Core/Models/Discussion.php @@ -295,6 +295,11 @@ class Discussion extends Model */ public function stateFor(User $user) { + $loadedState = array_get($this->relations, 'state'); + if ($loadedState && $loadedState->user_id === $user->id) { + return $loadedState; + } + $state = $this->state($user)->first(); if (! $state) { From ccf63a65e9f354505586d6c827b30eb93a4ea9c0 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 12:26:44 +0930 Subject: [PATCH 37/51] Only validate dirty attributes To prevent unique-checking queries on every update --- framework/core/src/Core/Models/Model.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/framework/core/src/Core/Models/Model.php b/framework/core/src/Core/Models/Model.php index 18580ba69..b5f20452b 100755 --- a/framework/core/src/Core/Models/Model.php +++ b/framework/core/src/Core/Models/Model.php @@ -130,9 +130,11 @@ class Model extends Eloquent */ protected function makeValidator() { - $rules = $this->expandUniqueRules(static::$rules); + $dirty = $this->getDirty(); - return static::$validator->make($this->attributes, $rules); + $rules = $this->expandUniqueRules(array_only(static::$rules, array_keys($dirty))); + + return static::$validator->make($dirty, $rules); } /** From 2900cac4566d277ae31b855d981785cdd8f4a0f7 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 17:54:37 +0930 Subject: [PATCH 38/51] Tweak reply placeholder appearance --- .../forum/src/components/reply-placeholder.js | 2 +- framework/core/less/forum/discussion.less | 22 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/framework/core/js/forum/src/components/reply-placeholder.js b/framework/core/js/forum/src/components/reply-placeholder.js index 5b569a7ac..801dab4c1 100644 --- a/framework/core/js/forum/src/components/reply-placeholder.js +++ b/framework/core/js/forum/src/components/reply-placeholder.js @@ -3,7 +3,7 @@ import avatar from 'flarum/helpers/avatar'; export default class ReplyPlaceholder extends Component { view() { - return m('article.post.reply-post', {onclick: () => this.props.discussion.replyAction(true)}, [ + return m('article.post.reply-post', {onmousedown: () => this.props.discussion.replyAction(true)}, [ m('header.post-header', avatar(app.session.user()), 'Write a Reply...'), ]); } diff --git a/framework/core/less/forum/discussion.less b/framework/core/less/forum/discussion.less index f1b744abd..0eec3c57d 100644 --- a/framework/core/less/forum/discussion.less +++ b/framework/core/less/forum/discussion.less @@ -463,17 +463,23 @@ font-size: 15px; cursor: text; overflow: hidden; - margin: -15px; - padding: 15px 15px 15px 90px + 15px; + margin: 50px -20px 0; + border: 2px dashed @fl-body-secondary-color; + color: @fl-body-muted-color; + border-radius: 10px; + padding: 20px 20px 20px 110px; + transition: color 0.2s, border-color 0.2s; - & .avatar { - opacity: 0.25; + & .post-header { + padding-top: 18px; + color: inherit; + } + & .avatar { + margin-top: -18px; } - &:hover { - border: 1px dashed @fl-body-secondary-color; - border-radius: 64px; - margin: -16px; + color: @fl-secondary-color; + border-color: @fl-body-muted-color; } } From 4a1020dfab39167583cf4710e7aca2942c65c4e6 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 17:55:05 +0930 Subject: [PATCH 39/51] Use icon instead in composer title when replying to another thread --- framework/core/js/forum/src/components/reply-composer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/framework/core/js/forum/src/components/reply-composer.js b/framework/core/js/forum/src/components/reply-composer.js index 3fdde161f..195c9c2a5 100644 --- a/framework/core/js/forum/src/components/reply-composer.js +++ b/framework/core/js/forum/src/components/reply-composer.js @@ -3,6 +3,7 @@ import ComposerBody from 'flarum/components/composer-body'; import Alert from 'flarum/components/alert'; import ActionButton from 'flarum/components/action-button'; import Composer from 'flarum/components/composer'; +import icon from 'flarum/helpers/icon'; export default class ReplyComposer extends ComposerBody { constructor(props) { @@ -25,7 +26,7 @@ export default class ReplyComposer extends ComposerBody { !app.current.discussion || app.current.discussion() !== this.props.discussion) { items.add('title', m('h3', [ - 'Replying to ', + icon('reply'), ' ', m('a', {href: app.route.discussion(this.props.discussion), config: m.route}, this.props.discussion.title()) ])); } From c70122c449e35f7836fd5b636b868a8835682102 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 17:55:13 +0930 Subject: [PATCH 40/51] Make user activity posts more compact --- framework/core/less/forum/user.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/core/less/forum/user.less b/framework/core/less/forum/user.less index 374028467..011ef9313 100644 --- a/framework/core/less/forum/user.less +++ b/framework/core/less/forum/user.less @@ -166,7 +166,7 @@ overflow: hidden; & .title { - margin: 0 0 10px; + margin: 0 0 5px; font-size: 14px; font-weight: bold; From 1b57eb3c9bdaf89a92143989ba982173a70028cf Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 17:55:41 +0930 Subject: [PATCH 41/51] Fix error on account registration --- .../core/src/Core/Handlers/Events/EmailConfirmationMailer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework/core/src/Core/Handlers/Events/EmailConfirmationMailer.php b/framework/core/src/Core/Handlers/Events/EmailConfirmationMailer.php index 40ad7912a..047540672 100755 --- a/framework/core/src/Core/Handlers/Events/EmailConfirmationMailer.php +++ b/framework/core/src/Core/Handlers/Events/EmailConfirmationMailer.php @@ -32,8 +32,8 @@ class EmailConfirmationMailer $user = $event->user; $data = $this->getPayload($user, $user->email); - $this->mailer->send(['text' => 'flarum::emails.activateAccount'], $data, function ($message) use ($email) { - $message->to($email); + $this->mailer->send(['text' => 'flarum::emails.activateAccount'], $data, function ($message) use ($user) { + $message->to($user->email); $message->subject('Activate Your New Account'); }); } From 306b79b22a84eacad09d6941e719b3f792bd22a3 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 1 Jun 2015 17:55:52 +0930 Subject: [PATCH 42/51] Password cannot be null --- framework/core/src/Core/Models/User.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/core/src/Core/Models/User.php b/framework/core/src/Core/Models/User.php index fc80c87ab..e29ea5fb5 100755 --- a/framework/core/src/Core/Models/User.php +++ b/framework/core/src/Core/Models/User.php @@ -174,7 +174,7 @@ class User extends Model */ public function setPasswordAttribute($value) { - $this->attributes['password'] = $value ? static::$hasher->make($value) : null; + $this->attributes['password'] = $value ? static::$hasher->make($value) : ''; } /** From c42627b46d919f2a3be17dc8ec2a2214fcb70fe9 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 2 Jun 2015 11:36:25 +0930 Subject: [PATCH 43/51] Add HTMLPurifier after formatters are run. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a morning of searching, it seems there is no PHP Markdown library that has built-in XSS/sanitization support. The recommended solution is to use HTMLPurifier. This actually works out OK, though, as it’s probably a good idea to enforce sanitization regardless of which formatters are enabled, and to not leave them with the responsibility of sanitization (it’s a big responsibility). Since we cache rendered posts, the slow speed of HTMLPurifier isn’t a concern. Note that HTMLPurifier requires a file to be loaded by Composer, but Studio does not yet support this, so for now I have included it manually. --- framework/core/composer.json | 3 +- framework/core/composer.lock | 169 +++++++++++------- .../src/Core/Formatter/FormatterManager.php | 17 +- 3 files changed, 122 insertions(+), 67 deletions(-) diff --git a/framework/core/composer.json b/framework/core/composer.json index e0be0e09b..760670f51 100644 --- a/framework/core/composer.json +++ b/framework/core/composer.json @@ -14,7 +14,8 @@ "tobscure/permissible": "dev-master", "misd/linkify": "1.1.*", "oyejorge/less.php": "dev-master", - "intervention/image": "dev-master" + "intervention/image": "dev-master", + "ezyang/htmlpurifier": "dev-master" }, "require-dev": { "fzaninotto/faker": "1.4.0", diff --git a/framework/core/composer.lock b/framework/core/composer.lock index f038d0954..80f279ff9 100644 --- a/framework/core/composer.lock +++ b/framework/core/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "2c72cfaf1f4ffecca91460d1e6f57f5d", + "hash": "7699f0af1ac08584c2a53fa6595c5d6e", "packages": [ { "name": "danielstjules/stringy", @@ -129,18 +129,62 @@ ], "time": "2015-01-01 18:34:57" }, + { + "name": "ezyang/htmlpurifier", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "0d7328dbb282875f995026ba9f9a732bf0d6c669" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/0d7328dbb282875f995026ba9f9a732bf0d6c669", + "reference": "0d7328dbb282875f995026ba9f9a732bf0d6c669", + "shasum": "" + }, + "require": { + "php": ">=5.2" + }, + "type": "library", + "autoload": { + "psr-0": { + "HTMLPurifier": "library/" + }, + "files": [ + "library/HTMLPurifier.composer.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "time": "2015-05-05 20:43:49" + }, { "name": "illuminate/container", "version": "5.0.x-dev", "source": { "type": "git", "url": "https://github.com/illuminate/container.git", - "reference": "55b81cfeb20745e74957d7ade2773e2bc2510bef" + "reference": "c5a78e53ef15204469b5b072d390af9785a82d32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/container/zipball/55b81cfeb20745e74957d7ade2773e2bc2510bef", - "reference": "55b81cfeb20745e74957d7ade2773e2bc2510bef", + "url": "https://api.github.com/repos/illuminate/container/zipball/c5a78e53ef15204469b5b072d390af9785a82d32", + "reference": "c5a78e53ef15204469b5b072d390af9785a82d32", "shasum": "" }, "require": { @@ -170,7 +214,7 @@ ], "description": "The Illuminate Container package.", "homepage": "http://laravel.com", - "time": "2015-03-25 17:06:14" + "time": "2015-05-29 20:16:27" }, { "name": "illuminate/contracts", @@ -220,12 +264,12 @@ "source": { "type": "git", "url": "https://github.com/illuminate/database.git", - "reference": "79ebeb4c169178a24c5eb7f17db94df01c7dd04d" + "reference": "923acfe1bba40aebec8a7324e17f3e2d48c4e91d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/database/zipball/79ebeb4c169178a24c5eb7f17db94df01c7dd04d", - "reference": "79ebeb4c169178a24c5eb7f17db94df01c7dd04d", + "url": "https://api.github.com/repos/illuminate/database/zipball/923acfe1bba40aebec8a7324e17f3e2d48c4e91d", + "reference": "923acfe1bba40aebec8a7324e17f3e2d48c4e91d", "shasum": "" }, "require": { @@ -270,7 +314,7 @@ "orm", "sql" ], - "time": "2015-05-14 14:12:37" + "time": "2015-05-27 15:02:58" }, { "name": "illuminate/support", @@ -485,12 +529,12 @@ "source": { "type": "git", "url": "https://github.com/oyejorge/less.php.git", - "reference": "b7f01fb8e86f8d77e0f5367715ec756418232e19" + "reference": "fc971e6d3eb54dff3d3eba4734ff207d37cb4e0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/oyejorge/less.php/zipball/b7f01fb8e86f8d77e0f5367715ec756418232e19", - "reference": "b7f01fb8e86f8d77e0f5367715ec756418232e19", + "url": "https://api.github.com/repos/oyejorge/less.php/zipball/fc971e6d3eb54dff3d3eba4734ff207d37cb4e0e", + "reference": "fc971e6d3eb54dff3d3eba4734ff207d37cb4e0e", "shasum": "" }, "require": { @@ -536,7 +580,7 @@ "php", "stylesheet" ], - "time": "2015-05-16 18:38:34" + "time": "2015-05-27 17:50:32" }, { "name": "symfony/translation", @@ -544,12 +588,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/Translation.git", - "reference": "a0735db452c5e592cb742333a32c6634a6d1ece1" + "reference": "ae980a18f73b88b3394510e07ed0f343f252ca4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/Translation/zipball/a0735db452c5e592cb742333a32c6634a6d1ece1", - "reference": "a0735db452c5e592cb742333a32c6634a6d1ece1", + "url": "https://api.github.com/repos/symfony/Translation/zipball/ae980a18f73b88b3394510e07ed0f343f252ca4f", + "reference": "ae980a18f73b88b3394510e07ed0f343f252ca4f", "shasum": "" }, "require": { @@ -597,7 +641,7 @@ ], "description": "Symfony Translation Component", "homepage": "https://symfony.com", - "time": "2015-05-15 14:11:12" + "time": "2015-05-20 09:35:10" }, { "name": "tobscure/json-api", @@ -605,12 +649,12 @@ "source": { "type": "git", "url": "https://github.com/tobscure/json-api.git", - "reference": "ec101f2b95cb3ef40489b778b01beb76c3a5c13f" + "reference": "d6c82a496289569e8907f3aa980ace407a35b45e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tobscure/json-api/zipball/31a74d27fd9ab6a9a9bc911614ceb696504d2c39", - "reference": "ec101f2b95cb3ef40489b778b01beb76c3a5c13f", + "url": "https://api.github.com/repos/tobscure/json-api/zipball/d6c82a496289569e8907f3aa980ace407a35b45e", + "reference": "d6c82a496289569e8907f3aa980ace407a35b45e", "shasum": "" }, "require": { @@ -633,7 +677,7 @@ } ], "description": "JSON-API responses in PHP.", - "time": "2015-05-07 07:23:04" + "time": "2015-06-01 08:23:11" }, { "name": "tobscure/permissible", @@ -641,12 +685,12 @@ "source": { "type": "git", "url": "https://github.com/tobscure/permissible.git", - "reference": "ac146ee44be5b2c4b99ad065e2cdcd51de1f7860" + "reference": "0ba23dd1ed6f5372bf86fa917450cb70d08c012b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tobscure/permissible/zipball/ac146ee44be5b2c4b99ad065e2cdcd51de1f7860", - "reference": "ac146ee44be5b2c4b99ad065e2cdcd51de1f7860", + "url": "https://api.github.com/repos/tobscure/permissible/zipball/0ba23dd1ed6f5372bf86fa917450cb70d08c012b", + "reference": "0ba23dd1ed6f5372bf86fa917450cb70d08c012b", "shasum": "" }, "require": { @@ -670,7 +714,7 @@ } ], "description": "Powerful, flexible, relational permissions using Eloquent.", - "time": "2015-03-24 09:00:05" + "time": "2015-05-29 05:01:56" } ], "packages-dev": [ @@ -680,12 +724,12 @@ "source": { "type": "git", "url": "https://github.com/Codeception/Codeception.git", - "reference": "d3cf78c6053f3fdfa4025bfcdb713f91e3ccdbdf" + "reference": "3999c5151932c987df9e60fb3736df163259af02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/Codeception/zipball/325a6e747a3089a5ac51bb757ab6f195627a11b0", - "reference": "d3cf78c6053f3fdfa4025bfcdb713f91e3ccdbdf", + "url": "https://api.github.com/repos/Codeception/Codeception/zipball/3999c5151932c987df9e60fb3736df163259af02", + "reference": "3999c5151932c987df9e60fb3736df163259af02", "shasum": "" }, "require": { @@ -752,7 +796,7 @@ "functional testing", "unit testing" ], - "time": "2015-05-16 22:10:29" + "time": "2015-06-01 18:13:03" }, { "name": "codeception/mockery-module", @@ -935,33 +979,27 @@ }, { "name": "guzzlehttp/guzzle", - "version": "5.2.0", + "version": "5.3.x-dev", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "475b29ccd411f2fa8a408e64576418728c032cfa" + "reference": "28475a313d7d413a033b68d762e0db18b3aa4b02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/475b29ccd411f2fa8a408e64576418728c032cfa", - "reference": "475b29ccd411f2fa8a408e64576418728c032cfa", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/28475a313d7d413a033b68d762e0db18b3aa4b02", + "reference": "28475a313d7d413a033b68d762e0db18b3aa4b02", "shasum": "" }, "require": { - "guzzlehttp/ringphp": "~1.0", + "guzzlehttp/ringphp": "^1.1", "php": ">=5.4.0" }, "require-dev": { "ext-curl": "*", - "phpunit/phpunit": "~4.0", - "psr/log": "~1.0" + "phpunit/phpunit": "^4.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0-dev" - } - }, "autoload": { "psr-4": { "GuzzleHttp\\": "src/" @@ -989,7 +1027,7 @@ "rest", "web service" ], - "time": "2015-01-28 01:03:29" + "time": "2015-05-26 17:54:26" }, { "name": "guzzlehttp/ringphp", @@ -997,12 +1035,12 @@ "source": { "type": "git", "url": "https://github.com/guzzle/RingPHP.git", - "reference": "2498ee848cd01639aecdcf3d5a257bace8665b7c" + "reference": "9465032ac5d6beaa55f10923403e6e1c36018d9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/RingPHP/zipball/2498ee848cd01639aecdcf3d5a257bace8665b7c", - "reference": "2498ee848cd01639aecdcf3d5a257bace8665b7c", + "url": "https://api.github.com/repos/guzzle/RingPHP/zipball/9465032ac5d6beaa55f10923403e6e1c36018d9c", + "reference": "9465032ac5d6beaa55f10923403e6e1c36018d9c", "shasum": "" }, "require": { @@ -1020,7 +1058,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "1.1-dev" } }, "autoload": { @@ -1040,7 +1078,7 @@ } ], "description": "Provides a simple API and specification that abstracts away the details of HTTP into a single PHP function.", - "time": "2015-05-01 04:57:09" + "time": "2015-05-21 17:23:02" }, { "name": "guzzlehttp/streams", @@ -1309,12 +1347,12 @@ "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "3132b1f44c7bf2ec4c7eb2d3cb78fdeca760d373" + "reference": "5a355f91730c845301a9e28f91c8a5053353c496" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/3132b1f44c7bf2ec4c7eb2d3cb78fdeca760d373", - "reference": "3132b1f44c7bf2ec4c7eb2d3cb78fdeca760d373", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/5a355f91730c845301a9e28f91c8a5053353c496", + "reference": "5a355f91730c845301a9e28f91c8a5053353c496", "shasum": "" }, "require": { @@ -1361,20 +1399,20 @@ "spy", "stub" ], - "time": "2015-04-27 22:15:08" + "time": "2015-05-20 16:00:43" }, { "name": "phpunit/php-code-coverage", - "version": "dev-master", + "version": "2.1.x-dev", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "9ef4b8cbf3e839a44a9b375d8c59e109ac7aa020" + "reference": "6b7d2094ca2a685a2cad846cb7cd7a30e8b9470f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/9ef4b8cbf3e839a44a9b375d8c59e109ac7aa020", - "reference": "9ef4b8cbf3e839a44a9b375d8c59e109ac7aa020", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/6b7d2094ca2a685a2cad846cb7cd7a30e8b9470f", + "reference": "6b7d2094ca2a685a2cad846cb7cd7a30e8b9470f", "shasum": "" }, "require": { @@ -1423,7 +1461,7 @@ "testing", "xunit" ], - "time": "2015-05-09 04:40:58" + "time": "2015-06-01 07:35:26" }, { "name": "phpunit/php-file-iterator", @@ -1615,12 +1653,12 @@ "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "3afe303d873a4d64c62ef84de491b97b006fbdac" + "reference": "816d12536a7a032adc3b68737f82cfbbf98b79c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3afe303d873a4d64c62ef84de491b97b006fbdac", - "reference": "3afe303d873a4d64c62ef84de491b97b006fbdac", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/816d12536a7a032adc3b68737f82cfbbf98b79c1", + "reference": "816d12536a7a032adc3b68737f82cfbbf98b79c1", "shasum": "" }, "require": { @@ -1679,7 +1717,7 @@ "testing", "xunit" ], - "time": "2015-04-29 15:18:52" + "time": "2015-05-29 06:00:03" }, { "name": "phpunit/phpunit-mock-objects", @@ -1687,12 +1725,12 @@ "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "74ffb87f527f24616f72460e54b595f508dccb5c" + "reference": "253c005852591fd547fc18cd5b7b43a1ec82d8f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/74ffb87f527f24616f72460e54b595f508dccb5c", - "reference": "74ffb87f527f24616f72460e54b595f508dccb5c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/253c005852591fd547fc18cd5b7b43a1ec82d8f7", + "reference": "253c005852591fd547fc18cd5b7b43a1ec82d8f7", "shasum": "" }, "require": { @@ -1734,7 +1772,7 @@ "mock", "xunit" ], - "time": "2015-04-02 05:36:41" + "time": "2015-05-29 05:19:18" }, { "name": "react/promise", @@ -2606,7 +2644,8 @@ "tobscure/json-api": 20, "tobscure/permissible": 20, "oyejorge/less.php": 20, - "intervention/image": 20 + "intervention/image": 20, + "ezyang/htmlpurifier": 20 }, "prefer-stable": false, "prefer-lowest": false, diff --git a/framework/core/src/Core/Formatter/FormatterManager.php b/framework/core/src/Core/Formatter/FormatterManager.php index 0c6c55fc0..6ea88cf7c 100644 --- a/framework/core/src/Core/Formatter/FormatterManager.php +++ b/framework/core/src/Core/Formatter/FormatterManager.php @@ -1,6 +1,8 @@ container->make($formatter)->format($text, $post); } - return $text; + // Studio does not yet merge autoload_files... + // https://github.com/franzliedke/studio/commit/4f0f4314db4ed3e36c869a5f79b855c97bdd1be7 + require __DIR__.'/../../../vendor/ezyang/htmlpurifier/library/HTMLPurifier.composer.php'; + + $config = HTMLPurifier_Config::createDefault(); + $config->set('Core.Encoding', 'UTF-8'); + $config->set('Core.EscapeInvalidTags', true); + $config->set('HTML.Doctype', 'HTML 4.01 Strict'); + $config->set('HTML.Allowed', 'p,em,strong,a[href|title],ul,ol,li,code,pre,blockquote,h1,h2,h3,h4,h5,h6,br'); + $config->set('HTML.Nofollow', true); + + $purifier = new HTMLPurifier($config); + + return $purifier->purify($text); } public function strip($text) From f1a7e8c1153fffad119386f3d9c4db1a8906f7a8 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 3 Jun 2015 18:06:39 +0930 Subject: [PATCH 44/51] Fix composer only sliding down some of the way --- framework/core/js/forum/src/components/composer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/core/js/forum/src/components/composer.js b/framework/core/js/forum/src/components/composer.js index d9c6b4519..5534ba263 100644 --- a/framework/core/js/forum/src/components/composer.js +++ b/framework/core/js/forum/src/components/composer.js @@ -17,7 +17,7 @@ class Composer extends Component { // (which is set when the resizing handle is dragged), and the composer's // current state. this.computedHeight = computed('height', 'position', function(height, position) { - if (position === Composer.PositionEnum.MINIMIZED || position === Composer.PositionEnum.HIDDEN) { + if (position === Composer.PositionEnum.MINIMIZED) { return ''; } else if (position === Composer.PositionEnum.FULLSCREEN) { return $(window).height(); From 920ad4f04f380df2daf5ed259a901506777dbbda Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 3 Jun 2015 18:10:56 +0930 Subject: [PATCH 45/51] Implement search on front end --- .../forum/src/components/discussion-list.js | 32 +- .../components/discussions-search-results.js | 36 ++ .../forum/src/components/header-secondary.js | 2 + .../js/forum/src/components/index-page.js | 378 ++++++++++++------ .../js/forum/src/components/post-preview.js | 3 +- .../js/forum/src/components/search-box.js | 222 ++++++++++ .../src/components/users-search-results.js | 22 + .../core/js/forum/src/initializers/boot.js | 5 +- framework/core/js/lib/helpers/highlight.js | 13 + framework/core/less/forum/app.less | 1 + framework/core/less/forum/index.less | 35 +- framework/core/less/lib/components.less | 8 + framework/core/less/lib/dropdowns.less | 16 +- framework/core/less/lib/forms.less | 44 +- framework/core/less/lib/layout.less | 8 +- framework/core/less/lib/search.less | 82 ++++ .../Api/Actions/Discussions/IndexAction.php | 4 +- .../Search/Discussions/DiscussionSearcher.php | 8 +- 18 files changed, 717 insertions(+), 202 deletions(-) create mode 100644 framework/core/js/forum/src/components/discussions-search-results.js create mode 100644 framework/core/js/forum/src/components/search-box.js create mode 100644 framework/core/js/forum/src/components/users-search-results.js create mode 100644 framework/core/js/lib/helpers/highlight.js create mode 100644 framework/core/less/lib/search.less diff --git a/framework/core/js/forum/src/components/discussion-list.js b/framework/core/js/forum/src/components/discussion-list.js index 3b8ab3bdb..3b091e20e 100644 --- a/framework/core/js/forum/src/components/discussion-list.js +++ b/framework/core/js/forum/src/components/discussion-list.js @@ -1,6 +1,7 @@ import Component from 'flarum/component'; import avatar from 'flarum/helpers/avatar'; import listItems from 'flarum/helpers/list-items'; +import highlight from 'flarum/helpers/highlight'; import humanTime from 'flarum/utils/human-time'; import ItemList from 'flarum/utils/item-list'; import abbreviateNumber from 'flarum/utils/abbreviate-number'; @@ -8,6 +9,7 @@ import ActionButton from 'flarum/components/action-button'; import DropdownButton from 'flarum/components/dropdown-button'; import LoadingIndicator from 'flarum/components/loading-indicator'; import TerminalPost from 'flarum/components/terminal-post'; +import PostPreview from 'flarum/components/post-preview'; import SubtreeRetainer from 'flarum/utils/subtree-retainer'; export default class DiscussionList extends Component { @@ -30,16 +32,26 @@ export default class DiscussionList extends Component { params[i] = this.props.params[i]; } params.sort = this.sortMap()[params.sort]; + if (params.q) { + params.include.push('relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user'); + } return params; } + willBeRedrawn() { + this.subtrees.map(subtree => subtree.invalidate()); + } + sortMap() { - return { - recent: '-lastTime', - replies: '-commentsCount', - newest: '-startTime', - oldest: '+startTime' - }; + var map = {}; + if (this.props.params.q) { + map.relevance = ''; + } + map.recent = '-lastTime'; + map.replies = '-commentsCount'; + map.newest = '-startTime'; + map.oldest = '+startTime'; + return map; } refresh() { @@ -124,6 +136,7 @@ export default class DiscussionList extends Component { var isUnread = discussion.isUnread(); var displayUnread = this.countType() !== 'replies' && isUnread; var jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1); + var relevantPosts = this.props.params.q ? discussion.relevantPosts() : ''; var controls = discussion.controls(this).toArray(); @@ -152,13 +165,16 @@ export default class DiscussionList extends Component { ]), m('ul.badges', listItems(discussion.badges().toArray())), m('a.main', {href: app.route('discussion.near', {id: discussion.id(), slug: discussion.slug(), near: jumpTo}), config: m.route}, [ - m('h3.title', discussion.title()), + m('h3.title', highlight(discussion.title(), this.props.params.q)), m('ul.info', listItems(this.infoItems(discussion).toArray())) ]), m('span.count', {onclick: this.markAsRead.bind(this, discussion)}, [ abbreviateNumber(discussion[displayUnread ? 'unreadCount' : 'repliesCount']()), m('span.label', displayUnread ? 'unread' : 'replies') - ]) + ]), + (relevantPosts && relevantPosts.length) + ? m('div.relevant-posts', relevantPosts.map(post => PostPreview.component({post, highlight: this.props.params.q}))) + : '' ])) }) ]), diff --git a/framework/core/js/forum/src/components/discussions-search-results.js b/framework/core/js/forum/src/components/discussions-search-results.js new file mode 100644 index 000000000..f7009fe81 --- /dev/null +++ b/framework/core/js/forum/src/components/discussions-search-results.js @@ -0,0 +1,36 @@ +import highlight from 'flarum/helpers/highlight'; +import ActionButton from 'flarum/components/action-button'; + +export default class DiscussionsSearchResults { + constructor() { + this.results = {}; + } + + search(string) { + this.results[string] = []; + return app.store.find('discussions', {q: string, page: {limit: 3}, include: 'relevantPosts,relevantPosts.discussion'}).then(results => { + this.results[string] = results; + }); + } + + view(string) { + return [ + m('li.dropdown-header', 'Discussions'), + m('li', ActionButton.component({ + icon: 'search', + label: 'Search all discussions for "'+string+'"', + href: app.route('index', {q: string}), + config: m.route + })), + (this.results[string] && this.results[string].length) ? this.results[string].map(discussion => { + var post = discussion.relevantPosts()[0]; + return m('li.discussion-search-result', {'data-index': 'discussions'+discussion.id()}, + m('a', { href: app.route.discussion(discussion, post.number()), config: m.route }, + m('div.title', highlight(discussion.title(), string)), + m('div.excerpt', highlight(post.excerpt(), string)) + ) + ); + }) : '' + ]; + } +} diff --git a/framework/core/js/forum/src/components/header-secondary.js b/framework/core/js/forum/src/components/header-secondary.js index 900002e56..f64a05061 100644 --- a/framework/core/js/forum/src/components/header-secondary.js +++ b/framework/core/js/forum/src/components/header-secondary.js @@ -16,6 +16,8 @@ export default class HeaderSecondary extends Component { items() { var items = new ItemList(); + items.add('search', app.search.view()); + if (app.session.user()) { items.add('notifications', UserNotifications.component({ user: app.session.user() })) items.add('user', UserDropdown.component({ user: app.session.user() })); diff --git a/framework/core/js/forum/src/components/index-page.js b/framework/core/js/forum/src/components/index-page.js index 4b0f2838d..fbb82b025 100644 --- a/framework/core/js/forum/src/components/index-page.js +++ b/framework/core/js/forum/src/components/index-page.js @@ -17,12 +17,32 @@ import LoadingIndicator from 'flarum/components/loading-indicator'; import DropdownSelect from 'flarum/components/dropdown-select'; export default class IndexPage extends Component { + /** + * @param {Object} props + */ constructor(props) { super(props); + // 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(); + } + var params = this.params(); + if (app.cache.discussionList) { - app.cache.discussionList.subtrees.map(subtree => subtree.invalidate()); + // The discussion list component is stored in the app's cache so that it + // can persist across interfaces. Since we will soon be redrawing the + // discussion list from scratch, we need to invalidate the component's + // subtree cache to ensure that it re-constructs the view. + app.cache.discussionList.willBeRedrawn(); + + // Compare the requested parameters (sort, search query) to the ones that + // are currently present in the cached discussion list. If they differ, we + // will clear the cache and set up a new discussion list component with + // the new parameters. Object.keys(params).some(key => { if (app.cache.discussionList.props.params[key] !== params[key]) { app.cache.discussionList = null; @@ -30,151 +50,53 @@ export default class IndexPage extends Component { } }); } - if (!app.cache.discussionList) { - app.cache.discussionList = new DiscussionList({params}); - } - if (app.current instanceof DiscussionPage) { - this.lastDiscussion = app.current.discussion(); + if (!app.cache.discussionList) { + app.cache.discussionList = new DiscussionList({ params }); } app.history.push('index'); app.current = this; } - onunload() { - app.cache.scrollTop = $(window).scrollTop(); - app.composer.minimize(); - } - /** - Params that stick between filter changes - */ - stickyParams() { - return { - sort: m.route.param('sort'), - show: m.route.param('show'), - q: m.route.param('q') - } - } - - /** - Params which are passed to the DiscussionList - */ - params() { - var params = this.stickyParams(); - params.filter = m.route.param('filter'); - return params; - } - - reorder(sort) { - var params = this.params(); - if (sort === 'recent') { - delete params.sort; - } else { - params.sort = sort; - } - m.route(app.route(this.props.routeName, params)); - } - - /** - Render the component. - - @method view - @return void + * Render the component. + * + * @return {Object} */ view() { - var sortOptions = {}; - for (var i in app.cache.discussionList.sortMap()) { - sortOptions[i] = i.substr(0, 1).toUpperCase()+i.substr(1); - } - return m('div.index-area', {config: this.onload.bind(this)}, [ - WelcomeHero.component(), + 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('div.index-toolbar-view', [ - SelectInput.component({ - options: sortOptions, - value: m.route.param('sort'), - onchange: this.reorder.bind(this) - }), - ]), - m('div.index-toolbar-action', [ - app.session.user() ? ActionButton.component({ - title: 'Mark All as Read', - icon: 'check', - className: 'control-markAllAsRead btn btn-default btn-icon', - onclick: this.markAllAsRead.bind(this) - }) : '' - ]) + m('ul.index-toolbar-view', listItems(this.viewItems().toArray())), + m('ul.index-toolbar-action', listItems(this.actionItems().toArray())) ]), app.cache.discussionList.view() ]) ]) - ]) - } - - onload(element, isInitialized, context) { - if (isInitialized) { return; } - - this.element(element); - - $('body').addClass('index-page'); - context.onunload = function() { - $('body').removeClass('index-page'); - } - - - var heroHeight = this.$('.hero').css('height', '').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 (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); - } - } - } - - app.setTitle(''); - } - - newDiscussion() { - if (app.session.user()) { - app.composer.load(new DiscussionComposer({ user: app.session.user() })); - app.composer.show(); - return true; - } else { - app.modal.show(new LoginModal({ - message: 'You must be logged in to do that.', - callback: this.newDiscussion.bind(this) - })); - } - } - - markAllAsRead() { - app.session.user().save({ readTime: new Date() }); + ]); } /** - 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). + * Get the component to display as the hero. + * + * @return {Object} + */ + hero() { + return WelcomeHero.component(); + } - @return {ItemList} + /** + * 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). + * + * @return {ItemList} */ sidebarItems() { var items = new ItemList(); @@ -200,14 +122,14 @@ export default class IndexPage extends Component { } /** - Build an item list for the navigation in the sidebar of the index page. By - default this is just the 'All Discussions' link. - - @return {ItemList} + * Build an item list for the navigation in the sidebar of the index page. By + * default this is just the 'All Discussions' link. + * + * @return {ItemList} */ navItems() { var items = new ItemList(); - var params = {sort: m.route.param('sort')}; + var params = this.stickyParams(); items.add('allDiscussions', IndexNavItem.component({ @@ -221,12 +143,182 @@ export default class IndexPage extends Component { } /** - Setup the sidebar DOM element to be affixed to the top of the viewport - using Bootstrap's affix plugin. + * Build an item list for the part of the toolbar which is concerned with how + * the results are displayed. By default this is just a select box to change + * the way discussions are sorted. + * + * @return {ItemList} + */ + viewItems() { + var items = new ItemList(); - @param {DOMElement} element - @param {Boolean} isInitialized - @return {void} + var sortOptions = {}; + for (var i in app.cache.discussionList.sortMap()) { + sortOptions[i] = i.substr(0, 1).toUpperCase()+i.substr(1); + } + + items.add('sort', + SelectInput.component({ + options: sortOptions, + value: this.params.sort, + onchange: this.reorder.bind(this) + }) + ); + + return items; + } + + /** + * Build an item list for the part of the toolbar which is about taking action + * on the results. By default this is just a "mark all as read" button. + * + * @return {ItemList} + */ + actionItems() { + var items = new ItemList(); + + if (app.session.user()) { + items.add('markAllAsRead', + ActionButton.component({ + title: 'Mark All as Read', + icon: 'check', + className: 'control-markAllAsRead btn btn-default btn-icon', + onclick: this.markAllAsRead.bind(this) + }) + ); + } + + return items; + } + + /** + * Return the current search query, if any. This is implemented to activate + * the search box in the header. + * + * @see module:flarum/components/search-box + * @return {String} + */ + searching() { + return this.params().q; + } + + /** + * Redirect to the index page without a search filter. This is called when the + * 'x' is clicked in the search box in the header. + * + * @see module:flarum/components/search-box + * @return void + */ + clearSearch() { + var params = this.params(); + delete params.q; + m.route(app.route('index', params)); + } + + /** + * Redirect to + * @param {[type]} sort [description] + * @return {[type]} + */ + reorder(sort) { + var params = this.params(); + if (sort === Object.keys(app.cache.discussionList.sortMap())[0]) { + delete params.sort; + } else { + params.sort = sort; + } + m.route(app.route(this.props.routeName, params)); + } + + /** + * Get URL parameters that stick between filter changes. + * + * @return {Object} + */ + stickyParams() { + return { + sort: m.route.param('sort'), + q: m.route.param('q') + } + } + + /** + * Get parameters to pass to the DiscussionList component. + * + * @return {Object} + */ + params() { + var params = this.stickyParams(); + + params.filter = m.route.param('filter'); + + return params; + } + + /** + * 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'); + }; + + 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; } @@ -243,4 +335,28 @@ export default class IndexPage extends Component { } }); } + + /** + * Initialize the composer for a new discussion. + * + * @todo return a promise + * @return void + */ + newDiscussion() { + if (app.session.user()) { + app.composer.load(new DiscussionComposer({ user: app.session.user() })); + app.composer.show(); + return true; + } + app.modal.show(new LoginModal({ onlogin: this.newDiscussion.bind(this) })); + } + + /** + * Mark all discussions as read. + * + * @return void + */ + markAllAsRead() { + app.session.user().save({ readTime: new Date() }); + } }; diff --git a/framework/core/js/forum/src/components/post-preview.js b/framework/core/js/forum/src/components/post-preview.js index dbc84c6e8..3318defc0 100644 --- a/framework/core/js/forum/src/components/post-preview.js +++ b/framework/core/js/forum/src/components/post-preview.js @@ -2,6 +2,7 @@ import Component from 'flarum/component'; import avatar from 'flarum/helpers/avatar'; import username from 'flarum/helpers/username'; import humanTime from 'flarum/helpers/human-time'; +import highlight from 'flarum/helpers/highlight'; export default class PostPreview extends Component { view() { @@ -16,7 +17,7 @@ export default class PostPreview extends Component { avatar(user), ' ', username(user), ' ', humanTime(post.time()), ' ', - post.excerpt() + highlight(post.excerpt(), this.props.highlight) ])); } } diff --git a/framework/core/js/forum/src/components/search-box.js b/framework/core/js/forum/src/components/search-box.js new file mode 100644 index 000000000..02d2b94aa --- /dev/null +++ b/framework/core/js/forum/src/components/search-box.js @@ -0,0 +1,222 @@ +import Component from 'flarum/component'; +import DiscussionPage from 'flarum/components/discussion-page'; +import IndexPage from 'flarum/components/index-page'; +import ActionButton from 'flarum/components/action-button'; +import LoadingIndicator from 'flarum/components/loading-indicator'; +import ItemList from 'flarum/utils/item-list'; +import classList from 'flarum/utils/class-list'; +import listItems from 'flarum/helpers/list-items'; +import icon from 'flarum/helpers/icon'; +import DiscussionsSearchResults from 'flarum/components/discussions-search-results'; +import UsersSearchResults from 'flarum/components/users-search-results'; + +/** + * A search box, which displays a menu of as-you-type results from a variety of + * sources. + * + * The search box will be 'activated' if the app's current controller implements + * a `searching` method that returns a truthy value. If this is the case, an 'x' + * button will be shown next to the search field, and clicking it will call the + * `clearSearch` method on the controller. + */ +export default class SearchBox extends Component { + constructor(props) { + super(props); + + this.value = m.prop(this.getCurrentSearch() || ''); + this.hasFocus = m.prop(false); + + this.sources = this.sourceItems().toArray(); + this.loadingSources = 0; + this.searched = []; + + /** + * The index of the currently-selected
  • in the results list. This can be + * a unique string (to account for the fact that an item's position may jump + * around as new results load), but otherwise it will be numeric (the + * sequential position within the list). + */ + this.index = m.prop(0); + } + + getCurrentSearch() { + return typeof app.current.searching === 'function' && app.current.searching(); + } + + view() { + var currentSearch = this.getCurrentSearch(); + + return m('div.search-box.dropdown', { + config: this.onload.bind(this), + className: classList({ + open: this.value() && this.hasFocus(), + active: !!currentSearch, + loading: !!this.loadingSources, + }) + }, + m('div.search-input', + m('input.form-control', { + placeholder: 'Search Forum', + value: this.value(), + oninput: m.withAttr('value', this.value), + onfocus: () => this.hasFocus(true), + onblur: () => this.hasFocus(false) + }), + this.loadingSources + ? LoadingIndicator.component({size: 'tiny', className: 'btn btn-icon btn-link'}) + : currentSearch + ? m('button.clear.btn.btn-icon.btn-link', {onclick: this.clear.bind(this)}, icon('times-circle')) + : '' + ), + m('ul.dropdown-menu.dropdown-menu-right.search-results', this.sources.map(source => source.view(this.value()))) + ); + } + + onload(element, isInitialized, context) { + this.element(element); + + // Highlight the item that is currently selected. + this.setIndex(this.getCurrentNumericIndex()); + + if (isInitialized) return; + + var self = this; + + this.$('.search-results') + .on('mousedown', e => e.preventDefault()) + .on('click', () => this.$('input').blur()) + + // Whenever the mouse is hovered over a search result, highlight it. + .on('mouseenter', '> li:not(.dropdown-header)', function(e) { + self.setIndex( + self.selectableItems().index(this) + ); + }); + + // Handle navigation key events on the search input. + this.$('input') + .on('keydown', e => { + switch (e.which) { + case 40: case 38: // Down/Up + this.setIndex(this.getCurrentNumericIndex() + (e.which === 40 ? 1 : -1), true); + e.preventDefault(); + break; + + case 13: // Return + this.$('input').blur(); + this.getItem(this.index()).find('a')[0].dispatchEvent(new Event('click')); + break; + + case 27: // Escape + this.clear(); + break; + } + }) + + // Handle input key events on the search input, triggering results to + // load. + .on('input focus', function(e) { + var value = this.value.toLowerCase(); + + if (value) { + clearTimeout(self.searchTimeout); + self.searchTimeout = setTimeout(() => { + if (self.searched.indexOf(value) === -1) { + if (value.length >= 3) { + self.sources.map(source => { + if (source.search) { + self.loadingSources++; + source.search(value).then(() => { + self.loadingSources--; + m.redraw(); + }); + } + }); + } + self.searched.push(value); + m.redraw(); + } + }, 500); + } + }); + } + + clear() { + this.value(''); + if (this.getCurrentSearch()) { + app.current.clearSearch(); + } else { + m.redraw(); + } + } + + sourceItems() { + var items = new ItemList(); + + items.add('discussions', new DiscussionsSearchResults()); + items.add('users', new UsersSearchResults()); + + return items; + } + + selectableItems() { + return this.$('.search-results > li:not(.dropdown-header)'); + } + + getCurrentNumericIndex() { + return this.selectableItems().index( + this.getItem(this.index()) + ); + } + + /** + * Get the
  • in the search results with the given index (numeric or named). + * + * @param {String} index + * @return {DOMElement} + */ + getItem(index) { + var $items = this.selectableItems(); + var $item = $items.filter('[data-index='+index+']'); + + if (!$item.length) { + $item = $items.eq(index); + } + + return $item; + } + + setIndex(index, scrollToItem) { + var $items = this.selectableItems(); + var $dropdown = $items.parent(); + + if (index < 0) { + index = $items.length - 1; + } else if (index >= $items.length) { + index = 0; + } + + var $item = $items.removeClass('active').eq(index).addClass('active'); + + this.index($item.attr('data-index') || index); + + if (scrollToItem) { + var dropdownScroll = $dropdown.scrollTop(); + var dropdownTop = $dropdown.offset().top; + var dropdownBottom = dropdownTop + $dropdown.outerHeight(); + var itemTop = $item.offset().top; + var itemBottom = itemTop + $item.outerHeight(); + + var scrollTop; + if (itemTop < dropdownTop) { + scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top')); + } else if (itemBottom > dropdownBottom) { + scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom')); + } + + if (typeof scrollTop !== 'undefined') { + $dropdown.stop(true).animate({scrollTop}, 100); + } + } + } +} diff --git a/framework/core/js/forum/src/components/users-search-results.js b/framework/core/js/forum/src/components/users-search-results.js new file mode 100644 index 000000000..a9bf956f1 --- /dev/null +++ b/framework/core/js/forum/src/components/users-search-results.js @@ -0,0 +1,22 @@ +import highlight from 'flarum/helpers/highlight'; +import avatar from 'flarum/helpers/avatar'; + +export default class UsersSearchResults { + search(string) { + return app.store.find('users', {q: string, page: {limit: 5}}); + } + + view(string) { + var results = app.store.all('users').filter(user => user.username().toLowerCase().substr(0, string.length) === string); + + return results.length ? [ + m('li.dropdown-header', 'Users'), + results.map(user => m('li.user-search-result', {'data-index': 'users'+user.id()}, + m('a', { + href: app.route.user(user), + config: m.route + }, avatar(user), highlight(user.username(), string)) + )) + ] : ''; + } +} diff --git a/framework/core/js/forum/src/initializers/boot.js b/framework/core/js/forum/src/initializers/boot.js index 5e3b55e20..80bf9edb0 100644 --- a/framework/core/js/forum/src/initializers/boot.js +++ b/framework/core/js/forum/src/initializers/boot.js @@ -11,8 +11,7 @@ import FooterSecondary from 'flarum/components/footer-secondary'; import Composer from 'flarum/components/composer'; import Modal from 'flarum/components/modal'; import Alerts from 'flarum/components/alerts'; -import SignupModal from 'flarum/components/signup-modal'; -import LoginModal from 'flarum/components/login-modal'; +import SearchBox from 'flarum/components/search-box'; export default function(app) { var id = id => document.getElementById(id); @@ -43,5 +42,7 @@ export default function(app) { m.route.mode = 'hash'; m.route(id('content'), '/', mapRoutes(app.routes)); + app.search = new SearchBox(); + new ScrollListener(top => $('body').toggleClass('scrolled', top > 0)).start(); } diff --git a/framework/core/js/lib/helpers/highlight.js b/framework/core/js/lib/helpers/highlight.js new file mode 100644 index 000000000..f01f5ba7e --- /dev/null +++ b/framework/core/js/lib/helpers/highlight.js @@ -0,0 +1,13 @@ +export default function(string, regexp) { + if (!regexp) { + return string; + } + + if (!(regexp instanceof RegExp)) { + regexp = new RegExp(regexp, 'gi'); + } + + return m.trust( + $('
    ').text(string).html().replace(regexp, '$&') + ); +} diff --git a/framework/core/less/forum/app.less b/framework/core/less/forum/app.less index 9fd60924b..d857852de 100644 --- a/framework/core/less/forum/app.less +++ b/framework/core/less/forum/app.less @@ -20,6 +20,7 @@ @import "@{lib-path}/modals.less"; @import "@{lib-path}/layout.less"; @import "@{lib-path}/side-nav.less"; +@import "@{lib-path}/search.less"; @import "composer.less"; @import "notifications.less"; diff --git a/framework/core/less/forum/index.less b/framework/core/less/forum/index.less index 207c29c6b..c1fc1ec2e 100644 --- a/framework/core/less/forum/index.less +++ b/framework/core/less/forum/index.less @@ -17,13 +17,13 @@ margin-bottom: 15px; } .index-toolbar-view { - display: inline-block; + &:extend(.list-inline); - & .control-show { - margin-right: 10px; - } + display: inline-block; } .index-toolbar-action { + &:extend(.list-inline); + float: right; } @@ -97,6 +97,9 @@ & .count strong { font-size: 18px; } + & .relevant-posts { + display: none; + } } } } @@ -238,6 +241,30 @@ cursor: pointer; } } + & .relevant-posts { + margin-bottom: 20px; + + & .post-preview { + background: @fl-body-secondary-color; + display: block; + padding: 10px 15px; + border-bottom: 2px dotted @fl-body-bg; + + & .avatar, & time { + display: none; + } + & .post-preview-content { + padding-left: 0; + } + &:first-child { + border-radius: @border-radius-base @border-radius-base 0 0; + } + &:hover { + background: darken(@fl-body-secondary-color, 2%); + text-decoration: none; + } + } + } } .load-more { text-align: center; diff --git a/framework/core/less/lib/components.less b/framework/core/less/lib/components.less index dd6500113..511186c3a 100644 --- a/framework/core/less/lib/components.less +++ b/framework/core/less/lib/components.less @@ -22,3 +22,11 @@ hr { border-top: 2px solid @fl-body-secondary-color; } + +mark { + background: #FFE300; + color: @fl-body-color; + padding: 1px; + border-radius: @border-radius-base; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1); +} diff --git a/framework/core/less/lib/dropdowns.less b/framework/core/less/lib/dropdowns.less index 0751a826d..100ea61d7 100644 --- a/framework/core/less/lib/dropdowns.less +++ b/framework/core/less/lib/dropdowns.less @@ -44,9 +44,23 @@ } } & .divider { - margin: 10px 0; + margin: 8px 0; background-color: @fl-body-control-bg; } + & .dropdown-header { + padding: 10px 15px; + color: @fl-body-heading-color; + text-transform: uppercase; + font-size: 12px; + font-weight: bold; + margin-top: 8px; + border-top: 1px solid @fl-body-control-bg; + + &:first-child { + margin-top: -8px; + border-top: 0; + } + } } @media @tablet, @desktop, @desktop-hd { .dropdown-split { diff --git a/framework/core/less/lib/forms.less b/framework/core/less/lib/forms.less index dfcb61e7b..1f214c1a4 100644 --- a/framework/core/less/lib/forms.less +++ b/framework/core/less/lib/forms.less @@ -3,11 +3,14 @@ } .form-control { .box-shadow(none); + border-width: 2px; + &:focus, &.focus { background-color: #fff; color: @fl-body-color; .box-shadow(none); + border: 2px solid @fl-body-primary-color; } } legend { @@ -17,47 +20,6 @@ legend { margin-bottom: 10px; } -// Search inputs -// @todo Extract some of this into header-specific definitions -.search-input { - overflow: hidden; - - &:before { - .fa(); - content: @fa-var-search; - float: left; - margin-right: -36px; - width: 36px; - font-size: 14px; - text-align: center; - color: @fl-body-muted-color; - position: relative; - padding: @padding-base-vertical - 1 0; - line-height: @line-height-base; - pointer-events: none; - } - & .form-control { - float: left; - width: 225px; - padding-left: 36px; - padding-right: 36px; - .transition(~"all 0.4s"); - } - & .clear { - float: left; - margin-left: -36px; - vertical-align: top; - opacity: 0; - width: 36px !important; - .rotate(-180deg); - .transition(~"transform 0.2s, opacity 0.2s"); - } - &.clearable .clear { - opacity: 1; - .rotate(0deg); - } -} - // Select inputs .select-input { display: inline-block; diff --git a/framework/core/less/lib/layout.less b/framework/core/less/lib/layout.less index 2a45bfc24..1a80f240a 100644 --- a/framework/core/less/lib/layout.less +++ b/framework/core/less/lib/layout.less @@ -169,7 +169,7 @@ body { background: fadein(@fl-drawer-control-bg, 5%); } } - & .search-input:before { + & .search-input { color: @fl-drawer-control-color; } & .btn-default, & .btn-default:hover { @@ -311,12 +311,8 @@ body { .header-secondary { float: right; - & .search-input { + & .search-box { margin-right: 10px; - - &:focus { - width: 400px; - } } } } diff --git a/framework/core/less/lib/search.less b/framework/core/less/lib/search.less new file mode 100644 index 000000000..6eee72cab --- /dev/null +++ b/framework/core/less/lib/search.less @@ -0,0 +1,82 @@ +.search-box { + & input:focus, &.active input, & .search-results { + width: 400px; + } +} +.search-results { + max-height: 70vh; + overflow: auto; + + & > li > a { + white-space: normal; + + &:hover { + background: none; + } + } + + & mark { + background: none; + padding: 0; + font-weight: bold; + color: inherit; + box-shadow: none; + } +} + +.search-input { + overflow: hidden; + color: @fl-body-muted-color; + + &:before { + .fa(); + content: @fa-var-search; + float: left; + margin-right: -36px; + width: 36px; + font-size: 14px; + text-align: center; + position: relative; + padding: @padding-base-vertical - 1 0; + line-height: @line-height-base; + pointer-events: none; + } + & input { + float: left; + width: 225px; + padding-left: 36px; + padding-right: 36px; + .transition(~"all 0.4s"); + + .active & { + background: @fl-body-bg; + border: 2px solid @fl-body-secondary-color; + + &:focus { + &:extend(.form-control:focus); + } + } + } + & .btn { + float: left; + margin-left: -36px; + width: 36px !important; + outline: none; + } +} + +.discussion-search-result { + & .title { + margin-bottom: 3px; + } + & .excerpt { + color: @fl-body-muted-color; + font-size: 11px; + } +} +.user-search-result { + & .avatar { + .avatar-size(24px); + margin: -2px 10px -2px 0; + } +} diff --git a/framework/core/src/Api/Actions/Discussions/IndexAction.php b/framework/core/src/Api/Actions/Discussions/IndexAction.php index 65695afd7..c0cd6aaed 100644 --- a/framework/core/src/Api/Actions/Discussions/IndexAction.php +++ b/framework/core/src/Api/Actions/Discussions/IndexAction.php @@ -33,7 +33,9 @@ class IndexAction extends SerializeCollectionAction 'lastUser' => true, 'startPost' => false, 'lastPost' => false, - 'relevantPosts' => false + 'relevantPosts' => false, + 'relevantPosts.discussion' => false, + 'relevantPosts.user' => false ]; /** diff --git a/framework/core/src/Core/Search/Discussions/DiscussionSearcher.php b/framework/core/src/Core/Search/Discussions/DiscussionSearcher.php index d4b7d74c8..742ba664c 100644 --- a/framework/core/src/Core/Search/Discussions/DiscussionSearcher.php +++ b/framework/core/src/Core/Search/Discussions/DiscussionSearcher.php @@ -93,7 +93,7 @@ class DiscussionSearcher implements SearcherInterface } if (in_array('relevantPosts', $load) && count($this->relevantPosts)) { - $load = array_diff($load, ['relevantPosts']); + $load = array_diff($load, ['relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user']); $postIds = []; foreach ($this->relevantPosts as $id => $posts) { @@ -104,12 +104,6 @@ class DiscussionSearcher implements SearcherInterface foreach ($discussions as $discussion) { $discussion->relevantPosts = $posts->filter(function ($post) use ($discussion) { return $post->discussion_id == $discussion->id; - }) - ->each(function ($post) { - $pos = strpos(strtolower($post->content), strtolower($this->fulltext)); - // TODO: make clipping more intelligent (full words only) - $start = max(0, $pos - 50); - $post->content = ($start > 0 ? '...' : '').str_limit(substr($post->content, $start), 300); }); } } From e73c21779e70e50328bf9fdcf8ca667cfbca8f55 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 3 Jun 2015 18:11:43 +0930 Subject: [PATCH 46/51] Style tweaks --- framework/core/less/forum/composer.less | 6 +++++- framework/core/less/forum/discussion.less | 9 ++++----- framework/core/less/forum/hero.less | 8 ++++---- framework/core/less/lib/components.less | 1 + 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/framework/core/less/forum/composer.less b/framework/core/less/forum/composer.less index 4df486a36..98125912d 100644 --- a/framework/core/less/forum/composer.less +++ b/framework/core/less/forum/composer.less @@ -29,7 +29,7 @@ font-weight: normal; } & input { - &, &[disabled] { + &, &[disabled], &:focus { background: none; border: 0; padding: 0 20px 0 0; @@ -147,6 +147,9 @@ .minimized & { pointer-events: none; } + .full-screen & { + margin-bottom: 20px; + } } .composer-content { padding: 20px 20px 0; @@ -231,6 +234,7 @@ &, &:focus, &[disabled] { background: none; + border: 0; } } } diff --git a/framework/core/less/forum/discussion.less b/framework/core/less/forum/discussion.less index 0eec3c57d..c2e4544ea 100644 --- a/framework/core/less/forum/discussion.less +++ b/framework/core/less/forum/discussion.less @@ -233,7 +233,7 @@ & pre { border: 0; padding: 15px; - background: #fafafa; + background: #f3f3f3; color: #666; font-size: 90%; } @@ -464,11 +464,11 @@ cursor: text; overflow: hidden; margin: 50px -20px 0; - border: 2px dashed @fl-body-secondary-color; + border: 2px dashed transparent; color: @fl-body-muted-color; border-radius: 10px; padding: 20px 20px 20px 110px; - transition: color 0.2s, border-color 0.2s; + transition: border-color 0.2s; & .post-header { padding-top: 18px; @@ -478,8 +478,7 @@ margin-top: -18px; } &:hover { - color: @fl-secondary-color; - border-color: @fl-body-muted-color; + border-color: @fl-body-secondary-color; } } diff --git a/framework/core/less/forum/hero.less b/framework/core/less/forum/hero.less index 5a3e320a4..abdb2a083 100644 --- a/framework/core/less/forum/hero.less +++ b/framework/core/less/forum/hero.less @@ -3,14 +3,15 @@ background: @fl-body-hero-bg; text-align: center; padding: 20px 0; + color: @fl-body-hero-color; - &, & a { - color: @fl-body-hero-color; + & a { + color: inherit; } & .close { float: right; margin-top: -10px; - color: @fl-body-hero-muted-color; + color: inherit; } & h2 { margin: 0; @@ -21,7 +22,6 @@ & .subtitle { margin: 8px 0 0; line-height: 1.5em; - color: @fl-body-hero-muted-color; } } @media @phone { diff --git a/framework/core/less/lib/components.less b/framework/core/less/lib/components.less index 511186c3a..cb7402df0 100644 --- a/framework/core/less/lib/components.less +++ b/framework/core/less/lib/components.less @@ -10,6 +10,7 @@ .loading-indicator { position: relative; + color: @fl-body-muted-color; } .loading-indicator-inline { display: inline-block; From 80f766127a193cd678425f8f7efd549bf78e1065 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 3 Jun 2015 18:12:15 +0930 Subject: [PATCH 47/51] Allow
    in posts --- framework/core/src/Core/Formatter/FormatterManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/core/src/Core/Formatter/FormatterManager.php b/framework/core/src/Core/Formatter/FormatterManager.php index 6ea88cf7c..7267a70af 100644 --- a/framework/core/src/Core/Formatter/FormatterManager.php +++ b/framework/core/src/Core/Formatter/FormatterManager.php @@ -70,7 +70,7 @@ class FormatterManager $config->set('Core.Encoding', 'UTF-8'); $config->set('Core.EscapeInvalidTags', true); $config->set('HTML.Doctype', 'HTML 4.01 Strict'); - $config->set('HTML.Allowed', 'p,em,strong,a[href|title],ul,ol,li,code,pre,blockquote,h1,h2,h3,h4,h5,h6,br'); + $config->set('HTML.Allowed', 'p,em,strong,a[href|title],ul,ol,li,code,pre,blockquote,h1,h2,h3,h4,h5,h6,br,hr'); $config->set('HTML.Nofollow', true); $purifier = new HTMLPurifier($config); From 944e5c649caed1298a5334a7132c3585faf617fb Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 4 Jun 2015 10:48:07 +0930 Subject: [PATCH 48/51] Rejig formatting API. closes flarum/core#85 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It works but it’s not the most pretty thing in the world. @franzliedke Would be great if you could take a look at the whole formatting API and work your magic on it sometime… my brain is fried! --- .../js/forum/src/components/post-preview.js | 16 ++++++- framework/core/js/lib/models/post.js | 2 +- .../Api/Serializers/PostBasicSerializer.php | 2 +- .../src/Core/Formatter/FormatterAbstract.php | 48 +++++++++++++++++++ .../src/Core/Formatter/FormatterInterface.php | 10 ++++ .../src/Core/Formatter/FormatterManager.php | 19 ++++---- .../src/Core/Formatter/LinkifyFormatter.php | 5 +- .../core/src/Core/Models/CommentPost.php | 11 ----- 8 files changed, 87 insertions(+), 26 deletions(-) create mode 100644 framework/core/src/Core/Formatter/FormatterAbstract.php create mode 100644 framework/core/src/Core/Formatter/FormatterInterface.php diff --git a/framework/core/js/forum/src/components/post-preview.js b/framework/core/js/forum/src/components/post-preview.js index 3318defc0..592916cb9 100644 --- a/framework/core/js/forum/src/components/post-preview.js +++ b/framework/core/js/forum/src/components/post-preview.js @@ -9,6 +9,20 @@ export default class PostPreview extends Component { var post = this.props.post; var user = post.user(); + var excerpt = post.contentPlain(); + var start = 0; + + if (highlight) { + var regexp = new RegExp(this.props.highlight, 'gi'); + start = Math.max(0, excerpt.search(regexp) - 100); + } + + excerpt = (start > 0 ? '...' : '')+excerpt.substring(start, start + 200)+(excerpt.length > start + 200 ? '...' : ''); + + if (highlight) { + excerpt = highlight(excerpt, regexp); + } + return m('a.post-preview', { href: app.route.post(post), config: m.route, @@ -17,7 +31,7 @@ export default class PostPreview extends Component { avatar(user), ' ', username(user), ' ', humanTime(post.time()), ' ', - highlight(post.excerpt(), this.props.highlight) + excerpt ])); } } diff --git a/framework/core/js/lib/models/post.js b/framework/core/js/lib/models/post.js index 0ac3f4787..671e3e91d 100644 --- a/framework/core/js/lib/models/post.js +++ b/framework/core/js/lib/models/post.js @@ -12,7 +12,7 @@ Post.prototype.user = Model.one('user'); Post.prototype.contentType = Model.prop('contentType'); Post.prototype.content = Model.prop('content'); Post.prototype.contentHtml = Model.prop('contentHtml'); -Post.prototype.excerpt = Model.prop('excerpt'); +Post.prototype.contentPlain = computed('contentHtml', contentHtml => $('
    ').html(contentHtml.replace(/(<\/p>|
    )/g, '$1 ')).text()); Post.prototype.editTime = Model.prop('editTime', Model.date); Post.prototype.editUser = Model.one('editUser'); diff --git a/framework/core/src/Api/Serializers/PostBasicSerializer.php b/framework/core/src/Api/Serializers/PostBasicSerializer.php index 92370541e..25aa96518 100644 --- a/framework/core/src/Api/Serializers/PostBasicSerializer.php +++ b/framework/core/src/Api/Serializers/PostBasicSerializer.php @@ -25,7 +25,7 @@ class PostBasicSerializer extends BaseSerializer ]; if ($post->type === 'comment') { - $attributes['excerpt'] = str_limit($post->contentPlain, 200); + $attributes['contentHtml'] = $post->content_html; } else { $attributes['content'] = $post->content; } diff --git a/framework/core/src/Core/Formatter/FormatterAbstract.php b/framework/core/src/Core/Formatter/FormatterAbstract.php new file mode 100644 index 000000000..27cb84b48 --- /dev/null +++ b/framework/core/src/Core/Formatter/FormatterAbstract.php @@ -0,0 +1,48 @@ +)/is', $text, 0, PREG_SPLIT_DELIM_CAPTURE); + + $openTag = null; + + for ($i = 0; $i < count($chunks); $i++) { + if ($i % 2 === 0) { // even numbers are text + // Only process this chunk if there are no unclosed $ignoreTags + if (null === $openTag) { + $chunks[$i] = $callback($chunks[$i]); + } + } else { // odd numbers are tags + // Only process this tag if there are no unclosed $ignoreTags + if (null === $openTag) { + // Check whether this tag is contained in $ignoreTags and is not self-closing + if (preg_match("`<(" . implode('|', $tags) . ").*(?$`is", $chunks[$i], $matches)) { + $openTag = $matches[1]; + } + } else { + // Otherwise, check whether this is the closing tag for $openTag. + if (preg_match('``i', $chunks[$i], $matches)) { + $openTag = null; + } + } + } + } + + return implode($chunks); + } +} diff --git a/framework/core/src/Core/Formatter/FormatterInterface.php b/framework/core/src/Core/Formatter/FormatterInterface.php new file mode 100644 index 000000000..0a09c5fc8 --- /dev/null +++ b/framework/core/src/Core/Formatter/FormatterInterface.php @@ -0,0 +1,10 @@ +getFormatters() as $formatter) { - $text = $this->container->make($formatter)->format($text, $post); + $formatters[] = $this->container->make($formatter); + } + + foreach ($formatters as $formatter) { + $text = $formatter->beforePurification($text, $post); } // Studio does not yet merge autoload_files... @@ -75,16 +80,10 @@ class FormatterManager $purifier = new HTMLPurifier($config); - return $purifier->purify($text); - } + $text = $purifier->purify($text); - public function strip($text) - { - foreach ($this->getFormatters() as $formatter) { - $formatter = $this->container->make($formatter); - if (method_exists($formatter, 'strip')) { - $text = $formatter->strip($text); - } + foreach ($formatters as $formatter) { + $text = $formatter->afterPurification($text, $post); } return $text; diff --git a/framework/core/src/Core/Formatter/LinkifyFormatter.php b/framework/core/src/Core/Formatter/LinkifyFormatter.php index be5570f29..5b616ecd7 100644 --- a/framework/core/src/Core/Formatter/LinkifyFormatter.php +++ b/framework/core/src/Core/Formatter/LinkifyFormatter.php @@ -1,8 +1,9 @@ linkify = $linkify; } - public function format($text) + public function beforePurification($text, Post $post = null) { return $this->linkify->process($text, ['attr' => ['target' => '_blank']]); } diff --git a/framework/core/src/Core/Models/CommentPost.php b/framework/core/src/Core/Models/CommentPost.php index 7d1bbf9de..2c63f695e 100755 --- a/framework/core/src/Core/Models/CommentPost.php +++ b/framework/core/src/Core/Models/CommentPost.php @@ -119,17 +119,6 @@ class CommentPost extends Post return $value; } - /** - * Get the content formatter as HTML. - * - * @param string $value - * @return string - */ - public function getContentPlainAttribute() - { - return static::$formatter->strip($this->content); - } - /** * Get text formatter instance. * From 6144e427d2a825ea0c20c60639c6d5da1bfea319 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 4 Jun 2015 11:11:56 +0930 Subject: [PATCH 49/51] Really rough fulltext driver implementation --- .../2015_02_24_000000_create_posts_table.php | 7 ++++--- .../core/src/Core/CoreServiceProvider.php | 5 +++++ .../Repositories/EloquentPostRepository.php | 19 +++++++++++++++---- .../Discussions/Fulltext/DriverInterface.php | 6 ++++++ .../Fulltext/MySqlFulltextDriver.php | 13 +++++++++++++ 5 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 framework/core/src/Core/Search/Discussions/Fulltext/DriverInterface.php create mode 100644 framework/core/src/Core/Search/Discussions/Fulltext/MySqlFulltextDriver.php diff --git a/framework/core/migrations/2015_02_24_000000_create_posts_table.php b/framework/core/migrations/2015_02_24_000000_create_posts_table.php index d5ab988e2..b172f342e 100644 --- a/framework/core/migrations/2015_02_24_000000_create_posts_table.php +++ b/framework/core/migrations/2015_02_24_000000_create_posts_table.php @@ -2,6 +2,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; +use DB; class CreatePostsTable extends Migration { @@ -14,7 +15,6 @@ class CreatePostsTable extends Migration public function up() { Schema::create('posts', function (Blueprint $table) { - $table->increments('id'); $table->integer('discussion_id')->unsigned(); $table->integer('number')->unsigned()->nullable(); @@ -29,10 +29,11 @@ class CreatePostsTable extends Migration $table->integer('edit_user_id')->unsigned()->nullable(); $table->dateTime('hide_time')->nullable(); $table->integer('hide_user_id')->unsigned()->nullable(); + + $table->unique(['discussion_id', 'number']); }); - // add fulltext index to content (and title?) - // add unique index on [discussion_id, number] !!! + DB::statement('ALTER TABLE posts ADD FULLTEXT content (content)'); } /** diff --git a/framework/core/src/Core/CoreServiceProvider.php b/framework/core/src/Core/CoreServiceProvider.php index 55833f4f1..5556a18f7 100644 --- a/framework/core/src/Core/CoreServiceProvider.php +++ b/framework/core/src/Core/CoreServiceProvider.php @@ -89,6 +89,11 @@ class CoreServiceProvider extends ServiceProvider 'Flarum\Core\Repositories\EloquentActivityRepository' ); + $this->app->bind( + 'Flarum\Core\Search\Discussions\Fulltext\DriverInterface', + 'Flarum\Core\Search\Discussions\Fulltext\MySqlFulltextDriver' + ); + $avatarFilesystem = function (Container $app) { return $app->make('Illuminate\Contracts\Filesystem\Factory')->disk('flarum-avatars')->getDriver(); }; diff --git a/framework/core/src/Core/Repositories/EloquentPostRepository.php b/framework/core/src/Core/Repositories/EloquentPostRepository.php index 886e0a415..b08d12ffb 100644 --- a/framework/core/src/Core/Repositories/EloquentPostRepository.php +++ b/framework/core/src/Core/Repositories/EloquentPostRepository.php @@ -3,9 +3,17 @@ use Illuminate\Database\Eloquent\Builder; use Flarum\Core\Models\Post; use Flarum\Core\Models\User; +use Flarum\Core\Search\Discussions\Fulltext\DriverInterface; class EloquentPostRepository implements PostRepositoryInterface { + protected $fulltext; + + public function __construct(DriverInterface $fulltext) + { + $this->fulltext = $fulltext; + } + /** * Find a post by ID, optionally making sure it is visible to a certain * user, or throw an exception. @@ -72,10 +80,13 @@ class EloquentPostRepository implements PostRepositoryInterface */ public function findByContent($string, User $user = null) { - $query = Post::select('id', 'discussion_id') - ->where('content', 'like', '%'.$string.'%'); - // ->whereRaw('MATCH (`content`) AGAINST (? IN BOOLEAN MODE)', [$string]) - // ->orderByRaw('MATCH (`content`) AGAINST (?) DESC', [$string]) + $ids = $this->fulltext->match($string); + + $query = Post::select('id', 'discussion_id')->whereIn('id', $ids); + + foreach ($ids as $id) { + $query->orderByRaw('id != ?', [$id]); + } return $this->scopeVisibleForUser($query, $user)->get(); } diff --git a/framework/core/src/Core/Search/Discussions/Fulltext/DriverInterface.php b/framework/core/src/Core/Search/Discussions/Fulltext/DriverInterface.php new file mode 100644 index 000000000..a80c6f07f --- /dev/null +++ b/framework/core/src/Core/Search/Discussions/Fulltext/DriverInterface.php @@ -0,0 +1,6 @@ +orderByRaw('MATCH (`content`) AGAINST (?) DESC', [$string]) + ->lists('id'); + } +} From 73cee225c62ba75cb6d1fef488493ce35a4f9259 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 4 Jun 2015 11:12:04 +0930 Subject: [PATCH 50/51] Fix error --- .../core/js/forum/src/components/discussions-search-results.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/core/js/forum/src/components/discussions-search-results.js b/framework/core/js/forum/src/components/discussions-search-results.js index f7009fe81..b5adafeb7 100644 --- a/framework/core/js/forum/src/components/discussions-search-results.js +++ b/framework/core/js/forum/src/components/discussions-search-results.js @@ -27,7 +27,7 @@ export default class DiscussionsSearchResults { return m('li.discussion-search-result', {'data-index': 'discussions'+discussion.id()}, m('a', { href: app.route.discussion(discussion, post.number()), config: m.route }, m('div.title', highlight(discussion.title(), string)), - m('div.excerpt', highlight(post.excerpt(), string)) + m('div.excerpt', highlight(post.contentPlain().substring(0, 100), string)) ) ); }) : '' From 62dac9b1eef31cef528ba1dd535850a22c352fb8 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 4 Jun 2015 11:19:23 +0930 Subject: [PATCH 51/51] Usernames must only contain alphanumeric chars/dashes/underscores Perhaps we can relax this a little bit, but right now these are the only characters that are parsed for @mentions anyway --- framework/core/src/Core/Models/User.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/core/src/Core/Models/User.php b/framework/core/src/Core/Models/User.php index e29ea5fb5..131530b83 100755 --- a/framework/core/src/Core/Models/User.php +++ b/framework/core/src/Core/Models/User.php @@ -32,7 +32,7 @@ class User extends Model * @var array */ public static $rules = [ - 'username' => 'required|unique', + 'username' => 'required|alpha_dash|unique', 'email' => 'required|email|unique', 'password' => 'required', 'join_time' => 'date',