diff --git a/extensions/likes/extend.php b/extensions/likes/extend.php index a61c0338d..881ff9c6a 100644 --- a/extensions/likes/extend.php +++ b/extensions/likes/extend.php @@ -13,12 +13,15 @@ use Flarum\Api\Controller; use Flarum\Api\Serializer\BasicUserSerializer; use Flarum\Api\Serializer\PostSerializer; use Flarum\Extend; +use Flarum\Likes\Api\LoadLikesRelationship; 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\Post; +use Flarum\User\Filter\UserFilterer; use Flarum\User\User; return [ @@ -41,19 +44,32 @@ return [ ->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\ApiController(Controller\ShowDiscussionController::class)) - ->addInclude('posts.likes'), + ->addInclude('posts.likes') + ->loadWhere('posts.likes', [LoadLikesRelationship::class, 'mutateRelation']) + ->prepareDataForSerialization([LoadLikesRelationship::class, 'countRelation']), (new Extend\ApiController(Controller\ListPostsController::class)) - ->addInclude('likes'), + ->addInclude('likes') + ->loadWhere('likes', [LoadLikesRelationship::class, 'mutateRelation']) + ->prepareDataForSerialization([LoadLikesRelationship::class, 'countRelation']), (new Extend\ApiController(Controller\ShowPostController::class)) - ->addInclude('likes'), + ->addInclude('likes') + ->loadWhere('likes', [LoadLikesRelationship::class, 'mutateRelation']) + ->prepareDataForSerialization([LoadLikesRelationship::class, 'countRelation']), (new Extend\ApiController(Controller\CreatePostController::class)) - ->addInclude('likes'), + ->addInclude('likes') + ->loadWhere('likes', [LoadLikesRelationship::class, 'mutateRelation']) + ->prepareDataForSerialization([LoadLikesRelationship::class, 'countRelation']), (new Extend\ApiController(Controller\UpdatePostController::class)) - ->addInclude('likes'), + ->addInclude('likes') + ->loadWhere('likes', [LoadLikesRelationship::class, 'mutateRelation']) + ->prepareDataForSerialization([LoadLikesRelationship::class, 'countRelation']), (new Extend\Event()) ->listen(PostWasLiked::class, Listener\SendNotificationWhenPostIsLiked::class) @@ -63,6 +79,9 @@ return [ (new Extend\Filter(PostFilterer::class)) ->addFilter(LikedByFilter::class), + (new Extend\Filter(UserFilterer::class)) + ->addFilter(LikedFilter::class), + (new Extend\Settings()) ->default('flarum-likes.like_own_post', true), diff --git a/extensions/likes/js/src/@types/shims.d.ts b/extensions/likes/js/src/@types/shims.d.ts new file mode 100644 index 000000000..fb573ff95 --- /dev/null +++ b/extensions/likes/js/src/@types/shims.d.ts @@ -0,0 +1,9 @@ +import Post from 'flarum/common/models/Post'; +import User from 'flarum/common/models/User'; + +declare module 'flarum/common/models/Post' { + export default interface Post { + likes(): User[]; + likesCount(): number; + } +} diff --git a/extensions/likes/js/src/forum/addLikesList.js b/extensions/likes/js/src/forum/addLikesList.js index 9ac377dd8..322436809 100644 --- a/extensions/likes/js/src/forum/addLikesList.js +++ b/extensions/likes/js/src/forum/addLikesList.js @@ -7,6 +7,7 @@ import username from 'flarum/common/helpers/username'; import icon from 'flarum/common/helpers/icon'; import PostLikesModal from './components/PostLikesModal'; +import Button from '@flarum/core/src/common/components/Button'; export default function () { extend(CommentPost.prototype, 'footerItems', function (items) { @@ -15,7 +16,7 @@ export default function () { if (likes && likes.length) { const limit = 4; - const overLimit = likes.length > limit; + const overLimit = post.likesCount() > limit; // Construct a list of names of users who have liked this post. Make sure the // current user is first in the list, and cap a maximum of 4 items. @@ -34,19 +35,24 @@ export default function () { // others" name to the end of the list. Clicking on it will display a modal // with a full list of names. if (overLimit) { - const count = likes.length - names.length; + const count = post.likesCount() - names.length; + const label = app.translator.trans('flarum-likes.forum.post.others_link', { count }); - names.push( - { - e.preventDefault(); - app.modal.show(PostLikesModal, { post }); - }} - > - {app.translator.trans('flarum-likes.forum.post.others_link', { count })} - - ); + if (app.forum.attribute('canSearchUsers')) { + names.push( + + ); + } else { + names.push({label}); + } } items.add( diff --git a/extensions/likes/js/src/forum/components/PostLikesModal.js b/extensions/likes/js/src/forum/components/PostLikesModal.js deleted file mode 100644 index 57703ef49..000000000 --- a/extensions/likes/js/src/forum/components/PostLikesModal.js +++ /dev/null @@ -1,31 +0,0 @@ -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 username from 'flarum/common/helpers/username'; - -export default class PostLikesModal extends Modal { - className() { - return 'PostLikesModal Modal--small'; - } - - title() { - return app.translator.trans('flarum-likes.forum.post_likes.title'); - } - - content() { - return ( -
- -
- ); - } -} diff --git a/extensions/likes/js/src/forum/components/PostLikesModal.tsx b/extensions/likes/js/src/forum/components/PostLikesModal.tsx new file mode 100644 index 000000000..e4ab35494 --- /dev/null +++ b/extensions/likes/js/src/forum/components/PostLikesModal.tsx @@ -0,0 +1,72 @@ +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 username from 'flarum/common/helpers/username'; +import type { IInternalModalAttrs } from 'flarum/common/components/Modal'; +import type Post from 'flarum/common/models/Post'; +import type Mithril from 'mithril'; +import PostLikesModalState from '../states/PostLikesModalState'; +import Button from '@flarum/core/src/common/components/Button'; +import LoadingIndicator from '@flarum/core/src/common/components/LoadingIndicator'; + +export interface IPostLikesModalAttrs extends IInternalModalAttrs { + post: Post; +} + +export default class PostLikesModal extends Modal { + oninit(vnode: Mithril.VnodeDOM) { + super.oninit(vnode); + + this.state = new PostLikesModalState({ + filter: { + liked: this.attrs.post.id()!, + }, + }); + + this.state.refresh(); + } + + className() { + return 'PostLikesModal Modal--small'; + } + + title() { + return app.translator.trans('flarum-likes.forum.post_likes.title'); + } + + content() { + return ( + <> +
+ {this.state.isInitialLoading() ? ( + + ) : ( +
    + {this.state.getPages().map((page) => + page.items.map((user) => ( +
  • + + {avatar(user)} {username(user)} + +
  • + )) + )} +
+ )} +
+ {this.state.hasNext() ? ( +
+
+
+ +
+
+
+ ) : null} + + ); + } +} diff --git a/extensions/likes/js/src/forum/extend.ts b/extensions/likes/js/src/forum/extend.ts index 6a95fc378..1a8b41bac 100644 --- a/extensions/likes/js/src/forum/extend.ts +++ b/extensions/likes/js/src/forum/extend.ts @@ -9,5 +9,6 @@ export default [ new Extend.Model(Post) // .hasMany('likes') + .attribute('likesCount') .attribute('canLike'), ]; diff --git a/extensions/likes/js/src/forum/states/PostLikesModalState.ts b/extensions/likes/js/src/forum/states/PostLikesModalState.ts new file mode 100644 index 000000000..baacedffc --- /dev/null +++ b/extensions/likes/js/src/forum/states/PostLikesModalState.ts @@ -0,0 +1,26 @@ +import PaginatedListState, { PaginatedListParams } from '@flarum/core/src/common/states/PaginatedListState'; +import User from 'flarum/common/models/User'; + +export interface PostLikesModalListParams extends PaginatedListParams { + filter: { + liked: string; + }; + page?: { + offset?: number; + limit: number; + }; +} + +export default class PostLikesModalState

extends PaginatedListState { + constructor(params: P, page: number = 1) { + const limit = 10; + + params.page = { ...(params.page || {}), limit }; + + super(params, page, limit); + } + + get type(): string { + return 'users'; + } +} diff --git a/extensions/likes/locale/en.yml b/extensions/likes/locale/en.yml index 19cac602e..f48275a58 100644 --- a/extensions/likes/locale/en.yml +++ b/extensions/likes/locale/en.yml @@ -35,6 +35,7 @@ flarum-likes: # These translations are used by the Users Who Like This modal dialog. post_likes: title: Users Who Like This + load_more_button: => core.ref.load_more # These translations are used in the Settings page. settings: diff --git a/extensions/likes/src/Api/LoadLikesRelationship.php b/extensions/likes/src/Api/LoadLikesRelationship.php new file mode 100644 index 000000000..21577b264 --- /dev/null +++ b/extensions/likes/src/Api/LoadLikesRelationship.php @@ -0,0 +1,61 @@ +getQuery()->getGrammar(); + + return $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($controller, $data): void + { + $loadable = null; + + if ($data instanceof Discussion) { + // @phpstan-ignore-next-line + $loadable = $data->newCollection($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'); + } + } +} diff --git a/extensions/likes/src/Query/LikedFilter.php b/extensions/likes/src/Query/LikedFilter.php new file mode 100644 index 000000000..d65a32bb3 --- /dev/null +++ b/extensions/likes/src/Query/LikedFilter.php @@ -0,0 +1,34 @@ +getQuery() + ->whereIn('id', function ($query) use ($likedId) { + $query->select('user_id') + ->from('post_likes') + ->where('post_id', $likedId); + }, 'and', $negate); + } +} diff --git a/extensions/likes/tests/integration/api/ListPostsTest.php b/extensions/likes/tests/integration/api/ListPostsTest.php new file mode 100644 index 000000000..59a8f7a43 --- /dev/null +++ b/extensions/likes/tests/integration/api/ListPostsTest.php @@ -0,0 +1,210 @@ +extension('flarum-likes'); + + $this->prepareDatabase([ + 'discussions' => [ + ['id' => 100, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 101, 'comment_count' => 1], + ], + 'posts' => [ + ['id' => 101, 'discussion_id' => 100, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => '

text

'], + ], + 'users' => [ + $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], + ['id' => 104, 'username' => 'user104', 'email' => '104@machine.local', 'is_email_confirmed' => 1], + ['id' => 105, 'username' => 'user105', 'email' => '105@machine.local', 'is_email_confirmed' => 1], + ['id' => 106, 'username' => 'user106', 'email' => '106@machine.local', 'is_email_confirmed' => 1], + ['id' => 107, 'username' => 'user107', 'email' => '107@machine.local', 'is_email_confirmed' => 1], + ['id' => 108, 'username' => 'user108', 'email' => '108@machine.local', 'is_email_confirmed' => 1], + ['id' => 109, 'username' => 'user109', 'email' => '109@machine.local', 'is_email_confirmed' => 1], + ['id' => 110, 'username' => 'user110', 'email' => '110@machine.local', 'is_email_confirmed' => 1], + ['id' => 111, 'username' => 'user111', 'email' => '111@machine.local', 'is_email_confirmed' => 1], + ['id' => 112, 'username' => 'user112', 'email' => '112@machine.local', 'is_email_confirmed' => 1], + ], + 'post_likes' => [ + ['user_id' => 102, 'post_id' => 101], + ['user_id' => 104, 'post_id' => 101], + ['user_id' => 105, 'post_id' => 101], + ['user_id' => 106, 'post_id' => 101], + ['user_id' => 107, 'post_id' => 101], + ['user_id' => 108, 'post_id' => 101], + ['user_id' => 109, 'post_id' => 101], + ['user_id' => 110, 'post_id' => 101], + ['user_id' => 2, 'post_id' => 101], + ['user_id' => 111, 'post_id' => 101], + ['user_id' => 112, 'post_id' => 101], + ], + 'group_permission' => [ + ['group_id' => Group::GUEST_ID, 'permission' => 'searchUsers'], + ], + ]); + } + + /** + * @test + */ + public function liked_filter_works() + { + $response = $this->send( + $this->request('GET', '/api/users') + ->withQueryParams([ + 'filter' => ['liked' => 101], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $ids = Arr::pluck($data, 'id'); + $this->assertEqualsCanonicalizing([ + 102, 104, 105, 106, 107, 108, 109, 110, 2, 111, 112 + ], $ids, 'IDs do not match'); + } + + /** + * @test + */ + public function liked_filter_works_negated() + { + $response = $this->send( + $this->request('GET', '/api/users') + ->withQueryParams([ + 'filter' => ['-liked' => 101], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + // Order-independent comparison + $ids = Arr::pluck($data, 'id'); + $this->assertEqualsCanonicalizing([1, 103], $ids, 'IDs do not match'); + } + + /** @test */ + public function likes_relation_returns_limited_results_and_shows_only_visible_posts_in_show_post_endpoint() + { + // List posts endpoint + $response = $this->send( + $this->request('GET', '/api/posts/101', [ + 'authenticatedAs' => 2, + ])->withQueryParams([ + 'include' => 'likes', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + $this->assertEquals(200, $response->getStatusCode()); + + $likes = $data['relationships']['likes']['data']; + + // Only displays a limited amount of likes + $this->assertCount(LoadLikesRelationship::$maxLikes, $likes); + // Displays the correct count of likes + $this->assertEquals(11, $data['attributes']['likesCount']); + // Of the limited amount of likes, the actor always appears + $this->assertEquals([2, 102, 104, 105], Arr::pluck($likes, 'id')); + } + + /** @test */ + public function likes_relation_returns_limited_results_and_shows_only_visible_posts_in_list_posts_endpoint() + { + // List posts endpoint + $response = $this->send( + $this->request('GET', '/api/posts', [ + 'authenticatedAs' => 2, + ])->withQueryParams([ + 'filter' => ['discussion' => 100], + 'include' => 'likes', + ]) + ); + + $data = json_decode($response->getBody()->getContents(), true)['data']; + + $this->assertEquals(200, $response->getStatusCode()); + + $likes = $data[0]['relationships']['likes']['data']; + + // Only displays a limited amount of likes + $this->assertCount(LoadLikesRelationship::$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 + $this->assertEquals([2, 102, 104, 105], Arr::pluck($likes, 'id')); + } + + /** + * @dataProvider likesIncludeProvider + * @test + */ + public function likes_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(string $include) + { + // Show discussion endpoint + $response = $this->send( + $this->request('GET', '/api/discussions/100', [ + 'authenticatedAs' => 2, + ])->withQueryParams([ + 'include' => $include, + ]) + ); + + $included = json_decode($response->getBody()->getContents(), true)['included']; + + $likes = collect($included) + ->where('type', 'posts') + ->where('id', 101) + ->first()['relationships']['likes']['data']; + + // Only displays a limited amount of likes + $this->assertCount(LoadLikesRelationship::$maxLikes, $likes); + // Displays the correct count of likes + $this->assertEquals(11, collect($included) + ->where('type', 'posts') + ->where('id', 101) + ->first()['attributes']['likesCount']); + // Of the limited amount of likes, the actor always appears + $this->assertEquals([2, 102, 104, 105], Arr::pluck($likes, 'id')); + } + + public function likesIncludeProvider(): array + { + return [ + ['posts,posts.likes'], + ['posts.likes'], + [''], + ]; + } +} diff --git a/extensions/mentions/js/src/forum/state/MentionedByModalState.ts b/extensions/mentions/js/src/forum/state/MentionedByModalState.ts index b2cdad8ca..b62c611b9 100644 --- a/extensions/mentions/js/src/forum/state/MentionedByModalState.ts +++ b/extensions/mentions/js/src/forum/state/MentionedByModalState.ts @@ -1,7 +1,7 @@ import PaginatedListState, { PaginatedListParams } from 'flarum/common/states/PaginatedListState'; import Post from 'flarum/common/models/Post'; -export interface MentionedByModalParams extends PaginatedListParams { +export interface MentionedByModalListParams extends PaginatedListParams { filter: { mentionedPost: string; }; @@ -12,7 +12,7 @@ export interface MentionedByModalParams extends PaginatedListParams { }; } -export default class MentionedByModalState

extends PaginatedListState { +export default class MentionedByModalState

extends PaginatedListState { constructor(params: P, page: number = 1) { const limit = 10; diff --git a/framework/core/src/Api/Controller/CreatePostController.php b/framework/core/src/Api/Controller/CreatePostController.php index af92f20e3..499415af4 100644 --- a/framework/core/src/Api/Controller/CreatePostController.php +++ b/framework/core/src/Api/Controller/CreatePostController.php @@ -74,6 +74,8 @@ class CreatePostController extends AbstractCreateController $discussion = $post->discussion; $discussion->posts = $discussion->posts()->whereVisibleTo($actor)->orderBy('created_at')->pluck('id'); + $this->loadRelations($post->newCollection([$post]), $this->extractInclude($request), $request); + return $post; } } diff --git a/framework/core/src/Api/Controller/UpdatePostController.php b/framework/core/src/Api/Controller/UpdatePostController.php index 5b3852e03..c4db51e8b 100644 --- a/framework/core/src/Api/Controller/UpdatePostController.php +++ b/framework/core/src/Api/Controller/UpdatePostController.php @@ -54,8 +54,12 @@ class UpdatePostController extends AbstractShowController $actor = RequestUtil::getActor($request); $data = Arr::get($request->getParsedBody(), 'data', []); - return $this->bus->dispatch( + $post = $this->bus->dispatch( new EditPost($id, $actor, $data) ); + + $this->loadRelations($post->newCollection([$post]), $this->extractInclude($request), $request); + + return $post; } } diff --git a/framework/core/src/User/User.php b/framework/core/src/User/User.php index 4231328b6..a15626d44 100644 --- a/framework/core/src/User/User.php +++ b/framework/core/src/User/User.php @@ -34,6 +34,7 @@ use Flarum\User\Exception\PermissionDeniedException; use Illuminate\Contracts\Filesystem\Factory; use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Support\Arr; +use Staudenmeir\EloquentEagerLimit\HasEagerLimit; /** * @property int $id @@ -55,6 +56,7 @@ class User extends AbstractModel { use EventGeneratorTrait; use ScopeVisibilityTrait; + use HasEagerLimit; /** * The attributes that should be mutated to dates.