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]
### 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.

View File

@ -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 (
<div className={'DiscussionListItem ' + (this.active() ? 'active' : '')}>
<div {...attrs}>
{controls.length ? Dropdown.component({
icon: 'ellipsis-v',

View File

@ -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();
}

View File

@ -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();
}
};

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
* 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.
*/

View File

@ -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', <Badge type="hidden" icon="trash" label="Hidden"/>);
}
return items;
}
/**

View File

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

View File

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

View File

@ -50,8 +50,8 @@
}
&.disabled {
opacity: 0.5;
background: none;
opacity: 0.4;
background: none !important;
}
}
> 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 = [
'editUser' => true,
'discussion' => true
];
/**

View File

@ -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');
}
}

View File

@ -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();

View File

@ -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.
*

View File

@ -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));

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;
/**
* 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;
}
}