From 1ab3029e7837071f3b05b587c5a26da5cc6eb10b Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Thu, 19 Sep 2024 17:01:58 +0100 Subject: [PATCH] feat: post search adapted with global search (#4019) --- extensions/likes/js/src/common/extend.ts | 7 + .../src/common/query/posts/LikedByGambit.ts | 16 ++ .../js/src/forum/components/LikesUserPage.ts | 16 ++ .../js/src/forum/components/LikesUserPage.tsx | 24 --- extensions/likes/js/src/forum/extend.ts | 4 + extensions/likes/js/src/forum/index.js | 18 ++- extensions/likes/locale/en.yml | 10 ++ extensions/likes/src/Query/LikedByFilter.php | 14 +- extensions/likes/src/Query/LikedFilter.php | 6 + extensions/mentions/js/src/admin/extend.ts | 3 + extensions/mentions/js/src/admin/index.js | 2 + extensions/mentions/js/src/common/extend.ts | 7 + .../src/common/query/posts/MentionedGambit.ts | 16 ++ .../src/forum/components/MentionsUserPage.js | 25 --- .../src/forum/components/MentionsUserPage.ts | 16 ++ extensions/mentions/js/src/forum/extend.ts | 4 + extensions/mentions/js/src/forum/index.js | 18 ++- extensions/mentions/locale/en.yml | 10 ++ .../mentions/src/Filter/MentionedFilter.php | 14 +- framework/core/js/src/common/GambitManager.ts | 4 +- .../core/js/src/common/helpers/highlight.tsx | 8 +- .../query/discussions/DiscussionGambit.ts | 16 ++ .../states/{PageState.js => PageState.ts} | 13 +- .../src/common/states/PaginatedListState.ts | 9 +- .../js/src/forum/components/CommentPost.js | 18 ++- .../forum/components/DiscussionListItem.tsx | 13 +- .../js/src/forum/components/IndexPage.tsx | 13 +- .../core/js/src/forum/components/PostList.tsx | 58 +++++++ .../js/src/forum/components/PostListItem.tsx | 30 ++++ .../js/src/forum/components/PostsPage.tsx | 144 ++++++++++++++++++ .../forum/components/PostsSearchSource.tsx | 76 +++++++++ .../js/src/forum/components/PostsUserPage.tsx | 109 ++----------- .../core/js/src/forum/components/Search.tsx | 2 + .../js/src/forum/components/SearchModal.tsx | 49 +++++- framework/core/js/src/forum/routes.ts | 1 + .../src/forum/states/DiscussionListState.ts | 7 - .../js/src/forum/states/GlobalSearchState.ts | 2 +- .../core/js/src/forum/states/PostListState.ts | 99 ++++++++++++ framework/core/less/common/Search.less | 2 +- framework/core/less/common/root.less | 4 + framework/core/less/forum.less | 1 + framework/core/less/forum/PostList.less | 19 +++ framework/core/locale/core.yml | 38 ++++- .../core/src/Api/Resource/PostResource.php | 4 +- .../core/src/Api/Resource/UserResource.php | 2 +- .../Discussion/Search/Filter/AuthorFilter.php | 7 + framework/core/src/Forum/Content/Posts.php | 83 ++++++++++ framework/core/src/Forum/routes.php | 6 + .../core/src/Post/Filter/AuthorFilter.php | 9 +- .../core/src/Post/Filter/FulltextFilter.php | 82 ++++++++++ .../core/src/Search/SearchServiceProvider.php | 2 +- framework/core/src/User/UserRepository.php | 2 +- 52 files changed, 974 insertions(+), 188 deletions(-) create mode 100644 extensions/likes/js/src/common/extend.ts create mode 100644 extensions/likes/js/src/common/query/posts/LikedByGambit.ts create mode 100644 extensions/likes/js/src/forum/components/LikesUserPage.ts delete mode 100644 extensions/likes/js/src/forum/components/LikesUserPage.tsx create mode 100644 extensions/mentions/js/src/admin/extend.ts create mode 100644 extensions/mentions/js/src/common/extend.ts create mode 100644 extensions/mentions/js/src/common/query/posts/MentionedGambit.ts delete mode 100644 extensions/mentions/js/src/forum/components/MentionsUserPage.js create mode 100644 extensions/mentions/js/src/forum/components/MentionsUserPage.ts create mode 100644 framework/core/js/src/common/query/discussions/DiscussionGambit.ts rename framework/core/js/src/common/states/{PageState.js => PageState.ts} (74%) create mode 100644 framework/core/js/src/forum/components/PostList.tsx create mode 100644 framework/core/js/src/forum/components/PostListItem.tsx create mode 100644 framework/core/js/src/forum/components/PostsPage.tsx create mode 100644 framework/core/js/src/forum/components/PostsSearchSource.tsx create mode 100644 framework/core/js/src/forum/states/PostListState.ts create mode 100644 framework/core/less/forum/PostList.less create mode 100644 framework/core/src/Forum/Content/Posts.php create mode 100644 framework/core/src/Post/Filter/FulltextFilter.php diff --git a/extensions/likes/js/src/common/extend.ts b/extensions/likes/js/src/common/extend.ts new file mode 100644 index 0000000000..3099dc5b86 --- /dev/null +++ b/extensions/likes/js/src/common/extend.ts @@ -0,0 +1,7 @@ +import Extend from 'flarum/common/extenders'; +import LikedByGambit from './query/posts/LikedByGambit'; + +export default [ + new Extend.Search() // + .gambit('posts', LikedByGambit), +]; diff --git a/extensions/likes/js/src/common/query/posts/LikedByGambit.ts b/extensions/likes/js/src/common/query/posts/LikedByGambit.ts new file mode 100644 index 0000000000..862474ab3b --- /dev/null +++ b/extensions/likes/js/src/common/query/posts/LikedByGambit.ts @@ -0,0 +1,16 @@ +import { KeyValueGambit } from 'flarum/common/query/IGambit'; +import app from 'flarum/common/app'; + +export default class LikedByGambit extends KeyValueGambit { + key(): string { + return app.translator.trans('flarum-likes.lib.gambits.posts.likedBy.key', {}, true); + } + + hint(): string { + return app.translator.trans('flarum-likes.lib.gambits.posts.likedBy.hint', {}, true); + } + + filterKey(): string { + return 'likedBy'; + } +} diff --git a/extensions/likes/js/src/forum/components/LikesUserPage.ts b/extensions/likes/js/src/forum/components/LikesUserPage.ts new file mode 100644 index 0000000000..d4b176ae59 --- /dev/null +++ b/extensions/likes/js/src/forum/components/LikesUserPage.ts @@ -0,0 +1,16 @@ +import PostsUserPage from 'flarum/forum/components/PostsUserPage'; +import type User from 'flarum/common/models/User'; + +/** + * The `LikesUserPage` component shows posts which user the user liked. + */ +export default class LikesUserPage extends PostsUserPage { + params(user: User) { + return { + filter: { + type: 'comment', + likedBy: user.id(), + }, + }; + } +} diff --git a/extensions/likes/js/src/forum/components/LikesUserPage.tsx b/extensions/likes/js/src/forum/components/LikesUserPage.tsx deleted file mode 100644 index 941716ac2a..0000000000 --- a/extensions/likes/js/src/forum/components/LikesUserPage.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import app from 'flarum/forum/app'; -import PostsUserPage from 'flarum/forum/components/PostsUserPage'; - -/** - * The `LikesUserPage` component shows posts which user the user liked. - */ -export default class LikesUserPage extends PostsUserPage { - /** - * Load a new page of the user's activity feed. - * - * @param offset The position to start getting results from. - * @protected - */ - loadResults(offset: number) { - return app.store.find('posts', { - filter: { - type: 'comment', - likedBy: this.user.id(), - }, - page: { offset, limit: this.loadLimit }, - sort: '-createdAt', - }); - } -} diff --git a/extensions/likes/js/src/forum/extend.ts b/extensions/likes/js/src/forum/extend.ts index ddb6b7bae8..9973a0d2f0 100644 --- a/extensions/likes/js/src/forum/extend.ts +++ b/extensions/likes/js/src/forum/extend.ts @@ -4,7 +4,11 @@ import User from 'flarum/common/models/User'; import LikesUserPage from './components/LikesUserPage'; import PostLikedNotification from './components/PostLikedNotification'; +import commonExtend from '../common/extend'; + export default [ + ...commonExtend, + new Extend.Routes() // .add('user.likes', '/u/:username/likes', LikesUserPage), diff --git a/extensions/likes/js/src/forum/index.js b/extensions/likes/js/src/forum/index.js index 78175ef314..5064bc853b 100644 --- a/extensions/likes/js/src/forum/index.js +++ b/extensions/likes/js/src/forum/index.js @@ -1,4 +1,4 @@ -import { extend } from 'flarum/common/extend'; +import { extend, override } from 'flarum/common/extend'; import app from 'flarum/forum/app'; import addLikeAction from './addLikeAction'; @@ -19,4 +19,20 @@ app.initializers.add('flarum-likes', () => { label: app.translator.trans('flarum-likes.forum.settings.notify_post_liked_label'), }); }); + + // Auto scope the search to the current user liked posts. + override('flarum/forum/components/SearchModal', 'defaultActiveSource', function (original) { + const orig = original(); + + if (!orig && app.current.data.routeName && app.current.data.routeName.includes('user.likes') && app.current.data.user) { + return 'posts'; + } + + return orig; + }); + extend('flarum/forum/components/SearchModal', 'defaultFilters', function (filters) { + if (app.current.data.routeName && app.current.data.routeName.includes('user.likes') && app.current.data.user) { + filters.posts.likedBy = app.current.data.user.username(); + } + }); }); diff --git a/extensions/likes/locale/en.yml b/extensions/likes/locale/en.yml index f48275a585..42d3024c63 100644 --- a/extensions/likes/locale/en.yml +++ b/extensions/likes/locale/en.yml @@ -44,3 +44,13 @@ flarum-likes: # These translations are used in the User profile page. user: likes_link: Likes + + # 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: + posts: + likedBy: + key: likedBy + hint: The ID or username of the user diff --git a/extensions/likes/src/Query/LikedByFilter.php b/extensions/likes/src/Query/LikedByFilter.php index 6fdb89c9a2..5398f522a7 100644 --- a/extensions/likes/src/Query/LikedByFilter.php +++ b/extensions/likes/src/Query/LikedByFilter.php @@ -13,6 +13,7 @@ use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; +use Flarum\User\UserRepository; /** * @implements FilterInterface @@ -21,6 +22,11 @@ class LikedByFilter implements FilterInterface { use ValidateFilterTrait; + public function __construct( + protected UserRepository $users + ) { + } + public function getFilterKey(): string { return 'likedBy'; @@ -28,7 +34,13 @@ public function getFilterKey(): string public function filter(SearchState $state, string|array $value, bool $negate): void { - $likedId = $this->asInt($value); + $likedUsername = $this->asString($value); + + $likedId = $this->users->getIdForUsername($likedUsername); + + if (! $likedId) { + $likedId = intval($likedUsername); + } $state ->getQuery() diff --git a/extensions/likes/src/Query/LikedFilter.php b/extensions/likes/src/Query/LikedFilter.php index b291d97eaf..e22cd674be 100644 --- a/extensions/likes/src/Query/LikedFilter.php +++ b/extensions/likes/src/Query/LikedFilter.php @@ -13,6 +13,7 @@ use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; +use Flarum\User\UserRepository; /** * @implements FilterInterface @@ -21,6 +22,11 @@ class LikedFilter implements FilterInterface { use ValidateFilterTrait; + public function __construct( + protected UserRepository $users + ) { + } + public function getFilterKey(): string { return 'liked'; diff --git a/extensions/mentions/js/src/admin/extend.ts b/extensions/mentions/js/src/admin/extend.ts new file mode 100644 index 0000000000..93caee0bcb --- /dev/null +++ b/extensions/mentions/js/src/admin/extend.ts @@ -0,0 +1,3 @@ +import commonExtend from '../common/extend'; + +export default [...commonExtend]; diff --git a/extensions/mentions/js/src/admin/index.js b/extensions/mentions/js/src/admin/index.js index 6dd8fad808..6cf148e60f 100644 --- a/extensions/mentions/js/src/admin/index.js +++ b/extensions/mentions/js/src/admin/index.js @@ -1,5 +1,7 @@ import app from 'flarum/admin/app'; +export { default as extend } from './extend'; + app.initializers.add('flarum-mentions', () => { app.extensionData .for('flarum-mentions') diff --git a/extensions/mentions/js/src/common/extend.ts b/extensions/mentions/js/src/common/extend.ts new file mode 100644 index 0000000000..593daeae7b --- /dev/null +++ b/extensions/mentions/js/src/common/extend.ts @@ -0,0 +1,7 @@ +import Extend from 'flarum/common/extenders'; +import MentionedGambit from './query/posts/MentionedGambit'; + +export default [ + new Extend.Search() // + .gambit('posts', MentionedGambit), +]; diff --git a/extensions/mentions/js/src/common/query/posts/MentionedGambit.ts b/extensions/mentions/js/src/common/query/posts/MentionedGambit.ts new file mode 100644 index 0000000000..7ccd1b30a1 --- /dev/null +++ b/extensions/mentions/js/src/common/query/posts/MentionedGambit.ts @@ -0,0 +1,16 @@ +import { KeyValueGambit } from 'flarum/common/query/IGambit'; +import app from 'flarum/common/app'; + +export default class MentionedGambit extends KeyValueGambit { + key(): string { + return app.translator.trans('flarum-mentions.lib.gambits.posts.mentioned.key', {}, true); + } + + hint(): string { + return app.translator.trans('flarum-mentions.lib.gambits.posts.mentioned.hint', {}, true); + } + + filterKey(): string { + return 'mentioned'; + } +} diff --git a/extensions/mentions/js/src/forum/components/MentionsUserPage.js b/extensions/mentions/js/src/forum/components/MentionsUserPage.js deleted file mode 100644 index 4ac4cec4c2..0000000000 --- a/extensions/mentions/js/src/forum/components/MentionsUserPage.js +++ /dev/null @@ -1,25 +0,0 @@ -import app from 'flarum/forum/app'; -import PostsUserPage from 'flarum/forum/components/PostsUserPage'; - -/** - * The `MentionsUserPage` component shows post which user Mentioned at - */ -export default class MentionsUserPage extends PostsUserPage { - /** - * Load a new page of the user's activity feed. - * - * @param {Integer} [offset] The position to start getting results from. - * @return {Promise} - * @protected - */ - loadResults(offset) { - return app.store.find('posts', { - filter: { - type: 'comment', - mentioned: this.user.id(), - }, - page: { offset, limit: this.loadLimit }, - sort: '-createdAt', - }); - } -} diff --git a/extensions/mentions/js/src/forum/components/MentionsUserPage.ts b/extensions/mentions/js/src/forum/components/MentionsUserPage.ts new file mode 100644 index 0000000000..c92e9d4943 --- /dev/null +++ b/extensions/mentions/js/src/forum/components/MentionsUserPage.ts @@ -0,0 +1,16 @@ +import PostsUserPage from 'flarum/forum/components/PostsUserPage'; +import type User from 'flarum/common/models/User'; + +/** + * The `MentionsUserPage` component shows post which user Mentioned at + */ +export default class MentionsUserPage extends PostsUserPage { + params(user: User) { + return { + filter: { + type: 'comment', + mentioned: user.id(), + }, + }; + } +} diff --git a/extensions/mentions/js/src/forum/extend.ts b/extensions/mentions/js/src/forum/extend.ts index 240edf3b39..2a31ed0fee 100644 --- a/extensions/mentions/js/src/forum/extend.ts +++ b/extensions/mentions/js/src/forum/extend.ts @@ -6,7 +6,11 @@ import PostMentionedNotification from './components/PostMentionedNotification'; import UserMentionedNotification from './components/UserMentionedNotification'; import GroupMentionedNotification from './components/GroupMentionedNotification'; +import commonExtend from '../common/extend'; + export default [ + ...commonExtend, + new Extend.Routes() // .add('user.mentions', '/u/:username/mentions', MentionsUserPage), diff --git a/extensions/mentions/js/src/forum/index.js b/extensions/mentions/js/src/forum/index.js index 83de4d6ba5..fb5f5fcb17 100644 --- a/extensions/mentions/js/src/forum/index.js +++ b/extensions/mentions/js/src/forum/index.js @@ -1,4 +1,4 @@ -import { extend } from 'flarum/common/extend'; +import { extend, override } from 'flarum/common/extend'; import app from 'flarum/forum/app'; import { getPlainContent } from 'flarum/common/utils/string'; import textContrastClass from 'flarum/common/helpers/textContrastClass'; @@ -79,6 +79,22 @@ app.initializers.add('flarum-mentions', () => { this.classList.add(textContrastClass(getComputedStyle(this).getPropertyValue('--color'))); }); }); + + // Auto scope the search to the current user mentioned posts. + override('flarum/forum/components/SearchModal', 'defaultActiveSource', function (original) { + const orig = original(); + + if (!orig && app.current.data.routeName && app.current.data.routeName.includes('user.mentions') && app.current.data.user) { + return 'posts'; + } + + return orig; + }); + extend('flarum/forum/components/SearchModal', 'defaultFilters', function (filters) { + if (app.current.data.routeName && app.current.data.routeName.includes('user.mentions') && app.current.data.user) { + filters.posts.mentioned = app.current.data.user.username(); + } + }); }); export * from './utils/textFormatter'; diff --git a/extensions/mentions/locale/en.yml b/extensions/mentions/locale/en.yml index 1de7ed530b..7457c54ee6 100644 --- a/extensions/mentions/locale/en.yml +++ b/extensions/mentions/locale/en.yml @@ -110,3 +110,13 @@ flarum-mentions: {content} html: body: "{mentioner_display_name} mentioned a group you're a member of in [{title}]({url})." + + # 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: + posts: + mentioned: + key: mentioned + hint: The ID or username of the mentioned user diff --git a/extensions/mentions/src/Filter/MentionedFilter.php b/extensions/mentions/src/Filter/MentionedFilter.php index 2c555de981..4c5e11a9ec 100644 --- a/extensions/mentions/src/Filter/MentionedFilter.php +++ b/extensions/mentions/src/Filter/MentionedFilter.php @@ -13,6 +13,7 @@ use Flarum\Search\Filter\FilterInterface; use Flarum\Search\SearchState; use Flarum\Search\ValidateFilterTrait; +use Flarum\User\UserRepository; /** * @implements FilterInterface @@ -21,6 +22,11 @@ class MentionedFilter implements FilterInterface { use ValidateFilterTrait; + public function __construct( + protected UserRepository $users + ) { + } + public function getFilterKey(): string { return 'mentioned'; @@ -28,7 +34,13 @@ public function getFilterKey(): string public function filter(SearchState $state, string|array $value, bool $negate): void { - $mentionedId = $this->asInt($value); + $mentionedUsername = $this->asString($value); + + $mentionedId = $this->users->getIdForUsername($mentionedUsername); + + if (! $mentionedId) { + $mentionedId = intval($mentionedUsername); + } $state ->getQuery() diff --git a/framework/core/js/src/common/GambitManager.ts b/framework/core/js/src/common/GambitManager.ts index 3609dc5b6f..2a6051c0ca 100644 --- a/framework/core/js/src/common/GambitManager.ts +++ b/framework/core/js/src/common/GambitManager.ts @@ -5,6 +5,7 @@ import HiddenGambit from './query/discussions/HiddenGambit'; import UnreadGambit from './query/discussions/UnreadGambit'; import EmailGambit from './query/users/EmailGambit'; import GroupGambit from './query/users/GroupGambit'; +import DiscussionGambit from './query/discussions/DiscussionGambit'; /** * The gambit registry. A map of resource types to gambit classes that @@ -15,6 +16,7 @@ import GroupGambit from './query/users/GroupGambit'; export default class GambitManager { gambits: Record IGambit>> = { discussions: [AuthorGambit, CreatedGambit, HiddenGambit, UnreadGambit], + posts: [AuthorGambit, DiscussionGambit], users: [EmailGambit, GroupGambit], }; @@ -43,7 +45,7 @@ export default class GambitManager { for (const gambit of gambits) { for (const bit of bits) { - const pattern = `^(-?)${gambit.pattern()}$`; + const pattern = new RegExp(`^(-?)${gambit.pattern()}$`, 'i'); let matches = bit.match(pattern); if (matches) { diff --git a/framework/core/js/src/common/helpers/highlight.tsx b/framework/core/js/src/common/helpers/highlight.tsx index e63fa65ad4..bebfbdf5f4 100644 --- a/framework/core/js/src/common/helpers/highlight.tsx +++ b/framework/core/js/src/common/helpers/highlight.tsx @@ -9,8 +9,10 @@ import { truncate } from '../utils/string'; * @param phrase The word or words to highlight. * @param [length] The number of characters to truncate the string to. * The string will be truncated surrounding the first match. + * @param safe Whether the content is safe to render as HTML or + * should be escaped (HTML entities encoded). */ -export default function highlight(string: string, phrase?: string | RegExp, length?: number): Mithril.Vnode | string { +export default function highlight(string: string, phrase?: string | RegExp, length?: number, safe = false): Mithril.Vnode | string { if (!phrase && !length) return string; // Convert the word phrase into a global regular expression (if it isn't @@ -29,7 +31,9 @@ export default function highlight(string: string, phrase?: string | RegExp, leng // Convert the string into HTML entities, then highlight all matches with // tags. Then we will return the result as a trusted HTML string. - highlighted = $('
').text(highlighted).html(); + if (!safe) { + highlighted = $('
').text(highlighted).html(); + } if (phrase) highlighted = highlighted.replace(regexp, '$&'); diff --git a/framework/core/js/src/common/query/discussions/DiscussionGambit.ts b/framework/core/js/src/common/query/discussions/DiscussionGambit.ts new file mode 100644 index 0000000000..e6199ea503 --- /dev/null +++ b/framework/core/js/src/common/query/discussions/DiscussionGambit.ts @@ -0,0 +1,16 @@ +import app from '../../app'; +import { KeyValueGambit } from '../IGambit'; + +export default class DiscussionGambit extends KeyValueGambit { + key(): string { + return app.translator.trans('core.lib.gambits.posts.discussion.key', {}, true); + } + + hint(): string { + return app.translator.trans('core.lib.gambits.posts.discussion.hint', {}, true); + } + + filterKey(): string { + return 'discussion'; + } +} diff --git a/framework/core/js/src/common/states/PageState.js b/framework/core/js/src/common/states/PageState.ts similarity index 74% rename from framework/core/js/src/common/states/PageState.js rename to framework/core/js/src/common/states/PageState.ts index 1ff8cbbbd4..278eb19d51 100644 --- a/framework/core/js/src/common/states/PageState.js +++ b/framework/core/js/src/common/states/PageState.ts @@ -1,7 +1,12 @@ import subclassOf from '../../common/utils/subclassOf'; export default class PageState { - constructor(type, data = {}) { + public type: Function | null; + public data: { + routeName?: string | null; + } & Record; + + constructor(type: Function | null, data: any = {}) { this.type = type; this.data = data; } @@ -13,7 +18,7 @@ export default class PageState { * @param {Record} data * @return {boolean} */ - matches(type, data = {}) { + matches(type: Function, data: any = {}) { // Fail early when the page is of a different type if (!subclassOf(this.type, type)) return false; @@ -22,11 +27,11 @@ export default class PageState { return Object.keys(data).every((key) => this.data[key] === data[key]); } - get(key) { + get(key: string): any { return this.data[key]; } - set(key, value) { + set(key: string, value: any) { this.data[key] = value; } } diff --git a/framework/core/js/src/common/states/PaginatedListState.ts b/framework/core/js/src/common/states/PaginatedListState.ts index 4b8efe9848..974a01eb65 100644 --- a/framework/core/js/src/common/states/PaginatedListState.ts +++ b/framework/core/js/src/common/states/PaginatedListState.ts @@ -33,7 +33,7 @@ export default abstract class PaginatedListState[] = []; protected params: P = {} as P; @@ -267,4 +267,11 @@ export default abstract class PaginatedListState pg.items) .flat(); } + + /** + * In the last request, has the user searched for a model? + */ + isSearchResults(): boolean { + return !!this.params.q; + } } diff --git a/framework/core/js/src/forum/components/CommentPost.js b/framework/core/js/src/forum/components/CommentPost.js index 74da778d4e..364ecbda9e 100644 --- a/framework/core/js/src/forum/components/CommentPost.js +++ b/framework/core/js/src/forum/components/CommentPost.js @@ -9,8 +9,10 @@ import listItems from '../../common/helpers/listItems'; import Button from '../../common/components/Button'; import ComposerPostPreview from './ComposerPostPreview'; import Link from '../../common/components/Link'; -import UserCard from './UserCard.js'; +import UserCard from './UserCard'; import Avatar from '../../common/components/Avatar'; +import escapeRegExp from '../../common/utils/escapeRegExp'; +import highlight from '../../common/helpers/highlight'; /** * The `CommentPost` component displays a standard `comment`-typed post. This @@ -60,6 +62,16 @@ export default class CommentPost extends Post { } content() { + let contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml(); + + if (!this.isEditing() && this.attrs.params?.q) { + const phrase = escapeRegExp(this.attrs.params.q); + const highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi'); + contentHtml = highlight(contentHtml, highlightRegExp, undefined, true); + } else { + contentHtml = m.trust(contentHtml); + } + return super.content().concat([
    {listItems(this.headerItems().toArray())}
@@ -68,9 +80,7 @@ export default class CommentPost extends Post { )}
, -
- {this.isEditing() ? : m.trust(this.attrs.post.contentHtml())} -
, +
{this.isEditing() ? : contentHtml}
, ]); } diff --git a/framework/core/js/src/forum/components/DiscussionListItem.tsx b/framework/core/js/src/forum/components/DiscussionListItem.tsx index 25d9ecc071..e71d5597d8 100644 --- a/framework/core/js/src/forum/components/DiscussionListItem.tsx +++ b/framework/core/js/src/forum/components/DiscussionListItem.tsx @@ -20,10 +20,15 @@ import type Mithril from 'mithril'; import type { DiscussionListParams } from '../states/DiscussionListState'; import Icon from '../../common/components/Icon'; import Avatar from '../../common/components/Avatar'; +import Post from '../../common/models/Post'; +import type User from '../../common/models/User'; export interface IDiscussionListItemAttrs extends ComponentAttrs { discussion: Discussion; + post?: Post; params: DiscussionListParams; + jumpTo?: number; + author?: User; } /** @@ -140,7 +145,7 @@ export default class DiscussionListItem(); const discussion = this.attrs.discussion; - const user = discussion.user(); + const user = this.attrs.author || discussion.user(); items.add( 'avatar', @@ -179,6 +184,10 @@ export default class DiscussionListItem(); if (this.attrs.params.q) { - const post = this.attrs.discussion.mostRelevantPost() || this.attrs.discussion.firstPost(); + const post = this.attrs.post || this.attrs.discussion.mostRelevantPost() || this.attrs.discussion.firstPost(); if (post && post.contentType() === 'comment') { const excerpt = highlight(post.contentPlain() ?? '', this.highlightRegExp, 175); diff --git a/framework/core/js/src/forum/components/IndexPage.tsx b/framework/core/js/src/forum/components/IndexPage.tsx index 7b6fad76c0..bd446fa628 100644 --- a/framework/core/js/src/forum/components/IndexPage.tsx +++ b/framework/core/js/src/forum/components/IndexPage.tsx @@ -12,6 +12,7 @@ import type Mithril from 'mithril'; import type Discussion from '../../common/models/Discussion'; import PageStructure from './PageStructure'; import IndexSidebar from './IndexSidebar'; +import PostsPage from './PostsPage'; export interface IIndexPageAttrs extends IPageAttrs {} @@ -33,6 +34,14 @@ export default class IndexPage extends Component { + view() { + const state = this.attrs.state; + + const params = state.getParams(); + const isLoading = state.isInitialLoading() || state.isLoadingNext(); + + let loading; + + if (isLoading) { + loading = ; + } else if (state.hasNext()) { + loading = ( + + ); + } + + if (state.isEmpty()) { + return ( +
+ +
+ ); + } + + const pageSize = state.pageSize || 0; + + return ( +
+
    + {state.getPages().map((pg, pageNum) => { + return pg.items.map((post, itemNum) => ( +
  • + +
  • + )); + })} +
+
{loading}
+
+ ); + } +} diff --git a/framework/core/js/src/forum/components/PostListItem.tsx b/framework/core/js/src/forum/components/PostListItem.tsx new file mode 100644 index 0000000000..46c10b6f90 --- /dev/null +++ b/framework/core/js/src/forum/components/PostListItem.tsx @@ -0,0 +1,30 @@ +import Component, { type ComponentAttrs } from '../../common/Component'; +import type Post from '../../common/models/Post'; +import Mithril from 'mithril'; +import app from '../app'; +import Link from '../../common/components/Link'; +import CommentPost from './CommentPost'; +import { PostListParams } from '../states/PostListState'; + +export interface IPostListItemAttrs extends ComponentAttrs { + post: Post; + params: PostListParams; +} + +export default class PostListItem extends Component { + view(): Mithril.Children { + const post = this.attrs.post; + + return ( +
+
+ {app.translator.trans('core.forum.post_list.in_discussion_text', { + discussion: {post.discussion().title()}, + })} +
+ + +
+ ); + } +} diff --git a/framework/core/js/src/forum/components/PostsPage.tsx b/framework/core/js/src/forum/components/PostsPage.tsx new file mode 100644 index 0000000000..a267a7fc07 --- /dev/null +++ b/framework/core/js/src/forum/components/PostsPage.tsx @@ -0,0 +1,144 @@ +import app from '../../forum/app'; +import Page, { IPageAttrs } from '../../common/components/Page'; +import ItemList from '../../common/utils/ItemList'; +import listItems from '../../common/helpers/listItems'; +import WelcomeHero from './WelcomeHero'; +import Dropdown from '../../common/components/Dropdown'; +import Button from '../../common/components/Button'; +import extractText from '../../common/utils/extractText'; +import type Mithril from 'mithril'; +import PageStructure from './PageStructure'; +import IndexSidebar from './IndexSidebar'; +import PostList from './PostList'; +import PostListState from '../states/PostListState'; + +export interface IPostsPageAttrs extends IPageAttrs {} + +/** + * The `PostsPage` component displays the index page, including the welcome + * hero, the sidebar, and the discussion list. + */ +export default class PostsPage extends Page { + static providesInitialSearch = true; + + protected posts!: PostListState; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.posts = new PostListState({}); + + this.posts.refreshParams(app.search.state.params(), (m.route.param('page') && Number(m.route.param('page'))) || 1); + + app.history.push('posts', extractText(app.translator.trans('core.forum.header.back_to_index_tooltip'))); + + this.bodyClass = 'App--posts'; + this.scrollTopOnCreate = false; + } + + view() { + return ( + +
+
    {listItems(this.viewItems().toArray())}
+
    {listItems(this.actionItems().toArray())}
+
+ +
+ ); + } + + setTitle() { + app.setTitle(extractText(app.translator.trans('core.forum.posts.meta_title_text'))); + app.setTitleCount(0); + } + + oncreate(vnode: Mithril.VnodeDOM) { + super.oncreate(vnode); + + this.setTitle(); + } + + onremove(vnode: Mithril.VnodeDOM) { + super.onremove(vnode); + + $('#app').css('min-height', ''); + } + + /** + * Get the component to display as the hero. + */ + hero() { + return ; + } + + sidebar() { + return ; + } + + /** + * Build an item list for the part of the toolbar which is concerned with how + * the results are displayed. By default this is just a select box to change + * the way discussions are sorted. + */ + viewItems() { + const items = new ItemList(); + const sortMap = this.posts.sortMap(); + + const sortOptions = Object.keys(sortMap).reduce((acc: any, sortId) => { + acc[sortId] = app.translator.trans(`core.forum.posts_sort.${sortId}_button`); + return acc; + }, {}); + + if (Object.keys(sortOptions).length > 1) { + items.add( + 'sort', + sortOptions[key])[0]} + accessibleToggleLabel={app.translator.trans('core.forum.posts_sort.toggle_dropdown_accessible_label')} + > + {Object.keys(sortOptions).map((value) => { + const label = sortOptions[value]; + const active = (app.search.state.params().sort || Object.keys(sortMap)[0]) === value; + + return ( + + ); + })} + + ); + } + + return items; + } + + /** + * Build an item list for the part of the toolbar which is about taking action + * on the results. By default this is just a "mark all as read" button. + */ + actionItems() { + const items = new ItemList(); + + items.add( + 'refresh', + -
- ); - } - return (
-
    - {this.posts.map((post) => ( -
  • -
    - {app.translator.trans('core.forum.user.in_discussion_text', { - discussion: {post.discussion().title()}, - })} -
    - - -
  • - ))} -
-
{footer}
+
); } @@ -90,56 +49,12 @@ export default class PostsUserPage extends UserPage { show(user: User): void { super.show(user); - this.refresh(); - } - - /** - * Clear and reload the user's activity feed. - */ - refresh() { - this.loading = true; - this.posts = []; - - m.redraw(); - - this.loadResults().then(this.parseResults.bind(this)); - } - - /** - * Load a new page of the user's activity feed. - * - * @protected - */ - loadResults(offset = 0) { - return app.store.find('posts', { - filter: { - author: this.user!.username(), - type: 'comment', - }, - page: { offset, limit: this.loadLimit }, - sort: '-createdAt', - }); + this.posts.refreshParams(this.params(user), 1); } - /** - * Load the next page of results. - */ - loadMore() { - this.loading = true; - this.loadResults(this.posts.length).then(this.parseResults.bind(this)); - } - - /** - * Parse results and append them to the activity feed. - */ - parseResults(results: Post[]): Post[] { - this.loading = false; - - this.posts.push(...results); - - this.moreResults = results.length >= this.loadLimit; - m.redraw(); - - return results; + params(user: User) { + return { + filter: { author: user.username() }, + }; } } diff --git a/framework/core/js/src/forum/components/Search.tsx b/framework/core/js/src/forum/components/Search.tsx index 3f6cb19495..b633d6f88f 100644 --- a/framework/core/js/src/forum/components/Search.tsx +++ b/framework/core/js/src/forum/components/Search.tsx @@ -8,6 +8,7 @@ import type Mithril from 'mithril'; import ItemList from '../../common/utils/ItemList'; import DiscussionsSearchSource from './DiscussionsSearchSource'; import UsersSearchSource from './UsersSearchSource'; +import PostsSearchSource from './PostsSearchSource'; export interface SearchAttrs extends ComponentAttrs { /** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */ @@ -129,6 +130,7 @@ export default class Search extends Compone if (app.forum.attribute('canViewForum')) { items.add('discussions', new DiscussionsSearchSource()); + items.add('posts', new PostsSearchSource()); } if (app.forum.attribute('canSearchUsers')) { diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx index e6b193398e..6d0313477f 100644 --- a/framework/core/js/src/forum/components/SearchModal.tsx +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -65,7 +65,10 @@ export default class SearchModal source.resource === this.defaultActiveSource()) || this.sources[0] : this.sources[0] + ); + this.query = Stream(this.prefill(this.searchState.getValue() || '').trim()); } title(): Mithril.Children { @@ -77,9 +80,6 @@ export default class SearchModal this.inputElement(), @@ -432,4 +432,45 @@ export default class SearchModal { return this.$('.SearchModal-input') as JQuery; } + + defaultActiveSource(): string | null { + const inDiscussion = + app.current.data.routeName && ['discussion', 'discussion.near'].includes(app.current.data.routeName) && app.current.data.discussion; + const inUser = app.current.data.routeName && app.current.data.routeName.includes('user.posts') && app.current.data.user; + const inPosts = app.current.data.routeName && app.current.data.routeName === 'posts'; + + if (inDiscussion || inUser || inPosts) { + return 'posts'; + } + + return null; + } + + defaultFilters(): Record> { + const filters: Record> = {}; + + this.sources.forEach((source) => { + filters[source.resource] = {}; + }); + + if (app.current.data.routeName && ['discussion', 'discussion.near'].includes(app.current.data.routeName) && app.current.data.discussion) { + filters.posts.discussion = app.current.data.discussion.id(); + } + + if (app.current.data.routeName && app.current.data.routeName.includes('user.posts') && app.current.data.user) { + filters.posts.author = app.current.data.user.username(); + } + + return filters; + } + + prefill(value: string): string { + const newQuery = app.search.gambits.from(this.activeSource().resource, value, this.defaultFilters()[this.activeSource().resource] || {}); + + if (!value.includes(newQuery.replace(value, '').trim())) { + return newQuery; + } + + return value; + } } diff --git a/framework/core/js/src/forum/routes.ts b/framework/core/js/src/forum/routes.ts index 5b18b1c6d0..bdab000730 100644 --- a/framework/core/js/src/forum/routes.ts +++ b/framework/core/js/src/forum/routes.ts @@ -22,6 +22,7 @@ export interface ForumRoutes { export default function (app: ForumApplication) { app.routes = { index: { path: '/all', component: IndexPage }, + posts: { path: '/posts', component: () => import('./components/PostsPage') }, discussion: { path: '/d/:id', component: DiscussionPage, resolverClass: DiscussionPageResolver }, 'discussion.near': { path: '/d/:id/:near', component: DiscussionPage, resolverClass: DiscussionPageResolver }, diff --git a/framework/core/js/src/forum/states/DiscussionListState.ts b/framework/core/js/src/forum/states/DiscussionListState.ts index 3620381215..be1a88df8b 100644 --- a/framework/core/js/src/forum/states/DiscussionListState.ts +++ b/framework/core/js/src/forum/states/DiscussionListState.ts @@ -76,13 +76,6 @@ export default class DiscussionListState

extends PaginatedListState { + protected extraPosts: Post[] = []; + protected eventEmitter: EventEmitter; + + constructor(params: P, page: number = 1, pageSize: number | null = null) { + super(params, page, pageSize); + + this.eventEmitter = globalEventEmitter; + } + + get type(): string { + return 'posts'; + } + + requestParams(): PaginatedListRequestParams { + const params = { + include: ['user', 'discussion'], + filter: { + type: 'comment', + ...(this.params.filter || {}), + }, + sort: this.sortMap()[this.params.sort ?? ''] || '-createdAt', + }; + + if (this.params.q) { + params.filter.q = this.params.q; + } + + return params; + } + + protected loadPage(page: number = 1): Promise> { + const preloadedPosts = app.preloadedApiDocument(); + + if (preloadedPosts) { + this.initialLoading = false; + this.pageSize = preloadedPosts.payload.meta?.perPage || PostListState.DEFAULT_PAGE_SIZE; + + return Promise.resolve(preloadedPosts); + } + + return super.loadPage(page); + } + + clear(): void { + super.clear(); + + this.extraPosts = []; + } + + /** + * Get a map of sort keys (which appear in the URL, and are used for + * translation) to the API sort value that they represent. + */ + sortMap() { + const map: any = {}; + + if (this.params.q) { + map.relevance = ''; + } + + map.newest = '-createdAt'; + map.oldest = 'createdAt'; + + return map; + } + + protected getAllItems(): Post[] { + return this.extraPosts.concat(super.getAllItems()); + } + + public getPages(): Page[] { + const pages = super.getPages(); + + if (this.extraPosts.length) { + return [ + { + number: -1, + items: this.extraPosts, + }, + ...pages, + ]; + } + + return pages; + } +} diff --git a/framework/core/less/common/Search.less b/framework/core/less/common/Search.less index ba423e72d8..522f5efa5d 100644 --- a/framework/core/less/common/Search.less +++ b/framework/core/less/common/Search.less @@ -140,7 +140,7 @@ .SearchModal-visual-input mark { margin: 0; padding: 0; - background-color: var(--control-bg-shaded); + background-color: var(--control-bg-light); color: transparent; } diff --git a/framework/core/less/common/root.less b/framework/core/less/common/root.less index 03b6ba5a8a..062ce1728c 100644 --- a/framework/core/less/common/root.less +++ b/framework/core/less/common/root.less @@ -83,6 +83,8 @@ [data-theme^=light] { .scheme(light); + --search-gambit: var(--control-bg-shaded); + // --------------------------------- // UTILITIES @@ -115,6 +117,8 @@ [data-theme^=dark] { .scheme(dark); + --search-gambit: var(--control-bg-light); + // --------------------------------- // UTILITIES diff --git a/framework/core/less/forum.less b/framework/core/less/forum.less index 0e09310387..3faf2e9664 100644 --- a/framework/core/less/forum.less +++ b/framework/core/less/forum.less @@ -17,6 +17,7 @@ @import "forum/HeaderDropdown"; @import "forum/HeaderList"; @import "forum/Post"; +@import "forum/PostList"; @import "forum/PostStream"; @import "forum/Scrubber"; @import "forum/UserSecurityPage"; diff --git a/framework/core/less/forum/PostList.less b/framework/core/less/forum/PostList.less new file mode 100644 index 0000000000..d79e9dfd15 --- /dev/null +++ b/framework/core/less/forum/PostList.less @@ -0,0 +1,19 @@ +.PostList-discussions { + list-style: none; + margin: 0; + padding: 0; +} + +.PostListItem-discussion { + font-weight: bold; + margin-bottom: 10px; + position: relative; + z-index: 1; + + &, a { + color: var(--muted-color); + } + a { + font-style: italic; + } +} diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 2ed44d06fb..db81e0a6dd 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -494,16 +494,16 @@ core: mark_all_as_read_confirmation: "Are you sure you want to mark all discussions as read?" mark_all_as_read_tooltip: => core.ref.mark_all_as_read meta_title_text: => core.ref.all_discussions - refresh_tooltip: Refresh + refresh_tooltip: => core.ref.refresh start_discussion_button: => core.ref.start_a_discussion toggle_sidenav_dropdown_accessible_label: Toggle navigation dropdown menu # These translations are used by the sorting control above the discussion list. index_sort: latest_button: Latest - newest_button: Newest - oldest_button: Oldest - relevance_button: Relevance + newest_button: => core.ref.newest + oldest_button: => core.ref.oldest + relevance_button: => core.ref.relevance toggle_dropdown_accessible_label: Change discussion list sorting top_button: Top @@ -546,6 +546,12 @@ core: restore_button: => core.ref.restore toggle_dropdown_accessible_label: Toggle post controls dropdown menu + # These translations are used in the post list. + post_list: + empty_text: It looks as though there are no posts here. + in_discussion_text: "In {discussion}" + load_more_button: => core.ref.load_more + # These translations are used in the scrubber to the right of the post stream. post_scrubber: now_link: Now @@ -561,6 +567,17 @@ core: reply_placeholder: => core.ref.write_a_reply time_lapsed_text: "{period} later" + # These translations are used by the sorting control above the post list. + posts_sort: + newest_button: => core.ref.newest + oldest_button: => core.ref.oldest + relevance_button: => core.ref.relevance + + # These translations are used in the posts search page. + posts: + meta_title_text: Post search results + refresh_tooltip: => core.ref.refresh + # These translations are used by the rename discussion modal. rename_discussion: submit_button: => core.ref.rename @@ -769,6 +786,9 @@ core: discussions: all_button: 'Search all discussions for "{query}"' heading: => core.ref.discussions + posts: + all_button: 'Search all posts for "{query}"' + heading: => core.ref.posts users: heading: => core.ref.users @@ -786,6 +806,10 @@ core: key: hidden unread: key: unread + posts: + discussion: + key: discussion + hint: the ID of the discussion users: email: key: email @@ -891,7 +915,7 @@ core: # Translations in this namespace are used in messages output by the API. api: - invalid_username_message: "The username may only contain letters, numbers, and dashes." + invalid_username_message: "The username may only contain letters, numbers, and dashes. With at least one letter." invalid_filter_type: must_be_numeric_message: "The {filter} filter must be numeric." must_not_be_array_message: "The {filter} filter must not be an array." @@ -1001,16 +1025,20 @@ core: mark_all_as_read: Mark All as Read never: Never new_token: New Token + newest: Newest next_page: Next Page notifications: Notifications okay: OK # Referenced by flarum-tags.yml + oldest: Oldest password: Password posts: Posts # Referenced by flarum-statistics.yml previous_page: Previous Page + relevance: Relevance remember_me_label: Remember Me remove: Remove rename: Rename reply: Reply # Referenced by flarum-mentions.yml + refresh: Refresh reset_your_password: Reset Your Password restore: Restore save_changes: Save Changes diff --git a/framework/core/src/Api/Resource/PostResource.php b/framework/core/src/Api/Resource/PostResource.php index c02cbd8db9..988bd21652 100644 --- a/framework/core/src/Api/Resource/PostResource.php +++ b/framework/core/src/Api/Resource/PostResource.php @@ -270,7 +270,9 @@ public function sorts(): array { return [ SortColumn::make('number'), - SortColumn::make('createdAt'), + SortColumn::make('createdAt') + ->ascendingAlias('oldest') + ->descendingAlias('newest'), ]; } diff --git a/framework/core/src/Api/Resource/UserResource.php b/framework/core/src/Api/Resource/UserResource.php index 8a5be23013..5160dc3eba 100644 --- a/framework/core/src/Api/Resource/UserResource.php +++ b/framework/core/src/Api/Resource/UserResource.php @@ -146,7 +146,7 @@ public function fields(): array Schema\Str::make('username') ->requiredOnCreateWithout(['token']) ->unique('users', 'username', true) - ->regex('/^[a-z0-9_-]+$/i') + ->regex('/^(?![0-9]*$)[a-z0-9_-]+$/i') ->validationMessages([ 'username.regex' => $translator->trans('core.api.invalid_username_message'), 'username.required_without' => $translator->trans('validation.required', ['attribute' => $translator->trans('validation.attributes.username')]) diff --git a/framework/core/src/Discussion/Search/Filter/AuthorFilter.php b/framework/core/src/Discussion/Search/Filter/AuthorFilter.php index 138cbc975f..8e27bc4f5a 100644 --- a/framework/core/src/Discussion/Search/Filter/AuthorFilter.php +++ b/framework/core/src/Discussion/Search/Filter/AuthorFilter.php @@ -44,6 +44,13 @@ protected function constrain(Builder $query, string|array $rawUsernames, bool $n $ids = $this->users->getIdsForUsernames($usernames); + // To be able to also use IDs. + $actualIds = array_diff($usernames, array_keys($ids)); + + if (! empty($actualIds)) { + $ids = array_merge($ids, $actualIds); + } + $query->whereIn('discussions.user_id', $ids, 'and', $negate); } } diff --git a/framework/core/src/Forum/Content/Posts.php b/framework/core/src/Forum/Content/Posts.php new file mode 100644 index 0000000000..a7ea5eaa10 --- /dev/null +++ b/framework/core/src/Forum/Content/Posts.php @@ -0,0 +1,83 @@ +getQueryParams(); + + $sort = Arr::pull($queryParams, 'sort'); + $q = Arr::pull($queryParams, 'q'); + $page = max(1, intval(Arr::pull($queryParams, 'page'))); + + $sortMap = $this->resource->sortMap(); + + $params = [ + ...$queryParams, + 'sort' => $sort && isset($sortMap[$sort]) ? $sortMap[$sort] : '-createdAt', + 'page' => [ + 'number' => $page + ], + ]; + + if ($q) { + $params['filter']['q'] = $q; + } + + $apiDocument = $this->getApiDocument($request, $params, $q); + + $document->title = $this->translator->trans('core.forum.index.meta_title_text'); +// $document->content = $this->view->make('flarum.forum::frontend.content.index', compact('apiDocument', 'page')); + $document->payload['apiDocument'] = $apiDocument ?? ((object) ['data' => []]); + + $document->canonicalUrl = $this->url->to('forum')->route('posts', $params); + $document->page = $page; + $document->hasNextPage = $apiDocument && isset($apiDocument->links->next); + + return $document; + } + + protected function getApiDocument(Request $request, array $params, ?string $q): ?object + { + return json_decode( + $this->api + ->withoutErrorHandling() + ->withParentRequest($request) + ->withQueryParams($params) + ->get('/posts') + ->getBody() + ); + } +} diff --git a/framework/core/src/Forum/routes.php b/framework/core/src/Forum/routes.php index a2d1052c6c..89ad6a033d 100644 --- a/framework/core/src/Forum/routes.php +++ b/framework/core/src/Forum/routes.php @@ -19,6 +19,12 @@ $route->toForum(Content\Index::class) ); + $map->get( + '/posts', + 'posts', + $route->toForum(Content\Posts::class) + ); + $map->get( '/d/{id:\d+(?:-[^/]*)?}[/{near:[^/]*}]', 'discussion', diff --git a/framework/core/src/Post/Filter/AuthorFilter.php b/framework/core/src/Post/Filter/AuthorFilter.php index f4bebf67c7..eedf9a650c 100644 --- a/framework/core/src/Post/Filter/AuthorFilter.php +++ b/framework/core/src/Post/Filter/AuthorFilter.php @@ -36,7 +36,14 @@ public function filter(SearchState $state, string|array $value, bool $negate): v { $usernames = $this->asStringArray($value); - $ids = $this->users->query()->whereIn('username', $usernames)->pluck('id'); + $ids = $this->users->getIdsForUsernames($usernames); + + // To be able to also use IDs. + $actualIds = array_diff($usernames, array_keys($ids)); + + if (! empty($actualIds)) { + $ids = array_merge($ids, $actualIds); + } $state->getQuery()->whereIn('posts.user_id', $ids, 'and', $negate); } diff --git a/framework/core/src/Post/Filter/FulltextFilter.php b/framework/core/src/Post/Filter/FulltextFilter.php new file mode 100644 index 0000000000..d8774da10b --- /dev/null +++ b/framework/core/src/Post/Filter/FulltextFilter.php @@ -0,0 +1,82 @@ + + */ +class FulltextFilter extends AbstractFulltextFilter +{ + public function __construct( + protected SettingsRepositoryInterface $settings + ) { + } + + public function search(SearchState $state, string $value): void + { + match ($state->getQuery()->getConnection()->getDriverName()) { + 'mysql' => $this->mysql($state, $value), + 'pgsql' => $this->pgsql($state, $value), + 'sqlite' => $this->sqlite($state, $value), + default => throw new RuntimeException('Unsupported database driver: '.$state->getQuery()->getConnection()->getDriverName()), + }; + } + + protected function sqlite(DatabaseSearchState $state, string $value): void + { + $state->getQuery()->where('content', 'like', "%$value%"); + } + + protected function mysql(DatabaseSearchState $state, string $value): void + { + $query = $state->getQuery(); + + // Replace all non-word characters with spaces. + // We do this to prevent MySQL fulltext search boolean mode from taking + // effect: https://dev.mysql.com/doc/refman/5.7/en/fulltext-boolean.html + $value = preg_replace('/[^\p{L}\p{N}\p{M}_]+/u', ' ', $value); + + $grammar = $query->getGrammar(); + + $match = 'MATCH('.$grammar->wrap('posts.content').') AGAINST (?)'; + $matchBooleanMode = 'MATCH('.$grammar->wrap('posts.content').') AGAINST (? IN BOOLEAN MODE)'; + + $query->whereRaw($matchBooleanMode, [$value]); + + $state->setDefaultSort(function (Builder $query) use ($value, $match) { + $query->orderByRaw($match.' desc', [$value]); + }); + } + + protected function pgsql(DatabaseSearchState $state, string $value): void + { + $searchConfig = $this->settings->get('pgsql_search_configuration'); + + $query = $state->getQuery(); + + $grammar = $query->getGrammar(); + + $matchCondition = "to_tsvector('$searchConfig', ".$grammar->wrap('posts.content').") @@ plainto_tsquery('$searchConfig', ?)"; + $matchScore = "ts_rank(to_tsvector('$searchConfig', ".$grammar->wrap('posts.content')."), plainto_tsquery('$searchConfig', ?))"; + + $query->whereRaw($matchCondition, [$value]); + + $state->setDefaultSort(function (Builder $query) use ($value, $matchScore) { + $query->orderByRaw($matchScore.' desc', [$value]); + }); + } +} diff --git a/framework/core/src/Search/SearchServiceProvider.php b/framework/core/src/Search/SearchServiceProvider.php index 95c1452f94..ffa9c98aa7 100644 --- a/framework/core/src/Search/SearchServiceProvider.php +++ b/framework/core/src/Search/SearchServiceProvider.php @@ -32,7 +32,6 @@ use Flarum\User\Search\UserSearcher; use Flarum\User\User; use Illuminate\Contracts\Container\Container; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; class SearchServiceProvider extends AbstractServiceProvider @@ -65,6 +64,7 @@ public function register(): void $this->container->singleton('flarum.search.fulltext', function () { return [ DiscussionSearcher::class => DiscussionFulltextFilter::class, + PostSearcher::class => PostFilter\FulltextFilter::class, UserSearcher::class => UserFulltextFilter::class ]; }); diff --git a/framework/core/src/User/UserRepository.php b/framework/core/src/User/UserRepository.php index ca45483ae4..c5a43032ad 100644 --- a/framework/core/src/User/UserRepository.php +++ b/framework/core/src/User/UserRepository.php @@ -86,7 +86,7 @@ public function getIdsForUsernames(array $usernames, User $actor = null): array { $query = $this->query()->whereIn('username', $usernames); - return $this->scopeVisibleTo($query, $actor)->pluck('id')->all(); + return $this->scopeVisibleTo($query, $actor)->pluck('id', 'username')->all(); } /**