Initial commit

This commit is contained in:
Toby Zerner 2015-09-04 13:26:51 +09:30
commit cb6347ef6a
44 changed files with 1633 additions and 0 deletions

View File

@ -0,0 +1,19 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_size = 2
[*.{diff,md}]
trim_trailing_whitespace = false
[*.php]
indent_size = 4

View File

@ -0,0 +1,5 @@
**/bower_components/**/*
**/node_modules/**/*
vendor/**/*
**/Gulpfile.js
**/dist/**/*

175
extensions/flags/.eslintrc Normal file
View File

@ -0,0 +1,175 @@
{
"parser": "babel-eslint", // https://github.com/babel/babel-eslint
"env": { // http://eslint.org/docs/user-guide/configuring.html#specifying-environments
"browser": true // browser global variables
},
"ecmaFeatures": {
"arrowFunctions": true,
"blockBindings": true,
"classes": true,
"defaultParams": true,
"destructuring": true,
"forOf": true,
"generators": false,
"modules": true,
"objectLiteralComputedProperties": true,
"objectLiteralDuplicateProperties": false,
"objectLiteralShorthandMethods": true,
"objectLiteralShorthandProperties": true,
"spread": true,
"superInFunctions": true,
"templateStrings": true,
"jsx": true
},
"globals": {
"m": true,
"app": true,
"$": true,
"moment": true
},
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-vars": 1,
/**
* Strict mode
*/
// babel inserts "use strict"; for us
"strict": [2, "never"], // http://eslint.org/docs/rules/strict
/**
* ES6
*/
"no-var": 2, // http://eslint.org/docs/rules/no-var
"prefer-const": 2, // http://eslint.org/docs/rules/prefer-const
/**
* Variables
*/
"no-shadow": 2, // http://eslint.org/docs/rules/no-shadow
"no-shadow-restricted-names": 2, // http://eslint.org/docs/rules/no-shadow-restricted-names
"no-unused-vars": [2, { // http://eslint.org/docs/rules/no-unused-vars
"vars": "local",
"args": "after-used"
}],
"no-use-before-define": 2, // http://eslint.org/docs/rules/no-use-before-define
/**
* Possible errors
*/
"comma-dangle": [2, "never"], // http://eslint.org/docs/rules/comma-dangle
"no-cond-assign": [2, "always"], // http://eslint.org/docs/rules/no-cond-assign
"no-console": 1, // http://eslint.org/docs/rules/no-console
"no-debugger": 1, // http://eslint.org/docs/rules/no-debugger
"no-alert": 1, // http://eslint.org/docs/rules/no-alert
"no-constant-condition": 1, // http://eslint.org/docs/rules/no-constant-condition
"no-dupe-keys": 2, // http://eslint.org/docs/rules/no-dupe-keys
"no-duplicate-case": 2, // http://eslint.org/docs/rules/no-duplicate-case
"no-empty": 2, // http://eslint.org/docs/rules/no-empty
"no-ex-assign": 2, // http://eslint.org/docs/rules/no-ex-assign
"no-extra-boolean-cast": 0, // http://eslint.org/docs/rules/no-extra-boolean-cast
"no-extra-semi": 2, // http://eslint.org/docs/rules/no-extra-semi
"no-func-assign": 2, // http://eslint.org/docs/rules/no-func-assign
"no-inner-declarations": 2, // http://eslint.org/docs/rules/no-inner-declarations
"no-invalid-regexp": 2, // http://eslint.org/docs/rules/no-invalid-regexp
"no-irregular-whitespace": 2, // http://eslint.org/docs/rules/no-irregular-whitespace
"no-obj-calls": 2, // http://eslint.org/docs/rules/no-obj-calls
"no-reserved-keys": 2, // http://eslint.org/docs/rules/no-reserved-keys
"no-sparse-arrays": 2, // http://eslint.org/docs/rules/no-sparse-arrays
"no-unreachable": 2, // http://eslint.org/docs/rules/no-unreachable
"use-isnan": 2, // http://eslint.org/docs/rules/use-isnan
"block-scoped-var": 2, // http://eslint.org/docs/rules/block-scoped-var
/**
* Best practices
*/
"consistent-return": 2, // http://eslint.org/docs/rules/consistent-return
"curly": [2, "multi-line"], // http://eslint.org/docs/rules/curly
"default-case": 2, // http://eslint.org/docs/rules/default-case
"dot-notation": [2, { // http://eslint.org/docs/rules/dot-notation
"allowKeywords": true
}],
"eqeqeq": 2, // http://eslint.org/docs/rules/eqeqeq
"no-caller": 2, // http://eslint.org/docs/rules/no-caller
"no-else-return": 2, // http://eslint.org/docs/rules/no-else-return
"no-eq-null": 2, // http://eslint.org/docs/rules/no-eq-null
"no-eval": 2, // http://eslint.org/docs/rules/no-eval
"no-extend-native": 2, // http://eslint.org/docs/rules/no-extend-native
"no-extra-bind": 2, // http://eslint.org/docs/rules/no-extra-bind
"no-fallthrough": 2, // http://eslint.org/docs/rules/no-fallthrough
"no-floating-decimal": 2, // http://eslint.org/docs/rules/no-floating-decimal
"no-implied-eval": 2, // http://eslint.org/docs/rules/no-implied-eval
"no-lone-blocks": 2, // http://eslint.org/docs/rules/no-lone-blocks
"no-loop-func": 2, // http://eslint.org/docs/rules/no-loop-func
"no-multi-str": 2, // http://eslint.org/docs/rules/no-multi-str
"no-native-reassign": 2, // http://eslint.org/docs/rules/no-native-reassign
"no-new": 2, // http://eslint.org/docs/rules/no-new
"no-new-func": 2, // http://eslint.org/docs/rules/no-new-func
"no-new-wrappers": 2, // http://eslint.org/docs/rules/no-new-wrappers
"no-octal": 2, // http://eslint.org/docs/rules/no-octal
"no-octal-escape": 2, // http://eslint.org/docs/rules/no-octal-escape
"no-param-reassign": 2, // http://eslint.org/docs/rules/no-param-reassign
"no-proto": 2, // http://eslint.org/docs/rules/no-proto
"no-redeclare": 2, // http://eslint.org/docs/rules/no-redeclare
"no-return-assign": 2, // http://eslint.org/docs/rules/no-return-assign
"no-self-compare": 2, // http://eslint.org/docs/rules/no-self-compare
"no-sequences": 2, // http://eslint.org/docs/rules/no-sequences
"no-throw-literal": 2, // http://eslint.org/docs/rules/no-throw-literal
"no-with": 2, // http://eslint.org/docs/rules/no-with
"radix": 2, // http://eslint.org/docs/rules/radix
"vars-on-top": 2, // http://eslint.org/docs/rules/vars-on-top
"wrap-iife": [2, "any"], // http://eslint.org/docs/rules/wrap-iife
"yoda": 2, // http://eslint.org/docs/rules/yoda
/**
* Style
*/
"indent": [2, 2], // http://eslint.org/docs/rules/indent
"brace-style": [2, // http://eslint.org/docs/rules/brace-style
"1tbs", {
"allowSingleLine": true
}],
"quotes": [
2, "single", "avoid-escape" // http://eslint.org/docs/rules/quotes
],
"camelcase": [2, { // http://eslint.org/docs/rules/camelcase
"properties": "never"
}],
"comma-spacing": [2, { // http://eslint.org/docs/rules/comma-spacing
"before": false,
"after": true
}],
"comma-style": [2, "last"], // http://eslint.org/docs/rules/comma-style
"eol-last": 2, // http://eslint.org/docs/rules/eol-last
"key-spacing": [2, { // http://eslint.org/docs/rules/key-spacing
"beforeColon": false,
"afterColon": true
}],
"new-cap": [2, { // http://eslint.org/docs/rules/new-cap
"newIsCap": true
}],
"no-multiple-empty-lines": [2, { // http://eslint.org/docs/rules/no-multiple-empty-lines
"max": 2
}],
"no-new-object": 2, // http://eslint.org/docs/rules/no-new-object
"no-spaced-func": 2, // http://eslint.org/docs/rules/no-spaced-func
"no-trailing-spaces": 2, // http://eslint.org/docs/rules/no-trailing-spaces
"no-wrap-func": 2, // http://eslint.org/docs/rules/no-wrap-func
"no-underscore-dangle": 0, // http://eslint.org/docs/rules/no-underscore-dangle
"one-var": [2, "never"], // http://eslint.org/docs/rules/one-var
"padded-blocks": [2, "never"], // http://eslint.org/docs/rules/padded-blocks
"semi": [2, "always"], // http://eslint.org/docs/rules/semi
"semi-spacing": [2, { // http://eslint.org/docs/rules/semi-spacing
"before": false,
"after": true
}],
"space-after-keywords": 2, // http://eslint.org/docs/rules/space-after-keywords
"space-before-blocks": 2, // http://eslint.org/docs/rules/space-before-blocks
"space-before-function-paren": [2, "never"], // http://eslint.org/docs/rules/space-before-function-paren
"space-infix-ops": 2, // http://eslint.org/docs/rules/space-infix-ops
"space-return-throw-case": 2, // http://eslint.org/docs/rules/space-return-throw-case
"spaced-line-comment": 2, // http://eslint.org/docs/rules/spaced-line-comment
}
}

4
extensions/flags/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/vendor
composer.phar
.DS_Store
Thumbs.db

26
extensions/flags/.php_cs Executable file
View File

@ -0,0 +1,26 @@
<?php
$header = <<<EOF
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.
EOF;
Symfony\CS\Fixer\Contrib\HeaderCommentFixer::setHeader($header);
$finder = Symfony\CS\Finder\DefaultFinder::create()
->exclude('js')
->exclude('less')
->in(__DIR__);
return Symfony\CS\Config\Config::create()
->level(Symfony\CS\FixerInterface::PSR2_LEVEL)
->fixers([
'short_array_syntax',
'header_comment',
'-psr0'
])
->finder($finder);

View File

@ -0,0 +1,23 @@
language: php
php:
- 5.5
- 5.6
matrix:
allow_failures:
- php: hhvm
fast_finish: true
before_script:
- curl -s http://getcomposer.org/installer | php
- php composer.phar install
script:
- php composer.phar style
notifications:
email:
on_failure: change
sudo: false

21
extensions/flags/LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2015 Toby Zerner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,14 @@
<?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.
*/
require __DIR__.'/vendor/autoload.php';
return 'Flarum\Reports\Extension';

View File

@ -0,0 +1,10 @@
{
"autoload": {
"psr-4": {
"Flarum\\Reports\\": "src/"
}
},
"scripts": {
"style": "phpcs --standard=PSR2 -np src"
}
}

View File

@ -0,0 +1,25 @@
{
"name": "reports",
"title": "Reports",
"description": "Allow users to report posts for moderator review.",
"keywords": [],
"version": "0.1.0-beta.2",
"author": {
"name": "Toby Zerner",
"email": "toby@flarum.org",
"homepage": "http://tobyzerner.com"
},
"license": "MIT",
"require": {
"flarum": ">=0.1.0-beta.2"
},
"support": {
"source": "https://github.com/flarum/reports",
"issues": "https://github.com/flarum/core/issues"
},
"icon": {
"name": "flag",
"backgroundColor": "#e92693",
"color": "#fff"
}
}

3
extensions/flags/js/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
bower_components
node_modules
dist

View File

@ -0,0 +1,7 @@
var gulp = require('flarum-gulp');
gulp({
modules: {
'reports': 'src/**/*.js'
}
});

View File

@ -0,0 +1,7 @@
{
"private": true,
"devDependencies": {
"gulp": "^3.8.11",
"flarum-gulp": "^0.1.0"
}
}

View File

@ -0,0 +1,19 @@
import { extend } from 'flarum/extend';
import app from 'flarum/app';
import PermissionGrid from 'flarum/components/PermissionGrid';
app.initializers.add('reports', () => {
extend(PermissionGrid.prototype, 'moderateItems', items => {
items.add('viewReports', {
label: 'View reported posts',
permission: 'discussion.viewReports'
});
});
extend(PermissionGrid.prototype, 'replyItems', items => {
items.add('reportPosts', {
label: 'Report posts',
permission: 'discussion.reportPosts'
});
});
});

View File

@ -0,0 +1,7 @@
var gulp = require('flarum-gulp');
gulp({
modules: {
'reports': 'src/**/*.js'
}
});

View File

@ -0,0 +1,7 @@
{
"private": true,
"devDependencies": {
"gulp": "^3.8.11",
"flarum-gulp": "^0.1.0"
}
}

View File

@ -0,0 +1,16 @@
import { extend } from 'flarum/extend';
import app from 'flarum/app';
import PostControls from 'flarum/utils/PostControls';
import Button from 'flarum/components/Button';
import ReportPostModal from 'reports/components/ReportPostModal';
export default function() {
extend(PostControls, 'userControls', function(items, post) {
if (post.isHidden() || post.contentType() !== 'comment' || !post.canReport() || post.user() === app.session.user) return;
items.add('report',
<Button icon="flag" onclick={() => app.modal.show(new ReportPostModal({post}))}>Report</Button>
);
});
}

View File

@ -0,0 +1,12 @@
import { extend } from 'flarum/extend';
import app from 'flarum/app';
import HeaderSecondary from 'flarum/components/HeaderSecondary';
import ReportsDropdown from 'reports/components/ReportsDropdown';
export default function() {
extend(HeaderSecondary.prototype, 'items', function(items) {
if (app.forum.attribute('canViewReports')) {
items.add('reports', <ReportsDropdown/>, 15);
}
});
}

View File

@ -0,0 +1,134 @@
import { extend } from 'flarum/extend';
import app from 'flarum/app';
import CommentPost from 'flarum/components/CommentPost';
import Button from 'flarum/components/Button';
import punctuate from 'flarum/helpers/punctuate';
import username from 'flarum/helpers/username';
import ItemList from 'flarum/utils/ItemList';
import PostControls from 'flarum/utils/PostControls';
export default function() {
extend(CommentPost.prototype, 'attrs', function(attrs) {
if (this.props.post.reports().length) {
attrs.className += ' Post--reported';
}
});
CommentPost.prototype.dismissReport = function(data) {
const post = this.props.post;
delete post.data.relationships.reports;
this.subtree.invalidate();
if (app.cache.reports) {
app.cache.reports.some((report, i) => {
if (report.post() === post) {
app.cache.reports.splice(i, 1);
if (app.cache.reportIndex === post) {
let next = app.cache.reports[i];
if (!next) next = app.cache.reports[0];
if (next) {
const nextPost = next.post();
app.cache.reportIndex = nextPost;
m.route(app.route.post(nextPost));
}
}
return true;
}
});
}
return app.request({
url: app.forum.attribute('apiUrl') + post.apiEndpoint() + '/reports',
method: 'DELETE',
data
});
};
CommentPost.prototype.reportActionItems = function() {
const items = new ItemList();
if (this.props.post.isHidden()) {
if (this.props.post.canDelete()) {
items.add('delete',
<Button className="Button"
icon="trash-o"
onclick={() => {
this.dismissReport().then(() => {
PostControls.deleteAction.apply(this.props.post);
m.redraw();
});
}}>
Delete Forever
</Button>,
100
);
}
} else {
items.add('hide',
<Button className="Button"
icon="trash-o"
onclick={() => {
this.dismissReport().then(() => {
PostControls.hideAction.apply(this.props.post);
m.redraw();
});
}}>
Delete Post
</Button>,
100
);
}
items.add('dismiss', <Button className="Button Button--icon Button--link" icon="times" onclick={this.dismissReport.bind(this)}>Dismiss Report</Button>, -100);
return items;
};
extend(CommentPost.prototype, 'content', function(vdom) {
const post = this.props.post;
const reports = post.reports();
if (!reports.length) return;
if (post.isHidden()) this.revealContent = true;
const users = reports.map(report => {
const user = report.user();
return user
? <a href={app.route.user(user)} config={m.route}>{username(user)}</a>
: report.reporter();
});
const usedReasons = [];
const reasons = reports.map(report => report.reason()).filter(reason => {
if (reason && usedReasons.indexOf(reason) === -1) {
usedReasons.push(reason);
return true;
}
});
const details = reports.map(report => report.reasonDetail()).filter(detail => detail);
vdom.unshift(
<div className="Post-reported">
<div className="Post-reported-summary">
{app.trans(reasons.length ? 'reports.reported_by_with_reason' : 'reports.reported_by', {
reasons: punctuate(reasons.map(reason => app.trans('reports.reason_' + reason, undefined, reason))),
users: punctuate(users)
})}
{details.map(detail => <div className="Post-reported-detail">{detail}</div>)}
</div>
<div className="Post-reported-actions">
{this.reportActionItems().toArray()}
</div>
</div>
);
});
}

View File

@ -0,0 +1,83 @@
import Component from 'flarum/Component';
import LoadingIndicator from 'flarum/components/LoadingIndicator';
import avatar from 'flarum/helpers/avatar';
import username from 'flarum/helpers/username';
import icon from 'flarum/helpers/icon';
import humanTime from 'flarum/helpers/humanTime';
export default class ReportList extends Component {
constructor(...args) {
super(...args);
/**
* Whether or not the notifications are loading.
*
* @type {Boolean}
*/
this.loading = false;
}
view() {
const reports = app.cache.reports || [];
return (
<div className="NotificationList ReportList">
<div className="NotificationList-header">
<h4 className="App-titleControl App-titleControl--text">Reported Posts</h4>
</div>
<div className="NotificationList-content">
<ul className="NotificationGroup-content">
{reports.length
? reports.map(report => {
const post = report.post();
return (
<li>
<a href={app.route.post(post)} className="Notification Report" config={function(element, isInitialized) {
m.route.apply(this, arguments);
if (!isInitialized) $(element).on('click', () => app.cache.reportIndex = post);
}}>
{avatar(post.user())}
{icon('flag', {className: 'Notification-icon'})}
<span className="Notification-content">
{username(post.user())} in <em>{post.discussion().title()}</em>
</span>
{humanTime(report.time())}
<div className="Notification-excerpt">
{post.contentPlain()}
</div>
</a>
</li>
);
})
: !this.loading
? <div className="NotificationList-empty">{app.trans('reports.no_reports')}</div>
: LoadingIndicator.component({className: 'LoadingIndicator--block'})}
</ul>
</div>
</div>
);
}
/**
* Load reports into the application's cache if they haven't already
* been loaded.
*/
load() {
if (app.cache.reports && !app.forum.attribute('unreadReportsCount')) {
return;
}
this.loading = true;
m.redraw();
app.store.find('reports').then(reports => {
app.forum.pushAttributes({unreadReportsCount: 0});
app.cache.reports = reports.sort((a, b) => b.time() - a.time());
this.loading = false;
m.redraw();
});
}
}

View File

@ -0,0 +1,85 @@
import Modal from 'flarum/components/Modal';
import Button from 'flarum/components/Button';
export default class ReportPostModal extends Modal {
constructor(...args) {
super(...args);
this.reason = m.prop('');
this.reasonDetail = m.prop('');
}
className() {
return 'ReportPostModal Modal--small';
}
title() {
return 'Report Post';
}
content() {
return (
<div className="Modal-body">
<div className="Form">
<div className="Form-group">
<label>Choose a Reason</label>
<div>
<label className="checkbox">
<input type="radio" name="reason" checked={this.reason() === 'off_topic'} value="off_topic" onclick={m.withAttr('value', this.reason)}/>
Off-topic
</label>
<label className="checkbox">
<input type="radio" name="reason" checked={this.reason() === 'inappropriate'} value="inappropriate" onclick={m.withAttr('value', this.reason)}/>
Inappropriate
</label>
<label className="checkbox">
<input type="radio" name="reason" checked={this.reason() === 'spam'} value="spam" onclick={m.withAttr('value', this.reason)}/>
Spam
</label>
<label className="checkbox">
<input type="radio" name="reason" checked={this.reason() === 'other'} value="other" onclick={m.withAttr('value', this.reason)}/>
Other
{this.reason() === 'other' ? (
<textarea className="FormControl" value={this.reasonDetail()} oninput={m.withAttr('value', this.reasonDetail)}></textarea>
) : ''}
</label>
</div>
</div>
<div className="Form-group">
{Button.component({
children: 'Report Post',
className: 'Button Button--primary',
loading: this.loading,
disabled: !this.reason()
})}
</div>
</div>
</div>
);
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
app.store.createRecord('reports').save({
reason: this.reason() === 'other' ? null : this.reason(),
reasonDetail: this.reasonDetail(),
relationships: {
user: app.session.user,
post: this.props.post
}
}).then(
() => this.hide(),
() => {
this.loading = false;
m.redraw();
}
);
}
}

View File

@ -0,0 +1,26 @@
import NotificationsDropdown from 'flarum/components/NotificationsDropdown';
import ReportList from 'reports/components/ReportList';
export default class ReportsDropdown extends NotificationsDropdown {
static initProps(props) {
props.label = props.label || 'Reports';
props.icon = props.icon || 'flag';
super.initProps(props);
}
constructor(...args) {
super(...args);
this.list = new ReportList();
}
goToRoute() {
m.route(app.route('reports'));
}
getUnreadCount() {
return app.forum.attribute('unreadReportsCount');
}
}

View File

@ -0,0 +1,24 @@
import Page from 'flarum/components/Page';
import ReportList from 'reports/components/ReportList';
/**
* The `ReportsPage` component shows the reports list. It is only
* used on mobile devices where the reports dropdown is within the drawer.
*/
export default class ReportsPage extends Page {
constructor(...args) {
super(...args);
app.history.push('reports');
this.list = new ReportList();
this.list.load();
this.bodyClass = 'App--reports';
}
view() {
return <div className="ReportsPage">{this.list.render()}</div>;
}
}

View File

@ -0,0 +1,21 @@
import app from 'flarum/app';
import Model from 'flarum/Model';
import Report from 'reports/models/Report';
import ReportsPage from 'reports/components/ReportsPage';
import addReportControl from 'reports/addReportControl';
import addReportsDropdown from 'reports/addReportsDropdown';
import addReportsToPosts from 'reports/addReportsToPosts';
app.initializers.add('reports', () => {
app.store.models.posts.prototype.reports = Model.hasMany('reports');
app.store.models.posts.prototype.canReport = Model.attribute('canReport');
app.store.models.reports = Report;
app.routes.reports = {path: '/reports', component: <ReportsPage/>};
addReportControl();
addReportsDropdown();
addReportsToPosts();
});

View File

@ -0,0 +1,12 @@
import Model from 'flarum/Model';
import mixin from 'flarum/utils/mixin';
export default class Report extends mixin(Model, {
reporter: Model.attribute('reporter'),
reason: Model.attribute('reason'),
reasonDetail: Model.attribute('reasonDetail'),
time: Model.attribute('time', Model.transformDate),
post: Model.hasOne('post'),
user: Model.hasOne('user')
}) {}

View File

@ -0,0 +1,59 @@
.Post--reported {
padding-top: 0 !important;
border: 2px solid @primary-color;
.Post-controls {
display: none;
}
}
.Post-header .item-reported {
display: block;
margin: 0;
}
.Post-reported {
background: @primary-color;
margin-top: -2px;
margin-bottom: 20px;
margin-left: -22px;
margin-right: -22px;
@media @tablet-up {
margin-left: -22px - 90px;
text-align: right;
}
padding: 10px;
border-radius: @border-radius @border-radius 0 0;
overflow: hidden;
.light-contents(@color: @body-bg; @control-color: @body-bg);
&, a {
color: @body-bg !important;
}
}
.Post-reported-summary {
@media @tablet-up {
float: left;
}
font-size: 14px;
margin: 7px 10px;
text-align: left;
font-weight: bold;
}
.Post-reported-detail {
font-size: 12px;
margin-top: 5px;
font-weight: normal;
}
.Post-reported-actions .Button {
margin-left: 5px;
}
.ReportsDropdown .Dropdown-toggle {
.Button-label,
.Button-caret {
display: none;
}
}

View File

@ -0,0 +1,8 @@
reports:
reason_off_topic: Off-topic
reason_spam: Spam
reason_inappropriate: Inappropriate
reason_other: Other
reported_by: "Reported by {users}"
reported_by_with_reason: "Reported as {reasons} by {users}"
no_reports: No Reports

View File

@ -0,0 +1,28 @@
<?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.
*/
use Illuminate\Database\Schema\Blueprint;
use Flarum\Migrations\Migration;
class AddReportsReadTimeToUsersTable extends Migration
{
public function up()
{
$this->schema->table('users', function (Blueprint $table) {
$table->dateTime('reports_read_time')->nullable();
});
}
public function down()
{
$this->schema->drop('reports_read_time');
}
}

View File

@ -0,0 +1,34 @@
<?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.
*/
use Illuminate\Database\Schema\Blueprint;
use Flarum\Migrations\Migration;
class CreateReportsTable extends Migration
{
public function up()
{
$this->schema->create('reports', function (Blueprint $table) {
$table->increments('id');
$table->integer('post_id')->unsigned();
$table->integer('user_id')->unsigned();
$table->string('reporter')->nullable();
$table->string('reason')->nullable();
$table->string('reason_detail')->nullable();
$table->dateTime('time');
});
}
public function down()
{
$this->schema->drop('reports');
}
}

View File

@ -0,0 +1,58 @@
<?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\Reports\Api;
use Flarum\Reports\Commands\CreateReport;
use Flarum\Api\Actions\CreateAction as BaseCreateAction;
use Flarum\Api\JsonApiRequest;
use Illuminate\Contracts\Bus\Dispatcher;
class CreateAction extends BaseCreateAction
{
/**
* @var Dispatcher
*/
protected $bus;
/**
* @inheritdoc
*/
public $serializer = 'Flarum\Reports\Api\ReportSerializer';
/**
* @inheritdoc
*/
public $include = [
'post' => true,
'post.reports' => true
];
/**
* @param Dispatcher $bus
*/
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
/**
* Create a report according to input from the API request.
*
* @param JsonApiRequest $request
* @return \Flarum\Reports\Report
*/
protected function create(JsonApiRequest $request)
{
return $this->bus->dispatch(
new CreateReport($request->actor, $request->get('data'))
);
}
}

View File

@ -0,0 +1,44 @@
<?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\Reports\Api;
use Flarum\Reports\Commands\DeleteReports;
use Flarum\Api\Actions\DeleteAction as BaseDeleteAction;
use Flarum\Api\Request;
use Illuminate\Contracts\Bus\Dispatcher;
class DeleteAction extends BaseDeleteAction
{
/**
* @var Dispatcher
*/
protected $bus;
/**
* @param Dispatcher $bus
*/
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
/**
* Delete reports for a post.
*
* @param Request $request
*/
protected function delete(Request $request)
{
$this->bus->dispatch(
new DeleteReports($request->get('id'), $request->actor, $request->all())
);
}
}

View File

@ -0,0 +1,53 @@
<?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\Reports\Api;
use Flarum\Api\Actions\SerializeCollectionAction;
use Flarum\Api\JsonApiRequest;
use Flarum\Reports\Report;
use Tobscure\JsonApi\Document;
class IndexAction extends SerializeCollectionAction
{
/**
* @inheritdoc
*/
public $serializer = 'Flarum\Reports\Api\ReportSerializer';
/**
* @inheritdoc
*/
public $include = [
'user' => true,
'post' => true,
'post.user' => true,
'post.discussion' => true
];
/**
* @inheritdoc
*/
public $link = [];
protected function data(JsonApiRequest $request, Document $document)
{
$actor = $request->actor;
$actor->reports_read_time = time();
$actor->save();
return Report::whereVisibleTo($actor)
->with($request->include)
->latest('reports.time')
->groupBy('post_id')
->get();
}
}

View File

@ -0,0 +1,37 @@
<?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\Reports\Api;
use Flarum\Api\Serializers\Serializer;
class ReportSerializer extends Serializer
{
protected $type = 'reports';
protected function getDefaultAttributes($report)
{
return [
'reporter' => $report->reporter,
'reason' => $report->reason,
'reasonDetail' => $report->reason_detail,
];
}
protected function post()
{
return $this->hasOne('Flarum\Api\Serializers\PostSerializer');
}
protected function user()
{
return $this->hasOne('Flarum\Api\Serializers\UserBasicSerializer');
}
}

View File

@ -0,0 +1,40 @@
<?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\Reports\Commands;
use Flarum\Core\Users\User;
class CreateReport
{
/**
* The user performing the action.
*
* @var User
*/
public $actor;
/**
* The attributes of the new report.
*
* @var array
*/
public $data;
/**
* @param User $actor The user performing the action.
* @param array $data The attributes of the new report.
*/
public function __construct(User $actor, array $data)
{
$this->actor = $actor;
$this->data = $data;
}
}

View File

@ -0,0 +1,63 @@
<?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\Reports\Commands;
use Flarum\Reports\Report;
use Flarum\Core\Posts\PostRepository;
use Flarum\Core\Posts\CommentPost;
use Exception;
class CreateReportHandler
{
private $posts;
public function __construct(PostRepository $posts)
{
$this->posts = $posts;
}
/**
* @param CreateReport $command
* @return Report
*/
public function handle(CreateReport $command)
{
$actor = $command->actor;
$data = $command->data;
$postId = array_get($data, 'relationships.post.data.id');
$post = $this->posts->findOrFail($postId, $actor);
if (! ($post instanceof CommentPost)) {
// TODO: throw 400(?) error
throw new Exception;
}
$post->assertCan($actor, 'report');
Report::unguard();
$report = Report::firstOrNew([
'post_id' => $post->id,
'user_id' => $actor->id
]);
$report->post_id = $post->id;
$report->user_id = $actor->id;
$report->reason = array_get($data, 'attributes.reason');
$report->reason_detail = array_get($data, 'attributes.reasonDetail');
$report->time = time();
$report->save();
return $report;
}
}

View File

@ -0,0 +1,48 @@
<?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\Reports\Commands;
use Flarum\Reports\Report;
use Flarum\Core\Users\User;
class DeleteReports
{
/**
* The ID of the post to delete reports for.
*
* @var int
*/
public $postId;
/**
* The user performing the action.
*
* @var User
*/
public $actor;
/**
* @var array
*/
public $data;
/**
* @param int $postId The ID of the post to delete reports for.
* @param User $actor The user performing the action.
* @param array $data
*/
public function __construct($postId, User $actor, array $data = [])
{
$this->postId = $postId;
$this->actor = $actor;
$this->data = $data;
}
}

View File

@ -0,0 +1,45 @@
<?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\Reports\Commands;
use Flarum\Reports\Report;
use Flarum\Core\Posts\PostRepository;
use Flarum\Reports\Events\ReportsWillBeDeleted;
class DeleteReportsHandler
{
protected $posts;
public function __construct(PostRepository $posts)
{
$this->posts = $posts;
}
/**
* @param DeleteReport $command
* @return Report
* @throws \Flarum\Core\Exceptions\PermissionDeniedException
*/
public function handle(DeleteReports $command)
{
$actor = $command->actor;
$post = $this->posts->findOrFail($command->postId, $actor);
$post->discussion->assertCan($actor, 'viewReports');
event(new ReportsWillBeDeleted($post, $actor, $command->data));
$post->reports()->delete();
return $post;
}
}

View File

@ -0,0 +1,45 @@
<?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\Reports\Events;
use Flarum\Core\Posts\Post;
use Flarum\Core\Users\User;
class ReportsWillBeDeleted
{
/**
* @var Post
*/
public $post;
/**
* @var User
*/
public $actor;
/**
* @var array
*/
public $data;
/**
* @param Post $post
* @param User $actor
* @param array $data
*/
public function __construct(Post $post, User $actor, array $data = [])
{
$this->post = $post;
$this->actor = $actor;
$this->data = $data;
}
}

View File

@ -0,0 +1,24 @@
<?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\Reports;
use Flarum\Support\Extension as BaseExtension;
use Illuminate\Events\Dispatcher;
class Extension extends BaseExtension
{
public function listen(Dispatcher $events)
{
$events->subscribe('Flarum\Reports\Listeners\AddClientAssets');
$events->subscribe('Flarum\Reports\Listeners\AddApiAttributes');
$events->subscribe('Flarum\Reports\Listeners\AddModelRelationship');
}
}

View File

@ -0,0 +1,129 @@
<?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\Reports\Listeners;
use Flarum\Events\ApiRelationship;
use Flarum\Events\WillSerializeData;
use Flarum\Events\BuildApiAction;
use Flarum\Events\ApiAttributes;
use Flarum\Events\RegisterApiRoutes;
use Flarum\Api\Serializers\PostSerializer;
use Flarum\Api\Serializers\ForumSerializer;
use Flarum\Api\Actions\Posts;
use Flarum\Api\Actions\Discussions;
use Flarum\Reports\Report;
use Flarum\Reports\Api\CreateAction as ReportsCreateAction;
use Illuminate\Database\Eloquent\Collection;
class AddApiAttributes
{
public function subscribe($events)
{
$events->listen(ApiRelationship::class, [$this, 'addReportsRelationship']);
$events->listen(WillSerializeData::class, [$this, 'loadReportsRelationship']);
$events->listen(BuildApiAction::class, [$this, 'includeReportsRelationship']);
$events->listen(ApiAttributes::class, [$this, 'addAttributes']);
$events->listen(RegisterApiRoutes::class, [$this, 'addRoutes']);
}
public function loadReportsRelationship(WillSerializeData $event)
{
// For any API action that allows the 'reports' relationship to be
// included, we need to preload this relationship onto the data (Post
// models) so that we can selectively expose only the reports that the
// user has permission to view.
if ($event->action instanceof Discussions\ShowAction) {
$discussion = $event->data;
$posts = $discussion->posts->all();
}
if ($event->action instanceof Posts\IndexAction) {
$posts = $event->data->all();
}
if ($event->action instanceof Posts\ShowAction) {
$posts = [$event->data];
}
if ($event->action instanceof ReportsCreateAction) {
$report = $event->data;
$posts = [$report->post];
}
if (isset($posts)) {
$actor = $event->request->actor;
$postsWithPermission = [];
foreach ($posts as $post) {
$post->setRelation('reports', null);
if ($post->discussion->can($actor, 'viewReports')) {
$postsWithPermission[] = $post;
}
}
if (count($postsWithPermission)) {
(new Collection($postsWithPermission))
->load('reports', 'reports.user');
}
}
}
public function addReportsRelationship(ApiRelationship $event)
{
if ($event->serializer instanceof PostSerializer &&
$event->relationship === 'reports') {
return $event->serializer->hasMany('Flarum\Reports\Api\ReportSerializer', 'reports');
}
}
public function includeReportsRelationship(BuildApiAction $event)
{
if ($event->action instanceof Discussions\ShowAction) {
$event->addInclude('posts.reports');
$event->addInclude('posts.reports.user');
}
if ($event->action instanceof Posts\IndexAction ||
$event->action instanceof Posts\ShowAction) {
$event->addInclude('reports');
$event->addInclude('reports.user');
}
}
public function addAttributes(ApiAttributes $event)
{
if ($event->serializer instanceof ForumSerializer) {
$event->attributes['canViewReports'] = $event->actor->hasPermissionLike('discussion.viewReports');
if ($event->attributes['canViewReports']) {
$query = Report::whereVisibleTo($event->actor);
if ($time = $event->actor->reports_read_time) {
$query->where('reports.time', '>', $time);
}
$event->attributes['unreadReportsCount'] = $query->distinct('reports.post_id')->count();
}
}
if ($event->serializer instanceof PostSerializer) {
$event->attributes['canReport'] = $event->model->can($event->actor, 'report');
}
}
public function addRoutes(RegisterApiRoutes $event)
{
$event->get('/reports', 'reports.index', 'Flarum\Reports\Api\IndexAction');
$event->post('/reports', 'reports.create', 'Flarum\Reports\Api\CreateAction');
$event->delete('/posts/{id}/reports', 'reports.delete', 'Flarum\Reports\Api\DeleteAction');
}
}

View File

@ -0,0 +1,60 @@
<?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\Reports\Listeners;
use Flarum\Events\RegisterLocales;
use Flarum\Events\BuildClientView;
use Illuminate\Contracts\Events\Dispatcher;
class AddClientAssets
{
public function subscribe(Dispatcher $events)
{
$events->listen(RegisterLocales::class, [$this, 'addLocale']);
$events->listen(BuildClientView::class, [$this, 'addAssets']);
}
public function addLocale(RegisterLocales $event)
{
$event->addTranslations('en', __DIR__.'/../../locale/en.yml');
}
public function addAssets(BuildClientView $event)
{
$event->forumAssets([
__DIR__.'/../../js/forum/dist/extension.js',
__DIR__.'/../../less/forum/extension.less'
]);
$event->forumBootstrapper('reports/main');
$event->forumTranslations([
'reports.reason_off_topic',
'reports.reason_spam',
'reports.reason_inappropriate',
'reports.reason_other',
'reports.reported_by',
'reports.reported_by_with_reason',
'reports.no_reports'
]);
$event->adminAssets([
__DIR__.'/../../js/admin/dist/extension.js',
__DIR__.'/../../less/admin/extension.less'
]);
$event->adminBootstrapper('reports/main');
$event->adminTranslations([
// 'report.hello_world'
]);
}
}

View File

@ -0,0 +1,40 @@
<?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\Reports\Listeners;
use Flarum\Events\ModelRelationship;
use Flarum\Events\ModelDates;
use Flarum\Core\Posts\Post;
use Flarum\Core\Users\User;
use Flarum\Reports\Report;
class AddModelRelationship
{
public function subscribe($events)
{
$events->listen(ModelRelationship::class, [$this, 'addReportsRelationship']);
$events->listen(ModelDates::class, [$this, 'modelDates']);
}
public function addReportsRelationship(ModelRelationship $event)
{
if ($event->model instanceof Post && $event->relationship === 'reports') {
return $event->model->hasMany('Flarum\Reports\Report', 'post_id');
}
}
public function modelDates(ModelDates $event)
{
if ($event->model instanceof User) {
$event->dates[] = 'reports_read_time';
}
}
}

View File

@ -0,0 +1,33 @@
<?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\Reports;
use Flarum\Core\Model;
use Flarum\Core\Support\VisibleScope;
class Report extends Model
{
use VisibleScope;
protected $table = 'reports';
protected $dates = ['time'];
public function post()
{
return $this->belongsTo('Flarum\Core\Posts\Post');
}
public function user()
{
return $this->belongsTo('Flarum\Core\Users\User');
}
}