feat: search UI/UX revamp (#3941)

* feat: first iteration

* chore: tweak

* feat: second iteration

* chore: incorrect code organization

* feat: gambit input suggestions

* feat: gambit keyboard navigation

* chore: bugs

* feat: negative gambits

* feat: improve gambit highlighting

* refactor: localize gambits

* feat: negative and positive gambit buttons

* fix: permissions

* chore: wat

* per: lazy load search modal

* fix: extensibility and bug fixes

* fix: bugs

* feat: reusable autocomplete dropdown

* chore: format

* fix: tag filter
This commit is contained in:
Sami Mazouz 2024-01-09 22:51:01 +01:00 committed by GitHub
parent fb1703cd9b
commit 3a34136e36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 2158 additions and 754 deletions

View File

@ -2,6 +2,7 @@ import { extend } from 'flarum/common/extend';
import TextEditorButton from 'flarum/common/components/TextEditorButton';
import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable';
import Tooltip from 'flarum/common/components/Tooltip';
import AutocompleteReader from 'flarum/common/utils/AutocompleteReader';
import AutocompleteDropdown from './fragments/AutocompleteDropdown';
import getEmojiIconCode from './helpers/getEmojiIconCode';
@ -40,15 +41,7 @@ export default function addComposerAutocomplete() {
extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) {
const emojiKeys = Object.keys(emojiMap);
let relEmojiStart;
let absEmojiStart;
let typed;
const applySuggestion = (replacement) => {
this.attrs.composer.editor.replaceBeforeCursor(absEmojiStart - 1, replacement + ' ');
this.emojiDropdown.hide();
};
const autocompleteReader = new AutocompleteReader(':');
params.inputListeners.push(() => {
const selection = this.attrs.composer.editor.getSelectionRange();
@ -57,29 +50,20 @@ export default function addComposerAutocomplete() {
if (selection[1] - cursor > 0) return;
// Search backwards from the cursor for an ':' symbol. If we find
// one and followed by a whitespace, we will want to show the
// autocomplete dropdown!
const lastChunk = this.attrs.composer.editor.getLastNChars(15);
absEmojiStart = 0;
for (let i = lastChunk.length - 1; i >= 0; i--) {
const character = lastChunk.substr(i, 1);
// check what user typed, emoji names only contains alphanumeric,
// underline, '+' and '-'
if (!/[a-z0-9]|\+|\-|_|\:/.test(character)) break;
// make sure ':' preceded by a whitespace or newline
if (character === ':' && (i == 0 || /\s/.test(lastChunk.substr(i - 1, 1)))) {
relEmojiStart = i + 1;
absEmojiStart = cursor - lastChunk.length + i + 1;
break;
}
}
const autocompleting = autocompleteReader.check(lastChunk, cursor, /[a-z0-9]|\+|\-|_|\:/);
this.emojiDropdown.hide();
this.emojiDropdown.active = false;
if (absEmojiStart) {
typed = lastChunk.substring(relEmojiStart).toLowerCase();
if (autocompleting) {
const typed = autocompleting.typed;
const emojiDropdown = this.emojiDropdown;
const applySuggestion = (replacement) => {
this.attrs.composer.editor.replaceBeforeCursor(autocompleting.absoluteStart - 1, replacement + ' ');
this.emojiDropdown.hide();
};
const makeSuggestion = function ({ emoji, name, code }) {
return (
@ -88,7 +72,7 @@ export default function addComposerAutocomplete() {
key={emoji}
onclick={() => applySuggestion(emoji)}
onmouseenter={function () {
this.emojiDropdown.setIndex($(this).parent().index() - 1);
emojiDropdown.setIndex($(this).parent().index() - 1);
}}
>
<img alt={emoji} className="emoji" draggable="false" loading="lazy" src={`${cdn}72x72/${code}.png`} title={name} />
@ -152,7 +136,7 @@ export default function addComposerAutocomplete() {
m.render(this.$('.ComposerBody-emojiDropdownContainer')[0], this.emojiDropdown.render());
this.emojiDropdown.show();
const coordinates = this.attrs.composer.editor.getCaretCoordinates(absEmojiStart);
const coordinates = this.attrs.composer.editor.getCaretCoordinates(autocompleting.absoluteStart);
const width = this.emojiDropdown.$().outerWidth();
const height = this.emojiDropdown.$().outerHeight();
const parent = this.emojiDropdown.$().offsetParent();

View File

@ -1,23 +1,12 @@
import IGambit from 'flarum/common/query/IGambit';
import { BooleanGambit } from 'flarum/common/query/IGambit';
import app from 'flarum/common/app';
export default class LockedGambit implements IGambit {
pattern(): string {
return 'is:locked';
}
toFilter(_matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'locked';
return {
[key]: true,
};
export default class LockedGambit extends BooleanGambit {
key(): string {
return app.translator.trans('flarum-lock.lib.gambits.discussions.locked.key', {}, true);
}
filterKey(): string {
return 'locked';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:locked`;
}
}

View File

@ -35,3 +35,12 @@ flarum-lock:
# These translations are used in the Settings page.
settings:
notify_discussion_locked_label: Someone locks a discussion I started
# 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:
discussions:
locked:
key: locked

View File

@ -2,6 +2,8 @@ import app from 'flarum/forum/app';
import { extend } from 'flarum/common/extend';
import TextEditorButton from 'flarum/common/components/TextEditorButton';
import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable';
import AutocompleteReader from 'flarum/common/utils/AutocompleteReader';
import { throttle } from 'flarum/common/utils/throttleDebounce';
import AutocompleteDropdown from './fragments/AutocompleteDropdown';
import MentionableModels from './mentionables/MentionableModels';
@ -9,6 +11,7 @@ import MentionableModels from './mentionables/MentionableModels';
export default function addComposerAutocomplete() {
extend('flarum/common/components/TextEditor', 'onbuild', function () {
this.mentionsDropdown = new AutocompleteDropdown();
this.searchMentions = throttle(250, (mentionables, buildSuggestions) => mentionables.search().then(buildSuggestions));
const $editor = this.$('.TextEditor-editor').wrap('<div class="ComposerBody-mentionsWrapper"></div>');
this.navigator = new KeyboardNavigatable();
@ -24,21 +27,8 @@ export default function addComposerAutocomplete() {
});
extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) {
let relMentionStart;
let absMentionStart;
let matchTyped;
let mentionables = new MentionableModels({
onmouseenter: function () {
this.mentionsDropdown.setIndex($(this).parent().index());
},
onclick: (replacement) => {
this.attrs.composer.editor.replaceBeforeCursor(absMentionStart - 1, replacement + ' ');
this.mentionsDropdown.hide();
},
});
const suggestionsInputListener = () => {
const selection = this.attrs.composer.editor.getSelectionRange();
@ -46,30 +36,27 @@ export default function addComposerAutocomplete() {
if (selection[1] - cursor > 0) return;
// Search backwards from the cursor for a mention triggering symbol. If we find one,
// we will want to show the correct autocomplete dropdown!
// Check classes implementing the IMentionableModel interface to see triggering symbols.
const lastChunk = this.attrs.composer.editor.getLastNChars(30);
absMentionStart = 0;
let activeFormat = null;
for (let i = lastChunk.length - 1; i >= 0; i--) {
const character = lastChunk.substr(i, 1);
activeFormat = app.mentionFormats.get(character);
const autocompleteReader = new AutocompleteReader((character) => !!(activeFormat = app.mentionFormats.get(character)));
const autocompleting = autocompleteReader.check(this.attrs.composer.editor.getLastNChars(30), cursor, /\S+/);
if (activeFormat && (i === 0 || /\s/.test(lastChunk.substr(i - 1, 1)))) {
relMentionStart = i + 1;
absMentionStart = cursor - lastChunk.length + i + 1;
mentionables.init(activeFormat.makeMentionables());
break;
}
}
const mentionsDropdown = this.mentionsDropdown;
let mentionables = new MentionableModels({
onmouseenter: function () {
mentionsDropdown.setIndex($(this).parent().index());
},
onclick: (replacement) => {
this.attrs.composer.editor.replaceBeforeCursor(autocompleting.absoluteStart - 1, replacement + ' ');
this.mentionsDropdown.hide();
},
});
this.mentionsDropdown.hide();
this.mentionsDropdown.active = false;
if (absMentionStart) {
const typed = lastChunk.substring(relMentionStart).toLowerCase();
matchTyped = activeFormat.queryFromTyped(typed);
if (autocompleting) {
mentionables.init(activeFormat.makeMentionables());
matchTyped = activeFormat.queryFromTyped(autocompleting.typed);
if (!matchTyped) return;
@ -85,7 +72,7 @@ export default function addComposerAutocomplete() {
m.render(this.$('.ComposerBody-mentionsDropdownContainer')[0], this.mentionsDropdown.render());
this.mentionsDropdown.show();
const coordinates = this.attrs.composer.editor.getCaretCoordinates(absMentionStart);
const coordinates = this.attrs.composer.editor.getCaretCoordinates(autocompleting.absoluteStart);
const width = this.mentionsDropdown.$().outerWidth();
const height = this.mentionsDropdown.$().outerHeight();
const parent = this.mentionsDropdown.$().offsetParent();
@ -118,7 +105,7 @@ export default function addComposerAutocomplete() {
this.mentionsDropdown.setIndex(0);
this.mentionsDropdown.$().scrollTop(0);
mentionables.search()?.then(buildSuggestions);
this.searchMentions(mentionables, buildSuggestions);
}
};

View File

@ -2,7 +2,6 @@ import type MentionableModel from './MentionableModel';
import type Model from 'flarum/common/Model';
import type Mithril from 'mithril';
import MentionsDropdownItem from '../components/MentionsDropdownItem';
import { throttle } from 'flarum/common/utils/throttleDebounce';
export default class MentionableModels {
protected mentionables?: MentionableModel[];
@ -33,7 +32,7 @@ export default class MentionableModels {
* Don't send API calls searching for models until at least 2 characters have been typed.
* This focuses the mention results on models already loaded.
*/
public readonly search = throttle(250, async (): Promise<void> => {
public readonly search = async (): Promise<void> => {
if (!this.typed || this.typed.length <= 1) return;
const typedLower = this.typed.toLowerCase();
@ -51,7 +50,7 @@ export default class MentionableModels {
this.searched.push(typedLower);
return Promise.resolve();
});
};
public matches(mentionable: MentionableModel, model: Model): boolean {
return mentionable.matches(model, this.typed?.toLowerCase() || '');

View File

@ -1,23 +1,12 @@
import IGambit from 'flarum/common/query/IGambit';
import { BooleanGambit } from 'flarum/common/query/IGambit';
import app from 'flarum/common/app';
export default class StickyGambit implements IGambit {
pattern(): string {
return 'is:sticky';
}
toFilter(_matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'sticky';
return {
[key]: true,
};
export default class StickyGambit extends BooleanGambit {
key(): string {
return app.translator.trans('flarum-sticky.lib.gambits.discussions.sticky.key', {}, true);
}
filterKey(): string {
return 'sticky';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:sticky`;
}
}

View File

@ -32,6 +32,15 @@ flarum-sticky:
# REUSED TRANSLATIONS - These keys should not be used directly in code!
##
# 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:
discussions:
sticky:
key: sticky
# Translations in this namespace are referenced by two or more unique keys.
ref:
sticky: Sticky

View File

@ -1,15 +1,19 @@
import IGambit from 'flarum/common/query/IGambit';
import app from 'flarum/common/app';
import { BooleanGambit } from 'flarum/common/query/IGambit';
export default class SubscriptionGambit implements IGambit {
pattern(): string {
return 'is:(follow|ignor)(?:ing|ed)';
export default class SubscriptionGambit extends BooleanGambit {
key(): string[] {
return [
app.translator.trans('flarum-subscriptions.lib.gambits.discussions.subscription.following_key', {}, true),
app.translator.trans('flarum-subscriptions.lib.gambits.discussions.subscription.ignoring_key', {}, true),
];
}
toFilter(matches: string[], negate: boolean): Record<string, any> {
const type = matches[1] === 'follow' ? 'following' : 'ignoring';
const key = (negate ? '-' : '') + this.filterKey();
return {
subscription: type,
[key]: matches[1],
};
}
@ -20,4 +24,8 @@ export default class SubscriptionGambit implements IGambit {
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:${value}`;
}
enabled(): boolean {
return !!app.session.user;
}
}

View File

@ -9,7 +9,7 @@ import GlobalSearchState from 'flarum/forum/states/GlobalSearchState';
export default function addSubscriptionFilter() {
extend(IndexSidebar.prototype, 'navItems', function (items) {
if (app.session.user) {
const params = app.search.stickyParams();
const params = app.search.state.stickyParams();
items.add(
'following',

View File

@ -75,6 +75,16 @@ flarum-subscriptions:
# REUSED TRANSLATIONS - These keys should not be used directly in code!
##
# 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:
discussions:
subscription:
following_key: following
ignoring_key: ignoring
# Translations in this namespace are referenced by two or more unique keys.
ref:
follow: Follow

View File

@ -8,6 +8,7 @@
*/
use Flarum\Api\Serializer\BasicUserSerializer;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Extend;
use Flarum\Search\Database\DatabaseSearchDriver;
@ -41,6 +42,11 @@ return [
(new Extend\ApiSerializer(UserSerializer::class))
->attributes(AddUserSuspendAttributes::class),
(new Extend\ApiSerializer(ForumSerializer::class))
->attribute('canSuspendUsers', function (ForumSerializer $serializer) {
return $serializer->getActor()->hasPermission('user.suspend');
}),
new Extend\Locales(__DIR__.'/locale'),
(new Extend\Notification())

View File

@ -0,0 +1,7 @@
import 'flarum/common/models/User';
declare module 'flarum/common/models/User' {
export default interface User {
canSuspend: () => boolean;
}
}

View File

@ -1,7 +1,12 @@
import Extend from 'flarum/common/extenders';
import SuspendedGambit from './query/users/SuspendedGambit';
import User from 'flarum/common/models/User';
// prettier-ignore
export default [
new Extend.Search() //
new Extend.Search()
.gambit('users', SuspendedGambit),
new Extend.Model(User)
.attribute<boolean>('canSuspend'),
];

View File

@ -1,23 +1,16 @@
import IGambit from 'flarum/common/query/IGambit';
import app from 'flarum/common/app';
import { BooleanGambit } from 'flarum/common/query/IGambit';
export default class SuspendedGambit implements IGambit {
pattern(): string {
return 'is:suspended';
}
toFilter(_matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'suspended';
return {
[key]: true,
};
export default class SuspendedGambit extends BooleanGambit {
key(): string {
return app.translator.trans('flarum-suspend.lib.gambits.users.suspended.key', {}, true);
}
filterKey(): string {
return 'suspended';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:suspended`;
enabled(): boolean {
return !!app.session.user && app.forum.attribute<boolean>('canSuspendUsers');
}
}

View File

@ -8,7 +8,6 @@ export default [
...commonExtend,
new Extend.Model(User)
.attribute<boolean>('canSuspend')
.attribute<Date | null | undefined, string | null | undefined>('suspendedUntil', Model.transformDate)
.attribute<string | null | undefined>('suspendReason')
.attribute<string | null | undefined>('suspendMessage'),

View File

@ -71,3 +71,12 @@ flarum-suspend:
{forum_url}
html:
body: "You have been unsuspended. You can head back to [{forumTitle}]({forum_url}) when you are ready."
# 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:
users:
suspended:
key: suspended

View File

@ -1,23 +1,38 @@
import IGambit from 'flarum/common/query/IGambit';
import app from 'flarum/common/app';
import { KeyValueGambit } from 'flarum/common/query/IGambit';
export default class TagGambit implements IGambit {
pattern(): string {
return 'tag:(.+)';
export default class TagGambit extends KeyValueGambit {
predicates = true;
key(): string {
return app.translator.trans('flarum-tags.lib.gambits.discussions.tag.key', {}, true);
}
toFilter(matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'tag';
return {
[key]: matches[1].split(','),
};
hint(): string {
return app.translator.trans('flarum-tags.lib.gambits.discussions.tag.hint', {}, true);
}
filterKey(): string {
return 'tag';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}tag:${value}`;
gambitValueToFilterValue(value: string): string[] {
return [value];
}
fromFilter(value: any, negate: boolean): string {
let gambits = [];
if (Array.isArray(value)) {
gambits = value.map((value) => this.fromFilter(value.toString(), negate));
} else {
return `${negate ? '-' : ''}${this.key()}:${this.filterValueToGambitValue(value)}`;
}
return gambits.join(' ');
}
filterValueToGambitValue(value: string): string {
return value;
}
}

View File

@ -20,7 +20,7 @@ export default function addTagFilter() {
return this.currentActiveTag;
}
const slug = this.search.params().tags;
const slug = this.search.state.params().tags;
let tag = null;
if (slug) {

View File

@ -24,7 +24,7 @@ export default function addTagList() {
items.add('separator', <Separator />, -12);
const params = app.search.stickyParams();
const params = app.search.state.stickyParams();
const tags = app.store.all('tags');
const currentTag = app.currentTag();

View File

@ -107,6 +107,13 @@ flarum-tags:
# This translation is displayed in place of the name of a tag that's been deleted.
deleted_tag_text: Deleted
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
gambits:
discussions:
tag:
key: tag
hint: name of a tag, or comma-separated list of tag names, or "untagged"
# These translations are used in the tag selection modal.
tag_selection_modal:
bypass_requirements: Bypass tag requirements

View File

@ -43,30 +43,36 @@ class TagFilter implements FilterInterface
protected function constrain(Builder $query, string|array $rawSlugs, bool $negate, User $actor): void
{
$slugs = $this->asStringArray($rawSlugs);
$rawSlugs = (array) $rawSlugs;
$query->where(function (Builder $query) use ($slugs, $negate, $actor) {
foreach ($slugs as $slug) {
if ($slug === 'untagged') {
$query->whereIn('discussions.id', function (Builder $query) {
$query->select('discussion_id')
->from('discussion_tag');
}, 'or', ! $negate);
} else {
// @TODO: grab all IDs first instead of multiple queries.
try {
$id = $this->slugger->forResource(Tag::class)->fromSlug($slug, $actor)->id;
} catch (ModelNotFoundException) {
$id = null;
$inputSlugs = $this->asStringArray($rawSlugs);
foreach ($inputSlugs as $orSlugs) {
$slugs = explode(',', $orSlugs);
$query->where(function (Builder $query) use ($slugs, $negate, $actor) {
foreach ($slugs as $slug) {
if ($slug === 'untagged') {
$query->whereIn('discussions.id', function (Builder $query) {
$query->select('discussion_id')
->from('discussion_tag');
}, 'or', ! $negate);
} else {
// @TODO: grab all IDs first instead of multiple queries.
try {
$id = $this->slugger->forResource(Tag::class)->fromSlug($slug, $actor)->id;
} catch (ModelNotFoundException) {
$id = null;
}
$query->whereIn('discussions.id', function (Builder $query) use ($id) {
$query->select('discussion_id')
->from('discussion_tag')
->where('tag_id', $id);
}, 'or', $negate);
}
$query->whereIn('discussions.id', function (Builder $query) use ($id) {
$query->select('discussion_id')
->from('discussion_tag')
->where('tag_id', $id);
}, 'or', $negate);
}
}
});
});
}
}
}

View File

@ -11,7 +11,7 @@ declare module '@askvortsov/rich-icu-message-formatter' {
type IRichHandler = (tag: any, values: IValues, contents: string) => any;
type ValueOrArray<T> = T | ValueOrArray<T>[];
type NestedStringArray = ValueOrArray<string>;
export type NestedStringArray = ValueOrArray<string>;
export class RichMessageFormatter {
locale: string | null;

View File

@ -6,6 +6,8 @@ import Navigation from '../common/components/Navigation';
import AdminNav from './components/AdminNav';
import ExtensionData from './utils/ExtensionData';
import IHistory from '../common/IHistory';
import SearchManager from '../common/SearchManager';
import SearchState from '../common/states/SearchState';
export type Extension = {
id: string;
@ -66,6 +68,8 @@ export default class AdminApplication extends Application {
home: () => {},
};
search: SearchManager<SearchState> = new SearchManager(new SearchState());
/**
* Settings are serialized to the admin dashboard as strings.
* Additional encoding/decoding is possible, but must take

View File

@ -6,6 +6,8 @@ import SelectDropdown from '../../common/components/SelectDropdown';
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
import Input from '../../common/components/Input';
import extractText from '../../common/utils/extractText';
export default class AdminNav extends Component {
oninit(vnode) {
@ -122,14 +124,13 @@ export default class AdminNav extends Component {
items.add(
'search',
<div className="Search-input">
<input
className="FormControl SearchBar"
bidi={this.query}
type="search"
placeholder={app.translator.trans('core.admin.nav.search_placeholder')}
/>
</div>,
<Input
type="search"
className="SearchBar"
stream={this.query}
clearable={true}
placeholder={extractText(app.translator.trans('core.admin.nav.search_placeholder'))}
/>,
0
);

View File

@ -17,6 +17,8 @@ import AdminPage from './AdminPage';
import { debounce } from '../../common/utils/throttleDebounce';
import CreateUserModal from './CreateUserModal';
import Icon from '../../common/components/Icon';
import Input from '../../common/components/Input';
import GambitsAutocompleteDropdown from '../../common/components/GambitsAutocompleteDropdown';
type ColumnData = {
/**
@ -234,20 +236,24 @@ export default class UserListPage extends AdminPage {
headerItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
const onchange = (value: string) => {
this.isLoadingPage = true;
this.query = value;
this.throttledSearch();
};
items.add(
'search',
<div className="Search-input">
<input
className="FormControl SearchBar"
<GambitsAutocompleteDropdown resource="users" query={this.query} onchange={onchange}>
<Input
type="search"
placeholder={app.translator.trans('core.admin.users.search_placeholder')}
oninput={(e: InputEvent) => {
this.isLoadingPage = true;
this.query = (e?.target as HTMLInputElement)?.value;
this.throttledSearch();
}}
clearable={true}
loading={this.isLoadingPage}
value={this.query}
onchange={onchange}
/>
</div>,
</GambitsAutocompleteDropdown>,
100
);

View File

@ -37,6 +37,7 @@ import fireApplicationError from './helpers/fireApplicationError';
import IHistory from './IHistory';
import IExtender from './extenders/IExtender';
import AccessToken from './models/AccessToken';
import SearchManager from './SearchManager';
export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
@ -184,6 +185,8 @@ export default class Application {
notifications: Notification,
});
search!: SearchManager;
/**
* A local cache that can be used to store data at the application level, so
* that is persists between different routes.

View File

@ -1,4 +1,4 @@
import IGambit from './query/IGambit';
import type IGambit from './query/IGambit';
import AuthorGambit from './query/discussions/AuthorGambit';
import CreatedGambit from './query/discussions/CreatedGambit';
import HiddenGambit from './query/discussions/HiddenGambit';
@ -19,15 +19,29 @@ export default class GambitManager {
};
public apply(type: string, filter: Record<string, any>): Record<string, any> {
const gambits = this.gambits[type] || [];
filter.q = this.match(type, filter.q, (gambit, matches, negate) => {
const additions = gambit.toFilter(matches, negate);
if (gambits.length === 0) return filter;
Object.keys(additions).forEach((key) => {
if (key in filter && gambit.predicates && Array.isArray(additions[key])) {
filter[key] = filter[key].concat(additions[key]);
} else {
filter[key] = additions[key];
}
});
});
const bits: string[] = filter.q.split(' ');
return filter;
}
for (const gambitClass of gambits) {
const gambit = new gambitClass();
public match(type: string, query: string, onmatch: (gambit: IGambit, matches: string[], negate: boolean, bit: string) => void): string {
const gambits = this.for(type).filter((gambit) => gambit.enabled());
if (gambits.length === 0) return query;
const bits: string[] = query.split(' ');
for (const gambit of gambits) {
for (const bit of bits) {
const pattern = `^(-?)${gambit.pattern()}$`;
let matches = bit.match(pattern);
@ -37,26 +51,25 @@ export default class GambitManager {
matches.splice(1, 1);
Object.assign(filter, gambit.toFilter(matches, negate));
onmatch(gambit, matches, negate, bit);
filter.q = filter.q.replace(bit, '');
query = query.replace(bit, '');
}
}
}
filter.q = filter.q.trim().replace(/\s+/g, ' ');
query = query.trim().replace(/\s+/g, ' ');
return filter;
return query;
}
public from(type: string, q: string, filter: Record<string, any>): string {
const gambits = this.gambits[type] || [];
const gambits = this.for(type);
if (gambits.length === 0) return q;
Object.keys(filter).forEach((key) => {
for (const gambitClass of gambits) {
const gambit = new gambitClass();
for (const gambit of gambits) {
const negate = key[0] === '-';
if (negate) key = key.substring(1);
@ -69,4 +82,8 @@ export default class GambitManager {
return q;
}
for(type: string): Array<IGambit> {
return (this.gambits[type] || []).map((gambitClass) => new gambitClass());
}
}

View File

@ -0,0 +1,25 @@
import SearchState from './states/SearchState';
import GambitManager from './GambitManager';
export default class SearchManager<State extends SearchState = SearchState> {
/**
* The minimum query length before sources are searched.
*/
public static MIN_SEARCH_LEN = 3;
/**
* An object which stores previously searched queries and provides convenient
* tools for retrieving and managing search values.
*/
public state: State;
/**
* The gambit manager that will convert search query gambits
* into API filters.
*/
public gambits = new GambitManager();
constructor(state: State) {
this.state = state;
}
}

View File

@ -89,12 +89,6 @@ export default class Store {
*/
models: Record<string, { new (): Model }>;
/**
* The gambit manager that will convert search query gambits
* into API filters.
*/
gambits = new GambitManager();
constructor(models: Record<string, { new (): Model }>) {
this.models = models;
}
@ -186,7 +180,7 @@ export default class Store {
}
if ('filter' in params && params?.filter?.q) {
params.filter = this.gambits.apply(type, params.filter);
params.filter = app.search.gambits.apply(type, params.filter);
}
return app

View File

@ -1,8 +1,9 @@
import { RichMessageFormatter, mithrilRichHandler } from '@askvortsov/rich-icu-message-formatter';
import { RichMessageFormatter, mithrilRichHandler, NestedStringArray } from '@askvortsov/rich-icu-message-formatter';
import { pluralTypeHandler, selectTypeHandler } from '@ultraq/icu-message-formatter';
import username from './helpers/username';
import User from './models/User';
import extract from './utils/extract';
import extractText from './utils/extractText';
type Translations = Record<string, string>;
type TranslatorParameters = Record<string, unknown>;
@ -69,12 +70,20 @@ export default class Translator {
return parameters;
}
trans(id: string, parameters: TranslatorParameters = {}) {
trans(id: string, parameters: TranslatorParameters): NestedStringArray;
trans(id: string, parameters: TranslatorParameters, extract: false): NestedStringArray;
trans(id: string, parameters: TranslatorParameters, extract: true): string;
trans(id: string): NestedStringArray | string;
trans(id: string, parameters: TranslatorParameters = {}, extract = false) {
const translation = this.translations[id];
if (translation) {
parameters = this.preprocessParameters(parameters);
return this.formatter.rich(translation, parameters);
const locale = this.formatter.rich(translation, parameters);
if (extract) return extractText(locale);
return locale;
}
return id;

View File

@ -5,7 +5,10 @@ import './states/PaginatedListState';
import './states/AlertManagerState';
import './states/ModalManagerState';
import './states/PageState';
import './states/SearchState';
import './utils/AutocompleteReader';
import './utils/GambitsAutocomplete';
import './utils/isObject';
import './utils/mixin';
import './utils/insertText';
@ -50,6 +53,7 @@ import './components/LoadingIndicator';
import './components/Placeholder';
import './components/Separator';
import './components/Dropdown';
import './components/InfoTile';
import './components/DetailedDropdownItem';
import './components/SplitDropdown';
import './components/RequestErrorModal';
@ -70,6 +74,8 @@ import './components/GroupBadge';
import './components/TextEditor';
import './components/TextEditorButton';
import './components/Tooltip';
import './components/AutocompleteDropdown';
import './components/GambitsAutocompleteDropdown';
import './helpers/fullTime';
import './components/Avatar';
@ -81,6 +87,8 @@ import './helpers/userOnline';
import './helpers/listItems';
import './helpers/textContrastClass';
import './query/IGambit';
import './resolvers/DefaultResolver';
import './Component';

View File

@ -0,0 +1,201 @@
import Component, { type ComponentAttrs } from '../Component';
import KeyboardNavigatable from '../utils/KeyboardNavigatable';
import type Mithril from 'mithril';
import classList from '../utils/classList';
export interface AutocompleteDropdownAttrs extends ComponentAttrs {
query: string;
onchange: (value: string) => void;
}
/**
* A reusable component that wraps around an input element and displays a list
* of suggestions based on the input's value.
* Must be extended and the `suggestions` method implemented.
*/
export default abstract class AutocompleteDropdown<
CustomAttrs extends AutocompleteDropdownAttrs = AutocompleteDropdownAttrs
> extends Component<CustomAttrs> {
/**
* The index of the currently-selected <li> in the results list. This can be
* a unique string (to account for the fact that an item's position may jump
* around as new results load), but otherwise it will be numeric (the
* sequential position within the list).
*/
protected index: number = 0;
protected navigator!: KeyboardNavigatable;
private updateMaxHeightHandler?: () => void;
/**
* Whether the input has focus.
*/
protected hasFocus = false;
abstract suggestions(): JSX.Element[];
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
const suggestions = this.suggestions();
const shouldShowSuggestions = !!suggestions.length;
return (
<div
className={classList('AutocompleteDropdown', {
focused: this.hasFocus,
open: shouldShowSuggestions && this.hasFocus,
})}
>
{vnode.children}
<ul
className="Dropdown-menu Dropdown-suggestions"
aria-hidden={!shouldShowSuggestions || undefined}
aria-live={shouldShowSuggestions ? 'polite' : undefined}
>
{suggestions}
</ul>
</div>
);
}
updateMaxHeight() {
// Since extensions might add elements above the search box on mobile,
// we need to calculate and set the max height dynamically.
const resultsElementMargin = 14;
const maxHeight = window.innerHeight - this.element.querySelector('.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin;
this.element.querySelector<HTMLElement>('.Dropdown-suggestions')?.style?.setProperty('max-height', `${maxHeight}px`);
}
onupdate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.onupdate(vnode);
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
this.updateMaxHeight();
}
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.oncreate(vnode);
const component = this;
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
this.$('.Dropdown-suggestions')
.on('mousedown', (e) => e.preventDefault())
// Whenever the mouse is hovered over a search result, highlight it.
.on('mouseenter', '> li:not(.Dropdown-header)', function () {
component.setIndex(component.selectableItems().index(this));
});
const $input = this.inputElement();
this.navigator = new KeyboardNavigatable();
this.navigator
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
.onSelect(this.selectSuggestion.bind(this), true)
.bindTo($input);
$input
.on('focus', function () {
component.hasFocus = true;
m.redraw();
$(this)
.one('mouseup', (e) => e.preventDefault())
.trigger('select');
})
.on('blur', function () {
component.hasFocus = false;
m.redraw();
});
this.updateMaxHeightHandler = this.updateMaxHeight.bind(this);
window.addEventListener('resize', this.updateMaxHeightHandler);
}
onremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.onremove(vnode);
if (this.updateMaxHeightHandler) {
window.removeEventListener('resize', this.updateMaxHeightHandler);
}
}
selectableItems(): JQuery {
return this.$('.Dropdown-suggestions > li:not(.Dropdown-header)');
}
inputElement(): JQuery<HTMLInputElement> {
return this.$('input') as JQuery<HTMLInputElement>;
}
selectSuggestion() {
this.getItem(this.index).find('button')[0].click();
}
/**
* Get the position of the currently selected item.
* Returns zero if not found.
*/
getCurrentNumericIndex(): number {
return Math.max(0, this.selectableItems().index(this.getItem(this.index)));
}
/**
* Get the <li> in the search results with the given index (numeric or named).
*/
getItem(index: number): JQuery {
const $items = this.selectableItems();
let $item = $items.filter(`[data-index="${index}"]`);
if (!$item.length) {
$item = $items.eq(index);
}
return $item;
}
/**
* Set the currently-selected search result item to the one with the given
* index.
*/
setIndex(index: number, scrollToItem: boolean = false) {
const $items = this.selectableItems();
const $dropdown = $items.parent();
let fixedIndex = index;
if (index < 0) {
fixedIndex = $items.length - 1;
} else if (index >= $items.length) {
fixedIndex = 0;
}
const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');
this.index = parseInt($item.attr('data-index') as string) || fixedIndex;
if (scrollToItem) {
const dropdownScroll = $dropdown.scrollTop()!;
const dropdownTop = $dropdown.offset()!.top;
const dropdownBottom = dropdownTop + $dropdown.outerHeight()!;
const itemTop = $item.offset()!.top;
const itemBottom = itemTop + $item.outerHeight()!;
let scrollTop;
if (itemTop < dropdownTop) {
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
} else if (itemBottom > dropdownBottom) {
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
}
if (typeof scrollTop !== 'undefined') {
$dropdown.stop(true).animate({ scrollTop }, 100);
}
}
}
}

View File

@ -0,0 +1,28 @@
import type Mithril from 'mithril';
import AutocompleteDropdown, { type AutocompleteDropdownAttrs } from './AutocompleteDropdown';
import GambitsAutocomplete from '../utils/GambitsAutocomplete';
export interface GambitsAutocompleteDropdownAttrs extends AutocompleteDropdownAttrs {
resource: string;
}
/**
* This is an autocomplete component not related to the SearchModal forum components.
* It is a standalone component that can be reused for search inputs of any other types
* of resources. It will display a dropdown menu under the input with gambit suggestions
* similar to the SearchModal component.
*/
export default class GambitsAutocompleteDropdown<
CustomAttrs extends GambitsAutocompleteDropdownAttrs = GambitsAutocompleteDropdownAttrs
> extends AutocompleteDropdown<CustomAttrs> {
protected gambitsAutocomplete!: GambitsAutocomplete;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
this.gambitsAutocomplete = new GambitsAutocomplete(this.attrs.resource, () => this.inputElement(), this.attrs.onchange, this.attrs.onchange);
}
suggestions(): JSX.Element[] {
return this.gambitsAutocomplete.suggestions(this.attrs.query);
}
}

View File

@ -0,0 +1,31 @@
import Component from '../Component';
import type { ComponentAttrs } from '../Component';
import type Mithril from 'mithril';
import Icon from './Icon';
import classList from '../utils/classList';
export interface IInfoTileAttrs extends ComponentAttrs {
icon?: string;
iconElement?: Mithril.Children;
}
export default class InfoTile<CustomAttrs extends IInfoTileAttrs = IInfoTileAttrs> extends Component<CustomAttrs> {
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
const { icon, className, ...attrs } = vnode.attrs;
return (
<div className={classList('InfoTile', className)} {...attrs}>
{this.icon()}
<div className="InfoTile-text">{vnode.children}</div>
</div>
);
}
icon(): Mithril.Children {
if (this.attrs.iconElement) return this.attrs.iconElement;
if (!this.attrs.icon) return null;
return <Icon name={classList(this.attrs.icon, 'InfoTile-icon')} />;
}
}

View File

@ -0,0 +1,97 @@
import app from '../../forum/app';
import Component from '../Component';
import Icon from './Icon';
import LoadingIndicator from './LoadingIndicator';
import classList from '../utils/classList';
import Button from './Button';
import Stream from '../utils/Stream';
import type { ComponentAttrs } from '../Component';
import type Mithril from 'mithril';
export interface IInputAttrs extends ComponentAttrs {
className?: string;
prefixIcon?: string;
clearable?: boolean;
clearLabel?: string;
loading?: boolean;
inputClassName?: string;
onchange?: (value: string) => void;
value?: string;
stream?: Stream<string>;
type?: string;
ariaLabel?: string;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
renderInput?: (attrs: any) => Mithril.Children;
inputAttrs?: {
className?: string;
[key: string]: any;
};
}
export default class Input<CustomAttrs extends IInputAttrs = IInputAttrs> extends Component<CustomAttrs> {
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
}
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
const { className: inputClassName, ...inputAttrs } = this.attrs.inputAttrs || {};
const value = this.attrs.value || this.attrs.stream?.() || '';
return (
<div
className={classList('Input', this.attrs.className, {
'Input--withPrefix': this.attrs.prefixIcon,
'Input--withClear': this.attrs.clearable,
})}
>
{this.attrs.prefixIcon && <Icon name={classList(this.attrs.prefixIcon, 'Input-prefix-icon')} />}
{this.input({ inputClassName, value, inputAttrs })}
{this.attrs.loading && <LoadingIndicator size="small" display="inline" containerClassName="Button Button--icon Button--link" />}
{this.attrs.clearable && value && !this.attrs.loading && (
<Button
className="Input-clear Button Button--icon Button--link"
onclick={this.clear.bind(this)}
aria-label={this.attrs.clearLabel || app.translator.trans('core.lib.input.clear_button')}
type="button"
icon="fas fa-times-circle"
/>
)}
</div>
);
}
input({ inputClassName, value, inputAttrs }: any) {
const attrs = {
className: classList('FormControl', inputClassName),
type: this.attrs.type || 'text',
value: value,
oninput: (e: InputEvent) => this.onchange?.((e.target as HTMLInputElement).value),
'aria-label': this.attrs.ariaLabel,
placeholder: this.attrs.placeholder,
readonly: this.attrs.readonly || undefined,
disabled: this.attrs.disabled || undefined,
...inputAttrs,
};
if (this.attrs.renderInput) {
return this.attrs.renderInput(attrs);
}
return <input {...attrs} />;
}
onchange(value: string) {
if (this.attrs.stream) {
this.attrs.stream(value);
} else {
this.attrs.onchange?.(value);
}
}
clear() {
this.onchange('');
}
}

View File

@ -7,7 +7,7 @@ export default class Search implements IExtender {
protected gambits: Record<string, Array<new () => IGambit>> = {};
public gambit(modelType: string, gambit: new () => IGambit): this {
this.gambits[modelType] = this.gambits[modelType] || [];
this.gambits[modelType] ||= [];
this.gambits[modelType].push(gambit);
return this;
@ -16,8 +16,8 @@ export default class Search implements IExtender {
extend(app: Application, extension: IExtensionModule): void {
for (const [modelType, gambits] of Object.entries(this.gambits)) {
for (const gambit of gambits) {
app.store.gambits.gambits[modelType] = app.store.gambits.gambits[modelType] || [];
app.store.gambits.gambits[modelType].push(gambit);
app.search.gambits.gambits[modelType] ||= [];
app.search.gambits.gambits[modelType].push(gambit);
}
}
}

View File

@ -1,6 +1,171 @@
export default interface IGambit {
import app from '../app';
export default interface IGambit<Type extends GambitType = GambitType> {
type: GambitType;
/**
* This is the regular expression pattern that will be used to match the gambit.
* The pattern language can be localized. for example, the pattern for the
* author gambit is `author:(.+)` in English, but `auteur:(.+)` in
* French.
*/
pattern(): string;
/**
* This is the method to transform a gambit into a filter format.
*/
toFilter(matches: string[], negate: boolean): Record<string, any>;
/**
* This is the server standardised filter key for this gambit.
* The filter key must not be localized.
*/
filterKey(): string;
fromFilter(value: string, negate: boolean): string;
/**
* This is the method to transform a filter into a gambit format.
* The gambit format can be localized.
*/
fromFilter(value: any, negate: boolean): string;
/**
* This returns information about how the gambit is structured for the UI.
* Use localized values.
*/
suggestion(): Type extends GambitType.KeyValue ? KeyValueGambitSuggestion : GroupedGambitSuggestion;
/**
* Whether this gambit can use logical operators.
* For example, the tag gambit can be used as such:
* `tag:foo,bar tag:baz` which translates to `(foo OR bar) AND baz`.
*
* The info allows generation of the correct filtering format, which would be
* ```
* {
* tag: [
* 'foo,bar', // OR because of the comma.
* 'baz', // AND because it's a separate item.
* ]
* }
* ```
*
* The backend filter must be able to handle this format.
* Checkout the TagGambit and TagFilter classes for an example.
*/
predicates: boolean;
/**
* Whether this gambit can be used by the actor.
*/
enabled(): boolean;
}
export enum GambitType {
KeyValue = 'key:value',
Grouped = 'grouped',
}
export type KeyValueGambitSuggestion = {
key: string;
hint: string;
};
export type GroupedGambitSuggestion = {
group: 'is' | 'has' | string;
key: string | string[];
};
export abstract class BooleanGambit implements IGambit<GambitType.Grouped> {
type = GambitType.Grouped;
predicates = false;
abstract key(): string | string[];
abstract filterKey(): string;
pattern(): string {
const is = app.translator.trans('core.lib.gambits.boolean_key', {}, true);
let key = this.key();
if (Array.isArray(key)) {
key = key.join('|');
}
return `${is}:(${key})`;
}
toFilter(_matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + this.filterKey();
return {
[key]: true,
};
}
fromFilter(value: string, negate: boolean): string {
const is = app.translator.trans('core.lib.gambits.boolean_key', {}, true);
const key = this.key();
return `${negate ? '-' : ''}${is}:${key}`;
}
suggestion() {
return {
group: app.translator.trans('core.lib.gambits.boolean_key', {}, true),
key: this.key(),
};
}
enabled(): boolean {
return true;
}
}
export abstract class KeyValueGambit implements IGambit<GambitType.KeyValue> {
type = GambitType.KeyValue;
predicates = false;
abstract key(): string;
abstract hint(): string;
abstract filterKey(): string;
valuePattern(): string {
return '(.+)';
}
gambitValueToFilterValue(value: string): string | number | boolean | Array<any> {
return value;
}
filterValueToGambitValue(value: any): string {
return Array.isArray(value) ? value.join(',') : value.toString();
}
pattern(): string {
const key = this.key();
return `${key}:` + this.valuePattern();
}
toFilter(matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + this.filterKey();
return {
[key]: this.gambitValueToFilterValue(matches[1]),
};
}
fromFilter(value: any, negate: boolean): string {
return `${negate ? '-' : ''}${this.key()}:${this.filterValueToGambitValue(value)}`;
}
suggestion() {
return {
key: this.key(),
hint: this.hint(),
};
}
enabled(): boolean {
return true;
}
}

View File

@ -1,23 +1,16 @@
import IGambit from '../IGambit';
import app from '../../app';
import { KeyValueGambit } from '../IGambit';
export default class AuthorGambit implements IGambit {
public pattern(): string {
return 'author:(.+)';
export default class AuthorGambit extends KeyValueGambit {
key(): string {
return app.translator.trans('core.lib.gambits.discussions.author.key', {}, true);
}
public toFilter(matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'author';
return {
[key]: matches[1].split(','),
};
hint(): string {
return app.translator.trans('core.lib.gambits.discussions.author.hint', {}, true);
}
filterKey(): string {
return 'author';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}author:${value}`;
}
}

View File

@ -1,23 +1,20 @@
import IGambit from '../IGambit';
import app from '../../app';
import { KeyValueGambit } from '../IGambit';
export default class CreatedGambit implements IGambit {
pattern(): string {
return 'created:(\\d{4}\\-\\d\\d\\-\\d\\d(?:\\.\\.(\\d{4}\\-\\d\\d\\-\\d\\d))?)';
export default class CreatedGambit extends KeyValueGambit {
key(): string {
return app.translator.trans('core.lib.gambits.discussions.created.key', {}, true);
}
toFilter(matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'created';
hint(): string {
return app.translator.trans('core.lib.gambits.discussions.created.hint', {}, true);
}
return {
[key]: matches[1],
};
valuePattern(): string {
return '(\\d{4}\\-\\d\\d\\-\\d\\d(?:\\.\\.(\\d{4}\\-\\d\\d\\-\\d\\d))?)';
}
filterKey(): string {
return 'created';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}created:${value}`;
}
}

View File

@ -1,23 +1,16 @@
import IGambit from '../IGambit';
import app from '../../app';
import { BooleanGambit } from '../IGambit';
export default class HiddenGambit implements IGambit {
public pattern(): string {
return 'is:hidden';
}
public toFilter(_matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'hidden';
return {
[key]: true,
};
export default class HiddenGambit extends BooleanGambit {
key(): string {
return app.translator.trans('core.lib.gambits.discussions.hidden.key', {}, true);
}
filterKey(): string {
return 'hidden';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:hidden`;
enabled(): boolean {
return !!app.session.user;
}
}

View File

@ -1,23 +1,16 @@
import IGambit from '../IGambit';
import app from '../../app';
import { BooleanGambit } from '../IGambit';
export default class UnreadGambit implements IGambit {
pattern(): string {
return 'is:unread';
}
toFilter(_matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'unread';
return {
[key]: true,
};
export default class UnreadGambit extends BooleanGambit {
key(): string {
return app.translator.trans('core.lib.gambits.discussions.unread.key', {}, true);
}
filterKey(): string {
return 'unread';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}is:unread`;
enabled(): boolean {
return !!app.session.user;
}
}

View File

@ -1,23 +1,20 @@
import IGambit from '../IGambit';
import app from '../../app';
import { KeyValueGambit } from '../IGambit';
export default class EmailGambit implements IGambit {
pattern(): string {
return 'email:(.+)';
export default class EmailGambit extends KeyValueGambit {
key(): string {
return app.translator.trans('core.lib.gambits.users.email.key', {}, true);
}
toFilter(matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'email';
return {
[key]: matches[1],
};
hint(): string {
return app.translator.trans('core.lib.gambits.users.email.hint', {}, true);
}
filterKey(): string {
return 'email';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}email:${value}`;
enabled(): boolean {
return !!(app.session.user && app.forum.attribute<boolean>('canEditUserCredentials'));
}
}

View File

@ -1,23 +1,16 @@
import IGambit from '../IGambit';
import app from '../../app';
import { KeyValueGambit } from '../IGambit';
export default class GroupGambit implements IGambit {
pattern(): string {
return 'group:(.+)';
export default class GroupGambit extends KeyValueGambit {
key(): string {
return app.translator.trans('core.lib.gambits.users.group.key', {}, true);
}
toFilter(matches: string[], negate: boolean): Record<string, any> {
const key = (negate ? '-' : '') + 'group';
return {
[key]: matches[1].split(','),
};
hint(): string {
return app.translator.trans('core.lib.gambits.users.group.hint', {}, true);
}
filterKey(): string {
return 'group';
}
fromFilter(value: string, negate: boolean): string {
return `${negate ? '-' : ''}group:${value}`;
}
}

View File

@ -0,0 +1,51 @@
export default class AutocompleteReader {
public readonly symbol: string | ((character: string) => boolean) | null;
public relativeStart: number = 0;
public absoluteStart: number = 0;
constructor(symbol: string | ((character: string) => boolean) | null) {
this.symbol = symbol;
}
check(lastChunk: string, cursor: number, validBit: RegExp | null = null): AutocompleteCheck | null {
this.absoluteStart = 0;
// Search backwards from the cursor for a symbol. If we find
// one and followed by a whitespace, we will want to show the
// autocomplete dropdown!
for (let i = lastChunk.length - 1; i >= 0; i--) {
const character = lastChunk.substr(i, 1);
// check what the user typed is valid.
if (validBit && !validBit?.test(character)) return null;
// check if the character is the symbol we are looking for.
if (this.symbol) {
const symbol = typeof this.symbol === 'string' ? (character: string) => character === this.symbol : this.symbol;
if (!symbol(character)) continue;
}
// make sure the symbol preceded by a whitespace or newline
if (i === 0 || /\s/.test(lastChunk.substr(i - 1, 1))) {
this.relativeStart = i + (this.symbol ? 1 : 0);
this.absoluteStart = cursor - lastChunk.length + i + (this.symbol ? 1 : 0);
return {
symbol: this.symbol && character,
relativeStart: this.relativeStart,
absoluteStart: this.absoluteStart,
typed: lastChunk.substring(this.relativeStart).toLowerCase(),
};
}
}
return null;
}
}
export type AutocompleteCheck = {
symbol: string | null;
relativeStart: number;
absoluteStart: number;
typed: string;
};

View File

@ -0,0 +1,174 @@
import app from '../app';
import { GambitType, type GroupedGambitSuggestion, type KeyValueGambitSuggestion } from '../query/IGambit';
import type IGambit from '../query/IGambit';
import AutocompleteReader, { type AutocompleteCheck } from '../utils/AutocompleteReader';
import Button from '../components/Button';
export default class GambitsAutocomplete {
protected query = '';
constructor(
public resource: string,
public jqueryInput: () => JQuery<HTMLInputElement>,
public onchange: (value: string) => void,
public afterSuggest: (value: string) => void
) {}
suggestions(query: string): JSX.Element[] {
const gambits = app.search.gambits.for(this.resource).filter((gambit) => gambit.enabled());
this.query = query;
// We group the boolean gambits together to produce an initial item of
// is:unread,sticky,locked, etc.
const groupedGambits: IGambit<GambitType.Grouped>[] = gambits.filter((gambit) => gambit.type === GambitType.Grouped);
const keyValueGambits: IGambit<GambitType.KeyValue>[] = gambits.filter((gambit) => gambit.type !== GambitType.Grouped);
const uniqueGroups: string[] = [];
for (const gambit of groupedGambits) {
if (uniqueGroups.includes(gambit.suggestion().group)) continue;
uniqueGroups.push(gambit.suggestion().group);
}
const instancePerGroup: IGambit<GambitType.Grouped>[] = [];
for (const group of uniqueGroups) {
instancePerGroup.push({
type: GambitType.Grouped,
suggestion: () => ({
group,
key: groupedGambits
.filter((gambit) => gambit.suggestion().group === group)
.map((gambit) => {
const key = gambit.suggestion().key;
return key instanceof Array ? key.join(', ') : key;
})
.join(', '),
}),
pattern: () => '',
filterKey: () => '',
toFilter: () => [],
fromFilter: () => '',
predicates: false,
enabled: () => true,
});
}
const autocompleteReader = new AutocompleteReader(null);
const cursorPosition = this.jqueryInput().prop('selectionStart') || query.length;
const lastChunk = query.slice(0, cursorPosition);
const autocomplete = autocompleteReader.check(lastChunk, cursorPosition, /\S+$/);
let typed = autocomplete?.typed || '';
// Negative gambits are a thing ;)
const negative = typed.startsWith('-');
if (negative) {
typed = typed.slice(1);
}
// if the query ends with 'is:' we will only list keys from that group.
if (typed.endsWith(':')) {
const gambitKey = typed.replace(/:$/, '') || null;
const gambitQuery = typed.split(':').pop() || '';
if (gambitKey) {
const specificGambitSuggestions = this.specificGambitSuggestions(gambitKey, gambitQuery, uniqueGroups, groupedGambits, autocomplete!);
if (specificGambitSuggestions) {
return specificGambitSuggestions;
}
}
}
// This is all the gambit suggestions.
return [...instancePerGroup, ...keyValueGambits]
.filter(
(gambit) =>
!autocomplete ||
new RegExp(typed).test(
gambit.type === GambitType.Grouped ? (gambit.suggestion() as GroupedGambitSuggestion).group : (gambit.suggestion().key as string)
)
)
.map((gambit) => {
const suggestion = gambit.suggestion();
const key = gambit.type === GambitType.Grouped ? (suggestion as GroupedGambitSuggestion).group : (suggestion.key as string);
const hint =
gambit.type === GambitType.Grouped ? (suggestion as KeyValueGambitSuggestion).key : (suggestion as KeyValueGambitSuggestion).hint;
return this.gambitSuggestion(key, hint, (negated: boolean | undefined) =>
this.suggest(((!!negated && '-') || '') + key + ':', typed || '', (autocomplete?.relativeStart ?? cursorPosition) + Number(negative))
);
});
}
specificGambitSuggestions(
gambitKey: string,
gambitQuery: string,
uniqueGroups: string[],
groupedGambits: IGambit<GambitType.Grouped>[],
autocomplete: AutocompleteCheck
): JSX.Element[] | null {
if (uniqueGroups.includes(gambitKey)) {
return groupedGambits
.filter((gambit) => gambit.suggestion().group === gambitKey)
.flatMap((gambit): string[] =>
gambit.suggestion().key instanceof Array ? (gambit.suggestion().key as string[]) : [gambit.suggestion().key as string]
)
.filter((key) => !gambitQuery || key.toLowerCase().startsWith(gambitQuery))
.map((gambit) =>
this.gambitSuggestion(gambit, null, () => this.suggest(gambit, gambitQuery, autocomplete.relativeStart + autocomplete.typed.length))
);
}
return null;
}
gambitSuggestion(key: string, value: string | null, suggest: (negated?: boolean) => void): JSX.Element {
return (
<li>
<span className="Dropdown-item GambitsAutocomplete-gambit">
<button type="button" className="Button--ua-reset" onclick={() => suggest()}>
<span className="GambitsAutocomplete-gambit-key">
{key}
{!!value && ':'}
</span>
{!!value && <span className="GambitsAutocomplete-gambit-value">{value}</span>}
</button>
{!!value && (
<span className="GambitsAutocomplete-gambit-actions">
<Button
class="Button Button--icon"
onclick={() => suggest()}
icon="fas fa-plus"
aria-label={app.translator.trans('core.forum.search.gambit_plus_button_a11y_label')}
/>
<Button
class="Button Button--icon"
onclick={() => suggest(true)}
icon="fas fa-minus"
aria-label={app.translator.trans('core.forum.search.gambit_minus_button_a11y_label')}
/>
</span>
)}
</span>
</li>
);
}
suggest(text: string, fromTyped: string, start: number) {
const $input = this.jqueryInput();
const query = this.query;
const replaced = query.slice(0, start) + text + query.slice(start + fromTyped.length);
this.onchange(replaced);
$input[0].focus();
setTimeout(() => {
$input[0].setSelectionRange(start + text.length, start + text.length);
m.redraw();
}, 50);
this.afterSuggest(replaced);
}
}

View File

@ -38,12 +38,7 @@ export default class KeyboardNavigatable {
* This will be triggered by the Up key.
*/
onUp(callback: KeyboardEventHandler): KeyboardNavigatable {
this.callbacks.set(Keys.ArrowUp, (e) => {
e.preventDefault();
callback(e);
});
return this;
return this.onDirection(callback, Keys.ArrowUp);
}
/**
@ -52,7 +47,29 @@ export default class KeyboardNavigatable {
* This will be triggered by the Down key.
*/
onDown(callback: KeyboardEventHandler): KeyboardNavigatable {
this.callbacks.set(Keys.ArrowDown, (e) => {
return this.onDirection(callback, Keys.ArrowDown);
}
/**
* Provide a callback to be executed when navigating leftwards.
*
* This will be triggered by the Left key.
*/
onLeft(callback: KeyboardEventHandler): KeyboardNavigatable {
return this.onDirection(callback, Keys.ArrowLeft);
}
/**
* Provide a callback to be executed when navigating rightwards.
*
* This will be triggered by the Right key.
*/
onRight(callback: KeyboardEventHandler): KeyboardNavigatable {
return this.onDirection(callback, Keys.ArrowRight);
}
onDirection(callback: KeyboardEventHandler, direction: Keys): KeyboardNavigatable {
this.callbacks.set(direction, (e) => {
e.preventDefault();
callback(e);
});

View File

@ -25,6 +25,7 @@ import type PostModel from '../common/models/Post';
import extractText from '../common/utils/extractText';
import Notices from './components/Notices';
import Footer from './components/Footer';
import SearchManager from '../common/SearchManager';
export interface ForumApplicationData extends ApplicationData {}
@ -61,10 +62,9 @@ export default class ForumApplication extends Application {
notifications: NotificationListState = new NotificationListState();
/**
* An object which stores previously searched queries and provides convenient
* tools for retrieving and managing search values.
* An object which stores the global search state and manages search capabilities.
*/
search: GlobalSearchState = new GlobalSearchState();
search: SearchManager<GlobalSearchState> = new SearchManager(new GlobalSearchState());
/**
* An object which controls the state of the composer.

View File

@ -54,7 +54,7 @@ export default class DiscussionListItem<CustomAttrs extends IDiscussionListItemA
elementAttrs() {
return {
className: classList('DiscussionListItem', {
className: classList('DiscussionListItem', this.attrs.className, {
active: this.active(),
'DiscussionListItem--hidden': this.attrs.discussion.isHidden(),
Slidable: 'ontouchstart' in window,
@ -163,7 +163,7 @@ export default class DiscussionListItem<CustomAttrs extends IDiscussionListItemA
badgesView(): Mithril.Children {
const discussion = this.attrs.discussion;
return <ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>;
return <ul className="DiscussionListItem-badges badges badges--packed">{listItems(discussion.badges().toArray())}</ul>;
}
mainView(): Mithril.Children {

View File

@ -1,10 +1,10 @@
import app from '../../forum/app';
import highlight from '../../common/helpers/highlight';
import app from '../app';
import LinkButton from '../../common/components/LinkButton';
import Link from '../../common/components/Link';
import { SearchSource } from './Search';
import type Mithril from 'mithril';
import Discussion from '../../common/models/Discussion';
import type Discussion from '../../common/models/Discussion';
import type { SearchSource } from './Search';
import extractText from '../../common/utils/extractText';
import MinimalDiscussionListItem from './MinimalDiscussionListItem';
/**
* The `DiscussionsSearchSource` finds and displays discussion search results in
@ -13,15 +13,25 @@ import Discussion from '../../common/models/Discussion';
export default class DiscussionsSearchSource implements SearchSource {
protected results = new Map<string, Discussion[]>();
async search(query: string): Promise<void> {
public resource: string = 'discussions';
title(): string {
return extractText(app.translator.trans('core.lib.search_source.discussions.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: 3 },
include: 'mostRelevantPost',
page: { limit },
include: 'mostRelevantPost,user,firstPost,tags',
};
return app.store.find<Discussion[]>('discussions', params).then((results) => {
@ -33,34 +43,34 @@ export default class DiscussionsSearchSource implements SearchSource {
view(query: string): Array<Mithril.Vnode> {
query = query.toLowerCase();
const results = (this.results.get(query) || []).map((discussion) => {
const mostRelevantPost = discussion.mostRelevantPost();
return (this.results.get(query) || []).map((discussion) => {
return (
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
<Link href={app.route.discussion(discussion, (mostRelevantPost && mostRelevantPost.number()) || 0)}>
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
{!!mostRelevantPost && (
<div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain() ?? '', query, 100)}</div>
)}
</Link>
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()} data-id={discussion.id()}>
<MinimalDiscussionListItem discussion={discussion} params={{ q: query }} />
</li>
);
}) as Array<Mithril.Vnode>;
}
const filter = app.store.gambits.apply('discussions', { q: query });
fullPage(query: string): Mithril.Vnode {
const filter = app.search.gambits.apply('discussions', { q: query });
const q = filter.q || null;
delete filter.q;
return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
return (
<li>
<LinkButton icon="fas fa-search" href={app.route('index', { q, filter })}>
{app.translator.trans('core.forum.search.all_discussions_button', { query })}
{app.translator.trans('core.lib.search_source.discussions.all_button', { query })}
</LinkButton>
</li>,
...results,
];
</li>
);
}
gotoItem(id: string): string | null {
const discussion = app.store.getById<Discussion>('discussions', id);
if (!discussion) return null;
return app.route.discussion(discussion);
}
}

View File

@ -26,7 +26,7 @@ export default class HeaderSecondary extends Component {
items() {
const items = new ItemList();
items.add('search', <Search state={app.search} />, 30);
items.add('search', <Search state={app.search.state} />, 30);
if (app.forum.attribute('showLanguageSelector') && Object.keys(app.data.locales).length > 1) {
const locales = [];

View File

@ -41,7 +41,7 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
app.discussions.clear();
}
app.discussions.refreshParams(app.search.params(), (m.route.param('page') && Number(m.route.param('page'))) || 1);
app.discussions.refreshParams(app.search.state.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')));
@ -154,15 +154,15 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
'sort',
<Dropdown
buttonClassName="Button"
label={sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0]}
label={sortOptions[app.search.state.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0]}
accessibleToggleLabel={app.translator.trans('core.forum.index_sort.toggle_dropdown_accessible_label')}
>
{Object.keys(sortOptions).map((value) => {
const label = sortOptions[value];
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
const active = (app.search.state.params().sort || Object.keys(sortMap)[0]) === value;
return (
<Button icon={active ? 'fas fa-check' : true} onclick={app.search.changeSort.bind(app.search, value)} active={active}>
<Button icon={active ? 'fas fa-check' : true} onclick={() => app.search.state.changeSort(value)} active={active}>
{label}
</Button>
);
@ -184,6 +184,7 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
'refresh',
<Button
title={app.translator.trans('core.forum.index.refresh_tooltip')}
aria-label={app.translator.trans('core.forum.index.refresh_tooltip')}
icon="fas fa-sync"
className="Button Button--icon"
onclick={() => {
@ -201,6 +202,7 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
'markAllAsRead',
<Button
title={app.translator.trans('core.forum.index.mark_all_as_read_tooltip')}
aria-label={app.translator.trans('core.forum.index.mark_all_as_read_tooltip')}
icon="fas fa-check"
className="Button Button--icon"
onclick={this.markAllAsRead.bind(this)}

View File

@ -65,7 +65,7 @@ export default class IndexSidebar<CustomAttrs extends IndexSidebarAttrs = IndexS
*/
navItems() {
const items = new ItemList<Mithril.Children>();
const params = app.search.stickyParams();
const params = app.search.state.stickyParams();
items.add(
'allDiscussions',

View File

@ -0,0 +1,45 @@
import DiscussionListItem, { IDiscussionListItemAttrs } from './DiscussionListItem';
import ItemList from '../../common/utils/ItemList';
import Mithril from 'mithril';
import Link from '../../common/components/Link';
import app from '../app';
import highlight from '../../common/helpers/highlight';
import listItems from '../../common/helpers/listItems';
import classList from '../../common/utils/classList';
export default class MinimalDiscussionListItem extends DiscussionListItem<IDiscussionListItemAttrs> {
elementAttrs() {
const attrs = super.elementAttrs();
attrs.className = classList(attrs.className, 'MinimalDiscussionListItem');
return attrs;
}
viewItems(): ItemList<Mithril.Children> {
return super.viewItems().remove('controls').remove('slidableUnderneath');
}
contentItems(): ItemList<Mithril.Children> {
return super.contentItems().remove('stats');
}
authorItems(): ItemList<Mithril.Children> {
return super.authorItems().remove('badges');
}
mainView(): Mithril.Children {
const discussion = this.attrs.discussion;
const jumpTo = this.getJumpTo();
return (
<Link href={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
<h2 className="DiscussionListItem-title">
{this.badgesView()}
<div>{highlight(discussion.title(), this.highlightRegExp)}</div>
</h2>
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
</Link>
);
}
}

View File

@ -38,7 +38,7 @@ export default class PostUser extends Component {
{username(user)}
</Link>
</h3>
<ul className="PostUser-badges badges">{listItems(user.badges().toArray())}</ul>
<ul className="PostUser-badges badges badges--packed">{listItems(user.badges().toArray())}</ul>
</div>
);
}

View File

@ -27,7 +27,7 @@ export default class ReplyPlaceholder extends Component {
<header className="Post-header">
<div className="PostUser">
<h3 className="PostUser-name">{username(app.session.user)}</h3>
<ul className="PostUser-badges badges">{listItems(app.session.user.badges().toArray())}</ul>
<ul className="PostUser-badges badges badges--packed">{listItems(app.session.user.badges().toArray())}</ul>
</div>
</header>
<div className="Post-body">

View File

@ -1,15 +1,18 @@
import app from '../../forum/app';
import Component, { ComponentAttrs } from '../../common/Component';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import ItemList from '../../common/utils/ItemList';
import classList from '../../common/utils/classList';
import extractText from '../../common/utils/extractText';
import KeyboardNavigatable from '../../common/utils/KeyboardNavigatable';
import SearchState from '../states/SearchState';
import Input from '../../common/components/Input';
import SearchState from '../../common/states/SearchState';
import SearchModal from './SearchModal';
import type Mithril from 'mithril';
import ItemList from '../../common/utils/ItemList';
import DiscussionsSearchSource from './DiscussionsSearchSource';
import UsersSearchSource from './UsersSearchSource';
import type Mithril from 'mithril';
import Icon from '../../common/components/Icon';
export interface SearchAttrs extends ComponentAttrs {
/** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */
state: SearchState;
}
/**
* The `SearchSource` interface defines a section of search results in the
@ -22,22 +25,42 @@ import Icon from '../../common/components/Icon';
* putting together the output from the `view` method of each source.
*/
export interface SearchSource {
/**
* The resource type that this search source is responsible for.
*/
resource: string;
/**
* Get the title for this search source.
*/
title(): string;
/**
* Check if a query has been cached for this search source.
*/
isCached(query: string): boolean;
/**
* Make a request to get results for the given query.
* The results will be updated internally in the search source, not exposed.
*/
search(query: string): Promise<void>;
search(query: string, limit: number): Promise<void>;
/**
* Get an array of virtual <li>s that list the search results for the given
* query.
*/
view(query: string): Array<Mithril.Vnode>;
}
export interface SearchAttrs extends ComponentAttrs {
/** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */
state: SearchState;
/**
* Get a list item for the full search results page.
*/
fullPage(query: string): Mithril.Vnode | null;
/**
* Get to the result item page. Only called if each list item has a data-id.
*/
gotoItem(id: string): string | null;
}
/**
@ -53,45 +76,11 @@ export interface SearchAttrs extends ComponentAttrs {
* - state: SearchState instance.
*/
export default class Search<T extends SearchAttrs = SearchAttrs> extends Component<T, SearchState> {
/**
* The minimum query length before sources are searched.
*/
protected static MIN_SEARCH_LEN = 3;
/**
* The instance of `SearchState` for this component.
*/
protected searchState!: SearchState;
/**
* Whether or not the search input has focus.
*/
protected hasFocus = false;
/**
* An array of SearchSources.
*/
protected sources?: SearchSource[];
/**
* The number of sources that are still loading results.
*/
protected loadingSources = 0;
/**
* The index of the currently-selected <li> in the results list. This can be
* a unique string (to account for the fact that an item's position may jump
* around as new results load), but otherwise it will be numeric (the
* sequential position within the list).
*/
protected index: number = 0;
protected navigator!: KeyboardNavigatable;
protected searchTimeout?: number;
private updateMaxHeightHandler?: () => void;
oninit(vnode: Mithril.Vnode<T, this>) {
super.oninit(vnode);
@ -99,270 +88,53 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
}
view() {
const currentSearch = this.searchState.getInitialSearch();
// Initialize search sources in the view rather than the constructor so
// that we have access to app.forum.
if (!this.sources) this.sources = this.sourceItems().toArray();
// Hide the search view if no sources were loaded
if (!this.sources.length) return <div></div>;
if (this.sourceItems().isEmpty()) return <div></div>;
const searchLabel = extractText(app.translator.trans('core.forum.header.search_placeholder'));
const isActive = !!currentSearch;
const shouldShowResults = !!(this.searchState.getValue() && this.hasFocus);
const shouldShowClearButton = !!(!this.loadingSources && this.searchState.getValue());
return (
<div
role="search"
aria-label={app.translator.trans('core.forum.header.search_role_label')}
className={classList('Search', {
open: this.searchState.getValue() && this.hasFocus,
focused: this.hasFocus,
active: isActive,
loading: !!this.loadingSources,
})}
>
<div className="Search-input">
<Icon name="fas fa-search Search-input-icon" />
<input
aria-label={searchLabel}
className="FormControl"
type="search"
placeholder={searchLabel}
value={this.searchState.getValue()}
oninput={(e: InputEvent) => this.searchState.setValue((e?.target as HTMLInputElement)?.value)}
onfocus={() => (this.hasFocus = true)}
onblur={() => (this.hasFocus = false)}
/>
{!!this.loadingSources && <LoadingIndicator size="small" display="inline" containerClassName="Button Button--icon Button--link" />}
{shouldShowClearButton && (
<button
className="Search-clear Button Button--icon Button--link"
onclick={this.clear.bind(this)}
aria-label={app.translator.trans('core.forum.header.search_clear_button_accessible_label')}
type="button"
>
<Icon name={'fas fa-times-circle'} />
</button>
)}
</div>
<ul
className="Dropdown-menu Search-results"
aria-hidden={!shouldShowResults || undefined}
aria-live={shouldShowResults ? 'polite' : undefined}
>
{shouldShowResults && this.sources.map((source) => source.view(this.searchState.getValue()))}
</ul>
<div role="search" className="Search" aria-label={app.translator.trans('core.forum.header.search_role_label')}>
<Input
type="search"
className="Search-input"
clearable={this.searchState.getValue()}
clearLabel={app.translator.trans('core.forum.header.search_clear_button_accessible_label')}
prefixIcon="fas fa-search"
aria-label={searchLabel}
readonly={true}
placeholder={searchLabel}
value={this.searchState.getValue()}
onchange={(value: string) => {
if (!value) this.searchState.clear();
else this.searchState.setValue(value);
}}
inputAttrs={{
onfocus: () =>
setTimeout(() => {
this.$('input').blur() &&
app.modal.show(() => import('./SearchModal'), { searchState: this.searchState, sources: this.sourceItems().toArray() });
}, 150),
}}
/>
</div>
);
}
updateMaxHeight() {
// Since extensions might add elements above the search box on mobile,
// we need to calculate and set the max height dynamically.
const resultsElementMargin = 14;
const maxHeight =
window.innerHeight - this.element.querySelector('.Search-input>.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin;
this.element.querySelector<HTMLElement>('.Search-results')?.style?.setProperty('max-height', `${maxHeight}px`);
}
onupdate(vnode: Mithril.VnodeDOM<T, this>) {
super.onupdate(vnode);
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
// If there are no sources, the search view is not shown.
if (!this.sources?.length) return;
this.updateMaxHeight();
}
oncreate(vnode: Mithril.VnodeDOM<T, this>) {
super.oncreate(vnode);
// If there are no sources, we shouldn't initialize logic for
// search elements, as they will not be shown.
if (!this.sources?.length) return;
const search = this;
const state = this.searchState;
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
this.$('.Search-results')
.on('mousedown', (e) => e.preventDefault())
.on('click', () => this.$('input').trigger('blur'))
// Whenever the mouse is hovered over a search result, highlight it.
.on('mouseenter', '> li:not(.Dropdown-header)', function () {
search.setIndex(search.selectableItems().index(this));
});
const $input = this.$('input') as JQuery<HTMLInputElement>;
this.navigator = new KeyboardNavigatable();
this.navigator
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
.onSelect(this.selectResult.bind(this), true)
.onCancel(this.clear.bind(this))
.bindTo($input);
// Handle input key events on the search input, triggering results to load.
$input
.on('input focus', function () {
const query = this.value.toLowerCase();
if (!query) return;
if (search.searchTimeout) clearTimeout(search.searchTimeout);
search.searchTimeout = window.setTimeout(() => {
if (state.isCached(query)) return;
if (query.length >= (search.constructor as typeof Search).MIN_SEARCH_LEN) {
search.sources?.map((source) => {
if (!source.search) return;
search.loadingSources++;
source.search(query).then(() => {
search.loadingSources = Math.max(0, search.loadingSources - 1);
m.redraw();
});
});
}
state.cache(query);
m.redraw();
}, 250);
})
.on('focus', function () {
$(this)
.one('mouseup', (e) => e.preventDefault())
.trigger('select');
});
this.updateMaxHeightHandler = this.updateMaxHeight.bind(this);
window.addEventListener('resize', this.updateMaxHeightHandler);
}
onremove(vnode: Mithril.VnodeDOM<T, this>) {
super.onremove(vnode);
if (this.updateMaxHeightHandler) {
window.removeEventListener('resize', this.updateMaxHeightHandler);
}
}
/**
* Navigate to the currently selected search result and close the list.
*/
selectResult() {
if (this.searchTimeout) clearTimeout(this.searchTimeout);
this.loadingSources = 0;
const selectedUrl = this.getItem(this.index).find('a').attr('href');
if (this.searchState.getValue() && selectedUrl) {
m.route.set(selectedUrl);
} else {
this.clear();
}
this.$('input').blur();
}
/**
* Clear the search
*/
clear() {
this.searchState.clear();
}
/**
* Build an item list of SearchSources.
* A list of search sources that can be used to query for search results.
*/
sourceItems(): ItemList<SearchSource> {
const items = new ItemList<SearchSource>();
if (app.forum.attribute('canViewForum')) items.add('discussions', new DiscussionsSearchSource());
if (app.forum.attribute('canSearchUsers')) items.add('users', new UsersSearchSource());
if (app.forum.attribute('canViewForum')) {
items.add('discussions', new DiscussionsSearchSource());
}
if (app.forum.attribute('canSearchUsers')) {
items.add('users', new UsersSearchSource());
}
return items;
}
/**
* Get all of the search result items that are selectable.
*/
selectableItems(): JQuery {
return this.$('.Search-results > li:not(.Dropdown-header)');
}
/**
* Get the position of the currently selected search result item.
* Returns zero if not found.
*/
getCurrentNumericIndex(): number {
return Math.max(0, this.selectableItems().index(this.getItem(this.index)));
}
/**
* Get the <li> in the search results with the given index (numeric or named).
*/
getItem(index: number): JQuery {
const $items = this.selectableItems();
let $item = $items.filter(`[data-index="${index}"]`);
if (!$item.length) {
$item = $items.eq(index);
}
return $item;
}
/**
* Set the currently-selected search result item to the one with the given
* index.
*/
setIndex(index: number, scrollToItem: boolean = false) {
const $items = this.selectableItems();
const $dropdown = $items.parent();
let fixedIndex = index;
if (index < 0) {
fixedIndex = $items.length - 1;
} else if (index >= $items.length) {
fixedIndex = 0;
}
const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');
this.index = parseInt($item.attr('data-index') as string) || fixedIndex;
if (scrollToItem) {
const dropdownScroll = $dropdown.scrollTop()!;
const dropdownTop = $dropdown.offset()!.top;
const dropdownBottom = dropdownTop + $dropdown.outerHeight()!;
const itemTop = $item.offset()!.top;
const itemBottom = itemTop + $item.outerHeight()!;
let scrollTop;
if (itemTop < dropdownTop) {
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
} else if (itemBottom > dropdownBottom) {
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
}
if (typeof scrollTop !== 'undefined') {
$dropdown.stop(true).animate({ scrollTop }, 100);
}
}
}
}

View File

@ -0,0 +1,435 @@
import app from '../app';
import type { IFormModalAttrs } from '../../common/components/FormModal';
import FormModal from '../../common/components/FormModal';
import type Mithril from 'mithril';
import type SearchState from '../../common/states/SearchState';
import KeyboardNavigatable from '../../common/utils/KeyboardNavigatable';
import SearchManager from '../../common/SearchManager';
import extractText from '../../common/utils/extractText';
import Input from '../../common/components/Input';
import Button from '../../common/components/Button';
import Stream from '../../common/utils/Stream';
import InfoTile from '../../common/components/InfoTile';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import type { SearchSource } from './Search';
import type IGambit from '../../common/query/IGambit';
import ItemList from '../../common/utils/ItemList';
import GambitsAutocomplete from '../../common/utils/GambitsAutocomplete';
export interface ISearchModalAttrs extends IFormModalAttrs {
onchange: (value: string) => void;
searchState: SearchState;
sources: SearchSource[];
}
export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearchModalAttrs> extends FormModal<CustomAttrs> {
public static LIMIT = 6;
protected searchState!: SearchState;
protected query!: Stream<string>;
/**
* An array of SearchSources.
*/
protected sources!: SearchSource[];
/**
* The key of the currently-active search source.
*/
protected activeSource!: Stream<SearchSource>;
/**
* The sources that are still loading results.
*/
protected loadingSources: string[] = [];
/**
* The index of the currently-selected <li> in the results list. This can be
* a unique string (to account for the fact that an item's position may jump
* around as new results load), but otherwise it will be numeric (the
* sequential position within the list).
*/
protected index: number = 0;
protected navigator!: KeyboardNavigatable;
protected searchTimeout?: number;
protected inputScroll = Stream(0);
protected gambitsAutocomplete: Record<string, GambitsAutocomplete> = {};
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
this.searchState = this.attrs.searchState;
this.sources = this.attrs.sources;
this.query = Stream(this.searchState.getValue() || '');
}
title(): Mithril.Children {
return app.translator.trans('core.forum.search.title');
}
className(): string {
return 'SearchModal Modal--flat';
}
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(),
this.query,
(value: string) => this.search(value)
);
const searchLabel = extractText(app.translator.trans('core.forum.search.placeholder'));
return (
<div className="Modal-body SearchModal-body">
<div className="SearchModal-form">
<Input
key="search"
type="search"
loading={!!this.loadingSources.length}
clearable={true}
clearLabel={app.translator.trans('core.forum.header.search_clear_button_accessible_label')}
prefixIcon="fas fa-search"
aria-label={searchLabel}
placeholder={searchLabel}
value={this.query()}
onchange={(value: string) => {
this.query(value);
this.inputScroll(this.inputElement()[0]?.scrollLeft ?? 0);
}}
inputAttrs={{ className: 'SearchModal-input' }}
renderInput={(attrs: any) => (
<>
<input {...attrs} onscroll={(e: Event) => this.inputScroll((e.target as HTMLInputElement).scrollLeft)} />
<div className="SearchModal-visual-wrapper">
<div className="SearchModal-visual-input" style={{ left: '-' + this.inputScroll() + 'px' }}>
{this.gambifyInput()}
</div>
</div>
</>
)}
/>
</div>
{this.tabs()}
</div>
);
}
tabs(): JSX.Element {
return (
<div className="SearchModal-tabs">
<div className="SearchModal-tabs-nav">{this.tabItems().toArray()}</div>
<div className="SearchModal-tabs-content">{this.activeTabItems().toArray()}</div>
</div>
);
}
tabItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
this.sources?.map((source, index) =>
items.add(
source.resource,
<Button className="Button Button--link" active={this.activeSource() === source} onclick={() => this.switchSource(source)}>
{source.title()}
</Button>,
100 - index
)
);
return items;
}
activeTabItems(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
const loading = this.loadingSources.includes(this.activeSource().resource);
const shouldShowResults = !!this.query() && !loading;
const gambits = this.gambits();
const fullPageLink = this.activeSource().fullPage(this.query());
const results = this.activeSource()?.view(this.query());
if (shouldShowResults && fullPageLink) {
items.add(
'fullPageLink',
<div className="SearchModal-section">
<hr className="Modal-divider" />
<ul className="Dropdown-menu SearchModal-fullPage">{fullPageLink}</ul>
</div>,
80
);
}
if (!!gambits.length) {
items.add(
'gambits',
<div className="SearchModal-section">
<hr className="Modal-divider" />
<ul className="Dropdown-menu SearchModal-options" aria-live={gambits.length ? 'polite' : undefined}>
<li className="Dropdown-header">{app.translator.trans('core.forum.search.options_heading')}</li>
{gambits}
</ul>
</div>,
60
);
}
items.add(
'results',
<div className="SearchModal-section">
<hr className="Modal-divider" />
<ul className="Dropdown-menu SearchModal-results" aria-live={shouldShowResults ? 'polite' : undefined}>
<li className="Dropdown-header">{app.translator.trans('core.forum.search.preview_heading')}</li>
{!shouldShowResults && (
<li className="Dropdown-message">
<InfoTile icon="fas fa-search">{app.translator.trans('core.forum.search.no_search_text')}</InfoTile>
</li>
)}
{shouldShowResults && results}
{shouldShowResults && !results?.length && (
<li className="Dropdown-message">
<InfoTile icon="far fa-tired">{app.translator.trans('core.forum.search.no_results_text')}</InfoTile>
</li>
)}
{loading && (
<li className="Dropdown-message">
<LoadingIndicator />
</li>
)}
</ul>
</div>,
40
);
return items;
}
switchSource(source: SearchSource) {
if (this.activeSource() !== source) {
this.activeSource(source);
this.search(this.query());
this.inputElement().focus();
m.redraw();
}
}
gambits(): JSX.Element[] {
return this.gambitsAutocomplete[this.activeSource().resource].suggestions(this.query());
}
/**
* Transforms a simple search text to wrap valid gambits in a mark tag.
* @example `lorem ipsum is:unread dolor` => `lorem ipsum <mark>is:unread</mark> dolor`
*/
gambifyInput(): Mithril.Children {
const query = this.query();
let marked = query;
app.search.gambits.match(this.activeSource().resource, query, (gambit: IGambit, matches: string[], negate: boolean, bit: string) => {
marked = marked.replace(bit, `<mark>${bit}</mark>`);
});
const jsx: Mithril.ChildArray = [];
marked.split(/(<mark>.*?<\/mark>)/).forEach((chunk) => {
if (chunk.startsWith('<mark>')) {
jsx.push(<mark>{chunk.replace(/<\/?mark>/g, '')}</mark>);
} else {
jsx.push(chunk);
}
});
return jsx;
}
onupdate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.onupdate(vnode);
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
const component = this;
this.$('.Dropdown-menu')
// Whenever the mouse is hovered over a search result, highlight it.
.on('mouseenter', '> li:not(.Dropdown-header):not(.Dropdown-message)', function () {
component.setIndex(component.selectableItems().index(this));
});
// If there are no sources, the search view is not shown.
if (!this.sources?.length) return;
}
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.oncreate(vnode);
// If there are no sources, we shouldn't initialize logic for
// search elements, as they will not be shown.
if (!this.sources?.length) return;
const search = this.search.bind(this);
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
const $input = this.inputElement() as JQuery<HTMLInputElement>;
this.navigator = new KeyboardNavigatable();
this.navigator
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
.onSelect(this.selectResult.bind(this), true)
.onCancel(this.clear.bind(this))
.bindTo($input);
// Handle input key events on the search input, triggering results to load.
$input.on('input focus', function () {
search(this.value.toLowerCase());
});
}
onremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
this.searchState.setValue(this.query());
super.onremove(vnode);
}
search(query: string) {
if (!query) return;
const source = this.activeSource();
if (this.searchTimeout) clearTimeout(this.searchTimeout);
this.searchTimeout = window.setTimeout(() => {
if (source.isCached(query)) return;
if (query.length >= SearchManager.MIN_SEARCH_LEN) {
if (!source.search) return;
this.loadingSources.push(source.resource);
source.search(query, SearchModal.LIMIT).then(() => {
this.loadingSources = this.loadingSources.filter((resource) => resource !== source.resource);
m.redraw();
});
}
this.searchState.cache(query);
m.redraw();
}, 250);
}
/**
* Navigate to the currently selected search result and close the list.
*/
selectResult() {
if (this.searchTimeout) clearTimeout(this.searchTimeout);
this.loadingSources = [];
const item = this.getItem(this.index);
const isResult = !!item.attr('data-id');
let selectedUrl = null;
if (isResult) {
const id = item.attr('data-id');
selectedUrl = id && this.activeSource().gotoItem(id as string);
} else if (item.find('a').length) {
selectedUrl = item.find('a').attr('href');
}
const query = this.query();
if (query && selectedUrl) {
m.route.set(selectedUrl);
} else {
item.find('button')[0].click();
}
}
/**
* Clear the search
*/
clear() {
this.query('');
}
/**
* Get all of the search result items that are selectable.
*/
selectableItems(): JQuery {
return this.$('.Dropdown-menu > li:not(.Dropdown-header):not(.Dropdown-message)');
}
/**
* Get the position of the currently selected search result item.
* Returns zero if not found.
*/
getCurrentNumericIndex(): number {
return Math.max(0, this.selectableItems().index(this.getItem(this.index)));
}
/**
* Get the <li> in the search results with the given index (numeric or named).
*/
getItem(index: number): JQuery {
const $items = this.selectableItems();
let $item = $items.filter(`[data-index="${index}"]`);
if (!$item.length) {
$item = $items.eq(index);
}
return $item;
}
/**
* Set the currently-selected search result item to the one with the given
* index.
*/
setIndex(index: number, scrollToItem: boolean = false) {
const $items = this.selectableItems();
const $dropdown = $items.parent();
let fixedIndex = index;
if (index < 0) {
fixedIndex = $items.length - 1;
} else if (index >= $items.length) {
fixedIndex = 0;
}
const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');
this.index = parseInt($item.attr('data-index') as string) || fixedIndex;
if (scrollToItem && $dropdown) {
const dropdownScroll = $dropdown.scrollTop()!;
const dropdownTop = $dropdown.offset()!.top;
const dropdownBottom = dropdownTop + $dropdown.outerHeight()!;
const itemTop = $item.offset()!.top;
const itemBottom = itemTop + $item.outerHeight()!;
let scrollTop;
if (itemTop < dropdownTop) {
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
} else if (itemBottom > dropdownBottom) {
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
}
if (typeof scrollTop !== 'undefined') {
$dropdown.stop(true).animate({ scrollTop }, 100);
}
}
}
inputElement(): JQuery<HTMLInputElement> {
return this.$('.SearchModal-input') as JQuery<HTMLInputElement>;
}
}

View File

@ -1,12 +1,14 @@
import type Mithril from 'mithril';
import app from '../../forum/app';
import app from '../app';
import highlight from '../../common/helpers/highlight';
import username from '../../common/helpers/username';
import Link from '../../common/components/Link';
import { SearchSource } from './Search';
import User from '../../common/models/User';
import type User from '../../common/models/User';
import Avatar from '../../common/components/Avatar';
import type { SearchSource } from './Search';
import extractText from '../../common/utils/extractText';
import listItems from '../../common/helpers/listItems';
/**
* The `UsersSearchSource` finds and displays user search results in the search
@ -15,11 +17,21 @@ import Avatar from '../../common/components/Avatar';
export default class UsersSearchResults implements SearchSource {
protected results = new Map<string, User[]>();
async search(query: string): Promise<void> {
public resource: string = 'users';
title(): string {
return extractText(app.translator.trans('core.lib.search_source.users.heading'));
}
isCached(query: string): boolean {
return this.results.has(query.toLowerCase());
}
async search(query: string, limit: number): Promise<void> {
return app.store
.find<User[]>('users', {
filter: { q: query },
page: { limit: 5 },
page: { limit },
})
.then((results) => {
this.results.set(query, results);
@ -41,20 +53,32 @@ export default class UsersSearchResults implements SearchSource {
if (!results.length) return [];
return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.users_heading')}</li>,
...results.map((user) => {
const name = username(user, (name: string) => highlight(name, query));
return results.map((user) => {
const name = username(user, (name: string) => highlight(name, query));
return (
<li className="UserSearchResult" data-index={'users' + user.id()}>
<Link href={app.route.user(user)}>
<Avatar user={user} />
return (
<li className="UserSearchResult" data-index={'users' + user.id()} data-id={user.id()}>
<Link href={app.route.user(user)}>
<Avatar user={user} />
<div className="UserSearchResult-name">
{name}
</Link>
</li>
);
}),
];
<div className="badges badges--packed">{listItems(user.badges().toArray())}</div>
</div>
</Link>
</li>
);
});
}
fullPage(query: string): null {
return null;
}
gotoItem(id: string): string | null {
const user = app.store.getById<User>('users', id);
if (!user) return null;
return app.route.user(user);
}
}

View File

@ -12,7 +12,6 @@ import './states/DiscussionListState';
import './states/GlobalSearchState';
import './states/NotificationListState';
import './states/PostStreamState';
import './states/SearchState';
import './components/AffixedSidebar';
import './components/DiscussionPage';
import './components/DiscussionListPane';

View File

@ -1,6 +1,6 @@
import app from '../../forum/app';
import setRouteWithForcedRefresh from '../../common/utils/setRouteWithForcedRefresh';
import SearchState from './SearchState';
import SearchState from '../../common/states/SearchState';
type SearchParams = Record<string, string>;
@ -43,7 +43,7 @@ export default class GlobalSearchState extends SearchState {
const q = this.params().q || '';
const filter = this.params().filter || {};
return app.store.gambits.from('users', app.store.gambits.from('discussions', q, filter), filter).trim();
return app.search.gambits.from('users', app.search.gambits.from('discussions', q, filter), filter).trim();
}
/**

View File

@ -146,7 +146,6 @@
font-weight: normal;
}
.Search-input,
.SearchBar {
max-width: 215px;
margin: 0 auto;

View File

@ -4,6 +4,13 @@
&-header {
margin-bottom: 16px;
display: flex;
align-items: center;
column-gap: 8px;
}
&-totalUsers {
margin: 0;
}
&-actions {

View File

@ -31,6 +31,21 @@
&, > li {
display: inline-block;
}
&--packed {
--packing-space: 10px;
padding-inline-start: var(--packing-space);
.Badge {
margin-left: calc(~"0px - var(--packing-space)");
position: relative;
pointer-events: auto;
}
}
&:empty {
display: none;
}
}
.Badge--hidden {

View File

@ -208,7 +208,7 @@
border-radius: 18px;
.Avatar {
margin: -2px 5px -2px -6px;
margin: -2px 0 -2px -6px;
.Avatar--size(24px);
}
}

View File

@ -62,7 +62,7 @@
}
}
&.active {
> a, > button {
> a, > button, > .Dropdown-item {
background: var(--control-bg);
}
}
@ -230,3 +230,45 @@
}
}
}
.AutocompleteDropdown {
position: relative;
}
.GambitsAutocomplete {
&-gambit {
display: flex;
align-items: center;
> button {
flex-grow: 1;
cursor: pointer;
gap: 4px;
display: flex;
align-items: center;
padding: 8px 15px;
margin: -8px 0 -8px -15px;
}
&-key {
font-weight: bold;
}
&-value {
color: var(--control-color);
}
&-actions {
flex-shrink: 0;
display: flex;
color: var(--control-color);
visibility: hidden;
margin-inline-end: -14px;
.Button {
margin: -8px 0;
}
}
}
}
li.active .GambitsAutocomplete-gambit-actions {
visibility: visible;
}

View File

@ -5,8 +5,8 @@
padding: 8px 13px;
font-size: 13px;
line-height: 1.5;
color: var(--control-color);
background-color: var(--control-bg);
color: var(--form-control-color, var(--control-color));
background-color: var(--form-control-bg, var(--control-bg));
border: 2px solid transparent;
border-radius: var(--border-radius);
transition: var(--transition);

View File

@ -0,0 +1,14 @@
.InfoTile {
display: flex;
flex-direction: column;
row-gap: 24px;
font-size: 1.1rem;
color: var(--control-color);
align-items: center;
padding: 8px 0;
.icon {
color: var(--control-muted-color);
font-size: 2rem;
}
}

View File

@ -0,0 +1,77 @@
.Input {
position: relative;
overflow: hidden;
color: var(--muted-color);
&-prefix-icon {
margin-right: -36px;
width: 36px;
font-size: 14px;
text-align: center;
position: absolute;
line-height: 1.5;
pointer-events: none;
}
.FormControl {
transition: var(--transition), width 0.4s;
box-sizing: inherit !important;
&[readonly] {
opacity: 1;
}
}
&--withPrefix {
.FormControl {
padding-left: 32px;
}
}
&-clear {
// It looks very weird due to the padding given to the button..
&:focus {
outline: none;
}
// ...so we display the ring around the icon inside the button, with an offset
.add-keyboard-focus-ring-nearby("> *");
.add-keyboard-focus-ring-nearby-offset("> *", 4px);
}
&--withClear {
// TODO v2.0 check if this is supported by Firefox,
// if so, consider switching to it.
::-webkit-search-cancel-button {
display: none;
}
.FormControl {
padding-right: 32px;
}
}
.LoadingIndicator-container {
height: 100%;
}
.Button, &-prefix-icon {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.Button {
position: absolute;
right: 0;
top: 0;
margin-left: -36px;
width: 36px !important;
&.LoadingIndicator {
width: var(--size) !important;
padding: 0;
}
}
}

View File

@ -94,8 +94,8 @@
color: var(--control-color);
.FormControl {
background-color: var(--body-bg);
color: var(--text-color);
--form-control-bg: var(--body-bg);
--form-control-color: var(--text-color);
}
.Form--centered {
@ -117,6 +117,12 @@
color: var(--muted-color);
}
.Modal-divider {
border-width: 1px;
margin-left: -16px;
margin-right: -16px;
}
.Modal--inverted {
.Modal-header {
background-color: var(--control-bg);
@ -128,6 +134,25 @@
}
}
.Modal--flat {
.Modal-header {
background-color: transparent;
text-align: start;
padding: 18px 18px 0 18px;
color: var(--control-color);
}
.Modal-body {
background-color: transparent;
color: unset;
padding: 18px 16px;
.FormControl:not(:focus) {
--form-control-bg: var(--control-bg);
--form-control-color: var(--control-color);
}
}
}
@media @phone {
.ModalManager {
position: fixed;

View File

@ -1,105 +1,146 @@
.Search {
position: relative;
}
// TODO v2.0 check if this is supported by Firefox,
// if so, consider switching to it.
::-webkit-search-cancel-button {
display: none;
.DiscussionSearchResult {
> .DiscussionListItem {
--discussion-list-item-bg-hover: var(--control-bg);
margin: 0 -16px;
border-radius: 0;
> .DiscussionListItem-content {
opacity: 1;
}
}
&-clear {
// It looks very weird due to the padding given to the button..
&:focus {
outline: none;
}
// ...so we display the ring around the icon inside the button, with an offset
.add-keyboard-focus-ring-nearby("> *");
.add-keyboard-focus-ring-nearby-offset("> *", 4px);
&.active > .DiscussionListItem {
background-color: var(--discussion-list-item-bg-hover);
}
}
@media @tablet-up {
.Search {
transition: margin-left 0.4s;
&.focused {
margin-left: -400px;
.UserSearchResult {
margin: 0 -16px;
input,
.Search-results {
width: 400px;
.badges {
margin-inline-start: 4px;
}
> a {
gap: 6px;
padding: 12px 15px;
border-radius: 0;
}
.Avatar {
--size: 36px;
}
.username {
font-size: 15px;
}
}
.SearchModal {
&-body {
display: flex;
flex-direction: column;
row-gap: 6px;
}
&-tabs {
&-nav + .Modal-divider {
margin-top: 0;
}
&-nav {
margin-bottom: -1px;
display: flex;
column-gap: 4px;
padding: 0 14px;
> .Button {
border-radius: 0;
font-size: 15px;
padding: 12px 8px;
border-bottom: 2px solid;
border-color: transparent;
&[active] {
--button-color: var(--text-color);
--link-color: var(--text-color);
border-color: var(--primary-color);
font-weight: bold;
}
}
}
&-content {
.Dropdown--expanded();
.Dropdown-header {
color: var(--muted-more-color);
}
> .SearchModal-section:first-of-type .Modal-divider {
margin-top: -1px;
}
}
}
}
.Search-results {
overflow: auto;
left: auto;
right: 0;
@media @phone {
left: 0;
&-input {
background: transparent !important;
height: 42px;
border-color: var(--form-control-color);
}
> li > a {
white-space: normal;
}
mark {
background: none;
padding: 0;
font-weight: bold;
color: inherit;
box-shadow: none;
}
}
.Search-input {
overflow: hidden;
color: var(--muted-color);
&-icon {
margin-right: -36px;
width: 36px;
font-size: 14px;
text-align: center;
position: absolute;
padding: 8px 0;
line-height: 1.5;
pointer-events: none;
}
input {
width: 225px;
padding-left: 32px;
padding-right: 32px;
transition: var(--transition), width 0.4s;
box-sizing: inherit !important;
}
.LoadingIndicator-container {
height: 36px;
}
.Button {
position: absolute;
right: 0;
top: 0;
margin-left: -36px;
width: 36px !important;
&.LoadingIndicator {
width: var(--size) !important;
&-results {
mark {
background: none;
padding: 0;
font-weight: bold;
color: inherit;
box-shadow: none;
}
.Badge {
--packing-space: 8px;
--size: 18px;
box-shadow: none;
}
}
&-fullPage .LinkButton {
font-weight: bold;
}
.Dropdown-menu>li>a, .Dropdown-menu>li>button, .Dropdown-menu>li>span {
border-radius: var(--border-radius);
}
.Input {
z-index: 0;
}
}
.DiscussionSearchResult-excerpt {
margin-top: 3px;
color: var(--muted-color);
font-size: 11px;
.SearchModal-visual-wrapper {
position: absolute;
inset: 0;
margin-left: 34px;
margin-right: 32px;
line-height: 42px;
color: transparent;
pointer-events: none;
z-index: -1;
overflow: hidden;
}
.UserSearchResult .Avatar {
.Avatar--size(24px);
margin: -2px 10px -2px 0;
.SearchModal-visual-input {
position: absolute;
inset: 0;
white-space: pre;
}
.SearchModal-visual-input mark {
margin: 0;
padding: 0;
background-color: var(--control-bg-shaded);
color: transparent;
}

View File

@ -20,6 +20,8 @@
@import "EditUserModal";
@import "Form";
@import "FormControl";
@import "Input";
@import "InfoTile";
@import "LoadingIndicator";
@import "Modal";
@import "Navigation";

View File

@ -28,6 +28,7 @@
--control-danger-bg: @control-danger-bg;
--control-danger-color: @control-danger-color;
--control-body-bg-mix: mix(@control-bg, @body-bg, 50%);
--control-muted-color: lighten(@control-color, 40%);
--error-color: @error-color;

View File

@ -37,12 +37,6 @@
position: absolute;
top: 0;
left: -2px;
.Badge {
margin-left: -10px;
position: relative;
pointer-events: auto;
}
}
.DiscussionListItem-title {
margin: 0 0 3px;
@ -115,6 +109,41 @@
}
}
.MinimalDiscussionListItem {
.DiscussionListItem-info .item-excerpt {
margin-right: 0;
}
.DiscussionListItem-badges {
position: relative;
padding: 0;
margin: 0;
inset: unset;
width: auto;
text-align: start;
padding-inline-start: 8px;
&:empty {
display: none;
}
}
.DiscussionListItem-title {
display: flex;
column-gap: 8px;
align-items: center;
margin-bottom: 6px;
line-height: normal;
white-space: nowrap;
> div {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
@media (any-hover: none) {
.DiscussionListItem-controls > .Dropdown-toggle {
display: none;

View File

@ -84,12 +84,6 @@
text-align: right;
white-space: nowrap;
pointer-events: none;
.Badge {
margin-left: -10px;
position: relative;
pointer-events: auto;
}
}
.Post-body {
@ -400,6 +394,9 @@
display: grid;
grid-template-columns: var(--avatar-column-width) 1fr;
}
.Post-main {
min-width: 0;
}
.CommentPost:not(.Post--hidden), .ReplyPlaceholder {
min-height: 64px + 40px; // avatar height + padding
}

View File

@ -519,11 +519,16 @@ core:
submit_button: => core.ref.rename
title: Rename Discussion
# These translations are used by the search results dropdown list.
# These translations are used by the search modal.
search:
all_discussions_button: 'Search all discussions for "{query}"'
discussions_heading: => core.ref.discussions
users_heading: => core.ref.users
gambit_plus_button_a11y_label: Add a positive filter
gambit_minus_button_a11y_label: Add a negative filter
title: Search
no_results_text: It looks like there are no results here.
no_search_text: You have not searched for anything yet.
options_heading: Search options
placeholder: Search...
preview_heading: Search preview
# These translations are used in the Security page.
security:
@ -667,6 +672,10 @@ core:
rate_limit_exceeded_message: You're going a little too quickly. Please try again in a few seconds.
render_failed_message: Sorry, we encountered an error while displaying this content. If you're a user, please try again later. If you're an administrator, take a look in your Flarum log files for more information.
# These translations are used in the input component.
input:
clear_button: Clear input
# These translations are used in the loading indicator component.
loading_indicator:
accessible_label: => core.ref.loading
@ -689,6 +698,36 @@ core:
kilo_text: K
mega_text: M
# These translations are used by search sources.
search_source:
discussions:
all_button: 'Search all discussions for "{query}"'
heading: => core.ref.discussions
users:
heading: => core.ref.users
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
gambits:
boolean_key: is
discussions:
author:
key: author
hint: username or comma-separated list of usernames
created:
key: created
hint: 2020-12-31 or 2020-12-31..2021-09-30
hidden:
key: hidden
unread:
key: unread
users:
email:
key: email
hint: example@machine.local
group:
key: group
hint: singular or plural group names
# These translations are used to punctuate a series of items.
series:
glue_text: ", "

View File

@ -89,6 +89,7 @@ class ForumSerializer extends AbstractSerializer
'canSearchUsers' => $this->actor->can('searchUsers'),
'canCreateAccessToken' => $this->actor->can('createAccessToken'),
'canModerateAccessTokens' => $this->actor->can('moderateAccessTokens'),
'canEditUserCredentials' => $this->actor->hasPermission('user.editCredentials'),
'assetsBaseUrl' => rtrim($this->assetsFilesystem->url(''), '/'),
'jsChunksBaseUrl' => $this->assetsFilesystem->url('js'),
];

View File

@ -29,7 +29,7 @@ class EmailFilter implements FilterInterface
public function filter(SearchState $state, string|array $value, bool $negate): void
{
if (! $state->getActor()->hasPermission('user.edit')) {
if (! $state->getActor()->hasPermission('user.editCredentials')) {
return;
}

View File

@ -91,10 +91,10 @@ function addAutoExports(source, pathToModule, moduleName) {
}
// 2.3. Finally, we check for all named exports
// these can be `export function|class|.. Name ..`
// these can be `export function|class|enum|.. Name ..`
// or `export { ... };
{
const matches = [...source.matchAll(/export\s+?(?:\* as|function|{\s*([A-z0-9, ]+)+\s?}|const|abstract\s?|class)+?\s?([A-Za-z_]*)?/gm)];
const matches = [...source.matchAll(/export\s+?(?:\* as|function|{\s*([A-z0-9, ]+)+\s?}|const|let|abstract\s?|class)+?\s?([A-Za-z_]*)?/gm)];
if (matches.length) {
const map = matches.reduce((map, match) => {