,
+ ,
100
);
diff --git a/framework/core/js/src/common/Application.tsx b/framework/core/js/src/common/Application.tsx
index 606b6e2b4..e15c4cf4b 100644
--- a/framework/core/js/src/common/Application.tsx
+++ b/framework/core/js/src/common/Application.tsx
@@ -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.
diff --git a/framework/core/js/src/common/GambitManager.ts b/framework/core/js/src/common/GambitManager.ts
index 10774c092..3609dc5b6 100644
--- a/framework/core/js/src/common/GambitManager.ts
+++ b/framework/core/js/src/common/GambitManager.ts
@@ -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): Record {
- 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 {
- 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 {
+ return (this.gambits[type] || []).map((gambitClass) => new gambitClass());
+ }
}
diff --git a/framework/core/js/src/common/SearchManager.ts b/framework/core/js/src/common/SearchManager.ts
new file mode 100644
index 000000000..6b2010015
--- /dev/null
+++ b/framework/core/js/src/common/SearchManager.ts
@@ -0,0 +1,25 @@
+import SearchState from './states/SearchState';
+import GambitManager from './GambitManager';
+
+export default class SearchManager {
+ /**
+ * 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;
+ }
+}
diff --git a/framework/core/js/src/common/Store.ts b/framework/core/js/src/common/Store.ts
index f1d9e8784..09051d18a 100644
--- a/framework/core/js/src/common/Store.ts
+++ b/framework/core/js/src/common/Store.ts
@@ -89,12 +89,6 @@ export default class Store {
*/
models: Record;
- /**
- * The gambit manager that will convert search query gambits
- * into API filters.
- */
- gambits = new GambitManager();
-
constructor(models: Record) {
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
diff --git a/framework/core/js/src/common/Translator.tsx b/framework/core/js/src/common/Translator.tsx
index 3ac1896de..29498fb3a 100644
--- a/framework/core/js/src/common/Translator.tsx
+++ b/framework/core/js/src/common/Translator.tsx
@@ -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;
type TranslatorParameters = Record;
@@ -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;
diff --git a/framework/core/js/src/common/common.ts b/framework/core/js/src/common/common.ts
index 13c450dc5..70fcb6837 100644
--- a/framework/core/js/src/common/common.ts
+++ b/framework/core/js/src/common/common.ts
@@ -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';
diff --git a/framework/core/js/src/common/components/AutocompleteDropdown.tsx b/framework/core/js/src/common/components/AutocompleteDropdown.tsx
new file mode 100644
index 000000000..88f01c90f
--- /dev/null
+++ b/framework/core/js/src/common/components/AutocompleteDropdown.tsx
@@ -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 {
+ /**
+ * The index of the currently-selected
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): Mithril.Children {
+ const suggestions = this.suggestions();
+ const shouldShowSuggestions = !!suggestions.length;
+
+ return (
+
+ {vnode.children}
+
+ {suggestions}
+
+
+ );
+ }
+
+ 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('.Dropdown-suggestions')?.style?.setProperty('max-height', `${maxHeight}px`);
+ }
+
+ onupdate(vnode: Mithril.VnodeDOM) {
+ super.onupdate(vnode);
+
+ // Highlight the item that is currently selected.
+ this.setIndex(this.getCurrentNumericIndex());
+
+ this.updateMaxHeight();
+ }
+
+ oncreate(vnode: Mithril.VnodeDOM) {
+ 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) {
+ super.onremove(vnode);
+
+ if (this.updateMaxHeightHandler) {
+ window.removeEventListener('resize', this.updateMaxHeightHandler);
+ }
+ }
+
+ selectableItems(): JQuery {
+ return this.$('.Dropdown-suggestions > li:not(.Dropdown-header)');
+ }
+
+ inputElement(): JQuery {
+ return this.$('input') as JQuery;
+ }
+
+ 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
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);
+ }
+ }
+ }
+}
diff --git a/framework/core/js/src/common/components/GambitsAutocompleteDropdown.tsx b/framework/core/js/src/common/components/GambitsAutocompleteDropdown.tsx
new file mode 100644
index 000000000..18f9b9ef6
--- /dev/null
+++ b/framework/core/js/src/common/components/GambitsAutocompleteDropdown.tsx
@@ -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 {
+ protected gambitsAutocomplete!: GambitsAutocomplete;
+
+ oninit(vnode: Mithril.Vnode) {
+ 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);
+ }
+}
diff --git a/framework/core/js/src/common/components/InfoTile.tsx b/framework/core/js/src/common/components/InfoTile.tsx
new file mode 100644
index 000000000..7e70f8129
--- /dev/null
+++ b/framework/core/js/src/common/components/InfoTile.tsx
@@ -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 extends Component {
+ view(vnode: Mithril.Vnode): Mithril.Children {
+ const { icon, className, ...attrs } = vnode.attrs;
+
+ return (
+
diff --git a/framework/core/js/src/forum/components/Search.tsx b/framework/core/js/src/forum/components/Search.tsx
index c77744f0d..3f6cb1949 100644
--- a/framework/core/js/src/forum/components/Search.tsx
+++ b/framework/core/js/src/forum/components/Search.tsx
@@ -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;
+ search(query: string, limit: number): Promise;
/**
* Get an array of virtual
s that list the search results for the given
* query.
*/
view(query: string): Array;
-}
-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 extends Component {
- /**
- * 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
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) {
super.oninit(vnode);
@@ -99,270 +88,53 @@ export default class Search 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 ;
+ if (this.sourceItems().isEmpty()) return ;
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 (
-