commit cb6347ef6ad302e96ef5f334ba39bc5320ee16f9
Author: Toby Zerner <toby.zerner@gmail.com>
Date:   Fri Sep 4 13:26:51 2015 +0930

    Initial commit

diff --git a/extensions/flags/.editorconfig b/extensions/flags/.editorconfig
new file mode 100644
index 000000000..87694ddab
--- /dev/null
+++ b/extensions/flags/.editorconfig
@@ -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
diff --git a/extensions/flags/.eslintignore b/extensions/flags/.eslintignore
new file mode 100644
index 000000000..86b7c8854
--- /dev/null
+++ b/extensions/flags/.eslintignore
@@ -0,0 +1,5 @@
+**/bower_components/**/*
+**/node_modules/**/*
+vendor/**/*
+**/Gulpfile.js
+**/dist/**/*
diff --git a/extensions/flags/.eslintrc b/extensions/flags/.eslintrc
new file mode 100644
index 000000000..9e89e6ba6
--- /dev/null
+++ b/extensions/flags/.eslintrc
@@ -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
+  }
+}
diff --git a/extensions/flags/.gitignore b/extensions/flags/.gitignore
new file mode 100644
index 000000000..a4f3b125e
--- /dev/null
+++ b/extensions/flags/.gitignore
@@ -0,0 +1,4 @@
+/vendor
+composer.phar
+.DS_Store
+Thumbs.db
diff --git a/extensions/flags/.php_cs b/extensions/flags/.php_cs
new file mode 100755
index 000000000..20d29c766
--- /dev/null
+++ b/extensions/flags/.php_cs
@@ -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);
diff --git a/extensions/flags/.travis.yml b/extensions/flags/.travis.yml
new file mode 100644
index 000000000..692e09f86
--- /dev/null
+++ b/extensions/flags/.travis.yml
@@ -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
diff --git a/extensions/flags/LICENSE b/extensions/flags/LICENSE
new file mode 100644
index 000000000..aa1e5fb86
--- /dev/null
+++ b/extensions/flags/LICENSE
@@ -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.
diff --git a/extensions/flags/bootstrap.php b/extensions/flags/bootstrap.php
new file mode 100644
index 000000000..c296e3d99
--- /dev/null
+++ b/extensions/flags/bootstrap.php
@@ -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';
diff --git a/extensions/flags/composer.json b/extensions/flags/composer.json
new file mode 100644
index 000000000..374a4871c
--- /dev/null
+++ b/extensions/flags/composer.json
@@ -0,0 +1,10 @@
+{
+    "autoload": {
+        "psr-4": {
+            "Flarum\\Reports\\": "src/"
+        }
+    },
+    "scripts": {
+        "style": "phpcs --standard=PSR2 -np src"
+    }
+}
diff --git a/extensions/flags/flarum.json b/extensions/flags/flarum.json
new file mode 100644
index 000000000..de9b3c06c
--- /dev/null
+++ b/extensions/flags/flarum.json
@@ -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"
+    }
+}
diff --git a/extensions/flags/js/.gitignore b/extensions/flags/js/.gitignore
new file mode 100644
index 000000000..372e20a51
--- /dev/null
+++ b/extensions/flags/js/.gitignore
@@ -0,0 +1,3 @@
+bower_components
+node_modules
+dist
diff --git a/extensions/flags/js/admin/Gulpfile.js b/extensions/flags/js/admin/Gulpfile.js
new file mode 100644
index 000000000..9db4f9477
--- /dev/null
+++ b/extensions/flags/js/admin/Gulpfile.js
@@ -0,0 +1,7 @@
+var gulp = require('flarum-gulp');
+
+gulp({
+  modules: {
+    'reports': 'src/**/*.js'
+  }
+});
diff --git a/extensions/flags/js/admin/package.json b/extensions/flags/js/admin/package.json
new file mode 100644
index 000000000..62ea6c691
--- /dev/null
+++ b/extensions/flags/js/admin/package.json
@@ -0,0 +1,7 @@
+{
+  "private": true,
+  "devDependencies": {
+    "gulp": "^3.8.11",
+    "flarum-gulp": "^0.1.0"
+  }
+}
diff --git a/extensions/flags/js/admin/src/main.js b/extensions/flags/js/admin/src/main.js
new file mode 100644
index 000000000..8b72ed4fa
--- /dev/null
+++ b/extensions/flags/js/admin/src/main.js
@@ -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'
+    });
+  });
+});
diff --git a/extensions/flags/js/forum/Gulpfile.js b/extensions/flags/js/forum/Gulpfile.js
new file mode 100644
index 000000000..9db4f9477
--- /dev/null
+++ b/extensions/flags/js/forum/Gulpfile.js
@@ -0,0 +1,7 @@
+var gulp = require('flarum-gulp');
+
+gulp({
+  modules: {
+    'reports': 'src/**/*.js'
+  }
+});
diff --git a/extensions/flags/js/forum/package.json b/extensions/flags/js/forum/package.json
new file mode 100644
index 000000000..62ea6c691
--- /dev/null
+++ b/extensions/flags/js/forum/package.json
@@ -0,0 +1,7 @@
+{
+  "private": true,
+  "devDependencies": {
+    "gulp": "^3.8.11",
+    "flarum-gulp": "^0.1.0"
+  }
+}
diff --git a/extensions/flags/js/forum/src/addReportControl.js b/extensions/flags/js/forum/src/addReportControl.js
new file mode 100644
index 000000000..ae3f4aa46
--- /dev/null
+++ b/extensions/flags/js/forum/src/addReportControl.js
@@ -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>
+    );
+  });
+}
diff --git a/extensions/flags/js/forum/src/addReportsDropdown.js b/extensions/flags/js/forum/src/addReportsDropdown.js
new file mode 100644
index 000000000..f887ba722
--- /dev/null
+++ b/extensions/flags/js/forum/src/addReportsDropdown.js
@@ -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);
+    }
+  });
+}
diff --git a/extensions/flags/js/forum/src/addReportsToPosts.js b/extensions/flags/js/forum/src/addReportsToPosts.js
new file mode 100644
index 000000000..bc35d0ed4
--- /dev/null
+++ b/extensions/flags/js/forum/src/addReportsToPosts.js
@@ -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>
+    );
+  });
+}
diff --git a/extensions/flags/js/forum/src/components/ReportList.js b/extensions/flags/js/forum/src/components/ReportList.js
new file mode 100644
index 000000000..7cb0f483b
--- /dev/null
+++ b/extensions/flags/js/forum/src/components/ReportList.js
@@ -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();
+    });
+  }
+}
diff --git a/extensions/flags/js/forum/src/components/ReportPostModal.js b/extensions/flags/js/forum/src/components/ReportPostModal.js
new file mode 100644
index 000000000..ce335caf4
--- /dev/null
+++ b/extensions/flags/js/forum/src/components/ReportPostModal.js
@@ -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();
+      }
+    );
+  }
+}
diff --git a/extensions/flags/js/forum/src/components/ReportsDropdown.js b/extensions/flags/js/forum/src/components/ReportsDropdown.js
new file mode 100644
index 000000000..8aadb271a
--- /dev/null
+++ b/extensions/flags/js/forum/src/components/ReportsDropdown.js
@@ -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');
+  }
+}
diff --git a/extensions/flags/js/forum/src/components/ReportsPage.js b/extensions/flags/js/forum/src/components/ReportsPage.js
new file mode 100644
index 000000000..e588496f5
--- /dev/null
+++ b/extensions/flags/js/forum/src/components/ReportsPage.js
@@ -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>;
+  }
+}
diff --git a/extensions/flags/js/forum/src/main.js b/extensions/flags/js/forum/src/main.js
new file mode 100644
index 000000000..19256b252
--- /dev/null
+++ b/extensions/flags/js/forum/src/main.js
@@ -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();
+});
diff --git a/extensions/flags/js/forum/src/models/Report.js b/extensions/flags/js/forum/src/models/Report.js
new file mode 100644
index 000000000..f539cc42f
--- /dev/null
+++ b/extensions/flags/js/forum/src/models/Report.js
@@ -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')
+}) {}
diff --git a/extensions/flags/less/admin/extension.less b/extensions/flags/less/admin/extension.less
new file mode 100644
index 000000000..e69de29bb
diff --git a/extensions/flags/less/forum/extension.less b/extensions/flags/less/forum/extension.less
new file mode 100644
index 000000000..5139037f3
--- /dev/null
+++ b/extensions/flags/less/forum/extension.less
@@ -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;
+  }
+}
diff --git a/extensions/flags/locale/en.yml b/extensions/flags/locale/en.yml
new file mode 100644
index 000000000..3a43bcf5c
--- /dev/null
+++ b/extensions/flags/locale/en.yml
@@ -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
diff --git a/extensions/flags/migrations/2015_09_02_000000_add_reports_read_time_to_users_table.php b/extensions/flags/migrations/2015_09_02_000000_add_reports_read_time_to_users_table.php
new file mode 100644
index 000000000..2d6cf6d20
--- /dev/null
+++ b/extensions/flags/migrations/2015_09_02_000000_add_reports_read_time_to_users_table.php
@@ -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');
+    }
+}
diff --git a/extensions/flags/migrations/2015_09_02_000000_create_reports_table.php b/extensions/flags/migrations/2015_09_02_000000_create_reports_table.php
new file mode 100644
index 000000000..914037250
--- /dev/null
+++ b/extensions/flags/migrations/2015_09_02_000000_create_reports_table.php
@@ -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');
+    }
+}
diff --git a/extensions/flags/src/Api/CreateAction.php b/extensions/flags/src/Api/CreateAction.php
new file mode 100644
index 000000000..d9c3a2d62
--- /dev/null
+++ b/extensions/flags/src/Api/CreateAction.php
@@ -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'))
+        );
+    }
+}
diff --git a/extensions/flags/src/Api/DeleteAction.php b/extensions/flags/src/Api/DeleteAction.php
new file mode 100644
index 000000000..90c527a86
--- /dev/null
+++ b/extensions/flags/src/Api/DeleteAction.php
@@ -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())
+        );
+    }
+}
diff --git a/extensions/flags/src/Api/IndexAction.php b/extensions/flags/src/Api/IndexAction.php
new file mode 100644
index 000000000..8c39f3857
--- /dev/null
+++ b/extensions/flags/src/Api/IndexAction.php
@@ -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();
+    }
+}
diff --git a/extensions/flags/src/Api/ReportSerializer.php b/extensions/flags/src/Api/ReportSerializer.php
new file mode 100644
index 000000000..8d05a536c
--- /dev/null
+++ b/extensions/flags/src/Api/ReportSerializer.php
@@ -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');
+    }
+}
diff --git a/extensions/flags/src/Commands/CreateReport.php b/extensions/flags/src/Commands/CreateReport.php
new file mode 100644
index 000000000..92c1ae2cf
--- /dev/null
+++ b/extensions/flags/src/Commands/CreateReport.php
@@ -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;
+    }
+}
diff --git a/extensions/flags/src/Commands/CreateReportHandler.php b/extensions/flags/src/Commands/CreateReportHandler.php
new file mode 100644
index 000000000..8a9040ee7
--- /dev/null
+++ b/extensions/flags/src/Commands/CreateReportHandler.php
@@ -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;
+    }
+}
diff --git a/extensions/flags/src/Commands/DeleteReports.php b/extensions/flags/src/Commands/DeleteReports.php
new file mode 100644
index 000000000..e1ffd6b09
--- /dev/null
+++ b/extensions/flags/src/Commands/DeleteReports.php
@@ -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;
+    }
+}
diff --git a/extensions/flags/src/Commands/DeleteReportsHandler.php b/extensions/flags/src/Commands/DeleteReportsHandler.php
new file mode 100644
index 000000000..3cebff1de
--- /dev/null
+++ b/extensions/flags/src/Commands/DeleteReportsHandler.php
@@ -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;
+    }
+}
diff --git a/extensions/flags/src/Events/ReportsWillBeDeleted.php b/extensions/flags/src/Events/ReportsWillBeDeleted.php
new file mode 100644
index 000000000..92dafe6a9
--- /dev/null
+++ b/extensions/flags/src/Events/ReportsWillBeDeleted.php
@@ -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;
+    }
+}
diff --git a/extensions/flags/src/Extension.php b/extensions/flags/src/Extension.php
new file mode 100644
index 000000000..bcee8fe97
--- /dev/null
+++ b/extensions/flags/src/Extension.php
@@ -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');
+    }
+}
diff --git a/extensions/flags/src/Listeners/AddApiAttributes.php b/extensions/flags/src/Listeners/AddApiAttributes.php
new file mode 100755
index 000000000..a24cd3557
--- /dev/null
+++ b/extensions/flags/src/Listeners/AddApiAttributes.php
@@ -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');
+    }
+}
diff --git a/extensions/flags/src/Listeners/AddClientAssets.php b/extensions/flags/src/Listeners/AddClientAssets.php
new file mode 100644
index 000000000..0171b79ad
--- /dev/null
+++ b/extensions/flags/src/Listeners/AddClientAssets.php
@@ -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'
+        ]);
+    }
+}
diff --git a/extensions/flags/src/Listeners/AddModelRelationship.php b/extensions/flags/src/Listeners/AddModelRelationship.php
new file mode 100755
index 000000000..8df04624c
--- /dev/null
+++ b/extensions/flags/src/Listeners/AddModelRelationship.php
@@ -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';
+        }
+    }
+}
diff --git a/extensions/flags/src/Report.php b/extensions/flags/src/Report.php
new file mode 100644
index 000000000..32766725a
--- /dev/null
+++ b/extensions/flags/src/Report.php
@@ -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');
+    }
+}