mirror of
https://github.com/flarum/core.git
synced 2025-08-13 20:04:24 +02:00
Compare commits
93 Commits
sm/let-err
...
v1.6.3
Author | SHA1 | Date | |
---|---|---|---|
|
243bc139b0 | ||
|
adf78bbd95 | ||
|
c8d9f1111e | ||
|
e5f05166a0 | ||
|
02556c6ca6 | ||
|
666223fa8c | ||
|
12dfcc5c79 | ||
|
248a71d9b5 | ||
|
a131e87911 | ||
|
be63b28437 | ||
|
132fdea659 | ||
|
8a65ad980d | ||
|
9a0668effd | ||
|
224b122303 | ||
|
ed0cee97f5 | ||
|
b5f324a7b3 | ||
|
8ef0df94b2 | ||
|
c50c924242 | ||
|
18bdd48835 | ||
|
f49cf887dc | ||
|
19793d5617 | ||
|
4a2f48ad04 | ||
|
2b413b06c5 | ||
|
2b89dedc08 | ||
|
00a880c467 | ||
|
92d2adc5fd | ||
|
bc59b8d9ab | ||
|
bb9f01372f | ||
|
069a29d22a | ||
|
105170b5bc | ||
|
b8261ef055 | ||
|
d14770188b | ||
|
e9bb646dbf | ||
|
46adf40675 | ||
|
6938a13223 | ||
|
ab56aefeaa | ||
|
259db92b9a | ||
|
094ec77980 | ||
|
bb712693d4 | ||
|
1e00e3fdbb | ||
|
47d7a6e155 | ||
|
45d91212f6 | ||
|
1a81c98d43 | ||
|
c98e6ba5a7 | ||
|
eeb00cc56b | ||
|
87cdb5b4d8 | ||
|
bc4b0b864c | ||
|
53ab1503e4 | ||
|
62a396e434 | ||
|
2096fa2807 | ||
|
06963df407 | ||
|
8fe09815f5 | ||
|
fccc3e2188 | ||
|
f0a867b20f | ||
|
69311ae689 | ||
|
f005b9e031 | ||
|
31ced98e0d | ||
|
9964ddd731 | ||
|
32ac0a8d8f | ||
|
827e905f8e | ||
|
cdc76567d4 | ||
|
5898a50463 | ||
|
72d9ee2010 | ||
|
4e5e4e5c6e | ||
|
201d7430fe | ||
|
52f6148876 | ||
|
dc215aba59 | ||
|
90a68506b8 | ||
|
bd0577f435 | ||
|
d33f1abffc | ||
|
b0b47a0888 | ||
|
8c0a14aff2 | ||
|
76788efaba | ||
|
973ec32e13 | ||
|
cf818aae9e | ||
|
6da0bc63b7 | ||
|
6e1bc2daed | ||
|
7ce9d63ed6 | ||
|
267f6759f8 | ||
|
b5874a08e4 | ||
|
368e08bb9b | ||
|
5f2d7fb7b6 | ||
|
68d6e30143 | ||
|
c5c312db0d | ||
|
05f80b7b83 | ||
|
31c3cfc4ea | ||
|
fd196454a5 | ||
|
f6761843b2 | ||
|
84c31165e5 | ||
|
fc4d5e3d43 | ||
|
b2fa28e4b5 | ||
|
fc743ba888 | ||
|
a35df8c139 |
@@ -20,3 +20,6 @@ indent_size = 4
|
||||
|
||||
[tsconfig.json]
|
||||
indent_size = 2
|
||||
|
||||
[*.neon]
|
||||
indent_style = tab
|
||||
|
47
.github/workflows/REUSABLE_backend.yml
vendored
47
.github/workflows/REUSABLE_backend.yml
vendored
@@ -9,6 +9,12 @@ on:
|
||||
default: true
|
||||
required: false
|
||||
|
||||
enable_phpstan:
|
||||
description: "Enable PHPStan Static Analysis?"
|
||||
type: boolean
|
||||
default: false
|
||||
required: false
|
||||
|
||||
backend_directory:
|
||||
description: The directory of the project where backend code is located. This should contain a `composer.json` file, and is generally the root directory of the repo.
|
||||
type: string
|
||||
@@ -20,6 +26,13 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: '["7.3", "7.4", "8.0", "8.1"]'
|
||||
|
||||
php_extensions:
|
||||
description: PHP extensions to install.
|
||||
type: string
|
||||
required: false
|
||||
default: 'curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip'
|
||||
|
||||
db_versions:
|
||||
description: Versions of databases to test with. Should be array of strings encoded as JSON array
|
||||
type: string
|
||||
@@ -100,7 +113,7 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: xdebug
|
||||
extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
|
||||
extensions: ${{ inputs.php_extensions }}
|
||||
tools: phpunit, composer:v2
|
||||
ini-values: ${{ inputs.php_ini_values }}
|
||||
|
||||
@@ -130,3 +143,35 @@ jobs:
|
||||
working-directory: ${{ inputs.backend_directory }}
|
||||
env:
|
||||
COMPOSER_PROCESS_TIMEOUT: 600
|
||||
|
||||
phpstan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: ${{ fromJSON(inputs.php_versions) }}
|
||||
|
||||
name: 'PHPStan PHP ${{ matrix.php }}'
|
||||
|
||||
if: >-
|
||||
inputs.enable_phpstan &&
|
||||
((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || github.event_name != 'pull_request')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: xdebug
|
||||
extensions: ${{ inputs.php_extensions }}
|
||||
tools: phpunit, composer:v2
|
||||
ini-values: ${{ inputs.php_ini_values }}
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install
|
||||
working-directory: ${{ inputs.backend_directory }}
|
||||
|
||||
- name: Run PHPStan
|
||||
run: composer analyse:phpstan
|
||||
|
12
.github/workflows/phpstan.yml
vendored
Normal file
12
.github/workflows/phpstan.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: Framework PHP
|
||||
|
||||
on: [workflow_dispatch, push, pull_request]
|
||||
|
||||
jobs:
|
||||
run:
|
||||
uses: ./.github/workflows/REUSABLE_backend.yml
|
||||
with:
|
||||
enable_backend_testing: false
|
||||
enable_phpstan: true
|
||||
|
||||
backend_directory: .
|
133
CHANGELOG.md
133
CHANGELOG.md
@@ -1,5 +1,138 @@
|
||||
# Changelog
|
||||
|
||||
# [v1.6.3](https://github.com/flarum/framework/compare/v1.6.2...v1.6.3)
|
||||
### Fixed
|
||||
* Post mentions can be used to read any post on the forum without access control (ab1c868b978e8b0d09a5d682c54665dae17d0985).
|
||||
* Notifications can leak restricted content (d0a2b95dca57d3dae9a0d77b610b1cb1d0b1766a).
|
||||
* Any user including unactivated can reply in public discussions whose first post was permanently deleted (12f14112a0ecd1484d97330b82beb2a145919015).
|
||||
* (subscriptions) Post notifications not getting access checked (https://github.com/flarum/framework/commit/e5f05166a062a9a6eb7c12e28728bfd5db7270e3).
|
||||
|
||||
## [v1.6.2](https://github.com/flarum/framework/compare/v1.6.1...v1.6.2)
|
||||
### Fixed
|
||||
* XSS Vulnerability in core (https://github.com/flarum/framework/pull/3684).
|
||||
|
||||
## [v1.6.1](https://github.com/flarum/framework/compare/v1.6.0...v1.6.1)
|
||||
### Fixed
|
||||
* JS dependencies update breaks utilities.
|
||||
|
||||
## [v1.6.0](https://github.com/flarum/framework/compare/v1.5.0...v1.6.0)
|
||||
### Fixed
|
||||
- (approval) posts approved for deleted users error ([b5874a0](b5874a08e482196f50af50aa78e43c93c29fb647))
|
||||
- (regression) bad import ([5f2d7fb](5f2d7fb7b6e430d40cf2bb05eca7c73f6ca5a2cc))
|
||||
- akismet fails when the extension is not on a version ([45d9121](45d91212f6bfa777cae9fc06c55c85d01ffd174d))
|
||||
- apply flex for AppearancePage colors input [#3651]
|
||||
- groupmentions have poor contrast on some backgrounds [#3672]
|
||||
- larastan v1 incompatible with phpstan v1.9.0 [#3665]
|
||||
- package manager failures not showing alerts [#3647]
|
||||
- password reset leaks user existence [#3616]
|
||||
- statistics previous period chart is unclear [#3654]
|
||||
### Changed
|
||||
- (package-manager) config composer to use web php version ([fd19645](fd196454a5641776784fa80886cc7577c840f8ed))
|
||||
- (package-manager) set min core version and add warning ([31c3cfc](31c3cfc4eab4c314260b9b0d11e53ac2d4be158d))
|
||||
- (statistics) prepare v1.5.1 ([dc215ab](dc215aba59145dfd7b0d6efad4388444f30e47fb))
|
||||
- Apply fixes from StyleCI ([267f675](267f6759f80bd06f468337245ea6045635e827d9))
|
||||
- Fix tag discussion count decreased by 2 when hiding before deleting [#3660]
|
||||
- Log migration path when up/down keys are missing [#3664]
|
||||
- Make it possible to extend SetupScript [#3643]
|
||||
- Setup PHPStan Level 5 [#3553]
|
||||
- `yarn format` ([c5c312d](c5c312db0d800e3b84b94a4abb9691e348dea742))
|
||||
- add missing last period to custom date ranges [#3661]
|
||||
- add priorities to profile settings page [#3657]
|
||||
- allow specifying php extensions in workflow ([b0b47a0](b0b47a0888f513a459b67e9f89e72a61de38f1ce))
|
||||
- format js ([06963df](06963df4079373fc8fc51b7479e9576f02beb098))
|
||||
- group mentions [#3658]
|
||||
- remove styleci from changelog ([b2fa28e](b2fa28e4b57094e46dbdb3d79fab74f290a17d17))
|
||||
- set flarum version to dev for 1.6.0 ([fc743ba](fc743ba88872031db13597d7365a063b8004c78f))
|
||||
- throw an exception when no serializer is provided to the controller [#3614]
|
||||
### Added
|
||||
- (statistics) support for custom date ranges [#3622]
|
||||
- Allow additional login params, Introduce `LogInValidator` [#3670]
|
||||
- Allow additional reset password params, introduce `ForgotPasswordValidator` [#3671]
|
||||
- add statistics chart export button [#3662]
|
||||
- allow specifying extensions when installing an instance [#3655]
|
||||
- contrast util with yiq calculator [#3652]
|
||||
- customizable session driver [#3610]
|
||||
- replace `ColorPreviewInput` for GroupModal color input [#3650]
|
||||
- send notifications of a new reply when post is approved [#3656]
|
||||
|
||||
## [v1.5.0](https://github.com/flarum/framework/compare/v1.4.0...v1.5.0)
|
||||
### Fixed
|
||||
- (a11y) add accessible labels to notification grid options [#3520]
|
||||
- (a11y) present post streams as feeds [#3522]
|
||||
- (a11y) set `aria-busy` when editing a post stream item [#3521]
|
||||
- (compilation) versioner not inject into compilers [#3589]
|
||||
- (mentions) accessing `id` of null `user` relation [#3618]
|
||||
- (subscriptions) add missing table prefix for filter gambit [#3599]
|
||||
- (tags) use default index sortmap [#3615]
|
||||
- Move guzzle requirement to core [#3544]
|
||||
- MyISAM tables for extensions during installation ([75aaef7](75aaef7d76317bc8578eac1439fed8091c87213b), [f926c58](f926c58e0143fe75a4a4c2e93810970c5910afc8))
|
||||
- Set the translator locale to user preference for email notifications [#3525]
|
||||
- `$events` property declared dynamically [#3598]
|
||||
- core settings header has no priority ([33bf228](33bf2284c77863a1bb18d71d87b8516483056a74))
|
||||
- html entities shown raw in page title [#3542]
|
||||
- incorrect centring of deleted user avatars in notification list [#3569]
|
||||
- intellisense imports defaulting to absolute path from `src` folder [#3549]
|
||||
- minor backward compatible fix for php 8.1 in st_replace ([07b2f86](07b2f86dcc90a3ef17c8ee19a1a07e99a4b17360))
|
||||
- post query wildcard selection causes ambiguity [#3621]
|
||||
- potential static caching memory exhaustion [#3548]
|
||||
- prepare release workflow has invalid layout ([70e483d](70e483d1b185332910be9513fd06cc6342830d49))
|
||||
- remove deprecation warning for decoding null values ([590639f](590639f5f3e1fe883f28c41e1f175c2826b4b5f4))
|
||||
- replace `.fa()` mixin usage with `.fas()` [#3537]
|
||||
- return type hint static is php 8+ ([b01b75e](b01b75e36790d8026dd27ce59051d9581ad47940))
|
||||
- sticky nav content displays below post stream [#3575]
|
||||
- titles positioned wrongly with custom header height [#3550]
|
||||
- typo in error message ([1a189f4](1a189f492320071365286a8835bc49d5a9571753))
|
||||
- unread notifications are globally cached between users. [#3543]
|
||||
- update workflow name ([628c281](628c281c39855f01069ddc40b698d80d29fec870))
|
||||
- user has wrong discussion read status [#3591]
|
||||
### Changed
|
||||
- (approval, likes) use subscribers [#3577]
|
||||
- (package-manager) last tweaks before beta tag ([335c602](335c602cea3fbaee9ad7c32ceecaaf222e5d89a7))
|
||||
- (statistics) add release notes for 1.4.1 ([f4ace73](f4ace73a3c59434b8717efb2d83f50084f470fe4))
|
||||
- (statistics) rewrite for performance on very large communities [#3531]
|
||||
- (statistics) split timed data into per-model XHR requests [#3601]
|
||||
- (tags) Replace event helper with event dispatcher [#3570]
|
||||
- Add `loading="lazy"` attribute for avatars [#3578]
|
||||
- Create CODEOWNERS ([6e48a03](6e48a0303e45bcf210e550ba3e0772bc8443a207))
|
||||
- MyISAM tables for extensions during installation" ([f128190](f128190f143398dd1262fd1379e634794daee4c1))
|
||||
- convert `AlertManager` `IndexPage` and `UserPage` components to TS [#3536]
|
||||
- convert `Badge` `Checkbox` and `Navigation` components to TS [#3532]
|
||||
- convert core modals to TypeScript [#3515]
|
||||
- convert page components to TypeScript [#3538]
|
||||
- debug line slipped in while rebasing a PR [#3580]
|
||||
- don't pass password field between auth modals [#3626]
|
||||
- fix github issue templates ([d3e456a](d3e456a1bf42d13b7cd2542c371f392712247c09))
|
||||
- format code ([4954621](495462183bfb3b33046b293e6b1088ab225968df))
|
||||
- getting the release workflow in ([5530400](5530400b093b5fd07d670e5c92d8a7da96634cfe))
|
||||
- link logo at the top with the official website [#3552]
|
||||
- prevent running both `push` and `pull_request` actions at the same time [#3597]
|
||||
- refactor prefix matrix and add `MySQL 8.0` & `PHP 7.3` to workflows [#3595]
|
||||
- relying on a third-party for avatar URL tests is unreliable [#3586]
|
||||
- require guzzle 6 or 7 ([46b3b7a](46b3b7a9527b935c3c52269aaad2010c75dcb6d8))
|
||||
- split FA imports into separate Less file for easy overriding [#3535]
|
||||
- unify JS actions into one (rewritten `flarum/action-build`) [#3573]
|
||||
- update version constant during cycle 22 ([d864405](d86440506dd37101e60adec591d4b017e7765ec6))
|
||||
- use `isCollapsed` instead of `rangeCount` [#3581]
|
||||
- use github issue template forms [#3526]
|
||||
### Added
|
||||
- (likes) Add likes tab to user profile [#3528]
|
||||
- (likes) Option to prevent users liking their own posts [#3534]
|
||||
- (modals) support stacking modals, remove bootstrap modals dependency [#3456]
|
||||
- (subscriptions) add option to send notifications when not caught up [#3503]
|
||||
- Add custom class for email confirmation alert [#3584]
|
||||
- Admin debug mode warning [#3590]
|
||||
- Delete all notifications [#3529]
|
||||
- Queue package manager commands [#3418]
|
||||
- Restart the queue worker after cache clearing, ext enable/disable, save settings [#3565]
|
||||
- add createTableIfNotExists migration helper [#3576]
|
||||
- add new workflow for generating release meta ([0901e59](0901e59a58a3e1f017762583a2adf419f7f34257))
|
||||
- clear password & email tokens when appropriate [#3567]
|
||||
- discussion UTF-8 slug driver [#3606]
|
||||
- expose assets base url to frontend forum model [#3566]
|
||||
- extender to add custom less variables [#3530]
|
||||
- publish assets on admin dashboard cache clear [#3564]
|
||||
- throttle email change, email confirmation, and password reset endpoints. [#3555]
|
||||
|
||||
## [1.4.0](https://github.com/flarum/framework/compare/v1.3.1...v1.4.0)
|
||||
|
||||
### Added
|
||||
|
@@ -140,8 +140,8 @@
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.4",
|
||||
"phpunit/phpunit": "^9.0",
|
||||
"phpstan/phpstan-php-parser": "^1.0",
|
||||
"phpstan/phpstan": "^1.2"
|
||||
"phpstan/phpstan": ">=1.8.11 < 1.9.0",
|
||||
"nunomaduro/larastan": "^1.0"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
@@ -178,5 +178,11 @@
|
||||
"extension.neon"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"analyse:phpstan": "phpstan analyse"
|
||||
},
|
||||
"scripts-descriptions": {
|
||||
"analyse:phpstan": "Run static analysis"
|
||||
}
|
||||
}
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4",
|
||||
"flarum/core": "^1.6",
|
||||
"flarum/approval": "^1.2"
|
||||
},
|
||||
"autoload": {
|
||||
|
@@ -38,7 +38,7 @@ class AkismetProvider extends AbstractServiceProvider
|
||||
$settings->get('flarum-akismet.api_key'),
|
||||
$url->to('forum')->base(),
|
||||
$app::VERSION,
|
||||
$extensions->getExtension('flarum-akismet')->getVersion(),
|
||||
$extensions->getExtension('flarum-akismet')->getVersion() ?? 'unknown',
|
||||
$config->inDebugMode()
|
||||
);
|
||||
});
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4",
|
||||
"flarum/core": "^1.6",
|
||||
"flarum/flags": "^1.2"
|
||||
},
|
||||
"autoload": {
|
||||
|
@@ -10,6 +10,7 @@
|
||||
use Flarum\Api\Serializer\BasicDiscussionSerializer;
|
||||
use Flarum\Api\Serializer\PostSerializer;
|
||||
use Flarum\Approval\Access;
|
||||
use Flarum\Approval\Event\PostWasApproved;
|
||||
use Flarum\Approval\Listener;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Extend;
|
||||
@@ -48,6 +49,7 @@ return [
|
||||
new Extend\Locales(__DIR__.'/locale'),
|
||||
|
||||
(new Extend\Event())
|
||||
->listen(PostWasApproved::class, Listener\UpdateDiscussionAfterPostApproval::class)
|
||||
->subscribe(Listener\ApproveContent::class)
|
||||
->subscribe(Listener\UnapproveNewContent::class),
|
||||
|
||||
|
@@ -21,12 +21,8 @@ class ApproveContent
|
||||
public function subscribe(Dispatcher $events)
|
||||
{
|
||||
$events->listen(Saving::class, [$this, 'approvePost']);
|
||||
$events->listen(PostWasApproved::class, [$this, 'approveDiscussion']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Saving $event
|
||||
*/
|
||||
public function approvePost(Saving $event)
|
||||
{
|
||||
$attributes = $event->data['attributes'];
|
||||
@@ -46,30 +42,4 @@ class ApproveContent
|
||||
$post->raise(new PostWasApproved($post, $event->actor));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PostWasApproved $event
|
||||
*/
|
||||
public function approveDiscussion(PostWasApproved $event)
|
||||
{
|
||||
$post = $event->post;
|
||||
$discussion = $post->discussion;
|
||||
$user = $discussion->user;
|
||||
|
||||
$discussion->refreshCommentCount();
|
||||
$discussion->refreshLastPost();
|
||||
|
||||
if ($post->number == 1) {
|
||||
$discussion->is_approved = true;
|
||||
|
||||
$discussion->afterSave(function () use ($user) {
|
||||
$user->refreshDiscussionCount();
|
||||
});
|
||||
}
|
||||
|
||||
$discussion->save();
|
||||
|
||||
$user->refreshCommentCount();
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Approval\Listener;
|
||||
|
||||
use Flarum\Approval\Event\PostWasApproved;
|
||||
|
||||
class UpdateDiscussionAfterPostApproval
|
||||
{
|
||||
public function handle(PostWasApproved $event)
|
||||
{
|
||||
$post = $event->post;
|
||||
$discussion = $post->discussion;
|
||||
$user = $discussion->user;
|
||||
|
||||
$discussion->refreshCommentCount();
|
||||
$discussion->refreshLastPost();
|
||||
|
||||
if ($post->number == 1) {
|
||||
$discussion->is_approved = true;
|
||||
|
||||
$discussion->afterSave(function () use ($user) {
|
||||
$user->refreshDiscussionCount();
|
||||
});
|
||||
}
|
||||
|
||||
$discussion->save();
|
||||
|
||||
if ($discussion->user) {
|
||||
$user->refreshCommentCount();
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
}
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -7,7 +7,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -12,10 +12,11 @@ namespace Flarum\Mentions;
|
||||
use Flarum\Api\Controller;
|
||||
use Flarum\Api\Serializer\BasicPostSerializer;
|
||||
use Flarum\Api\Serializer\BasicUserSerializer;
|
||||
use Flarum\Api\Serializer\CurrentUserSerializer;
|
||||
use Flarum\Api\Serializer\GroupSerializer;
|
||||
use Flarum\Api\Serializer\PostSerializer;
|
||||
use Flarum\Extend;
|
||||
use Flarum\Mentions\Notification\PostMentionedBlueprint;
|
||||
use Flarum\Mentions\Notification\UserMentionedBlueprint;
|
||||
use Flarum\Group\Group;
|
||||
use Flarum\Post\Event\Deleted;
|
||||
use Flarum\Post\Event\Hidden;
|
||||
use Flarum\Post\Event\Posted;
|
||||
@@ -37,13 +38,16 @@ return [
|
||||
->configure(ConfigureMentions::class)
|
||||
->render(Formatter\FormatPostMentions::class)
|
||||
->render(Formatter\FormatUserMentions::class)
|
||||
->render(Formatter\FormatGroupMentions::class)
|
||||
->unparse(Formatter\UnparsePostMentions::class)
|
||||
->unparse(Formatter\UnparseUserMentions::class),
|
||||
->unparse(Formatter\UnparseUserMentions::class)
|
||||
->parse(Formatter\CheckPermissions::class),
|
||||
|
||||
(new Extend\Model(Post::class))
|
||||
->belongsToMany('mentionedBy', Post::class, 'post_mentions_post', 'mentions_post_id', 'post_id')
|
||||
->belongsToMany('mentionsPosts', Post::class, 'post_mentions_post', 'post_id', 'mentions_post_id')
|
||||
->belongsToMany('mentionsUsers', User::class, 'post_mentions_user', 'post_id', 'mentions_user_id'),
|
||||
->belongsToMany('mentionsUsers', User::class, 'post_mentions_user', 'post_id', 'mentions_user_id')
|
||||
->belongsToMany('mentionsGroups', Group::class, 'post_mentions_group', 'post_id', 'mentions_group_id'),
|
||||
|
||||
new Extend\Locales(__DIR__.'/locale'),
|
||||
|
||||
@@ -51,25 +55,28 @@ return [
|
||||
->namespace('flarum-mentions', __DIR__.'/views'),
|
||||
|
||||
(new Extend\Notification())
|
||||
->type(PostMentionedBlueprint::class, PostSerializer::class, ['alert'])
|
||||
->type(UserMentionedBlueprint::class, PostSerializer::class, ['alert']),
|
||||
->type(Notification\PostMentionedBlueprint::class, PostSerializer::class, ['alert'])
|
||||
->type(Notification\UserMentionedBlueprint::class, PostSerializer::class, ['alert'])
|
||||
->type(Notification\GroupMentionedBlueprint::class, PostSerializer::class, ['alert']),
|
||||
|
||||
(new Extend\ApiSerializer(BasicPostSerializer::class))
|
||||
->hasMany('mentionedBy', BasicPostSerializer::class)
|
||||
->hasMany('mentionsPosts', BasicPostSerializer::class)
|
||||
->hasMany('mentionsUsers', BasicUserSerializer::class),
|
||||
->hasMany('mentionsUsers', BasicUserSerializer::class)
|
||||
->hasMany('mentionsGroups', GroupSerializer::class),
|
||||
|
||||
(new Extend\ApiController(Controller\ShowDiscussionController::class))
|
||||
->addInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion'])
|
||||
->load([
|
||||
'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user', 'posts.mentionedBy',
|
||||
'posts.mentionedBy.mentionsPosts', 'posts.mentionedBy.mentionsPosts.user', 'posts.mentionedBy.mentionsUsers',
|
||||
'posts.mentionsGroups'
|
||||
]),
|
||||
|
||||
(new Extend\ApiController(Controller\ListDiscussionsController::class))
|
||||
->load([
|
||||
'firstPost.mentionsUsers', 'firstPost.mentionsPosts', 'firstPost.mentionsPosts.user',
|
||||
'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsPosts.user'
|
||||
'firstPost.mentionsUsers', 'firstPost.mentionsPosts', 'firstPost.mentionsPosts.user', 'firstPost.mentionsGroups',
|
||||
'lastPost.mentionsUsers', 'lastPost.mentionsPosts', 'lastPost.mentionsPosts.user', 'lastPost.mentionsGroups'
|
||||
]),
|
||||
|
||||
(new Extend\ApiController(Controller\ShowPostController::class))
|
||||
@@ -80,13 +87,14 @@ return [
|
||||
->load([
|
||||
'mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionedBy',
|
||||
'mentionedBy.mentionsPosts', 'mentionedBy.mentionsPosts.user', 'mentionedBy.mentionsUsers',
|
||||
'mentionsGroups'
|
||||
]),
|
||||
|
||||
(new Extend\ApiController(Controller\CreatePostController::class))
|
||||
->addInclude(['mentionsPosts', 'mentionsPosts.mentionedBy']),
|
||||
->addOptionalInclude('mentionsGroups'),
|
||||
|
||||
(new Extend\ApiController(Controller\UpdatePostController::class))
|
||||
->addInclude(['mentionsPosts', 'mentionsPosts.mentionedBy']),
|
||||
->addOptionalInclude('mentionsGroups'),
|
||||
|
||||
(new Extend\ApiController(Controller\AbstractSerializeController::class))
|
||||
->prepareDataForSerialization(FilterVisiblePosts::class),
|
||||
@@ -103,4 +111,9 @@ return [
|
||||
|
||||
(new Extend\Filter(PostFilterer::class))
|
||||
->addFilter(Filter\MentionedFilter::class),
|
||||
|
||||
(new Extend\ApiSerializer(CurrentUserSerializer::class))
|
||||
->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user, array $attributes): bool {
|
||||
return $user->can('mentionGroups');
|
||||
})
|
||||
];
|
||||
|
2
extensions/mentions/js/dist/admin.js
generated
vendored
2
extensions/mentions/js/dist/admin.js
generated
vendored
@@ -1,2 +1,2 @@
|
||||
(()=>{var e={n:t=>{var a=t&&t.__esModule?()=>t.default:()=>t;return e.d(a,{a}),a},d:(t,a)=>{for(var r in a)e.o(a,r)&&!e.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:a[r]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},t={};(()=>{"use strict";e.r(t);const a=flarum.core.compat["admin/app"];var r=e.n(a);r().initializers.add("flarum-mentions",(function(){r().extensionData.for("flarum-mentions").registerSetting({setting:"flarum-mentions.allow_username_format",type:"boolean",label:r().translator.trans("flarum-mentions.admin.settings.allow_username_format_label"),help:r().translator.trans("flarum-mentions.admin.settings.allow_username_format_text")})}))})(),module.exports=t})();
|
||||
(()=>{var e={n:t=>{var r=t&&t.__esModule?()=>t.default:()=>t;return e.d(r,{a:r}),r},d:(t,r)=>{for(var a in r)e.o(r,a)&&!e.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:r[a]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},t={};(()=>{"use strict";e.r(t);const r=flarum.core.compat["admin/app"];var a=e.n(r);a().initializers.add("flarum-mentions",(function(){a().extensionData.for("flarum-mentions").registerSetting({setting:"flarum-mentions.allow_username_format",type:"boolean",label:a().translator.trans("flarum-mentions.admin.settings.allow_username_format_label"),help:a().translator.trans("flarum-mentions.admin.settings.allow_username_format_text")}).registerPermission({permission:"mentionGroups",label:a().translator.trans("flarum-mentions.admin.permissions.mention_groups_label"),icon:"fas fa-at"},"start")}))})(),module.exports=t})();
|
||||
//# sourceMappingURL=admin.js.map
|
2
extensions/mentions/js/dist/admin.js.map
generated
vendored
2
extensions/mentions/js/dist/admin.js.map
generated
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"admin.js","mappings":"MACA,IAAIA,EAAsB,CCA1BA,EAAyBC,IACxB,IAAIC,EAASD,GAAUA,EAAOE,WAC7B,IAAOF,EAAiB,QACxB,IAAM,EAEP,OADAD,EAAoBI,EAAEF,EAAQ,CAAEG,IACzBH,CAAM,ECLdF,EAAwB,CAACM,EAASC,KACjC,IAAI,IAAIC,KAAOD,EACXP,EAAoBS,EAAEF,EAAYC,KAASR,EAAoBS,EAAEH,EAASE,IAC5EE,OAAOC,eAAeL,EAASE,EAAK,CAAEI,YAAY,EAAMC,IAAKN,EAAWC,IAE1E,ECNDR,EAAwB,CAACc,EAAKC,IAAUL,OAAOM,UAAUC,eAAeC,KAAKJ,EAAKC,GCClFf,EAAyBM,IACH,oBAAXa,QAA0BA,OAAOC,aAC1CV,OAAOC,eAAeL,EAASa,OAAOC,YAAa,CAAEC,MAAO,WAE7DX,OAAOC,eAAeL,EAAS,aAAc,CAAEe,OAAO,GAAO,G,+BCL9D,MAAM,EAA+BC,OAAOC,KAAKC,OAAO,a,aCExDC,IAAAA,aAAAA,IAAqB,mBAAmB,WACtCA,IAAAA,cAAAA,IAAsB,mBAAmBC,gBAAgB,CACvDC,QAAS,wCACTC,KAAM,UACNC,MAAOJ,IAAAA,WAAAA,MAAqB,8DAC5BK,KAAML,IAAAA,WAAAA,MAAqB,8DAE9B,G","sources":["webpack://@flarum/mentions/webpack/bootstrap","webpack://@flarum/mentions/webpack/runtime/compat get default export","webpack://@flarum/mentions/webpack/runtime/define property getters","webpack://@flarum/mentions/webpack/runtime/hasOwnProperty shorthand","webpack://@flarum/mentions/webpack/runtime/make namespace object","webpack://@flarum/mentions/external root \"flarum.core.compat['admin/app']\"","webpack://@flarum/mentions/./src/admin/index.js"],"sourcesContent":["// The require scope\nvar __webpack_require__ = {};\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['admin/app'];","import app from 'flarum/admin/app';\n\napp.initializers.add('flarum-mentions', function () {\n app.extensionData.for('flarum-mentions').registerSetting({\n setting: 'flarum-mentions.allow_username_format',\n type: 'boolean',\n label: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_label'),\n help: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_text'),\n });\n});\n"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","key","o","Object","defineProperty","enumerable","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","flarum","core","compat","app","registerSetting","setting","type","label","help"],"sourceRoot":""}
|
||||
{"version":3,"file":"admin.js","mappings":"MACA,IAAIA,EAAsB,CCA1BA,EAAyBC,IACxB,IAAIC,EAASD,GAAUA,EAAOE,WAC7B,IAAOF,EAAiB,QACxB,IAAM,EAEP,OADAD,EAAoBI,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,CAAM,ECLdF,EAAwB,CAACM,EAASC,KACjC,IAAI,IAAIC,KAAOD,EACXP,EAAoBS,EAAEF,EAAYC,KAASR,EAAoBS,EAAEH,EAASE,IAC5EE,OAAOC,eAAeL,EAASE,EAAK,CAAEI,YAAY,EAAMC,IAAKN,EAAWC,IAE1E,ECNDR,EAAwB,CAACc,EAAKC,IAAUL,OAAOM,UAAUC,eAAeC,KAAKJ,EAAKC,GCClFf,EAAyBM,IACH,oBAAXa,QAA0BA,OAAOC,aAC1CV,OAAOC,eAAeL,EAASa,OAAOC,YAAa,CAAEC,MAAO,WAE7DX,OAAOC,eAAeL,EAAS,aAAc,CAAEe,OAAO,GAAO,G,+BCL9D,MAAM,EAA+BC,OAAOC,KAAKC,OAAO,a,aCExDC,IAAAA,aAAAA,IAAqB,mBAAmB,WACtCA,IAAAA,cAAAA,IACO,mBACJC,gBAAgB,CACfC,QAAS,wCACTC,KAAM,UACNC,MAAOJ,IAAAA,WAAAA,MAAqB,8DAC5BK,KAAML,IAAAA,WAAAA,MAAqB,+DAE5BM,mBACC,CACEC,WAAY,gBACZH,MAAOJ,IAAAA,WAAAA,MAAqB,0DAC5BQ,KAAM,aAER,QAEL,G","sources":["webpack://@flarum/mentions/webpack/bootstrap","webpack://@flarum/mentions/webpack/runtime/compat get default export","webpack://@flarum/mentions/webpack/runtime/define property getters","webpack://@flarum/mentions/webpack/runtime/hasOwnProperty shorthand","webpack://@flarum/mentions/webpack/runtime/make namespace object","webpack://@flarum/mentions/external root \"flarum.core.compat['admin/app']\"","webpack://@flarum/mentions/./src/admin/index.js"],"sourcesContent":["// The require scope\nvar __webpack_require__ = {};\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['admin/app'];","import app from 'flarum/admin/app';\n\napp.initializers.add('flarum-mentions', function () {\n app.extensionData\n .for('flarum-mentions')\n .registerSetting({\n setting: 'flarum-mentions.allow_username_format',\n type: 'boolean',\n label: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_label'),\n help: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_text'),\n })\n .registerPermission(\n {\n permission: 'mentionGroups',\n label: app.translator.trans('flarum-mentions.admin.permissions.mention_groups_label'),\n icon: 'fas fa-at',\n },\n 'start'\n );\n});\n"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","key","o","Object","defineProperty","enumerable","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","flarum","core","compat","app","registerSetting","setting","type","label","help","registerPermission","permission","icon"],"sourceRoot":""}
|
2
extensions/mentions/js/dist/forum.js
generated
vendored
2
extensions/mentions/js/dist/forum.js
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/mentions/js/dist/forum.js.map
generated
vendored
2
extensions/mentions/js/dist/forum.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -1,10 +1,20 @@
|
||||
import app from 'flarum/admin/app';
|
||||
|
||||
app.initializers.add('flarum-mentions', function () {
|
||||
app.extensionData.for('flarum-mentions').registerSetting({
|
||||
setting: 'flarum-mentions.allow_username_format',
|
||||
type: 'boolean',
|
||||
label: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_label'),
|
||||
help: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_text'),
|
||||
});
|
||||
app.extensionData
|
||||
.for('flarum-mentions')
|
||||
.registerSetting({
|
||||
setting: 'flarum-mentions.allow_username_format',
|
||||
type: 'boolean',
|
||||
label: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_label'),
|
||||
help: app.translator.trans('flarum-mentions.admin.settings.allow_username_format_text'),
|
||||
})
|
||||
.registerPermission(
|
||||
{
|
||||
permission: 'mentionGroups',
|
||||
label: app.translator.trans('flarum-mentions.admin.permissions.mention_groups_label'),
|
||||
icon: 'fas fa-at',
|
||||
},
|
||||
'start'
|
||||
);
|
||||
});
|
||||
|
@@ -10,6 +10,8 @@ import highlight from 'flarum/common/helpers/highlight';
|
||||
import KeyboardNavigatable from 'flarum/forum/utils/KeyboardNavigatable';
|
||||
import { truncate } from 'flarum/common/utils/string';
|
||||
import { throttle } from 'flarum/common/utils/throttleDebounce';
|
||||
import Badge from 'flarum/common/components/Badge';
|
||||
import Group from 'flarum/common/models/Group';
|
||||
|
||||
import AutocompleteDropdown from './fragments/AutocompleteDropdown';
|
||||
import getMentionText from './utils/getMentionText';
|
||||
@@ -29,6 +31,7 @@ const throttledSearch = throttle(
|
||||
|
||||
buildSuggestions();
|
||||
});
|
||||
|
||||
searched.push(typedLower);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +69,13 @@ export default function addComposerAutocomplete() {
|
||||
const returnedUsers = Array.from(app.store.all('users'));
|
||||
const returnedUserIds = new Set(returnedUsers.map((u) => u.id()));
|
||||
|
||||
// Store groups, but exclude the two virtual groups - 'Guest' and 'Member'.
|
||||
const returnedGroups = Array.from(
|
||||
app.store.all('groups').filter((group) => {
|
||||
return group.id() != Group.GUEST_ID && group.id() != Group.MEMBER_ID;
|
||||
})
|
||||
);
|
||||
|
||||
const applySuggestion = (replacement) => {
|
||||
this.attrs.composer.editor.replaceBeforeCursor(absMentionStart - 1, replacement + ' ');
|
||||
|
||||
@@ -124,12 +134,41 @@ export default function addComposerAutocomplete() {
|
||||
);
|
||||
};
|
||||
|
||||
const makeGroupSuggestion = function (group, replacement, content, className = '') {
|
||||
let groupName = group.namePlural().toLowerCase();
|
||||
|
||||
if (typed) {
|
||||
groupName = highlight(groupName, typed);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={'PostPreview ' + className}
|
||||
onclick={() => applySuggestion(replacement)}
|
||||
onmouseenter={function () {
|
||||
dropdown.setIndex($(this).parent().index());
|
||||
}}
|
||||
>
|
||||
<span className="PostPreview-content">
|
||||
<Badge class={`Avatar Badge Badge--group--${group.id()} Badge-icon `} color={group.color()} type="group" icon={group.icon()} />
|
||||
<span className="username">{groupName}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const userMatches = function (user) {
|
||||
const names = [user.username(), user.displayName()];
|
||||
|
||||
return names.some((name) => name.toLowerCase().substr(0, typed.length) === typed);
|
||||
};
|
||||
|
||||
const groupMatches = function (group) {
|
||||
const names = [group.nameSingular(), group.namePlural()];
|
||||
|
||||
return names.some((name) => name.toLowerCase().substr(0, typed.length) === typed);
|
||||
};
|
||||
|
||||
const buildSuggestions = () => {
|
||||
const suggestions = [];
|
||||
|
||||
@@ -141,6 +180,15 @@ export default function addComposerAutocomplete() {
|
||||
|
||||
suggestions.push(makeSuggestion(user, getMentionText(user), '', 'MentionsDropdown-user'));
|
||||
});
|
||||
|
||||
// ... or groups.
|
||||
if (app.session?.user?.canMentionGroups()) {
|
||||
returnedGroups.forEach((group) => {
|
||||
if (!groupMatches(group)) return;
|
||||
|
||||
suggestions.push(makeGroupSuggestion(group, getMentionText(undefined, undefined, group), '', 'MentionsDropdown-group'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If the user is replying to a discussion, or if they are editing a
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import GroupMentionedNotification from './components/GroupMentionedNotification';
|
||||
import MentionsUserPage from './components/MentionsUserPage';
|
||||
import PostMentionedNotification from './components/PostMentionedNotification';
|
||||
import UserMentionedNotification from './components/UserMentionedNotification';
|
||||
@@ -13,6 +14,7 @@ export default {
|
||||
'mentions/components/MentionsUserPage': MentionsUserPage,
|
||||
'mentions/components/PostMentionedNotification': PostMentionedNotification,
|
||||
'mentions/components/UserMentionedNotification': UserMentionedNotification,
|
||||
'mentions/components/GroupMentionedNotification': GroupMentionedNotification,
|
||||
'mentions/fragments/AutocompleteDropdown': AutocompleteDropdown,
|
||||
'mentions/fragments/PostQuoteButton': PostQuoteButton,
|
||||
'mentions/utils/getCleanDisplayName': getCleanDisplayName,
|
||||
|
@@ -0,0 +1,25 @@
|
||||
import app from 'flarum/forum/app';
|
||||
import Notification from 'flarum/forum/components/Notification';
|
||||
import { truncate } from 'flarum/common/utils/string';
|
||||
|
||||
export default class GroupMentionedNotification extends Notification {
|
||||
icon() {
|
||||
return 'fas fa-at';
|
||||
}
|
||||
|
||||
href() {
|
||||
const post = this.attrs.notification.subject();
|
||||
|
||||
return app.route.discussion(post.discussion(), post.number());
|
||||
}
|
||||
|
||||
content() {
|
||||
const user = this.attrs.notification.fromUser();
|
||||
|
||||
return app.translator.trans('flarum-mentions.forum.notifications.group_mentioned_text', { user });
|
||||
}
|
||||
|
||||
excerpt() {
|
||||
return truncate(this.attrs.notification.subject().contentPlain(), 200);
|
||||
}
|
||||
}
|
@@ -10,11 +10,16 @@ import addPostQuoteButton from './addPostQuoteButton';
|
||||
import addComposerAutocomplete from './addComposerAutocomplete';
|
||||
import PostMentionedNotification from './components/PostMentionedNotification';
|
||||
import UserMentionedNotification from './components/UserMentionedNotification';
|
||||
import GroupMentionedNotification from './components/GroupMentionedNotification';
|
||||
import UserPage from 'flarum/forum/components/UserPage';
|
||||
import LinkButton from 'flarum/common/components/LinkButton';
|
||||
import MentionsUserPage from './components/MentionsUserPage';
|
||||
import User from 'flarum/common/models/User';
|
||||
import Model from 'flarum/common/Model';
|
||||
|
||||
app.initializers.add('flarum-mentions', function () {
|
||||
User.prototype.canMentionGroups = Model.attribute('canMentionGroups');
|
||||
|
||||
// For every mention of a post inside a post's content, set up a hover handler
|
||||
// that shows a preview of the mentioned post.
|
||||
addPostMentionPreviews();
|
||||
@@ -36,6 +41,7 @@ app.initializers.add('flarum-mentions', function () {
|
||||
|
||||
app.notificationComponents.postMentioned = PostMentionedNotification;
|
||||
app.notificationComponents.userMentioned = UserMentionedNotification;
|
||||
app.notificationComponents.groupMentioned = GroupMentionedNotification;
|
||||
|
||||
// Add notification preferences.
|
||||
extend(NotificationGrid.prototype, 'notificationTypes', function (items) {
|
||||
@@ -50,6 +56,12 @@ app.initializers.add('flarum-mentions', function () {
|
||||
icon: 'fas fa-at',
|
||||
label: app.translator.trans('flarum-mentions.forum.settings.notify_user_mentioned_label'),
|
||||
});
|
||||
|
||||
items.add('groupMentioned', {
|
||||
name: 'groupMentioned',
|
||||
icon: 'fas fa-at',
|
||||
label: app.translator.trans('flarum-mentions.forum.settings.notify_group_mentioned_label'),
|
||||
});
|
||||
});
|
||||
|
||||
// Add mentions tab in user profile
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import getCleanDisplayName, { shouldUseOldFormat } from './getCleanDisplayName';
|
||||
|
||||
/**
|
||||
* Fetches the mention text for a specified user (and optionally a post ID for replies).
|
||||
* Fetches the mention text for a specified user (and optionally a post ID for replies, or group).
|
||||
*
|
||||
* Automatically determines which mention syntax to be used based on the option in the
|
||||
* admin dashboard. Also performs display name clean-up automatically.
|
||||
@@ -17,9 +17,13 @@ import getCleanDisplayName, { shouldUseOldFormat } from './getCleanDisplayName';
|
||||
* @example <caption>Using old syntax</caption>
|
||||
* // '@username'
|
||||
* getMentionText(User) // User's username is 'username'
|
||||
*
|
||||
* @example <caption>Group mention</caption>
|
||||
* // '@"Mods"#g4'
|
||||
* getMentionText(undefined, undefined, group) // Group display name is 'Mods', group ID is 4
|
||||
*/
|
||||
export default function getMentionText(user, postId) {
|
||||
if (postId === undefined) {
|
||||
export default function getMentionText(user, postId, group) {
|
||||
if (user !== undefined && postId === undefined) {
|
||||
if (shouldUseOldFormat()) {
|
||||
// Plain @username
|
||||
const cleanText = getCleanDisplayName(user, false);
|
||||
@@ -28,9 +32,14 @@ export default function getMentionText(user, postId) {
|
||||
// @"Display name"#UserID
|
||||
const cleanText = getCleanDisplayName(user);
|
||||
return `@"${cleanText}"#${user.id()}`;
|
||||
} else {
|
||||
} else if (user !== undefined && postId !== undefined) {
|
||||
// @"Display name"#pPostID
|
||||
const cleanText = getCleanDisplayName(user);
|
||||
return `@"${cleanText}"#p${postId}`;
|
||||
} else if (group !== undefined) {
|
||||
// @"Name Plural"#gGroupID
|
||||
return `@"${group.namePlural()}"#g${group.id()}`;
|
||||
} else {
|
||||
throw 'No parameters were passed';
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import app from 'flarum/forum/app';
|
||||
import username from 'flarum/common/helpers/username';
|
||||
import extractText from 'flarum/common/utils/extractText';
|
||||
import isDark from 'flarum/common/utils/isDark';
|
||||
|
||||
export function filterUserMentions(tag) {
|
||||
let user;
|
||||
@@ -31,3 +32,20 @@ export function filterPostMentions(tag) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterGroupMentions(tag) {
|
||||
if (app.session?.user?.canMentionGroups()) {
|
||||
const group = app.store.getById('groups', tag.getAttribute('id'));
|
||||
|
||||
if (group) {
|
||||
tag.setAttribute('groupname', extractText(group.namePlural()));
|
||||
tag.setAttribute('icon', group.icon());
|
||||
tag.setAttribute('color', group.color());
|
||||
tag.setAttribute('class', isDark(group.color()) ? 'GroupMention--light' : 'GroupMention--dark');
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
tag.invalidate();
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
.PostMention, .UserMention {
|
||||
.PostMention, .UserMention, .GroupMention {
|
||||
background: @control-bg;
|
||||
color: @control-color;
|
||||
border-radius: @border-radius;
|
||||
@@ -14,7 +14,7 @@
|
||||
color: @link-color;
|
||||
}
|
||||
}
|
||||
.UserMention, .PostMention {
|
||||
.UserMention, .PostMention, .GroupMention {
|
||||
&--deleted {
|
||||
opacity: 0.8;
|
||||
filter: grayscale(1);
|
||||
@@ -97,6 +97,45 @@
|
||||
position: absolute;
|
||||
.Button--color(@tooltip-color, @tooltip-bg);
|
||||
}
|
||||
.GroupMention {
|
||||
& when (@config-dark-mode = false) {
|
||||
&,
|
||||
&:hover,
|
||||
&:active {
|
||||
color: @text-on-light;
|
||||
}
|
||||
}
|
||||
& when (@config-dark-mode = true) {
|
||||
&,
|
||||
&:hover,
|
||||
&:active {
|
||||
color: @text-on-dark;
|
||||
}
|
||||
}
|
||||
|
||||
&--light {
|
||||
&,
|
||||
&:hover,
|
||||
&:active {
|
||||
color: @text-on-light;
|
||||
}
|
||||
}
|
||||
|
||||
&--dark {
|
||||
&,
|
||||
&:hover,
|
||||
&:active {
|
||||
color: @text-on-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
.MentionsDropdown .Badge {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media @phone {
|
||||
.MentionsDropdown {
|
||||
|
@@ -7,6 +7,9 @@ flarum-mentions:
|
||||
# Translations in this namespace are used by the admin interface.
|
||||
admin:
|
||||
|
||||
# These translations are used in the mentions permissions
|
||||
permissions:
|
||||
mention_groups_label: Mention groups
|
||||
# These translations are used in the mentions Settings page.
|
||||
settings:
|
||||
allow_username_format_label: Allow username mention format (@Username)
|
||||
@@ -19,7 +22,7 @@ flarum-mentions:
|
||||
|
||||
# These translations are used by the composer (reply autocompletion function).
|
||||
composer:
|
||||
mention_tooltip: Mention a user or post
|
||||
mention_tooltip: Mention a user, group or post
|
||||
reply_to_post_text: "Reply to #{number}"
|
||||
|
||||
# These translations are used by the Notifications dropdown, a.k.a. "the bell".
|
||||
@@ -27,6 +30,7 @@ flarum-mentions:
|
||||
others_text: => core.ref.some_others
|
||||
post_mentioned_text: "{username} replied to your post" # Can be pluralized to agree with the number of users!
|
||||
user_mentioned_text: "{username} mentioned you"
|
||||
group_mentioned_text: "{username} mentioned a group you're a member of"
|
||||
|
||||
# These translations are displayed beneath individual posts.
|
||||
post:
|
||||
@@ -41,6 +45,7 @@ flarum-mentions:
|
||||
settings:
|
||||
notify_post_mentioned_label: Someone replies to one of my posts
|
||||
notify_user_mentioned_label: Someone mentions me in a post
|
||||
notify_group_mentioned_label: Someone mentions a group I'm a member of in a post
|
||||
|
||||
# These translations are used in the user profile page and profile popup.
|
||||
user:
|
||||
@@ -50,6 +55,9 @@ flarum-mentions:
|
||||
post_mention:
|
||||
deleted_text: "[unknown]"
|
||||
|
||||
group_mention:
|
||||
deleted_text: "[unknown group]"
|
||||
|
||||
# Translations in this namespace are used in emails sent by the forum.
|
||||
email:
|
||||
|
||||
@@ -80,4 +88,16 @@ flarum-mentions:
|
||||
---
|
||||
|
||||
{content}
|
||||
# These translations are used in emails sent when a group is mentioned
|
||||
group_mentioned:
|
||||
subject: "{mentioner_display_name} mentioned a group you're a member of in {title}"
|
||||
body: |
|
||||
Hey {recipient_display_name}!
|
||||
|
||||
{mentioner_display_name} mentioned a group you're a member of in {title}.
|
||||
|
||||
{url}
|
||||
|
||||
---
|
||||
|
||||
{content}
|
||||
|
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
|
||||
return [
|
||||
'up' => function (Builder $schema) {
|
||||
$schema->create('post_mentions_group', function (Blueprint $table) {
|
||||
$table->integer('post_id')->unsigned();
|
||||
$table->integer('mentions_group_id')->unsigned();
|
||||
$table->dateTime('created_at')->useCurrent()->nullable();
|
||||
$table->primary(['post_id', 'mentions_group_id']);
|
||||
|
||||
$table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade');
|
||||
$table->foreign('mentions_group_id')->references('id')->on('groups')->onDelete('cascade');
|
||||
});
|
||||
},
|
||||
|
||||
'down' => function (Builder $schema) {
|
||||
$schema->drop('post_mentions_group');
|
||||
}
|
||||
];
|
@@ -9,10 +9,12 @@
|
||||
|
||||
namespace Flarum\Mentions;
|
||||
|
||||
use Flarum\Group\Group;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Post\CommentPost;
|
||||
use Flarum\Post\PostRepository;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Support\Str;
|
||||
use s9e\TextFormatter\Configurator;
|
||||
|
||||
class ConfigureMentions
|
||||
@@ -34,6 +36,7 @@ class ConfigureMentions
|
||||
{
|
||||
$this->configureUserMentions($config);
|
||||
$this->configurePostMentions($config);
|
||||
$this->configureGroupMentions($config);
|
||||
}
|
||||
|
||||
private function configureUserMentions(Configurator $config)
|
||||
@@ -112,7 +115,8 @@ class ConfigureMentions
|
||||
|
||||
$tag->filterChain
|
||||
->prepend([static::class, 'addPostId'])
|
||||
->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterPostMentions(tag); }');
|
||||
->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterPostMentions(tag); }')
|
||||
->addParameterByName('actor');
|
||||
|
||||
$config->Preg->match('/\B@["|“](?<displayname>((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#p(?<id>[0-9]+)\b/', $tagName);
|
||||
}
|
||||
@@ -121,9 +125,11 @@ class ConfigureMentions
|
||||
* @param $tag
|
||||
* @return bool
|
||||
*/
|
||||
public static function addPostId($tag)
|
||||
public static function addPostId($tag, User $actor)
|
||||
{
|
||||
$post = CommentPost::find($tag->getAttribute('id'));
|
||||
$post = resolve(PostRepository::class)
|
||||
->queryVisibleTo($actor)
|
||||
->find($tag->getAttribute('id'));
|
||||
|
||||
if ($post) {
|
||||
$tag->setAttribute('discussionid', (int) $post->discussion_id);
|
||||
@@ -136,4 +142,80 @@ class ConfigureMentions
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function configureGroupMentions(Configurator $config)
|
||||
{
|
||||
$tagName = 'GROUPMENTION';
|
||||
|
||||
$tag = $config->tags->add($tagName);
|
||||
$tag->attributes->add('groupname');
|
||||
$tag->attributes->add('icon');
|
||||
$tag->attributes->add('color');
|
||||
$tag->attributes->add('class');
|
||||
$tag->attributes->add('id')->filterChain->append('#uint');
|
||||
|
||||
$tag->template = '
|
||||
<xsl:choose>
|
||||
<xsl:when test="@deleted != 1">
|
||||
<span class="GroupMention {@class}" style="background: {@color}">@<xsl:value-of select="@groupname"/><i class="icon {@icon}"></i></span>
|
||||
</xsl:when>
|
||||
<xsl:otherwise>
|
||||
<span class="GroupMention GroupMention--deleted" style="background: {@color}">@<xsl:value-of select="@groupname"/><i class="icon {@icon}"></i></span>
|
||||
</xsl:otherwise>
|
||||
</xsl:choose>';
|
||||
$tag->filterChain->prepend([static::class, 'addGroupId'])
|
||||
->setJS('function(tag) { return flarum.extensions["flarum-mentions"].filterGroupMentions(tag); }');
|
||||
|
||||
$config->Preg->match('/\B@["|“](?<groupname>((?!"#[a-z]{0,3}[0-9]+).)+)["|”]#g(?<id>[0-9]+)\b/', $tagName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $tag
|
||||
* @return bool
|
||||
*/
|
||||
public static function addGroupId($tag)
|
||||
{
|
||||
$group = Group::find($tag->getAttribute('id'));
|
||||
|
||||
if (isset($group) && ! in_array($group->id, [Group::GUEST_ID, Group::MEMBER_ID])) {
|
||||
$tag->setAttribute('id', $group->id);
|
||||
$tag->setAttribute('groupname', $group->name_plural);
|
||||
$tag->setAttribute('icon', $group->icon ?? 'fas fa-at');
|
||||
$tag->setAttribute('color', $group->color);
|
||||
if (! empty($group->color)) {
|
||||
$tag->setAttribute('class', self::isDark($group->color) ? 'GroupMention--light' : 'GroupMention--dark');
|
||||
} else {
|
||||
$tag->setAttribute('class', '');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$tag->invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* The `isDark` utility converts a hex color to rgb, and then calcul a YIQ
|
||||
* value in order to get the appropriate brightness value (is it dark or is it
|
||||
* light?) See https://www.w3.org/TR/AERT/#color-contrast for references. A YIQ
|
||||
* value >= 128 is a light color.
|
||||
*/
|
||||
public static function isDark(?string $hexColor): bool
|
||||
{
|
||||
if (! $hexColor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hexNumbers = Str::replace('#', '', $hexColor);
|
||||
if (Str::length($hexNumbers) === 3) {
|
||||
$hexNumbers += $hexNumbers;
|
||||
}
|
||||
|
||||
$r = hexdec(Str::substr($hexNumbers, 0, 2));
|
||||
$g = hexdec(Str::subStr($hexNumbers, 2, 2));
|
||||
$b = hexdec(Str::subStr($hexNumbers, 4, 2));
|
||||
$yiq = ($r * 299 + $g * 587 + $b * 114) / 1000;
|
||||
|
||||
return $yiq >= 128 ? false : true;
|
||||
}
|
||||
}
|
||||
|
@@ -54,8 +54,8 @@ class FilterVisiblePosts
|
||||
|| $controller instanceof Controller\CreatePostController
|
||||
|| $controller instanceof Controller\UpdatePostController) {
|
||||
$relations = [
|
||||
'mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionedBy',
|
||||
'mentionedBy.mentionsPosts', 'mentionedBy.mentionsPosts.user', 'mentionedBy.mentionsUsers'
|
||||
'mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionedBy', 'mentionsGroups',
|
||||
'mentionedBy.mentionsPosts', 'mentionedBy.mentionsPosts.user', 'mentionedBy.mentionsUsers', 'mentionedBy.mentionsGroups.group'
|
||||
];
|
||||
|
||||
$posts = [$data];
|
||||
|
26
extensions/mentions/src/Formatter/CheckPermissions.php
Normal file
26
extensions/mentions/src/Formatter/CheckPermissions.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mentions\Formatter;
|
||||
|
||||
use Flarum\User\User;
|
||||
use s9e\TextFormatter\Parser;
|
||||
|
||||
class CheckPermissions
|
||||
{
|
||||
public function __invoke(Parser $parser, $content, string $text, ?User $actor): string
|
||||
{
|
||||
// Check user has `mentionGroups` permission, if not, remove the `GROUPMENTION` tag from the parser.
|
||||
if ($actor && $actor->cannot('mentionGroups')) {
|
||||
$parser->disableTag('GROUPMENTION');
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
59
extensions/mentions/src/Formatter/FormatGroupMentions.php
Normal file
59
extensions/mentions/src/Formatter/FormatGroupMentions.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mentions\Formatter;
|
||||
|
||||
use Flarum\Group\Group;
|
||||
use Flarum\Post\Post;
|
||||
use s9e\TextFormatter\Renderer;
|
||||
use s9e\TextFormatter\Utils;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class FormatGroupMentions
|
||||
{
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
private $translator;
|
||||
|
||||
public function __construct(TranslatorInterface $translator)
|
||||
{
|
||||
$this->translator = $translator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure rendering for group mentions.
|
||||
*
|
||||
* @param \s9e\TextFormatter\Renderer $renderer
|
||||
* @param mixed $context
|
||||
* @param string $xml
|
||||
* @return string
|
||||
*/
|
||||
public function __invoke(Renderer $renderer, $context, string $xml): string
|
||||
{
|
||||
return Utils::replaceAttributes($xml, 'GROUPMENTION', function ($attributes) use ($context) {
|
||||
$group = (($context && isset($context->getRelations()['mentionsGroups'])) || $context instanceof Post)
|
||||
? $context->mentionsGroups->find($attributes['id'])
|
||||
: Group::find($attributes['id']);
|
||||
|
||||
if ($group) {
|
||||
$attributes['groupname'] = $group->name_plural;
|
||||
$attributes['icon'] = $group->icon ?? 'fas fa-at';
|
||||
$attributes['color'] = $group->color;
|
||||
$attributes['deleted'] = false;
|
||||
} else {
|
||||
$attributes['groupname'] = $this->translator->trans('flarum-mentions.forum.group_mention.deleted_text');
|
||||
$attributes['icon'] = '';
|
||||
$attributes['deleted'] = true;
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
});
|
||||
}
|
||||
}
|
@@ -40,5 +40,8 @@ class UpdateMentionsMetadataWhenInvisible
|
||||
|
||||
// Remove post mentions
|
||||
$event->post->mentionsPosts()->sync([]);
|
||||
|
||||
// Remove group mentions
|
||||
$event->post->mentionsGroups()->sync([]);
|
||||
}
|
||||
}
|
||||
|
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace Flarum\Mentions\Listener;
|
||||
|
||||
use Flarum\Mentions\Notification\GroupMentionedBlueprint;
|
||||
use Flarum\Mentions\Notification\PostMentionedBlueprint;
|
||||
use Flarum\Mentions\Notification\UserMentionedBlueprint;
|
||||
use Flarum\Notification\NotificationSyncer;
|
||||
@@ -50,6 +51,11 @@ class UpdateMentionsMetadataWhenVisible
|
||||
$event->post,
|
||||
Utils::getAttributeValues($content, 'POSTMENTION', 'id')
|
||||
);
|
||||
|
||||
$this->syncGroupMentions(
|
||||
$event->post,
|
||||
Utils::getAttributeValues($content, 'GROUPMENTION', 'id')
|
||||
);
|
||||
}
|
||||
|
||||
protected function syncUserMentions(Post $post, array $mentioned)
|
||||
@@ -84,4 +90,21 @@ class UpdateMentionsMetadataWhenVisible
|
||||
$this->notifications->sync(new PostMentionedBlueprint($post, $reply), [$post->user]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function syncGroupMentions(Post $post, array $mentioned)
|
||||
{
|
||||
$post->mentionsGroups()->sync($mentioned);
|
||||
$post->unsetRelation('mentionsGroups');
|
||||
|
||||
$users = User::whereHas('groups', function ($query) use ($mentioned) {
|
||||
$query->whereIn('id', $mentioned);
|
||||
})
|
||||
->get()
|
||||
->filter(function (User $user) use ($post) {
|
||||
return $post->isVisibleTo($user) && $user->id !== $post->user_id;
|
||||
})
|
||||
->all();
|
||||
|
||||
$this->notifications->sync(new GroupMentionedBlueprint($post), $users);
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mentions\Notification;
|
||||
|
||||
use Flarum\Notification\Blueprint\BlueprintInterface;
|
||||
use Flarum\Notification\MailableInterface;
|
||||
use Flarum\Post\Post;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class GroupMentionedBlueprint implements BlueprintInterface, MailableInterface
|
||||
{
|
||||
/**
|
||||
* @var Post
|
||||
*/
|
||||
public $post;
|
||||
|
||||
/**
|
||||
* @param Post $post
|
||||
*/
|
||||
public function __construct(Post $post)
|
||||
{
|
||||
$this->post = $post;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getSubject()
|
||||
{
|
||||
return $this->post;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFromUser()
|
||||
{
|
||||
return $this->post->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getData()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getEmailView()
|
||||
{
|
||||
return ['text' => 'flarum-mentions::emails.groupMentioned'];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getEmailSubject(TranslatorInterface $translator)
|
||||
{
|
||||
return $translator->trans('flarum-mentions.email.group_mentioned.subject', [
|
||||
'{mentioner_display_name}' => $this->post->user->display_name,
|
||||
'{title}' => $this->post->discussion->title
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function getType()
|
||||
{
|
||||
return 'groupMentioned';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function getSubjectModel()
|
||||
{
|
||||
return Post::class;
|
||||
}
|
||||
}
|
420
extensions/mentions/tests/integration/api/GroupMentionsTest.php
Normal file
420
extensions/mentions/tests/integration/api/GroupMentionsTest.php
Normal file
@@ -0,0 +1,420 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Mentions\Tests\integration\api;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Group\Group;
|
||||
use Flarum\Post\CommentPost;
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
use Flarum\User\User;
|
||||
|
||||
class GroupMentionsTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->extension('flarum-mentions');
|
||||
|
||||
$this->prepareDatabase([
|
||||
'users' => [
|
||||
['id' => 3, 'username' => 'potato', 'email' => 'potato@machine.local', 'is_email_confirmed' => 1],
|
||||
['id' => 4, 'username' => 'toby', 'email' => 'toby@machine.local', 'is_email_confirmed' => 1],
|
||||
['id' => 5, 'username' => 'bad_user', 'email' => 'bad_user@machine.local', 'is_email_confirmed' => 1],
|
||||
],
|
||||
'discussions' => [
|
||||
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 3, 'first_post_id' => 4, 'comment_count' => 2],
|
||||
],
|
||||
'posts' => [
|
||||
['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p>One of the <GROUPMENTION color="#80349E" groupname="Mods" icon="fas fa-bolt" id="4">@"Mods"#g4</GROUPMENTION> will look at this</p></r>'],
|
||||
['id' => 6, 'number' => 3, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p><GROUPMENTION color="#80349E" groupname="OldGroupName" icon="fas fa-circle" id="100">@"OldGroupName"#g100</GROUPMENTION></p></r>'],
|
||||
['id' => 7, 'number' => 4, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><p><GROUPMENTION color="#000" groupname="OldGroupName" icon="fas fa-circle" id="11">@"OldGroupName"#g11</GROUPMENTION></p></r>'],
|
||||
],
|
||||
'post_mentions_group' => [
|
||||
['post_id' => 4, 'mentions_group_id' => 4],
|
||||
['post_id' => 7, 'mentions_group_id' => 11],
|
||||
],
|
||||
'group_permission' => [
|
||||
['group_id' => Group::MEMBER_ID, 'permission' => 'postWithoutThrottle'],
|
||||
],
|
||||
'groups' => [
|
||||
[
|
||||
'id' => 10,
|
||||
'name_singular' => 'Hidden',
|
||||
'name_plural' => 'Ninjas',
|
||||
'color' => null,
|
||||
'icon' => 'fas fa-wrench',
|
||||
'is_hidden' => 1
|
||||
],
|
||||
[
|
||||
'id' => 11,
|
||||
'name_singular' => 'Fresh Name',
|
||||
'name_plural' => 'Fresh Name',
|
||||
'color' => '#ccc',
|
||||
'icon' => 'fas fa-users',
|
||||
'is_hidden' => 0
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function rendering_a_valid_group_mention_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/posts/4')
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('<p>One of the <span style="background:#80349E" class="GroupMention ">@Mods<i class="icon fas fa-bolt"></i></span> will look at this</p>', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsGroups->find(4));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function mentioning_an_invalid_group_doesnt_work()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"InvalidGroup"#g99',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
]
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('@"InvalidGroup"#g99', $response['data']['attributes']['content']);
|
||||
$this->assertStringNotContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function deleted_group_mentions_render_with_deleted_label()
|
||||
{
|
||||
$deleted_text = $this->app()->getContainer()->make('translator')->trans('flarum-mentions.forum.group_mention.deleted_text');
|
||||
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/posts/6', [
|
||||
'authenticatedAs' => 1,
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString("@$deleted_text", $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('GroupMention--deleted', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringNotContainsString('@OldGroupName', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function group_mentions_render_with_fresh_data()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/posts/7', [
|
||||
'authenticatedAs' => 1,
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('@Fresh Name', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringNotContainsString('@OldGroupName', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsGroups->find(11));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function mentioning_a_group_as_an_admin_user_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Mods"#g4',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
]
|
||||
]
|
||||
]
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('@Mods', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('fas fa-bolt', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"Mods"#g4', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function mentioning_multiple_groups_as_an_admin_user_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Admins"#g1 @"Mods"#g4',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
]
|
||||
]
|
||||
]
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('@Admins', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('@Mods', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('fas fa-wrench', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('fas fa-bolt', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"Admins"#g1 @"Mods"#g4', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(2, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function mentioning_a_virtual_group_as_an_admin_user_does_not_work()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Members"#g3 @"Guests"#g2',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
]
|
||||
]
|
||||
]
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringNotContainsString('@Members', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringNotContainsString('@Guests', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"Members"#g3 @"Guests"#g2', $response['data']['attributes']['content']);
|
||||
$this->assertStringNotContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function regular_user_does_not_have_group_mention_permission_by_default()
|
||||
{
|
||||
$this->database();
|
||||
$this->assertFalse(User::find(3)->can('mentionGroups'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function regular_user_does_have_group_mention_permission_when_added()
|
||||
{
|
||||
$this->prepareDatabase([
|
||||
'group_permission' => [
|
||||
['group_id' => Group::MEMBER_ID, 'permission' => 'mentionGroups'],
|
||||
]
|
||||
]);
|
||||
|
||||
$this->database();
|
||||
$this->assertTrue(User::find(3)->can('mentionGroups'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function user_without_permission_cannot_mention_groups()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 3,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Mods"#g4',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringNotContainsString('@Mods', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('@"Mods"#g4', $response['data']['attributes']['content']);
|
||||
$this->assertStringNotContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function user_with_permission_can_mention_groups()
|
||||
{
|
||||
$this->prepareDatabase([
|
||||
'group_permission' => [
|
||||
['group_id' => Group::MEMBER_ID, 'permission' => 'mentionGroups'],
|
||||
]
|
||||
]);
|
||||
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 3,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Mods"#g4',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('@Mods', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('@"Mods"#g4', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function user_with_permission_cannot_mention_hidden_groups()
|
||||
{
|
||||
$this->prepareDatabase([
|
||||
'group_permission' => [
|
||||
['group_id' => Group::MEMBER_ID, 'permission' => 'mentionGroups'],
|
||||
]
|
||||
]);
|
||||
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 3,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"Ninjas"#g10',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringNotContainsString('@Ninjas', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertStringContainsString('@"Ninjas"#g10', $response['data']['attributes']['content']);
|
||||
$this->assertStringNotContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function editing_a_post_that_has_a_mention_works()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('PATCH', '/api/posts/4', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => 'New content with @"Mods"#g4 mention',
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('@Mods', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('New content with @"Mods"#g4 mention', $response['data']['attributes']['content']);
|
||||
$this->assertStringContainsString('GroupMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsGroups->find(4));
|
||||
}
|
||||
}
|
@@ -38,6 +38,7 @@ class PostMentionsTest extends TestCase
|
||||
],
|
||||
'discussions' => [
|
||||
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 3, 'first_post_id' => 4, 'comment_count' => 2],
|
||||
['id' => 50, 'title' => __CLASS__, 'is_private' => true, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 3, 'first_post_id' => 4, 'comment_count' => 1],
|
||||
],
|
||||
'posts' => [
|
||||
['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="TobyFlarum___" id="5" number="2" discussionid="2" username="toby">@tobyuuu#5</POSTMENTION></r>'],
|
||||
@@ -49,6 +50,9 @@ class PostMentionsTest extends TestCase
|
||||
['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="Bad "#p6 User" id="9" number="10" discussionid="2">@"Bad "#p6 User"#p9</POSTMENTION></r>'],
|
||||
['id' => 11, 'number' => 12, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 40, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="Bad "#p6 User" id="9" number="10" discussionid="2">@"Bad "#p6 User"#p9</POSTMENTION></r>'],
|
||||
['id' => 12, 'number' => 13, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="deleted_user" id="11" number="12" discussionid="2">@"acme"#p11</POSTMENTION></r>'],
|
||||
|
||||
// Restricted access
|
||||
['id' => 50, 'number' => 1, 'discussion_id' => 50, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r>no</r>'],
|
||||
],
|
||||
'post_mentions_post' => [
|
||||
['post_id' => 4, 'mentions_post_id' => 5],
|
||||
@@ -128,6 +132,37 @@ class PostMentionsTest extends TestCase
|
||||
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(4));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function cannot_mention_a_post_without_access()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => '@"potato"#p50',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 2]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = json_decode($response->getBody(), true);
|
||||
|
||||
$this->assertStringContainsString('potato', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertEquals('@"potato"#p50', $response['data']['attributes']['content']);
|
||||
$this->assertStringNotContainsString('PostMention', $response['data']['attributes']['contentHtml']);
|
||||
$this->assertNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(50));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
|
@@ -0,0 +1,7 @@
|
||||
{!! $translator->trans('flarum-mentions.email.group_mentioned.body', [
|
||||
'{recipient_display_name}' => $user->display_name,
|
||||
'{mentioner_display_name}' => $blueprint->post->user->display_name,
|
||||
'{title}' => $blueprint->post->discussion->title,
|
||||
'{url}' => $url->to('forum')->route('discussion', ['id' => $blueprint->post->discussion_id, 'near' => $blueprint->post->number]),
|
||||
'{content}' => $blueprint->post->content
|
||||
]) !!}
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -22,7 +22,7 @@
|
||||
"source": "https://github.com/flarum/package-manager"
|
||||
},
|
||||
"require": {
|
||||
"flarum/core": "^1.0.0",
|
||||
"flarum/core": "^1.5.0",
|
||||
"composer/composer": "^2.3"
|
||||
},
|
||||
"require-dev": {
|
||||
|
2
extensions/package-manager/js/dist/admin.js
generated
vendored
2
extensions/package-manager/js/dist/admin.js
generated
vendored
File diff suppressed because one or more lines are too long
2
extensions/package-manager/js/dist/admin.js.map
generated
vendored
2
extensions/package-manager/js/dist/admin.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -64,7 +64,6 @@ export default class Installer extends Component<InstallerAttrs> {
|
||||
body: {
|
||||
data: this.data(),
|
||||
},
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.processing) {
|
||||
@@ -79,8 +78,10 @@ export default class Installer extends Component<InstallerAttrs> {
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(errorHandler)
|
||||
.finally(() => {
|
||||
app.packageManager.control.setLoading(null);
|
||||
app.modal.close();
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
@@ -104,7 +104,6 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
|
||||
body: {
|
||||
data: { dryRun },
|
||||
},
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response?.processing) {
|
||||
@@ -114,6 +113,7 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(errorHandler)
|
||||
.catch((e: RequestError) => {
|
||||
app.modal.close();
|
||||
this.updateState.status = 'failure';
|
||||
|
@@ -47,12 +47,12 @@ export default class WhyNotModal<CustomAttrs extends WhyNotModalAttrs = WhyNotMo
|
||||
package: this.attrs.package,
|
||||
},
|
||||
},
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
this.loading = false;
|
||||
this.whyNot = response.data.reason;
|
||||
m.redraw();
|
||||
});
|
||||
})
|
||||
.catch(errorHandler);
|
||||
}
|
||||
}
|
||||
|
@@ -4,8 +4,9 @@ import ExtensionPage from 'flarum/admin/components/ExtensionPage';
|
||||
import Button from 'flarum/common/components/Button';
|
||||
import LoadingModal from 'flarum/admin/components/LoadingModal';
|
||||
import isExtensionEnabled from 'flarum/admin/utils/isExtensionEnabled';
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
import Alert from 'flarum/common/components/Alert';
|
||||
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
import Task from './models/Task';
|
||||
import jumpToQueue from './utils/jumpToQueue';
|
||||
import extractText from 'flarum/common/utils/extractText';
|
||||
@@ -19,6 +20,13 @@ app.initializers.add('flarum-package-manager', (app) => {
|
||||
|
||||
app.extensionData
|
||||
.for('flarum-package-manager')
|
||||
.registerSetting(() => (
|
||||
<div className="Form-group">
|
||||
<Alert type="warning" dismissible={false}>
|
||||
{app.translator.trans('flarum-package-manager.admin.settings.access_warning')}
|
||||
</Alert>
|
||||
</div>
|
||||
))
|
||||
.registerSetting({
|
||||
setting: 'flarum-package-manager.queue_jobs',
|
||||
label: app.translator.trans('flarum-package-manager.admin.settings.queue_jobs'),
|
||||
|
@@ -94,7 +94,6 @@ export default class ControlSectionState {
|
||||
.request<AsyncBackendResponse | LastUpdateCheck>({
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('apiUrl')}/package-manager/check-for-updates`,
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
if ((response as AsyncBackendResponse).processing) {
|
||||
@@ -106,6 +105,7 @@ export default class ControlSectionState {
|
||||
m.redraw();
|
||||
}
|
||||
})
|
||||
.catch(errorHandler)
|
||||
.finally(() => {
|
||||
this.setLoading(null);
|
||||
m.redraw();
|
||||
@@ -121,7 +121,6 @@ export default class ControlSectionState {
|
||||
.request<AsyncBackendResponse | null>({
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('apiUrl')}/package-manager/minor-update`,
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response?.processing) {
|
||||
@@ -131,6 +130,7 @@ export default class ControlSectionState {
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(errorHandler)
|
||||
.finally(() => {
|
||||
this.setLoading(null);
|
||||
app.modal.close();
|
||||
@@ -147,7 +147,6 @@ export default class ControlSectionState {
|
||||
.request<AsyncBackendResponse | null>({
|
||||
method: 'PATCH',
|
||||
url: `${app.forum.attribute('apiUrl')}/package-manager/extensions/${extension.id}`,
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response?.processing) {
|
||||
@@ -162,6 +161,7 @@ export default class ControlSectionState {
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(errorHandler)
|
||||
.finally(() => {
|
||||
this.setLoading(null);
|
||||
app.modal.close();
|
||||
@@ -177,7 +177,6 @@ export default class ControlSectionState {
|
||||
.request<AsyncBackendResponse | null>({
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('apiUrl')}/package-manager/global-update`,
|
||||
errorHandler,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response?.processing) {
|
||||
@@ -187,6 +186,7 @@ export default class ControlSectionState {
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(errorHandler)
|
||||
.finally(() => {
|
||||
this.setLoading(null);
|
||||
app.modal.close();
|
||||
|
@@ -72,6 +72,7 @@ flarum-package-manager:
|
||||
title: Queue
|
||||
|
||||
settings:
|
||||
access_warning: Please be careful to who you give access to the admin area, the package manager could be misused by bad actors to install packages that can lead to security breaches.
|
||||
queue_jobs: Run operations in the background queue
|
||||
queue_jobs_help: >
|
||||
You can read about a <a href='{basic_impl_link}'>basic queue</a> implementation or a <a href='{adv_impl_link}'>more advanced</a> one.
|
||||
|
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace Flarum\PackageManager\Composer;
|
||||
|
||||
use Composer\Config;
|
||||
use Composer\Console\Application;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Flarum\PackageManager\OutputLogger;
|
||||
@@ -70,4 +71,9 @@ class ComposerAdapter
|
||||
|
||||
return new ComposerOutput($exitCode, $output);
|
||||
}
|
||||
|
||||
public static function setPhpVersion(string $phpVersion)
|
||||
{
|
||||
Config::$defaultConfig['platform']['php'] = $phpVersion;
|
||||
}
|
||||
}
|
||||
|
@@ -11,6 +11,7 @@ namespace Flarum\PackageManager\Job;
|
||||
|
||||
use Flarum\Bus\Dispatcher;
|
||||
use Flarum\PackageManager\Command\BusinessCommandInterface;
|
||||
use Flarum\PackageManager\Composer\ComposerAdapter;
|
||||
use Flarum\Queue\AbstractJob;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Throwable;
|
||||
@@ -23,11 +24,11 @@ class ComposerCommandJob extends AbstractJob
|
||||
protected $command;
|
||||
|
||||
/**
|
||||
* @var int[]
|
||||
* @var string
|
||||
*/
|
||||
protected $phpVersion;
|
||||
|
||||
public function __construct(BusinessCommandInterface $command, array $phpVersion)
|
||||
public function __construct(BusinessCommandInterface $command, string $phpVersion)
|
||||
{
|
||||
$this->command = $command;
|
||||
$this->phpVersion = $phpVersion;
|
||||
@@ -36,12 +37,7 @@ class ComposerCommandJob extends AbstractJob
|
||||
public function handle(Dispatcher $bus)
|
||||
{
|
||||
try {
|
||||
if ([PHP_MAJOR_VERSION, PHP_MINOR_VERSION] !== [$this->phpVersion[0], $this->phpVersion[1]]) {
|
||||
$webPhpVersion = implode('.', $this->phpVersion);
|
||||
$sshPhpVersion = implode('.', [PHP_MAJOR_VERSION, PHP_MINOR_VERSION]);
|
||||
|
||||
throw new \Exception("PHP version mismatch. SSH PHP version must match web server PHP version. Found SSH (PHP $sshPhpVersion) and Web Server (PHP $webPhpVersion).");
|
||||
}
|
||||
ComposerAdapter::setPhpVersion($this->phpVersion);
|
||||
|
||||
$this->command->task->start();
|
||||
|
||||
|
@@ -73,7 +73,7 @@ class Dispatcher
|
||||
$command->task = $task;
|
||||
|
||||
$this->queue->push(
|
||||
new ComposerCommandJob($command, [PHP_MAJOR_VERSION, PHP_MINOR_VERSION])
|
||||
new ComposerCommandJob($command, PHP_VERSION)
|
||||
);
|
||||
} else {
|
||||
$data = $this->bus->dispatch($command);
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4",
|
||||
"flarum/core": "^1.6",
|
||||
"pusher/pusher-php-server": "^2.2"
|
||||
},
|
||||
"require-dev": {
|
||||
|
1
extensions/pusher/js/dist/forum.js.LICENSE.txt
generated
vendored
Normal file
1
extensions/pusher/js/dist/forum.js.LICENSE.txt
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
|
@@ -1,9 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.2](https://github.com/flarum/statistics/compare/v1.4.1...v1.4.2)
|
||||
## [1.5.1](https://github.com/flarum/statistics/compare/v1.5.0...v1.5.1)
|
||||
|
||||
### Changed
|
||||
- Split timed data into per-model XHR requests (https://github.com/flarum/framework/pull/3601)
|
||||
### Added
|
||||
- Support for custom date ranges (https://github.com/flarum/framework/pull/3622)
|
||||
|
||||
### Fixed
|
||||
- Previous period chart is unclear (https://github.com/flarum/framework/pull/3654)
|
||||
|
||||
## [1.4.1](https://github.com/flarum/statistics/compare/v1.4.0...v1.4.1)
|
||||
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
6
extensions/statistics/js/dist-typings/components/StatisticsWidget.d.ts
generated
vendored
6
extensions/statistics/js/dist-typings/components/StatisticsWidget.d.ts
generated
vendored
@@ -1,4 +1,5 @@
|
||||
import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget';
|
||||
import { IDateSelection } from './StatisticsWidgetDateSelectionModal';
|
||||
import type Mithril from 'mithril';
|
||||
interface IPeriodDeclaration {
|
||||
start: number;
|
||||
@@ -9,10 +10,14 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
entities: string[];
|
||||
periods: undefined | Record<string, IPeriodDeclaration>;
|
||||
chart: any;
|
||||
customPeriod: IDateSelection | null;
|
||||
timedData: Record<string, undefined | any>;
|
||||
lifetimeData: any;
|
||||
customPeriodData: Record<string, undefined | any>;
|
||||
noData: boolean;
|
||||
loadingLifetime: boolean;
|
||||
loadingTimed: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>;
|
||||
loadingCustom: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>;
|
||||
selectedEntity: string;
|
||||
selectedPeriod: undefined | string;
|
||||
chartEntity?: string;
|
||||
@@ -20,6 +25,7 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
oncreate(vnode: Mithril.VnodeDOM<IDashboardWidgetAttrs, this>): void;
|
||||
loadLifetimeData(): Promise<void>;
|
||||
loadTimedData(model: string): Promise<void>;
|
||||
loadCustomRangeData(model: string): Promise<void>;
|
||||
className(): string;
|
||||
content(): JSX.Element;
|
||||
drawChart(vnode: Mithril.VnodeDOM<any, any>): void;
|
||||
|
39
extensions/statistics/js/dist-typings/components/StatisticsWidgetDateSelectionModal.d.ts
generated
vendored
Normal file
39
extensions/statistics/js/dist-typings/components/StatisticsWidgetDateSelectionModal.d.ts
generated
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import ItemList from 'flarum/common/utils/ItemList';
|
||||
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
|
||||
import Mithril from 'mithril';
|
||||
export interface IDateSelection {
|
||||
/**
|
||||
* Timestamp (seconds, not ms) for start date
|
||||
*/
|
||||
start: number;
|
||||
/**
|
||||
* Timestamp (seconds, not ms) for end date
|
||||
*/
|
||||
end: number;
|
||||
}
|
||||
export interface IStatisticsWidgetDateSelectionModalAttrs extends IInternalModalAttrs {
|
||||
onModalSubmit: (dates: IDateSelection) => void;
|
||||
value?: IDateSelection;
|
||||
}
|
||||
interface IStatisticsWidgetDateSelectionModalState {
|
||||
inputs: {
|
||||
startDateVal: string;
|
||||
endDateVal: string;
|
||||
};
|
||||
ids: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
}
|
||||
export default class StatisticsWidgetDateSelectionModal extends Modal<IStatisticsWidgetDateSelectionModalAttrs> {
|
||||
state: IStatisticsWidgetDateSelectionModalState;
|
||||
oninit(vnode: Mithril.Vnode<IStatisticsWidgetDateSelectionModalAttrs, this>): void;
|
||||
className(): string;
|
||||
title(): Mithril.Children;
|
||||
content(): Mithril.Children;
|
||||
items(): ItemList<Mithril.Children>;
|
||||
updateState(field: keyof IStatisticsWidgetDateSelectionModalState['inputs']): (e: InputEvent) => void;
|
||||
submitData(): IDateSelection;
|
||||
onsubmit(e: SubmitEvent): void;
|
||||
}
|
||||
export {};
|
2
extensions/statistics/js/dist/admin.js
generated
vendored
2
extensions/statistics/js/dist/admin.js
generated
vendored
File diff suppressed because one or more lines are too long
1
extensions/statistics/js/dist/admin.js.LICENSE.txt
generated
vendored
Normal file
1
extensions/statistics/js/dist/admin.js.LICENSE.txt
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
|
2
extensions/statistics/js/dist/admin.js.map
generated
vendored
2
extensions/statistics/js/dist/admin.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -7,15 +7,15 @@
|
||||
"frappe-charts": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mithril": "^2.0.11",
|
||||
"prettier": "^2.7.1",
|
||||
"flarum-webpack-config": "^2.0.0",
|
||||
"webpack": "^5.73.0",
|
||||
"webpack-cli": "^4.10.0",
|
||||
"@flarum/prettier-config": "^1.0.0",
|
||||
"@types/mithril": "^2.0.11",
|
||||
"flarum-tsconfig": "^1.0.2",
|
||||
"flarum-webpack-config": "^2.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"typescript": "^4.7.4",
|
||||
"typescript-coverage-report": "^0.6.4"
|
||||
"typescript-coverage-report": "^0.6.4",
|
||||
"webpack": "^5.73.0",
|
||||
"webpack-cli": "^4.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "webpack --mode development --watch",
|
||||
|
@@ -3,16 +3,26 @@ import app from 'flarum/admin/app';
|
||||
import SelectDropdown from 'flarum/common/components/SelectDropdown';
|
||||
import Button from 'flarum/common/components/Button';
|
||||
import abbreviateNumber from 'flarum/common/utils/abbreviateNumber';
|
||||
import extractText from 'flarum/common/utils/extractText';
|
||||
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
|
||||
import Placeholder from 'flarum/common/components/Placeholder';
|
||||
import icon from 'flarum/common/helpers/icon';
|
||||
|
||||
import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget';
|
||||
|
||||
import StatisticsWidgetDateSelectionModal, { IDateSelection, IStatisticsWidgetDateSelectionModalAttrs } from './StatisticsWidgetDateSelectionModal';
|
||||
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import dayjsUtc from 'dayjs/plugin/utc';
|
||||
import dayjsLocalizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
// @ts-expect-error No typings available
|
||||
import { Chart } from 'frappe-charts';
|
||||
|
||||
dayjs.extend(dayjsUtc);
|
||||
dayjs.extend(dayjsLocalizedFormat);
|
||||
|
||||
interface IPeriodDeclaration {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -25,14 +35,23 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
|
||||
chart: any;
|
||||
|
||||
customPeriod: IDateSelection | null = null;
|
||||
|
||||
timedData: Record<string, undefined | any> = {};
|
||||
lifetimeData: any;
|
||||
customPeriodData: Record<string, undefined | any> = {};
|
||||
|
||||
noData: boolean = false;
|
||||
|
||||
loadingLifetime = true;
|
||||
loadingTimed: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
|
||||
acc[curr] = 'unloaded';
|
||||
return acc;
|
||||
}, {} as Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>);
|
||||
loadingCustom: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
|
||||
acc[curr] = 'unloaded';
|
||||
return acc;
|
||||
}, {} as Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>);
|
||||
|
||||
selectedEntity = 'users';
|
||||
selectedPeriod: undefined | string;
|
||||
@@ -105,17 +124,74 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
async loadCustomRangeData(model: string): Promise<void> {
|
||||
this.loadingCustom[model] = 'loading';
|
||||
m.redraw();
|
||||
|
||||
// We clone so we can check that the same period is still selected
|
||||
// once the HTTP request is complete and the data is to be displayed
|
||||
const range = { ...this.customPeriod };
|
||||
try {
|
||||
const data = await app.request({
|
||||
method: 'GET',
|
||||
url: app.forum.attribute('apiUrl') + '/statistics',
|
||||
params: {
|
||||
period: 'custom',
|
||||
model,
|
||||
dateRange: {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (JSON.stringify(range) !== JSON.stringify(this.customPeriod)) {
|
||||
// The range this method was called with is no longer the selected.
|
||||
// Bail out here.
|
||||
return;
|
||||
}
|
||||
|
||||
this.customPeriodData[model] = data;
|
||||
this.loadingCustom[model] = 'loaded';
|
||||
|
||||
m.redraw();
|
||||
} catch (e) {
|
||||
if (JSON.stringify(range) !== JSON.stringify(this.customPeriod)) {
|
||||
// The range this method was called with is no longer the selected.
|
||||
// Bail out here.
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(e);
|
||||
this.loadingCustom[model] = 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'StatisticsWidget';
|
||||
}
|
||||
|
||||
content() {
|
||||
const loadingSelectedEntity = this.loadingTimed[this.selectedEntity] !== 'loaded';
|
||||
const loadingSelectedEntity = (this.selectedPeriod === 'custom' ? this.loadingCustom : this.loadingTimed)[this.selectedEntity] !== 'loaded';
|
||||
|
||||
const thisPeriod = loadingSelectedEntity ? null : this.periods![this.selectedPeriod!];
|
||||
const thisPeriod = loadingSelectedEntity
|
||||
? null
|
||||
: this.selectedPeriod === 'custom'
|
||||
? {
|
||||
start: this.customPeriod?.end!,
|
||||
end: this.customPeriod?.end!,
|
||||
step: 86400,
|
||||
}
|
||||
: this.periods![this.selectedPeriod!];
|
||||
|
||||
if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') {
|
||||
this.loadTimedData(this.selectedEntity);
|
||||
if (this.selectedPeriod === 'custom') {
|
||||
if (!this.customPeriodData[this.selectedEntity] && this.loadingCustom[this.selectedEntity] === 'unloaded') {
|
||||
this.loadCustomRangeData(this.selectedEntity);
|
||||
}
|
||||
} else {
|
||||
if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') {
|
||||
this.loadTimedData(this.selectedEntity);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -128,16 +204,56 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
<LoadingIndicator size="small" display="inline" />
|
||||
) : (
|
||||
<SelectDropdown disabled={loadingSelectedEntity} buttonClassName="Button Button--text" caretIcon="fas fa-caret-down">
|
||||
{Object.keys(this.periods!).map((period) => (
|
||||
<Button
|
||||
key={period}
|
||||
active={period === this.selectedPeriod}
|
||||
onclick={this.changePeriod.bind(this, period)}
|
||||
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
|
||||
>
|
||||
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
|
||||
</Button>
|
||||
))}
|
||||
{Object.keys(this.periods!)
|
||||
.map((period) => (
|
||||
<Button
|
||||
key={period}
|
||||
active={period === this.selectedPeriod}
|
||||
onclick={this.changePeriod.bind(this, period)}
|
||||
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
|
||||
>
|
||||
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
|
||||
</Button>
|
||||
))
|
||||
.concat([
|
||||
<Button
|
||||
key="custom"
|
||||
active={this.selectedPeriod === 'custom'}
|
||||
onclick={() => {
|
||||
const attrs: IStatisticsWidgetDateSelectionModalAttrs = {
|
||||
onModalSubmit: (dates: IDateSelection) => {
|
||||
if (JSON.stringify(dates) === JSON.stringify(this.customPeriod)) {
|
||||
// If same period is selected, don't reload data
|
||||
return;
|
||||
}
|
||||
|
||||
this.customPeriodData = {};
|
||||
Object.keys(this.loadingCustom).forEach((k) => (this.loadingCustom[k] = 'unloaded'));
|
||||
this.customPeriod = dates;
|
||||
this.changePeriod('custom');
|
||||
},
|
||||
} as any;
|
||||
|
||||
// If we have a custom period set already,
|
||||
// let's prefill the modal with it
|
||||
if (this.customPeriod) {
|
||||
attrs.value = this.customPeriod;
|
||||
}
|
||||
|
||||
app.modal.show(StatisticsWidgetDateSelectionModal as any, attrs as any);
|
||||
}}
|
||||
icon={this.selectedPeriod === 'custom' ? 'fas fa-check' : true}
|
||||
>
|
||||
{this.selectedPeriod === 'custom'
|
||||
? extractText(
|
||||
app.translator.trans(`flarum-statistics.admin.statistics.custom_label_specified`, {
|
||||
fromDate: dayjs.utc(this.customPeriod!.start! * 1000).format('ll'),
|
||||
toDate: dayjs.utc(this.customPeriod!.end! * 1000).format('ll'),
|
||||
})
|
||||
)
|
||||
: app.translator.trans(`flarum-statistics.admin.statistics.custom_label`)}
|
||||
</Button>,
|
||||
])}
|
||||
</SelectDropdown>
|
||||
)}
|
||||
</div>
|
||||
@@ -148,11 +264,14 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
const thisPeriodCount = loadingSelectedEntity
|
||||
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
||||
: this.getPeriodCount(entity, thisPeriod!);
|
||||
const lastPeriodCount = loadingSelectedEntity
|
||||
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
||||
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
|
||||
const lastPeriodCount =
|
||||
this.selectedPeriod === 'custom'
|
||||
? null
|
||||
: loadingSelectedEntity
|
||||
? app.translator.trans('flarum-statistics.admin.statistics.loading')
|
||||
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
|
||||
const periodChange =
|
||||
loadingSelectedEntity || lastPeriodCount === 0
|
||||
loadingSelectedEntity || lastPeriodCount === 0 || lastPeriodCount === null
|
||||
? 0
|
||||
: (((thisPeriodCount as number) - (lastPeriodCount as number)) / (lastPeriodCount as number)) * 100;
|
||||
|
||||
@@ -197,6 +316,20 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
{this.noData && <Placeholder text={app.translator.trans(`flarum-statistics.admin.statistics.no_data`)} />}
|
||||
|
||||
{!this.noData && !!this.chart && (
|
||||
<Button
|
||||
className="StatisticsWidget-chartExport Button"
|
||||
icon="fas fa-file-export"
|
||||
onclick={() => {
|
||||
this.chart.export();
|
||||
}}
|
||||
>
|
||||
{app.translator.trans('flarum-statistics.admin.statistics.export_chart_button')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -206,9 +339,16 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
const period = this.periods![this.selectedPeriod!];
|
||||
const period =
|
||||
this.selectedPeriod === 'custom'
|
||||
? {
|
||||
start: this.customPeriod?.start!,
|
||||
end: this.customPeriod?.end!,
|
||||
step: 86400,
|
||||
}
|
||||
: this.periods![this.selectedPeriod!];
|
||||
const periodLength = period.end - period.start;
|
||||
const labels = [];
|
||||
const labels: string[] = [];
|
||||
const thisPeriod = [];
|
||||
const lastPeriod = [];
|
||||
|
||||
@@ -216,22 +356,45 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
let label;
|
||||
|
||||
if (period.step < 86400) {
|
||||
label = dayjs.unix(i).format('h A');
|
||||
label = dayjs.unix(i).utc().format('h A');
|
||||
} else {
|
||||
label = dayjs.unix(i).format('D MMM');
|
||||
label = dayjs.unix(i).utc().format('D MMM');
|
||||
|
||||
if (period.step > 86400) {
|
||||
label += ' - ' + dayjs.unix(i + period.step - 1).format('D MMM');
|
||||
label +=
|
||||
' - ' +
|
||||
dayjs
|
||||
.unix(i + period.step - 1)
|
||||
.utc()
|
||||
.format('D MMM');
|
||||
}
|
||||
}
|
||||
|
||||
labels.push(label);
|
||||
|
||||
thisPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i, end: i + period.step }));
|
||||
lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength + period.step }));
|
||||
lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength }));
|
||||
}
|
||||
|
||||
const datasets = [{ values: lastPeriod }, { values: thisPeriod }];
|
||||
if (thisPeriod.length === 0) {
|
||||
this.noData = true;
|
||||
m.redraw();
|
||||
return;
|
||||
} else {
|
||||
this.noData = false;
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
const datasets = [
|
||||
{
|
||||
name: extractText(app.translator.trans('flarum-statistics.admin.statistics.current_period')),
|
||||
values: thisPeriod,
|
||||
},
|
||||
{
|
||||
name: extractText(app.translator.trans('flarum-statistics.admin.statistics.previous_period')),
|
||||
values: lastPeriod,
|
||||
},
|
||||
];
|
||||
const data = {
|
||||
labels,
|
||||
datasets,
|
||||
@@ -251,8 +414,9 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
},
|
||||
lineOptions: {
|
||||
hideDots: 1,
|
||||
regionFill: 1,
|
||||
},
|
||||
colors: ['black', app.forum.attribute('themePrimaryColor')],
|
||||
colors: [app.forum.attribute('themePrimaryColor'), 'black'],
|
||||
});
|
||||
} else {
|
||||
this.chart.update(data);
|
||||
@@ -275,7 +439,7 @@ export default class StatisticsWidget extends DashboardWidget {
|
||||
}
|
||||
|
||||
getPeriodCount(entity: string, period: { start: number; end: number }) {
|
||||
const timed: Record<string, number> = this.timedData[entity];
|
||||
const timed: Record<string, number> = (this.selectedPeriod === 'custom' ? this.customPeriodData : this.timedData)[entity];
|
||||
let count = 0;
|
||||
|
||||
for (const t in timed) {
|
||||
|
@@ -0,0 +1,161 @@
|
||||
import app from 'flarum/admin/app';
|
||||
import ItemList from 'flarum/common/utils/ItemList';
|
||||
import generateElementId from 'flarum/admin/utils/generateElementId';
|
||||
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
|
||||
|
||||
import Mithril from 'mithril';
|
||||
import Button from 'flarum/common/components/Button';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import dayjsUtc from 'dayjs/plugin/utc';
|
||||
|
||||
dayjs.extend(dayjsUtc);
|
||||
|
||||
export interface IDateSelection {
|
||||
/**
|
||||
* Timestamp (seconds, not ms) for start date
|
||||
*/
|
||||
start: number;
|
||||
/**
|
||||
* Timestamp (seconds, not ms) for end date
|
||||
*/
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface IStatisticsWidgetDateSelectionModalAttrs extends IInternalModalAttrs {
|
||||
onModalSubmit: (dates: IDateSelection) => void;
|
||||
value?: IDateSelection;
|
||||
}
|
||||
|
||||
interface IStatisticsWidgetDateSelectionModalState {
|
||||
inputs: {
|
||||
startDateVal: string;
|
||||
endDateVal: string;
|
||||
};
|
||||
ids: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default class StatisticsWidgetDateSelectionModal extends Modal<IStatisticsWidgetDateSelectionModalAttrs> {
|
||||
/* @ts-expect-error core typings don't allow us to set the type of the state attr :( */
|
||||
state: IStatisticsWidgetDateSelectionModalState = {
|
||||
inputs: {
|
||||
startDateVal: dayjs().format('YYYY-MM-DD'),
|
||||
endDateVal: dayjs().format('YYYY-MM-DD'),
|
||||
},
|
||||
ids: {
|
||||
startDate: generateElementId(),
|
||||
endDate: generateElementId(),
|
||||
},
|
||||
};
|
||||
|
||||
oninit(vnode: Mithril.Vnode<IStatisticsWidgetDateSelectionModalAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
if (this.attrs.value) {
|
||||
this.state.inputs = {
|
||||
startDateVal: dayjs.utc(this.attrs.value.start * 1000).format('YYYY-MM-DD'),
|
||||
endDateVal: dayjs.utc(this.attrs.value.end * 1000).format('YYYY-MM-DD'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
className(): string {
|
||||
return 'StatisticsWidgetDateSelectionModal Modal--small';
|
||||
}
|
||||
|
||||
title(): Mithril.Children {
|
||||
return app.translator.trans('flarum-statistics.admin.date_selection_modal.title');
|
||||
}
|
||||
|
||||
content(): Mithril.Children {
|
||||
return <div class="Modal-body">{this.items().toArray()}</div>;
|
||||
}
|
||||
|
||||
items(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
items.add('intro', <p>{app.translator.trans('flarum-statistics.admin.date_selection_modal.description')}</p>, 100);
|
||||
|
||||
items.add(
|
||||
'date_start',
|
||||
<div class="Form-group">
|
||||
<label htmlFor={this.state.ids.startDate}>{app.translator.trans('flarum-statistics.admin.date_selection_modal.start_date')}</label>
|
||||
<input
|
||||
type="date"
|
||||
id={this.state.ids.startDate}
|
||||
value={this.state.inputs.startDateVal}
|
||||
onchange={this.updateState('startDateVal')}
|
||||
className="FormControl"
|
||||
/>
|
||||
</div>,
|
||||
90
|
||||
);
|
||||
|
||||
items.add(
|
||||
'date_end',
|
||||
<div class="Form-group">
|
||||
<label htmlFor={this.state.ids.endDate}>{app.translator.trans('flarum-statistics.admin.date_selection_modal.end_date')}</label>
|
||||
<input
|
||||
type="date"
|
||||
id={this.state.ids.endDate}
|
||||
value={this.state.inputs.endDateVal}
|
||||
onchange={this.updateState('endDateVal')}
|
||||
className="FormControl"
|
||||
/>
|
||||
</div>,
|
||||
80
|
||||
);
|
||||
|
||||
items.add(
|
||||
'submit',
|
||||
<Button class="Button Button--primary" type="submit">
|
||||
{app.translator.trans('flarum-statistics.admin.date_selection_modal.submit_button')}
|
||||
</Button>,
|
||||
0
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
updateState(field: keyof IStatisticsWidgetDateSelectionModalState['inputs']): (e: InputEvent) => void {
|
||||
return (e: InputEvent) => {
|
||||
this.state.inputs[field] = (e.currentTarget as HTMLInputElement).value;
|
||||
};
|
||||
}
|
||||
|
||||
submitData(): IDateSelection {
|
||||
// We force 'zulu' time (UTC)
|
||||
return {
|
||||
start: Math.floor(+dayjs.utc(this.state.inputs.startDateVal + 'Z') / 1000),
|
||||
// Ensures that the end date is the end of the day
|
||||
end: Math.floor(
|
||||
+dayjs
|
||||
.utc(this.state.inputs.endDateVal + 'Z')
|
||||
.hour(23)
|
||||
.minute(59)
|
||||
.second(59)
|
||||
.millisecond(999) / 1000
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit(e: SubmitEvent): void {
|
||||
e.preventDefault();
|
||||
|
||||
const data = this.submitData();
|
||||
|
||||
if (data.end < data.start) {
|
||||
this.alertAttrs = {
|
||||
type: 'error',
|
||||
controls: app.translator.trans('flarum-statistics.admin.date_selection_modal.errors.end_before_start'),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
this.attrs.onModalSubmit(data);
|
||||
this.hide();
|
||||
}
|
||||
}
|
@@ -93,22 +93,29 @@
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
.dataset-0 {
|
||||
.dataset-1 {
|
||||
opacity: 0.2;
|
||||
}
|
||||
.chart-legend {
|
||||
display: none;
|
||||
}
|
||||
// Hide the "last period" data from the tooltip
|
||||
.graph-svg-tip ul.data-point-list > li:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-viewFull {
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.Placeholder {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
&-chartExport {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 16px;
|
||||
margin-top: -32px;
|
||||
}
|
||||
}
|
||||
|
||||
/*!
|
||||
@@ -119,9 +126,9 @@
|
||||
position: relative; /* for absolutely positioned tooltip */
|
||||
|
||||
/* https://www.smashingmagazine.com/2015/11/using-system-ui-fonts-practical-guide/ */
|
||||
font-family: -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
|
||||
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
|
||||
.axis,
|
||||
.chart-label {
|
||||
@@ -194,6 +201,10 @@
|
||||
min-width: 90px;
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
|
||||
&:nth-child(2) {
|
||||
border-top-color: #5a5a5a !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
strong {
|
||||
|
@@ -1,24 +1,40 @@
|
||||
flarum-statistics:
|
||||
|
||||
##
|
||||
# UNIQUE KEYS - The following keys are used in only one location each.
|
||||
##
|
||||
|
||||
# Translations in this namespace are used by the admin interface.
|
||||
admin:
|
||||
# These translations are used in the date selection modal.
|
||||
date_selection_modal:
|
||||
description: |
|
||||
Pick a custom date range to display statistics for. Loading data may take
|
||||
multiple minutes on forums with a lot of activity.
|
||||
end_date: End date (inclusive)
|
||||
errors:
|
||||
end_before_start: The end date must be after the start date.
|
||||
start_date: Start date (inclusive)
|
||||
submit_button: Confirm date range
|
||||
title: Choose custom date range
|
||||
|
||||
# These translations are used in the Statistics dashboard widget.
|
||||
statistics:
|
||||
discussions_heading: => core.ref.discussions
|
||||
export_chart_button: Export chart to SVG
|
||||
last_12_months_label: Last 12 months
|
||||
last_28_days_label: Last 28 days
|
||||
last_7_days_label: Last 7 days
|
||||
mini_heading: Forum statistics
|
||||
previous_28_days_label: Previous 28 days
|
||||
previous_7_days_label: Previous 7 days
|
||||
custom_label: Choose custom range...
|
||||
custom_label_specified: "{fromDate} to {toDate}"
|
||||
loading: => core.ref.loading
|
||||
posts_heading: => core.ref.posts
|
||||
today_label: Today
|
||||
total_label: Total
|
||||
users_heading: => core.ref.users
|
||||
view_full: View more statistics
|
||||
no_data: There is no data available for this date range.
|
||||
current_period: Current period
|
||||
previous_period: Previous period
|
||||
|
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace Flarum\Statistics\Api\Controller;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use DateTime;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Http\RequestUtil;
|
||||
@@ -69,20 +70,39 @@ class ShowStatisticsData implements RequestHandlerInterface
|
||||
// control panel.
|
||||
$actor->assertAdmin();
|
||||
|
||||
$reportingPeriod = Arr::get($request->getQueryParams(), 'period');
|
||||
$model = Arr::get($request->getQueryParams(), 'model');
|
||||
$query = $request->getQueryParams();
|
||||
|
||||
return new JsonResponse($this->getResponse($model, $reportingPeriod));
|
||||
$reportingPeriod = Arr::get($query, 'period');
|
||||
$model = Arr::get($query, 'model');
|
||||
$customDateRange = Arr::get($query, 'dateRange');
|
||||
|
||||
return new JsonResponse($this->getResponse($model, $reportingPeriod, $customDateRange));
|
||||
}
|
||||
|
||||
private function getResponse(?string $model, ?string $period): array
|
||||
private function getResponse(?string $model, ?string $period, ?array $customDateRange): array
|
||||
{
|
||||
if ($period === 'lifetime') {
|
||||
return $this->getLifetimeStatistics();
|
||||
}
|
||||
|
||||
if (! Arr::exists($this->entities, $model)) {
|
||||
throw new InvalidParameterException();
|
||||
throw new InvalidParameterException('A model must be specified');
|
||||
}
|
||||
|
||||
if ($period === 'custom') {
|
||||
$start = (int) $customDateRange['start'];
|
||||
$end = (int) $customDateRange['end'];
|
||||
|
||||
if (! $customDateRange || ! $start || ! $end) {
|
||||
throw new InvalidParameterException('A custom date range must be specified');
|
||||
}
|
||||
|
||||
// Seconds-based timestamps
|
||||
$startRange = Carbon::createFromTimestampUTC($start)->toDateTime();
|
||||
$endRange = Carbon::createFromTimestampUTC($end)->toDateTime();
|
||||
|
||||
// We can't really cache this
|
||||
return $this->getTimedCounts($this->entities[$model][0], $this->entities[$model][1], $startRange, $endRange);
|
||||
}
|
||||
|
||||
return $this->getTimedStatistics($model);
|
||||
@@ -104,8 +124,23 @@ class ShowStatisticsData implements RequestHandlerInterface
|
||||
});
|
||||
}
|
||||
|
||||
private function getTimedCounts(Builder $query, $column)
|
||||
private function getTimedCounts(Builder $query, string $column, ?DateTime $startDate = null, ?DateTime $endDate = null)
|
||||
{
|
||||
$diff = $startDate && $endDate ? $startDate->diff($endDate) : null;
|
||||
|
||||
if (! isset($startDate)) {
|
||||
// need -12 months and period before that
|
||||
$startDate = new DateTime('-2 years');
|
||||
} else {
|
||||
// If the start date is custom, we need to include an equal amount beforehand
|
||||
// to show the data for the previous period.
|
||||
$startDate = (new Carbon($startDate))->subtract($diff)->toDateTime();
|
||||
}
|
||||
|
||||
if (! isset($endDate)) {
|
||||
$endDate = new DateTime();
|
||||
}
|
||||
|
||||
$results = $query
|
||||
->selectRaw(
|
||||
'DATE_FORMAT(
|
||||
@@ -115,7 +150,8 @@ class ShowStatisticsData implements RequestHandlerInterface
|
||||
[new DateTime('-25 hours')]
|
||||
)
|
||||
->selectRaw('COUNT(id) as count')
|
||||
->where($column, '>', new DateTime('-365 days'))
|
||||
->where($column, '>', $startDate)
|
||||
->where($column, '<=', $endDate)
|
||||
->groupBy('time_group')
|
||||
->pluck('count', 'time_group');
|
||||
|
||||
|
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Statistics\tests\integration\api;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
|
||||
class CanRequestCustomTimedStatisticsTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
/**
|
||||
* @var Carbon
|
||||
*/
|
||||
protected $nowTime;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->nowTime = Carbon::now()->subDays(10);
|
||||
|
||||
$this->extension('flarum-statistics');
|
||||
|
||||
$this->prepareDatabase($this->getDatabaseData());
|
||||
}
|
||||
|
||||
protected function getDatabaseData(): array
|
||||
{
|
||||
return [
|
||||
'users' => [
|
||||
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()],
|
||||
['id' => 2, 'username' => 'normal', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()->subDays(1)],
|
||||
['id' => 3, 'username' => 'normal2', 'email' => 'normal2@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()->subDays(2)],
|
||||
],
|
||||
'discussions' => [
|
||||
['id' => 1, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
|
||||
['id' => 2, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
|
||||
['id' => 3, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
|
||||
['id' => 4, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(2), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
|
||||
],
|
||||
'posts' => [
|
||||
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()],
|
||||
['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)],
|
||||
['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)],
|
||||
['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(2)],
|
||||
['id' => 5, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 2, 'created_at' => $this->nowTime->copy()],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function can_request_timed_stats()
|
||||
{
|
||||
$time = $this->nowTime->copy();
|
||||
|
||||
$start = $time->copy()->subDays(1)->startOfDay()->getTimestamp();
|
||||
$end = $time->copy()->endOfDay()->getTimestamp();
|
||||
|
||||
$timeStart = $time->copy()->startOfDay();
|
||||
|
||||
$models = [
|
||||
'users' => [
|
||||
$timeStart->copy()->getTimestamp() => 1,
|
||||
$timeStart->copy()->subDays(1)->getTimestamp() => 1,
|
||||
$timeStart->copy()->subDays(2)->getTimestamp() => 1,
|
||||
], 'discussions' => [
|
||||
$timeStart->copy()->getTimestamp() => 1,
|
||||
$timeStart->copy()->subDays(1)->getTimestamp() => 2,
|
||||
$timeStart->copy()->subDays(2)->getTimestamp() => 1,
|
||||
], 'posts' => [
|
||||
$timeStart->copy()->getTimestamp() => 2,
|
||||
$timeStart->copy()->subDays(1)->getTimestamp() => 2,
|
||||
$timeStart->copy()->subDays(2)->getTimestamp() => 1,
|
||||
]
|
||||
];
|
||||
|
||||
foreach ($models as $model => $data) {
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/statistics', ['authenticatedAs' => 1])->withQueryParams([
|
||||
'model' => $model,
|
||||
'period' => 'custom',
|
||||
'dateRange' => [
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$this->assertEquals(
|
||||
$data,
|
||||
$body,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Statistics\tests\integration\api;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
|
||||
class CanRequestLifetimeStatisticsTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
/**
|
||||
* @var Carbon
|
||||
*/
|
||||
protected $nowTime;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->nowTime = Carbon::now();
|
||||
|
||||
$this->extension('flarum-statistics');
|
||||
|
||||
$this->prepareDatabase($this->getDatabaseData());
|
||||
}
|
||||
|
||||
protected function getDatabaseData(): array
|
||||
{
|
||||
return [
|
||||
'users' => [
|
||||
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
|
||||
['id' => 2, 'username' => 'normal', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->subDays(1)],
|
||||
],
|
||||
'discussions' => [
|
||||
['id' => 1, 'title' => __CLASS__, 'created_at' => $this->nowTime, 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
|
||||
['id' => 2, 'title' => __CLASS__, 'created_at' => $this->nowTime->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
|
||||
['id' => 3, 'title' => __CLASS__, 'created_at' => $this->nowTime->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
|
||||
['id' => 4, 'title' => __CLASS__, 'created_at' => $this->nowTime->subDays(2), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
|
||||
],
|
||||
'posts' => [
|
||||
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1],
|
||||
['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1],
|
||||
['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1],
|
||||
['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1],
|
||||
['id' => 5, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 2],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function can_request_lifetime_stats()
|
||||
{
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/statistics', ['authenticatedAs' => 1])->withQueryParams([
|
||||
'period' => 'lifetime',
|
||||
])
|
||||
);
|
||||
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
$db = $this->getDatabaseData();
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$this->assertEqualsCanonicalizing(
|
||||
[
|
||||
'users' => count($db['users']),
|
||||
'discussions' => count($db['discussions']),
|
||||
'posts' => count($db['posts']),
|
||||
],
|
||||
$body
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Statistics\tests\integration\api;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
|
||||
class CanRequestTimedStatisticsTest extends TestCase
|
||||
{
|
||||
use RetrievesAuthorizedUsers;
|
||||
|
||||
/**
|
||||
* @var Carbon
|
||||
*/
|
||||
protected $nowTime;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->nowTime = Carbon::now()->subDays(10);
|
||||
|
||||
$this->extension('flarum-statistics');
|
||||
|
||||
$this->prepareDatabase($this->getDatabaseData());
|
||||
}
|
||||
|
||||
protected function getDatabaseData(): array
|
||||
{
|
||||
return [
|
||||
'users' => [
|
||||
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()],
|
||||
['id' => 2, 'username' => 'normal', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()->subDays(1)],
|
||||
],
|
||||
'discussions' => [
|
||||
['id' => 1, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
|
||||
['id' => 2, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
|
||||
['id' => 3, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
|
||||
['id' => 4, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(2), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
|
||||
],
|
||||
'posts' => [
|
||||
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()],
|
||||
['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)],
|
||||
['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)],
|
||||
['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(2)],
|
||||
['id' => 5, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 2, 'created_at' => $this->nowTime->copy()],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function can_request_timed_stats()
|
||||
{
|
||||
$time = $this->nowTime->copy();
|
||||
$time->setTime(0, 0, 0, 0);
|
||||
|
||||
$models = [
|
||||
'users' => [
|
||||
$time->copy()->getTimestamp() => 1,
|
||||
$time->copy()->subDays(1)->getTimestamp() => 1,
|
||||
], 'discussions' => [
|
||||
$time->copy()->getTimestamp() => 1,
|
||||
$time->copy()->subDays(1)->getTimestamp() => 2,
|
||||
$time->copy()->subDays(2)->getTimestamp() => 1,
|
||||
], 'posts' => [
|
||||
$time->copy()->getTimestamp() => 2,
|
||||
$time->copy()->subDays(1)->getTimestamp() => 2,
|
||||
$time->copy()->subDays(2)->getTimestamp() => 1,
|
||||
]
|
||||
];
|
||||
|
||||
foreach ($models as $model => $data) {
|
||||
$response = $this->send(
|
||||
$this->request('GET', '/api/statistics', ['authenticatedAs' => 1])->withQueryParams([
|
||||
'model' => $model,
|
||||
])
|
||||
);
|
||||
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$this->assertEqualsCanonicalizing(
|
||||
$data,
|
||||
$body
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -33,6 +33,9 @@
|
||||
"flarum-extension": {
|
||||
"title": "Subscriptions",
|
||||
"category": "feature",
|
||||
"optional-dependencies": [
|
||||
"flarum/approval"
|
||||
],
|
||||
"icon": {
|
||||
"name": "fas fa-star",
|
||||
"backgroundColor": "#ffea7b",
|
||||
@@ -86,6 +89,7 @@
|
||||
"test:setup": "Sets up a database for use with integration tests. Execute this only once."
|
||||
},
|
||||
"require-dev": {
|
||||
"flarum/testing": "^1.0.0"
|
||||
"flarum/testing": "^1.0.0",
|
||||
"flarum/approval": "@dev"
|
||||
}
|
||||
}
|
||||
|
@@ -9,6 +9,7 @@
|
||||
|
||||
use Flarum\Api\Serializer\BasicDiscussionSerializer;
|
||||
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||
use Flarum\Approval\Event\PostWasApproved;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Discussion\Event\Saving;
|
||||
use Flarum\Discussion\Filter\DiscussionFilterer;
|
||||
@@ -20,6 +21,7 @@ use Flarum\Post\Event\Posted;
|
||||
use Flarum\Post\Event\Restored;
|
||||
use Flarum\Subscriptions\HideIgnoredFromAllDiscussionsPage;
|
||||
use Flarum\Subscriptions\Listener;
|
||||
use Flarum\Subscriptions\Notification\FilterVisiblePostsBeforeSending;
|
||||
use Flarum\Subscriptions\Notification\NewPostBlueprint;
|
||||
use Flarum\Subscriptions\Query\SubscriptionFilterGambit;
|
||||
|
||||
@@ -35,7 +37,8 @@ return [
|
||||
->namespace('flarum-subscriptions', __DIR__.'/views'),
|
||||
|
||||
(new Extend\Notification())
|
||||
->type(NewPostBlueprint::class, BasicDiscussionSerializer::class, ['alert', 'email']),
|
||||
->type(NewPostBlueprint::class, BasicDiscussionSerializer::class, ['alert', 'email'])
|
||||
->beforeSending(FilterVisiblePostsBeforeSending::class),
|
||||
|
||||
(new Extend\ApiSerializer(DiscussionSerializer::class))
|
||||
->attribute('subscription', function (DiscussionSerializer $serializer, Discussion $discussion) {
|
||||
@@ -50,6 +53,7 @@ return [
|
||||
(new Extend\Event())
|
||||
->listen(Saving::class, Listener\SaveSubscriptionToDatabase::class)
|
||||
->listen(Posted::class, Listener\SendNotificationWhenReplyIsPosted::class)
|
||||
->listen(PostWasApproved::class, Listener\SendNotificationWhenReplyIsPosted::class)
|
||||
->listen(Hidden::class, Listener\DeleteNotificationWhenPostIsHiddenOrDeleted::class)
|
||||
->listen(Restored::class, Listener\RestoreNotificationWhenPostIsRestored::class)
|
||||
->listen(Deleted::class, Listener\DeleteNotificationWhenPostIsHiddenOrDeleted::class)
|
||||
|
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace Flarum\Subscriptions\Listener;
|
||||
|
||||
use Flarum\Approval\Event\PostWasApproved;
|
||||
use Flarum\Post\Event\Posted;
|
||||
use Flarum\Subscriptions\Job\SendReplyNotification;
|
||||
use Illuminate\Contracts\Queue\Queue;
|
||||
@@ -25,7 +26,11 @@ class SendNotificationWhenReplyIsPosted
|
||||
$this->queue = $queue;
|
||||
}
|
||||
|
||||
public function handle(Posted $event)
|
||||
/**
|
||||
* @param Posted|PostWasApproved $event
|
||||
* @return void
|
||||
*/
|
||||
public function handle($event)
|
||||
{
|
||||
$this->queue->push(
|
||||
new SendReplyNotification($event->post, $event->post->discussion->last_post_number)
|
||||
|
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Subscriptions\Notification;
|
||||
|
||||
use Flarum\Notification\Blueprint\BlueprintInterface;
|
||||
|
||||
class FilterVisiblePostsBeforeSending
|
||||
{
|
||||
public function __invoke(BlueprintInterface $blueprint, array $recipients): array
|
||||
{
|
||||
if ($blueprint instanceof NewPostBlueprint) {
|
||||
$newRecipients = [];
|
||||
|
||||
// Flarum has built-in access control for the notification subject,
|
||||
// but subscriptions post notifications has the discussion as the subject.
|
||||
// We'll add a post visibility check so that users can't get access to hidden replies by subscribing.
|
||||
foreach ($recipients as $recipient) {
|
||||
if ($blueprint->post->isVisibleTo($recipient)) {
|
||||
$newRecipients[] = $recipient;
|
||||
}
|
||||
}
|
||||
|
||||
return $newRecipients;
|
||||
}
|
||||
|
||||
return $recipients;
|
||||
}
|
||||
}
|
@@ -10,6 +10,9 @@
|
||||
namespace Flarum\Subscriptions\tests\integration\api\discussions;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Extend\ModelVisibility;
|
||||
use Flarum\Group\Group;
|
||||
use Flarum\Post\Post;
|
||||
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||
use Flarum\Testing\integration\TestCase;
|
||||
use Flarum\User\User;
|
||||
@@ -221,4 +224,103 @@ class ReplyNotificationTest extends TestCase
|
||||
[[8, 9, 10]]
|
||||
];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function approving_reply_sends_reply_notification()
|
||||
{
|
||||
// Flags was only specified because it is required for approval.
|
||||
$this->extensions = ['flarum-flags', 'flarum-approval', 'flarum-subscriptions'];
|
||||
|
||||
$this->app();
|
||||
|
||||
$this->database()
|
||||
->table('group_permission')
|
||||
->where('group_id', Group::MEMBER_ID)
|
||||
->where('permission', 'discussion.replyWithoutApproval')
|
||||
->delete();
|
||||
|
||||
/** @var User $mainUser */
|
||||
$mainUser = User::query()->find(2);
|
||||
|
||||
$this->assertEquals(0, $mainUser->getUnreadNotificationCount());
|
||||
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 4,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => 'reply with predetermined content for automated testing - too-obscure',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 1]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(0, $mainUser->getUnreadNotificationCount());
|
||||
|
||||
$json = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// Approve the previous post
|
||||
$this->send(
|
||||
$this->request('PATCH', '/api/posts/'.$json['data']['id'], [
|
||||
'authenticatedAs' => 1,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'isApproved' => 1,
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(1, $mainUser->getUnreadNotificationCount());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function replying_to_a_discussion_with_a_restricted_post_only_sends_notifications_to_allowed_users()
|
||||
{
|
||||
// Add visibility scoper to only allow admin
|
||||
// to see expected new post with content containing 'restricted-test-post'.
|
||||
$this->extend(
|
||||
(new ModelVisibility(Post::class))
|
||||
->scope(function (User $actor, $query) {
|
||||
if (! $actor->isAdmin()) {
|
||||
$query->where('content', 'not like', '%restricted-test-post%');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
$this->app();
|
||||
|
||||
/** @var User $allowedUser */
|
||||
$allowedUser = User::query()->find(1);
|
||||
$normalUser = User::query()->find(2);
|
||||
|
||||
$this->assertEquals(0, $allowedUser->getUnreadNotificationCount());
|
||||
$this->assertEquals(0, $normalUser->getUnreadNotificationCount());
|
||||
|
||||
$this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 3,
|
||||
'json' => [
|
||||
'data' => [
|
||||
'attributes' => [
|
||||
'content' => 'restricted-test-post',
|
||||
],
|
||||
'relationships' => [
|
||||
'discussion' => ['data' => ['id' => 1]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(1, $allowedUser->getUnreadNotificationCount());
|
||||
$this->assertEquals(0, $normalUser->getUnreadNotificationCount());
|
||||
}
|
||||
}
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"flarum/core": "^1.4"
|
||||
"flarum/core": "^1.6"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
1
extensions/tags/js/dist/forum.js.LICENSE.txt
generated
vendored
Normal file
1
extensions/tags/js/dist/forum.js.LICENSE.txt
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
|
@@ -66,7 +66,9 @@ class UpdateTagMetadata
|
||||
*/
|
||||
public function whenDiscussionIsDeleted(Deleted $event)
|
||||
{
|
||||
$this->updateTags($event->discussion, -1);
|
||||
// If already soft deleted when permanently deleted, the -1 delta has already been applied in Hidden listener
|
||||
$delta = $event->discussion->hidden_at ? 0 : -1;
|
||||
$this->updateTags($event->discussion, $delta);
|
||||
|
||||
$event->discussion->tags()->detach();
|
||||
}
|
||||
|
1
framework/core/js/dist-typings/admin/compat.d.ts
generated
vendored
1
framework/core/js/dist-typings/admin/compat.d.ts
generated
vendored
@@ -76,6 +76,7 @@ declare const _default: {
|
||||
'utils/throttleDebounce': typeof import("../common/utils/throttleDebounce");
|
||||
'utils/isObject': typeof import("../common/utils/isObject").default;
|
||||
'utils/focusTrap': typeof import("../common/utils/focusTrap");
|
||||
'utils/isDark': typeof import("../common/utils/isDark").default;
|
||||
'models/Notification': typeof import("../common/models/Notification").default;
|
||||
'models/User': typeof import("../common/models/User").default;
|
||||
'models/Post': typeof import("../common/models/Post").default;
|
||||
|
2
framework/core/js/dist-typings/common/compat.d.ts
generated
vendored
2
framework/core/js/dist-typings/common/compat.d.ts
generated
vendored
@@ -28,6 +28,7 @@ import extractText from './utils/extractText';
|
||||
import formatNumber from './utils/formatNumber';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import * as FocusTrap from './utils/focusTrap';
|
||||
import isDark from './utils/isDark';
|
||||
import Notification from './models/Notification';
|
||||
import User from './models/User';
|
||||
import Post from './models/Post';
|
||||
@@ -121,6 +122,7 @@ declare const _default: {
|
||||
'utils/throttleDebounce': typeof ThrottleDebounce;
|
||||
'utils/isObject': typeof isObject;
|
||||
'utils/focusTrap': typeof FocusTrap;
|
||||
'utils/isDark': typeof isDark;
|
||||
'models/Notification': typeof Notification;
|
||||
'models/User': typeof User;
|
||||
'models/Post': typeof Post;
|
||||
|
2
framework/core/js/dist-typings/common/helpers/userOnline.d.ts
generated
vendored
2
framework/core/js/dist-typings/common/helpers/userOnline.d.ts
generated
vendored
@@ -1,6 +1,6 @@
|
||||
import type Mithril from 'mithril';
|
||||
import User from '../models/User';
|
||||
/**
|
||||
* The `useronline` helper displays a green circle if the user is online
|
||||
* The `useronline` helper displays a green circle if the user is online.
|
||||
*/
|
||||
export default function userOnline(user: User): Mithril.Vnode<{}, {}> | null;
|
||||
|
7
framework/core/js/dist-typings/common/utils/isDark.d.ts
generated
vendored
Normal file
7
framework/core/js/dist-typings/common/utils/isDark.d.ts
generated
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* The `isDark` utility converts a hex color to rgb, and then calcul a YIQ
|
||||
* value in order to get the appropriate brightness value (is it dark or is it
|
||||
* light?) See https://www.w3.org/TR/AERT/#color-contrast for references. A YIQ
|
||||
* value >= 128 is a light color.
|
||||
*/
|
||||
export default function isDark(hexcolor: String): boolean;
|
1
framework/core/js/dist-typings/forum/compat.d.ts
generated
vendored
1
framework/core/js/dist-typings/forum/compat.d.ts
generated
vendored
@@ -112,6 +112,7 @@ declare const _default: {
|
||||
'utils/throttleDebounce': typeof import("../common/utils/throttleDebounce");
|
||||
'utils/isObject': typeof import("../common/utils/isObject").default;
|
||||
'utils/focusTrap': typeof import("../common/utils/focusTrap");
|
||||
'utils/isDark': typeof import("../common/utils/isDark").default;
|
||||
'models/Notification': typeof import("../common/models/Notification").default;
|
||||
'models/User': typeof import("../common/models/User").default;
|
||||
'models/Post': typeof import("../common/models/Post").default;
|
||||
|
3
framework/core/js/dist-typings/forum/components/ForgotPasswordModal.d.ts
generated
vendored
3
framework/core/js/dist-typings/forum/components/ForgotPasswordModal.d.ts
generated
vendored
@@ -3,6 +3,7 @@ import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import Mithril from 'mithril';
|
||||
import RequestError from '../../common/utils/RequestError';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
export interface IForgotPasswordModalAttrs extends IInternalModalAttrs {
|
||||
email?: string;
|
||||
}
|
||||
@@ -20,6 +21,8 @@ export default class ForgotPasswordModal<CustomAttrs extends IForgotPasswordModa
|
||||
className(): string;
|
||||
title(): import("@askvortsov/rich-icu-message-formatter").NestedStringArray;
|
||||
content(): JSX.Element;
|
||||
fields(): ItemList<unknown>;
|
||||
onsubmit(e: SubmitEvent): void;
|
||||
requestParams(): Record<string, unknown>;
|
||||
onerror(error: RequestError): void;
|
||||
}
|
||||
|
2
framework/core/js/dist-typings/forum/components/LogInModal.d.ts
generated
vendored
2
framework/core/js/dist-typings/forum/components/LogInModal.d.ts
generated
vendored
@@ -4,6 +4,7 @@ import ItemList from '../../common/utils/ItemList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import type Mithril from 'mithril';
|
||||
import RequestError from '../../common/utils/RequestError';
|
||||
import type { LoginParams } from '../../common/Session';
|
||||
export interface ILoginModalAttrs extends IInternalModalAttrs {
|
||||
identification?: string;
|
||||
password?: string;
|
||||
@@ -41,5 +42,6 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
|
||||
signUp(): void;
|
||||
onready(): void;
|
||||
onsubmit(e: SubmitEvent): void;
|
||||
loginParams(): LoginParams;
|
||||
onerror(error: RequestError): void;
|
||||
}
|
||||
|
2
framework/core/js/dist/admin.js
generated
vendored
2
framework/core/js/dist/admin.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/admin.js.map
generated
vendored
2
framework/core/js/dist/admin.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum.js
generated
vendored
2
framework/core/js/dist/forum.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum.js.map
generated
vendored
2
framework/core/js/dist/forum.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@ import Switch from '../../common/components/Switch';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import Mithril from 'mithril';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import ColorPreviewInput from '../../common/components/ColorPreviewInput';
|
||||
|
||||
export interface IEditGroupModalAttrs extends IInternalModalAttrs {
|
||||
group?: Group;
|
||||
@@ -81,7 +82,7 @@ export default class EditGroupModal<CustomAttrs extends IEditGroupModalAttrs = I
|
||||
'color',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
|
||||
<input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
|
||||
<ColorPreviewInput placeholder="#aaaaaa" bidi={this.color} />
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
|
@@ -410,16 +410,22 @@ export default class Application {
|
||||
pageNumber: 1,
|
||||
};
|
||||
|
||||
const title =
|
||||
let title =
|
||||
onHomepage || !this.title
|
||||
? extractText(app.translator.trans('core.lib.meta_titles.without_page_title', params))
|
||||
: extractText(app.translator.trans('core.lib.meta_titles.with_page_title', params));
|
||||
|
||||
const tempEl = document.createElement('div');
|
||||
tempEl.innerHTML = title;
|
||||
const decodedTitle = tempEl.innerText;
|
||||
title = count + title;
|
||||
|
||||
document.title = count + decodedTitle;
|
||||
// We pass the title through a DOMParser to allow HTML entities
|
||||
// to be rendered correctly, while still preventing XSS attacks
|
||||
// from user input by using a script-disabled environment.
|
||||
// https://github.com/flarum/framework/issues/3514
|
||||
// https://github.com/flarum/framework/pull/3684
|
||||
const parser = new DOMParser();
|
||||
const safeTitle = parser.parseFromString(title, 'text/html').body.innerHTML;
|
||||
|
||||
document.title = safeTitle;
|
||||
}
|
||||
|
||||
protected transformRequestOptions<ResponseType>(flarumOptions: FlarumRequestOptions<ResponseType>): InternalFlarumRequestOptions<ResponseType> {
|
||||
|
@@ -33,6 +33,7 @@ import formatNumber from './utils/formatNumber';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import withAttr from './utils/withAttr';
|
||||
import * as FocusTrap from './utils/focusTrap';
|
||||
import isDark from './utils/isDark';
|
||||
import Notification from './models/Notification';
|
||||
import User from './models/User';
|
||||
import Post from './models/Post';
|
||||
@@ -120,6 +121,7 @@ export default {
|
||||
'utils/throttleDebounce': ThrottleDebounce,
|
||||
'utils/isObject': isObject,
|
||||
'utils/focusTrap': FocusTrap,
|
||||
'utils/isDark': isDark,
|
||||
'models/Notification': Notification,
|
||||
'models/User': User,
|
||||
'models/Post': Post,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user