feat: post search adapted with global search (#4019)

This commit is contained in:
Sami Mazouz 2024-09-19 17:01:58 +01:00 committed by GitHub
parent 06eb613c9b
commit 1ab3029e78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 974 additions and 188 deletions

View File

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

View File

@ -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';
}
}

View File

@ -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(),
},
};
}
}

View File

@ -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',
});
}
}

View File

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

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 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();
}
});
});

View File

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

View File

@ -13,6 +13,7 @@ use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Flarum\User\UserRepository;
/**
* @implements FilterInterface<DatabaseSearchState>
@ -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 @@ class LikedByFilter implements FilterInterface
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()

View File

@ -13,6 +13,7 @@ use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Flarum\User\UserRepository;
/**
* @implements FilterInterface<DatabaseSearchState>
@ -21,6 +22,11 @@ class LikedFilter implements FilterInterface
{
use ValidateFilterTrait;
public function __construct(
protected UserRepository $users
) {
}
public function getFilterKey(): string
{
return 'liked';

View File

@ -0,0 +1,3 @@
import commonExtend from '../common/extend';
export default [...commonExtend];

View File

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

View File

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

View File

@ -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';
}
}

View File

@ -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',
});
}
}

View File

@ -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(),
},
};
}
}

View File

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

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 { 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';

View File

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

View File

@ -13,6 +13,7 @@ use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\Filter\FilterInterface;
use Flarum\Search\SearchState;
use Flarum\Search\ValidateFilterTrait;
use Flarum\User\UserRepository;
/**
* @implements FilterInterface<DatabaseSearchState>
@ -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 @@ class MentionedFilter implements FilterInterface
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()

View File

@ -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<string, Array<new () => 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) {

View File

@ -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<any, any> | string {
export default function highlight(string: string, phrase?: string | RegExp, length?: number, safe = false): Mithril.Vnode<any, any> | 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
// <mark> tags. Then we will return the result as a trusted HTML string.
highlighted = $('<div/>').text(highlighted).html();
if (!safe) {
highlighted = $('<div/>').text(highlighted).html();
}
if (phrase) highlighted = highlighted.replace(regexp, '<mark>$&</mark>');

View File

@ -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';
}
}

View File

@ -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<string, any>;
constructor(type: Function | null, data: any = {}) {
this.type = type;
this.data = data;
}
@ -13,7 +18,7 @@ export default class PageState {
* @param {Record<string, unknown>} 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;
}
}

View File

@ -33,7 +33,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
public static DEFAULT_PAGE_SIZE = 20;
protected location!: PaginationLocation;
protected pageSize: number | null;
public pageSize: number | null;
protected pages: Page<T>[] = [];
protected params: P = {} as P;
@ -267,4 +267,11 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
.map((pg) => pg.items)
.flat();
}
/**
* In the last request, has the user searched for a model?
*/
isSearchResults(): boolean {
return !!this.params.q;
}
}

View File

@ -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([
<header className="Post-header">
<ul>{listItems(this.headerItems().toArray())}</ul>
@ -68,9 +80,7 @@ export default class CommentPost extends Post {
<UserCard user={this.attrs.post.user()} className="UserCard--popover" controlsButtonClassName="Button Button--icon Button--flat" />
)}
</header>,
<div className="Post-body">
{this.isEditing() ? <ComposerPostPreview className="Post-preview" composer={app.composer} /> : m.trust(this.attrs.post.contentHtml())}
</div>,
<div className="Post-body">{this.isEditing() ? <ComposerPostPreview className="Post-preview" composer={app.composer} /> : contentHtml}</div>,
]);
}

View File

@ -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<CustomAttrs extends IDiscussionListItemA
const items = new ItemList<Mithril.Children>();
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<CustomAttrs extends IDiscussionListItemA
}
getJumpTo() {
if (this.attrs.jumpTo) {
return this.attrs.jumpTo;
}
const discussion = this.attrs.discussion;
let jumpTo = 0;
@ -262,7 +271,7 @@ export default class DiscussionListItem<CustomAttrs extends IDiscussionListItemA
const items = new ItemList<Mithril.Children>();
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);

View File

@ -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<CustomAttrs extends IIndexPageAttrs = IIndexPageA
this.lastDiscussion = app.previous.get('discussion');
}
const params = app.search.state.params();
// If there is an active search and the user is coming from the PostsPage,
// then we will clear the search state so that discussions aren't searched.
if (app.previous.matches(PostsPage)) {
app.search.state.clear();
}
// If the user is coming from the discussion list, then they have either
// just switched one of the parameters (filter, sort, search) or they
// probably want to refresh the results. We will clear the discussion list
@ -41,7 +50,9 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
app.discussions.clear();
}
app.discussions.refreshParams(app.search.state.params(), (m.route.param('page') && Number(m.route.param('page'))) || 1);
if (!app.previous.matches(PostsPage) || !params.q) {
app.discussions.refreshParams(params, (m.route.param('page') && Number(m.route.param('page'))) || 1);
}
app.history.push('index', extractText(app.translator.trans('core.forum.header.back_to_index_tooltip')));

View File

@ -0,0 +1,58 @@
import app from '../../forum/app';
import Component, { type ComponentAttrs } from '../../common/Component';
import Button from '../../common/components/Button';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Placeholder from '../../common/components/Placeholder';
import classList from '../../common/utils/classList';
import PostListState from '../states/PostListState';
import PostListItem from './PostListItem';
export interface IPostListAttrs extends ComponentAttrs {
state: PostListState;
}
export default class PostList<CustomAttrs extends IPostListAttrs = IPostListAttrs> extends Component<CustomAttrs> {
view() {
const state = this.attrs.state;
const params = state.getParams();
const isLoading = state.isInitialLoading() || state.isLoadingNext();
let loading;
if (isLoading) {
loading = <LoadingIndicator />;
} else if (state.hasNext()) {
loading = (
<Button className="Button" onclick={state.loadNext.bind(state)}>
{app.translator.trans('core.forum.post_list.load_more_button')}
</Button>
);
}
if (state.isEmpty()) {
return (
<div className="PostList">
<Placeholder text={app.translator.trans('core.forum.post_list.empty_text')} />
</div>
);
}
const pageSize = state.pageSize || 0;
return (
<div className={classList('PostList', { 'PostList--searchResults': state.isSearchResults() })}>
<ul role="feed" aria-busy={isLoading} className="PostList-discussions">
{state.getPages().map((pg, pageNum) => {
return pg.items.map((post, itemNum) => (
<li key={post.id()} data-id={post.id()} role="article" aria-setsize="-1" aria-posinset={pageNum * pageSize + itemNum}>
<PostListItem post={post} params={params} />
</li>
));
})}
</ul>
<div className="PostList-loadMore">{loading}</div>
</div>
);
}
}

View File

@ -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<CustomAttrs extends IPostListItemAttrs = IPostListItemAttrs> extends Component<CustomAttrs> {
view(): Mithril.Children {
const post = this.attrs.post;
return (
<div className="PostListItem">
<div className="PostListItem-discussion">
{app.translator.trans('core.forum.post_list.in_discussion_text', {
discussion: <Link href={app.route.post(post)}>{post.discussion().title()}</Link>,
})}
</div>
<CommentPost post={post} params={this.attrs.params} />
</div>
);
}
}

View File

@ -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<CustomAttrs extends IPostsPageAttrs = IPostsPageAttrs, CustomState = {}> extends Page<CustomAttrs, CustomState> {
static providesInitialSearch = true;
protected posts!: PostListState;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
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 (
<PageStructure className="PostsPage" hero={this.hero.bind(this)} sidebar={this.sidebar.bind(this)}>
<div className="IndexPage-toolbar PostsPage-toolbar">
<ul className="IndexPage-toolbar-view PostsPage-toolbar-view">{listItems(this.viewItems().toArray())}</ul>
<ul className="IndexPage-toolbar-action PostsPage-toolbar-action">{listItems(this.actionItems().toArray())}</ul>
</div>
<PostList state={this.posts} />
</PageStructure>
);
}
setTitle() {
app.setTitle(extractText(app.translator.trans('core.forum.posts.meta_title_text')));
app.setTitleCount(0);
}
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.oncreate(vnode);
this.setTitle();
}
onremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.onremove(vnode);
$('#app').css('min-height', '');
}
/**
* Get the component to display as the hero.
*/
hero() {
return <WelcomeHero />;
}
sidebar() {
return <IndexSidebar />;
}
/**
* 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<Mithril.Children>();
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',
<Dropdown
buttonClassName="Button"
label={sortOptions[app.search.state.params().sort] || Object.keys(sortMap).map((key) => 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 (
<Button icon={active ? 'fas fa-check' : true} onclick={() => app.search.state.changeSort(value)} active={active}>
{label}
</Button>
);
})}
</Dropdown>
);
}
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<Mithril.Children>();
items.add(
'refresh',
<Button
title={app.translator.trans('core.forum.posts.refresh_tooltip')}
aria-label={app.translator.trans('core.forum.posts.refresh_tooltip')}
icon="fas fa-sync"
className="Button Button--icon"
onclick={() => {
this.posts.refresh();
if (app.session.user) {
app.store.find('users', app.session.user.id()!);
m.redraw();
}
}}
/>
);
return items;
}
}

View File

@ -0,0 +1,76 @@
import app from '../app';
import LinkButton from '../../common/components/LinkButton';
import type Mithril from 'mithril';
import type Post from '../../common/models/Post';
import type { SearchSource } from './Search';
import extractText from '../../common/utils/extractText';
import MinimalDiscussionListItem from './MinimalDiscussionListItem';
/**
* The `PostsSearchSource` finds and displays post search results in
* the search dropdown.
*/
export default class PostsSearchSource implements SearchSource {
protected results = new Map<string, Post[]>();
public resource: string = 'posts';
title(): string {
return extractText(app.translator.trans('core.lib.search_source.posts.heading'));
}
isCached(query: string): boolean {
return this.results.has(query.toLowerCase());
}
async search(query: string, limit: number): Promise<void> {
query = query.toLowerCase();
this.results.set(query, []);
const params = {
filter: { q: query },
page: { limit },
include: 'user,discussion.tags',
};
return app.store.find<Post[]>('posts', params).then((results) => {
this.results.set(query, results);
m.redraw();
});
}
view(query: string): Array<Mithril.Vnode> {
query = query.toLowerCase();
return (this.results.get(query) || []).map((post) => {
return (
<li className="PostSearchResult" data-index={'posts' + post.id()} data-id={post.id()}>
<MinimalDiscussionListItem discussion={post.discussion()} post={post} params={{ q: query }} jumpTo={post.number()} author={post.user()} />
</li>
);
}) as Array<Mithril.Vnode>;
}
fullPage(query: string): Mithril.Vnode {
const filter = app.search.gambits.apply('posts', { q: query });
const q = filter.q || null;
delete filter.q;
return (
<li>
<LinkButton icon="fas fa-search" href={app.route('posts', { q, filter })}>
{app.translator.trans('core.lib.search_source.posts.all_button', { query })}
</LinkButton>
</li>
);
}
gotoItem(id: string): string | null {
const post = app.store.getById<Post>('posts', id);
if (!post) return null;
return app.route.post(post);
}
}

View File

@ -8,6 +8,8 @@ import CommentPost from './CommentPost';
import type Post from '../../common/models/Post';
import type Mithril from 'mithril';
import type User from '../../common/models/User';
import PostListState from '../states/PostListState';
import PostList from './PostList';
/**
* The `PostsUserPage` component shows a user's activity feed inside of their
@ -15,19 +17,9 @@ import type User from '../../common/models/User';
*/
export default class PostsUserPage extends UserPage {
/**
* Whether or not the activity feed is currently loading.
* The state of the Post models in the feed.
*/
loading: boolean = true;
/**
* Whether or not there are any more activity items that can be loaded.
*/
moreResults: boolean = false;
/**
* The Post models in the feed.
*/
posts: Post[] = [];
posts!: PostListState;
/**
* The number of activity items to load per request.
@ -37,48 +29,15 @@ export default class PostsUserPage extends UserPage {
oninit(vnode: Mithril.Vnode<IUserPageAttrs, this>) {
super.oninit(vnode);
this.posts = new PostListState({}, this.loadLimit);
this.loadUser(m.route.param('username'));
}
content() {
if (this.posts.length === 0 && !this.loading) {
return (
<div className="PostsUserPage">
<Placeholder text={app.translator.trans('core.forum.user.posts_empty_text')} />
</div>
);
}
let footer;
if (this.loading) {
footer = <LoadingIndicator />;
} else if (this.moreResults) {
footer = (
<div className="PostsUserPage-loadMore">
<Button className="Button" onclick={this.loadMore.bind(this)}>
{app.translator.trans('core.forum.user.posts_load_more_button')}
</Button>
</div>
);
}
return (
<div className="PostsUserPage">
<ul className="PostsUserPage-list">
{this.posts.map((post) => (
<li>
<div className="PostsUserPage-discussion">
{app.translator.trans('core.forum.user.in_discussion_text', {
discussion: <Link href={app.route.post(post)}>{post.discussion().title()}</Link>,
})}
</div>
<CommentPost post={post} />
</li>
))}
</ul>
<div className="PostsUserPage-loadMore">{footer}</div>
<PostList state={this.posts} />
</div>
);
}
@ -90,56 +49,12 @@ export default class PostsUserPage extends UserPage {
show(user: User): void {
super.show(user);
this.refresh();
this.posts.refreshParams(this.params(user), 1);
}
/**
* 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<Post[]>('posts', {
filter: {
author: this.user!.username(),
type: 'comment',
},
page: { offset, limit: this.loadLimit },
sort: '-createdAt',
});
}
/**
* 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() },
};
}
}

View File

@ -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<T extends SearchAttrs = SearchAttrs> extends Compone
if (app.forum.attribute('canViewForum')) {
items.add('discussions', new DiscussionsSearchSource());
items.add('posts', new PostsSearchSource());
}
if (app.forum.attribute('canSearchUsers')) {

View File

@ -65,7 +65,10 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
this.searchState = this.attrs.searchState;
this.sources = this.attrs.sources;
this.query = Stream(this.searchState.getValue() || '');
this.activeSource = Stream(
this.defaultActiveSource() ? this.sources.find((source) => 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<CustomAttrs extends ISearchModalAttrs = ISearch
}
content(): Mithril.Children {
// Initialize the active source.
if (!this.activeSource) this.activeSource = Stream(this.sources[0]);
this.gambitsAutocomplete[this.activeSource().resource] ||= new GambitsAutocomplete(
this.activeSource().resource,
() => this.inputElement(),
@ -432,4 +432,45 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
inputElement(): JQuery<HTMLInputElement> {
return this.$('.SearchModal-input') as JQuery<HTMLInputElement>;
}
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<string, Record<string, any>> {
const filters: Record<string, Record<string, any>> = {};
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;
}
}

View File

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

View File

@ -76,13 +76,6 @@ export default class DiscussionListState<P extends DiscussionListParams = Discus
return map;
}
/**
* In the last request, has the user searched for a discussion?
*/
isSearchResults(): boolean {
return !!this.params.q;
}
removeDiscussion(discussion: Discussion): void {
this.eventEmitter.emit('discussion.deleted', discussion);
}

View File

@ -29,7 +29,7 @@ export default class GlobalSearchState extends SearchState {
}
protected currPageProvidesSearch(): boolean {
return app.current.type && app.current.type.providesInitialSearch;
return app.current.type && 'providesInitialSearch' in app.current.type && (app.current.type as any).providesInitialSearch;
}
/**

View File

@ -0,0 +1,99 @@
import app from '../../forum/app';
import PaginatedListState, { Page, PaginatedListParams, PaginatedListRequestParams } from '../../common/states/PaginatedListState';
import Post from '../../common/models/Post';
import { ApiResponsePlural } from '../../common/Store';
import EventEmitter from '../../common/utils/EventEmitter';
export interface PostListParams extends PaginatedListParams {
sort?: string;
}
const globalEventEmitter = new EventEmitter();
export default class PostListState<P extends PostListParams = PostListParams> extends PaginatedListState<Post, P> {
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<ApiResponsePlural<Post>> {
const preloadedPosts = app.preloadedApiDocument<Post[]>();
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<Post>[] {
const pages = super.getPages();
if (this.extraPosts.length) {
return [
{
number: -1,
items: this.extraPosts,
},
...pages,
];
}
return pages;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -270,7 +270,9 @@ class PostResource extends AbstractDatabaseResource
{
return [
SortColumn::make('number'),
SortColumn::make('createdAt'),
SortColumn::make('createdAt')
->ascendingAlias('oldest')
->descendingAlias('newest'),
];
}

View File

@ -146,7 +146,7 @@ class UserResource extends AbstractDatabaseResource
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')])

View File

@ -44,6 +44,13 @@ class AuthorFilter implements FilterInterface
$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);
}
}

View File

@ -0,0 +1,83 @@
<?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\Forum\Content;
use Flarum\Api\Client;
use Flarum\Api\Resource\PostResource;
use Flarum\Frontend\Document;
use Flarum\Http\UrlGenerator;
use Flarum\Locale\TranslatorInterface;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\View\Factory;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Post search results.
*/
class Posts
{
public function __construct(
protected Client $api,
protected Factory $view,
protected SettingsRepositoryInterface $settings,
protected UrlGenerator $url,
protected TranslatorInterface $translator,
protected PostResource $resource,
) {
}
public function __invoke(Document $document, Request $request): Document
{
$queryParams = $request->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()
);
}
}

View File

@ -19,6 +19,12 @@ return function (RouteCollection $map, RouteHandlerFactory $route) {
$route->toForum(Content\Index::class)
);
$map->get(
'/posts',
'posts',
$route->toForum(Content\Posts::class)
);
$map->get(
'/d/{id:\d+(?:-[^/]*)?}[/{near:[^/]*}]',
'discussion',

View File

@ -36,7 +36,14 @@ class AuthorFilter implements FilterInterface
{
$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);
}

View File

@ -0,0 +1,82 @@
<?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\Post\Filter;
use Flarum\Search\AbstractFulltextFilter;
use Flarum\Search\Database\DatabaseSearchState;
use Flarum\Search\SearchState;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Database\Eloquent\Builder;
use RuntimeException;
/**
* @extends AbstractFulltextFilter<DatabaseSearchState>
*/
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]);
});
}
}

View File

@ -32,7 +32,6 @@ use Flarum\User\Search\FulltextFilter as UserFulltextFilter;
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 @@ class SearchServiceProvider extends AbstractServiceProvider
$this->container->singleton('flarum.search.fulltext', function () {
return [
DiscussionSearcher::class => DiscussionFulltextFilter::class,
PostSearcher::class => PostFilter\FulltextFilter::class,
UserSearcher::class => UserFulltextFilter::class
];
});

View File

@ -86,7 +86,7 @@ class UserRepository
{
$query = $this->query()->whereIn('username', $usernames);
return $this->scopeVisibleTo($query, $actor)->pluck('id')->all();
return $this->scopeVisibleTo($query, $actor)->pluck('id', 'username')->all();
}
/**