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

Compare commits

..

93 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
341 changed files with 4177 additions and 11325 deletions

View File

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

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
@@ -20,6 +26,13 @@ on:
type: string
required: false
default: '["7.3", "7.4", "8.0", "8.1"]'
php_extensions:
description: PHP extensions to install.
type: string
required: false
default: 'curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip'
db_versions:
description: Versions of databases to test with. Should be array of strings encoded as JSON array
type: string
@@ -100,7 +113,7 @@ jobs:
with:
php-version: ${{ matrix.php }}
coverage: xdebug
extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
extensions: ${{ inputs.php_extensions }}
tools: phpunit, composer:v2
ini-values: ${{ inputs.php_ini_values }}
@@ -130,3 +143,35 @@ jobs:
working-directory: ${{ inputs.backend_directory }}
env:
COMPOSER_PROCESS_TIMEOUT: 600
phpstan:
runs-on: ubuntu-latest
strategy:
matrix:
php: ${{ fromJSON(inputs.php_versions) }}
name: 'PHPStan PHP ${{ matrix.php }}'
if: >-
inputs.enable_phpstan &&
((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || github.event_name != 'pull_request')
steps:
- uses: actions/checkout@master
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: xdebug
extensions: ${{ inputs.php_extensions }}
tools: phpunit, composer:v2
ini-values: ${{ inputs.php_ini_values }}
- name: Install Composer dependencies
run: composer install
working-directory: ${{ inputs.backend_directory }}
- name: Run PHPStan
run: composer analyse:phpstan

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

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

@@ -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)
@@ -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>'],
@@ -49,6 +50,9 @@ class PostMentionsTest extends TestCase
['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="Bad &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],
@@ -128,6 +132,37 @@ class PostMentionsTest extends TestCase
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(4));
}
/**
* @test
*/
public function cannot_mention_a_post_without_access()
{
$response = $this->send(
$this->request('POST', '/api/posts', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'attributes' => [
'content' => '@"potato"#p50',
],
'relationships' => [
'discussion' => ['data' => ['id' => 2]],
],
],
],
])
);
$this->assertEquals(201, $response->getStatusCode());
$response = json_decode($response->getBody(), true);
$this->assertStringContainsString('potato', $response['data']['attributes']['contentHtml']);
$this->assertEquals('@"potato"#p50', $response['data']['attributes']['content']);
$this->assertStringNotContainsString('PostMention', $response['data']['attributes']['contentHtml']);
$this->assertNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(50));
}
/**
* @test
*/

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -64,7 +64,6 @@ export default class Installer extends Component<InstallerAttrs> {
body: {
data: this.data(),
},
errorHandler,
})
.then((response) => {
if (response.processing) {
@@ -79,8 +78,10 @@ export default class Installer extends Component<InstallerAttrs> {
window.location.reload();
}
})
.catch(errorHandler)
.finally(() => {
app.packageManager.control.setLoading(null);
app.modal.close();
m.redraw();
});
}

View File

@@ -104,7 +104,6 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
body: {
data: { dryRun },
},
errorHandler,
})
.then((response) => {
if (response?.processing) {
@@ -114,6 +113,7 @@ export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttr
window.location.reload();
}
})
.catch(errorHandler)
.catch((e: RequestError) => {
app.modal.close();
this.updateState.status = 'failure';

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,8 +4,9 @@ import ExtensionPage from 'flarum/admin/components/ExtensionPage';
import Button from 'flarum/common/components/Button';
import LoadingModal from 'flarum/admin/components/LoadingModal';
import isExtensionEnabled from 'flarum/admin/utils/isExtensionEnabled';
import SettingsPage from './components/SettingsPage';
import Alert from 'flarum/common/components/Alert';
import SettingsPage from './components/SettingsPage';
import Task from './models/Task';
import jumpToQueue from './utils/jumpToQueue';
import extractText from 'flarum/common/utils/extractText';
@@ -19,6 +20,13 @@ app.initializers.add('flarum-package-manager', (app) => {
app.extensionData
.for('flarum-package-manager')
.registerSetting(() => (
<div className="Form-group">
<Alert type="warning" dismissible={false}>
{app.translator.trans('flarum-package-manager.admin.settings.access_warning')}
</Alert>
</div>
))
.registerSetting({
setting: 'flarum-package-manager.queue_jobs',
label: app.translator.trans('flarum-package-manager.admin.settings.queue_jobs'),

View File

@@ -94,7 +94,6 @@ export default class ControlSectionState {
.request<AsyncBackendResponse | LastUpdateCheck>({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/check-for-updates`,
errorHandler,
})
.then((response) => {
if ((response as AsyncBackendResponse).processing) {
@@ -106,6 +105,7 @@ export default class ControlSectionState {
m.redraw();
}
})
.catch(errorHandler)
.finally(() => {
this.setLoading(null);
m.redraw();
@@ -121,7 +121,6 @@ export default class ControlSectionState {
.request<AsyncBackendResponse | null>({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/minor-update`,
errorHandler,
})
.then((response) => {
if (response?.processing) {
@@ -131,6 +130,7 @@ export default class ControlSectionState {
window.location.reload();
}
})
.catch(errorHandler)
.finally(() => {
this.setLoading(null);
app.modal.close();
@@ -147,7 +147,6 @@ export default class ControlSectionState {
.request<AsyncBackendResponse | null>({
method: 'PATCH',
url: `${app.forum.attribute('apiUrl')}/package-manager/extensions/${extension.id}`,
errorHandler,
})
.then((response) => {
if (response?.processing) {
@@ -162,6 +161,7 @@ export default class ControlSectionState {
window.location.reload();
}
})
.catch(errorHandler)
.finally(() => {
this.setLoading(null);
app.modal.close();
@@ -177,7 +177,6 @@ export default class ControlSectionState {
.request<AsyncBackendResponse | null>({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/global-update`,
errorHandler,
})
.then((response) => {
if (response?.processing) {
@@ -187,6 +186,7 @@ export default class ControlSectionState {
window.location.reload();
}
})
.catch(errorHandler)
.finally(() => {
this.setLoading(null);
app.modal.close();

View File

@@ -72,6 +72,7 @@ flarum-package-manager:
title: Queue
settings:
access_warning: Please be careful to who you give access to the admin area, the package manager could be misused by bad actors to install packages that can lead to security breaches.
queue_jobs: Run operations in the background queue
queue_jobs_help: >
You can read about a <a href='{basic_impl_link}'>basic queue</a> implementation or a <a href='{adv_impl_link}'>more advanced</a> one.

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,6 +11,7 @@ namespace Flarum\PackageManager\Job;
use Flarum\Bus\Dispatcher;
use Flarum\PackageManager\Command\BusinessCommandInterface;
use Flarum\PackageManager\Composer\ComposerAdapter;
use Flarum\Queue\AbstractJob;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Throwable;
@@ -23,11 +24,11 @@ class ComposerCommandJob extends AbstractJob
protected $command;
/**
* @var int[]
* @var string
*/
protected $phpVersion;
public function __construct(BusinessCommandInterface $command, array $phpVersion)
public function __construct(BusinessCommandInterface $command, string $phpVersion)
{
$this->command = $command;
$this->phpVersion = $phpVersion;
@@ -36,12 +37,7 @@ class ComposerCommandJob extends AbstractJob
public function handle(Dispatcher $bus)
{
try {
if ([PHP_MAJOR_VERSION, PHP_MINOR_VERSION] !== [$this->phpVersion[0], $this->phpVersion[1]]) {
$webPhpVersion = implode('.', $this->phpVersion);
$sshPhpVersion = implode('.', [PHP_MAJOR_VERSION, PHP_MINOR_VERSION]);
throw new \Exception("PHP version mismatch. SSH PHP version must match web server PHP version. Found SSH (PHP $sshPhpVersion) and Web Server (PHP $webPhpVersion).");
}
ComposerAdapter::setPhpVersion($this->phpVersion);
$this->command->task->start();

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,9 +1,12 @@
# Changelog
## [1.4.2](https://github.com/flarum/statistics/compare/v1.4.1...v1.4.2)
## [1.5.1](https://github.com/flarum/statistics/compare/v1.5.0...v1.5.1)
### Changed
- Split timed data into per-model XHR requests (https://github.com/flarum/framework/pull/3601)
### Added
- Support for custom date ranges (https://github.com/flarum/framework/pull/3622)
### Fixed
- Previous period chart is unclear (https://github.com/flarum/framework/pull/3654)
## [1.4.1](https://github.com/flarum/statistics/compare/v1.4.0...v1.4.1)

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

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,14 +35,23 @@ export default class StatisticsWidget extends DashboardWidget {
chart: any;
customPeriod: IDateSelection | null = null;
timedData: Record<string, undefined | any> = {};
lifetimeData: any;
customPeriodData: Record<string, undefined | any> = {};
noData: boolean = false;
loadingLifetime = true;
loadingTimed: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
acc[curr] = 'unloaded';
return acc;
}, {} as Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>);
loadingCustom: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
acc[curr] = 'unloaded';
return acc;
}, {} as Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>);
selectedEntity = 'users';
selectedPeriod: undefined | string;
@@ -105,17 +124,74 @@ export default class StatisticsWidget extends DashboardWidget {
m.redraw();
}
async loadCustomRangeData(model: string): Promise<void> {
this.loadingCustom[model] = 'loading';
m.redraw();
// We clone so we can check that the same period is still selected
// once the HTTP request is complete and the data is to be displayed
const range = { ...this.customPeriod };
try {
const data = await app.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/statistics',
params: {
period: 'custom',
model,
dateRange: {
start: range.start,
end: range.end,
},
},
});
if (JSON.stringify(range) !== JSON.stringify(this.customPeriod)) {
// The range this method was called with is no longer the selected.
// Bail out here.
return;
}
this.customPeriodData[model] = data;
this.loadingCustom[model] = 'loaded';
m.redraw();
} catch (e) {
if (JSON.stringify(range) !== JSON.stringify(this.customPeriod)) {
// The range this method was called with is no longer the selected.
// Bail out here.
return;
}
console.error(e);
this.loadingCustom[model] = 'fail';
}
}
className() {
return 'StatisticsWidget';
}
content() {
const loadingSelectedEntity = this.loadingTimed[this.selectedEntity] !== 'loaded';
const loadingSelectedEntity = (this.selectedPeriod === 'custom' ? this.loadingCustom : this.loadingTimed)[this.selectedEntity] !== 'loaded';
const thisPeriod = loadingSelectedEntity ? null : this.periods![this.selectedPeriod!];
const thisPeriod = loadingSelectedEntity
? null
: this.selectedPeriod === 'custom'
? {
start: this.customPeriod?.end!,
end: this.customPeriod?.end!,
step: 86400,
}
: this.periods![this.selectedPeriod!];
if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') {
this.loadTimedData(this.selectedEntity);
if (this.selectedPeriod === 'custom') {
if (!this.customPeriodData[this.selectedEntity] && this.loadingCustom[this.selectedEntity] === 'unloaded') {
this.loadCustomRangeData(this.selectedEntity);
}
} else {
if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') {
this.loadTimedData(this.selectedEntity);
}
}
return (
@@ -128,16 +204,56 @@ export default class StatisticsWidget extends DashboardWidget {
<LoadingIndicator size="small" display="inline" />
) : (
<SelectDropdown disabled={loadingSelectedEntity} buttonClassName="Button Button--text" caretIcon="fas fa-caret-down">
{Object.keys(this.periods!).map((period) => (
<Button
key={period}
active={period === this.selectedPeriod}
onclick={this.changePeriod.bind(this, period)}
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
>
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
</Button>
))}
{Object.keys(this.periods!)
.map((period) => (
<Button
key={period}
active={period === this.selectedPeriod}
onclick={this.changePeriod.bind(this, period)}
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
>
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
</Button>
))
.concat([
<Button
key="custom"
active={this.selectedPeriod === 'custom'}
onclick={() => {
const attrs: IStatisticsWidgetDateSelectionModalAttrs = {
onModalSubmit: (dates: IDateSelection) => {
if (JSON.stringify(dates) === JSON.stringify(this.customPeriod)) {
// If same period is selected, don't reload data
return;
}
this.customPeriodData = {};
Object.keys(this.loadingCustom).forEach((k) => (this.loadingCustom[k] = 'unloaded'));
this.customPeriod = dates;
this.changePeriod('custom');
},
} as any;
// If we have a custom period set already,
// let's prefill the modal with it
if (this.customPeriod) {
attrs.value = this.customPeriod;
}
app.modal.show(StatisticsWidgetDateSelectionModal as any, attrs as any);
}}
icon={this.selectedPeriod === 'custom' ? 'fas fa-check' : true}
>
{this.selectedPeriod === 'custom'
? extractText(
app.translator.trans(`flarum-statistics.admin.statistics.custom_label_specified`, {
fromDate: dayjs.utc(this.customPeriod!.start! * 1000).format('ll'),
toDate: dayjs.utc(this.customPeriod!.end! * 1000).format('ll'),
})
)
: app.translator.trans(`flarum-statistics.admin.statistics.custom_label`)}
</Button>,
])}
</SelectDropdown>
)}
</div>
@@ -148,11 +264,14 @@ export default class StatisticsWidget extends DashboardWidget {
const thisPeriodCount = loadingSelectedEntity
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, thisPeriod!);
const lastPeriodCount = loadingSelectedEntity
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
const lastPeriodCount =
this.selectedPeriod === 'custom'
? null
: loadingSelectedEntity
? app.translator.trans('flarum-statistics.admin.statistics.loading')
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
const periodChange =
loadingSelectedEntity || lastPeriodCount === 0
loadingSelectedEntity || lastPeriodCount === 0 || lastPeriodCount === null
? 0
: (((thisPeriodCount as number) - (lastPeriodCount as number)) / (lastPeriodCount as number)) * 100;
@@ -197,6 +316,20 @@ export default class StatisticsWidget extends DashboardWidget {
/>
)}
</>
{this.noData && <Placeholder text={app.translator.trans(`flarum-statistics.admin.statistics.no_data`)} />}
{!this.noData && !!this.chart && (
<Button
className="StatisticsWidget-chartExport Button"
icon="fas fa-file-export"
onclick={() => {
this.chart.export();
}}
>
{app.translator.trans('flarum-statistics.admin.statistics.export_chart_button')}
</Button>
)}
</div>
);
}
@@ -206,9 +339,16 @@ export default class StatisticsWidget extends DashboardWidget {
return;
}
const period = this.periods![this.selectedPeriod!];
const period =
this.selectedPeriod === 'custom'
? {
start: this.customPeriod?.start!,
end: this.customPeriod?.end!,
step: 86400,
}
: this.periods![this.selectedPeriod!];
const periodLength = period.end - period.start;
const labels = [];
const labels: string[] = [];
const thisPeriod = [];
const lastPeriod = [];
@@ -216,22 +356,45 @@ export default class StatisticsWidget extends DashboardWidget {
let label;
if (period.step < 86400) {
label = dayjs.unix(i).format('h A');
label = dayjs.unix(i).utc().format('h A');
} else {
label = dayjs.unix(i).format('D MMM');
label = dayjs.unix(i).utc().format('D MMM');
if (period.step > 86400) {
label += ' - ' + dayjs.unix(i + period.step - 1).format('D MMM');
label +=
' - ' +
dayjs
.unix(i + period.step - 1)
.utc()
.format('D MMM');
}
}
labels.push(label);
thisPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i, end: i + period.step }));
lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength + period.step }));
lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength }));
}
const datasets = [{ values: lastPeriod }, { values: thisPeriod }];
if (thisPeriod.length === 0) {
this.noData = true;
m.redraw();
return;
} else {
this.noData = false;
m.redraw();
}
const datasets = [
{
name: extractText(app.translator.trans('flarum-statistics.admin.statistics.current_period')),
values: thisPeriod,
},
{
name: extractText(app.translator.trans('flarum-statistics.admin.statistics.previous_period')),
values: lastPeriod,
},
];
const data = {
labels,
datasets,
@@ -251,8 +414,9 @@ export default class StatisticsWidget extends DashboardWidget {
},
lineOptions: {
hideDots: 1,
regionFill: 1,
},
colors: ['black', app.forum.attribute('themePrimaryColor')],
colors: [app.forum.attribute('themePrimaryColor'), 'black'],
});
} else {
this.chart.update(data);
@@ -275,7 +439,7 @@ export default class StatisticsWidget extends DashboardWidget {
}
getPeriodCount(entity: string, period: { start: number; end: number }) {
const timed: Record<string, number> = this.timedData[entity];
const timed: Record<string, number> = (this.selectedPeriod === 'custom' ? this.customPeriodData : this.timedData)[entity];
let count = 0;
for (const t in timed) {

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

View File

@@ -9,6 +9,7 @@
namespace Flarum\Statistics\Api\Controller;
use Carbon\Carbon;
use DateTime;
use Flarum\Discussion\Discussion;
use Flarum\Http\RequestUtil;
@@ -69,20 +70,39 @@ class ShowStatisticsData implements RequestHandlerInterface
// control panel.
$actor->assertAdmin();
$reportingPeriod = Arr::get($request->getQueryParams(), 'period');
$model = Arr::get($request->getQueryParams(), 'model');
$query = $request->getQueryParams();
return new JsonResponse($this->getResponse($model, $reportingPeriod));
$reportingPeriod = Arr::get($query, 'period');
$model = Arr::get($query, 'model');
$customDateRange = Arr::get($query, 'dateRange');
return new JsonResponse($this->getResponse($model, $reportingPeriod, $customDateRange));
}
private function getResponse(?string $model, ?string $period): array
private function getResponse(?string $model, ?string $period, ?array $customDateRange): array
{
if ($period === 'lifetime') {
return $this->getLifetimeStatistics();
}
if (! Arr::exists($this->entities, $model)) {
throw new InvalidParameterException();
throw new InvalidParameterException('A model must be specified');
}
if ($period === 'custom') {
$start = (int) $customDateRange['start'];
$end = (int) $customDateRange['end'];
if (! $customDateRange || ! $start || ! $end) {
throw new InvalidParameterException('A custom date range must be specified');
}
// Seconds-based timestamps
$startRange = Carbon::createFromTimestampUTC($start)->toDateTime();
$endRange = Carbon::createFromTimestampUTC($end)->toDateTime();
// We can't really cache this
return $this->getTimedCounts($this->entities[$model][0], $this->entities[$model][1], $startRange, $endRange);
}
return $this->getTimedStatistics($model);
@@ -104,8 +124,23 @@ class ShowStatisticsData implements RequestHandlerInterface
});
}
private function getTimedCounts(Builder $query, $column)
private function getTimedCounts(Builder $query, string $column, ?DateTime $startDate = null, ?DateTime $endDate = null)
{
$diff = $startDate && $endDate ? $startDate->diff($endDate) : null;
if (! isset($startDate)) {
// need -12 months and period before that
$startDate = new DateTime('-2 years');
} else {
// If the start date is custom, we need to include an equal amount beforehand
// to show the data for the previous period.
$startDate = (new Carbon($startDate))->subtract($diff)->toDateTime();
}
if (! isset($endDate)) {
$endDate = new DateTime();
}
$results = $query
->selectRaw(
'DATE_FORMAT(
@@ -115,7 +150,8 @@ class ShowStatisticsData implements RequestHandlerInterface
[new DateTime('-25 hours')]
)
->selectRaw('COUNT(id) as count')
->where($column, '>', new DateTime('-365 days'))
->where($column, '>', $startDate)
->where($column, '<=', $endDate)
->groupBy('time_group')
->pluck('count', 'time_group');

View File

@@ -0,0 +1,110 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Statistics\tests\integration\api;
use Carbon\Carbon;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class CanRequestCustomTimedStatisticsTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @var Carbon
*/
protected $nowTime;
protected function setUp(): void
{
parent::setUp();
$this->nowTime = Carbon::now()->subDays(10);
$this->extension('flarum-statistics');
$this->prepareDatabase($this->getDatabaseData());
}
protected function getDatabaseData(): array
{
return [
'users' => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()],
['id' => 2, 'username' => 'normal', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()->subDays(1)],
['id' => 3, 'username' => 'normal2', 'email' => 'normal2@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()->subDays(2)],
],
'discussions' => [
['id' => 1, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 3, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 4, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(2), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()],
['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)],
['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)],
['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(2)],
['id' => 5, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 2, 'created_at' => $this->nowTime->copy()],
],
];
}
/**
* @test
*/
public function can_request_timed_stats()
{
$time = $this->nowTime->copy();
$start = $time->copy()->subDays(1)->startOfDay()->getTimestamp();
$end = $time->copy()->endOfDay()->getTimestamp();
$timeStart = $time->copy()->startOfDay();
$models = [
'users' => [
$timeStart->copy()->getTimestamp() => 1,
$timeStart->copy()->subDays(1)->getTimestamp() => 1,
$timeStart->copy()->subDays(2)->getTimestamp() => 1,
], 'discussions' => [
$timeStart->copy()->getTimestamp() => 1,
$timeStart->copy()->subDays(1)->getTimestamp() => 2,
$timeStart->copy()->subDays(2)->getTimestamp() => 1,
], 'posts' => [
$timeStart->copy()->getTimestamp() => 2,
$timeStart->copy()->subDays(1)->getTimestamp() => 2,
$timeStart->copy()->subDays(2)->getTimestamp() => 1,
]
];
foreach ($models as $model => $data) {
$response = $this->send(
$this->request('GET', '/api/statistics', ['authenticatedAs' => 1])->withQueryParams([
'model' => $model,
'period' => 'custom',
'dateRange' => [
'start' => $start,
'end' => $end,
],
])
);
$body = json_decode($response->getBody()->getContents(), true);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(
$data,
$body,
);
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Statistics\tests\integration\api;
use Carbon\Carbon;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class CanRequestLifetimeStatisticsTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @var Carbon
*/
protected $nowTime;
protected function setUp(): void
{
parent::setUp();
$this->nowTime = Carbon::now();
$this->extension('flarum-statistics');
$this->prepareDatabase($this->getDatabaseData());
}
protected function getDatabaseData(): array
{
return [
'users' => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
['id' => 2, 'username' => 'normal', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->subDays(1)],
],
'discussions' => [
['id' => 1, 'title' => __CLASS__, 'created_at' => $this->nowTime, 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => $this->nowTime->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 3, 'title' => __CLASS__, 'created_at' => $this->nowTime->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 4, 'title' => __CLASS__, 'created_at' => $this->nowTime->subDays(2), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1],
['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1],
['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1],
['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1],
['id' => 5, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 2],
],
];
}
/**
* @test
*/
public function can_request_lifetime_stats()
{
$response = $this->send(
$this->request('GET', '/api/statistics', ['authenticatedAs' => 1])->withQueryParams([
'period' => 'lifetime',
])
);
$body = json_decode($response->getBody()->getContents(), true);
$db = $this->getDatabaseData();
$this->assertEquals(200, $response->getStatusCode());
$this->assertEqualsCanonicalizing(
[
'users' => count($db['users']),
'discussions' => count($db['discussions']),
'posts' => count($db['posts']),
],
$body
);
}
}

View File

@@ -0,0 +1,99 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Statistics\tests\integration\api;
use Carbon\Carbon;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class CanRequestTimedStatisticsTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @var Carbon
*/
protected $nowTime;
protected function setUp(): void
{
parent::setUp();
$this->nowTime = Carbon::now()->subDays(10);
$this->extension('flarum-statistics');
$this->prepareDatabase($this->getDatabaseData());
}
protected function getDatabaseData(): array
{
return [
'users' => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()],
['id' => 2, 'username' => 'normal', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()->subDays(1)],
],
'discussions' => [
['id' => 1, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 3, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
['id' => 4, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(2), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()],
['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)],
['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)],
['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(2)],
['id' => 5, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'number' => 2, 'created_at' => $this->nowTime->copy()],
],
];
}
/**
* @test
*/
public function can_request_timed_stats()
{
$time = $this->nowTime->copy();
$time->setTime(0, 0, 0, 0);
$models = [
'users' => [
$time->copy()->getTimestamp() => 1,
$time->copy()->subDays(1)->getTimestamp() => 1,
], 'discussions' => [
$time->copy()->getTimestamp() => 1,
$time->copy()->subDays(1)->getTimestamp() => 2,
$time->copy()->subDays(2)->getTimestamp() => 1,
], 'posts' => [
$time->copy()->getTimestamp() => 2,
$time->copy()->subDays(1)->getTimestamp() => 2,
$time->copy()->subDays(2)->getTimestamp() => 1,
]
];
foreach ($models as $model => $data) {
$response = $this->send(
$this->request('GET', '/api/statistics', ['authenticatedAs' => 1])->withQueryParams([
'model' => $model,
])
);
$body = json_decode($response->getBody()->getContents(), true);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEqualsCanonicalizing(
$data,
$body
);
}
}
}

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.3"
},
"autoload": {
"psr-4": {
@@ -33,6 +33,9 @@
"flarum-extension": {
"title": "Subscriptions",
"category": "feature",
"optional-dependencies": [
"flarum/approval"
],
"icon": {
"name": "fas fa-star",
"backgroundColor": "#ffea7b",
@@ -86,6 +89,7 @@
"test:setup": "Sets up a database for use with integration tests. Execute this only once."
},
"require-dev": {
"flarum/testing": "^1.0.0"
"flarum/testing": "^1.0.0",
"flarum/approval": "@dev"
}
}

View File

@@ -9,6 +9,7 @@
use Flarum\Api\Serializer\BasicDiscussionSerializer;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Approval\Event\PostWasApproved;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event\Saving;
use Flarum\Discussion\Filter\DiscussionFilterer;
@@ -20,6 +21,7 @@ use Flarum\Post\Event\Posted;
use Flarum\Post\Event\Restored;
use Flarum\Subscriptions\HideIgnoredFromAllDiscussionsPage;
use Flarum\Subscriptions\Listener;
use Flarum\Subscriptions\Notification\FilterVisiblePostsBeforeSending;
use Flarum\Subscriptions\Notification\NewPostBlueprint;
use Flarum\Subscriptions\Query\SubscriptionFilterGambit;
@@ -35,7 +37,8 @@ return [
->namespace('flarum-subscriptions', __DIR__.'/views'),
(new Extend\Notification())
->type(NewPostBlueprint::class, BasicDiscussionSerializer::class, ['alert', 'email']),
->type(NewPostBlueprint::class, BasicDiscussionSerializer::class, ['alert', 'email'])
->beforeSending(FilterVisiblePostsBeforeSending::class),
(new Extend\ApiSerializer(DiscussionSerializer::class))
->attribute('subscription', function (DiscussionSerializer $serializer, Discussion $discussion) {
@@ -50,6 +53,7 @@ return [
(new Extend\Event())
->listen(Saving::class, Listener\SaveSubscriptionToDatabase::class)
->listen(Posted::class, Listener\SendNotificationWhenReplyIsPosted::class)
->listen(PostWasApproved::class, Listener\SendNotificationWhenReplyIsPosted::class)
->listen(Hidden::class, Listener\DeleteNotificationWhenPostIsHiddenOrDeleted::class)
->listen(Restored::class, Listener\RestoreNotificationWhenPostIsRestored::class)
->listen(Deleted::class, Listener\DeleteNotificationWhenPostIsHiddenOrDeleted::class)

View File

@@ -9,6 +9,7 @@
namespace Flarum\Subscriptions\Listener;
use Flarum\Approval\Event\PostWasApproved;
use Flarum\Post\Event\Posted;
use Flarum\Subscriptions\Job\SendReplyNotification;
use Illuminate\Contracts\Queue\Queue;
@@ -25,7 +26,11 @@ class SendNotificationWhenReplyIsPosted
$this->queue = $queue;
}
public function handle(Posted $event)
/**
* @param Posted|PostWasApproved $event
* @return void
*/
public function handle($event)
{
$this->queue->push(
new SendReplyNotification($event->post, $event->post->discussion->last_post_number)

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Subscriptions\Notification;
use Flarum\Notification\Blueprint\BlueprintInterface;
class FilterVisiblePostsBeforeSending
{
public function __invoke(BlueprintInterface $blueprint, array $recipients): array
{
if ($blueprint instanceof NewPostBlueprint) {
$newRecipients = [];
// Flarum has built-in access control for the notification subject,
// but subscriptions post notifications has the discussion as the subject.
// We'll add a post visibility check so that users can't get access to hidden replies by subscribing.
foreach ($recipients as $recipient) {
if ($blueprint->post->isVisibleTo($recipient)) {
$newRecipients[] = $recipient;
}
}
return $newRecipients;
}
return $recipients;
}
}

View File

@@ -10,6 +10,9 @@
namespace Flarum\Subscriptions\tests\integration\api\discussions;
use Carbon\Carbon;
use Flarum\Extend\ModelVisibility;
use Flarum\Group\Group;
use Flarum\Post\Post;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
@@ -221,4 +224,103 @@ class ReplyNotificationTest extends TestCase
[[8, 9, 10]]
];
}
/** @test */
public function approving_reply_sends_reply_notification()
{
// Flags was only specified because it is required for approval.
$this->extensions = ['flarum-flags', 'flarum-approval', 'flarum-subscriptions'];
$this->app();
$this->database()
->table('group_permission')
->where('group_id', Group::MEMBER_ID)
->where('permission', 'discussion.replyWithoutApproval')
->delete();
/** @var User $mainUser */
$mainUser = User::query()->find(2);
$this->assertEquals(0, $mainUser->getUnreadNotificationCount());
$response = $this->send(
$this->request('POST', '/api/posts', [
'authenticatedAs' => 4,
'json' => [
'data' => [
'attributes' => [
'content' => 'reply with predetermined content for automated testing - too-obscure',
],
'relationships' => [
'discussion' => ['data' => ['id' => 1]],
],
],
],
])
);
$this->assertEquals(0, $mainUser->getUnreadNotificationCount());
$json = json_decode($response->getBody()->getContents(), true);
// Approve the previous post
$this->send(
$this->request('PATCH', '/api/posts/'.$json['data']['id'], [
'authenticatedAs' => 1,
'json' => [
'data' => [
'attributes' => [
'isApproved' => 1,
],
],
],
])
);
$this->assertEquals(1, $mainUser->getUnreadNotificationCount());
}
/** @test */
public function replying_to_a_discussion_with_a_restricted_post_only_sends_notifications_to_allowed_users()
{
// Add visibility scoper to only allow admin
// to see expected new post with content containing 'restricted-test-post'.
$this->extend(
(new ModelVisibility(Post::class))
->scope(function (User $actor, $query) {
if (! $actor->isAdmin()) {
$query->where('content', 'not like', '%restricted-test-post%');
}
})
);
$this->app();
/** @var User $allowedUser */
$allowedUser = User::query()->find(1);
$normalUser = User::query()->find(2);
$this->assertEquals(0, $allowedUser->getUnreadNotificationCount());
$this->assertEquals(0, $normalUser->getUnreadNotificationCount());
$this->send(
$this->request('POST', '/api/posts', [
'authenticatedAs' => 3,
'json' => [
'data' => [
'attributes' => [
'content' => 'restricted-test-post',
],
'relationships' => [
'discussion' => ['data' => ['id' => 1]],
],
],
],
])
);
$this->assertEquals(1, $allowedUser->getUnreadNotificationCount());
$this->assertEquals(0, $normalUser->getUnreadNotificationCount());
}
}

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

1
extensions/tags/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

@@ -66,7 +66,9 @@ class UpdateTagMetadata
*/
public function whenDiscussionIsDeleted(Deleted $event)
{
$this->updateTags($event->discussion, -1);
// If already soft deleted when permanently deleted, the -1 delta has already been applied in Hidden listener
$delta = $event->discussion->hidden_at ? 0 : -1;
$this->updateTags($event->discussion, $delta);
$event->discussion->tags()->detach();
}

View File

@@ -76,6 +76,7 @@ declare const _default: {
'utils/throttleDebounce': typeof import("../common/utils/throttleDebounce");
'utils/isObject': typeof import("../common/utils/isObject").default;
'utils/focusTrap': typeof import("../common/utils/focusTrap");
'utils/isDark': typeof import("../common/utils/isDark").default;
'models/Notification': typeof import("../common/models/Notification").default;
'models/User': typeof import("../common/models/User").default;
'models/Post': typeof import("../common/models/Post").default;

View File

@@ -28,6 +28,7 @@ import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes';
import * as FocusTrap from './utils/focusTrap';
import isDark from './utils/isDark';
import Notification from './models/Notification';
import User from './models/User';
import Post from './models/Post';
@@ -121,6 +122,7 @@ declare const _default: {
'utils/throttleDebounce': typeof ThrottleDebounce;
'utils/isObject': typeof isObject;
'utils/focusTrap': typeof FocusTrap;
'utils/isDark': typeof isDark;
'models/Notification': typeof Notification;
'models/User': typeof User;
'models/Post': typeof Post;

View File

@@ -1,6 +1,6 @@
import type Mithril from 'mithril';
import User from '../models/User';
/**
* The `useronline` helper displays a green circle if the user is online
* The `useronline` helper displays a green circle if the user is online.
*/
export default function userOnline(user: User): Mithril.Vnode<{}, {}> | null;

View File

@@ -0,0 +1,7 @@
/**
* The `isDark` utility converts a hex color to rgb, and then calcul a YIQ
* value in order to get the appropriate brightness value (is it dark or is it
* light?) See https://www.w3.org/TR/AERT/#color-contrast for references. A YIQ
* value >= 128 is a light color.
*/
export default function isDark(hexcolor: String): boolean;

View File

@@ -112,6 +112,7 @@ declare const _default: {
'utils/throttleDebounce': typeof import("../common/utils/throttleDebounce");
'utils/isObject': typeof import("../common/utils/isObject").default;
'utils/focusTrap': typeof import("../common/utils/focusTrap");
'utils/isDark': typeof import("../common/utils/isDark").default;
'models/Notification': typeof import("../common/models/Notification").default;
'models/User': typeof import("../common/models/User").default;
'models/Post': typeof import("../common/models/Post").default;

View File

@@ -3,6 +3,7 @@ import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
import Stream from '../../common/utils/Stream';
import Mithril from 'mithril';
import RequestError from '../../common/utils/RequestError';
import ItemList from '../../common/utils/ItemList';
export interface IForgotPasswordModalAttrs extends IInternalModalAttrs {
email?: string;
}
@@ -20,6 +21,8 @@ export default class ForgotPasswordModal<CustomAttrs extends IForgotPasswordModa
className(): string;
title(): import("@askvortsov/rich-icu-message-formatter").NestedStringArray;
content(): JSX.Element;
fields(): ItemList<unknown>;
onsubmit(e: SubmitEvent): void;
requestParams(): Record<string, unknown>;
onerror(error: RequestError): void;
}

View File

@@ -4,6 +4,7 @@ import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
import type Mithril from 'mithril';
import RequestError from '../../common/utils/RequestError';
import type { LoginParams } from '../../common/Session';
export interface ILoginModalAttrs extends IInternalModalAttrs {
identification?: string;
password?: string;
@@ -41,5 +42,6 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
signUp(): void;
onready(): void;
onsubmit(e: SubmitEvent): void;
loginParams(): LoginParams;
onerror(error: RequestError): void;
}

2
framework/core/js/dist/admin.js generated vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
framework/core/js/dist/forum.js generated vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,7 @@ import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
import Mithril from 'mithril';
import extractText from '../../common/utils/extractText';
import ColorPreviewInput from '../../common/components/ColorPreviewInput';
export interface IEditGroupModalAttrs extends IInternalModalAttrs {
group?: Group;
@@ -81,7 +82,7 @@ export default class EditGroupModal<CustomAttrs extends IEditGroupModalAttrs = I
'color',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
<input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
<ColorPreviewInput placeholder="#aaaaaa" bidi={this.color} />
</div>,
20
);

View File

@@ -410,16 +410,22 @@ export default class Application {
pageNumber: 1,
};
const title =
let title =
onHomepage || !this.title
? extractText(app.translator.trans('core.lib.meta_titles.without_page_title', params))
: extractText(app.translator.trans('core.lib.meta_titles.with_page_title', params));
const tempEl = document.createElement('div');
tempEl.innerHTML = title;
const decodedTitle = tempEl.innerText;
title = count + title;
document.title = count + decodedTitle;
// We pass the title through a DOMParser to allow HTML entities
// to be rendered correctly, while still preventing XSS attacks
// from user input by using a script-disabled environment.
// https://github.com/flarum/framework/issues/3514
// https://github.com/flarum/framework/pull/3684
const parser = new DOMParser();
const safeTitle = parser.parseFromString(title, 'text/html').body.innerHTML;
document.title = safeTitle;
}
protected transformRequestOptions<ResponseType>(flarumOptions: FlarumRequestOptions<ResponseType>): InternalFlarumRequestOptions<ResponseType> {

View File

@@ -33,6 +33,7 @@ import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes';
import withAttr from './utils/withAttr';
import * as FocusTrap from './utils/focusTrap';
import isDark from './utils/isDark';
import Notification from './models/Notification';
import User from './models/User';
import Post from './models/Post';
@@ -120,6 +121,7 @@ export default {
'utils/throttleDebounce': ThrottleDebounce,
'utils/isObject': isObject,
'utils/focusTrap': FocusTrap,
'utils/isDark': isDark,
'models/Notification': Notification,
'models/User': User,
'models/Post': Post,

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