('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.