From 3ac9efde3eb8a58c99cf93039d6a75da43d5ef59 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 22 Sep 2015 17:48:21 +0930 Subject: [PATCH] Allow discussions to be hidden and restored --- framework/core/CHANGELOG.md | 1 + .../src/components/DiscussionListItem.js | 14 ++++- .../js/forum/src/utils/DiscussionControls.js | 51 +++++++++++++++++-- .../core/js/forum/src/utils/PostControls.js | 15 ++++-- framework/core/js/lib/components/Badge.js | 1 + framework/core/js/lib/models/Discussion.js | 14 ++++- .../core/less/forum/DiscussionListItem.less | 6 +++ framework/core/less/lib/Badge.less | 4 ++ framework/core/less/lib/Dropdown.less | 4 +- ...5_09_20_224327_add_hide_to_discussions.php | 34 +++++++++++++ .../src/Api/Actions/Posts/UpdateAction.php | 1 + .../Api/Serializers/DiscussionSerializer.php | 16 +++++- .../Commands/EditDiscussionHandler.php | 10 ++++ .../core/src/Core/Discussions/Discussion.php | 39 +++++++++++++- .../DiscussionsServiceProvider.php | 17 ++++--- .../Search/Gambits/HiddenGambit.php | 42 +++++++++++++++ .../core/src/Events/DiscussionWasHidden.php | 31 +++++++++++ .../core/src/Events/DiscussionWasRestored.php | 31 +++++++++++ ...hp => ScopeHiddenDiscussionVisibility.php} | 13 +++-- 19 files changed, 321 insertions(+), 23 deletions(-) create mode 100644 framework/core/migrations/2015_09_20_224327_add_hide_to_discussions.php create mode 100644 framework/core/src/Core/Discussions/Search/Gambits/HiddenGambit.php create mode 100644 framework/core/src/Events/DiscussionWasHidden.php create mode 100644 framework/core/src/Events/DiscussionWasRestored.php rename framework/core/src/Events/{ScopeEmptyDiscussionVisibility.php => ScopeHiddenDiscussionVisibility.php} (66%) diff --git a/framework/core/CHANGELOG.md b/framework/core/CHANGELOG.md index 3a8586e08..77d286c6d 100644 --- a/framework/core/CHANGELOG.md +++ b/framework/core/CHANGELOG.md @@ -4,6 +4,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased][unreleased] ### Added +- Allow discussions to be hidden and restored. - External authentication (social login) API. - API to set asset compiler filename. - Migration generator, available via generate:migration console command. diff --git a/framework/core/js/forum/src/components/DiscussionListItem.js b/framework/core/js/forum/src/components/DiscussionListItem.js index f82cbb32d..937cb777b 100644 --- a/framework/core/js/forum/src/components/DiscussionListItem.js +++ b/framework/core/js/forum/src/components/DiscussionListItem.js @@ -13,6 +13,7 @@ import SubtreeRetainer from 'flarum/utils/SubtreeRetainer'; import DiscussionControls from 'flarum/utils/DiscussionControls'; import slidable from 'flarum/utils/slidable'; import extractText from 'flarum/utils/extractText'; +import classList from 'flarum/utils/classList'; /** * The `DiscussionListItem` component shows a single discussion in the @@ -43,6 +44,16 @@ export default class DiscussionListItem extends Component { ); } + attrs() { + return { + className: classList([ + 'DiscussionListItem', + this.active() ? 'active' : '', + this.props.discussion.isHidden() ? 'DiscussionListItem--hidden' : '' + ]) + }; + } + view() { const retain = this.subtree.retain(); @@ -56,9 +67,10 @@ export default class DiscussionListItem extends Component { const jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1); const relevantPosts = this.props.params.q ? discussion.relevantPosts() : []; const controls = DiscussionControls.controls(discussion, this).toArray(); + const attrs = this.attrs(); return ( -
+
{controls.length ? Dropdown.component({ icon: 'ellipsis-v', diff --git a/framework/core/js/forum/src/utils/DiscussionControls.js b/framework/core/js/forum/src/utils/DiscussionControls.js index 7c2c5d2a0..073a140f1 100644 --- a/framework/core/js/forum/src/utils/DiscussionControls.js +++ b/framework/core/js/forum/src/utils/DiscussionControls.js @@ -105,10 +105,25 @@ export default { destructiveControls(discussion) { const items = new ItemList(); - if (discussion.canDelete()) { + if (!discussion.isHidden()) { + if (discussion.canHide()) { + items.add('hide', Button.component({ + icon: 'trash-o', + children: app.trans('core.delete'), + onclick: this.hideAction.bind(discussion) + })); + } + } else if (discussion.canDelete()) { + items.add('restore', Button.component({ + icon: 'reply', + children: app.trans('core.restore'), + onclick: this.restoreAction.bind(discussion), + disabled: discussion.commentsCount() === 0 + })); + items.add('delete', Button.component({ icon: 'times', - children: app.trans('core.delete'), + children: app.trans('core.delete_forever'), onclick: this.deleteAction.bind(discussion) })); } @@ -173,13 +188,35 @@ export default { return deferred.promise; }, + /** + * Hide a discussion. + * + * @return {Promise} + */ + hideAction() { + this.pushAttributes({ hideTime: new Date(), hideUser: app.session.user }); + + return this.save({ isHidden: true }); + }, + + /** + * Restore a discussion. + * + * @return {Promise} + */ + restoreAction() { + this.pushAttributes({ hideTime: null, hideUser: null }); + + return this.save({ isHidden: false }); + }, + /** * Delete the discussion after confirming with the user. + * + * @return {Promise} */ deleteAction() { if (confirm(extractText(app.trans('core.confirm_delete_discussion')))) { - this.delete(); - // If there is a discussion list in the cache, remove this discussion. if (app.cache.discussionList) { app.cache.discussionList.removeDiscussion(this); @@ -190,11 +227,15 @@ export default { if (app.viewingDiscussion(this)) { app.history.back(); } + + return this.delete(); } }, /** * Rename the discussion. + * + * @return {Promise} */ renameAction() { const currentTitle = this.title(); @@ -204,7 +245,7 @@ export default { // save has completed, update the post stream as there will be a new post // indicating that the discussion was renamed. if (title && title !== currentTitle) { - this.save({title}).then(() => { + return this.save({title}).then(() => { if (app.viewingDiscussion(this)) { app.current.stream.update(); } diff --git a/framework/core/js/forum/src/utils/PostControls.js b/framework/core/js/forum/src/utils/PostControls.js index 7429e249b..acfc2b152 100644 --- a/framework/core/js/forum/src/utils/PostControls.js +++ b/framework/core/js/forum/src/utils/PostControls.js @@ -114,25 +114,34 @@ export default { /** * Hide a post. + * + * @return {Promise} */ hideAction() { - this.save({ isHidden: true }); this.pushAttributes({ hideTime: new Date(), hideUser: app.session.user }); + + return this.save({ isHidden: true }).then(() => m.redraw()); }, /** * Restore a post. + * + * @return {Promise} */ restoreAction() { - this.save({ isHidden: false }); this.pushAttributes({ hideTime: null, hideUser: null }); + + return this.save({ isHidden: false }).then(() => m.redraw()); }, /** * Delete a post. + * + * @return {Promise} */ deleteAction() { - this.delete(); this.discussion().removePost(this.id()); + + return this.delete(); } }; diff --git a/framework/core/js/lib/components/Badge.js b/framework/core/js/lib/components/Badge.js index 8da355982..2b42489b8 100644 --- a/framework/core/js/lib/components/Badge.js +++ b/framework/core/js/lib/components/Badge.js @@ -11,6 +11,7 @@ import extract from 'flarum/utils/extract'; * - `type` The type of badge this is. This will be used to give the badge a * class name of `Badge--{type}`. * - `icon` The name of an icon to show inside the badge. + * - `label` * * All other props will be assigned as attributes on the badge element. */ diff --git a/framework/core/js/lib/models/Discussion.js b/framework/core/js/lib/models/Discussion.js index 631f261d3..472caf895 100644 --- a/framework/core/js/lib/models/Discussion.js +++ b/framework/core/js/lib/models/Discussion.js @@ -3,6 +3,7 @@ import mixin from 'flarum/utils/mixin'; import computed from 'flarum/utils/computed'; import ItemList from 'flarum/utils/ItemList'; import { slug } from 'flarum/utils/string'; +import Badge from 'flarum/components/Badge'; export default class Discussion extends mixin(Model, { title: Model.attribute('title'), @@ -27,8 +28,13 @@ export default class Discussion extends mixin(Model, { isUnread: computed('unreadCount', unreadCount => !!unreadCount), isRead: computed('unreadCount', unreadCount => app.session.user && !unreadCount), + hideTime: Model.attribute('hideTime', Model.transformDate), + hideUser: Model.hasOne('hideUser'), + isHidden: computed('hideTime', 'commentsCount', (hideTime, commentsCount) => !!hideTime || commentsCount === 0), + canReply: Model.attribute('canReply'), canRename: Model.attribute('canRename'), + canHide: Model.attribute('canHide'), canDelete: Model.attribute('canDelete') }) { /** @@ -75,7 +81,13 @@ export default class Discussion extends mixin(Model, { * @public */ badges() { - return new ItemList(); + const items = new ItemList(); + + if (this.isHidden()) { + items.add('hidden', ); + } + + return items; } /** diff --git a/framework/core/less/forum/DiscussionListItem.less b/framework/core/less/forum/DiscussionListItem.less index 55d2c735c..b2fce8db7 100644 --- a/framework/core/less/forum/DiscussionListItem.less +++ b/framework/core/less/forum/DiscussionListItem.less @@ -3,6 +3,12 @@ max-width: none; } } +.DiscussionListItem--hidden { + .DiscussionListItem-content { + opacity: 0.5; + } +} + .DiscussionListItem a { text-decoration: none; } diff --git a/framework/core/less/lib/Badge.less b/framework/core/less/lib/Badge.less index ffb555d11..1864d80d6 100755 --- a/framework/core/less/lib/Badge.less +++ b/framework/core/less/lib/Badge.less @@ -33,3 +33,7 @@ display: inline-block; } } + +.Badge--hidden { + background: #888; +} diff --git a/framework/core/less/lib/Dropdown.less b/framework/core/less/lib/Dropdown.less index 3c093ede0..c3b2c0e21 100755 --- a/framework/core/less/lib/Dropdown.less +++ b/framework/core/less/lib/Dropdown.less @@ -50,8 +50,8 @@ } &.disabled { - opacity: 0.5; - background: none; + opacity: 0.4; + background: none !important; } } > a, > button { diff --git a/framework/core/migrations/2015_09_20_224327_add_hide_to_discussions.php b/framework/core/migrations/2015_09_20_224327_add_hide_to_discussions.php new file mode 100644 index 000000000..0abd6caaa --- /dev/null +++ b/framework/core/migrations/2015_09_20_224327_add_hide_to_discussions.php @@ -0,0 +1,34 @@ +schema->table('discussions', function (Blueprint $table) { + $table->dateTime('hide_time')->nullable(); + $table->integer('hide_user_id')->unsigned()->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + $this->schema->table('discussions', function (Blueprint $table) { + $table->dropColumn(['hide_time', 'hide_user_id']); + }); + } +} diff --git a/framework/core/src/Api/Actions/Posts/UpdateAction.php b/framework/core/src/Api/Actions/Posts/UpdateAction.php index c119b6ab5..2187c3bcb 100644 --- a/framework/core/src/Api/Actions/Posts/UpdateAction.php +++ b/framework/core/src/Api/Actions/Posts/UpdateAction.php @@ -33,6 +33,7 @@ class UpdateAction extends SerializeResourceAction */ public $include = [ 'editUser' => true, + 'discussion' => true ]; /** diff --git a/framework/core/src/Api/Serializers/DiscussionSerializer.php b/framework/core/src/Api/Serializers/DiscussionSerializer.php index 39af34ee8..ddf0743de 100644 --- a/framework/core/src/Api/Serializers/DiscussionSerializer.php +++ b/framework/core/src/Api/Serializers/DiscussionSerializer.php @@ -27,9 +27,15 @@ class DiscussionSerializer extends DiscussionBasicSerializer 'lastPostNumber' => $discussion->last_post_number, 'canReply' => $discussion->can($this->actor, 'reply'), 'canRename' => $discussion->can($this->actor, 'rename'), - 'canDelete' => $discussion->can($this->actor, 'delete') + 'canDelete' => $discussion->can($this->actor, 'delete'), + 'canHide' => $discussion->can($this->actor, 'hide') ]; + if ($discussion->hide_time) { + $attributes['isHidden'] = true; + $attributes['hideTime'] = $discussion->hide_time->toRFC3339String(); + } + Discussion::setStateUser($this->actor); if ($state = $discussion->state) { @@ -41,4 +47,12 @@ class DiscussionSerializer extends DiscussionBasicSerializer return $attributes; } + + /** + * @return callable + */ + public function hideUser() + { + return $this->hasOne('Flarum\Api\Serializers\UserSerializer'); + } } diff --git a/framework/core/src/Core/Discussions/Commands/EditDiscussionHandler.php b/framework/core/src/Core/Discussions/Commands/EditDiscussionHandler.php index c1a600b79..93574d8e0 100644 --- a/framework/core/src/Core/Discussions/Commands/EditDiscussionHandler.php +++ b/framework/core/src/Core/Discussions/Commands/EditDiscussionHandler.php @@ -49,6 +49,16 @@ class EditDiscussionHandler $discussion->rename($attributes['title'], $actor); } + if (isset($attributes['isHidden'])) { + $discussion->assertCan($actor, 'hide'); + + if ($attributes['isHidden']) { + $discussion->hide($actor); + } else { + $discussion->restore(); + } + } + event(new DiscussionWillBeSaved($discussion, $actor, $data)); $discussion->save(); diff --git a/framework/core/src/Core/Discussions/Discussion.php b/framework/core/src/Core/Discussions/Discussion.php index 75e1e5fee..ae3374c5d 100644 --- a/framework/core/src/Core/Discussions/Discussion.php +++ b/framework/core/src/Core/Discussions/Discussion.php @@ -14,6 +14,8 @@ use Flarum\Core\Model; use Flarum\Events\DiscussionWasDeleted; use Flarum\Events\DiscussionWasStarted; use Flarum\Events\DiscussionWasRenamed; +use Flarum\Events\DiscussionWasHidden; +use Flarum\Events\DiscussionWasRestored; use Flarum\Events\PostWasDeleted; use Flarum\Events\ScopePostVisibility; use Flarum\Core\Posts\Post; @@ -68,7 +70,7 @@ class Discussion extends Model /** * {@inheritdoc} */ - protected $dates = ['start_time', 'last_time']; + protected $dates = ['start_time', 'last_time', 'hide_time']; /** * The user for which the state relationship should be loaded. @@ -147,6 +149,41 @@ class Discussion extends Model return $this; } + /** + * Hide the discussion. + * + * @param User $actor + * @return $this + */ + public function hide(User $actor = null) + { + if (! $this->hide_time) { + $this->hide_time = time(); + $this->hide_user_id = $actor ? $actor->id : null; + + $this->raise(new DiscussionWasHidden($this)); + } + + return $this; + } + + /** + * Restore the discussion. + * + * @return $this + */ + public function restore() + { + if ($this->hide_time !== null) { + $this->hide_time = null; + $this->hide_user_id = null; + + $this->raise(new DiscussionWasRestored($this)); + } + + return $this; + } + /** * Set the discussion's start post details. * diff --git a/framework/core/src/Core/Discussions/DiscussionsServiceProvider.php b/framework/core/src/Core/Discussions/DiscussionsServiceProvider.php index dc7f2c532..273aa0ae5 100644 --- a/framework/core/src/Core/Discussions/DiscussionsServiceProvider.php +++ b/framework/core/src/Core/Discussions/DiscussionsServiceProvider.php @@ -15,7 +15,7 @@ use Flarum\Core\Users\User; use Flarum\Events\ModelAllow; use Flarum\Events\ScopeModelVisibility; use Flarum\Events\RegisterDiscussionGambits; -use Flarum\Events\ScopeEmptyDiscussionVisibility; +use Flarum\Events\ScopeHiddenDiscussionVisibility; use Flarum\Support\ServiceProvider; use Flarum\Extend; use Illuminate\Contracts\Container\Container; @@ -58,12 +58,15 @@ class DiscussionsServiceProvider extends ServiceProvider $events->listen(ScopeModelVisibility::class, function (ScopeModelVisibility $event) { if ($event->model instanceof Discussion) { - if (! $event->actor->hasPermission('discussion.editPosts')) { - $event->query->where(function ($query) use ($event) { - $query->where('comments_count', '>', '0') - ->orWhere('start_user_id', $event->actor->id); + $user = $event->actor; - event(new ScopeEmptyDiscussionVisibility($query, $event->actor)); + if (! $user->hasPermission('discussion.hide')) { + $event->query->where(function ($query) use ($user) { + $query->whereNull('discussions.hide_time') + ->where('comments_count', '>', 0) + ->orWhere('start_user_id', $user->id); + + event(new ScopeHiddenDiscussionVisibility($query, $user, 'discussion.hide')); }); } } @@ -86,8 +89,10 @@ class DiscussionsServiceProvider extends ServiceProvider ->needs('Flarum\Core\Search\GambitManager') ->give(function (Container $app) { $gambits = new GambitManager($app); + $gambits->setFulltextGambit('Flarum\Core\Discussions\Search\Gambits\FulltextGambit'); $gambits->add('Flarum\Core\Discussions\Search\Gambits\AuthorGambit'); + $gambits->add('Flarum\Core\Discussions\Search\Gambits\HiddenGambit'); $gambits->add('Flarum\Core\Discussions\Search\Gambits\UnreadGambit'); event(new RegisterDiscussionGambits($gambits)); diff --git a/framework/core/src/Core/Discussions/Search/Gambits/HiddenGambit.php b/framework/core/src/Core/Discussions/Search/Gambits/HiddenGambit.php new file mode 100644 index 000000000..bd840857c --- /dev/null +++ b/framework/core/src/Core/Discussions/Search/Gambits/HiddenGambit.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Core\Discussions\Search\Gambits; + +use Flarum\Core\Discussions\Search\DiscussionSearch; +use Flarum\Core\Search\RegexGambit; +use Flarum\Core\Search\Search; +use LogicException; + +class HiddenGambit extends RegexGambit +{ + /** + * {@inheritdoc} + */ + protected $pattern = 'is:hidden'; + + /** + * {@inheritdoc} + */ + protected function conditions(Search $search, array $matches, $negate) + { + if (! $search instanceof DiscussionSearch) { + throw new LogicException('This gambit can only be applied on a DiscussionSearch'); + } + + $search->getQuery()->where(function ($query) use ($negate) { + if ($negate) { + $query->whereNull('hide_time')->where('comments_count', '>', 0); + } else { + $query->whereNotNull('hide_time')->orWhere('comments_count', 0); + } + }); + } +} diff --git a/framework/core/src/Events/DiscussionWasHidden.php b/framework/core/src/Events/DiscussionWasHidden.php new file mode 100644 index 000000000..9228bbb6e --- /dev/null +++ b/framework/core/src/Events/DiscussionWasHidden.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Events; + +use Flarum\Core\Discussions\Discussion; + +class DiscussionWasHidden +{ + /** + * The discussion that was hidden. + * + * @var Discussion + */ + public $discussion; + + /** + * @param Discussion $discussion + */ + public function __construct(Discussion $discussion) + { + $this->discussion = $discussion; + } +} diff --git a/framework/core/src/Events/DiscussionWasRestored.php b/framework/core/src/Events/DiscussionWasRestored.php new file mode 100644 index 000000000..cd3c02019 --- /dev/null +++ b/framework/core/src/Events/DiscussionWasRestored.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Events; + +use Flarum\Core\Discussions\Discussion; + +class DiscussionWasRestored +{ + /** + * The discussion that was restored. + * + * @var Discussion + */ + public $discussion; + + /** + * @param Discussion $discussion + */ + public function __construct(Discussion $discussion) + { + $this->discussion = $discussion; + } +} diff --git a/framework/core/src/Events/ScopeEmptyDiscussionVisibility.php b/framework/core/src/Events/ScopeHiddenDiscussionVisibility.php similarity index 66% rename from framework/core/src/Events/ScopeEmptyDiscussionVisibility.php rename to framework/core/src/Events/ScopeHiddenDiscussionVisibility.php index 1f0299e25..cacebde5d 100644 --- a/framework/core/src/Events/ScopeEmptyDiscussionVisibility.php +++ b/framework/core/src/Events/ScopeHiddenDiscussionVisibility.php @@ -14,9 +14,9 @@ use Flarum\Core\Users\User; use Illuminate\Database\Eloquent\Builder; /** - * The `ScopeEmptyDiscussionVisibility` event + * The `ScopeHiddenDiscussionVisibility` event */ -class ScopeEmptyDiscussionVisibility +class ScopeHiddenDiscussionVisibility { /** * @var Builder @@ -28,13 +28,20 @@ class ScopeEmptyDiscussionVisibility */ public $actor; + /** + * @var string + */ + public $permission; + /** * @param Builder $query * @param User $actor + * @param string $permission */ - public function __construct(Builder $query, User $actor) + public function __construct(Builder $query, User $actor, $permission) { $this->query = $query; $this->actor = $actor; + $this->permission = $permission; } }