Allow discussions to be hidden and restored

This commit is contained in:
Toby Zerner 2015-09-22 17:48:21 +09:30
parent c7ed189cf3
commit 264725d872
19 changed files with 321 additions and 23 deletions

View File

@ -4,6 +4,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased][unreleased] ## [Unreleased][unreleased]
### Added ### Added
- Allow discussions to be hidden and restored.
- External authentication (social login) API. - External authentication (social login) API.
- API to set asset compiler filename. - API to set asset compiler filename.
- Migration generator, available via generate:migration console command. - Migration generator, available via generate:migration console command.

View File

@ -13,6 +13,7 @@ import SubtreeRetainer from 'flarum/utils/SubtreeRetainer';
import DiscussionControls from 'flarum/utils/DiscussionControls'; import DiscussionControls from 'flarum/utils/DiscussionControls';
import slidable from 'flarum/utils/slidable'; import slidable from 'flarum/utils/slidable';
import extractText from 'flarum/utils/extractText'; import extractText from 'flarum/utils/extractText';
import classList from 'flarum/utils/classList';
/** /**
* The `DiscussionListItem` component shows a single discussion in the * 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() { view() {
const retain = this.subtree.retain(); 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 jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1);
const relevantPosts = this.props.params.q ? discussion.relevantPosts() : []; const relevantPosts = this.props.params.q ? discussion.relevantPosts() : [];
const controls = DiscussionControls.controls(discussion, this).toArray(); const controls = DiscussionControls.controls(discussion, this).toArray();
const attrs = this.attrs();
return ( return (
<div className={'DiscussionListItem ' + (this.active() ? 'active' : '')}> <div {...attrs}>
{controls.length ? Dropdown.component({ {controls.length ? Dropdown.component({
icon: 'ellipsis-v', icon: 'ellipsis-v',

View File

@ -105,10 +105,25 @@ export default {
destructiveControls(discussion) { destructiveControls(discussion) {
const items = new ItemList(); 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({ items.add('delete', Button.component({
icon: 'times', icon: 'times',
children: app.trans('core.delete'), children: app.trans('core.delete_forever'),
onclick: this.deleteAction.bind(discussion) onclick: this.deleteAction.bind(discussion)
})); }));
} }
@ -173,13 +188,35 @@ export default {
return deferred.promise; 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. * Delete the discussion after confirming with the user.
*
* @return {Promise}
*/ */
deleteAction() { deleteAction() {
if (confirm(extractText(app.trans('core.confirm_delete_discussion')))) { if (confirm(extractText(app.trans('core.confirm_delete_discussion')))) {
this.delete();
// If there is a discussion list in the cache, remove this discussion. // If there is a discussion list in the cache, remove this discussion.
if (app.cache.discussionList) { if (app.cache.discussionList) {
app.cache.discussionList.removeDiscussion(this); app.cache.discussionList.removeDiscussion(this);
@ -190,11 +227,15 @@ export default {
if (app.viewingDiscussion(this)) { if (app.viewingDiscussion(this)) {
app.history.back(); app.history.back();
} }
return this.delete();
} }
}, },
/** /**
* Rename the discussion. * Rename the discussion.
*
* @return {Promise}
*/ */
renameAction() { renameAction() {
const currentTitle = this.title(); const currentTitle = this.title();
@ -204,7 +245,7 @@ export default {
// save has completed, update the post stream as there will be a new post // save has completed, update the post stream as there will be a new post
// indicating that the discussion was renamed. // indicating that the discussion was renamed.
if (title && title !== currentTitle) { if (title && title !== currentTitle) {
this.save({title}).then(() => { return this.save({title}).then(() => {
if (app.viewingDiscussion(this)) { if (app.viewingDiscussion(this)) {
app.current.stream.update(); app.current.stream.update();
} }

View File

@ -114,25 +114,34 @@ export default {
/** /**
* Hide a post. * Hide a post.
*
* @return {Promise}
*/ */
hideAction() { hideAction() {
this.save({ isHidden: true });
this.pushAttributes({ hideTime: new Date(), hideUser: app.session.user }); this.pushAttributes({ hideTime: new Date(), hideUser: app.session.user });
return this.save({ isHidden: true }).then(() => m.redraw());
}, },
/** /**
* Restore a post. * Restore a post.
*
* @return {Promise}
*/ */
restoreAction() { restoreAction() {
this.save({ isHidden: false });
this.pushAttributes({ hideTime: null, hideUser: null }); this.pushAttributes({ hideTime: null, hideUser: null });
return this.save({ isHidden: false }).then(() => m.redraw());
}, },
/** /**
* Delete a post. * Delete a post.
*
* @return {Promise}
*/ */
deleteAction() { deleteAction() {
this.delete();
this.discussion().removePost(this.id()); this.discussion().removePost(this.id());
return this.delete();
} }
}; };

View File

@ -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 * - `type` The type of badge this is. This will be used to give the badge a
* class name of `Badge--{type}`. * class name of `Badge--{type}`.
* - `icon` The name of an icon to show inside the badge. * - `icon` The name of an icon to show inside the badge.
* - `label`
* *
* All other props will be assigned as attributes on the badge element. * All other props will be assigned as attributes on the badge element.
*/ */

View File

@ -3,6 +3,7 @@ import mixin from 'flarum/utils/mixin';
import computed from 'flarum/utils/computed'; import computed from 'flarum/utils/computed';
import ItemList from 'flarum/utils/ItemList'; import ItemList from 'flarum/utils/ItemList';
import { slug } from 'flarum/utils/string'; import { slug } from 'flarum/utils/string';
import Badge from 'flarum/components/Badge';
export default class Discussion extends mixin(Model, { export default class Discussion extends mixin(Model, {
title: Model.attribute('title'), title: Model.attribute('title'),
@ -27,8 +28,13 @@ export default class Discussion extends mixin(Model, {
isUnread: computed('unreadCount', unreadCount => !!unreadCount), isUnread: computed('unreadCount', unreadCount => !!unreadCount),
isRead: computed('unreadCount', unreadCount => app.session.user && !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'), canReply: Model.attribute('canReply'),
canRename: Model.attribute('canRename'), canRename: Model.attribute('canRename'),
canHide: Model.attribute('canHide'),
canDelete: Model.attribute('canDelete') canDelete: Model.attribute('canDelete')
}) { }) {
/** /**
@ -75,7 +81,13 @@ export default class Discussion extends mixin(Model, {
* @public * @public
*/ */
badges() { badges() {
return new ItemList(); const items = new ItemList();
if (this.isHidden()) {
items.add('hidden', <Badge type="hidden" icon="trash" label="Hidden"/>);
}
return items;
} }
/** /**

View File

@ -3,6 +3,12 @@
max-width: none; max-width: none;
} }
} }
.DiscussionListItem--hidden {
.DiscussionListItem-content {
opacity: 0.5;
}
}
.DiscussionListItem a { .DiscussionListItem a {
text-decoration: none; text-decoration: none;
} }

View File

@ -33,3 +33,7 @@
display: inline-block; display: inline-block;
} }
} }
.Badge--hidden {
background: #888;
}

View File

@ -50,8 +50,8 @@
} }
&.disabled { &.disabled {
opacity: 0.5; opacity: 0.4;
background: none; background: none !important;
} }
} }
> a, > button { > a, > button {

View File

@ -0,0 +1,34 @@
<?php
namespace Flarum\Migrations\Core;
use Illuminate\Database\Schema\Blueprint;
use Flarum\Migrations\Migration;
class AddHideToDiscussions extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$this->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']);
});
}
}

View File

@ -33,6 +33,7 @@ class UpdateAction extends SerializeResourceAction
*/ */
public $include = [ public $include = [
'editUser' => true, 'editUser' => true,
'discussion' => true
]; ];
/** /**

View File

@ -27,9 +27,15 @@ class DiscussionSerializer extends DiscussionBasicSerializer
'lastPostNumber' => $discussion->last_post_number, 'lastPostNumber' => $discussion->last_post_number,
'canReply' => $discussion->can($this->actor, 'reply'), 'canReply' => $discussion->can($this->actor, 'reply'),
'canRename' => $discussion->can($this->actor, 'rename'), '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); Discussion::setStateUser($this->actor);
if ($state = $discussion->state) { if ($state = $discussion->state) {
@ -41,4 +47,12 @@ class DiscussionSerializer extends DiscussionBasicSerializer
return $attributes; return $attributes;
} }
/**
* @return callable
*/
public function hideUser()
{
return $this->hasOne('Flarum\Api\Serializers\UserSerializer');
}
} }

View File

@ -49,6 +49,16 @@ class EditDiscussionHandler
$discussion->rename($attributes['title'], $actor); $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)); event(new DiscussionWillBeSaved($discussion, $actor, $data));
$discussion->save(); $discussion->save();

View File

@ -14,6 +14,8 @@ use Flarum\Core\Model;
use Flarum\Events\DiscussionWasDeleted; use Flarum\Events\DiscussionWasDeleted;
use Flarum\Events\DiscussionWasStarted; use Flarum\Events\DiscussionWasStarted;
use Flarum\Events\DiscussionWasRenamed; use Flarum\Events\DiscussionWasRenamed;
use Flarum\Events\DiscussionWasHidden;
use Flarum\Events\DiscussionWasRestored;
use Flarum\Events\PostWasDeleted; use Flarum\Events\PostWasDeleted;
use Flarum\Events\ScopePostVisibility; use Flarum\Events\ScopePostVisibility;
use Flarum\Core\Posts\Post; use Flarum\Core\Posts\Post;
@ -68,7 +70,7 @@ class Discussion extends Model
/** /**
* {@inheritdoc} * {@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. * The user for which the state relationship should be loaded.
@ -147,6 +149,41 @@ class Discussion extends Model
return $this; 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. * Set the discussion's start post details.
* *

View File

@ -15,7 +15,7 @@ use Flarum\Core\Users\User;
use Flarum\Events\ModelAllow; use Flarum\Events\ModelAllow;
use Flarum\Events\ScopeModelVisibility; use Flarum\Events\ScopeModelVisibility;
use Flarum\Events\RegisterDiscussionGambits; use Flarum\Events\RegisterDiscussionGambits;
use Flarum\Events\ScopeEmptyDiscussionVisibility; use Flarum\Events\ScopeHiddenDiscussionVisibility;
use Flarum\Support\ServiceProvider; use Flarum\Support\ServiceProvider;
use Flarum\Extend; use Flarum\Extend;
use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Container\Container;
@ -58,12 +58,15 @@ class DiscussionsServiceProvider extends ServiceProvider
$events->listen(ScopeModelVisibility::class, function (ScopeModelVisibility $event) { $events->listen(ScopeModelVisibility::class, function (ScopeModelVisibility $event) {
if ($event->model instanceof Discussion) { if ($event->model instanceof Discussion) {
if (! $event->actor->hasPermission('discussion.editPosts')) { $user = $event->actor;
$event->query->where(function ($query) use ($event) {
$query->where('comments_count', '>', '0')
->orWhere('start_user_id', $event->actor->id);
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') ->needs('Flarum\Core\Search\GambitManager')
->give(function (Container $app) { ->give(function (Container $app) {
$gambits = new GambitManager($app); $gambits = new GambitManager($app);
$gambits->setFulltextGambit('Flarum\Core\Discussions\Search\Gambits\FulltextGambit'); $gambits->setFulltextGambit('Flarum\Core\Discussions\Search\Gambits\FulltextGambit');
$gambits->add('Flarum\Core\Discussions\Search\Gambits\AuthorGambit'); $gambits->add('Flarum\Core\Discussions\Search\Gambits\AuthorGambit');
$gambits->add('Flarum\Core\Discussions\Search\Gambits\HiddenGambit');
$gambits->add('Flarum\Core\Discussions\Search\Gambits\UnreadGambit'); $gambits->add('Flarum\Core\Discussions\Search\Gambits\UnreadGambit');
event(new RegisterDiscussionGambits($gambits)); event(new RegisterDiscussionGambits($gambits));

View File

@ -0,0 +1,42 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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);
}
});
}
}

View File

@ -0,0 +1,31 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}
}

View File

@ -0,0 +1,31 @@
<?php
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* 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;
}
}

View File

@ -14,9 +14,9 @@ use Flarum\Core\Users\User;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
/** /**
* The `ScopeEmptyDiscussionVisibility` event * The `ScopeHiddenDiscussionVisibility` event
*/ */
class ScopeEmptyDiscussionVisibility class ScopeHiddenDiscussionVisibility
{ {
/** /**
* @var Builder * @var Builder
@ -28,13 +28,20 @@ class ScopeEmptyDiscussionVisibility
*/ */
public $actor; public $actor;
/**
* @var string
*/
public $permission;
/** /**
* @param Builder $query * @param Builder $query
* @param User $actor * @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->query = $query;
$this->actor = $actor; $this->actor = $actor;
$this->permission = $permission;
} }
} }