1
0
mirror of https://github.com/flarum/core.git synced 2025-08-13 20:04:24 +02:00

Compare commits

..

121 Commits

Author SHA1 Message Date
Sami Mazouz
243bc139b0 chore: changelog
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2023-01-10 15:22:17 +01:00
Sami Mazouz
adf78bbd95 chore(subscriptions): prepare
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2023-01-10 15:06:03 +01:00
StyleCI Bot
c8d9f1111e Apply fixes from StyleCI 2023-01-10 14:04:18 +00:00
Sami Mazouz
e5f05166a0 fix(subscriptions): post notifications not getting access checked
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2023-01-10 15:04:02 +01:00
Sami Mazouz
02556c6ca6 chore: prepare v1.6.3 release
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2023-01-10 15:00:38 +01:00
Sami Mazouz
666223fa8c test: make data providers public
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2023-01-10 15:00:38 +01:00
Sami Mazouz
12dfcc5c79 Merge pull request from GHSA-hph3-hv3c-7725
* test: add reply creation tests

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

* fix: access checking being bypassed for post creation when first post is deleted

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

* chore: recover tests

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

* chore: make provider public

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2023-01-10 15:00:38 +01:00
StyleCI Bot
248a71d9b5 Apply fixes from StyleCI 2023-01-10 15:00:37 +01:00
Sami Mazouz
a131e87911 Merge pull request from GHSA-8gcg-vwmw-rxj4
* fix: notifications grant access to private data of posts

* chore: fix tests

* test: start with tests about notification subject visibility

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

* fix: check subject access before sending notification to user

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
Co-authored-by: Daniël Klabbers <daniel@klabbers.email>
2023-01-10 15:00:37 +01:00
StyleCI Bot
be63b28437 Apply fixes from StyleCI 2023-01-10 15:00:37 +01:00
Sami Mazouz
132fdea659 Merge pull request from GHSA-22m9-m3ww-53h3
* fix: check post visibility when mentioning

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

* fix: `mentionsPosts` include is not used and leaks private posts

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

* chre: use `PostRepository`

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2023-01-10 15:00:37 +01:00
Sami Mazouz
8a65ad980d chore: v1.6.2 changelog
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-18 22:38:41 +01:00
Sami Mazouz
9a0668effd chore: update version constant to v1.6.2
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-18 22:35:12 +01:00
Sami Mazouz
224b122303 chore: yarn build
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-18 22:34:29 +01:00
Sami Mazouz
ed0cee97f5 fix: evaluated page title content (#3684)
* fix: evaluated page title content
* chore: add comment
* chore: use DOMParser instead
* fix: use `innerHTML` for the actual value

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
Co-authored-by: David Wheatley <hi@davwheat.dev>
2022-11-18 22:32:24 +01:00
Sami Mazouz
b5f324a7b3 chore: v1.6.1 changelog
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-16 12:29:36 +01:00
Sami Mazouz
8ef0df94b2 chore: update app version constant
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-16 12:27:40 +01:00
flarum-bot
c50c924242 Bundled output for commit 18bdd48835
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-16 10:30:13 +00:00
Sami Mazouz
18bdd48835 Merge remote-tracking branch 'origin/main' 2022-11-16 11:22:47 +01:00
Sami Mazouz
f49cf887dc revert: fix typing errors after dependencies update
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-16 11:17:20 +01:00
Sami Mazouz
19793d5617 chore: run yarn-audit-fix
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-16 10:54:04 +01:00
Sami Mazouz
4a2f48ad04 revert: before audit fix and JS dependency updates
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-16 10:50:35 +01:00
Daniël Klabbers
2b413b06c5 Revert "chore: try to fallback on npm as yarn seems to use wrong deps"
This reverts commit bb9f01372f.
2022-11-16 09:58:54 +01:00
Daniël Klabbers
2b89dedc08 Revert "chore: wrong package manager value changed to fix mentions bug"
This reverts commit bc59b8d9ab.
2022-11-16 09:58:41 +01:00
Daniël Klabbers
00a880c467 Revert "chore: lock needed for npm builds"
This reverts commit 92d2adc5fd.
2022-11-16 09:58:30 +01:00
Daniël Klabbers
92d2adc5fd chore: lock needed for npm builds 2022-11-16 09:51:50 +01:00
Daniël Klabbers
bc59b8d9ab chore: wrong package manager value changed to fix mentions bug 2022-11-16 09:41:52 +01:00
Daniël Klabbers
bb9f01372f chore: try to fallback on npm as yarn seems to use wrong deps 2022-11-16 09:26:08 +01:00
flarum-bot
069a29d22a Bundled output for commit 105170b5bc
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-16 07:26:43 +00:00
Daniël Klabbers
105170b5bc chore: recompile assets to fix mentions bug 2022-11-16 08:20:04 +01:00
flarum-bot
b8261ef055 Bundled output for commit d14770188b
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-15 23:43:41 +00:00
Ian Morland
d14770188b chore: add fullstop to comment 2022-11-15 23:38:02 +00:00
Ian Morland
e9bb646dbf chore: newline 2022-11-15 23:25:35 +00:00
flarum-bot
46adf40675 Bundled output for commit 6938a13223
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-13 14:37:46 +00:00
Sami Mazouz
6938a13223 fix: typing errors after dependencies update
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-13 15:31:10 +01:00
flarum-bot
ab56aefeaa Bundled output for commit 259db92b9a
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-10 23:30:18 +00:00
David Wheatley
259db92b9a fix: update JS dependencies 2022-11-10 23:23:44 +00:00
Daniël Klabbers
094ec77980 chore: js vulnerabilities through audit fix 2022-11-10 13:48:31 +01:00
Daniël Klabbers
bb712693d4 chore: update application constant for version 2022-11-10 13:48:17 +01:00
Daniël Klabbers
1e00e3fdbb chore: dependency on core updated for extensions 2022-11-10 13:48:00 +01:00
Daniël Klabbers
47d7a6e155 chore: changelog for v1.6.0 2022-11-10 13:47:23 +01:00
Daniël Klabbers
45d91212f6 fix: akismet fails when the extension is not on a version 2022-11-09 20:42:49 +01:00
flarum-bot
1a81c98d43 Bundled output for commit c98e6ba5a7
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-07 15:55:28 +00:00
Ian Morland
c98e6ba5a7 fix: groupmentions have poor contrast on some backgrounds (#3672)
* fix: poor text contrast on some backgrounds
* use hexdec()
* fix render test
* appears to be working now
2022-11-07 16:49:47 +01:00
flarum-bot
eeb00cc56b Bundled output for commit 87cdb5b4d8
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-07 15:14:44 +00:00
Ian Morland
87cdb5b4d8 feat: Allow additional reset password params, introduce ForgotPasswordValidator (#3671)
* feat: Allow additional reset password params, introduce 'ForgotPasswordValidator'

* Apply fixes from StyleCI

Co-authored-by: StyleCI Bot <bot@styleci.io>
2022-11-07 15:06:00 +00:00
flarum-bot
bc4b0b864c Bundled output for commit 53ab1503e4
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-07 13:53:51 +00:00
Ian Morland
53ab1503e4 feat: Allow additional login params, Introduce LogInValidator (#3670)
* Allow additional login params, dispatch 'LoggingIn' event

* Update framework/core/js/src/forum/components/LogInModal.tsx

Co-authored-by: David Wheatley <hi@davwheat.dev>

* Introduce 'LogInValidator'

* Apply fixes from StyleCI

Co-authored-by: David Wheatley <hi@davwheat.dev>
Co-authored-by: StyleCI Bot <bot@styleci.io>
2022-11-07 13:47:04 +00:00
Sami Mazouz
62a396e434 feat: send notifications of a new reply when post is approved (#3656)
* test(subscriptions): approved reply sends out notifications to users

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

* feat: send notifications when a post is approved

The code in approval was extracted into a listener because no matter what listeners are always executed before subscribers even if the extension is set to load before.

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-07 11:52:28 +00:00
flarum-bot
2096fa2807 Bundled output for commit 06963df407
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-07 11:36:10 +00:00
Ian Morland
06963df407 chore: format js 2022-11-07 11:30:01 +00:00
flarum-bot
8fe09815f5 Bundled output for commit fccc3e2188
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-07 11:28:16 +00:00
ornanovitch
fccc3e2188 feat: contrast util with yiq calculator (#3652)
* add yiq calculator util

* fix: convert 3 chars hex to 6 chars hex

* fix: clarify util name

* feat: add text color variables not depending on the dark/light mode

* refactor: change getContrast to isDark with for a more direct approach

* fix: adjust snippet description

* chore: change `var` to `let`

Co-authored-by: David Wheatley <david@davwheat.dev>
2022-11-07 11:20:02 +00:00
Sami Mazouz
f0a867b20f chore: throw an exception when no serializer is provided to the controller (#3614)
* chore: throw an exception when no serializer is provided to the controller

Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com>

* test: no serializer set throws exception

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com>
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-06 18:56:07 +00:00
Sami Mazouz
69311ae689 feat: allow specifying extensions when installing an instance (#3655)
* feat: allow specifying extensions when installing an instance

Useful when doing migrations where more than the default extensions are required to migrate the data to flarum. This allows quickly spinning up a flarum database with the necessary schema.

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

* fix: consider dependency graph before running migrations

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-06 18:43:13 +00:00
Clark Winkelmann
f005b9e031 Fix tag discussion count decreased by 2 when hiding before deleting (#3660) 2022-11-06 18:27:32 +00:00
flarum-bot
31ced98e0d Bundled output for commit 9964ddd731
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-06 18:26:49 +00:00
David Wheatley
9964ddd731 [statistics] fix: add missing last period to custom date ranges (#3661)
* fix: last node in previous data matches first node of current data

* fix: add previous period support for custom periods

* test: update to show previous period for custom range
2022-11-06 18:21:28 +00:00
flarum-bot
32ac0a8d8f Bundled output for commit 827e905f8e
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-05 19:34:25 +00:00
Ian Morland
827e905f8e [mentions] feat: group mentions (#3658)
* wip: group mentions

* Apply fixes from StyleCI

* chore: format

* group mention autocomplete

* chore: format

* remove console.log

* implement notifications

* prevent guest and member groups from being mentioned

* Update extensions/mentions/less/forum.less

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* rename displayname to groupname

* Update extensions/mentions/src/Formatter/FormatGroupMentions.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* remove redundant unparse

* simplify migrations

* add group deleted translation

* Apply fixes from StyleCI

* handle everything falsy

* Include icon in group mention preview

* remove box-shadow from autocomplete group results

* Add color to preview

* chore: format

* Remove box shadow from group autocomplete results

* Update extensions/mentions/migrations/2022_10_21_000000_create_post_mentions_groups_table.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* remove unneeded migration

* prevent former group icon from beingdisplayed

* add group searcher with permissions

* Apply fixes from StyleCI

* Search groups based on canSearchGroups permission

* Don't include virtual groups in results

* Add search groups translation

* Revert "remove unneeded migration"

This reverts commit 9347665baa.

* Revert "Update extensions/mentions/migrations/2022_10_21_000000_create_post_mentions_groups_table.php"

This reverts commit 8406d51df2.

* add searchGroups permission to tests

* Apply fixes from StyleCI

* Add default searchGroups permission

* Apply fixes from StyleCI

* Update extensions/mentions/js/src/forum/addComposerAutocomplete.js

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update extensions/mentions/migrations/2022_10_21_000000_create_post_mentions_groups_table.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* remove unneeded migration, correct table table

* correct table name in down migration

* Remove group searcher

* Apply fixes from StyleCI

* Remove group searching from composer autocomplete

* Add mentionGroups permission

* Apply fixes from StyleCI

* prevent post preview from rendering a group mention when user does not have permission

* remove test changes

* wip: expose ServerRequestInterface to textformatter parse()

* Apply fixes from StyleCI

* Set post content properly

* php 7.x compatibility

* begin adding groupmention tests

* Apply fixes from StyleCI

* test virtual groups don't mention

* Apply fixes from StyleCI

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update extensions/mentions/extend.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update extensions/mentions/extend.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* requested changes

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/tests/integration/api/groups/ListTest.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/src/Search/SearchServiceProvider.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* Update framework/core/src/Extend/Formatter.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* remove default permission migration

* try using datetime column instead of timestamp

* Apply fixes from StyleCI

* chore: remove commented code

* add tests

* Apply fixes from StyleCI

* Pass actor to parser instead of ServerRequest

* Allow for  to be null

* Update framework/core/src/Extend/Formatter.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* pass actor instead of request

* Apply fixes from StyleCI

* actor instead of request

* remove serverrequest

* Apply fixes from StyleCI

* remove dupe actor

* Update extensions/mentions/src/Formatter/CheckPermissions.php

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>

* fix type in comment

* group does not have the relation, post does

* test: invalid, deleted, fresh data mentions

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

* Apply fixes from StyleCI

* fix: group mentions don't work when editing posts

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
Co-authored-by: StyleCI Bot <bot@styleci.io>
Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-05 19:29:01 +00:00
Sami Mazouz
cdc76567d4 fix: larastan v1 incompatible with phpstan v1.9.0 (#3665)
We can't update to larastan v2 until we update to laravel v9 which has to wait for Flarum v2, so we need to stay on phpstan v1.8

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-11-03 11:55:52 +01:00
Clark Winkelmann
5898a50463 Log migration path when up/down keys are missing (#3664)
* Log migration path when up/down keys are missing
2022-11-02 15:22:21 +01:00
flarum-bot
72d9ee2010 Bundled output for commit 4e5e4e5c6e
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-11-01 17:53:52 +00:00
David Wheatley
4e5e4e5c6e feat: add statistics chart export button (#3662) 2022-11-01 18:47:22 +01:00
flarum-bot
201d7430fe Bundled output for commit 52f6148876
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-10-18 14:44:18 +00:00
Sami Mazouz
52f6148876 chore: add priorities to profile settings page (#3657)
To give extensions more flexibility
2022-10-18 16:38:25 +02:00
Sami Mazouz
dc215aba59 chore(statistics): prepare v1.5.1
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-10-11 18:22:47 +01:00
flarum-bot
90a68506b8 Bundled output for commit bd0577f435
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-10-02 18:49:08 +00:00
Sami Mazouz
bd0577f435 fix: statistics previous period chart is unclear (#3654)
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-10-02 19:43:47 +01:00
Ngô Quốc Đạt
d33f1abffc fix: apply flex for AppearancePage colors input (#3651) 2022-10-01 20:18:03 +02:00
Sami Mazouz
b0b47a0888 test: allow specifying php extensions in workflow
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-09-30 20:53:50 +01:00
flarum-bot
8c0a14aff2 Bundled output for commit 76788efaba
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-09-29 11:17:55 +00:00
David Wheatley
76788efaba feat(statistics): support for custom date ranges (#3622)
* feat: backend support for statistics custom date ranges
* feat: use seconds-based timestamps on backend instead
* feat: add frontend date selection option
* feat: add tests for lifetime and timed stats
* fix: add error alert when end date is after start date
* fix: wrong label
* fix: no data when start and end date are same day
* fix: use utc dayjs for formatting custom date range on widget
* chore: add dayjs as project dep
* fix: make end date inclusive
* feat: add integration test for custom date period
* fix: incorrect ts expect error comment
* fix: add missing type
* fix: typing errors
* fix(tests): remove type from class attribute definition
* fix: extract default values to function body
* fix: typo
* chore: use small modal
* fix: add missing `FormControl` class
* fix: cast url params to int to enforce type
* chore: `yarn format`

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>
2022-09-29 13:12:54 +02:00
flarum-bot
973ec32e13 Bundled output for commit cf818aae9e
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-09-23 18:59:15 +00:00
Ngô Quốc Đạt
cf818aae9e replace ColorPreviewInput for GroupModal color input (#3650) 2022-09-23 19:52:48 +01:00
flarum-bot
6da0bc63b7 Bundled output for commit 6e1bc2daed
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-09-23 13:18:01 +00:00
Ngô Quốc Đạt
6e1bc2daed fix: package manager failures not showing alerts (#3647)
* fix: close `LoadingModal` when install falied
* move error handler to catch
2022-09-23 14:11:36 +01:00
Rafael Horvat
7ce9d63ed6 feat(test): Make it possible to extend SetupScript (#3643)
* Make it possible to extend Flarum\Testing\integration\Setup\SetupScript and added public methods to add settings or extensions to in initial installation pipeline

* Fix syntax error, unexpected 'static'

* Remove `addExtensions` method and document `addSettings`
2022-09-23 13:44:17 +02:00
StyleCI Bot
267f6759f8 Apply fixes from StyleCI 2022-09-22 08:21:34 +00:00
Daniël Klabbers
b5874a08e4 fix(approval): posts approved for deleted users error
In certain edge cases posts get approved through moderative action and
throws an error when the user has already been deleted.

Ref: DISCUSS-11K on sentry
2022-09-22 10:20:06 +02:00
flarum-bot
368e08bb9b Bundled output for commit 5f2d7fb7b6
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-09-17 11:36:06 +00:00
Sami Mazouz
5f2d7fb7b6 fix(regression): bad import
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-09-17 12:29:16 +01:00
flarum-bot
68d6e30143 Bundled output for commit c5c312db0d
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-09-17 11:07:16 +00:00
Sami Mazouz
c5c312db0d chore: yarn format
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-09-17 11:54:09 +01:00
flarum-bot
05f80b7b83 Bundled output for commit 31c3cfc4ea
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-09-16 10:02:55 +00:00
Sami Mazouz
31c3cfc4ea chore(package-manager): set min core version and add warning
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-09-16 10:54:39 +01:00
Sami Mazouz
fd196454a5 chore(package-manager): config composer to use web php version
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-09-15 11:07:08 +01:00
Sami Mazouz
f6761843b2 feat: customizable session driver (#3610) 2022-09-14 18:10:30 +01:00
Sami Mazouz
84c31165e5 fix: password reset leaks user existence (#3616) 2022-09-14 15:57:52 +01:00
Sami Mazouz
fc4d5e3d43 chore: Setup PHPStan Level 5 (#3553) 2022-09-14 15:23:56 +01:00
Daniël Klabbers
b2fa28e4b5 chore: remove styleci from changelog 2022-09-13 12:22:45 +02:00
Daniël Klabbers
fc743ba888 chore: set flarum version to dev for 1.6.0 2022-09-13 12:22:29 +02:00
Daniël Klabbers
a35df8c139 chore: v1.5.0 preparations 2022-09-13 10:55:30 +02:00
Sami Mazouz
8aec252452 fix(qa): simpler grid layout fix for overflow
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-09-08 20:52:33 +01:00
Sami Mazouz
32961d480c fix(qa): grid layout causes overflowing of content
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-09-08 20:33:32 +01:00
Alexander Skvortsov
27e3d322ec feat: increase margin between notifications list control icons (#3629)
* feat: increase margin between notifications list control icons

This feels cleaner, and reduces the chances of a misclick.

Co-authored-by: Sami Mazouz <sychocouldy@gmail.com>
2022-09-06 12:28:09 +02:00
Sami Mazouz
9eab1085da fix(regression): exiting long modal by clicking backdrop fails
Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-09-05 15:32:14 +01:00
flarum-bot
c305f9a105 Bundled output for commit 3b773e2677
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-09-05 13:31:03 +00:00
Sami Mazouz
3b773e2677 chore: don't pass password field between auth modals (#3626)
* chore: don't pass password field between auth modals
* chore: reset password on failed attempts
* chore: `yarn format`

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-09-05 13:44:11 +01:00
Daniël Klabbers
6e48a0303e Create CODEOWNERS
Start requiring an approval from the core team on PR's
2022-09-05 09:03:20 +02:00
Sami Mazouz
31f1ffd6a5 fix(regression): bad post actions alignment introduced in #3540 (#3619)
* chore: merge media queries
* chore: change discussion page skeleton to grid layout
* chore: use grid areas instead of order

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
2022-09-03 13:30:44 +01:00
Sami Mazouz
9897f682a0 fix: explicitly select post columns (#3621)
additional columns can be explicitly added by extensions

Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com>
2022-09-03 12:33:38 +01:00
flarum-bot
f3156c65c9 Bundled output for commit 2719042c71
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-08-31 19:22:47 +00:00
Robert Korulczyk
2719042c71 feat: add custom class for email confirmation alert (#3584) 2022-08-31 21:13:25 +02:00
flarum-bot
ffd0b90a83 Bundled output for commit 87aaaf6971
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-08-31 09:20:08 +00:00
David Wheatley
87aaaf6971 feat(subscriptions): add option to send notifications when not caught up (#3503) 2022-08-31 11:13:51 +02:00
Sami Mazouz
6ffa9e3736 fix(mentions): accessing id of null user relation (#3618)
* test: editing a post with deleted author and mentions
* fix(mentions): accessing `id` of null `user` relation

Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com>
2022-08-26 10:38:00 +01:00
Sami Mazouz
434c459246 fix(tags): use default index sortmap (#3615)
Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com>
2022-08-24 12:17:00 +01:00
Sami Mazouz
7f2f3e34f5 chore(statistics): add release notes for 1.4.2
Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com>
2022-08-22 11:24:29 +01:00
Sami Mazouz
f7dd609b26 feat: discussion UTF-8 slug driver (#3606)
* feat: add utf-8 slug driver
* test: add tests for slugging expectations
* fix: non-word characters aren't removed

Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com>
Co-authored-by: Alexander Skvortsov <sasha.skvortsov109@gmail.com>
2022-08-21 15:27:41 +01:00
flarum-bot
ec97ee41f9 Bundled output for commit 335c602cea
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-08-20 17:30:31 +00:00
Sami Mazouz
335c602cea chore(package-manager): last tweaks before beta tag
chore: fix workflow errors
chore: fix workflow errors
chore: avoid updating an extension that wasn't directly required
chore: prevent job overlap
chore: reorganize code, separate state from view
fix: update checking ui display
chore: minor improvements

Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com>
2022-08-20 18:21:02 +01:00
flarum-bot
082117d8bc Bundled output for commit 11a9b73610
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-08-17 13:02:21 +00:00
Hasan Özbey
11a9b73610 fix: use isCollapsed instead of rangeCount (#3581) 2022-08-17 13:56:03 +01:00
flarum-bot
974f003fe3 Bundled output for commit 352a50e3ad
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2022-08-16 17:36:57 +00:00
David Wheatley
352a50e3ad perf(statistics): split timed data into per-model XHR requests (#3601)
* chore: kill off timeset offset from statistics extension

* perf: split timed data into per-model requests
2022-08-16 18:30:24 +01:00
Ngô Quốc Đạt
5637fe8041 fix: $events property declared dynamically (#3598) 2022-08-16 17:50:29 +01:00
David Wheatley
3d167749cb fix(subscriptions): add missing table prefix for filter gambit (#3599) 2022-08-15 12:35:03 +02:00
David Wheatley
1a189f4923 fix: typo in error message 2022-08-14 17:16:52 +01:00
Sami Mazouz
6de8113720 test: prevent running both push and pull_request actions at the same time (#3597)
Not a 100% perfect solution, however as us maintainers always push branches directly to the original repository and make PRs from those, this is plenty enough. Instead of having 300+ actions in each PR which takes time, we'll just have the normal 171 `push` actions.

This makes sure to still run `pull_request` actions from contributors as they'd be forking the repo.

Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com>
2022-08-13 09:33:37 +01:00
Sami Mazouz
f74f7f58cd test: refactor prefix matrix and add MySQL 8.0 & PHP 7.3 to workflows (#3595)
* chore: simplify added prefix tests to matrix without using exclusions
* test: recover 7.3 testing
* test: add mysql 8.0 to matrix
* test: exclude some PHP versions from running with MySQL 8.0 to reduce actions

Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com>
2022-08-12 19:22:38 +01:00
389 changed files with 5310 additions and 11901 deletions

View File

@@ -20,3 +20,6 @@ indent_size = 4
[tsconfig.json]
indent_size = 2
[*.neon]
indent_style = tab

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @flarum/core

View File

@@ -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
@@ -19,12 +25,19 @@ on:
description: Versions of PHP to test with. Should be array of strings encoded as JSON array
type: string
required: false
default: '["7.4", "8.0", "8.1"]'
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
required: false
default: '["mysql:5.7", "mariadb"]'
default: '["mysql:5.7", "mysql:8.0.30", "mariadb"]'
php_ini_values:
description: PHP ini values
@@ -44,23 +57,41 @@ jobs:
matrix:
php: ${{ fromJSON(inputs.php_versions) }}
service: ${{ fromJSON(inputs.db_versions) }}
prefix: ['', flarum_]
prefix: ['']
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrixinclude
include:
# Expands the matrix by naming DBs.
- service: 'mysql:5.7'
db: MySQL
db: MySQL 5.7
- service: 'mysql:8.0.30'
db: MySQL 8.0
- service: mariadb
db: MariaDB
- prefix: flarum_
# Include Database prefix tests with only one PHP version.
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'mysql:5.7'
db: MySQL 5.7
prefix: flarum_
prefixStr: (prefix)
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'mysql:8.0.30'
db: MySQL 8.0
prefix: flarum_
prefixStr: (prefix)
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: mariadb
db: MariaDB
prefix: flarum_
prefixStr: (prefix)
# To reduce number of actions, we exclude some PHP versions from running with some DB versions.
exclude:
- php: 8.0
service: 'mysql:5.7'
prefix: flarum_
- php: 8.0
service: mariadb
prefix: flarum_
- php: ${{ fromJSON(inputs.php_versions)[1] }}
service: 'mysql:8.0.30'
- php: ${{ fromJSON(inputs.php_versions)[2] }}
service: 'mysql:8.0.30'
services:
mysql:
@@ -70,7 +101,9 @@ jobs:
name: 'PHP ${{ matrix.php }} / ${{ matrix.db }} ${{ matrix.prefixStr }}'
if: inputs.enable_backend_testing
if: >-
inputs.enable_backend_testing &&
((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
@@ -80,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 }}
@@ -110,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

View File

@@ -91,6 +91,9 @@ jobs:
name: Checks & Build
runs-on: ubuntu-latest
if: >-
((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || github.event_name != 'pull_request')
steps:
- name: Check out code
uses: actions/checkout@v2

12
.github/workflows/phpstan.yml vendored Normal file
View 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: .

View File

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

View File

@@ -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"
}
}

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.4",
"flarum/core": "^1.6",
"flarum/approval": "^1.2"
},
"autoload": {

View File

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

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.4",
"flarum/core": "^1.6",
"flarum/flags": "^1.2"
},
"autoload": {

View File

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

View File

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

View File

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

View File

@@ -28,11 +28,8 @@ trait InteractsWithUnapprovedContent
['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 3, 'comment_count' => 1, 'is_approved' => 0, 'is_private' => 1],
['id' => 4, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 4, 'comment_count' => 1, 'is_approved' => 1, 'is_private' => 0],
['id' => 5, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 5, 'comment_count' => 1, 'is_approved' => 1, 'is_private' => 0],
['id' => 6, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 2, 'first_post_id' => 6, 'comment_count' => 1, 'is_approved' => 0, 'is_private' => 1],
['id' => 6, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 6, 'comment_count' => 1, 'is_approved' => 0, 'is_private' => 1],
['id' => 7, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 7, 'comment_count' => 1, 'is_approved' => 1, 'is_private' => 0],
// Normal discussion with first post being private (also means comment_count = 0).
['id' => 8, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 12, 'comment_count' => 0, 'is_approved' => 1, 'is_private' => 0],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
@@ -40,26 +37,39 @@ trait InteractsWithUnapprovedContent
['id' => 3, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 4, 'discussion_id' => 4, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 5, 'discussion_id' => 5, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 6, 'discussion_id' => 6, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 6, 'discussion_id' => 6, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 7, 'discussion_id' => 7, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 8, 'discussion_id' => 7, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 2],
['id' => 9, 'discussion_id' => 7, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 1, 'is_approved' => 0, 'number' => 3],
['id' => 9, 'discussion_id' => 7, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 1, 'is_approved' => 0, 'number' => 3],
['id' => 10, 'discussion_id' => 7, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 4],
['id' => 11, 'discussion_id' => 7, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 1, 'is_approved' => 0, 'number' => 5],
// First post of a normal discussion being private.
['id' => 12, 'discussion_id' => 8, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 1, 'is_approved' => 0, 'number' => 1],
],
'groups' => [
['id' => 100, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0]
['id' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0]
],
'group_user' => [
['user_id' => 3, 'group_id' => 100]
['user_id' => 3, 'group_id' => 4]
],
'group_permission' => [
['permission' => 'discussion.approvePosts', 'group_id' => 100]
['permission' => 'discussion.approvePosts', 'group_id' => 4]
]
]);
}
/**
* null: Guest, 2: Normal User.
*/
public function unallowedUsers(): array
{
return [[null], [2]];
}
/**
* 1: Admin, 3: Permission Given, 4: Discussions Author.
*/
public function allowedUsers(): array
{
return [[1], [3], [4]];
}
}

View File

@@ -29,10 +29,10 @@ class ListDiscussionsTest extends TestCase
}
/**
* @dataProvider userVisibleDiscussionsDataProvider
* @dataProvider unallowedUsers
* @test
*/
public function can_only_see_approved_if_allowed(?int $authenticatedAs, array $visibleDiscussionIds)
public function can_only_see_approved_if_not_allowed_to_approve(?int $authenticatedAs)
{
$response = $this->send(
$this->request('GET', '/api/discussions', compact('authenticatedAs'))
@@ -41,17 +41,22 @@ class ListDiscussionsTest extends TestCase
$body = json_decode($response->getBody()->getContents(), true);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEqualsCanonicalizing($visibleDiscussionIds, Arr::pluck($body['data'], 'id'));
$this->assertEqualsCanonicalizing([1, 4, 5, 7], Arr::pluck($body['data'], 'id'));
}
public function userVisibleDiscussionsDataProvider(): array
/**
* @dataProvider allowedUsers
* @test
*/
public function can_see_unapproved_if_allowed_to_approve(int $authenticatedAs)
{
return [
'admin can view unapproved discussions' => [1, [1, 2, 3, 4, 5, 6, 7, 8]],
'user with perms can view unapproved discussions' => [3, [1, 2, 3, 4, 5, 6, 7, 8]],
'guests cannot view unapproved discussions' => [null, [1, 4, 5, 7]],
'normal users cannot view unapproved discussions unless being an author 1' => [2, [1, 4, 5, 6, 7]],
'normal users cannot view unapproved discussions unless being an author 2' => [4, [1, 2, 3, 4, 5, 7, 8]],
];
$response = $this->send(
$this->request('GET', '/api/discussions', compact('authenticatedAs'))
);
$body = json_decode($response->getBody()->getContents(), true);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEqualsCanonicalizing([1, 2, 3, 4, 5, 6, 7], Arr::pluck($body['data'], 'id'));
}
}

View File

@@ -29,10 +29,10 @@ class ListPostsTest extends TestCase
}
/**
* @dataProvider userVisiblePostsDataProvider
* @dataProvider unallowedUsers
* @test
*/
public function can_only_see_approved_if_allowed(?int $authenticatedAs, array $visiblePostIds)
public function can_only_see_approved_if_not_allowed_to_approve(?int $authenticatedAs)
{
$response = $this->send(
$this
@@ -47,22 +47,28 @@ class ListPostsTest extends TestCase
$body = json_decode($response->getBody()->getContents(), true);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEqualsCanonicalizing($visiblePostIds, Arr::pluck($body['data'], 'id'));
$this->assertEqualsCanonicalizing([7, 8, 10], Arr::pluck($body['data'], 'id'));
}
public function userVisiblePostsDataProvider(): array
/**
* @dataProvider allowedUsers
* @test
*/
public function can_see_unapproved_if_allowed_to_approve(int $authenticatedAs)
{
return [
// Admin can view unapproved posts.
[1, [7, 8, 9, 10, 11, 12]],
$response = $this->send(
$this
->request('GET', '/api/posts', compact('authenticatedAs'))
->withQueryParams([
'filter' => [
'discussion' => 7
]
])
);
// User with approval perms can view unapproved posts.
[3, [7, 8, 9, 10, 11, 12]],
$body = json_decode($response->getBody()->getContents(), true);
// Normal users cannot view unapproved posts unless being an author.
[null, [7, 8, 10]],
[2, [7, 8, 9, 10]],
[4, [7, 8, 10, 11, 12]],
];
$this->assertEquals(200, $response->getStatusCode());
$this->assertEqualsCanonicalizing([7, 8, 9, 10, 11], Arr::pluck($body['data'], 'id'));
}
}

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.4"
"flarum/core": "^1.6"
},
"extra": {
"branch-alias": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.4"
"flarum/core": "^1.6"
},
"autoload": {
"psr-4": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.4"
"flarum/core": "^1.6"
},
"extra": {
"branch-alias": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.4"
"flarum/core": "^1.6"
},
"autoload": {
"psr-4": {

View File

@@ -7,7 +7,7 @@
],
"license": "MIT",
"require": {
"flarum/core": "^1.4"
"flarum/core": "^1.6"
},
"extra": {
"branch-alias": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.4"
"flarum/core": "^1.6"
},
"autoload": {
"psr-4": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.4"
"flarum/core": "^1.6"
},
"autoload": {
"psr-4": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.4"
"flarum/core": "^1.6"
},
"extra": {
"branch-alias": {

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.4"
"flarum/core": "^1.6.3"
},
"autoload": {
"psr-4": {

View File

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

View File

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

View File

@@ -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":""}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
export default function selectedText(body) {
const selection = window.getSelection();
if (selection?.rangeCount) {
if (!selection.isCollapsed) {
const range = selection.getRangeAt(0);
const parent = range.commonAncestorContainer;

View File

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

View File

@@ -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 {

View File

@@ -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}

View File

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

View File

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

View File

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

View 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;
}
}

View 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;
});
}
}

View File

@@ -40,5 +40,8 @@ class UpdateMentionsMetadataWhenInvisible
// Remove post mentions
$event->post->mentionsPosts()->sync([]);
// Remove group mentions
$event->post->mentionsGroups()->sync([]);
}
}

View File

@@ -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)
@@ -60,7 +66,7 @@ class UpdateMentionsMetadataWhenVisible
$users = User::whereIn('id', $mentioned)
->get()
->filter(function ($user) use ($post) {
return $post->isVisibleTo($user) && $user->id !== $post->user->id;
return $post->isVisibleTo($user) && $user->id !== $post->user_id;
})
->all();
@@ -75,8 +81,8 @@ class UpdateMentionsMetadataWhenVisible
$posts = Post::with('user')
->whereIn('id', $mentioned)
->get()
->filter(function ($post) use ($reply) {
return $post->user && $post->user->id !== $reply->user_id && $reply->isVisibleTo($post->user);
->filter(function (Post $post) use ($reply) {
return $post->user && $post->user_id !== $reply->user_id && $reply->isVisibleTo($post->user);
})
->all();
@@ -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);
}
}

View File

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

View 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));
}
}

View File

@@ -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>'],
@@ -47,12 +48,17 @@ class PostMentionsTest extends TestCase
['id' => 8, 'number' => 6, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="i_am_a_deleted_user" id="2020" number="8" discussionid="2" username="i_am_a_deleted_user">@"i_am_a_deleted_user"#p2020</POSTMENTION></r>'],
['id' => 9, 'number' => 10, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 5, 'type' => 'comment', 'content' => '<r><p>I am bad</p></r>'],
['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="Bad &quot;#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 &quot;#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],
['post_id' => 5, 'mentions_post_id' => 4],
['post_id' => 6, 'mentions_post_id' => 7],
['post_id' => 10, 'mentions_post_id' => 9]
['post_id' => 10, 'mentions_post_id' => 9],
],
]);
@@ -126,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
*/
@@ -417,6 +454,90 @@ class PostMentionsTest extends TestCase
$this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']);
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(9));
}
/**
* @test
*/
public function editing_a_post_that_has_a_mention_works()
{
$response = $this->send(
$this->request('PATCH', '/api/posts/10', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'attributes' => [
'content' => '@"Bad _ User"#p9',
],
],
],
])
);
$this->assertEquals(200, $response->getStatusCode());
$response = json_decode($response->getBody(), true);
$this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']);
$this->assertEquals('@"Bad _ User"#p9', $response['data']['attributes']['content']);
$this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']);
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(9));
}
/**
* @test
*/
public function editing_a_post_with_deleted_author_that_has_a_mention_works()
{
$response = $this->send(
$this->request('PATCH', '/api/posts/11', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'attributes' => [
'content' => '@"Bad _ User"#p9',
],
],
],
])
);
$this->assertEquals(200, $response->getStatusCode());
$response = json_decode($response->getBody(), true);
$this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']);
$this->assertEquals('@"Bad _ User"#p9', $response['data']['attributes']['content']);
$this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']);
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(9));
}
/**
* @test
*/
public function editing_a_post_with_a_mention_of_a_post_with_deleted_author_works()
{
$response = $this->send(
$this->request('PATCH', '/api/posts/12', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'attributes' => [
'content' => '@"acme"#p11',
],
],
],
])
);
$this->assertEquals(200, $response->getStatusCode());
$response = json_decode($response->getBody(), true);
$this->assertStringContainsString('[deleted]', $response['data']['attributes']['contentHtml']);
$this->assertEquals('@"[deleted]"#p11', $response['data']['attributes']['content']);
$this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']);
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(11));
}
}
class CustomOtherDisplayNameDriver implements DriverInterface

View File

@@ -44,10 +44,11 @@ class UserMentionsTest extends TestCase
['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><USERMENTION displayname="TobyFlarum___" id="4" username="toby">@tobyuuu</USERMENTION></r>'],
['id' => 6, 'number' => 3, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><USERMENTION displayname="i_am_a_deleted_user" id="2021" username="i_am_a_deleted_user">@"i_am_a_deleted_user"#2021</USERMENTION></r>'],
['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 5, 'type' => 'comment', 'content' => '<r><USERMENTION displayname="Bad &quot;#p6 User" id="5">@"Bad "#p6 User"#5</USERMENTION></r>'],
['id' => 11, 'number' => 12, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 50, 'type' => 'comment', 'content' => '<r><USERMENTION displayname="Bad &quot;#p6 User" id="5">@"Bad "#p6 User"#5</USERMENTION></r>'],
],
'post_mentions_user' => [
['post_id' => 4, 'mentions_user_id' => 4],
['post_id' => 10, 'mentions_user_id' => 5]
['post_id' => 10, 'mentions_user_id' => 5],
],
]);
@@ -438,6 +439,62 @@ class UserMentionsTest extends TestCase
$this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']);
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(5));
}
/**
* @test
*/
public function editing_a_post_that_has_a_mention_works()
{
$response = $this->send(
$this->request('PATCH', '/api/posts/10', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'attributes' => [
'content' => '@"Bad _ User"#5',
],
],
],
])
);
$this->assertEquals(200, $response->getStatusCode());
$response = json_decode($response->getBody(), true);
$this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']);
$this->assertEquals('@"Bad _ User"#5', $response['data']['attributes']['content']);
$this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']);
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(5));
}
/**
* @test
*/
public function editing_a_post_with_deleted_author_that_has_a_mention_works()
{
$response = $this->send(
$this->request('PATCH', '/api/posts/11', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'attributes' => [
'content' => '@"Bad _ User"#5',
],
],
],
])
);
$this->assertEquals(200, $response->getStatusCode());
$response = json_decode($response->getBody(), true);
$this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']);
$this->assertEquals('@"Bad _ User"#5', $response['data']['attributes']['content']);
$this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']);
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(5));
}
}
class CustomDisplayNameDriver implements DriverInterface

View File

@@ -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
]) !!}

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.4"
"flarum/core": "^1.6"
},
"autoload": {
"psr-4": {

View File

@@ -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": {

View File

@@ -41,7 +41,8 @@ return [
$paths = resolve(Paths::class);
$document->payload['flarum-package-manager.writable_dirs'] = is_writable($paths->vendor)
&& is_writable($paths->storage.'/.composer')
&& is_writable($paths->storage)
&& (! file_exists($paths->storage.'/.composer') || is_writable($paths->storage.'/.composer'))
&& is_writable($paths->base.'/composer.json')
&& is_writable($paths->base.'/composer.lock');

View File

@@ -1,5 +1,7 @@
/// <reference types="mithril" />
import Component from 'flarum/common/Component';
export default class ControlSection extends Component {
import { ComponentAttrs } from 'flarum/common/Component';
import Mithril from 'mithril';
export default class ControlSection extends Component<ComponentAttrs> {
oninit(vnode: Mithril.Vnode<ComponentAttrs, this>): void;
view(): JSX.Element;
}

View File

@@ -1,7 +1,7 @@
import type Mithril from 'mithril';
import Component, { ComponentAttrs } from 'flarum/common/Component';
import { Extension } from 'flarum/admin/AdminApplication';
import { UpdatedPackage } from './Updater';
import { UpdatedPackage } from '../states/ControlSectionState';
export interface ExtensionItemAttrs extends ComponentAttrs {
extension: Extension;
updates: UpdatedPackage;

View File

@@ -1,14 +1,13 @@
import type Mithril from 'mithril';
import Component, { ComponentAttrs } from 'flarum/common/Component';
import Stream from 'flarum/common/utils/Stream';
interface InstallerAttrs extends ComponentAttrs {
export interface InstallerAttrs extends ComponentAttrs {
}
export declare type InstallerLoadingTypes = 'extension-install' | null;
export default class Installer extends Component<InstallerAttrs> {
packageName: Stream<string>;
isLoading: boolean;
oninit(vnode: Mithril.Vnode<InstallerAttrs, this>): void;
view(): Mithril.Children;
data(): any;
onsubmit(): void;
}
export {};

View File

@@ -1,15 +1,14 @@
import type Mithril from 'mithril';
import Component, { ComponentAttrs } from 'flarum/common/Component';
import { UpdatedPackage, UpdateState } from './Updater';
interface MajorUpdaterAttrs extends ComponentAttrs {
import { UpdatedPackage, UpdateState } from '../states/ControlSectionState';
export interface MajorUpdaterAttrs extends ComponentAttrs {
coreUpdate: UpdatedPackage;
updateState: UpdateState;
}
export declare type MajorUpdaterLoadingTypes = 'major-update' | 'major-update-dry-run';
export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttrs> extends Component<T> {
isLoading: string | null;
updateState: UpdateState;
oninit(vnode: Mithril.Vnode<T, this>): void;
view(vnode: Mithril.Vnode<T, this>): Mithril.Children;
view(): Mithril.Children;
update(dryRun: boolean): void;
}
export {};

View File

@@ -1,5 +1,5 @@
/// <reference types="mithril" />
/// <reference types="flarum/@types/translator-icu-rich" />
/// <reference types="@flarum/core/dist-typings/@types/translator-icu-rich" />
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
import Task from '../models/Task';
interface TaskOutputModalAttrs extends IInternalModalAttrs {

View File

@@ -1,49 +1,12 @@
import Mithril from 'mithril';
/// <reference types="mithril" />
import Component, { ComponentAttrs } from 'flarum/common/Component';
import { Extension } from 'flarum/admin/AdminApplication';
export declare type UpdatedPackage = {
name: string;
version: string;
latest: string;
'latest-minor': string | null;
'latest-major': string | null;
'latest-status': string;
description: string;
};
export declare type ComposerUpdates = {
installed: UpdatedPackage[];
};
export declare type LastUpdateCheck = {
checkedAt: Date | null;
updates: ComposerUpdates;
};
declare type UpdateType = 'major' | 'minor' | 'global';
declare type UpdateStatus = 'success' | 'failure' | null;
export declare type UpdateState = {
ranAt: Date | null;
status: UpdateStatus;
limitedPackages: string[];
incompatibleExtensions: string[];
};
export declare type LastUpdateRun = {
[key in UpdateType]: UpdateState;
} & {
limitedPackages: () => string[];
};
interface UpdaterAttrs extends ComponentAttrs {
import ItemList from '@flarum/core/src/common/utils/ItemList';
export interface IUpdaterAttrs extends ComponentAttrs {
}
export default class Updater extends Component<UpdaterAttrs> {
isLoading: string | null;
packageUpdates: Record<string, UpdatedPackage>;
lastUpdateCheck: LastUpdateCheck;
get lastUpdateRun(): LastUpdateRun;
oninit(vnode: Mithril.Vnode<UpdaterAttrs, this>): void;
export declare type UpdaterLoadingTypes = 'check' | 'minor-update' | 'global-update' | 'extension-update' | null;
export default class Updater extends Component<IUpdaterAttrs> {
view(): (JSX.Element | null)[];
getExtensionUpdates(): Extension[];
getCoreUpdate(): UpdatedPackage | undefined;
checkForUpdates(): void;
updateCoreMinor(): void;
updateExtension(extension: any): void;
updateGlobally(): void;
lastUpdateCheckView(): JSX.Element | null;
availableUpdatesView(): JSX.Element;
controlItems(): ItemList<unknown>;
}
export {};

View File

@@ -1,4 +1,4 @@
/// <reference types="flarum/@types/translator-icu-rich" />
/// <reference types="@flarum/core/dist-typings/@types/translator-icu-rich" />
import type Mithril from 'mithril';
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
export interface WhyNotModalAttrs extends IInternalModalAttrs {

View File

@@ -0,0 +1,57 @@
import { UpdaterLoadingTypes } from '../components/Updater';
import { InstallerLoadingTypes } from '../components/Installer';
import { MajorUpdaterLoadingTypes } from '../components/MajorUpdater';
import { Extension } from 'flarum/admin/AdminApplication';
export declare type UpdatedPackage = {
name: string;
version: string;
latest: string;
'latest-minor': string | null;
'latest-major': string | null;
'latest-status': string;
description: string;
};
export declare type ComposerUpdates = {
installed: UpdatedPackage[];
};
export declare type LastUpdateCheck = {
checkedAt: Date | null;
updates: ComposerUpdates;
};
declare type UpdateType = 'major' | 'minor' | 'global';
declare type UpdateStatus = 'success' | 'failure' | null;
export declare type UpdateState = {
ranAt: Date | null;
status: UpdateStatus;
limitedPackages: string[];
incompatibleExtensions: string[];
};
export declare type LastUpdateRun = {
[key in UpdateType]: UpdateState;
} & {
limitedPackages: () => string[];
};
export declare type LoadingTypes = UpdaterLoadingTypes | InstallerLoadingTypes | MajorUpdaterLoadingTypes;
export declare type CoreUpdate = {
package: UpdatedPackage;
extension: Extension;
};
export default class ControlSectionState {
loading: LoadingTypes;
packageUpdates: Record<string, UpdatedPackage>;
lastUpdateCheck: LastUpdateCheck;
extensionUpdates: Extension[];
coreUpdate: CoreUpdate | null;
get lastUpdateRun(): LastUpdateRun;
constructor();
isLoading(name?: LoadingTypes): boolean;
isLoadingOtherThan(name: LoadingTypes): boolean;
setLoading(name: LoadingTypes): void;
checkForUpdates(): void;
updateCoreMinor(): void;
updateExtension(extension: Extension): void;
updateGlobally(): void;
formatExtensionUpdates(lastUpdateCheck: LastUpdateCheck): Extension[];
formatCoreUpdate(lastUpdateCheck: LastUpdateCheck): CoreUpdate | null;
}
export {};

View File

@@ -0,0 +1,6 @@
import QueueState from './QueueState';
import ControlSectionState from './ControlSectionState';
export default class PackageManagerState {
queue: QueueState;
control: ControlSectionState;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,17 @@
import app from 'flarum/admin/app';
import Component from 'flarum/common/Component';
import Alert from 'flarum/common/components/Alert';
import { ComponentAttrs } from 'flarum/common/Component';
import Installer from './Installer';
import Updater from './Updater';
import Mithril from 'mithril';
export default class ControlSection extends Component<ComponentAttrs> {
oninit(vnode: Mithril.Vnode<ComponentAttrs, this>) {
super.oninit(vnode);
}
export default class ControlSection extends Component {
view() {
return (
<div className="ExtensionPage-permissions PackageManager-controlSection">

View File

@@ -7,7 +7,7 @@ import Tooltip from 'flarum/common/components/Tooltip';
import Button from 'flarum/common/components/Button';
import { Extension } from 'flarum/admin/AdminApplication';
import { UpdatedPackage } from './Updater';
import { UpdatedPackage } from '../states/ControlSectionState';
import WhyNotModal from './WhyNotModal';
import Label from './Label';
@@ -40,7 +40,7 @@ export default class ExtensionItem<Attrs extends ExtensionItemAttrs = ExtensionI
<div className="PackageManager-extension-info">
<div className="PackageManager-extension-name">{extension.extra['flarum-extension'].title}</div>
<div className="PackageManager-extension-version">
<span className="PackageManager-extension-version-current">{this.version(extension.version)}</span>
<span className="PackageManager-extension-version-current">{this.version(updates['version'])}</span>
{latestVersion ? (
<Label className="PackageManager-extension-version-latest" type={updates['latest-minor'] ? 'success' : 'warning'}>
{this.version(latestVersion)}

View File

@@ -9,11 +9,12 @@ import errorHandler from '../utils/errorHandler';
import jumpToQueue from '../utils/jumpToQueue';
import { AsyncBackendResponse } from '../shims';
interface InstallerAttrs extends ComponentAttrs {}
export interface InstallerAttrs extends ComponentAttrs {}
export type InstallerLoadingTypes = 'extension-install' | null;
export default class Installer extends Component<InstallerAttrs> {
packageName!: Stream<string>;
isLoading: boolean = false;
oninit(vnode: Mithril.Vnode<InstallerAttrs, this>): void {
super.oninit(vnode);
@@ -32,7 +33,13 @@ export default class Installer extends Component<InstallerAttrs> {
</p>
<div className="FormControl-container">
<input className="FormControl" id="install-extension" placeholder="vendor/package-name" bidi={this.packageName} />
<Button className="Button" icon="fas fa-download" onclick={this.onsubmit.bind(this)} loading={this.isLoading}>
<Button
className="Button"
icon="fas fa-download"
onclick={this.onsubmit.bind(this)}
loading={app.packageManager.control.isLoading('extension-install')}
disabled={app.packageManager.control.isLoadingOtherThan('extension-install')}
>
{app.translator.trans('flarum-package-manager.admin.extensions.proceed')}
</Button>
</div>
@@ -47,7 +54,7 @@ export default class Installer extends Component<InstallerAttrs> {
}
onsubmit(): void {
this.isLoading = true;
app.packageManager.control.setLoading('extension-install');
app.modal.show(LoadingModal);
app
@@ -57,7 +64,6 @@ export default class Installer extends Component<InstallerAttrs> {
body: {
data: this.data(),
},
errorHandler,
})
.then((response) => {
if (response.processing) {
@@ -72,8 +78,10 @@ export default class Installer extends Component<InstallerAttrs> {
window.location.reload();
}
})
.catch(errorHandler)
.finally(() => {
this.isLoading = false;
app.packageManager.control.setLoading(null);
app.modal.close();
m.redraw();
});
}

View File

@@ -7,20 +7,21 @@ import LoadingModal from 'flarum/admin/components/LoadingModal';
import Alert from 'flarum/common/components/Alert';
import RequestError from 'flarum/common/utils/RequestError';
import { UpdatedPackage, UpdateState } from './Updater';
import { UpdatedPackage, UpdateState } from '../states/ControlSectionState';
import errorHandler from '../utils/errorHandler';
import WhyNotModal from './WhyNotModal';
import ExtensionItem from './ExtensionItem';
import { AsyncBackendResponse } from '../shims';
import jumpToQueue from '../utils/jumpToQueue';
interface MajorUpdaterAttrs extends ComponentAttrs {
export interface MajorUpdaterAttrs extends ComponentAttrs {
coreUpdate: UpdatedPackage;
updateState: UpdateState;
}
export type MajorUpdaterLoadingTypes = 'major-update' | 'major-update-dry-run';
export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttrs> extends Component<T> {
isLoading: string | null = null;
updateState!: UpdateState;
oninit(vnode: Mithril.Vnode<T, this>) {
@@ -29,7 +30,7 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
this.updateState = this.attrs.updateState;
}
view(vnode: Mithril.Vnode<T, this>): Mithril.Children {
view(): Mithril.Children {
// @todo move Form-group--danger class to core for reuse
return (
<div className="Form-group Form-group--danger PackageManager-majorUpdate">
@@ -38,11 +39,21 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
<p className="helpText">{app.translator.trans('flarum-package-manager.admin.major_updater.description')}</p>
<div className="PackageManager-updaterControls">
<Tooltip text={app.translator.trans('flarum-package-manager.admin.major_updater.dry_run_help')}>
<Button className="Button" icon="fas fa-vial" onclick={this.update.bind(this, true)}>
<Button
className="Button"
icon="fas fa-vial"
onclick={this.update.bind(this, true)}
disabled={app.packageManager.control.isLoadingOtherThan('major-update-dry-run')}
>
{app.translator.trans('flarum-package-manager.admin.major_updater.dry_run')}
</Button>
</Tooltip>
<Button className="Button Button--danger" icon="fas fa-play" onclick={this.update.bind(this, false)}>
<Button
className="Button Button--danger"
icon="fas fa-play"
onclick={this.update.bind(this, false)}
disabled={app.packageManager.control.isLoadingOtherThan('major-update')}
>
{app.translator.trans('flarum-package-manager.admin.major_updater.update')}
</Button>
</div>
@@ -83,7 +94,7 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
}
update(dryRun: boolean) {
this.isLoading = `update-${dryRun ? 'dry-run' : 'run'}`;
app.packageManager.control.setLoading(dryRun ? 'major-update-dry-run' : 'major-update');
app.modal.show(LoadingModal);
app
@@ -93,7 +104,6 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
body: {
data: { dryRun },
},
errorHandler,
})
.then((response) => {
if (response?.processing) {
@@ -103,13 +113,14 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
window.location.reload();
}
})
.catch(errorHandler)
.catch((e: RequestError) => {
app.modal.close();
this.updateState.status = 'failure';
this.updateState.incompatibleExtensions = e.response?.errors?.pop()?.incompatible_extensions as string[];
})
.finally(() => {
this.isLoading = null;
app.packageManager.control.setLoading(null);
m.redraw();
});
}

View File

@@ -24,7 +24,7 @@ export default class QueueSection extends Component<{}> {
oninit(vnode: Mithril.Vnode<{}, this>) {
super.oninit(vnode);
app.packageManagerQueue.load();
app.packageManager.queue.load();
}
view() {
@@ -36,7 +36,7 @@ export default class QueueSection extends Component<{}> {
<Button
className="Button Button--icon"
icon="fas fa-sync-alt"
onclick={() => app.packageManagerQueue.load()}
onclick={() => app.packageManager.queue.load()}
aria-label={app.translator.trans('flarum-package-manager.admin.sections.queue.refresh')}
/>
</div>
@@ -154,7 +154,7 @@ export default class QueueSection extends Component<{}> {
}
queueTable() {
const tasks = app.packageManagerQueue.getItems();
const tasks = app.packageManager.queue.getItems();
if (!tasks) {
return <LoadingIndicator />;
@@ -193,7 +193,7 @@ export default class QueueSection extends Component<{}> {
</tbody>
</table>
<Pagination list={app.packageManagerQueue} />
<Pagination list={app.packageManager.queue} />
</>
);
}

View File

@@ -8,16 +8,16 @@ import ControlSection from './ControlSection';
export default class SettingsPage extends ExtensionPage {
sections(vnode: Mithril.VnodeDOM<ExtensionPageAttrs, this>): ItemList<unknown> {
// @todo add core feature to register sections
const items = super.sections(vnode);
if (app.data.settings['flarum-package-manager.queue_jobs']) {
items.add('queue', <QueueSection />, 5);
}
items.setPriority('content', 10);
items.add('control', <ControlSection />, 8);
items.setPriority('content', 10);
if (parseInt(app.data.settings['flarum-package-manager.queue_jobs'])) {
items.add('queue', <QueueSection />, 5);
}
items.setPriority('permissions', 0);
return items;

View File

@@ -1,278 +1,126 @@
import Mithril from 'mithril';
import app from 'flarum/admin/app';
import Component, { ComponentAttrs } from 'flarum/common/Component';
import Button from 'flarum/common/components/Button';
import humanTime from 'flarum/common/helpers/humanTime';
import LoadingModal from 'flarum/admin/components/LoadingModal';
import errorHandler from '../utils/errorHandler';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import MajorUpdater from './MajorUpdater';
import ExtensionItem from './ExtensionItem';
import extractText from 'flarum/common/utils/extractText';
import jumpToQueue from '../utils/jumpToQueue';
import { AsyncBackendResponse } from '../shims';
import { Extension } from 'flarum/admin/AdminApplication';
import Alert from 'flarum/common/components/Alert';
import ItemList from '@flarum/core/src/common/utils/ItemList';
export type UpdatedPackage = {
name: string;
version: string;
latest: string;
'latest-minor': string | null;
'latest-major': string | null;
'latest-status': string;
description: string;
};
export interface IUpdaterAttrs extends ComponentAttrs {}
export type ComposerUpdates = {
installed: UpdatedPackage[];
};
export type LastUpdateCheck = {
checkedAt: Date | null;
updates: ComposerUpdates;
};
type UpdateType = 'major' | 'minor' | 'global';
type UpdateStatus = 'success' | 'failure' | null;
export type UpdateState = {
ranAt: Date | null;
status: UpdateStatus;
limitedPackages: string[];
incompatibleExtensions: string[];
};
export type LastUpdateRun = {
[key in UpdateType]: UpdateState;
} & {
limitedPackages: () => string[];
};
interface UpdaterAttrs extends ComponentAttrs {}
export default class Updater extends Component<UpdaterAttrs> {
isLoading: string | null = null;
packageUpdates: Record<string, UpdatedPackage> = {};
lastUpdateCheck: LastUpdateCheck = JSON.parse(app.data.settings['flarum-package-manager.last_update_check']) as LastUpdateCheck;
get lastUpdateRun(): LastUpdateRun {
const lastUpdateRun = JSON.parse(app.data.settings['flarum-package-manager.last_update_run']) as LastUpdateRun;
lastUpdateRun.limitedPackages = () => [
...lastUpdateRun.major.limitedPackages,
...lastUpdateRun.minor.limitedPackages,
...lastUpdateRun.global.limitedPackages,
];
return lastUpdateRun;
}
oninit(vnode: Mithril.Vnode<UpdaterAttrs, this>) {
super.oninit(vnode);
}
export type UpdaterLoadingTypes = 'check' | 'minor-update' | 'global-update' | 'extension-update' | null;
export default class Updater extends Component<IUpdaterAttrs> {
view() {
const extensions = this.getExtensionUpdates();
let coreUpdate: UpdatedPackage | undefined = this.getCoreUpdate();
let core: any;
if (coreUpdate) {
core = {
id: 'flarum-core',
name: 'flarum/core',
version: app.data.settings.version,
icon: {
backgroundImage: `url(${app.forum.attribute('baseUrl')}/assets/extensions/flarum-package-manager/flarum.svg`,
},
extra: {
'flarum-extension': {
title: app.translator.trans('flarum-package-manager.admin.updater.flarum'),
},
},
};
}
const core = app.packageManager.control.coreUpdate;
return [
<div className="Form-group">
<label>{app.translator.trans('flarum-package-manager.admin.updater.updater_title')}</label>
<p className="helpText">{app.translator.trans('flarum-package-manager.admin.updater.updater_help')}</p>
{this.lastUpdateCheck?.checkedAt && (
<p className="PackageManager-lastUpdatedAt">
<span className="PackageManager-lastUpdatedAt-label">
{app.translator.trans('flarum-package-manager.admin.updater.last_update_checked_at')}
</span>
<span className="PackageManager-lastUpdatedAt-value">{humanTime(this.lastUpdateCheck.checkedAt)}</span>
</p>
)}
<div className="PackageManager-updaterControls">
<Button
className="Button"
icon="fas fa-sync-alt"
onclick={this.checkForUpdates.bind(this)}
loading={this.isLoading === 'check'}
disabled={this.isLoading !== null && this.isLoading !== 'check'}
>
{app.translator.trans('flarum-package-manager.admin.updater.check_for_updates')}
</Button>
<Button
className="Button"
icon="fas fa-play"
onclick={this.updateGlobally.bind(this)}
loading={this.isLoading === 'global-update'}
disabled={this.isLoading !== null && this.isLoading !== 'global-update'}
>
{app.translator.trans('flarum-package-manager.admin.updater.run_global_update')}
</Button>
</div>
{this.isLoading !== null ? (
<div className="PackageManager-extensions">
<LoadingIndicator />
</div>
) : extensions.length || core ? (
<div className="PackageManager-extensions">
<div className="PackageManager-extensions-grid">
{core ? (
<ExtensionItem
extension={core}
updates={coreUpdate}
isCore={true}
onClickUpdate={this.updateCoreMinor.bind(this)}
whyNotWarning={this.lastUpdateRun.limitedPackages().includes('flarum/core')}
/>
) : null}
{extensions.map((extension: Extension) => (
<ExtensionItem
extension={extension}
updates={this.packageUpdates[extension.id]}
onClickUpdate={this.updateExtension.bind(this, extension)}
whyNotWarning={this.lastUpdateRun.limitedPackages().includes(extension.name)}
/>
))}
</div>
</div>
) : null}
{this.lastUpdateCheckView()}
<div className="PackageManager-updaterControls">{this.controlItems().toArray()}</div>
{this.availableUpdatesView()}
</div>,
coreUpdate && coreUpdate['latest-major'] ? <MajorUpdater coreUpdate={coreUpdate} updateState={this.lastUpdateRun.major} /> : null,
core && core.package['latest-major'] ? (
<MajorUpdater coreUpdate={core.package} updateState={app.packageManager.control.lastUpdateRun.major} />
) : null,
];
}
getExtensionUpdates(): Extension[] {
this.lastUpdateCheck?.updates?.installed?.filter((composerPackage: UpdatedPackage) => {
const id = composerPackage.name.replace('/', '-').replace(/(flarum-ext-)|(flarum-)/, '');
const extension = app.data.extensions[id];
const safeToUpdate = ['semver-safe-update', 'update-possible'].includes(composerPackage['latest-status']);
if (extension && safeToUpdate) {
this.packageUpdates[extension.id] = composerPackage;
}
return extension && safeToUpdate;
});
return (Object.values(app.data.extensions) as Extension[]).filter((extension: Extension) => this.packageUpdates[extension.id]);
lastUpdateCheckView() {
return (
(app.packageManager.control.lastUpdateCheck?.checkedAt && (
<p className="PackageManager-lastUpdatedAt">
<span className="PackageManager-lastUpdatedAt-label">
{app.translator.trans('flarum-package-manager.admin.updater.last_update_checked_at')}
</span>
<span className="PackageManager-lastUpdatedAt-value">{humanTime(app.packageManager.control.lastUpdateCheck.checkedAt)}</span>
</p>
)) ||
null
);
}
getCoreUpdate(): UpdatedPackage | undefined {
return this.lastUpdateCheck?.updates?.installed?.filter((composerPackage: UpdatedPackage) => composerPackage.name === 'flarum/core').pop();
}
availableUpdatesView() {
const state = app.packageManager.control;
checkForUpdates() {
this.isLoading = 'check';
app
.request<AsyncBackendResponse | LastUpdateCheck>({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/check-for-updates`,
errorHandler,
})
.then((response) => {
if ((response as AsyncBackendResponse).processing) {
jumpToQueue();
} else {
this.lastUpdateCheck = response as LastUpdateCheck;
}
})
.finally(() => {
this.isLoading = null;
m.redraw();
});
}
updateCoreMinor() {
if (confirm(extractText(app.translator.trans('flarum-package-manager.admin.minor_update_confirmation.content')))) {
app.modal.show(LoadingModal);
this.isLoading = 'minor-update';
app
.request<AsyncBackendResponse | null>({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/minor-update`,
errorHandler,
})
.then((response) => {
if (response?.processing) {
jumpToQueue();
} else {
app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.update_successful'));
window.location.reload();
}
})
.finally(() => {
this.isLoading = null;
m.redraw();
});
if (app.packageManager.control.isLoading()) {
return (
<div className="PackageManager-extensions">
<LoadingIndicator />
</div>
);
}
if (!(state.extensionUpdates.length || state.coreUpdate)) {
return (
<div className="PackageManager-extensions">
<Alert type="success" dismissible={false}>
{app.translator.trans('flarum-package-manager.admin.updater.up_to_date')}
</Alert>
</div>
);
}
return (
<div className="PackageManager-extensions">
<div className="PackageManager-extensions-grid">
{state.coreUpdate ? (
<ExtensionItem
extension={state.coreUpdate.extension}
updates={state.coreUpdate.package}
isCore={true}
onClickUpdate={() => state.updateCoreMinor()}
whyNotWarning={state.lastUpdateRun.limitedPackages().includes('flarum/core')}
/>
) : null}
{state.extensionUpdates.map((extension: Extension) => (
<ExtensionItem
extension={extension}
updates={state.packageUpdates[extension.id]}
onClickUpdate={() => state.updateExtension(extension)}
whyNotWarning={state.lastUpdateRun.limitedPackages().includes(extension.name)}
/>
))}
</div>
</div>
);
}
updateExtension(extension: any) {
app.modal.show(LoadingModal);
this.isLoading = 'extension-update';
controlItems() {
const items = new ItemList();
app
.request<AsyncBackendResponse | null>({
method: 'PATCH',
url: `${app.forum.attribute('apiUrl')}/package-manager/extensions/${extension.id}`,
errorHandler,
})
.then((response) => {
if (response?.processing) {
jumpToQueue();
} else {
app.alerts.show(
{ type: 'success' },
app.translator.trans('flarum-package-manager.admin.extensions.successful_update', {
extension: extension.extra['flarum-extension'].title,
})
);
window.location.reload();
}
})
.finally(() => {
this.isLoading = null;
m.redraw();
});
}
items.add(
'updateCheck',
<Button
className="Button"
icon="fas fa-sync-alt"
onclick={() => app.packageManager.control.checkForUpdates()}
loading={app.packageManager.control.isLoading('check')}
disabled={app.packageManager.control.isLoadingOtherThan('check')}
>
{app.translator.trans('flarum-package-manager.admin.updater.check_for_updates')}
</Button>,
100
);
updateGlobally() {
app.modal.show(LoadingModal);
this.isLoading = 'global-update';
items.add(
'globalUpdate',
<Button
className="Button"
icon="fas fa-play"
onclick={() => app.packageManager.control.updateGlobally()}
loading={app.packageManager.control.isLoading('global-update')}
disabled={app.packageManager.control.isLoadingOtherThan('global-update')}
>
{app.translator.trans('flarum-package-manager.admin.updater.run_global_update')}
</Button>
);
app
.request<AsyncBackendResponse | null>({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/global-update`,
errorHandler,
})
.then((response) => {
if (response?.processing) {
jumpToQueue();
} else {
app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.updater.global_update_successful'));
window.location.reload();
}
})
.finally(() => {
this.isLoading = null;
m.redraw();
});
return items;
}
}

View File

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

View File

@@ -4,21 +4,29 @@ 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 QueueState from './states/QueueState';
import extractText from 'flarum/common/utils/extractText';
import { AsyncBackendResponse } from './shims';
import PackageManagerState from './states/PackageManagerState';
app.initializers.add('flarum-package-manager', (app) => {
app.store.models['package-manager-tasks'] = Task;
app.packageManagerQueue = new QueueState();
app.packageManager = new PackageManagerState();
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'),

View File

@@ -1,4 +1,4 @@
import QueueState from './states/QueueState';
import PackageManagerState from './states/PackageManagerState';
export interface AsyncBackendResponse {
processing: boolean;
@@ -6,6 +6,6 @@ export interface AsyncBackendResponse {
declare module 'flarum/admin/AdminApplication' {
export default interface AdminApplication {
packageManagerQueue: QueueState;
packageManager: PackageManagerState;
}
}

View File

@@ -0,0 +1,239 @@
import app from 'flarum/admin/app';
import LoadingModal from 'flarum/admin/components/LoadingModal';
import { UpdaterLoadingTypes } from '../components/Updater';
import { InstallerLoadingTypes } from '../components/Installer';
import { MajorUpdaterLoadingTypes } from '../components/MajorUpdater';
import { AsyncBackendResponse } from '../shims';
import errorHandler from '../utils/errorHandler';
import jumpToQueue from '../utils/jumpToQueue';
import { Extension } from 'flarum/admin/AdminApplication';
import extractText from 'flarum/common/utils/extractText';
export type UpdatedPackage = {
name: string;
version: string;
latest: string;
'latest-minor': string | null;
'latest-major': string | null;
'latest-status': string;
description: string;
};
export type ComposerUpdates = {
installed: UpdatedPackage[];
};
export type LastUpdateCheck = {
checkedAt: Date | null;
updates: ComposerUpdates;
};
type UpdateType = 'major' | 'minor' | 'global';
type UpdateStatus = 'success' | 'failure' | null;
export type UpdateState = {
ranAt: Date | null;
status: UpdateStatus;
limitedPackages: string[];
incompatibleExtensions: string[];
};
export type LastUpdateRun = {
[key in UpdateType]: UpdateState;
} & {
limitedPackages: () => string[];
};
export type LoadingTypes = UpdaterLoadingTypes | InstallerLoadingTypes | MajorUpdaterLoadingTypes;
export type CoreUpdate = {
package: UpdatedPackage;
extension: Extension;
};
export default class ControlSectionState {
loading: LoadingTypes = null;
public packageUpdates: Record<string, UpdatedPackage> = {};
public lastUpdateCheck!: LastUpdateCheck;
public extensionUpdates!: Extension[];
public coreUpdate: CoreUpdate | null = null;
get lastUpdateRun(): LastUpdateRun {
const lastUpdateRun = JSON.parse(app.data.settings['flarum-package-manager.last_update_run']) as LastUpdateRun;
lastUpdateRun.limitedPackages = () => [
...lastUpdateRun.major.limitedPackages,
...lastUpdateRun.minor.limitedPackages,
...lastUpdateRun.global.limitedPackages,
];
return lastUpdateRun;
}
constructor() {
this.lastUpdateCheck = JSON.parse(app.data.settings['flarum-package-manager.last_update_check']) as LastUpdateCheck;
this.extensionUpdates = this.formatExtensionUpdates(this.lastUpdateCheck);
this.coreUpdate = this.formatCoreUpdate(this.lastUpdateCheck);
}
isLoading(name: LoadingTypes = null): boolean {
return (name && this.loading === name) || (!name && this.loading !== null);
}
isLoadingOtherThan(name: LoadingTypes): boolean {
return this.loading !== null && this.loading !== name;
}
setLoading(name: LoadingTypes): void {
this.loading = name;
}
checkForUpdates() {
this.setLoading('check');
app
.request<AsyncBackendResponse | LastUpdateCheck>({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/check-for-updates`,
})
.then((response) => {
if ((response as AsyncBackendResponse).processing) {
jumpToQueue();
} else {
this.lastUpdateCheck = response as LastUpdateCheck;
this.extensionUpdates = this.formatExtensionUpdates(response as LastUpdateCheck);
this.coreUpdate = this.formatCoreUpdate(response as LastUpdateCheck);
m.redraw();
}
})
.catch(errorHandler)
.finally(() => {
this.setLoading(null);
m.redraw();
});
}
updateCoreMinor() {
if (confirm(extractText(app.translator.trans('flarum-package-manager.admin.minor_update_confirmation.content')))) {
app.modal.show(LoadingModal);
this.setLoading('minor-update');
app
.request<AsyncBackendResponse | null>({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/minor-update`,
})
.then((response) => {
if (response?.processing) {
jumpToQueue();
} else {
app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.update_successful'));
window.location.reload();
}
})
.catch(errorHandler)
.finally(() => {
this.setLoading(null);
app.modal.close();
m.redraw();
});
}
}
updateExtension(extension: Extension) {
app.modal.show(LoadingModal);
this.setLoading('extension-update');
app
.request<AsyncBackendResponse | null>({
method: 'PATCH',
url: `${app.forum.attribute('apiUrl')}/package-manager/extensions/${extension.id}`,
})
.then((response) => {
if (response?.processing) {
jumpToQueue();
} else {
app.alerts.show(
{ type: 'success' },
app.translator.trans('flarum-package-manager.admin.extensions.successful_update', {
extension: extension.extra['flarum-extension'].title,
})
);
window.location.reload();
}
})
.catch(errorHandler)
.finally(() => {
this.setLoading(null);
app.modal.close();
m.redraw();
});
}
updateGlobally() {
app.modal.show(LoadingModal);
this.setLoading('global-update');
app
.request<AsyncBackendResponse | null>({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/global-update`,
})
.then((response) => {
if (response?.processing) {
jumpToQueue();
} else {
app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.updater.global_update_successful'));
window.location.reload();
}
})
.catch(errorHandler)
.finally(() => {
this.setLoading(null);
app.modal.close();
m.redraw();
});
}
formatExtensionUpdates(lastUpdateCheck: LastUpdateCheck): Extension[] {
this.packageUpdates = {};
lastUpdateCheck?.updates?.installed?.filter((composerPackage: UpdatedPackage) => {
const id = composerPackage.name.replace('/', '-').replace(/(flarum-ext-)|(flarum-)/, '');
const extension = app.data.extensions[id];
const safeToUpdate = ['semver-safe-update', 'update-possible'].includes(composerPackage['latest-status']);
if (extension && safeToUpdate) {
this.packageUpdates[extension.id] = composerPackage;
}
return extension && safeToUpdate;
});
return (Object.values(app.data.extensions) as Extension[]).filter((extension: Extension) => this.packageUpdates[extension.id]);
}
formatCoreUpdate(lastUpdateCheck: LastUpdateCheck): CoreUpdate | null {
const core = lastUpdateCheck?.updates?.installed?.filter((composerPackage: UpdatedPackage) => composerPackage.name === 'flarum/core').pop();
if (!core) return null;
return {
package: core,
extension: {
id: 'flarum-core',
name: 'flarum/core',
version: app.data.settings.version,
icon: {
// @ts-ignore
backgroundImage: `url(${app.forum.attribute('baseUrl')}/assets/extensions/flarum-package-manager/flarum.svg`,
},
extra: {
'flarum-extension': {
title: extractText(app.translator.trans('flarum-package-manager.admin.updater.flarum')),
},
},
},
};
}
}

View File

@@ -0,0 +1,7 @@
import QueueState from './QueueState';
import ControlSectionState from './ControlSectionState';
export default class PackageManagerState {
public queue: QueueState = new QueueState();
public control: ControlSectionState = new ControlSectionState();
}

View File

@@ -4,7 +4,7 @@ import { ApiQueryParamsPlural } from 'flarum/common/Store';
export default class QueueState {
private tasks: Task[] | null = null;
private limit = 5;
private limit = 20;
private offset = 0;
private total = 0;

View File

@@ -6,7 +6,7 @@ window.jumpToQueue = jumpToQueue;
export default function jumpToQueue(): void {
app.modal.close();
m.route.set(app.route('extension', { id: 'flarum-package-manager' }));
app.packageManagerQueue.load();
app.packageManager.queue.load();
setTimeout(() => {
document.getElementById('PackageManager-queueSection')?.scrollIntoView({ block: 'nearest' });
}, 200);

View File

@@ -11,6 +11,7 @@
flex-wrap: wrap;
gap: 8px;
grid-area: controls;
margin-bottom: 16px;
}
.PackageManager-extensions {
@@ -19,7 +20,6 @@
display: grid;
grid-template-columns: repeat(auto-fit, calc(~"100% / 3 - var(--gap)"));
gap: var(--gap);
margin-top: 16px;
}
}

View File

@@ -22,7 +22,7 @@ flarum-package-manager:
update: Update
file_permissions: >
The package manager requires read and write permissions on the following files and directories: composer.json, composer.lock, vendor, storage/.composer
The package manager requires read and write permissions on the following files and directories: composer.json, composer.lock, vendor, storage, storage/.composer
major_updater:
description: Major Flarum updates are not backwards compatible, meaning that some of your currently installed extensions, and manually made modifications might not work with this new version.
@@ -72,12 +72,14 @@ 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.
Make sure the PHP version used for the queue is {php_version}. Make sure <a href='{folder_perms_link}'>folder permissions</a> are correctly configured.
updater:
up_to_date: Everything is up to date!
check_for_updates: Check for updates
flarum: Flarum Core
global_update_successful: Successfully updated all packages.

View File

@@ -10,6 +10,8 @@
namespace Flarum\PackageManager\Command;
use Flarum\Extension\ExtensionManager;
use Flarum\Foundation\Paths;
use Flarum\Foundation\ValidationException;
use Flarum\PackageManager\Composer\ComposerAdapter;
use Flarum\PackageManager\Exception\ComposerUpdateFailedException;
use Flarum\PackageManager\Exception\ExtensionNotInstalledException;
@@ -46,18 +48,25 @@ class UpdateExtensionHandler
*/
protected $events;
/**
* @var Paths
*/
protected $paths;
public function __construct(
ComposerAdapter $composer,
ExtensionManager $extensions,
UpdateExtensionValidator $validator,
LastUpdateCheck $lastUpdateCheck,
Dispatcher $events
Dispatcher $events,
Paths $paths
) {
$this->composer = $composer;
$this->extensions = $extensions;
$this->validator = $validator;
$this->lastUpdateCheck = $lastUpdateCheck;
$this->events = $events;
$this->paths = $paths;
}
/**
@@ -76,6 +85,17 @@ class UpdateExtensionHandler
throw new ExtensionNotInstalledException($command->extensionId);
}
$rootComposer = json_decode(file_get_contents("{$this->paths->base}/composer.json"), true);
// If this was installed as a requirement for another extension,
// don't update it directly.
// @TODO communicate this in the UI.
if (! isset($rootComposer['require'][$extension->name]) && ! empty($extension->getExtensionDependencyIds())) {
throw new ValidationException([
'message' => "Cannot update $extension->name. It was installed as a requirement for other extensions: ".implode(', ', $extension->getExtensionDependencyIds()).'. Update those extensions instead.'
]);
}
$output = $this->composer->run(
new StringInput("require $extension->name:*"),
$command->task ?? null

View File

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

View File

@@ -11,7 +11,9 @@ 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;
class ComposerCommandJob extends AbstractJob
@@ -22,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;
@@ -35,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();
@@ -62,4 +59,11 @@ class ComposerCommandJob extends AbstractJob
$this->fail($exception);
}
public function middleware()
{
return [
new WithoutOverlapping(),
];
}
}

View File

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

View File

@@ -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
View File

@@ -0,0 +1 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */

View File

@@ -1,5 +1,13 @@
# Changelog
## [1.5.1](https://github.com/flarum/statistics/compare/v1.5.0...v1.5.1)
### 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)
### Changed

View File

@@ -19,7 +19,7 @@
}
],
"require": {
"flarum/core": "^1.4"
"flarum/core": "^1.6"
},
"autoload": {
"psr-4": {

View File

@@ -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,17 +10,22 @@ export default class StatisticsWidget extends DashboardWidget {
entities: string[];
periods: undefined | Record<string, IPeriodDeclaration>;
chart: any;
timedData: any;
customPeriod: IDateSelection | null;
timedData: Record<string, undefined | any>;
lifetimeData: any;
customPeriodData: Record<string, undefined | any>;
noData: boolean;
loadingLifetime: boolean;
loadingTimed: boolean;
loadingTimed: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>;
loadingCustom: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>;
selectedEntity: string;
selectedPeriod: undefined | string;
chartEntity?: string;
chartPeriod?: string;
oncreate(vnode: Mithril.VnodeDOM<IDashboardWidgetAttrs, this>): void;
loadLifetimeData(): Promise<void>;
loadTimedData(): Promise<void>;
loadTimedData(model: string): Promise<void>;
loadCustomRangeData(model: string): Promise<void>;
className(): string;
content(): JSX.Element;
drawChart(vnode: Mithril.VnodeDOM<any, any>): void;

View 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 {};

File diff suppressed because one or more lines are too long

1
extensions/statistics/js/dist/admin.js.LICENSE.txt generated vendored Normal file
View File

@@ -0,0 +1 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */

File diff suppressed because one or more lines are too long

View File

@@ -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",

View File

@@ -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,11 +35,23 @@ export default class StatisticsWidget extends DashboardWidget {
chart: any;
timedData: any;
customPeriod: IDateSelection | null = null;
timedData: Record<string, undefined | any> = {};
lifetimeData: any;
customPeriodData: Record<string, undefined | any> = {};
noData: boolean = false;
loadingLifetime = true;
loadingTimed = 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;
@@ -41,7 +63,6 @@ export default class StatisticsWidget extends DashboardWidget {
super.oncreate(vnode);
this.loadLifetimeData();
this.loadTimedData();
}
async loadLifetimeData() {
@@ -62,49 +83,116 @@ export default class StatisticsWidget extends DashboardWidget {
m.redraw();
}
async loadTimedData() {
this.loadingTimed = true;
async loadTimedData(model: string) {
this.loadingTimed[model] = 'loading';
m.redraw();
const data = await app.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/statistics',
});
try {
const data = await app.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/statistics',
params: {
period: 'timed',
model,
},
});
this.timedData = data;
this.loadingTimed = false;
this.timedData[model] = data;
this.loadingTimed[model] = 'loaded';
// Create a Date object which represents the start of the day in the
// configured timezone. To do this we convert a UTC time into that timezone,
// reset to the first hour of the day, and then convert back into UTC time.
// We'll be working with seconds rather than milliseconds throughout too.
let todayDate = new Date();
todayDate.setTime(todayDate.getTime() + this.timedData.timezoneOffset * 1000);
todayDate.setUTCHours(0, 0, 0, 0);
todayDate.setTime(todayDate.getTime() - this.timedData.timezoneOffset * 1000);
// Create a Date object which represents the start of the day.
let todayDate = new Date();
todayDate.setUTCHours(0, 0, 0, 0);
const today = todayDate.getTime() / 1000;
const today = todayDate.getTime() / 1000;
this.periods = {
today: { start: today, end: today + 86400, step: 3600 },
last_7_days: { start: today - 86400 * 7, end: today, step: 86400 },
previous_7_days: { start: today - 86400 * 14, end: today - 86400 * 7, step: 86400 },
last_28_days: { start: today - 86400 * 28, end: today, step: 86400 },
previous_28_days: { start: today - 86400 * 28 * 2, end: today - 86400 * 28, step: 86400 },
last_12_months: { start: today - 86400 * 364, end: today, step: 86400 * 7 },
};
this.periods = {
today: { start: today, end: today + 86400, step: 3600 },
last_7_days: { start: today - 86400 * 7, end: today, step: 86400 },
previous_7_days: { start: today - 86400 * 14, end: today - 86400 * 7, step: 86400 },
last_28_days: { start: today - 86400 * 28, end: today, step: 86400 },
previous_28_days: { start: today - 86400 * 28 * 2, end: today - 86400 * 28, step: 86400 },
last_12_months: { start: today - 86400 * 364, end: today, step: 86400 * 7 },
};
this.selectedPeriod = 'last_7_days';
this.selectedPeriod = 'last_7_days';
} catch (e) {
console.error(e);
this.loadingTimed[model] = 'fail';
}
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 thisPeriod = this.loadingTimed ? null : this.periods![this.selectedPeriod!];
const loadingSelectedEntity = (this.selectedPeriod === 'custom' ? this.loadingCustom : this.loadingTimed)[this.selectedEntity] !== 'loaded';
const thisPeriod = loadingSelectedEntity
? null
: this.selectedPeriod === 'custom'
? {
start: this.customPeriod?.end!,
end: this.customPeriod?.end!,
step: 86400,
}
: this.periods![this.selectedPeriod!];
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 (
<div className="StatisticsWidget-table">
@@ -112,20 +200,60 @@ export default class StatisticsWidget extends DashboardWidget {
<div className="StatisticsWidget-labels">
<div className="StatisticsWidget-label">{app.translator.trans('flarum-statistics.admin.statistics.total_label')}</div>
<div className="StatisticsWidget-label">
{this.loadingTimed ? (
{loadingSelectedEntity ? (
<LoadingIndicator size="small" display="inline" />
) : (
<SelectDropdown disabled={this.loadingTimed} 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>
))}
<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>
))
.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>
@@ -133,14 +261,17 @@ export default class StatisticsWidget extends DashboardWidget {
{this.entities.map((entity) => {
const totalCount = this.loadingLifetime ? app.translator.trans('flarum-statistics.admin.statistics.loading') : this.getTotalCount(entity);
const thisPeriodCount = this.loadingTimed
const thisPeriodCount = loadingSelectedEntity
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, thisPeriod!);
const lastPeriodCount = this.loadingTimed
? 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 =
this.loadingTimed || lastPeriodCount === 0
loadingSelectedEntity || lastPeriodCount === 0 || lastPeriodCount === null
? 0
: (((thisPeriodCount as number) - (lastPeriodCount as number)) / (lastPeriodCount as number)) * 100;
@@ -154,7 +285,7 @@ export default class StatisticsWidget extends DashboardWidget {
{this.loadingLifetime ? <LoadingIndicator display="inline" /> : abbreviateNumber(totalCount as number)}
</div>
<div className="StatisticsWidget-period" title={thisPeriodCount}>
{this.loadingTimed ? <LoadingIndicator display="inline" /> : abbreviateNumber(thisPeriodCount as number)}
{loadingSelectedEntity ? <LoadingIndicator display="inline" /> : abbreviateNumber(thisPeriodCount as number)}
{periodChange !== 0 && (
<>
{' '}
@@ -170,12 +301,34 @@ export default class StatisticsWidget extends DashboardWidget {
})}
</div>
{this.loadingTimed ? (
<div className="StatisticsWidget-chart">
<LoadingIndicator size="large" />
</div>
) : (
<div className="StatisticsWidget-chart" oncreate={this.drawChart.bind(this)} onupdate={this.drawChart.bind(this)} />
<>
{loadingSelectedEntity ? (
<div key="loading" className="StatisticsWidget-chart" data-loading="true">
<LoadingIndicator size="large" />
</div>
) : (
<div
key="loaded"
className="StatisticsWidget-chart"
data-loading="false"
oncreate={this.drawChart.bind(this)}
onupdate={this.drawChart.bind(this)}
/>
)}
</>
{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>
);
@@ -186,10 +339,16 @@ export default class StatisticsWidget extends DashboardWidget {
return;
}
const offset = this.timedData.timezoneOffset;
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 = [];
@@ -197,29 +356,53 @@ export default class StatisticsWidget extends DashboardWidget {
let label;
if (period.step < 86400) {
label = dayjs.unix(i + offset).format('h A');
label = dayjs.unix(i).utc().format('h A');
} else {
label = dayjs.unix(i + offset).format('D MMM');
label = dayjs.unix(i).utc().format('D MMM');
if (period.step > 86400) {
label += ' - ' + dayjs.unix(i + offset + 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,
};
if (!this.chart) {
// If the dom element no longer exists, recreate the chart
// https://stackoverflow.com/a/2620373/11091039
if (!this.chart || !(document.compareDocumentPosition(this.chart.parent) & 16)) {
this.chart = new Chart(vnode.dom, {
data,
type: 'line',
@@ -231,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);
@@ -255,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) {

View File

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

View File

@@ -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 {

View File

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

Some files were not shown because too many files have changed in this diff Show More