mirror of
https://github.com/flarum/framework.git
synced 2025-04-01 13:25:13 +08:00
feat: post search adapted with global search (#4019)
This commit is contained in:
parent
06eb613c9b
commit
1ab3029e78
7
extensions/likes/js/src/common/extend.ts
Normal file
7
extensions/likes/js/src/common/extend.ts
Normal 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),
|
||||
];
|
16
extensions/likes/js/src/common/query/posts/LikedByGambit.ts
Normal file
16
extensions/likes/js/src/common/query/posts/LikedByGambit.ts
Normal 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';
|
||||
}
|
||||
}
|
16
extensions/likes/js/src/forum/components/LikesUserPage.ts
Normal file
16
extensions/likes/js/src/forum/components/LikesUserPage.ts
Normal 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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
@ -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),
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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';
|
||||
|
3
extensions/mentions/js/src/admin/extend.ts
Normal file
3
extensions/mentions/js/src/admin/extend.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import commonExtend from '../common/extend';
|
||||
|
||||
export default [...commonExtend];
|
@ -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')
|
||||
|
7
extensions/mentions/js/src/common/extend.ts
Normal file
7
extensions/mentions/js/src/common/extend.ts
Normal 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),
|
||||
];
|
@ -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';
|
||||
}
|
||||
}
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
@ -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),
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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) {
|
||||
|
@ -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>');
|
||||
|
||||
|
@ -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';
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>,
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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')));
|
||||
|
||||
|
58
framework/core/js/src/forum/components/PostList.tsx
Normal file
58
framework/core/js/src/forum/components/PostList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
30
framework/core/js/src/forum/components/PostListItem.tsx
Normal file
30
framework/core/js/src/forum/components/PostListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
144
framework/core/js/src/forum/components/PostsPage.tsx
Normal file
144
framework/core/js/src/forum/components/PostsPage.tsx
Normal 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;
|
||||
}
|
||||
}
|
76
framework/core/js/src/forum/components/PostsSearchSource.tsx
Normal file
76
framework/core/js/src/forum/components/PostsSearchSource.tsx
Normal 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);
|
||||
}
|
||||
}
|
@ -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() },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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')) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 },
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
99
framework/core/js/src/forum/states/PostListState.ts
Normal file
99
framework/core/js/src/forum/states/PostListState.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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";
|
||||
|
19
framework/core/less/forum/PostList.less
Normal file
19
framework/core/less/forum/PostList.less
Normal 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;
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -270,7 +270,9 @@ class PostResource extends AbstractDatabaseResource
|
||||
{
|
||||
return [
|
||||
SortColumn::make('number'),
|
||||
SortColumn::make('createdAt'),
|
||||
SortColumn::make('createdAt')
|
||||
->ascendingAlias('oldest')
|
||||
->descendingAlias('newest'),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -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')])
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
83
framework/core/src/Forum/Content/Posts.php
Normal file
83
framework/core/src/Forum/Content/Posts.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
@ -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',
|
||||
|
@ -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);
|
||||
}
|
||||
|
82
framework/core/src/Post/Filter/FulltextFilter.php
Normal file
82
framework/core/src/Post/Filter/FulltextFilter.php
Normal 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]);
|
||||
});
|
||||
}
|
||||
}
|
@ -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
|
||||
];
|
||||
});
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
x
Reference in New Issue
Block a user