1
0
mirror of https://github.com/flarum/core.git synced 2025-08-13 03:44:32 +02:00

Compare commits

...

96 Commits

Author SHA1 Message Date
Sami Mazouz
96b8b92d42 phpstan 2024-06-21 10:30:58 +01:00
Sami Mazouz
a03104d61d fix 2024-06-21 10:25:02 +01:00
StyleCI Bot
7504e31399 Apply fixes from StyleCI 2024-06-21 09:38:55 +01:00
Sami Mazouz
aa39d0c11b chore: adapt 2024-06-21 09:38:55 +01:00
Sami Mazouz
d9e5ab4f11 chore 2024-06-21 09:38:55 +01:00
Sami Mazouz
ac27cd03dd chore: custom Serializer 2024-06-21 09:38:55 +01:00
StyleCI Bot
a442aad3be Apply fixes from StyleCI 2024-06-21 09:38:55 +01:00
Sami Mazouz
51e2ab8502 chore: drop the need for a json-api-server fork 2024-06-21 09:38:55 +01:00
Sami Mazouz
a8777c6198 refactor: JSON:API (#3971)
* refactor: json:api refactor iteration 1
* chore: delete dead code
* fix: regressions
* chore: move additions/changes to package
* feat: AccessTokenResource
* feat: allow dependency injection in resources
* feat: `ApiResource` extender
* feat: improve
* feat: refactor tags extension
* feat: refactor flags extension
* fix: regressions
* fix: drop bc layer
* feat: refactor suspend extension
* feat: refactor subscriptions extension
* feat: refactor approval extension
* feat: refactor sticky extension
* feat: refactor nicknames extension
* feat: refactor mentions extension
* feat: refactor lock extension
* feat: refactor likes extension
* chore: merge conflicts
* feat: refactor extension-manager extension
* feat: context current endpoint helpers
* chore: minor
* feat: cleaner sortmap implementation
* chore: drop old package
* chore: not needed (auto scoping)
* fix: actor only fields
* refactor: simplify index endpoint
* feat: eager loading
* test: adapt
* test: phpstan
* test: adapt
* fix: typing
* fix: approving content
* tet: adapt frontend tests
* chore: typings
* chore: review
* fix: breaking change
2024-06-21 09:36:32 +01:00
flarum-bot
10514709f1 Bundled output for commit eb6e599df1
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-06-21 06:28:22 +00:00
Sami Mazouz
eb6e599df1 feat: add support for SQLite (#3984)
* feat: add support for sqlite

* chore: add warning on install

* fix: ignore constraints before transaction begins

* chore: update workflow

* Apply fixes from StyleCI

* chore: generate sqlite dump and manually add foreign keys

* chore: fix actions

* chore: fix actions

* chore: fix actions

* chore: fix actions

* chore: fix actions

* chore: fix actions

* test: fix

* Apply fixes from StyleCI

* fix: sqlite with db prefix

* Apply fixes from StyleCI

* fix: statistics sqlite
2024-06-21 07:25:11 +01:00
flarum-bot
5ce1aeab47 Bundled output for commit 389d004ddc
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-06-16 15:55:43 +00:00
Ngô Quốc Đạt
389d004ddc feat: Add conditional rendering for email status in MailPage.tsx (#3997) 2024-06-16 17:52:38 +02:00
Ngô Quốc Đạt
72f89c0209 fix: setting key safe_mode_extensions not exists (#3992) 2024-05-18 18:14:04 +01:00
Davide Iadeluca
1e7eddb61e ci: allow custom actions runner to be defined (#3988) 2024-05-16 17:30:13 +01:00
flarum-bot
1302378141 Bundled output for commit 29ede5aa27
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-05-14 20:13:28 +00:00
Sami Mazouz
29ede5aa27 feat: JS Notification extender (#3974)
* feat: JS `Notification` extender

* fix
2024-05-14 21:10:07 +01:00
flarum-bot
d273b1920f Bundled output for commit b02f8190ea
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-05-03 13:23:30 +00:00
Sami Mazouz
b02f8190ea feat: extension bisect (#3980)
* feat: extension bisect
* Apply fixes from StyleCI
* chore: review
* Apply suggestions from code review
* feat: add stop bisect button
* feat: redirect to result extension page

Co-authored-by: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com>
2024-05-03 14:20:12 +01:00
flarum-bot
e0025df3e7 Bundled output for commit b8e17182e9
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-05-03 13:09:21 +00:00
Sami Mazouz
b8e17182e9 feat: advanced maintenance modes (#3977)
* feat: low maintenance mode (maintenance with admin access) (#3975)
* feat: low maintenance mode (maintenance with admin access)
* Apply fixes from StyleCI
* chore: only required when basic
* chore: more concise code
* chore(review): enum
* feat: enable through settings
* Apply fixes from StyleCI
* core: typing
* feat: safe mode (#3978)
* feat: safe mode
* feat: add extension page warning
* feat: `safe_mode_extensions`
* Apply fixes from StyleCI
2024-05-03 14:05:58 +01:00
Sami Mazouz
2b917372a7 feat: eloquent factories (primarily for tests) (#3982) 2024-05-03 09:20:27 +01:00
Sami Mazouz
270188b5b0 fix: compiling split chunks in production 2024-04-26 14:25:31 +01:00
flarum-bot
9149ecc7aa Bundled output for commit 5fc2bb5eb6
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-04-07 12:23:32 +00:00
Sami Mazouz
5fc2bb5eb6 fix: broken assets 2024-04-07 13:19:57 +01:00
flarum-bot
24f3a6829f Bundled output for commit bf523b2325
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-04-06 13:55:44 +00:00
Sami Mazouz
bf523b2325 chore: extract buildSettingComponent method into a FormGroup component (#3927)
* chore: extract `buildSettingComponent` method into a `FormGroup` component

* chore: typings

* feat: move to common
2024-04-06 14:52:13 +01:00
Daniël Klabbers
e771b908d5 Patch vulnerability advisory (#3966)
Seems composer has a vulnerability, see https://github.com/advisories/GHSA-7c6p-848j-wh5h


Affected versions
>= 2.0.0-alpha1, < 2.2.23 -- patched in 2.2.23
>= 2.3.0-rc1, < 2.7.0 -- patched in 2.7.0

---

Let's raise the minimum to enforce the latest.

Thank you @peopleinside for reporting this.
2024-02-22 11:40:56 +01:00
flarum-bot
721e2eae3d Bundled output for commit 3fbe05fd18
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-01-22 18:01:17 +00:00
Sami Mazouz
3fbe05fd18 feat(em): port extension manager from 1.0 (#3959)
* feat(em): port extension manager from 1.0

* Apply fixes from StyleCI

* chore: phpstan

---------

Co-authored-by: StyleCI Bot <bot@styleci.io>
2024-01-22 18:58:08 +01:00
IanM
8f29b7af82 feat: support composer auth (#3961) 2024-01-22 18:57:54 +01:00
Ngô Quốc Đạt
734f4a150c chore: use hex_color rule for color validation (#3936) 2024-01-19 12:09:22 +01:00
flarum-bot
0186ca909e Bundled output for commit 1aa7806244
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-01-19 11:07:33 +00:00
Ngô Quốc Đạt
1aa7806244 Fix notify for all posts switch field loading state (#3938) 2024-01-19 12:04:25 +01:00
IanM
e3350543af feat: upgrade intervention/image to 3.2 (#3947)
* chore: create standalone imageprovider

* chore: upgrade intervention to v3

* Apply fixes from StyleCI

* use new static instatiation

* Revert "Apply fixes from StyleCI"

This reverts commit 096b4d9a79fa41c948a7572cf65316ebc6b07d36.

* get avatar from remote

* Apply fixes from StyleCI

* fix: incorrect gid exception namespace

* fix test

* remove debug code

---------

Co-authored-by: StyleCI Bot <bot@styleci.io>
2024-01-19 11:49:00 +01:00
Sami Mazouz
d400dcbc2f feat: dispatch event to flarum/installation-packages on release (#3625) 2024-01-19 09:54:26 +01:00
Davide Iadeluca
430709bf5b [2.x] fix(Mentions): allow renderer to be used without context (#3954)
* fix(Mentions): allow renderer to be used without context

* test(Mentions): implement test for rendering post without context

* Update UnparsePostMentions.php

* Update PostMentionsTest.php

---------

Co-authored-by: IanM <16573496+imorland@users.noreply.github.com>
2024-01-10 11:17:11 +00:00
flarum-bot
f784f48906 Bundled output for commit 3a34136e36
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-01-09 21:54:15 +00:00
Sami Mazouz
3a34136e36 feat: search UI/UX revamp (#3941)
* feat: first iteration

* chore: tweak

* feat: second iteration

* chore: incorrect code organization

* feat: gambit input suggestions

* feat: gambit keyboard navigation

* chore: bugs

* feat: negative gambits

* feat: improve gambit highlighting

* refactor: localize gambits

* feat: negative and positive gambit buttons

* fix: permissions

* chore: wat

* per: lazy load search modal

* fix: extensibility and bug fixes

* fix: bugs

* feat: reusable autocomplete dropdown

* chore: format

* fix: tag filter
2024-01-09 21:51:01 +00:00
flarum-bot
fb1703cd9b Bundled output for commit b58fec7ead
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-01-09 18:13:57 +00:00
Ngô Quốc Đạt
b58fec7ead Fix width issue in edit user modal (#3939)
* Fix width input issue in EditUserModal

* update
2024-01-09 18:10:55 +00:00
flarum-bot
537f97a07a Bundled output for commit c1be00e79a
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-01-09 18:04:43 +00:00
Sami Mazouz
c1be00e79a chore: improve debugging experience (#3944) 2024-01-09 18:01:29 +00:00
dependabot[bot]
91b89bc698 chore(deps): bump follow-redirects from 1.15.2 to 1.15.4 (#3957)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.2 to 1.15.4.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.2...v1.15.4)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-09 18:19:28 +01:00
flarum-bot
278617a10d Bundled output for commit f793e5b8f8
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2024-01-09 17:10:55 +00:00
IanM
f793e5b8f8 fix: ts error causing build to fail (#3956) 2024-01-09 17:07:38 +00:00
IanM
01598555a9 chore: larastan changed namespace (#3955) 2024-01-09 17:07:26 +00:00
StyleCI Bot
5399c86a1b Apply fixes from StyleCI 2024-01-09 15:06:12 +00:00
IanM
74ce4cf1a7 chore: dummy commit for StyleCI purposes 2024-01-09 15:05:47 +00:00
Ngô Quốc Đạt
b4a82e81fc fix: overflowing forum stats (#3940) 2023-12-01 11:42:36 +01:00
Rafał Całka
471ce0ea2a fix(approval): PostWasApproved event triggered incorrectly 2023-11-23 18:50:47 +01:00
IanM
723cb73d48 Enable testing on PHP 8.3 (#3932) 2023-11-23 18:48:24 +01:00
flarum-bot
e4abf93763 Bundled output for commit 9310ce13d8
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-11-23 17:19:53 +00:00
Sami Mazouz
9310ce13d8 chore: yarn format 2023-11-23 18:16:49 +01:00
flarum-bot
d4c532c949 Bundled output for commit 46357ee9a9
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-11-23 17:14:53 +00:00
Tristian Kelly
46357ee9a9 perf(flags): add pagination to flags list (#3931) 2023-11-23 18:11:46 +01:00
flarum-bot
6cbdfb6aa1 Bundled output for commit 4b126d9f4c
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-11-11 18:46:13 +00:00
Sami Mazouz
4b126d9f4c feat: revamp search (#3893)
* refactor: move gambits to frontend (#3885)
* refactor: move gambits to frontend
* test: GambitManager
* refactor: merge filterer and searcher concepts (#3892)
* chore: drop remaining backend regex gambits
* refactor: merge filterer & searcher concept
* refactor: adapt extenders
* refactor: no longer need to push gambits to `q`
* refactor: filters to gambits
* refactor: drop shred `Query` namespace
* chore: cleanup
* chore: leftover gambit references on the backend (#3894)
* chore: leftover gambit references on the backend
* chore: namespace
* feat: search driver backend extension API (#3902)
* feat: first iteration of search drivers
* feat: indexer API & tweaks
* feat: changes after POC driver
* fix: properly fire custom observables
* chore: remove debugging code
* fix: phpstan
* fix: custom eloquent events
* chore: drop POC usage
* test: indexer extender API
* fix: extension searcher fails without filters
* fix: phpstan
* fix: frontend created gambit
* feat: advanced page and localized driver settings (#3905)
* feat: allow getting total search results and replacing filters (#3906)
* feat: allow accessing total search results
* feat: allow replacing filters
* chore: phpstan
2023-11-11 19:43:09 +01:00
flarum-bot
9e04b010d8 Bundled output for commit 1c0e0933b0
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-11-11 18:38:17 +00:00
Sami Mazouz
1c0e0933b0 feat: improve emoji autocomplete (#3923)
* feat: improve emoji autocomplete
* chore: improve dropdown header
2023-11-11 19:31:53 +01:00
flarum-bot
60ffa78531 Bundled output for commit 294878291c
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-11-10 22:16:11 +00:00
IanM
294878291c chore: format js 2023-11-10 22:09:50 +00:00
flarum-bot
05a7e24836 Bundled output for commit eaabeab8c9
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-11-10 22:04:30 +00:00
Sami Mazouz
eaabeab8c9 chore: extract FormModal from Modal (#3922) 2023-11-10 21:59:34 +00:00
flarum-bot
9ef366493c Bundled output for commit 208b94dc12
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-11-10 21:50:02 +00:00
Sami Mazouz
208b94dc12 chore(mentions,emoji): tie autocomplete to editor instance (#3913) 2023-11-10 21:44:00 +00:00
Sami Mazouz
5e3f8db095 fix: handled API errors break preloaded content (#3920)
* fix: handled API errors break preloaded content

* Apply fixes from StyleCI

---------

Co-authored-by: StyleCI Bot <bot@styleci.io>
2023-11-10 21:39:08 +00:00
flarum-bot
deb99f0de4 Bundled output for commit 693bce912a
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-11-10 21:38:19 +00:00
Sami Mazouz
693bce912a fix(testing): use cookie for testing authentication (#3924) 2023-11-10 21:35:24 +00:00
Sami Mazouz
3107319812 fix: paginated list limit hard to change (#3918)
* fix: paginated list limit hard to change

* chore: use the default value

* chore: apply to other list states

* chore: remove debugging code

* fix: typings
2023-11-10 21:31:46 +00:00
Sami Mazouz
a9756cb5eb fix: only set actor on events that have it (#3914) 2023-11-10 21:25:10 +00:00
flarum-bot
bbdf3b5aba Bundled output for commit e2281a2123
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-11-10 21:23:23 +00:00
Sami Mazouz
e2281a2123 chore: handle deprecations from 1.x (#3909)
* chore: drop deprecated `post_number_index` column

* chore: remove deprecated `FlagsWillBeDeleted` event

* chore: `Migration::addSettings` can still be needed

* chore: `settings->get` default can still be needed

* chore: deprecated `$default` in `Settings::serializeToForum` extender

* chore: deprecated request `actor` attribute

* chore: already handled

* chore: remove `RecompileFrontendAssets::whenSettingsSaved`

* chore: remove `getReadIds`

* chore: `Model::dateAttribute` extender

* chore: `evented` js util

* chore: `WelcomeHero` js hidden prop

* chore: attributes pushData with relations

* chore: app request options `extract`

* chore: itemlist deprecations

* chore: `search` state

* chore: `getMentionText`

* chore: deprecated non-registered store type

* chore: `Button` title

* chore: `Modal` deprecations

* chore

* chore: deprecated `less` mixins

* Apply fixes from StyleCI

* fix

* fix: phpstan

* fix

* fix

---------

Co-authored-by: StyleCI Bot <bot@styleci.io>
2023-11-10 21:20:18 +00:00
flarum-bot
d01c0e5210 Bundled output for commit bee50bec73
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-11-03 13:15:30 +00:00
Sami Mazouz
bee50bec73 fix: color input changes while typing (#3919) 2023-11-03 14:08:40 +01:00
flarum-bot
679d32729e Bundled output for commit e978e29e00
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-11-03 08:10:35 +00:00
IanM
e978e29e00 fix: add history push (#3916) 2023-11-03 09:07:25 +01:00
Sami Mazouz
5cdfa0f640 fix: load event mentioned tags on show discussion endpoint (#3915) 2023-11-01 13:43:00 +00:00
flarum-bot
a3192d2934 Bundled output for commit 96ba2f5f2d
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-10-29 13:51:57 +00:00
dependabot[bot]
96ba2f5f2d chore(deps): bump @babel/traverse from 7.20.1 to 7.23.2 (#3901)
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.20.1 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-29 14:46:38 +01:00
Sami Mazouz
87a83d33b3 chore: avoid using .fa() mixins and @fa-var vars (#3912) 2023-10-29 14:45:50 +01:00
Ngô Quốc Đạt
015529ff1e Use ::class syntax to fetch class name instead of get_class() function (#3910) 2023-10-29 14:43:58 +01:00
flarum-bot
2950290ad1 Bundled output for commit d154388468
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-10-29 12:32:28 +00:00
Sami Mazouz
d154388468 chore: function names 2023-10-29 13:27:34 +01:00
Gary Green
7c885c72fd feat: frontend content flexible order priorities (#3765)
* Fix frontend factory so it includes controller content
* chore: more readable parameter passing
* feat: add priorities to frontend content
2023-10-29 12:59:48 +01:00
Sami Mazouz
577fc3e6a8 fix(webpack-config): split chunks can fail in dev mode (#3911) 2023-10-27 15:27:11 +01:00
IanM
e4e0fbff73 chore: remove ExtenderInterface[] as a conditional option, only support callable or ::class invoke (#3904)
* chore: remove ExtenderInterface[] as a conditional option, only support callable or ::class invoke

* Apply fixes from StyleCI

* stan

* review

---------

Co-authored-by: StyleCI Bot <bot@styleci.io>
2023-10-21 17:37:07 +01:00
IanM
94de8b42b4 fix: console extender does not accept ::class attribute for schedule (#3903) 2023-10-21 17:34:48 +01:00
flarum-bot
db0d9cb006 Bundled output for commit 5ab5257ff5
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-10-10 20:41:11 +00:00
Sami Mazouz
5ab5257ff5 feat: theming and extensibility improvements (#3876)
* feat: make page structure customizable across different pages (#3867)

* feat: create `PageStructure` component
* feat: apply to `DiscussionPage`
* feat: apply to `UserPage`
* feat: apply to `TagsPage`
* fix: adapt subscriptions ext
* chore: cleanup

* chore: use grid & flexbox for the discussion list item (#3868)

* chore: rename `DiscussionPage-list` to `DiscussionListPane`
* chore: itemlistify `DiscussionListItem`
* chore: use flex and grid for `DiscussionListItem`

* chore: use flexbox for `App-header` (#3869)

* chore: use flex and grid for `App-header`
* chore: drop search floats
* fix: adapt admin styles

* chore: use flexbox in dropdowns and SplitDropdown for subscriptions (#3874)

* chore: flexbox dropdown menu items
* chore: normalize subscriptions menu (use slit dropdown)
* chore: cleanup

* chore: misc flexbox/grid changes (#3875)

* chore: `TagsPage` to tsx
* chore: `TagsPage` flexbox/grid
* chore: `IndexPage-toolbar` flexbox
* chore: `UserCard` flexbox & itemlists
* fix: `Post` improve spacing logic
* chore: `Post` grid and proper spacing
* fix: avatar editor hover layer layout
* chore: `Button` flex

* chore: normalize form semantics (#3877)

* chore: normalize fieldsets
* fix: `LinkButton` spacing
* chore: consistent form semantics

* fix: styling regressions (#3878)

* fix: post spacing goes off in other pages
* fix: regression

* feat: extract reusable components from `NotificationsDropdown` (#3879)

* feat: extensible global notices (#3880)

* fix: js error on null item list
* feat: extensible global notices

* chore: housekeeping (#3881)

* chore: use CSS variables where still not using
* chore: cleanup suspension modal
* chore: cleanup post flag
* fix: badge vertical align
* chore: use CSS variables for custom coloring
* chore: `icon` helper to `Icon` component
* chore: `avatar` helper to `Avatar` component
* fix: chunk loading fails on admin frontend
* chore: format

* feat: reusable `UploadImageButton` component (#3882)

* chore: convert `UploadImageButton` to tsx
* feat: reusable `UploadImageButton` component
* feat: add `image-upload` setting type

* feat: extensible default footer component (#3883)

* chore: yarn format
2023-10-10 21:36:08 +01:00
flarum-bot
24d13e33bb Bundled output for commit 412cfafb3a
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-09-29 15:39:58 +00:00
IanM
412cfafb3a feat: notification unsubscribe & email overhaul with HTML multipart (#3872) 2023-09-29 16:34:54 +01:00
Sami Mazouz
ec5cb98c77 chore: merge the app with the container & implement the ApplicationContract (#3862)
* chore: merge the app with the container & implement the ApplicationContract

Illuminate components always expect the app to be the container, but also expect the app to be implementing the laravel app contract. This means that very often between minor illuminate updates we get a call to a method on the app that doesn't exist in the Flarum app. This fixes the issue once and for all.

* chore: improve concern implementation readability
* chore: service provider no longer has to change app type
* chore: unimplement `terminat(e/ing)`
* Apply fixes from StyleCI
* chore: recover `container` prop
* chore: return types
* fix: phpstan errors
2023-09-15 09:30:24 +01:00
flarum-bot
23fdddf185 Bundled output for commit be9eb16d7d
Includes transpiled JS/TS, and Typescript declaration files (typings).

[skip ci]
2023-09-14 20:09:52 +00:00
Ngô Quốc Đạt
be9eb16d7d chore: use str_contains (#3841) 2023-09-14 21:05:15 +01:00
IanM
ee34217b15 test: add MySQL 8.1 to the suite (#3870)
* Test using MySQL 8.1 also
* fix: db not added to matrix
* chore: add MySQL 8.1 to prefix tests
2023-09-14 21:04:25 +01:00
1234 changed files with 26807 additions and 17438 deletions

View File

@@ -23,3 +23,6 @@ indent_size = 2
[*.neon]
indent_style = tab
[{install,update}.php]
indent_size = 2

View File

@@ -31,7 +31,8 @@ on:
description: Versions of PHP to test with. Should be array of strings encoded as JSON array
type: string
required: false
default: '["8.1", "8.2"]'
# Keep PHP versions synced with build-install-packages.yml
default: '["8.1", "8.2", "8.3"]'
php_extensions:
description: PHP extensions to install.
@@ -43,7 +44,7 @@ on:
description: Versions of databases to test with. Should be array of strings encoded as JSON array
type: string
required: false
default: '["mysql:5.7", "mysql:8.0.30", "mariadb"]'
default: '["mysql:5.7", "mysql:8.0.30", "mysql:8.1.0", "mariadb", "sqlite:3"]'
php_ini_values:
description: PHP ini values
@@ -51,14 +52,26 @@ on:
required: false
default: error_reporting=E_ALL
runner_type:
description: The type of runner to use for the jobs. This should be one of the types supported by the `runs-on` keyword.
type: string
required: false
default: 'ubuntu-latest'
secrets:
composer_auth:
description: The Composer auth tokens to use for private packages.
required: false
env:
COMPOSER_ROOT_VERSION: dev-main
# `inputs.composer_directory` defaults to `inputs.backend_directory`
FLARUM_TEST_TMP_DIR_LOCAL: tests/integration/tmp
COMPOSER_AUTH: ${{ secrets.composer_auth }}
jobs:
test:
runs-on: ubuntu-latest
runs-on: ${{ inputs.runner_type }}
strategy:
matrix:
@@ -72,25 +85,49 @@ jobs:
# Expands the matrix by naming DBs.
- service: 'mysql:5.7'
db: MySQL 5.7
driver: mysql
- service: 'mysql:8.0.30'
db: MySQL 8.0
driver: mysql
- service: mariadb
db: MariaDB
driver: mysql
- service: 'mysql:8.1.0'
db: MySQL 8.1
driver: mysql
- service: 'sqlite:3'
db: SQLite
driver: sqlite
# Include Database prefix tests with only one PHP version.
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'mysql:5.7'
db: MySQL 5.7
driver: mysql
prefix: flarum_
prefixStr: (prefix)
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'mysql:8.0.30'
db: MySQL 8.0
driver: mysql
prefix: flarum_
prefixStr: (prefix)
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: mariadb
db: MariaDB
driver: mysql
prefix: flarum_
prefixStr: (prefix)
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'mysql:8.1.0'
db: MySQL 8.1
driver: mysql
prefix: flarum_
prefixStr: (prefix)
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'sqlite:3'
db: SQLite
driver: sqlite
prefix: flarum_
prefixStr: (prefix)
@@ -98,10 +135,22 @@ jobs:
exclude:
- php: ${{ fromJSON(inputs.php_versions)[1] }}
service: 'mysql:8.0.30'
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: mariadb
- php: ${{ fromJSON(inputs.php_versions)[1] }}
service: mariadb
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'mysql:8.1.0'
- php: ${{ fromJSON(inputs.php_versions)[1] }}
service: 'mysql:8.1.0'
- php: ${{ fromJSON(inputs.php_versions)[0] }}
service: 'sqlite:3'
- php: ${{ fromJSON(inputs.php_versions)[1] }}
service: 'sqlite:3'
services:
mysql:
image: ${{ matrix.service }}
image: ${{ matrix.service != 'sqlite:3' && matrix.service || '' }}
ports:
- 13306:3306
@@ -124,6 +173,7 @@ jobs:
ini-values: ${{ matrix.php_ini_values }}
- name: Create MySQL Database
if: ${{ matrix.service != 'sqlite:3' }}
run: |
sudo systemctl start mysql
mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306
@@ -153,10 +203,11 @@ jobs:
DB_PORT: 13306
DB_PASSWORD: root
DB_PREFIX: ${{ matrix.prefix }}
DB_DRIVER: ${{ matrix.driver }}
COMPOSER_PROCESS_TIMEOUT: 600
phpstan:
runs-on: ubuntu-latest
runs-on: ${{ inputs.runner_type }}
strategy:
matrix:

View File

@@ -86,20 +86,30 @@ on:
type: string
required: false
runner_type:
description: The type of runner to use for the jobs. This should be one of the types supported by the `runs-on` keyword.
type: string
required: false
default: 'ubuntu-latest'
secrets:
bundlewatch_github_token:
description: The GitHub token to use for Bundlewatch.
required: false
composer_auth:
description: The Composer auth tokens to use for private packages.
required: false
env:
COMPOSER_ROOT_VERSION: dev-main
ci_script: ${{ inputs.js_package_manager == 'yarn' && 'yarn install --immutable' || 'npm ci' }}
cache_dependency_path: ${{ inputs.cache_dependency_path || format(inputs.js_package_manager == 'yarn' && '{0}/yarn.lock' || '{0}/package-lock.json', inputs.frontend_directory) }}
COMPOSER_AUTH: ${{ secrets.composer_auth }}
jobs:
build:
name: Checks & Build
runs-on: ubuntu-latest
runs-on: ${{ inputs.runner_type }}
if: >-
((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || github.event_name != 'pull_request')

View File

@@ -0,0 +1,29 @@
name: Build Install Packages
on:
release:
types: [released]
env:
VERSION: ${{ github.event.release.tag_name }}
PHP_VERSIONS: '8.1 8.2 8.3'
INSTALL_PACKAGES_INPUTS: '{ "flarum_version": "{0}", "php_versions": "{1}" }'
jobs:
delay:
name: Wait for packagist to publish new packages
runs-on: ubuntu-latest
steps:
- run: sleep 30m
build:
name: Build Installation Packages
runs-on: ubuntu-latest
steps:
- name: Trigger build in flarum/installation-packages
uses: benc-uk/workflow-dispatch@v1
with:
workflow: Build Flarum Install Packages
repo: flarum/installation-packages
token: ${{ secrets.PACKAGES_BUILD_TOKEN }}
inputs: ${{ format(env.INSTALL_PACKAGES_INPUTS, env.VERSION, env.PHP_VERSIONS) }}

View File

@@ -38,3 +38,4 @@ If you discover a security vulnerability within Flarum, please send an e-mail to
## License
Flarum is open-source software licensed under the [MIT License](https://github.com/flarum/flarum/blob/master/LICENSE).

View File

@@ -46,7 +46,7 @@
"Flarum\\Lock\\": "extensions/lock/src",
"Flarum\\Mentions\\": "extensions/mentions/src",
"Flarum\\Nicknames\\": "extensions/nicknames/src",
"Flarum\\PackageManager\\": "extensions/package-manager/src",
"Flarum\\ExtensionManager\\": "extensions/package-manager/src",
"Flarum\\Pusher\\": "extensions/pusher/src",
"Flarum\\Statistics\\": "extensions/statistics/src",
"Flarum\\Sticky\\": "extensions/sticky/src",
@@ -70,7 +70,7 @@
"Flarum\\Lock\\Tests\\": "extensions/lock/tests",
"Flarum\\Mentions\\Tests\\": "extensions/mentions/tests",
"Flarum\\Nicknames\\Tests\\": "extensions/nicknames/tests",
"Flarum\\PackageManager\\Tests\\": "extensions/package-manager/tests",
"Flarum\\ExtensionManager\\Tests\\": "extensions/package-manager/tests",
"Flarum\\Pusher\\Tests\\": "extensions/pusher/tests",
"Flarum\\Statistics\\Tests\\": "extensions/statistics/tests",
"Flarum\\Sticky\\Tests\\": "extensions/sticky/tests",
@@ -94,7 +94,7 @@
"flarum/markdown": "self.version",
"flarum/mentions": "self.version",
"flarum/nicknames": "self.version",
"flarum/package-manager": "self.version",
"flarum/extension-manager": "self.version",
"flarum/pusher": "self.version",
"flarum/statistics": "self.version",
"flarum/sticky": "self.version",
@@ -108,10 +108,11 @@
"php": "^8.1",
"ext-json": "*",
"components/font-awesome": "^5.15.0",
"composer/composer": "^2.0",
"composer/composer": "^2.7",
"dflydev/fig-cookies": "^3.0",
"doctrine/dbal": "^3.6.2",
"dragonmantank/cron-expression": "^3.3",
"fakerphp/faker": "^1.9.1",
"franzl/whoops-middleware": "2.0",
"guzzlehttp/guzzle": "*",
"illuminate/bus": "^10.0",
@@ -130,7 +131,7 @@
"illuminate/support": "^10.0",
"illuminate/validation": "^10.0",
"illuminate/view": "^10.0",
"intervention/image": "^2.7.2",
"intervention/image": "^3.2",
"jenssegers/agent": "^2.6",
"laminas/laminas-diactoros": "^3.0",
"laminas/laminas-httphandlerrunner": "^2.6",
@@ -150,7 +151,6 @@
"pusher/pusher-php-server": "^7.2",
"s9e/text-formatter": "^2.13",
"staudenmeir/eloquent-eager-limit": "^1.8.2",
"sycho/json-api": "^0.5.0",
"sycho/sourcemap": "^2.0.0",
"symfony/config": "^6.3",
"symfony/console": "^6.3",
@@ -162,13 +162,14 @@
"symfony/postmark-mailer": "^6.3",
"symfony/translation": "^6.3",
"symfony/yaml": "^6.3",
"flarum/json-api-server": "^0.1.0",
"wikimedia/less.php": "^4.1"
},
"require-dev": {
"mockery/mockery": "^1.5",
"phpunit/phpunit": "^9.0",
"phpstan/phpstan": "^1.10.0",
"nunomaduro/larastan": "^2.6",
"larastan/larastan": "^2.7",
"symfony/var-dumper": "^6.3"
},
"config": {

View File

@@ -47,7 +47,7 @@ class Akismet
$client = new Client();
return $client->request('POST', "$this->apiUrl/$type", [
'headers' => [
'headers' => [
'User-Agent' => "Flarum/$this->flarumVersion | Akismet/$this->extensionVersion",
],
'form_params' => $this->params,

View File

@@ -7,9 +7,10 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Api\Serializer\BasicDiscussionSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Api\Resource;
use Flarum\Api\Schema;
use Flarum\Approval\Access;
use Flarum\Approval\Api\PostResourceFields;
use Flarum\Approval\Event\PostWasApproved;
use Flarum\Approval\Listener;
use Flarum\Discussion\Discussion;
@@ -36,17 +37,13 @@ return [
->default('is_approved', true)
->cast('is_approved', 'bool'),
(new Extend\ApiSerializer(BasicDiscussionSerializer::class))
->attribute('isApproved', function (BasicDiscussionSerializer $serializer, Discussion $discussion): bool {
return $discussion->is_approved;
}),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->fields(fn () => [
Schema\Boolean::make('isApproved'),
]),
(new Extend\ApiSerializer(PostSerializer::class))
->attribute('isApproved', function ($serializer, Post $post) {
return (bool) $post->is_approved;
})->attribute('canApprove', function (PostSerializer $serializer, Post $post) {
return (bool) $serializer->getActor()->can('approvePosts', $post->discussion);
}),
(new Extend\ApiResource(Resource\PostResource::class))
->fields(PostResourceFields::class),
new Extend\Locales(__DIR__.'/locale'),

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.
*/
namespace Flarum\Approval\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Post\Post;
class PostResourceFields
{
public function __invoke(): array
{
return [
Schema\Boolean::make('isApproved')
->writable(fn (Post $post, Context $context) => $context->getActor()->can('approve', $post))
// set by the ApproveContent listener.
->set(fn () => null),
Schema\Boolean::make('canApprove')
->get(fn (Post $post, Context $context) => $context->getActor()->can('approvePosts', $post->discussion)),
];
}
}

View File

@@ -11,6 +11,7 @@ namespace Flarum\Approval\Listener;
use Flarum\Approval\Event\PostWasApproved;
use Flarum\Post\Event\Saving;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Contracts\Events\Dispatcher;
class ApproveContent
@@ -20,23 +21,42 @@ class ApproveContent
$events->listen(Saving::class, $this->approvePost(...));
}
/**
* @throws PermissionDeniedException
*/
public function approvePost(Saving $event): void
{
$attributes = $event->data['attributes'];
$post = $event->post;
// Nothing to do if it is already approved.
if ($post->is_approved) {
return;
}
/*
* We approve a post in one of two cases:
* - The post was unapproved and the allowed action is approving it. We trigger an event.
* - The post was unapproved and the allowed actor is hiding or un-hiding it.
* We approve it silently if the action is unhiding.
*/
$approvingSilently = false;
if (isset($attributes['isApproved'])) {
$event->actor->assertCan('approve', $post);
$isApproved = (bool) $attributes['isApproved'];
} elseif (! empty($attributes['isHidden']) && $event->actor->can('approve', $post)) {
} elseif (isset($attributes['isHidden']) && $event->actor->can('approve', $post)) {
$isApproved = true;
$approvingSilently = $attributes['isHidden'];
}
if (! empty($isApproved)) {
$post->is_approved = true;
$post->raise(new PostWasApproved($post, $event->actor));
if (! $approvingSilently) {
$post->raise(new PostWasApproved($post, $event->actor));
}
}
}
}

View File

@@ -10,19 +10,23 @@
namespace Flarum\Approval\Tests\integration;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Group\Group;
use Flarum\Post\Post;
use Flarum\User\User;
trait InteractsWithUnapprovedContent
{
protected function prepareUnapprovedDatabaseContent()
{
$this->prepareDatabase([
'users' => [
User::class => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
$this->normalUser(),
['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1],
['id' => 4, 'username' => 'luceos', 'email' => 'luceos@machine.local', 'is_email_confirmed' => 1],
],
'discussions' => [
Discussion::class => [
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 1, 'comment_count' => 1, 'is_approved' => 1, 'is_private' => 0],
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 2, 'comment_count' => 1, 'is_approved' => 0, 'is_private' => 1],
['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 3, 'comment_count' => 1, 'is_approved' => 0, 'is_private' => 1],
@@ -31,7 +35,7 @@ trait InteractsWithUnapprovedContent
['id' => 6, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 6, 'comment_count' => 1, 'is_approved' => 0, 'is_private' => 1],
['id' => 7, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 7, 'comment_count' => 1, 'is_approved' => 1, 'is_private' => 0],
],
'posts' => [
Post::class => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 2, 'discussion_id' => 2, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 3, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
@@ -45,7 +49,7 @@ trait InteractsWithUnapprovedContent
['id' => 10, 'discussion_id' => 7, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 4],
['id' => 11, 'discussion_id' => 7, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 1, 'is_approved' => 0, 'number' => 5],
],
'groups' => [
Group::class => [
['id' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0]
],
'group_user' => [

View File

@@ -0,0 +1,123 @@
<?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\Tests\integration\api;
use Carbon\Carbon;
use Flarum\Approval\Tests\integration\InteractsWithUnapprovedContent;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class ApprovePostsTest extends TestCase
{
use RetrievesAuthorizedUsers;
use InteractsWithUnapprovedContent;
protected function setUp(): void
{
parent::setUp();
$this->extension('flarum-approval');
$this->prepareDatabase([
'users' => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
$this->normalUser(),
['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1],
['id' => 4, 'username' => 'luceos', 'email' => 'luceos@machine.local', 'is_email_confirmed' => 1],
],
'discussions' => [
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 1, 'comment_count' => 1, 'is_approved' => 1],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 2, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => 0, 'is_approved' => 1, 'number' => 2],
['id' => 3, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => 0, 'is_approved' => 0, 'number' => 3],
['id' => 4, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => Carbon::now(), 'is_approved' => 1, 'number' => 4],
['id' => 5, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'hidden_at' => 0, 'is_approved' => 0, 'number' => 5],
],
'groups' => [
['id' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0],
['id' => 5, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0],
],
'group_user' => [
['user_id' => 3, 'group_id' => 4],
],
'group_permission' => [
['group_id' => 4, 'permission' => 'discussion.approvePosts'],
]
]);
}
/**
* @test
*/
public function can_approve_unapproved_post()
{
$response = $this->send(
$this->request('PATCH', '/api/posts/3', [
'authenticatedAs' => 3,
'json' => [
'data' => [
'attributes' => [
'isApproved' => true
]
]
]
])
);
$this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents());
$this->assertEquals(1, $this->database()->table('posts')->where('id', 3)->where('is_approved', 1)->count());
}
/**
* @test
*/
public function cannot_approve_post_without_permission()
{
$response = $this->send(
$this->request('PATCH', '/api/posts/3', [
'authenticatedAs' => 4,
'json' => [
'data' => [
'attributes' => [
'isApproved' => true
]
]
]
])
);
$this->assertEquals(403, $response->getStatusCode(), $response->getBody()->getContents());
$this->assertEquals(0, $this->database()->table('posts')->where('id', 3)->where('is_approved', 1)->count());
}
/**
* @test
*/
public function hiding_post_silently_approves_it()
{
$response = $this->send(
$this->request('PATCH', '/api/posts/5', [
'authenticatedAs' => 3,
'json' => [
'data' => [
'attributes' => [
'isHidden' => true
]
]
]
])
);
$this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents());
$this->assertEquals(1, $this->database()->table('posts')->where('id', 5)->where('is_approved', 1)->count());
}
}

View File

@@ -0,0 +1,153 @@
<?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\Tests\integration\api;
use Carbon\Carbon;
use Flarum\Approval\Tests\integration\InteractsWithUnapprovedContent;
use Flarum\Group\Group;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
class CreatePostsTest extends TestCase
{
use RetrievesAuthorizedUsers;
use InteractsWithUnapprovedContent;
protected function setUp(): void
{
parent::setUp();
$this->extension('flarum-flags', 'flarum-approval');
$this->prepareDatabase([
'users' => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
$this->normalUser(),
['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1],
['id' => 4, 'username' => 'luceos', 'email' => 'luceos@machine.local', 'is_email_confirmed' => 1],
],
'discussions' => [
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 1, 'comment_count' => 1, 'is_approved' => 1],
['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 2, 'comment_count' => 1, 'is_approved' => 0],
['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 3, 'comment_count' => 1, 'is_approved' => 0],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 2, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 2],
['id' => 3, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 3],
['id' => 4, 'discussion_id' => 2, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 5, 'discussion_id' => 2, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 2],
['id' => 6, 'discussion_id' => 2, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 3],
['id' => 7, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 1],
['id' => 8, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 1, 'number' => 2],
['id' => 9, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '<t><p>Text</p></t>', 'is_private' => 0, 'is_approved' => 0, 'number' => 3],
],
'groups' => [
['id' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0],
['id' => 5, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0],
],
'group_user' => [
['user_id' => 3, 'group_id' => 4],
['user_id' => 2, 'group_id' => 5],
],
'group_permission' => [
['group_id' => 4, 'permission' => 'discussion.startWithoutApproval'],
['group_id' => 5, 'permission' => 'discussion.replyWithoutApproval'],
]
]);
}
/**
* @dataProvider startDiscussionDataProvider
* @test
*/
public function can_start_discussion_without_approval_when_allowed(int $authenticatedAs, bool $allowed)
{
$this->database()->table('group_permission')->where('group_id', Group::MEMBER_ID)->where('permission', 'discussion.startWithoutApproval')->delete();
$response = $this->send(
$this->request('POST', '/api/discussions', [
'authenticatedAs' => $authenticatedAs,
'json' => [
'data' => [
'type' => 'discussions',
'attributes' => [
'title' => 'This is a new discussion',
'content' => 'This is a new discussion',
]
]
]
])
);
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
$this->assertEquals(201, $response->getStatusCode(), $body);
$this->assertEquals($allowed ? 1 : 0, $this->database()->table('discussions')->where('id', $json['data']['id'])->value('is_approved'));
}
/**
* @dataProvider replyToDiscussionDataProvider
* @test
*/
public function can_reply_without_approval_when_allowed(?int $authenticatedAs, bool $allowed)
{
$this->database()->table('group_permission')->where('group_id', Group::MEMBER_ID)->where('permission', 'discussion.replyWithoutApproval')->delete();
$response = $this->send(
$this->request('POST', '/api/posts', [
'authenticatedAs' => $authenticatedAs,
'json' => [
'data' => [
'type' => 'posts',
'attributes' => [
'content' => 'This is a new reply',
],
'relationships' => [
'discussion' => [
'data' => [
'type' => 'discussions',
'id' => 1
]
]
]
]
]
])
);
$body = $response->getBody()->getContents();
$json = json_decode($body, true);
$this->assertEquals(201, $response->getStatusCode(), $body);
$this->assertEquals($allowed ? 1 : 0, $this->database()->table('posts')->where('id', $json['data']['id'])->value('is_approved'));
}
public static function startDiscussionDataProvider(): array
{
return [
'Admin' => [1, true],
'User without permission' => [2, false],
'Permission Given' => [3, true],
'Another user without permission' => [4, false],
];
}
public static function replyToDiscussionDataProvider(): array
{
return [
'Admin' => [1, true],
'User without permission' => [3, false],
'Permission Given' => [2, true],
'Another user without permission' => [4, false],
];
}
}

View File

@@ -50,7 +50,7 @@
padding: 15px 15px;
.scrolled & {
.box-shadow(0 2px 6px @shadow-color);
box-shadow: 0 2px 6px var(--shadow-color);
}
}
@@ -69,7 +69,7 @@
margin: 0;
&, a {
color: @muted-color;
color: var(--muted-color);
}
}
}

View File

@@ -1,6 +0,0 @@
{
"printWidth": 150,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5"
}

2
extensions/emoji/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

@@ -1,47 +1,47 @@
import { extend } from 'flarum/common/extend';
import TextEditorButton from 'flarum/common/components/TextEditorButton';
import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable';
import Tooltip from 'flarum/common/components/Tooltip';
import AutocompleteReader from 'flarum/common/utils/AutocompleteReader';
import AutocompleteDropdown from './fragments/AutocompleteDropdown';
import getEmojiIconCode from './helpers/getEmojiIconCode';
import cdn from './cdn';
export default function addComposerAutocomplete() {
const $container = $('<div class="ComposerBody-emojiDropdownContainer"></div>');
const dropdown = new AutocompleteDropdown();
let emojiMap = null;
extend('flarum/common/components/TextEditor', 'oninit', function () {
this._loaders.push(async () => await import('./emojiMap').then((m) => (emojiMap = m.default)));
// prettier-ignore
this.commonEmoji = [
'😀', '😁', '😂', '😃', '😄', '😅', '😆', '😇', '😈', '😉', '😊', '😋', '😌', '😍', '😎', '😏', '😐️', '😑', '😒',
'😓', '😔', '😕', '😖', '😗', '😘', '😙', '😚', '😛', '😜', '😝', '😞', '😟', '😠', '😡', '😢', '😣', '😤', '😥',
'😦', '😧', '😨', '😩', '😪', '😫', '😬', '😭', '😮', '😮‍💨', '😯', '😰', '😱', '😲', '😳', '😴', '😵', '😵‍💫',
'😶', '😶‍🌫️', '😷', '😸', '😹', '😺', '😻', '😼', '😽', '😾', '😿', '🙀', '🙁', '🙂', '🙃', '🙄',
];
});
extend('flarum/common/components/TextEditor', 'onbuild', function () {
this.emojiDropdown = new AutocompleteDropdown();
const $editor = this.$('.TextEditor-editor').wrap('<div class="ComposerBody-emojiWrapper"></div>');
this.navigator = new KeyboardNavigatable();
this.navigator
.when(() => dropdown.active)
.onUp(() => dropdown.navigate(-1))
.onDown(() => dropdown.navigate(1))
.onSelect(dropdown.complete.bind(dropdown))
.onCancel(dropdown.hide.bind(dropdown))
.when(() => this.emojiDropdown.active)
.onUp(() => this.emojiDropdown.navigate(-1))
.onDown(() => this.emojiDropdown.navigate(1))
.onSelect(this.emojiDropdown.complete.bind(this.emojiDropdown))
.onCancel(this.emojiDropdown.hide.bind(this.emojiDropdown))
.bindTo($editor);
$editor.after($container);
$editor.after($('<div class="ComposerBody-emojiDropdownContainer"></div>'));
});
extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) {
const emojiKeys = Object.keys(emojiMap);
let relEmojiStart;
let absEmojiStart;
let typed;
const applySuggestion = (replacement) => {
this.attrs.composer.editor.replaceBeforeCursor(absEmojiStart - 1, replacement + ' ');
dropdown.hide();
};
const autocompleteReader = new AutocompleteReader(':');
params.inputListeners.push(() => {
const selection = this.attrs.composer.editor.getSelectionRange();
@@ -50,42 +50,34 @@ export default function addComposerAutocomplete() {
if (selection[1] - cursor > 0) return;
// Search backwards from the cursor for an ':' symbol. If we find
// one and followed by a whitespace, we will want to show the
// autocomplete dropdown!
const lastChunk = this.attrs.composer.editor.getLastNChars(15);
absEmojiStart = 0;
for (let i = lastChunk.length - 1; i >= 0; i--) {
const character = lastChunk.substr(i, 1);
// check what user typed, emoji names only contains alphanumeric,
// underline, '+' and '-'
if (!/[a-z0-9]|\+|\-|_|\:/.test(character)) break;
// make sure ':' preceded by a whitespace or newline
if (character === ':' && (i == 0 || /\s/.test(lastChunk.substr(i - 1, 1)))) {
relEmojiStart = i + 1;
absEmojiStart = cursor - lastChunk.length + i + 1;
break;
}
}
const autocompleting = autocompleteReader.check(lastChunk, cursor, /[a-z0-9]|\+|\-|_|\:/);
dropdown.hide();
dropdown.active = false;
this.emojiDropdown.hide();
this.emojiDropdown.active = false;
if (absEmojiStart) {
typed = lastChunk.substring(relEmojiStart).toLowerCase();
if (autocompleting) {
const typed = autocompleting.typed;
const emojiDropdown = this.emojiDropdown;
const applySuggestion = (replacement) => {
this.attrs.composer.editor.replaceBeforeCursor(autocompleting.absoluteStart - 1, replacement + ' ');
this.emojiDropdown.hide();
};
const makeSuggestion = function ({ emoji, name, code }) {
return (
<button
key={emoji}
onclick={() => applySuggestion(emoji)}
onmouseenter={function () {
dropdown.setIndex($(this).parent().index() - 1);
}}
>
<img alt={emoji} className="emoji" draggable="false" loading="lazy" src={`${cdn}72x72/${code}.png`} />
{name}
</button>
<Tooltip text={name}>
<button
key={emoji}
onclick={() => applySuggestion(emoji)}
onmouseenter={function () {
emojiDropdown.setIndex($(this).parent().index() - 1);
}}
>
<img alt={emoji} className="emoji" draggable="false" loading="lazy" src={`${cdn}72x72/${code}.png`} title={name} />
</button>
</Tooltip>
);
};
@@ -99,7 +91,7 @@ export default function addComposerAutocomplete() {
};
const regTyped = fuzzyRegexp(typed);
let maxSuggestions = 7;
let maxSuggestions = 40;
const findMatchingEmojis = (matcher) => {
for (let i = 0; i < emojiKeys.length && maxSuggestions > 0; i++) {
@@ -108,7 +100,7 @@ export default function addComposerAutocomplete() {
if (similarEmoji.indexOf(curEmoji) === -1) {
const names = emojiMap[curEmoji];
for (let name of names) {
if (matcher(name)) {
if (matcher(name, curEmoji)) {
--maxSuggestions;
similarEmoji.push(curEmoji);
break;
@@ -119,10 +111,17 @@ export default function addComposerAutocomplete() {
};
// First, try to find all emojis starting with the given string
findMatchingEmojis((emoji) => emoji.indexOf(typed) === 0);
findMatchingEmojis((emojiName, emoji) => {
// If no input is provided yet, match the most common emojis.
if (!typed) {
return this.commonEmoji?.includes(emoji);
}
return emojiName.indexOf(typed) === 0;
});
// If there are still suggestions left, try for some fuzzy matches
findMatchingEmojis((emoji) => regTyped.test(emoji));
findMatchingEmojis((emojiName) => regTyped.test(emojiName));
const suggestions = similarEmoji
.map((emoji) => ({
@@ -133,14 +132,14 @@ export default function addComposerAutocomplete() {
.map(makeSuggestion);
if (suggestions.length) {
dropdown.items = suggestions;
m.render($container[0], dropdown.render());
this.emojiDropdown.items = suggestions;
m.render(this.$('.ComposerBody-emojiDropdownContainer')[0], this.emojiDropdown.render());
dropdown.show();
const coordinates = this.attrs.composer.editor.getCaretCoordinates(absEmojiStart);
const width = dropdown.$().outerWidth();
const height = dropdown.$().outerHeight();
const parent = dropdown.$().offsetParent();
this.emojiDropdown.show();
const coordinates = this.attrs.composer.editor.getCaretCoordinates(autocompleting.absoluteStart);
const width = this.emojiDropdown.$().outerWidth();
const height = this.emojiDropdown.$().outerHeight();
const parent = this.emojiDropdown.$().offsetParent();
let left = coordinates.left;
let top = coordinates.top + 15;
@@ -156,15 +155,15 @@ export default function addComposerAutocomplete() {
top = Math.max(-(parent.offset().top - $(document).scrollTop()), top);
left = Math.max(-parent.offset().left, left);
dropdown.show(left, top);
this.emojiDropdown.show(left, top);
}
};
buildSuggestions();
dropdown.setIndex(0);
dropdown.$().scrollTop(0);
dropdown.active = true;
this.emojiDropdown.setIndex(0);
this.emojiDropdown.$().scrollTop(0);
this.emojiDropdown.active = true;
}
});
});

View File

@@ -7,29 +7,28 @@ img.emoji {
.EmojiDropdown {
max-width: 500px;
max-height: 200px;
overflow: auto;
overflow: visible;
position: absolute;
margin: 5px 0 !important;
padding: 8px;
> li > button {
color: @text-color;
font-weight: bold;
padding-top: 6px;
padding-bottom: 6px;
padding-left: 45px;
> li {
display: inline-block;
.emoji {
float: left;
margin-left: -30px;
> button {
color: var(--text-color);
font-weight: bold;
padding: 8px;
border-radius: var(--border-radius);
}
}
.Dropdown-header {
color: @muted-more-color;
> .Dropdown-header {
display: block;
color: var(--muted-more-color);
text-transform: none;
font-weight: normal;
padding-bottom: 5px;
font-size: 11px;
padding: 4px 8px;
margin: 0 0 4px 0;
}
}

View File

@@ -7,25 +7,17 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Api\Controller\AbstractSerializeController;
use Flarum\Api\Controller\ListPostsController;
use Flarum\Api\Controller\ShowDiscussionController;
use Flarum\Api\Controller\ShowPostController;
use Flarum\Api\Serializer\CurrentUserSerializer;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource;
use Flarum\Extend;
use Flarum\Flags\Access\ScopeFlagVisibility;
use Flarum\Flags\AddCanFlagAttribute;
use Flarum\Flags\AddFlagsApiAttributes;
use Flarum\Flags\AddNewFlagCountAttribute;
use Flarum\Flags\Api\Controller\CreateFlagController;
use Flarum\Flags\Api\Controller\DeleteFlagsController;
use Flarum\Flags\Api\Controller\ListFlagsController;
use Flarum\Flags\Api\Serializer\FlagSerializer;
use Flarum\Flags\Api\ForumResourceFields;
use Flarum\Flags\Api\PostResourceFields;
use Flarum\Flags\Api\Resource\FlagResource;
use Flarum\Flags\Api\UserResourceFields;
use Flarum\Flags\Flag;
use Flarum\Flags\Listener;
use Flarum\Flags\PrepareFlagsApiData;
use Flarum\Forum\Content\AssertRegistered;
use Flarum\Post\Event\Deleted;
use Flarum\Post\Post;
@@ -41,8 +33,6 @@ return [
->js(__DIR__.'/js/dist/admin.js'),
(new Extend\Routes('api'))
->get('/flags', 'flags.index', ListFlagsController::class)
->post('/flags', 'flags.create', CreateFlagController::class)
->delete('/posts/{id}/flags', 'flags.delete', DeleteFlagsController::class),
(new Extend\Model(User::class))
@@ -51,27 +41,26 @@ return [
(new Extend\Model(Post::class))
->hasMany('flags', Flag::class, 'post_id'),
(new Extend\ApiSerializer(PostSerializer::class))
->hasMany('flags', FlagSerializer::class)
->attribute('canFlag', AddCanFlagAttribute::class),
new Extend\ApiResource(FlagResource::class),
(new Extend\ApiSerializer(CurrentUserSerializer::class))
->attribute('newFlagCount', AddNewFlagCountAttribute::class),
(new Extend\ApiResource(Resource\PostResource::class))
->fields(PostResourceFields::class),
(new Extend\ApiSerializer(ForumSerializer::class))
->attributes(AddFlagsApiAttributes::class),
(new Extend\ApiResource(Resource\UserResource::class))
->fields(UserResourceFields::class),
(new Extend\ApiController(ShowDiscussionController::class))
->addInclude(['posts.flags', 'posts.flags.user']),
(new Extend\ApiResource(Resource\ForumResource::class))
->fields(ForumResourceFields::class),
(new Extend\ApiController(ListPostsController::class))
->addInclude(['flags', 'flags.user']),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint) {
return $endpoint->addDefaultInclude(['posts.flags', 'posts.flags.user']);
}),
(new Extend\ApiController(ShowPostController::class))
->addInclude(['flags', 'flags.user']),
(new Extend\ApiController(AbstractSerializeController::class))
->prepareDataForSerialization(PrepareFlagsApiData::class),
(new Extend\ApiResource(Resource\PostResource::class))
->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint) {
return $endpoint->addDefaultInclude(['flags', 'flags.user']);
}),
(new Extend\Settings())
->serializeToForum('guidelinesUrl', 'flarum-flags.guidelines_url'),

View File

@@ -1,7 +1,12 @@
export default class FlagList extends Component<import("flarum/common/Component").ComponentAttrs, undefined> {
constructor();
oninit(vnode: any): void;
state: any;
view(): JSX.Element;
import Component from 'flarum/common/Component';
import type { ComponentAttrs } from 'flarum/common/Component';
import type Mithril from 'mithril';
import type FlagListState from '../states/FlagListState';
export interface IFlagListAttrs extends ComponentAttrs {
state: FlagListState;
}
export default class FlagList<CustomAttrs extends IFlagListAttrs = IFlagListAttrs> extends Component<CustomAttrs, FlagListState> {
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
view(): JSX.Element;
content(state: FlagListState): JSX.Element[][] | null;
}
import Component from "flarum/common/Component";

View File

@@ -1,5 +1,5 @@
/// <reference types="flarum/@types/translator-icu-rich" />
export default class FlagPostModal extends Modal<import("flarum/common/components/Modal").IInternalModalAttrs, undefined> {
export default class FlagPostModal extends FormModal<import("flarum/common/components/FormModal").IFormModalAttrs, undefined> {
constructor();
oninit(vnode: any): void;
success: boolean | undefined;
@@ -10,6 +10,6 @@ export default class FlagPostModal extends Modal<import("flarum/common/component
flagReasons(): ItemList<any>;
onsubmit(e: any): void;
}
import Modal from "flarum/common/components/Modal";
import FormModal from "flarum/common/components/FormModal";
import Stream from "flarum/common/utils/Stream";
import ItemList from "flarum/common/utils/ItemList";

View File

@@ -1,7 +1,12 @@
export default class FlagsDropdown extends NotificationsDropdown<import("flarum/common/components/Dropdown").IDropdownAttrs> {
static initAttrs(attrs: any): void;
constructor();
getUnreadCount(): any;
getNewCount(): unknown;
/// <reference types="mithril" />
import HeaderDropdown from 'flarum/forum/components/HeaderDropdown';
import type { IHeaderDropdownAttrs } from 'flarum/forum/components/HeaderDropdown';
export interface IFlagsDropdownAttrs extends IHeaderDropdownAttrs {
}
export default class FlagsDropdown<CustomAttrs extends IFlagsDropdownAttrs = IFlagsDropdownAttrs> extends HeaderDropdown<CustomAttrs> {
static initAttrs(attrs: IFlagsDropdownAttrs): void;
getContent(): JSX.Element;
goToRoute(): void;
getUnreadCount(): number;
getNewCount(): number;
}
import NotificationsDropdown from "flarum/forum/components/NotificationsDropdown";

View File

@@ -1,16 +1,13 @@
export default class FlagListState {
constructor(app: any);
app: any;
/**
* Whether or not the flags are loading.
*
* @type {Boolean}
*/
loading: boolean;
import type ForumApplication from 'flarum/forum/ForumApplication';
import type Flag from '../models/Flag';
import PaginatedListState from 'flarum/common/states/PaginatedListState';
export default class FlagListState extends PaginatedListState<Flag> {
app: ForumApplication;
constructor(app: ForumApplication);
get type(): string;
/**
* Load flags into the application's cache if they haven't already
* been loaded.
*/
load(): void;
cache: any;
load(): Promise<void>;
}

2
extensions/flags/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

@@ -1,4 +1,4 @@
import { extend } from 'flarum/common/extend';
import { extend, override } from 'flarum/common/extend';
import app from 'flarum/forum/app';
import Post from 'flarum/forum/components/Post';
import Button from 'flarum/common/components/Button';
@@ -54,7 +54,7 @@ export default function () {
const controls = PostControls.destructiveControls(this.attrs.post);
Object.keys(controls.items).forEach((k) => {
Object.keys(controls.toObject()).forEach((k) => {
const attrs = controls.get(k).attrs;
attrs.className = 'Button';
@@ -75,7 +75,7 @@ export default function () {
return items;
};
extend(Post.prototype, 'content', function (vdom) {
override(Post.prototype, 'header', function (vdom) {
const post = this.attrs.post;
const flags = post.flags();
@@ -83,7 +83,7 @@ export default function () {
if (post.isHidden()) this.revealContent = true;
vdom.unshift(
return (
<div className="Post-flagged">
<div className="Post-flagged-flags">
{flags.map((flag) => (

View File

@@ -1,65 +0,0 @@
import app from 'flarum/forum/app';
import Component from 'flarum/common/Component';
import Link from 'flarum/common/components/Link';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import avatar from 'flarum/common/helpers/avatar';
import username from 'flarum/common/helpers/username';
import icon from 'flarum/common/helpers/icon';
import humanTime from 'flarum/common/helpers/humanTime';
export default class FlagList extends Component {
oninit(vnode) {
super.oninit(vnode);
this.state = this.attrs.state;
}
view() {
const flags = this.state.cache || [];
return (
<div className="NotificationList FlagList">
<div className="NotificationList-header">
<h4 className="App-titleControl App-titleControl--text">{app.translator.trans('flarum-flags.forum.flagged_posts.title')}</h4>
</div>
<div className="NotificationList-content">
<ul className="NotificationGroup-content">
{flags.length ? (
flags.map((flag) => {
const post = flag.post();
return (
<li>
<Link
href={app.route.post(post)}
className="Notification Flag"
onclick={(e) => {
app.flags.index = post;
e.redraw = false;
}}
>
{avatar(post.user())}
{icon('fas fa-flag', { className: 'Notification-icon' })}
<span className="Notification-content">
{app.translator.trans('flarum-flags.forum.flagged_posts.item_text', {
username: username(post.user()),
em: <em />,
discussion: post.discussion().title(),
})}
</span>
{humanTime(flag.createdAt())}
<div className="Notification-excerpt">{post.contentPlain()}</div>
</Link>
</li>
);
})
) : !this.state.loading ? (
<div className="NotificationList-empty">{app.translator.trans('flarum-flags.forum.flagged_posts.empty_text')}</div>
) : (
<LoadingIndicator className="LoadingIndicator--block" />
)}
</ul>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,72 @@
import app from 'flarum/forum/app';
import Component from 'flarum/common/Component';
import type { ComponentAttrs } from 'flarum/common/Component';
import Avatar from 'flarum/common/components/Avatar';
import username from 'flarum/common/helpers/username';
import HeaderList from 'flarum/forum/components/HeaderList';
import HeaderListItem from 'flarum/forum/components/HeaderListItem';
import type Mithril from 'mithril';
import type Post from 'flarum/common/models/Post';
import type FlagListState from '../states/FlagListState';
import type Flag from '../models/Flag';
import { Page } from 'flarum/common/states/PaginatedListState';
export interface IFlagListAttrs extends ComponentAttrs {
state: FlagListState;
}
export default class FlagList<CustomAttrs extends IFlagListAttrs = IFlagListAttrs> extends Component<CustomAttrs, FlagListState> {
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
}
view() {
const state = this.attrs.state;
return (
<HeaderList
className="FlagList"
title={app.translator.trans('flarum-flags.forum.flagged_posts.title')}
hasItems={state.hasItems()}
loading={state.isLoading()}
emptyText={app.translator.trans('flarum-flags.forum.flagged_posts.empty_text')}
loadMore={() => state.hasNext() && !state.isLoadingNext() && state.loadNext()}
>
<ul className="HeaderListGroup-content">{this.content(state)}</ul>
</HeaderList>
);
}
content(state: FlagListState) {
if (!state.isLoading() && state.hasItems()) {
return state.getPages().map((page: Page<Flag>) => {
return page.items.map((flag: Flag) => {
const post = flag.post() as Post;
return (
<li>
<HeaderListItem
className="Flag"
avatar={<Avatar user={post.user() || null} />}
icon="fas fa-flag"
content={app.translator.trans('flarum-flags.forum.flagged_posts.item_text', {
username: username(post.user()),
em: <em />,
discussion: post.discussion().title(),
})}
excerpt={post.contentPlain()}
datetime={flag.createdAt()}
href={app.route.post(post)}
onclick={(e: MouseEvent) => {
e.redraw = false;
}}
/>
</li>
);
});
});
}
return null;
}
}

View File

@@ -1,12 +1,13 @@
import app from 'flarum/forum/app';
import Modal from 'flarum/common/components/Modal';
import FormModal from 'flarum/common/components/FormModal';
import Form from 'flarum/common/components/Form';
import Button from 'flarum/common/components/Button';
import Stream from 'flarum/common/utils/Stream';
import withAttr from 'flarum/common/utils/withAttr';
import ItemList from 'flarum/common/utils/ItemList';
export default class FlagPostModal extends Modal {
export default class FlagPostModal extends FormModal {
oninit(vnode) {
super.oninit(vnode);
@@ -28,31 +29,31 @@ export default class FlagPostModal extends Modal {
if (this.success) {
return (
<div className="Modal-body">
<div className="Form Form--centered">
<Form className="Form--centered">
<p className="helpText">{app.translator.trans('flarum-flags.forum.flag_post.confirmation_message')}</p>
<div className="Form-group">
<div className="Form-group Form-controls">
<Button className="Button Button--primary Button--block" onclick={this.hide.bind(this)}>
{app.translator.trans('flarum-flags.forum.flag_post.dismiss_button')}
</Button>
</div>
</div>
</Form>
</div>
);
}
return (
<div className="Modal-body">
<div className="Form Form--centered">
<Form className="Form--centered">
<div className="Form-group">
<div>{this.flagReasons().toArray()}</div>
</div>
<div className="Form-group">
<div className="Form-group Form-controls">
<Button className="Button Button--primary Button--block" type="submit" loading={this.loading} disabled={!this.reason()}>
{app.translator.trans('flarum-flags.forum.flag_post.submit_button')}
</Button>
</div>
</div>
</Form>
</div>
);
}
@@ -150,7 +151,6 @@ export default class FlagPostModal extends Modal {
reason: this.reason() === 'other' ? null : this.reason(),
reasonDetail: this.reasonDetail(),
relationships: {
user: app.session.user,
post: this.attrs.post,
},
},

View File

@@ -1,33 +0,0 @@
import app from 'flarum/forum/app';
import NotificationsDropdown from 'flarum/forum/components/NotificationsDropdown';
import FlagList from './FlagList';
export default class FlagsDropdown extends NotificationsDropdown {
static initAttrs(attrs) {
attrs.label = attrs.label || app.translator.trans('flarum-flags.forum.flagged_posts.tooltip');
attrs.icon = attrs.icon || 'fas fa-flag';
super.initAttrs(attrs);
}
getMenu() {
return (
<div className={'Dropdown-menu ' + this.attrs.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing && <FlagList state={this.attrs.state} />}
</div>
);
}
goToRoute() {
m.route.set(app.route('flags'));
}
getUnreadCount() {
return app.flags.cache ? app.flags.cache.length : app.forum.attribute('flagCount');
}
getNewCount() {
return app.session.user.attribute('newFlagCount');
}
}

View File

@@ -0,0 +1,34 @@
import app from 'flarum/forum/app';
import HeaderDropdown from 'flarum/forum/components/HeaderDropdown';
import type { IHeaderDropdownAttrs } from 'flarum/forum/components/HeaderDropdown';
import classList from 'flarum/common/utils/classList';
import FlagList from './FlagList';
export interface IFlagsDropdownAttrs extends IHeaderDropdownAttrs {}
export default class FlagsDropdown<CustomAttrs extends IFlagsDropdownAttrs = IFlagsDropdownAttrs> extends HeaderDropdown<CustomAttrs> {
static initAttrs(attrs: IFlagsDropdownAttrs) {
attrs.className = classList('FlagsDropdown', attrs.className);
attrs.label = attrs.label || app.translator.trans('flarum-flags.forum.flagged_posts.tooltip');
attrs.icon = attrs.icon || 'fas fa-flag';
super.initAttrs(attrs);
}
getContent() {
return <FlagList state={this.attrs.state} />;
}
goToRoute() {
m.route.set(app.route('flags'));
}
getUnreadCount() {
return app.forum.attribute<number>('flagCount');
}
getNewCount() {
return app.session.user!.attribute<number>('newFlagCount');
}
}

View File

@@ -1,37 +0,0 @@
export default class FlagListState {
constructor(app) {
this.app = app;
/**
* Whether or not the flags are loading.
*
* @type {Boolean}
*/
this.loading = false;
}
/**
* Load flags into the application's cache if they haven't already
* been loaded.
*/
load() {
if (this.cache && !this.app.session.user.attribute('newFlagCount')) {
return;
}
this.loading = true;
m.redraw();
this.app.store
.find('flags')
.then((flags) => {
this.app.session.user.pushAttributes({ newFlagCount: 0 });
this.cache = flags.sort((a, b) => b.createdAt() - a.createdAt());
})
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
}

View File

@@ -0,0 +1,33 @@
import type ForumApplication from 'flarum/forum/ForumApplication';
import type Flag from '../models/Flag';
import PaginatedListState from 'flarum/common/states/PaginatedListState';
export default class FlagListState extends PaginatedListState<Flag> {
public app: ForumApplication;
constructor(app: ForumApplication) {
super({}, 1, null);
this.app = app;
}
get type(): string {
return 'flags';
}
/**
* Load flags into the application's cache if they haven't already
* been loaded.
*/
load(): Promise<void> {
if (this.app.session.user?.attribute<number>('newFlagCount')) {
this.pages = [];
this.location = { page: 1 };
}
if (this.pages.length > 0) {
return Promise.resolve();
}
return super.loadNext();
}
}

View File

@@ -1,6 +1,9 @@
.Post--flagged {
--border-width: 2px;
padding-top: 0 !important;
border: 2px solid @primary-color;
padding-left: var(--post-padding);
margin-left: calc(~"0px - var(--post-padding)");
border: var(--border-width) solid var(--primary-color);
}
.Post-header .item-flagged {
@@ -8,29 +11,22 @@
margin: 0;
}
.Post-flagged {
background: @primary-color;
margin-top: -2px;
margin-bottom: 20px;
margin-left: -22px;
margin-right: -22px;
background: var(--primary-color);
margin: calc(~"0px - var(--border-width)") calc(~"0px - var(--border-width) - var(--post-padding)") var(--post-padding);
padding: 10px;
border-radius: @border-radius @border-radius 0 0;
border-radius: var(--border-radius) var(--border-radius) 0 0;
overflow: hidden;
.light-contents(@color: @body-bg; @control-color: @body-bg);
@media @tablet-up {
margin-left: -22px - 85px;
}
display: flex;
align-items: center;
justify-content: space-between;
&, a {
color: @body-bg !important;
color: var(--body-bg) !important;
}
}
.Post-flagged-flags {
@media @tablet-up {
float: left;
}
font-size: 14px;
margin: 7px 10px;
text-align: left;
@@ -42,19 +38,10 @@
font-weight: normal;
}
.Post-flagged-actions {
@media @tablet-up {
float: right;
}
}
.Post-flagged-actions .Button {
margin-left: 5px;
}
.FlagsDropdown .Dropdown-toggle {
.Button-label,
.Button-caret {
display: none;
}
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
}
.FlagPostModal {
@@ -66,7 +53,16 @@
strong {
display: block;
color: @text-color;
color: var(--text-color);
}
}
}
.Flag .HeaderListItem-title {
justify-content: space-between;
flex-wrap: nowrap;
}
.Flag .HeaderListItem-time {
flex-shrink: 0;
}

View File

@@ -10,7 +10,6 @@
namespace Flarum\Flags\Access;
use Flarum\Extension\ExtensionManager;
use Flarum\Tags\Tag;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
@@ -23,31 +22,26 @@ class ScopeFlagVisibility
public function __invoke(User $actor, Builder $query): void
{
if ($this->extensions->isEnabled('flarum-tags')) {
$query
->select('flags.*')
->leftJoin('posts', 'posts.id', '=', 'flags.post_id')
->leftJoin('discussions', 'discussions.id', '=', 'posts.discussion_id')
->whereNotExists(function ($query) use ($actor) {
return $query->selectRaw('1')
->from('discussion_tag')
->whereNotIn('tag_id', function ($query) use ($actor) {
Tag::query()->setQuery($query->from('tags'))->whereHasPermission($actor, 'discussion.viewFlags')->select('tags.id');
})
->whereColumn('discussions.id', 'discussion_id');
});
$query
->whereHas('post', function (Builder $query) use ($actor) {
$query->whereVisibleTo($actor);
})
->where(function (Builder $query) use ($actor) {
if ($this->extensions->isEnabled('flarum-tags')) {
$query
->select('flags.*')
->whereHas('post.discussion.tags', function ($query) use ($actor) {
$query->whereHasPermission($actor, 'discussion.viewFlags');
});
if (! $actor->hasPermission('discussion.viewFlags')) {
$query->whereExists(function ($query) {
return $query->selectRaw('1')
->from('discussion_tag')
->whereColumn('discussions.id', 'discussion_id');
});
}
}
if ($actor->hasPermission('discussion.viewFlags')) {
$query->orWhereDoesntHave('post.discussion.tags');
}
}
if (! $actor->hasPermission('discussion.viewFlags')) {
$query->orWhere('flags.user_id', $actor->id);
}
if (! $actor->hasPermission('discussion.viewFlags')) {
$query->orWhere('flags.user_id', $actor->id);
}
});
}
}

View File

@@ -1,38 +0,0 @@
<?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\Flags;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Post\Post;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
class AddCanFlagAttribute
{
public function __construct(
protected SettingsRepositoryInterface $settings
) {
}
public function __invoke(PostSerializer $serializer, Post $post): bool
{
return $serializer->getActor()->can('flag', $post) && $this->checkFlagOwnPostSetting($serializer->getActor(), $post);
}
protected function checkFlagOwnPostSetting(User $actor, Post $post): bool
{
if ($actor->id === $post->user_id) {
// If $actor is the post author, check to see if the setting is enabled
return (bool) $this->settings->get('flarum-flags.can_flag_own');
}
// $actor is not the post author
return true;
}
}

View File

@@ -1,40 +0,0 @@
<?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\Flags;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
class AddFlagsApiAttributes
{
public function __construct(
protected SettingsRepositoryInterface $settings
) {
}
public function __invoke(ForumSerializer $serializer): array
{
$attributes = [
'canViewFlags' => $serializer->getActor()->hasPermissionLike('discussion.viewFlags')
];
if ($attributes['canViewFlags']) {
$attributes['flagCount'] = (int) $this->getFlagCount($serializer->getActor());
}
return $attributes;
}
protected function getFlagCount(User $actor): int
{
return Flag::whereVisibleTo($actor)->distinct()->count('flags.post_id');
}
}

View File

@@ -1,32 +0,0 @@
<?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\Flags;
use Flarum\Api\Serializer\CurrentUserSerializer;
use Flarum\User\User;
class AddNewFlagCountAttribute
{
public function __invoke(CurrentUserSerializer $serializer, User $user): int
{
return $this->getNewFlagCount($user);
}
protected function getNewFlagCount(User $actor): int
{
$query = Flag::whereVisibleTo($actor);
if ($time = $actor->read_flags_at) {
$query->where('flags.created_at', '>', $time);
}
return $query->distinct()->count('flags.post_id');
}
}

View File

@@ -1,43 +0,0 @@
<?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\Flags\Api\Controller;
use Flarum\Api\Controller\AbstractCreateController;
use Flarum\Flags\Api\Serializer\FlagSerializer;
use Flarum\Flags\Command\CreateFlag;
use Flarum\Flags\Flag;
use Flarum\Http\RequestUtil;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class CreateFlagController extends AbstractCreateController
{
public ?string $serializer = FlagSerializer::class;
public array $include = [
'post',
'post.flags',
'user'
];
public function __construct(
protected Dispatcher $bus
) {
}
protected function data(ServerRequestInterface $request, Document $document): Flag
{
return $this->bus->dispatch(
new CreateFlag(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data', []))
);
}
}

View File

@@ -1,54 +0,0 @@
<?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\Flags\Api\Controller;
use Carbon\Carbon;
use Flarum\Api\Controller\AbstractListController;
use Flarum\Flags\Api\Serializer\FlagSerializer;
use Flarum\Flags\Flag;
use Flarum\Http\RequestUtil;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListFlagsController extends AbstractListController
{
public ?string $serializer = FlagSerializer::class;
public array $include = [
'user',
'post',
'post.user',
'post.discussion'
];
protected function data(ServerRequestInterface $request, Document $document): iterable
{
$actor = RequestUtil::getActor($request);
$include = $this->extractInclude($request);
$actor->assertRegistered();
$actor->read_flags_at = Carbon::now();
$actor->save();
$flags = Flag::whereVisibleTo($actor)
->latest('flags.created_at')
->groupBy('post_id')
->get();
if (in_array('post.user', $include)) {
$include[] = 'post.user.groups';
}
$this->loadRelations($flags, $include);
return $flags;
}
}

View File

@@ -0,0 +1,32 @@
<?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\Flags\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Flags\Flag;
class ForumResourceFields
{
public function __invoke(): array
{
return [
Schema\Boolean::make('canViewFlags')
->get(function (object $model, Context $context) {
return $context->getActor()->hasPermissionLike('discussion.viewFlags');
}),
Schema\Integer::make('flagCount')
->visible(fn (object $model, Context $context) => $context->getActor()->hasPermissionLike('discussion.viewFlags'))
->get(function (object $model, Context $context) {
return Flag::whereVisibleTo($context->getActor())->distinct()->count('flags.post_id');
}),
];
}
}

View File

@@ -0,0 +1,42 @@
<?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\Flags\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Post\Post;
use Flarum\Settings\SettingsRepositoryInterface;
class PostResourceFields
{
public function __construct(
protected SettingsRepositoryInterface $settings
) {
}
public function __invoke(): array
{
return [
Schema\Boolean::make('canFlag')
->get(function (Post $post, Context $context) {
$actor = $context->getActor();
return $actor->can('flag', $post) && (
// $actor is not the post author
$actor->id !== $post->user_id
// If $actor is the post author, check to see if the setting is enabled
|| ((bool) $this->settings->get('flarum-flags.can_flag_own'))
);
}),
Schema\Relationship\ToMany::make('flags')
->includable(),
];
}
}

View File

@@ -0,0 +1,165 @@
<?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\Flags\Api\Resource;
use Carbon\Carbon;
use Flarum\Api\Context as FlarumContext;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource\AbstractDatabaseResource;
use Flarum\Api\Schema;
use Flarum\Api\Sort\SortColumn;
use Flarum\Flags\Event\Created;
use Flarum\Flags\Flag;
use Flarum\Http\Exception\InvalidParameterException;
use Flarum\Locale\TranslatorInterface;
use Flarum\Post\CommentPost;
use Flarum\Post\Post;
use Flarum\Post\PostRepository;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Tobyz\JsonApiServer\Context;
/**
* @extends AbstractDatabaseResource<Flag>
*/
class FlagResource extends AbstractDatabaseResource
{
public function __construct(
protected PostRepository $posts,
protected TranslatorInterface $translator,
protected SettingsRepositoryInterface $settings,
) {
}
public function type(): string
{
return 'flags';
}
public function model(): string
{
return Flag::class;
}
public function query(Context $context): object
{
if ($context->listing(self::class)) {
$query = Flag::query()->groupBy('post_id');
$this->scope($query, $context);
return $query;
}
return parent::query($context);
}
public function scope(Builder $query, Context $context): void
{
$query->whereVisibleTo($context->getActor());
}
public function newModel(Context $context): object
{
if ($context->creating(self::class)) {
Flag::unguard();
return Flag::query()->firstOrNew([
'post_id' => (int) Arr::get($context->body(), 'data.relationships.post.data.id'),
'user_id' => $context->getActor()->id
], [
'type' => 'user',
]);
}
return parent::newModel($context);
}
public function endpoints(): array
{
return [
Endpoint\Create::make()
->authenticated()
->defaultInclude(['post', 'post.flags', 'user']),
Endpoint\Index::make()
->authenticated()
->defaultInclude(['user', 'post', 'post.user', 'post.discussion'])
->defaultSort('-createdAt')
->paginate()
->after(function (FlarumContext $context, $data) {
$actor = $context->getActor();
$actor->read_flags_at = Carbon::now();
$actor->save();
return $data;
}),
];
}
public function fields(): array
{
return [
Schema\Str::make('type'),
Schema\Str::make('reason')
->writableOnCreate()
->nullable()
->requiredOnCreateWithout(['reasonDetail'])
->validationMessages([
'reason.required_without' => $this->translator->trans('flarum-flags.forum.flag_post.reason_missing_message'),
]),
Schema\Str::make('reasonDetail')
->writableOnCreate()
->nullable()
->requiredOnCreateWithout(['reason'])
->validationMessages([
'reasonDetail.required_without' => $this->translator->trans('flarum-flags.forum.flag_post.reason_missing_message'),
]),
Schema\DateTime::make('createdAt'),
Schema\Relationship\ToOne::make('post')
->includable()
->writable(fn (Flag $flag, FlarumContext $context) => $context->creating())
->set(function (Flag $flag, Post $post, FlarumContext $context) {
if (! ($post instanceof CommentPost)) {
throw new InvalidParameterException;
}
$actor = $context->getActor();
$actor->assertCan('flag', $post);
if ($actor->id === $post->user_id && ! $this->settings->get('flarum-flags.can_flag_own')) {
throw new PermissionDeniedException;
}
$flag->post_id = $post->id;
}),
Schema\Relationship\ToOne::make('user')
->includable(),
];
}
public function sorts(): array
{
return [
SortColumn::make('createdAt'),
];
}
public function created(object $model, Context $context): ?object
{
$this->events->dispatch(new Created($model, $context->getActor(), $context->body()));
return parent::created($model, $context);
}
}

View File

@@ -1,48 +0,0 @@
<?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\Flags\Api\Serializer;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\Api\Serializer\BasicUserSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Flags\Flag;
use InvalidArgumentException;
use Tobscure\JsonApi\Relationship;
class FlagSerializer extends AbstractSerializer
{
protected $type = 'flags';
protected function getDefaultAttributes(object|array $model): array
{
if (! ($model instanceof Flag)) {
throw new InvalidArgumentException(
get_class($this).' can only serialize instances of '.Flag::class
);
}
return [
'type' => $model->type,
'reason' => $model->reason,
'reasonDetail' => $model->reason_detail,
'createdAt' => $this->formatDate($model->created_at),
];
}
protected function post(Flag $flag): ?Relationship
{
return $this->hasOne($flag, PostSerializer::class);
}
protected function user(Flag $flag): ?Relationship
{
return $this->hasOne($flag, BasicUserSerializer::class);
}
}

View File

@@ -0,0 +1,36 @@
<?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\Flags\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Flags\Flag;
use Flarum\User\User;
class UserResourceFields
{
public function __invoke(): array
{
return [
Schema\Integer::make('newFlagCount')
->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id)
->get(function (User $user, Context $context) {
$actor = $context->getActor();
$query = Flag::whereVisibleTo($actor);
if ($time = $actor->read_flags_at) {
$query->where('flags.created_at', '>', $time);
}
return $query->distinct()->count('flags.post_id');
}),
];
}
}

View File

@@ -1,79 +0,0 @@
<?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\Flags\Command;
use Carbon\Carbon;
use Flarum\Flags\Event\Created;
use Flarum\Flags\Flag;
use Flarum\Foundation\ValidationException;
use Flarum\Locale\TranslatorInterface;
use Flarum\Post\CommentPost;
use Flarum\Post\PostRepository;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\Exception\PermissionDeniedException;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Arr;
use Tobscure\JsonApi\Exception\InvalidParameterException;
class CreateFlagHandler
{
public function __construct(
protected PostRepository $posts,
protected TranslatorInterface $translator,
protected SettingsRepositoryInterface $settings,
protected Dispatcher $events
) {
}
public function handle(CreateFlag $command): Flag
{
$actor = $command->actor;
$data = $command->data;
$postId = Arr::get($data, 'relationships.post.data.id');
$post = $this->posts->findOrFail($postId, $actor);
if (! ($post instanceof CommentPost)) {
throw new InvalidParameterException;
}
$actor->assertCan('flag', $post);
if ($actor->id === $post->user_id && ! $this->settings->get('flarum-flags.can_flag_own')) {
throw new PermissionDeniedException();
}
if (Arr::get($data, 'attributes.reason') === null && Arr::get($data, 'attributes.reasonDetail') === '') {
throw new ValidationException([
'message' => $this->translator->trans('flarum-flags.forum.flag_post.reason_missing_message')
]);
}
Flag::unguard();
$flag = Flag::firstOrNew([
'post_id' => $post->id,
'user_id' => $actor->id
]);
$flag->post_id = $post->id;
$flag->user_id = $actor->id;
$flag->type = 'user';
$flag->reason = Arr::get($data, 'attributes.reason');
$flag->reason_detail = Arr::get($data, 'attributes.reasonDetail');
$flag->created_at = Carbon::now();
$flag->save();
$this->events->dispatch(new Created($flag, $actor, $data));
return $flag;
}
}

View File

@@ -10,7 +10,6 @@
namespace Flarum\Flags\Command;
use Flarum\Flags\Event\Deleting;
use Flarum\Flags\Event\FlagsWillBeDeleted;
use Flarum\Post\Post;
use Flarum\Post\PostRepository;
use Illuminate\Events\Dispatcher;
@@ -31,9 +30,6 @@ class DeleteFlagsHandler
$actor->assertCan('viewFlags', $post->discussion);
// Deprecated, removed v2.0
$this->events->dispatch(new FlagsWillBeDeleted($post, $actor, $command->data));
foreach ($post->flags as $flag) {
$this->events->dispatch(new Deleting($flag, $actor, $command->data));
}

View File

@@ -1,27 +0,0 @@
<?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\Flags\Event;
use Flarum\Post\Post;
use Flarum\User\User;
/**
* @deprecated v2.0
* Listen for Flarum\Flags\Event\Deleting instead
*/
class FlagsWillBeDeleted
{
public function __construct(
public Post $post,
public User $actor,
public array $data = []
) {
}
}

View File

@@ -14,6 +14,7 @@ use Flarum\Database\AbstractModel;
use Flarum\Database\ScopeVisibilityTrait;
use Flarum\Post\Post;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
@@ -30,6 +31,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Flag extends AbstractModel
{
use ScopeVisibilityTrait;
use HasFactory;
public $timestamps = true;
public const UPDATED_AT = null;
protected $casts = ['created_at' => 'datetime'];

View File

@@ -0,0 +1,30 @@
<?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\Flags;
use Carbon\Carbon;
use Flarum\Post\Post;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class FlagFactory extends Factory
{
public function definition(): array
{
return [
'type' => 'user',
'post_id' => Post::factory(),
'user_id' => User::factory(),
'reason' => $this->faker->sentence,
'reason_detail' => $this->faker->sentence,
'created_at' => Carbon::now(),
];
}
}

View File

@@ -1,64 +0,0 @@
<?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\Flags;
use Flarum\Api\Controller;
use Flarum\Flags\Api\Controller\CreateFlagController;
use Flarum\Http\RequestUtil;
use Illuminate\Database\Eloquent\Collection;
use Psr\Http\Message\ServerRequestInterface;
class PrepareFlagsApiData
{
public function __invoke(Controller\AbstractSerializeController $controller, mixed $data, ServerRequestInterface $request): void
{
// For any API action that allows the 'flags' relationship to be
// included, we need to preload this relationship onto the data (Post
// models) so that we can selectively expose only the flags that the
// user has permission to view.
if ($controller instanceof Controller\ShowDiscussionController) {
if ($data->relationLoaded('posts')) {
$posts = $data->getRelation('posts');
}
}
if ($controller instanceof Controller\ListPostsController) {
$posts = $data->all();
}
if ($controller instanceof Controller\ShowPostController) {
$posts = [$data];
}
if ($controller instanceof CreateFlagController) {
$posts = [$data->post];
}
if (isset($posts)) {
$actor = RequestUtil::getActor($request);
$postsWithPermission = [];
foreach ($posts as $post) {
if (is_object($post)) {
$post->setRelation('flags', null);
if ($actor->can('viewFlags', $post->discussion)) {
$postsWithPermission[] = $post;
}
}
}
if (count($postsWithPermission)) {
(new Collection($postsWithPermission))
->load('flags', 'flags.user');
}
}
}
}

View File

@@ -9,9 +9,13 @@
namespace Flarum\Flags\Tests\integration\api\flags;
use Flarum\Discussion\Discussion;
use Flarum\Flags\Flag;
use Flarum\Group\Group;
use Flarum\Post\Post;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
use Illuminate\Support\Arr;
class ListTest extends TestCase
@@ -28,7 +32,7 @@ class ListTest extends TestCase
$this->extension('flarum-flags');
$this->prepareDatabase([
'users' => [
User::class => [
$this->normalUser(),
[
'id' => 3,
@@ -44,20 +48,22 @@ class ListTest extends TestCase
'group_permission' => [
['group_id' => Group::MODERATOR_ID, 'permission' => 'discussion.viewFlags'],
],
'discussions' => [
Discussion::class => [
['id' => 1, 'title' => '', 'user_id' => 1, 'comment_count' => 1],
],
'posts' => [
Post::class => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 2, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 3, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 4, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>', 'is_private' => true],
],
'flags' => [
Flag::class => [
['id' => 1, 'post_id' => 1, 'user_id' => 1],
['id' => 2, 'post_id' => 1, 'user_id' => 2],
['id' => 3, 'post_id' => 1, 'user_id' => 3],
['id' => 4, 'post_id' => 2, 'user_id' => 2],
['id' => 5, 'post_id' => 3, 'user_id' => 1],
['id' => 6, 'post_id' => 4, 'user_id' => 1],
]
]);
}
@@ -65,7 +71,7 @@ class ListTest extends TestCase
/**
* @test
*/
public function admin_can_see_one_flag_per_post()
public function admin_can_see_one_flag_per_visible_post()
{
$response = $this->send(
$this->request('GET', '/api/flags', [
@@ -73,9 +79,9 @@ class ListTest extends TestCase
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents());
$data = json_decode($response->getBody()->getContents(), true)['data'];
$data = json_decode($body, true)['data'];
$ids = Arr::pluck($data, 'id');
$this->assertEqualsCanonicalizing(['1', '4', '5'], $ids);
@@ -84,7 +90,7 @@ class ListTest extends TestCase
/**
* @test
*/
public function regular_user_sees_own_flags()
public function regular_user_sees_own_flags_of_visible_posts()
{
$response = $this->send(
$this->request('GET', '/api/flags', [
@@ -103,7 +109,7 @@ class ListTest extends TestCase
/**
* @test
*/
public function mod_can_see_one_flag_per_post()
public function mod_can_see_one_flag_per_visible_post()
{
$response = $this->send(
$this->request('GET', '/api/flags', [

View File

@@ -9,9 +9,14 @@
namespace Flarum\Flags\Tests\integration\api\flags;
use Flarum\Discussion\Discussion;
use Flarum\Flags\Flag;
use Flarum\Group\Group;
use Flarum\Post\Post;
use Flarum\Tags\Tag;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
use Illuminate\Support\Arr;
class ListWithTagsTest extends TestCase
@@ -29,13 +34,13 @@ class ListWithTagsTest extends TestCase
$this->extension('flarum-tags');
$this->prepareDatabase([
'tags' => [
Tag::class => [
['id' => 1, 'name' => 'Unrestricted', 'slug' => '1', 'position' => 0, 'parent_id' => null],
['id' => 2, 'name' => 'Mods can view discussions', 'slug' => '2', 'position' => 0, 'parent_id' => null, 'is_restricted' => true],
['id' => 3, 'name' => 'Mods can view flags', 'slug' => '3', 'position' => 0, 'parent_id' => null, 'is_restricted' => true],
['id' => 4, 'name' => 'Mods can view discussions and flags', 'slug' => '4', 'position' => 0, 'parent_id' => null, 'is_restricted' => true],
],
'users' => [
User::class => [
$this->normalUser(),
[
'id' => 3,
@@ -50,12 +55,12 @@ class ListWithTagsTest extends TestCase
],
'group_permission' => [
['group_id' => Group::MODERATOR_ID, 'permission' => 'discussion.viewFlags'],
['group_id' => Group::MODERATOR_ID, 'permission' => 'tag2.viewDiscussions'],
['group_id' => Group::MODERATOR_ID, 'permission' => 'tag2.viewForum'],
['group_id' => Group::MODERATOR_ID, 'permission' => 'tag3.discussion.viewFlags'],
['group_id' => Group::MODERATOR_ID, 'permission' => 'tag4.viewDiscussions'],
['group_id' => Group::MODERATOR_ID, 'permission' => 'tag4.viewForum'],
['group_id' => Group::MODERATOR_ID, 'permission' => 'tag4.discussion.viewFlags'],
],
'discussions' => [
Discussion::class => [
['id' => 1, 'title' => 'no tags', 'user_id' => 1, 'comment_count' => 1],
['id' => 2, 'title' => 'has tags where mods can view discussions but not flags', 'user_id' => 1, 'comment_count' => 1],
['id' => 3, 'title' => 'has tags where mods can view flags but not discussions', 'user_id' => 1, 'comment_count' => 1],
@@ -68,7 +73,7 @@ class ListWithTagsTest extends TestCase
['discussion_id' => 4, 'tag_id' => 4],
['discussion_id' => 5, 'tag_id' => 1],
],
'posts' => [
Post::class => [
// From regular ListTest
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 2, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
@@ -79,7 +84,7 @@ class ListWithTagsTest extends TestCase
['id' => 6, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 7, 'discussion_id' => 5, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
],
'flags' => [
Flag::class => [
// From regular ListTest
['id' => 1, 'post_id' => 1, 'user_id' => 1],
['id' => 2, 'post_id' => 1, 'user_id' => 2],
@@ -149,9 +154,7 @@ class ListWithTagsTest extends TestCase
$data = json_decode($response->getBody()->getContents(), true)['data'];
$ids = Arr::pluck($data, 'id');
// 7 is included, even though mods can't view discussions.
// This is because the UI doesnt allow discussions.viewFlags without viewDiscussions.
$this->assertEqualsCanonicalizing(['1', '4', '5', '7', '8', '9'], $ids);
$this->assertEqualsCanonicalizing(['1', '4', '5', '8', '9'], $ids);
}
/**

View File

@@ -0,0 +1,145 @@
<?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\Flags\Tests\integration\api\posts;
use Flarum\Group\Group;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Illuminate\Support\Arr;
class IncludeFlagsVisibilityTest extends TestCase
{
use RetrievesAuthorizedUsers;
/**
* @inheritDoc
*/
protected function setup(): void
{
parent::setUp();
$this->extension('flarum-tags', 'flarum-flags');
$this->prepareDatabase([
'users' => [
$this->normalUser(),
[
'id' => 3,
'username' => 'mod',
'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', // BCrypt hash for "too-obscure"
'email' => 'normal2@machine.local',
'is_email_confirmed' => 1,
],
[
'id' => 4,
'username' => 'tod',
'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', // BCrypt hash for "too-obscure"
'email' => 'tod@machine.local',
'is_email_confirmed' => 1,
],
[
'id' => 5,
'username' => 'ted',
'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', // BCrypt hash for "too-obscure"
'email' => 'ted@machine.local',
'is_email_confirmed' => 1,
],
],
'group_user' => [
['group_id' => 5, 'user_id' => 2],
['group_id' => 6, 'user_id' => 3],
],
'groups' => [
['id' => 5, 'name_singular' => 'group5', 'name_plural' => 'group5', 'color' => null, 'icon' => 'fas fa-crown', 'is_hidden' => false],
['id' => 6, 'name_singular' => 'group1', 'name_plural' => 'group1', 'color' => null, 'icon' => 'fas fa-cog', 'is_hidden' => false],
],
'group_permission' => [
['group_id' => Group::MEMBER_ID, 'permission' => 'tag1.viewForum'],
['group_id' => 5, 'permission' => 'tag1.viewForum'],
['group_id' => 5, 'permission' => 'discussion.viewFlags'],
['group_id' => 6, 'permission' => 'tag1.discussion.viewFlags'],
['group_id' => 6, 'permission' => 'tag1.viewForum'],
],
'tags' => [
['id' => 1, 'name' => 'Tag 1', 'slug' => 'tag-1', 'is_primary' => false, 'position' => null, 'parent_id' => null, 'is_restricted' => true],
['id' => 2, 'name' => 'Tag 2', 'slug' => 'tag-2', 'is_primary' => true, 'position' => 2, 'parent_id' => null, 'is_restricted' => false],
],
'discussions' => [
['id' => 1, 'title' => 'Test1', 'user_id' => 1, 'comment_count' => 1],
['id' => 2, 'title' => 'Test2', 'user_id' => 1, 'comment_count' => 1],
],
'discussion_tag' => [
['discussion_id' => 1, 'tag_id' => 1],
['discussion_id' => 2, 'tag_id' => 2],
],
'posts' => [
['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 2, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 3, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 4, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
['id' => 5, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p></p></t>'],
],
'flags' => [
['id' => 1, 'post_id' => 1, 'user_id' => 1],
['id' => 2, 'post_id' => 1, 'user_id' => 5],
['id' => 3, 'post_id' => 1, 'user_id' => 3],
['id' => 4, 'post_id' => 2, 'user_id' => 5],
['id' => 5, 'post_id' => 3, 'user_id' => 1],
['id' => 6, 'post_id' => 4, 'user_id' => 1],
['id' => 7, 'post_id' => 5, 'user_id' => 5],
['id' => 8, 'post_id' => 5, 'user_id' => 5],
],
]);
}
/**
* @dataProvider listFlagsIncludesDataProvider
* @test
*/
public function user_sees_where_allowed_with_included_tags(int $actorId, array $expectedIncludes)
{
$response = $this->send(
$this->request('GET', '/api/posts', [
'authenticatedAs' => $actorId,
])->withQueryParams([
'include' => 'flags'
])
);
$this->assertEquals(200, $response->getStatusCode());
$responseBody = json_decode($response->getBody()->getContents(), true);
$data = $responseBody['data'];
$this->assertEquals(['1', '2', '3', '4', '5'], Arr::pluck($data, 'id'));
$this->assertEqualsCanonicalizing(
$expectedIncludes,
collect($responseBody['included'] ?? [])
->filter(fn ($include) => $include['type'] === 'flags')
->pluck('id')
->map(strval(...))
->all()
);
}
public function listFlagsIncludesDataProvider(): array
{
return [
'admin_sees_all' => [1, [1, 2, 3, 4, 5, 6, 7, 8]],
'user_with_general_permission_sees_where_unrestricted_tag' => [2, [6, 7, 8]],
'user_with_tag1_permission_sees_tag1_flags' => [3, [1, 2, 3, 4, 5]],
'normal_user_sees_none' => [4, []],
'normal_user_sees_own' => [5, [2, 7, 4, 8]],
];
}
}

View File

@@ -9,19 +9,20 @@
namespace Flarum\Likes;
use Flarum\Api\Controller;
use Flarum\Api\Serializer\BasicUserSerializer;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource;
use Flarum\Extend;
use Flarum\Likes\Api\LoadLikesRelationship;
use Flarum\Likes\Api\PostResourceFields;
use Flarum\Likes\Event\PostWasLiked;
use Flarum\Likes\Event\PostWasUnliked;
use Flarum\Likes\Notification\PostLikedBlueprint;
use Flarum\Likes\Query\LikedByFilter;
use Flarum\Likes\Query\LikedFilter;
use Flarum\Post\Filter\PostFilterer;
use Flarum\Post\Event\Deleted;
use Flarum\Post\Filter\PostSearcher;
use Flarum\Post\Post;
use Flarum\User\Filter\UserFilterer;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\User\Search\UserSearcher;
use Flarum\User\User;
return [
@@ -38,49 +39,32 @@ return [
new Extend\Locales(__DIR__.'/locale'),
(new Extend\Notification())
->type(PostLikedBlueprint::class, PostSerializer::class, ['alert']),
->type(PostLikedBlueprint::class, ['alert']),
(new Extend\ApiSerializer(PostSerializer::class))
->hasMany('likes', BasicUserSerializer::class)
->attribute('canLike', function (PostSerializer $serializer, $model) {
return (bool) $serializer->getActor()->can('like', $model);
})
->attribute('likesCount', function (PostSerializer $serializer, $model) {
return $model->getAttribute('likes_count') ?: 0;
(new Extend\ApiResource(Resource\PostResource::class))
->fields(PostResourceFields::class)
->endpoint(
[Endpoint\Index::class, Endpoint\Show::class, Endpoint\Create::class, Endpoint\Update::class],
function (Endpoint\Index|Endpoint\Show|Endpoint\Create|Endpoint\Update $endpoint): Endpoint\Endpoint {
return $endpoint->addDefaultInclude(['likes']);
}
),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Endpoint {
return $endpoint->addDefaultInclude(['posts.likes']);
}),
(new Extend\ApiController(Controller\ShowDiscussionController::class))
->addInclude('posts.likes')
->loadWhere('posts.likes', LoadLikesRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)),
(new Extend\ApiController(Controller\ListPostsController::class))
->addInclude('likes')
->loadWhere('likes', LoadLikesRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)),
(new Extend\ApiController(Controller\ShowPostController::class))
->addInclude('likes')
->loadWhere('likes', LoadLikesRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)),
(new Extend\ApiController(Controller\CreatePostController::class))
->addInclude('likes')
->loadWhere('likes', LoadLikesRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)),
(new Extend\ApiController(Controller\UpdatePostController::class))
->addInclude('likes')
->loadWhere('likes', LoadLikesRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)),
(new Extend\Event())
->listen(PostWasLiked::class, Listener\SendNotificationWhenPostIsLiked::class)
->listen(PostWasUnliked::class, Listener\SendNotificationWhenPostIsUnliked::class)
->subscribe(Listener\SaveLikesToDatabase::class),
->listen(Deleted::class, function (Deleted $event) {
$event->post->likes()->detach();
}),
(new Extend\Filter(PostFilterer::class))
->addFilter(LikedByFilter::class),
(new Extend\Filter(UserFilterer::class))
->addFilter(LikedFilter::class),
(new Extend\SearchDriver(DatabaseSearchDriver::class))
->addFilter(PostSearcher::class, LikedByFilter::class)
->addFilter(UserSearcher::class, LikedFilter::class),
(new Extend\Settings())
->default('flarum-likes.like_own_post', true),

2
extensions/likes/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

@@ -4,7 +4,7 @@ import CommentPost from 'flarum/forum/components/CommentPost';
import Link from 'flarum/common/components/Link';
import punctuateSeries from 'flarum/common/helpers/punctuateSeries';
import username from 'flarum/common/helpers/username';
import icon from 'flarum/common/helpers/icon';
import Icon from 'flarum/common/components/Icon';
import Button from 'flarum/common/components/Button';
import PostLikesModal from './components/PostLikesModal';
@@ -58,7 +58,7 @@ export default function () {
items.add(
'liked',
<div className="Post-likedBy">
{icon('far fa-thumbs-up')}
<Icon name={'far fa-thumbs-up'} />
{app.translator.trans(`flarum-likes.forum.post.liked_by${likes[0] === app.session.user ? '_self' : ''}_text`, {
count: names.length,
users: punctuateSeries(names),

View File

@@ -1,7 +1,7 @@
import app from 'flarum/forum/app';
import Modal from 'flarum/common/components/Modal';
import Link from 'flarum/common/components/Link';
import avatar from 'flarum/common/helpers/avatar';
import Avatar from 'flarum/common/components/Avatar';
import username from 'flarum/common/helpers/username';
import type { IInternalModalAttrs } from 'flarum/common/components/Modal';
import type Post from 'flarum/common/models/Post';
@@ -9,6 +9,7 @@ import type Mithril from 'mithril';
import PostLikesModalState from '../states/PostLikesModalState';
import Button from 'flarum/common/components/Button';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import Form from 'flarum/common/components/Form';
export interface IPostLikesModalAttrs extends IInternalModalAttrs {
post: Post;
@@ -47,7 +48,7 @@ export default class PostLikesModal<CustomAttrs extends IPostLikesModalAttrs = I
page.items.map((user) => (
<li>
<Link href={app.route.user(user)}>
{avatar(user)} {username(user)}
<Avatar user={user} /> {username(user)}
</Link>
</li>
))
@@ -57,13 +58,13 @@ export default class PostLikesModal<CustomAttrs extends IPostLikesModalAttrs = I
</div>
{this.state.hasNext() ? (
<div className="Modal-footer">
<div className="Form Form--centered">
<Form className="Form--centered">
<div className="Form-group">
<Button className="Button Button--block" onclick={() => this.state.loadNext()} loading={this.state.isLoadingNext()}>
{app.translator.trans('flarum-likes.forum.post_likes.load_more_button')}
</Button>
</div>
</div>
</Form>
</div>
) : null}
</>

View File

@@ -2,11 +2,15 @@ import Extend from 'flarum/common/extenders';
import Post from 'flarum/common/models/Post';
import User from 'flarum/common/models/User';
import LikesUserPage from './components/LikesUserPage';
import PostLikedNotification from './components/PostLikedNotification';
export default [
new Extend.Routes() //
.add('user.likes', '/u/:username/likes', LikesUserPage),
new Extend.Notification() //
.add('postLiked', PostLikedNotification),
new Extend.Model(Post) //
.hasMany<User>('likes')
.attribute<number>('likesCount')

View File

@@ -3,14 +3,11 @@ import app from 'flarum/forum/app';
import addLikeAction from './addLikeAction';
import addLikesList from './addLikesList';
import PostLikedNotification from './components/PostLikedNotification';
import addLikesTabToUserProfile from './addLikesTabToUserProfile';
export { default as extend } from './extend';
app.initializers.add('flarum-likes', () => {
app.notificationComponents.postLiked = PostLikedNotification;
addLikeAction();
addLikesList();
addLikesTabToUserProfile();

View File

@@ -4,7 +4,7 @@
margin: 0;
a {
color: @text-color;
color: var(--text-color);
font-size: 15px;
font-weight: bold;
display: block;

View File

@@ -1,65 +0,0 @@
<?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\Likes\Api;
use Flarum\Api\Controller\AbstractSerializeController;
use Flarum\Discussion\Discussion;
use Flarum\Http\RequestUtil;
use Flarum\Post\Post;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Query\Expression;
use Psr\Http\Message\ServerRequestInterface;
class LoadLikesRelationship
{
public static int $maxLikes = 4;
public static function mutateRelation(BelongsToMany $query, ServerRequestInterface $request): void
{
$actor = RequestUtil::getActor($request);
$grammar = $query->getQuery()->getGrammar();
$query
// So that we can tell if the current user has liked the post.
->orderBy(new Expression($grammar->wrap('user_id').' = '.$actor->id), 'desc')
// Limiting a relationship results is only possible because
// the Post model uses the \Staudenmeir\EloquentEagerLimit\HasEagerLimit
// trait.
->limit(self::$maxLikes);
}
/**
* Called using the @see ApiController::prepareDataForSerialization extender.
*/
public static function countRelation(AbstractSerializeController $controller, mixed $data): array
{
$loadable = null;
if ($data instanceof Discussion) {
// We do this because the ShowDiscussionController manipulates the posts
// in a way that some of them are just ids.
$loadable = $data->posts->filter(function ($post) {
return $post instanceof Post;
});
} elseif ($data instanceof Collection) {
$loadable = $data;
} elseif ($data instanceof Post) {
$loadable = $data->newCollection([$data]);
}
if ($loadable) {
$loadable->loadCount('likes');
}
return [];
}
}

View File

@@ -0,0 +1,65 @@
<?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\Likes\Api;
use Flarum\Api\Context;
use Flarum\Api\Schema;
use Flarum\Likes\Event\PostWasLiked;
use Flarum\Likes\Event\PostWasUnliked;
use Flarum\Post\Post;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Expression;
class PostResourceFields
{
public static int $maxLikes = 4;
public function __invoke(): array
{
return [
Schema\Boolean::make('isLiked')
->visible(false)
->writable(fn (Post $post, Context $context) => $context->getActor()->can('like', $post))
->set(function (Post $post, bool $liked, Context $context) {
$actor = $context->getActor();
$currentlyLiked = $post->likes()->where('user_id', $actor->id)->exists();
if ($liked && ! $currentlyLiked) {
$post->likes()->attach($actor->id);
$post->raise(new PostWasLiked($post, $actor));
} elseif ($currentlyLiked) {
$post->likes()->detach($actor->id);
$post->raise(new PostWasUnliked($post, $actor));
}
}),
Schema\Boolean::make('canLike')
->get(fn (Post $post, Context $context) => $context->getActor()->can('like', $post)),
Schema\Integer::make('likesCount')
->countRelation('likes'),
Schema\Relationship\ToMany::make('likes')
->type('users')
->includable()
->scope(function (Builder $query, Context $context) {
$actor = $context->getActor();
$grammar = $query->getQuery()->getGrammar();
// So that we can tell if the current user has liked the post.
$query
->orderBy(new Expression($grammar->wrap('user_id').' = '.$actor->id), 'desc')
->limit(static::$maxLikes);
}),
];
}
}

View File

@@ -1,55 +0,0 @@
<?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\Likes\Listener;
use Flarum\Likes\Event\PostWasLiked;
use Flarum\Likes\Event\PostWasUnliked;
use Flarum\Post\Event\Deleted;
use Flarum\Post\Event\Saving;
use Illuminate\Contracts\Events\Dispatcher;
class SaveLikesToDatabase
{
public function subscribe(Dispatcher $events): void
{
$events->listen(Saving::class, $this->whenPostIsSaving(...));
$events->listen(Deleted::class, $this->whenPostIsDeleted(...));
}
public function whenPostIsSaving(Saving $event): void
{
$post = $event->post;
$data = $event->data;
if ($post->exists && isset($data['attributes']['isLiked'])) {
$actor = $event->actor;
$liked = (bool) $data['attributes']['isLiked'];
$actor->assertCan('like', $post);
$currentlyLiked = $post->likes()->where('user_id', $actor->id)->exists();
if ($liked && ! $currentlyLiked) {
$post->likes()->attach($actor->id);
$post->raise(new PostWasLiked($post, $actor));
} elseif ($currentlyLiked) {
$post->likes()->detach($actor->id);
$post->raise(new PostWasUnliked($post, $actor));
}
}
}
public function whenPostIsDeleted(Deleted $event): void
{
$event->post->likes()->detach();
}
}

View File

@@ -9,10 +9,14 @@
namespace Flarum\Likes\Query;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Filter\ValidateFilterTrait;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
class LikedByFilter implements FilterInterface
{
use ValidateFilterTrait;
@@ -22,11 +26,11 @@ class LikedByFilter implements FilterInterface
return 'likedBy';
}
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
public function filter(SearchState $state, string|array $value, bool $negate): void
{
$likedId = $this->asInt($filterValue);
$likedId = $this->asInt($value);
$filterState
$state
->getQuery()
->whereIn('id', function ($query) use ($likedId, $negate) {
$query->select('post_id')

View File

@@ -9,10 +9,14 @@
namespace Flarum\Likes\Query;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Filter\ValidateFilterTrait;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
class LikedFilter implements FilterInterface
{
use ValidateFilterTrait;
@@ -22,11 +26,11 @@ class LikedFilter implements FilterInterface
return 'liked';
}
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
public function filter(SearchState $state, string|array $value, bool $negate): void
{
$likedId = $this->asString($filterValue);
$likedId = $this->asString($value);
$filterState
$state
->getQuery()
->whereIn('id', function ($query) use ($likedId) {
$query->select('user_id')

View File

@@ -10,9 +10,13 @@
namespace Flarum\Likes\Tests\integration\api;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Group\Group;
use Flarum\Post\CommentPost;
use Flarum\Post\Post;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
use Psr\Http\Message\ResponseInterface;
class LikePostTest extends TestCase
@@ -26,21 +30,21 @@ class LikePostTest extends TestCase
$this->extension('flarum-likes');
$this->prepareDatabase([
'users' => [
User::class => [
['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1],
$this->normalUser(),
['id' => 3, 'username' => 'Acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1],
],
'discussions' => [
Discussion::class => [
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 2],
],
'posts' => [
Post::class => [
['id' => 1, 'number' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>something</p></t>'],
['id' => 3, 'number' => 2, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>something</p></t>'],
['id' => 5, 'number' => 3, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'discussionRenamed', 'content' => '<t><p>something</p></t>'],
['id' => 6, 'number' => 4, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>something</p></t>'],
],
'groups' => [
Group::class => [
['id' => 5, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0],
['id' => 6, 'name_singular' => 'Acme1', 'name_plural' => 'Acme1', 'is_hidden' => 0]
],
@@ -72,7 +76,7 @@ class LikePostTest extends TestCase
$post = CommentPost::query()->find($postId);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents());
$this->assertNotNull($post->likes->where('id', $authenticatedAs)->first(), $message);
}
@@ -92,7 +96,7 @@ class LikePostTest extends TestCase
$post = CommentPost::query()->find($postId);
$this->assertEquals(403, $response->getStatusCode(), $message);
$this->assertContainsEquals($response->getStatusCode(), [401, 403], $message);
$this->assertNull($post->likes->where('id', $authenticatedAs)->first());
}

View File

@@ -10,10 +10,13 @@
namespace Flarum\Likes\Tests\integration\api\discussions;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Group\Group;
use Flarum\Likes\Api\LoadLikesRelationship;
use Flarum\Likes\Api\PostResourceFields;
use Flarum\Post\Post;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Flarum\Testing\integration\TestCase;
use Flarum\User\User;
use Illuminate\Support\Arr;
class ListPostsTest extends TestCase
@@ -30,13 +33,13 @@ class ListPostsTest extends TestCase
$this->extension('flarum-likes');
$this->prepareDatabase([
'discussions' => [
Discussion::class => [
['id' => 100, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 101, 'comment_count' => 1],
],
'posts' => [
Post::class => [
['id' => 101, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '<t><p>text</p></t>'],
],
'users' => [
User::class => [
$this->normalUser(),
['id' => 102, 'username' => 'user102', 'email' => '102@machine.local', 'is_email_confirmed' => 1],
['id' => 103, 'username' => 'user103', 'email' => '103@machine.local', 'is_email_confirmed' => 1],
@@ -132,7 +135,7 @@ class ListPostsTest extends TestCase
$likes = $data['relationships']['likes']['data'];
// Only displays a limited amount of likes
$this->assertCount(LoadLikesRelationship::$maxLikes, $likes);
$this->assertCount(PostResourceFields::$maxLikes, $likes);
// Displays the correct count of likes
$this->assertEquals(11, $data['attributes']['likesCount']);
// Of the limited amount of likes, the actor always appears
@@ -159,7 +162,7 @@ class ListPostsTest extends TestCase
$likes = $data[0]['relationships']['likes']['data'];
// Only displays a limited amount of likes
$this->assertCount(LoadLikesRelationship::$maxLikes, $likes);
$this->assertCount(PostResourceFields::$maxLikes, $likes);
// Displays the correct count of likes
$this->assertEquals(11, $data[0]['attributes']['likesCount']);
// Of the limited amount of likes, the actor always appears
@@ -170,7 +173,7 @@ class ListPostsTest extends TestCase
* @dataProvider likesIncludeProvider
* @test
*/
public function likes_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(string $include)
public function likes_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(?string $include)
{
// Show discussion endpoint
$response = $this->send(
@@ -181,22 +184,27 @@ class ListPostsTest extends TestCase
])
);
$included = json_decode($response->getBody()->getContents(), true)['included'];
$body = $response->getBody()->getContents();
$this->assertEquals(200, $response->getStatusCode(), $body);
$included = json_decode($body, true)['included'] ?? [];
$likes = collect($included)
->where('type', 'posts')
->where('id', 101)
->first()['relationships']['likes']['data'];
->first()['relationships']['likes']['data'] ?? null;
// Only displays a limited amount of likes
$this->assertCount(LoadLikesRelationship::$maxLikes, $likes);
$this->assertNotNull($likes, $body);
$this->assertCount(PostResourceFields::$maxLikes, $likes);
// Displays the correct count of likes
$this->assertEquals(11, collect($included)
->where('type', 'posts')
->where('id', 101)
->first()['attributes']['likesCount']);
->first()['attributes']['likesCount'] ?? null, $body);
// Of the limited amount of likes, the actor always appears
$this->assertEquals([2, 102, 104, 105], Arr::pluck($likes, 'id'));
$this->assertEquals([2, 102, 104, 105], Arr::pluck($likes, 'id'), $body);
}
public function likesIncludeProvider(): array
@@ -204,7 +212,7 @@ class ListPostsTest extends TestCase
return [
['posts,posts.likes'],
['posts.likes'],
[''],
[null],
];
}
}

View File

@@ -7,20 +7,20 @@
* LICENSE file that was distributed with this source code.
*/
use Flarum\Api\Serializer\BasicDiscussionSerializer;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Api\Context;
use Flarum\Api\Resource;
use Flarum\Api\Schema;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event\Saving;
use Flarum\Discussion\Filter\DiscussionFilterer;
use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Extend;
use Flarum\Lock\Access;
use Flarum\Lock\Event\DiscussionWasLocked;
use Flarum\Lock\Event\DiscussionWasUnlocked;
use Flarum\Lock\Filter\LockedFilter;
use Flarum\Lock\Listener;
use Flarum\Lock\Notification\DiscussionLockedBlueprint;
use Flarum\Lock\Post\DiscussionLockedPost;
use Flarum\Lock\Query\LockedFilterGambit;
use Flarum\Search\Database\DatabaseSearchDriver;
return [
(new Extend\Frontend('forum'))
@@ -33,33 +33,44 @@ return [
new Extend\Locales(__DIR__.'/locale'),
(new Extend\Notification())
->type(DiscussionLockedBlueprint::class, BasicDiscussionSerializer::class, ['alert']),
->type(DiscussionLockedBlueprint::class, ['alert']),
(new Extend\Model(Discussion::class))
->cast('is_locked', 'bool'),
(new Extend\ApiSerializer(DiscussionSerializer::class))
->attribute('isLocked', function (DiscussionSerializer $serializer, Discussion $discussion) {
return $discussion->is_locked;
})
->attribute('canLock', function (DiscussionSerializer $serializer, Discussion $discussion) {
return $serializer->getActor()->can('lock', $discussion);
}),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->fields(fn () => [
Schema\Boolean::make('isLocked')
->writable(fn (Discussion $discussion, Context $context) => $context->getActor()->can('lock', $discussion))
->set(function (Discussion $discussion, bool $isLocked, Context $context) {
$actor = $context->getActor();
if ($discussion->is_locked === $isLocked) {
return;
}
$discussion->is_locked = $isLocked;
$discussion->raise(
$discussion->is_locked
? new DiscussionWasLocked($discussion, $actor)
: new DiscussionWasUnlocked($discussion, $actor)
);
}),
Schema\Boolean::make('canLock')
->get(fn (Discussion $discussion, Context $context) => $context->getActor()->can('lock', $discussion)),
]),
(new Extend\Post())
->type(DiscussionLockedPost::class),
(new Extend\Event())
->listen(Saving::class, Listener\SaveLockedToDatabase::class)
->listen(DiscussionWasLocked::class, Listener\CreatePostWhenDiscussionIsLocked::class)
->listen(DiscussionWasUnlocked::class, Listener\CreatePostWhenDiscussionIsUnlocked::class),
(new Extend\Policy())
->modelPolicy(Discussion::class, Access\DiscussionPolicy::class),
(new Extend\Filter(DiscussionFilterer::class))
->addFilter(LockedFilterGambit::class),
(new Extend\SimpleFlarumSearch(DiscussionSearcher::class))
->addGambit(LockedFilterGambit::class),
(new Extend\SearchDriver(DatabaseSearchDriver::class))
->addFilter(DiscussionSearcher::class, LockedFilter::class),
];

2
extensions/lock/js/dist/admin.js generated vendored
View File

@@ -1,2 +1,2 @@
(()=>{var e={n:o=>{var r=o&&o.__esModule?()=>o.default:()=>o;return e.d(r,{a:r}),r},d:(o,r)=>{for(var a in r)e.o(r,a)&&!e.o(o,a)&&Object.defineProperty(o,a,{enumerable:!0,get:r[a]})},o:(e,o)=>Object.prototype.hasOwnProperty.call(e,o),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},o={};(()=>{"use strict";e.r(o);const r=flarum.reg.get("core","admin/app");var a=e.n(r);a().initializers.add("lock",(()=>{a().extensionData.for("flarum-lock").registerPermission({icon:"fas fa-lock",label:a().translator.trans("flarum-lock.admin.permissions.lock_discussions_label"),permission:"discussion.lock"},"moderate",95)}))})(),module.exports=o})();
(()=>{var e={n:r=>{var o=r&&r.__esModule?()=>r.default:()=>r;return e.d(o,{a:o}),o},d:(r,o)=>{for(var a in o)e.o(o,a)&&!e.o(r,a)&&Object.defineProperty(r,a,{enumerable:!0,get:o[a]})},o:(e,r)=>Object.prototype.hasOwnProperty.call(e,r),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},r={};(()=>{"use strict";e.r(r),e.d(r,{extend:()=>m});const o=flarum.reg.get("core","admin/app");var a=e.n(o);const t=flarum.reg.get("core","common/extenders");var s=e.n(t);const n=flarum.reg.get("core","common/query/IGambit"),l=flarum.reg.get("core","common/app");var i=e.n(l);class c extends n.BooleanGambit{key(){return i().translator.trans("flarum-lock.lib.gambits.discussions.locked.key",{},!0)}filterKey(){return"locked"}}flarum.reg.add("flarum-lock","common/query/discussions/LockedGambit",c);const m=[(new(s().Search)).gambit("discussions",c)];a().initializers.add("lock",(()=>{a().extensionData.for("flarum-lock").registerPermission({icon:"fas fa-lock",label:a().translator.trans("flarum-lock.admin.permissions.lock_discussions_label"),permission:"discussion.lock"},"moderate",95)}))})(),module.exports=r})();
//# 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,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,IAAIV,IAAI,OAAQ,a,aCC5D,qBAAqB,QAAQ,KAC3B,sBAAsB,eAAeW,mBAAmB,CACtDC,KAAM,cACNC,MAAO,qBAAqB,wDAC5BC,WAAY,mBACX,WAAY,GAAG,G","sources":["webpack://@flarum/lock/webpack/bootstrap","webpack://@flarum/lock/webpack/runtime/compat get default export","webpack://@flarum/lock/webpack/runtime/define property getters","webpack://@flarum/lock/webpack/runtime/hasOwnProperty shorthand","webpack://@flarum/lock/webpack/runtime/make namespace object","webpack://@flarum/lock/external root \"flarum.reg.get('core', 'admin/app')\"","webpack://@flarum/lock/./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.reg.get('core', 'admin/app');","import app from 'flarum/admin/app';\napp.initializers.add('lock', () => {\n app.extensionData.for('flarum-lock').registerPermission({\n icon: 'fas fa-lock',\n label: app.translator.trans('flarum-lock.admin.permissions.lock_discussions_label'),\n permission: 'discussion.lock'\n }, 'moderate', 95);\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","reg","registerPermission","icon","label","permission"],"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,qDCL9D,MAAM,EAA+BC,OAAOC,IAAIV,IAAI,OAAQ,a,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,oB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,wBCAtD,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,c,aCE7C,MAAMW,UAAqB,EAAAC,cACxCjB,MACE,OAAO,qBAAqB,iDAAkD,CAAC,GAAG,EACpF,CACAkB,YACE,MAAO,QACT,EAEFJ,OAAOC,IAAII,IAAI,cAAe,wCAAyCH,GCRvE,UAAgB,IAAI,aACnBI,OAAO,cAAeJ,ICDvB,qBAAqB,QAAQ,KAC3B,sBAAsB,eAAeK,mBAAmB,CACtDC,KAAM,cACNC,MAAO,qBAAqB,wDAC5BC,WAAY,mBACX,WAAY,GAAG,G","sources":["webpack://@flarum/lock/webpack/bootstrap","webpack://@flarum/lock/webpack/runtime/compat get default export","webpack://@flarum/lock/webpack/runtime/define property getters","webpack://@flarum/lock/webpack/runtime/hasOwnProperty shorthand","webpack://@flarum/lock/webpack/runtime/make namespace object","webpack://@flarum/lock/external root \"flarum.reg.get('core', 'admin/app')\"","webpack://@flarum/lock/external root \"flarum.reg.get('core', 'common/extenders')\"","webpack://@flarum/lock/external root \"flarum.reg.get('core', 'common/query/IGambit')\"","webpack://@flarum/lock/external root \"flarum.reg.get('core', 'common/app')\"","webpack://@flarum/lock/./src/common/query/discussions/LockedGambit.ts","webpack://@flarum/lock/./src/common/extend.ts","webpack://@flarum/lock/./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.reg.get('core', 'admin/app');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/extenders');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/query/IGambit');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/app');","import { BooleanGambit } from 'flarum/common/query/IGambit';\nimport app from 'flarum/common/app';\nexport default class LockedGambit extends BooleanGambit {\n key() {\n return app.translator.trans('flarum-lock.lib.gambits.discussions.locked.key', {}, true);\n }\n filterKey() {\n return 'locked';\n }\n}\nflarum.reg.add('flarum-lock', 'common/query/discussions/LockedGambit', LockedGambit);","import Extend from 'flarum/common/extenders';\nimport LockedGambit from './query/discussions/LockedGambit';\nexport default [new Extend.Search() //\n.gambit('discussions', LockedGambit)];","import app from 'flarum/admin/app';\nexport { default as extend } from './extend';\napp.initializers.add('lock', () => {\n app.extensionData.for('flarum-lock').registerPermission({\n icon: 'fas fa-lock',\n label: app.translator.trans('flarum-lock.admin.permissions.lock_discussions_label'),\n permission: 'discussion.lock'\n }, 'moderate', 95);\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","reg","LockedGambit","BooleanGambit","filterKey","add","gambit","registerPermission","icon","label","permission"],"sourceRoot":""}

2
extensions/lock/js/dist/forum.js generated vendored
View File

@@ -1,2 +1,2 @@
(()=>{var o={n:t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return o.d(e,{a:e}),e},d:(t,e)=>{for(var n in e)o.o(e,n)&&!o.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},o:(o,t)=>Object.prototype.hasOwnProperty.call(o,t),r:o=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(o,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(o,"__esModule",{value:!0})}},t={};(()=>{"use strict";o.r(t),o.d(t,{extend:()=>P});const e=flarum.reg.get("core","common/extend"),n=flarum.reg.get("core","forum/app");var r=o.n(n);const s=flarum.reg.get("core","forum/components/Notification");var c=o.n(s);class a extends(c()){icon(){return"fas fa-lock"}href(){const o=this.attrs.notification;return r().route.discussion(o.subject(),o.content().postNumber)}content(){return r().translator.trans("flarum-lock.forum.notifications.discussion_locked_text",{user:this.attrs.notification.fromUser()})}}flarum.reg.add("flarum-lock","forum/components/DiscussionLockedNotification",a);const i=flarum.reg.get("core","common/models/Discussion");var u=o.n(i);const l=flarum.reg.get("core","common/components/Badge");var d=o.n(l);const f=flarum.reg.get("core","forum/utils/DiscussionControls");var k=o.n(f);const g=flarum.reg.get("core","forum/components/DiscussionPage");var p=o.n(g);const b=flarum.reg.get("core","common/components/Button");var _=o.n(b);const v=flarum.reg.get("core","common/extenders");var y=o.n(v);const L=flarum.reg.get("core","forum/components/EventPost");var h=o.n(L);class x extends(h()){icon(){return this.attrs.post.content().locked?"fas fa-lock":"fas fa-unlock"}descriptionKey(){return this.attrs.post.content().locked?"flarum-lock.forum.post_stream.discussion_locked_text":"flarum-lock.forum.post_stream.discussion_unlocked_text"}}flarum.reg.add("flarum-lock","forum/components/DiscussionLockedPost",x);const P=[(new(y().PostTypes)).add("discussionLocked",x),new(y().Model)(u()).attribute("isLocked").attribute("canLock")];r().initializers.add("flarum-lock",(()=>{r().notificationComponents.discussionLocked=a,(0,e.extend)(u().prototype,"badges",(function(o){this.isLocked()&&o.add("locked",m(d(),{type:"locked",label:r().translator.trans("flarum-lock.forum.badge.locked_tooltip"),icon:"fas fa-lock"}))})),(0,e.extend)(k(),"moderationControls",(function(o,t){t.canLock()&&o.add("lock",m(_(),{icon:"fas fa-lock",onclick:this.lockAction.bind(t)},r().translator.trans("flarum-lock.forum.discussion_controls.".concat(t.isLocked()?"unlock":"lock","_button"))))})),k().lockAction=function(){this.save({isLocked:!this.isLocked()}).then((()=>{r().current.matches(p())&&r().current.get("stream").update(),m.redraw()}))},(0,e.extend)("flarum/forum/components/NotificationGrid","notificationTypes",(function(o){o.add("discussionLocked",{name:"discussionLocked",icon:"fas fa-lock",label:r().translator.trans("flarum-lock.forum.settings.notify_discussion_locked_label")})}))}))})(),module.exports=t})();
(()=>{var o={n:e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return o.d(t,{a:t}),t},d:(e,t)=>{for(var n in t)o.o(t,n)&&!o.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},o:(o,e)=>Object.prototype.hasOwnProperty.call(o,e),r:o=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(o,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(o,"__esModule",{value:!0})}},e={};(()=>{"use strict";o.r(e),o.d(e,{extend:()=>N});const t=flarum.reg.get("core","common/extend"),n=flarum.reg.get("core","forum/app");var r=o.n(n);const s=flarum.reg.get("core","common/models/Discussion");var c=o.n(s);const a=flarum.reg.get("core","common/components/Badge");var i=o.n(a);const l=flarum.reg.get("core","forum/utils/DiscussionControls");var u=o.n(l);const d=flarum.reg.get("core","forum/components/DiscussionPage");var f=o.n(d);const k=flarum.reg.get("core","common/components/Button");var g=o.n(k);const p=flarum.reg.get("core","common/extenders");var b=o.n(p);const y=flarum.reg.get("core","forum/components/EventPost");var _=o.n(y);class v extends(_()){icon(){return this.attrs.post.content().locked?"fas fa-lock":"fas fa-unlock"}descriptionKey(){return this.attrs.post.content().locked?"flarum-lock.forum.post_stream.discussion_locked_text":"flarum-lock.forum.post_stream.discussion_unlocked_text"}}flarum.reg.add("flarum-lock","forum/components/DiscussionLockedPost",v);const x=flarum.reg.get("core","common/query/IGambit"),L=flarum.reg.get("core","common/app");var h=o.n(L);class P extends x.BooleanGambit{key(){return h().translator.trans("flarum-lock.lib.gambits.discussions.locked.key",{},!0)}filterKey(){return"locked"}}flarum.reg.add("flarum-lock","common/query/discussions/LockedGambit",P);const w=[(new(b().Search)).gambit("discussions",P)],S=flarum.reg.get("core","forum/components/Notification");var j=o.n(S);class D extends(j()){icon(){return"fas fa-lock"}href(){const o=this.attrs.notification;return r().route.discussion(o.subject(),o.content().postNumber)}content(){return r().translator.trans("flarum-lock.forum.notifications.discussion_locked_text",{user:this.attrs.notification.fromUser()})}excerpt(){return null}}flarum.reg.add("flarum-lock","forum/components/DiscussionLockedNotification",D);const N=[...w,(new(b().PostTypes)).add("discussionLocked",v),(new(b().Notification)).add("discussionLocked",D),new(b().Model)(c()).attribute("isLocked").attribute("canLock")];r().initializers.add("flarum-lock",(()=>{(0,t.extend)(c().prototype,"badges",(function(o){this.isLocked()&&o.add("locked",m(i(),{type:"locked",label:r().translator.trans("flarum-lock.forum.badge.locked_tooltip"),icon:"fas fa-lock"}))})),(0,t.extend)(u(),"moderationControls",(function(o,e){e.canLock()&&o.add("lock",m(g(),{icon:"fas fa-lock",onclick:this.lockAction.bind(e)},r().translator.trans("flarum-lock.forum.discussion_controls.".concat(e.isLocked()?"unlock":"lock","_button"))))})),u().lockAction=function(){this.save({isLocked:!this.isLocked()}).then((()=>{r().current.matches(f())&&r().current.get("stream").update(),m.redraw()}))},(0,t.extend)("flarum/forum/components/NotificationGrid","notificationTypes",(function(o){o.add("discussionLocked",{name:"discussionLocked",icon:"fas fa-lock",label:r().translator.trans("flarum-lock.forum.settings.notify_discussion_locked_label")})}))}))})(),module.exports=e})();
//# sourceMappingURL=forum.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
export { default as default } from '../common/extend';

View File

@@ -1,5 +1,7 @@
import app from 'flarum/admin/app';
export { default as extend } from './extend';
app.initializers.add('lock', () => {
app.extensionData.for('flarum-lock').registerPermission(
{

View File

@@ -0,0 +1,7 @@
import Extend from 'flarum/common/extenders';
import LockedGambit from './query/discussions/LockedGambit';
export default [
new Extend.Search() //
.gambit('discussions', LockedGambit),
];

View File

@@ -0,0 +1,12 @@
import { BooleanGambit } from 'flarum/common/query/IGambit';
import app from 'flarum/common/app';
export default class LockedGambit extends BooleanGambit {
key(): string {
return app.translator.trans('flarum-lock.lib.gambits.discussions.locked.key', {}, true);
}
filterKey(): string {
return 'locked';
}
}

View File

@@ -15,4 +15,8 @@ export default class DiscussionLockedNotification extends Notification {
content() {
return app.translator.trans('flarum-lock.forum.notifications.discussion_locked_text', { user: this.attrs.notification.fromUser() });
}
excerpt() {
return null;
}
}

View File

@@ -2,10 +2,18 @@ import Extend from 'flarum/common/extenders';
import Discussion from 'flarum/common/models/Discussion';
import DiscussionLockedPost from './components/DiscussionLockedPost';
import commonExtend from '../common/extend';
import DiscussionLockedNotification from './components/DiscussionLockedNotification';
export default [
...commonExtend,
new Extend.PostTypes() //
.add('discussionLocked', DiscussionLockedPost),
new Extend.Notification() //
.add('discussionLocked', DiscussionLockedNotification),
new Extend.Model(Discussion) //
.attribute<boolean>('isLocked')
.attribute<boolean>('canLock'),

View File

@@ -1,15 +1,12 @@
import { extend } from 'flarum/common/extend';
import app from 'flarum/forum/app';
import DiscussionLockedNotification from './components/DiscussionLockedNotification';
import addLockBadge from './addLockBadge';
import addLockControl from './addLockControl';
export { default as extend } from './extend';
app.initializers.add('flarum-lock', () => {
app.notificationComponents.discussionLocked = DiscussionLockedNotification;
addLockBadge();
addLockControl();

View File

@@ -35,3 +35,12 @@ flarum-lock:
# These translations are used in the Settings page.
settings:
notify_discussion_locked_label: Someone locks a discussion I started
# Translations in this namespace are used by the forum and admin interfaces.
lib:
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
gambits:
discussions:
locked:
key: locked

View File

@@ -0,0 +1,36 @@
<?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\Lock\Filter;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
/**
* @implements FilterInterface<DatabaseSearchState>
*/
class LockedFilter implements FilterInterface
{
public function getFilterKey(): string
{
return 'locked';
}
public function filter(SearchState $state, string|array $value, bool $negate): void
{
$this->constrain($state->getQuery(), $negate);
}
protected function constrain(Builder $query, bool $negate): void
{
$query->where('is_locked', ! $negate);
}
}

View File

@@ -1,40 +0,0 @@
<?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\Lock\Listener;
use Flarum\Discussion\Event\Saving;
use Flarum\Lock\Event\DiscussionWasLocked;
use Flarum\Lock\Event\DiscussionWasUnlocked;
class SaveLockedToDatabase
{
public function handle(Saving $event): void
{
if (isset($event->data['attributes']['isLocked'])) {
$isLocked = (bool) $event->data['attributes']['isLocked'];
$discussion = $event->discussion;
$actor = $event->actor;
$actor->assertCan('lock', $discussion);
if ((bool) $discussion->is_locked === $isLocked) {
return;
}
$discussion->is_locked = $isLocked;
$discussion->raise(
$discussion->is_locked
? new DiscussionWasLocked($discussion, $actor)
: new DiscussionWasUnlocked($discussion, $actor)
);
}
}
}

View File

@@ -1,44 +0,0 @@
<?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\Lock\Query;
use Flarum\Filter\FilterInterface;
use Flarum\Filter\FilterState;
use Flarum\Search\AbstractRegexGambit;
use Flarum\Search\SearchState;
use Illuminate\Database\Query\Builder;
class LockedFilterGambit extends AbstractRegexGambit implements FilterInterface
{
protected function getGambitPattern(): string
{
return 'is:locked';
}
protected function conditions(SearchState $search, array $matches, bool $negate): void
{
$this->constrain($search->getQuery(), $negate);
}
public function getFilterKey(): string
{
return 'locked';
}
public function filter(FilterState $filterState, string|array $filterValue, bool $negate): void
{
$this->constrain($filterState->getQuery(), $negate);
}
protected function constrain(Builder $query, bool $negate): void
{
$query->where('is_locked', ! $negate);
}
}

View File

@@ -1,3 +1,3 @@
/*! For license information please see admin.js.LICENSE.txt */
(()=>{var t={n:e=>{var o=e&&e.__esModule?()=>e.default:()=>e;return t.d(o,{a:o}),o},d:(e,o)=>{for(var r in o)t.o(o,r)&&!t.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:o[r]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},e={};(()=>{"use strict";t.r(e);const o=flarum.reg.get("core","admin/app");var r=t.n(o);const i=flarum.reg.get("core","common/app");var n=t.n(i);const a=flarum.reg.get("core","common/extend"),l=flarum.reg.get("core","common/utils/BasicEditorDriver");var c=t.n(l);const s=flarum.reg.get("core","common/utils/styleSelectedText");var d=t.n(s);const f=flarum.reg.get("core","common/Component");var u=t.n(f);class p extends(u()){view(t){return m("div",{className:"MarkdownToolbar"},t.children)}}flarum.reg.add("flarum-markdown","common/components/MarkdownToolbar",p);const k=flarum.reg.get("core","common/helpers/icon");var h=t.n(k);const g=flarum.reg.get("core","common/components/Tooltip");var x=t.n(g);class b extends(u()){oncreate(t){super.oncreate(t)}view(){const t=m("button",{className:"Button Button--icon Button--link",type:"button","data-hotkey":this.attrs.hotkey,onkeydown:this.keydown.bind(this),onclick:this.attrs.onclick},h()(this.attrs.icon));return this.attrs.title?m(x(),{text:this.attrs.title},t):t}keydown(t){" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this.element.click())}}flarum.reg.add("flarum-markdown","common/components/MarkdownButton",b);const y=flarum.reg.get("core","common/utils/ItemList");var v=t.n(y);const w=navigator.userAgent.match(/Macintosh/)?"⌘":"ctrl",_={header:{prefix:"### "},bold:{prefix:"**",suffix:"**",trimFirst:!0},italic:{prefix:"_",suffix:"_",trimFirst:!0},strikethrough:{prefix:"~~",suffix:"~~",trimFirst:!0},quote:{prefix:"> ",multiline:!0,surroundWithNewlines:!0},code:{prefix:"`",suffix:"`",blockPrefix:"```",blockSuffix:"```"},link:{prefix:"[",suffix:"](https://)",replaceNext:"https://",scanFor:"https?://"},image:{prefix:"![",suffix:"](https://)",replaceNext:"https://",scanFor:"https?://"},unordered_list:{prefix:"- ",multiline:!0,surroundWithNewlines:!0},ordered_list:{prefix:"1. ",multiline:!0,orderedList:!0},spoiler:{prefix:">!",suffix:"!<",blockPrefix:">! ",multiline:!0,trimFirst:!0}},T=(t,e)=>{d()(e.el,_[t])};function S(t,e,o){return function(r){r.key===e&&(r.metaKey&&"⌘"===w||r.ctrlKey&&"ctrl"===w)&&(r.preventDefault(),T(t,o))}}function M(t){const e="function"==typeof t?t():new(v());function o(t,e){return n().translator.trans("flarum-markdown.lib.composer.".concat(t,"_tooltip"))+(e?" <".concat(w,"-").concat(e,">"):"")}const r=t=>()=>T(t,this.attrs.composer.editor);return e.add("header",m(b,{title:o("header"),icon:"fas fa-heading",onclick:r("header")}),1e3),e.add("bold",m(b,{title:o("bold","b"),icon:"fas fa-bold",onclick:r("bold")}),900),e.add("italic",m(b,{title:o("italic","i"),icon:"fas fa-italic",onclick:r("italic")}),800),e.add("strikethrough",m(b,{title:o("strikethrough"),icon:"fas fa-strikethrough",onclick:r("strikethrough")}),700),e.add("quote",m(b,{title:o("quote"),icon:"fas fa-quote-left",onclick:r("quote")}),600),e.add("spoiler",m(b,{title:o("spoiler"),icon:"fas fa-exclamation-triangle",onclick:r("spoiler")}),500),e.add("code",m(b,{title:o("code"),icon:"fas fa-code",onclick:r("code")}),400),e.add("link",m(b,{title:o("link"),icon:"fas fa-link",onclick:r("link")}),300),e.add("image",m(b,{title:o("image"),icon:"fas fa-image",onclick:r("image")}),200),e.add("unordered_list",m(b,{title:o("unordered_list"),icon:"fas fa-list-ul",onclick:r("unordered_list")}),100),e.add("ordered_list",m(b,{title:o("ordered_list"),icon:"fas fa-list-ol",onclick:r("ordered_list")}),0),e}r().initializers.add("flarum-markdown",(function(t){(0,a.extend)(c().prototype,"keyHandlers",(function(t){t.add("bold",S("bold","b",this)),t.add("italic",S("italic","i",this))})),(0,a.override)("flarum/common/components/TextEditor","markdownToolbarItems",M),(0,a.extend)("flarum/common/components/TextEditor","toolbarItems",(function(t){t.add("markdown",m(p,{for:this.textareaId,setShortcutHandler:t=>shortcutHandler=t},this.markdownToolbarItems().toArray()),100)}))}))})(),module.exports=e})();
(()=>{var t={n:e=>{var o=e&&e.__esModule?()=>e.default:()=>e;return t.d(o,{a:o}),o},d:(e,o)=>{for(var r in o)t.o(o,r)&&!t.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:o[r]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},e={};(()=>{"use strict";t.r(e);const o=flarum.reg.get("core","admin/app");var r=t.n(o);const i=flarum.reg.get("core","common/app");var n=t.n(i);const a=flarum.reg.get("core","common/extend"),l=flarum.reg.get("core","common/utils/BasicEditorDriver");var c=t.n(l);const s=flarum.reg.get("core","common/utils/styleSelectedText");var d=t.n(s);const f=flarum.reg.get("core","common/Component");var u=t.n(f);class p extends(u()){view(t){return m("div",{className:"MarkdownToolbar"},t.children)}}flarum.reg.add("flarum-markdown","common/components/MarkdownToolbar",p);const k=flarum.reg.get("core","common/components/Icon");var h=t.n(k);const g=flarum.reg.get("core","common/components/Tooltip");var x=t.n(g);class b extends(u()){oncreate(t){super.oncreate(t)}view(){const t=m("button",{className:"Button Button--icon Button--link",type:"button","data-hotkey":this.attrs.hotkey,onkeydown:this.keydown.bind(this),onclick:this.attrs.onclick},m(h(),{name:this.attrs.icon}));return this.attrs.title?m(x(),{text:this.attrs.title},t):t}keydown(t){" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this.element.click())}}flarum.reg.add("flarum-markdown","common/components/MarkdownButton",b);const y=flarum.reg.get("core","common/utils/ItemList");var v=t.n(y);const w=navigator.userAgent.match(/Macintosh/)?"⌘":"ctrl",_={header:{prefix:"### "},bold:{prefix:"**",suffix:"**",trimFirst:!0},italic:{prefix:"_",suffix:"_",trimFirst:!0},strikethrough:{prefix:"~~",suffix:"~~",trimFirst:!0},quote:{prefix:"> ",multiline:!0,surroundWithNewlines:!0},code:{prefix:"`",suffix:"`",blockPrefix:"```",blockSuffix:"```"},link:{prefix:"[",suffix:"](https://)",replaceNext:"https://",scanFor:"https?://"},image:{prefix:"![",suffix:"](https://)",replaceNext:"https://",scanFor:"https?://"},unordered_list:{prefix:"- ",multiline:!0,surroundWithNewlines:!0},ordered_list:{prefix:"1. ",multiline:!0,orderedList:!0},spoiler:{prefix:">!",suffix:"!<",blockPrefix:">! ",multiline:!0,trimFirst:!0}},T=(t,e)=>{d()(e.el,_[t])};function S(t,e,o){return function(r){r.key===e&&(r.metaKey&&"⌘"===w||r.ctrlKey&&"ctrl"===w)&&(r.preventDefault(),T(t,o))}}function M(t){const e="function"==typeof t?t():new(v());function o(t,e){return n().translator.trans("flarum-markdown.lib.composer.".concat(t,"_tooltip"))+(e?" <".concat(w,"-").concat(e,">"):"")}const r=t=>()=>T(t,this.attrs.composer.editor);return e.add("header",m(b,{title:o("header"),icon:"fas fa-heading",onclick:r("header")}),1e3),e.add("bold",m(b,{title:o("bold","b"),icon:"fas fa-bold",onclick:r("bold")}),900),e.add("italic",m(b,{title:o("italic","i"),icon:"fas fa-italic",onclick:r("italic")}),800),e.add("strikethrough",m(b,{title:o("strikethrough"),icon:"fas fa-strikethrough",onclick:r("strikethrough")}),700),e.add("quote",m(b,{title:o("quote"),icon:"fas fa-quote-left",onclick:r("quote")}),600),e.add("spoiler",m(b,{title:o("spoiler"),icon:"fas fa-exclamation-triangle",onclick:r("spoiler")}),500),e.add("code",m(b,{title:o("code"),icon:"fas fa-code",onclick:r("code")}),400),e.add("link",m(b,{title:o("link"),icon:"fas fa-link",onclick:r("link")}),300),e.add("image",m(b,{title:o("image"),icon:"fas fa-image",onclick:r("image")}),200),e.add("unordered_list",m(b,{title:o("unordered_list"),icon:"fas fa-list-ul",onclick:r("unordered_list")}),100),e.add("ordered_list",m(b,{title:o("ordered_list"),icon:"fas fa-list-ol",onclick:r("ordered_list")}),0),e}r().initializers.add("flarum-markdown",(function(t){(0,a.extend)(c().prototype,"keyHandlers",(function(t){t.add("bold",S("bold","b",this)),t.add("italic",S("italic","i",this))})),(0,a.override)("flarum/common/components/TextEditor","markdownToolbarItems",M),(0,a.extend)("flarum/common/components/TextEditor","toolbarItems",(function(t){t.add("markdown",m(p,{for:this.textareaId,setShortcutHandler:t=>shortcutHandler=t},this.markdownToolbarItems().toArray()),100)}))}))})(),module.exports=e})();
//# sourceMappingURL=admin.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,3 @@
/*! For license information please see forum.js.LICENSE.txt */
(()=>{var t={n:e=>{var o=e&&e.__esModule?()=>e.default:()=>e;return t.d(o,{a:o}),o},d:(e,o)=>{for(var r in o)t.o(o,r)&&!t.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:o[r]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},e={};(()=>{"use strict";t.r(e);const o=flarum.reg.get("core","forum/app");var r=t.n(o);const i=flarum.reg.get("core","common/app");var n=t.n(i);const a=flarum.reg.get("core","common/extend"),l=flarum.reg.get("core","common/utils/BasicEditorDriver");var c=t.n(l);const s=flarum.reg.get("core","common/utils/styleSelectedText");var d=t.n(s);const f=flarum.reg.get("core","common/Component");var u=t.n(f);class p extends(u()){view(t){return m("div",{className:"MarkdownToolbar"},t.children)}}flarum.reg.add("flarum-markdown","common/components/MarkdownToolbar",p);const k=flarum.reg.get("core","common/helpers/icon");var h=t.n(k);const g=flarum.reg.get("core","common/components/Tooltip");var x=t.n(g);class b extends(u()){oncreate(t){super.oncreate(t)}view(){const t=m("button",{className:"Button Button--icon Button--link",type:"button","data-hotkey":this.attrs.hotkey,onkeydown:this.keydown.bind(this),onclick:this.attrs.onclick},h()(this.attrs.icon));return this.attrs.title?m(x(),{text:this.attrs.title},t):t}keydown(t){" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this.element.click())}}flarum.reg.add("flarum-markdown","common/components/MarkdownButton",b);const y=flarum.reg.get("core","common/utils/ItemList");var v=t.n(y);const w=navigator.userAgent.match(/Macintosh/)?"⌘":"ctrl",_={header:{prefix:"### "},bold:{prefix:"**",suffix:"**",trimFirst:!0},italic:{prefix:"_",suffix:"_",trimFirst:!0},strikethrough:{prefix:"~~",suffix:"~~",trimFirst:!0},quote:{prefix:"> ",multiline:!0,surroundWithNewlines:!0},code:{prefix:"`",suffix:"`",blockPrefix:"```",blockSuffix:"```"},link:{prefix:"[",suffix:"](https://)",replaceNext:"https://",scanFor:"https?://"},image:{prefix:"![",suffix:"](https://)",replaceNext:"https://",scanFor:"https?://"},unordered_list:{prefix:"- ",multiline:!0,surroundWithNewlines:!0},ordered_list:{prefix:"1. ",multiline:!0,orderedList:!0},spoiler:{prefix:">!",suffix:"!<",blockPrefix:">! ",multiline:!0,trimFirst:!0}},T=(t,e)=>{d()(e.el,_[t])};function S(t,e,o){return function(r){r.key===e&&(r.metaKey&&"⌘"===w||r.ctrlKey&&"ctrl"===w)&&(r.preventDefault(),T(t,o))}}function M(t){const e="function"==typeof t?t():new(v());function o(t,e){return n().translator.trans("flarum-markdown.lib.composer.".concat(t,"_tooltip"))+(e?" <".concat(w,"-").concat(e,">"):"")}const r=t=>()=>T(t,this.attrs.composer.editor);return e.add("header",m(b,{title:o("header"),icon:"fas fa-heading",onclick:r("header")}),1e3),e.add("bold",m(b,{title:o("bold","b"),icon:"fas fa-bold",onclick:r("bold")}),900),e.add("italic",m(b,{title:o("italic","i"),icon:"fas fa-italic",onclick:r("italic")}),800),e.add("strikethrough",m(b,{title:o("strikethrough"),icon:"fas fa-strikethrough",onclick:r("strikethrough")}),700),e.add("quote",m(b,{title:o("quote"),icon:"fas fa-quote-left",onclick:r("quote")}),600),e.add("spoiler",m(b,{title:o("spoiler"),icon:"fas fa-exclamation-triangle",onclick:r("spoiler")}),500),e.add("code",m(b,{title:o("code"),icon:"fas fa-code",onclick:r("code")}),400),e.add("link",m(b,{title:o("link"),icon:"fas fa-link",onclick:r("link")}),300),e.add("image",m(b,{title:o("image"),icon:"fas fa-image",onclick:r("image")}),200),e.add("unordered_list",m(b,{title:o("unordered_list"),icon:"fas fa-list-ul",onclick:r("unordered_list")}),100),e.add("ordered_list",m(b,{title:o("ordered_list"),icon:"fas fa-list-ol",onclick:r("ordered_list")}),0),e}r().initializers.add("flarum-markdown",(function(t){(0,a.extend)(c().prototype,"keyHandlers",(function(t){t.add("bold",S("bold","b",this)),t.add("italic",S("italic","i",this))})),(0,a.override)("flarum/common/components/TextEditor","markdownToolbarItems",M),(0,a.extend)("flarum/common/components/TextEditor","toolbarItems",(function(t){t.add("markdown",m(p,{for:this.textareaId,setShortcutHandler:t=>shortcutHandler=t},this.markdownToolbarItems().toArray()),100)}))}))})(),module.exports=e})();
(()=>{var t={n:e=>{var o=e&&e.__esModule?()=>e.default:()=>e;return t.d(o,{a:o}),o},d:(e,o)=>{for(var r in o)t.o(o,r)&&!t.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:o[r]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},e={};(()=>{"use strict";t.r(e);const o=flarum.reg.get("core","forum/app");var r=t.n(o);const i=flarum.reg.get("core","common/app");var n=t.n(i);const a=flarum.reg.get("core","common/extend"),l=flarum.reg.get("core","common/utils/BasicEditorDriver");var c=t.n(l);const s=flarum.reg.get("core","common/utils/styleSelectedText");var d=t.n(s);const f=flarum.reg.get("core","common/Component");var u=t.n(f);class p extends(u()){view(t){return m("div",{className:"MarkdownToolbar"},t.children)}}flarum.reg.add("flarum-markdown","common/components/MarkdownToolbar",p);const k=flarum.reg.get("core","common/components/Icon");var h=t.n(k);const g=flarum.reg.get("core","common/components/Tooltip");var x=t.n(g);class b extends(u()){oncreate(t){super.oncreate(t)}view(){const t=m("button",{className:"Button Button--icon Button--link",type:"button","data-hotkey":this.attrs.hotkey,onkeydown:this.keydown.bind(this),onclick:this.attrs.onclick},m(h(),{name:this.attrs.icon}));return this.attrs.title?m(x(),{text:this.attrs.title},t):t}keydown(t){" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this.element.click())}}flarum.reg.add("flarum-markdown","common/components/MarkdownButton",b);const y=flarum.reg.get("core","common/utils/ItemList");var v=t.n(y);const w=navigator.userAgent.match(/Macintosh/)?"⌘":"ctrl",_={header:{prefix:"### "},bold:{prefix:"**",suffix:"**",trimFirst:!0},italic:{prefix:"_",suffix:"_",trimFirst:!0},strikethrough:{prefix:"~~",suffix:"~~",trimFirst:!0},quote:{prefix:"> ",multiline:!0,surroundWithNewlines:!0},code:{prefix:"`",suffix:"`",blockPrefix:"```",blockSuffix:"```"},link:{prefix:"[",suffix:"](https://)",replaceNext:"https://",scanFor:"https?://"},image:{prefix:"![",suffix:"](https://)",replaceNext:"https://",scanFor:"https?://"},unordered_list:{prefix:"- ",multiline:!0,surroundWithNewlines:!0},ordered_list:{prefix:"1. ",multiline:!0,orderedList:!0},spoiler:{prefix:">!",suffix:"!<",blockPrefix:">! ",multiline:!0,trimFirst:!0}},T=(t,e)=>{d()(e.el,_[t])};function S(t,e,o){return function(r){r.key===e&&(r.metaKey&&"⌘"===w||r.ctrlKey&&"ctrl"===w)&&(r.preventDefault(),T(t,o))}}function M(t){const e="function"==typeof t?t():new(v());function o(t,e){return n().translator.trans("flarum-markdown.lib.composer.".concat(t,"_tooltip"))+(e?" <".concat(w,"-").concat(e,">"):"")}const r=t=>()=>T(t,this.attrs.composer.editor);return e.add("header",m(b,{title:o("header"),icon:"fas fa-heading",onclick:r("header")}),1e3),e.add("bold",m(b,{title:o("bold","b"),icon:"fas fa-bold",onclick:r("bold")}),900),e.add("italic",m(b,{title:o("italic","i"),icon:"fas fa-italic",onclick:r("italic")}),800),e.add("strikethrough",m(b,{title:o("strikethrough"),icon:"fas fa-strikethrough",onclick:r("strikethrough")}),700),e.add("quote",m(b,{title:o("quote"),icon:"fas fa-quote-left",onclick:r("quote")}),600),e.add("spoiler",m(b,{title:o("spoiler"),icon:"fas fa-exclamation-triangle",onclick:r("spoiler")}),500),e.add("code",m(b,{title:o("code"),icon:"fas fa-code",onclick:r("code")}),400),e.add("link",m(b,{title:o("link"),icon:"fas fa-link",onclick:r("link")}),300),e.add("image",m(b,{title:o("image"),icon:"fas fa-image",onclick:r("image")}),200),e.add("unordered_list",m(b,{title:o("unordered_list"),icon:"fas fa-list-ul",onclick:r("unordered_list")}),100),e.add("ordered_list",m(b,{title:o("ordered_list"),icon:"fas fa-list-ol",onclick:r("ordered_list")}),0),e}r().initializers.add("flarum-markdown",(function(t){(0,a.extend)(c().prototype,"keyHandlers",(function(t){t.add("bold",S("bold","b",this)),t.add("italic",S("italic","i",this))})),(0,a.override)("flarum/common/components/TextEditor","markdownToolbarItems",M),(0,a.extend)("flarum/common/components/TextEditor","toolbarItems",(function(t){t.add("markdown",m(p,{for:this.textareaId,setShortcutHandler:t=>shortcutHandler=t},this.markdownToolbarItems().toArray()),100)}))}))})(),module.exports=e})();
//# sourceMappingURL=forum.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
import Component from 'flarum/common/Component';
import icon from 'flarum/common/helpers/icon';
import Icon from 'flarum/common/components/Icon';
import Tooltip from 'flarum/common/components/Tooltip';
export default class MarkdownButton extends Component {
@@ -16,7 +16,7 @@ export default class MarkdownButton extends Component {
onkeydown={this.keydown.bind(this)}
onclick={this.attrs.onclick}
>
{icon(this.attrs.icon)}
<Icon name={this.attrs.icon} />
</button>
);

View File

@@ -9,25 +9,22 @@
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\Api\Context;
use Flarum\Api\Endpoint;
use Flarum\Api\Resource;
use Flarum\Api\Schema;
use Flarum\Approval\Event\PostWasApproved;
use Flarum\Extend;
use Flarum\Group\Group;
use Flarum\Mentions\Api\LoadMentionedByRelationship;
use Flarum\Mentions\Api\PostResourceFields;
use Flarum\Post\Event\Deleted;
use Flarum\Post\Event\Hidden;
use Flarum\Post\Event\Posted;
use Flarum\Post\Event\Restored;
use Flarum\Post\Event\Revised;
use Flarum\Post\Filter\PostFilterer;
use Flarum\Post\Filter\PostSearcher;
use Flarum\Post\Post;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\Tag;
use Flarum\Search\Database\DatabaseSearchDriver;
use Flarum\User\User;
return [
@@ -60,50 +57,49 @@ return [
->namespace('flarum-mentions', __DIR__.'/views'),
(new Extend\Notification())
->type(Notification\PostMentionedBlueprint::class, PostSerializer::class, ['alert'])
->type(Notification\UserMentionedBlueprint::class, PostSerializer::class, ['alert'])
->type(Notification\GroupMentionedBlueprint::class, PostSerializer::class, ['alert']),
->type(Notification\PostMentionedBlueprint::class, ['alert'])
->type(Notification\UserMentionedBlueprint::class, ['alert'])
->type(Notification\GroupMentionedBlueprint::class, ['alert']),
(new Extend\ApiSerializer(BasicPostSerializer::class))
->hasMany('mentionedBy', BasicPostSerializer::class)
->hasMany('mentionsPosts', BasicPostSerializer::class)
->hasMany('mentionsUsers', BasicUserSerializer::class)
->hasMany('mentionsGroups', GroupSerializer::class)
->attribute('mentionedByCount', function (BasicPostSerializer $serializer, Post $post) {
// Only if it was eager loaded.
return $post->getAttribute('mentioned_by_count') ?? 0;
(new Extend\ApiResource(Resource\PostResource::class))
->fields(PostResourceFields::class)
->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\Endpoint {
return $endpoint->addDefaultInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion']);
})
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index {
return $endpoint->eagerLoad(['mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionsPosts.discussion', 'mentionsGroups']);
}),
(new Extend\ApiController(Controller\ShowDiscussionController::class))
->addInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion'])
->load([
'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user',
'posts.mentionsPosts.discussion', 'posts.mentionsGroups'
])
->loadWhere('posts.mentionedBy', LoadMentionedByRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadMentionedByRelationship::countRelation(...)),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index {
return $endpoint->eagerLoadWhenIncluded([
'firstPost' => [
'firstPost.mentionsUsers', 'firstPost.mentionsPosts',
'firstPost.mentionsPosts.user', 'firstPost.mentionsPosts.discussion', 'firstPost.mentionsGroups',
],
'lastPost' => [
'lastPost.mentionsUsers', 'lastPost.mentionsPosts',
'lastPost.mentionsPosts.user', 'lastPost.mentionsPosts.discussion', 'lastPost.mentionsGroups',
],
]);
})
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Show {
return $endpoint->addDefaultInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion'])
->eagerLoadWhenIncluded([
'posts' => [
'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user',
'posts.mentionsPosts.discussion', 'posts.mentionsGroups'
],
]);
}),
(new Extend\ApiController(Controller\ListDiscussionsController::class))
->load([
'firstPost.mentionsUsers', 'firstPost.mentionsPosts',
'firstPost.mentionsPosts.user', 'firstPost.mentionsPosts.discussion', 'firstPost.mentionsGroups',
'lastPost.mentionsUsers', 'lastPost.mentionsPosts',
'lastPost.mentionsPosts.user', 'lastPost.mentionsPosts.discussion', 'lastPost.mentionsGroups',
(new Extend\ApiResource(Resource\UserResource::class))
->fields(fn () => [
Schema\Boolean::make('canMentionGroups')
->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id)
->get(fn (User $user) => $user->can('mentionGroups')),
]),
(new Extend\ApiController(Controller\ShowPostController::class))
->addInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion'])
// We wouldn't normally need to eager load on a single model,
// but we do so here for visibility scoping.
->loadWhere('mentionedBy', LoadMentionedByRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadMentionedByRelationship::countRelation(...)),
(new Extend\ApiController(Controller\ListPostsController::class))
->addInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion'])
->load(['mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionsPosts.discussion', 'mentionsGroups'])
->loadWhere('mentionedBy', LoadMentionedByRelationship::mutateRelation(...))
->prepareDataForSerialization(LoadMentionedByRelationship::countRelation(...)),
(new Extend\Settings)
->serializeToForum('allowUsernameMentionFormat', 'flarum-mentions.allow_username_format', 'boolval'),
@@ -115,34 +111,34 @@ return [
->listen(Hidden::class, Listener\UpdateMentionsMetadataWhenInvisible::class)
->listen(Deleted::class, Listener\UpdateMentionsMetadataWhenInvisible::class),
(new Extend\Filter(PostFilterer::class))
->addFilter(Filter\MentionedFilter::class)
->addFilter(Filter\MentionedPostFilter::class),
(new Extend\ApiSerializer(CurrentUserSerializer::class))
->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user): bool {
return $user->can('mentionGroups');
}),
(new Extend\SearchDriver(DatabaseSearchDriver::class))
->addFilter(PostSearcher::class, Filter\MentionedFilter::class)
->addFilter(PostSearcher::class, Filter\MentionedPostFilter::class),
// Tag mentions
(new Extend\Conditional())
->whenExtensionEnabled('flarum-tags', [
->whenExtensionEnabled('flarum-tags', fn () => [
(new Extend\Formatter)
->render(Formatter\FormatTagMentions::class)
->unparse(Formatter\UnparseTagMentions::class),
(new Extend\ApiSerializer(BasicPostSerializer::class))
->hasMany('mentionsTags', TagSerializer::class),
(new Extend\ApiController(Controller\ShowDiscussionController::class))
->load(['posts.mentionsTags']),
(new Extend\ApiController(Controller\ListDiscussionsController::class))
->load([
'firstPost.mentionsTags', 'lastPost.mentionsTags',
(new Extend\ApiResource(Resource\PostResource::class))
->fields(fn () => [
Schema\Relationship\ToMany::make('mentionsTags')
->type('tags'),
]),
(new Extend\ApiController(Controller\ListPostsController::class))
->load(['mentionsTags']),
(new Extend\ApiResource(Resource\DiscussionResource::class))
->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Show {
return $endpoint->eagerLoadWhenIncluded(['posts' => ['posts.mentionsTags']]);
})
->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index {
return $endpoint->eagerLoadWhenIncluded(['firstPost' => ['firstPost.mentionsTags'], 'lastPost' => ['lastPost.mentionsTags']]);
}),
(new Extend\ApiResource(Resource\PostResource::class))
->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\Endpoint {
return $endpoint->eagerLoad(['mentionsTags']);
}),
]),
];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,45 +2,33 @@ import app from 'flarum/forum/app';
import { extend } from 'flarum/common/extend';
import TextEditorButton from 'flarum/common/components/TextEditorButton';
import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable';
import AutocompleteReader from 'flarum/common/utils/AutocompleteReader';
import { throttle } from 'flarum/common/utils/throttleDebounce';
import AutocompleteDropdown from './fragments/AutocompleteDropdown';
import MentionableModels from './mentionables/MentionableModels';
export default function addComposerAutocomplete() {
const $container = $('<div class="ComposerBody-mentionsDropdownContainer"></div>');
const dropdown = new AutocompleteDropdown();
extend('flarum/common/components/TextEditor', 'onbuild', function () {
this.mentionsDropdown = new AutocompleteDropdown();
this.searchMentions = throttle(250, (mentionables, buildSuggestions) => mentionables.search().then(buildSuggestions));
const $editor = this.$('.TextEditor-editor').wrap('<div class="ComposerBody-mentionsWrapper"></div>');
this.navigator = new KeyboardNavigatable();
this.navigator
.when(() => dropdown.active)
.onUp(() => dropdown.navigate(-1))
.onDown(() => dropdown.navigate(1))
.onSelect(dropdown.complete.bind(dropdown))
.onCancel(dropdown.hide.bind(dropdown))
.when(() => this.mentionsDropdown.active)
.onUp(() => this.mentionsDropdown.navigate(-1))
.onDown(() => this.mentionsDropdown.navigate(1))
.onSelect(this.mentionsDropdown.complete.bind(this.mentionsDropdown))
.onCancel(this.mentionsDropdown.hide.bind(this.mentionsDropdown))
.bindTo($editor);
$editor.after($container);
$editor.after($('<div class="ComposerBody-mentionsDropdownContainer"></div>'));
});
extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) {
let relMentionStart;
let absMentionStart;
let matchTyped;
let mentionables = new MentionableModels({
onmouseenter: function () {
dropdown.setIndex($(this).parent().index());
},
onclick: (replacement) => {
this.attrs.composer.editor.replaceBeforeCursor(absMentionStart - 1, replacement + ' ');
dropdown.hide();
},
});
const suggestionsInputListener = () => {
const selection = this.attrs.composer.editor.getSelectionRange();
@@ -48,30 +36,27 @@ export default function addComposerAutocomplete() {
if (selection[1] - cursor > 0) return;
// Search backwards from the cursor for a mention triggering symbol. If we find one,
// we will want to show the correct autocomplete dropdown!
// Check classes implementing the IMentionableModel interface to see triggering symbols.
const lastChunk = this.attrs.composer.editor.getLastNChars(30);
absMentionStart = 0;
let activeFormat = null;
for (let i = lastChunk.length - 1; i >= 0; i--) {
const character = lastChunk.substr(i, 1);
activeFormat = app.mentionFormats.get(character);
const autocompleteReader = new AutocompleteReader((character) => !!(activeFormat = app.mentionFormats.get(character)));
const autocompleting = autocompleteReader.check(this.attrs.composer.editor.getLastNChars(30), cursor, /\S+/);
if (activeFormat && (i === 0 || /\s/.test(lastChunk.substr(i - 1, 1)))) {
relMentionStart = i + 1;
absMentionStart = cursor - lastChunk.length + i + 1;
mentionables.init(activeFormat.makeMentionables());
break;
}
}
const mentionsDropdown = this.mentionsDropdown;
let mentionables = new MentionableModels({
onmouseenter: function () {
mentionsDropdown.setIndex($(this).parent().index());
},
onclick: (replacement) => {
this.attrs.composer.editor.replaceBeforeCursor(autocompleting.absoluteStart - 1, replacement + ' ');
this.mentionsDropdown.hide();
},
});
dropdown.hide();
dropdown.active = false;
this.mentionsDropdown.hide();
this.mentionsDropdown.active = false;
if (absMentionStart) {
const typed = lastChunk.substring(relMentionStart).toLowerCase();
matchTyped = activeFormat.queryFromTyped(typed);
if (autocompleting) {
mentionables.init(activeFormat.makeMentionables());
matchTyped = activeFormat.queryFromTyped(autocompleting.typed);
if (!matchTyped) return;
@@ -83,14 +68,14 @@ export default function addComposerAutocomplete() {
const suggestions = mentionables.buildSuggestions();
if (suggestions.length) {
dropdown.items = suggestions;
m.render($container[0], dropdown.render());
this.mentionsDropdown.items = suggestions;
m.render(this.$('.ComposerBody-mentionsDropdownContainer')[0], this.mentionsDropdown.render());
dropdown.show();
const coordinates = this.attrs.composer.editor.getCaretCoordinates(absMentionStart);
const width = dropdown.$().outerWidth();
const height = dropdown.$().outerHeight();
const parent = dropdown.$().offsetParent();
this.mentionsDropdown.show();
const coordinates = this.attrs.composer.editor.getCaretCoordinates(autocompleting.absoluteStart);
const width = this.mentionsDropdown.$().outerWidth();
const height = this.mentionsDropdown.$().outerHeight();
const parent = this.mentionsDropdown.$().offsetParent();
let left = coordinates.left;
let top = coordinates.top + 15;
@@ -106,21 +91,21 @@ export default function addComposerAutocomplete() {
top = Math.max(-(parent.offset().top - $(document).scrollTop()), top);
left = Math.max(-parent.offset().left, left);
dropdown.show(left, top);
this.mentionsDropdown.show(left, top);
} else {
dropdown.active = false;
dropdown.hide();
this.mentionsDropdown.active = false;
this.mentionsDropdown.hide();
}
};
dropdown.active = true;
this.mentionsDropdown.active = true;
buildSuggestions();
dropdown.setIndex(0);
dropdown.$().scrollTop(0);
this.mentionsDropdown.setIndex(0);
this.mentionsDropdown.$().scrollTop(0);
mentionables.search()?.then(buildSuggestions);
this.searchMentions(mentionables, buildSuggestions);
}
};

View File

@@ -5,7 +5,7 @@ import Link from 'flarum/common/components/Link';
import PostPreview from 'flarum/forum/components/PostPreview';
import punctuateSeries from 'flarum/common/helpers/punctuateSeries';
import username from 'flarum/common/helpers/username';
import icon from 'flarum/common/helpers/icon';
import Icon from 'flarum/common/components/Icon';
import Button from 'flarum/common/components/Button';
import MentionedByModal from './components/MentionedByModal';
@@ -54,7 +54,9 @@ export default function addMentionedByList() {
}}
>
<span className="PostPreview-content">
<span className="PostPreview-badge Avatar">{icon('fas fa-reply-all')}</span>
<span className="PostPreview-badge Avatar">
<Icon name={'fas fa-reply-all'} />
</span>
<span>
{app.translator.trans('flarum-mentions.forum.post.mentioned_by_more_text', { count: post.mentionedByCount() - replies.length })}
</span>
@@ -145,7 +147,7 @@ export default function addMentionedByList() {
'replies',
<div className="Post-mentionedBy">
<span className="Post-mentionedBy-summary">
{icon('fas fa-reply')}
<Icon name={'fas fa-reply'} />
{app.translator.trans(`flarum-mentions.forum.post.mentioned_by${repliers[0].user() === app.session.user ? '_self' : ''}_text`, {
count: names.length,
users: punctuateSeries(names),

View File

@@ -5,7 +5,7 @@ import CommentPost from 'flarum/forum/components/CommentPost';
import reply from './utils/reply';
export default function () {
export default function addPostReplyAction() {
extend(CommentPost.prototype, 'actionItems', function (items) {
const post = this.attrs.post;

View File

@@ -6,6 +6,7 @@ import type Post from 'flarum/common/models/Post';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import Button from 'flarum/common/components/Button';
import MentionedByModalState from '../state/MentionedByModalState';
import Form from 'flarum/common/components/Form';
export interface IMentionedByModalAttrs extends IInternalModalAttrs {
post: Post;
@@ -58,13 +59,13 @@ export default class MentionedByModal<CustomAttrs extends IMentionedByModalAttrs
</div>
{this.state.hasNext() && (
<div className="Modal-footer">
<div className="Form Form--centered">
<Form className="Form--centered">
<div className="Form-group">
<Button className="Button Button--block" onclick={() => this.state.loadNext()} loading={this.state.isLoadingNext()}>
{app.translator.trans('flarum-mentions.forum.mentioned_by.load_more_button')}
</Button>
</div>
</div>
</Form>
</div>
)}
</>

View File

@@ -2,6 +2,9 @@ import Extend from 'flarum/common/extenders';
import Post from 'flarum/common/models/Post';
import User from 'flarum/common/models/User';
import MentionsUserPage from './components/MentionsUserPage';
import PostMentionedNotification from './components/PostMentionedNotification';
import UserMentionedNotification from './components/UserMentionedNotification';
import GroupMentionedNotification from './components/GroupMentionedNotification';
export default [
new Extend.Routes() //
@@ -11,6 +14,11 @@ export default [
.hasMany<Post>('mentionedBy')
.attribute<number>('mentionedByCount'),
new Extend.Notification() //
.add('postMentioned', PostMentionedNotification)
.add('userMentioned', UserMentionedNotification)
.add('groupMentioned', GroupMentionedNotification),
new Extend.Model(User) //
.attribute<boolean>('canMentionGroups'),
];

View File

@@ -5,7 +5,6 @@ import './components/UserMentionedNotification';
import './fragments/AutocompleteDropdown';
import './fragments/PostQuoteButton';
import './utils/getCleanDisplayName';
import './utils/getMentionText';
import './utils/reply';
import './utils/selectedText';
import './utils/textFormatter';

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