From 3a34136e36d60b7d88c5b55e686579b4521a6f2c Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Tue, 9 Jan 2024 22:51:01 +0100 Subject: [PATCH] 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 --- .../js/src/forum/addComposerAutocomplete.js | 42 +- .../common/query/discussions/LockedGambit.ts | 21 +- extensions/lock/locale/en.yml | 9 + .../js/src/forum/addComposerAutocomplete.js | 55 +-- .../forum/mentionables/MentionableModels.tsx | 5 +- .../common/query/discussions/StickyGambit.ts | 21 +- extensions/sticky/locale/en.yml | 9 + .../query/discussions/SubscriptionGambit.ts | 20 +- .../js/src/forum/addSubscriptionFilter.js | 2 +- extensions/subscriptions/locale/en.yml | 10 + extensions/suspend/extend.php | 6 + extensions/suspend/js/src/@types/shims.d.ts | 7 + extensions/suspend/js/src/common/extend.ts | 7 +- .../src/common/query/users/SuspendedGambit.ts | 21 +- extensions/suspend/js/src/forum/extend.ts | 1 - extensions/suspend/locale/en.yml | 9 + .../src/common/query/discussions/TagGambit.ts | 39 +- extensions/tags/js/src/forum/addTagFilter.tsx | 2 +- extensions/tags/js/src/forum/addTagList.js | 2 +- extensions/tags/locale/en.yml | 7 + .../tags/src/Search/Filter/TagFilter.php | 50 +- .../js/src/@types/translator-icu-rich.d.ts | 2 +- .../core/js/src/admin/AdminApplication.tsx | 4 + .../core/js/src/admin/components/AdminNav.js | 17 +- .../js/src/admin/components/UserListPage.tsx | 24 +- framework/core/js/src/common/Application.tsx | 3 + framework/core/js/src/common/GambitManager.ts | 43 +- framework/core/js/src/common/SearchManager.ts | 25 + framework/core/js/src/common/Store.ts | 8 +- framework/core/js/src/common/Translator.tsx | 15 +- framework/core/js/src/common/common.ts | 8 + .../components/AutocompleteDropdown.tsx | 201 ++++++++ .../GambitsAutocompleteDropdown.tsx | 28 ++ .../js/src/common/components/InfoTile.tsx | 31 ++ .../core/js/src/common/components/Input.tsx | 97 ++++ .../core/js/src/common/extenders/Search.ts | 6 +- framework/core/js/src/common/query/IGambit.ts | 169 ++++++- .../common/query/discussions/AuthorGambit.ts | 21 +- .../common/query/discussions/CreatedGambit.ts | 23 +- .../common/query/discussions/HiddenGambit.ts | 21 +- .../common/query/discussions/UnreadGambit.ts | 21 +- .../js/src/common/query/users/EmailGambit.ts | 21 +- .../js/src/common/query/users/GroupGambit.ts | 21 +- .../{forum => common}/states/SearchState.ts | 0 .../js/src/common/utils/AutocompleteReader.ts | 51 ++ .../src/common/utils/GambitsAutocomplete.tsx | 174 +++++++ .../src/common/utils/KeyboardNavigatable.ts | 31 +- .../core/js/src/forum/ForumApplication.tsx | 6 +- .../forum/components/DiscussionListItem.tsx | 4 +- .../components/DiscussionsSearchSource.tsx | 62 +-- .../src/forum/components/HeaderSecondary.js | 2 +- .../js/src/forum/components/IndexPage.tsx | 10 +- .../js/src/forum/components/IndexSidebar.tsx | 2 +- .../components/MinimalDiscussionListItem.tsx | 45 ++ .../core/js/src/forum/components/PostUser.js | 2 +- .../src/forum/components/ReplyPlaceholder.js | 2 +- .../core/js/src/forum/components/Search.tsx | 358 +++----------- .../js/src/forum/components/SearchModal.tsx | 435 ++++++++++++++++++ .../forum/components/UsersSearchSource.tsx | 60 ++- framework/core/js/src/forum/forum.ts | 1 - .../js/src/forum/states/GlobalSearchState.ts | 4 +- framework/core/less/admin/AdminNav.less | 1 - framework/core/less/admin/UsersListPage.less | 7 + framework/core/less/common/Badge.less | 15 + framework/core/less/common/Button.less | 2 +- framework/core/less/common/Dropdown.less | 44 +- framework/core/less/common/FormControl.less | 4 +- framework/core/less/common/InfoTile.less | 14 + framework/core/less/common/Input.less | 77 ++++ framework/core/less/common/Modal.less | 29 +- framework/core/less/common/Search.less | 193 +++++--- framework/core/less/common/common.less | 2 + framework/core/less/common/root.less | 1 + .../core/less/forum/DiscussionListItem.less | 41 +- framework/core/less/forum/Post.less | 9 +- framework/core/locale/core.yml | 47 +- .../src/Api/Serializer/ForumSerializer.php | 1 + .../src/User/Search/Filter/EmailFilter.php | 2 +- .../webpack-config/src/autoExportLoader.cjs | 4 +- 79 files changed, 2150 insertions(+), 746 deletions(-) create mode 100644 extensions/suspend/js/src/@types/shims.d.ts create mode 100644 framework/core/js/src/common/SearchManager.ts create mode 100644 framework/core/js/src/common/components/AutocompleteDropdown.tsx create mode 100644 framework/core/js/src/common/components/GambitsAutocompleteDropdown.tsx create mode 100644 framework/core/js/src/common/components/InfoTile.tsx create mode 100644 framework/core/js/src/common/components/Input.tsx rename framework/core/js/src/{forum => common}/states/SearchState.ts (100%) create mode 100644 framework/core/js/src/common/utils/AutocompleteReader.ts create mode 100644 framework/core/js/src/common/utils/GambitsAutocomplete.tsx create mode 100644 framework/core/js/src/forum/components/MinimalDiscussionListItem.tsx create mode 100644 framework/core/js/src/forum/components/SearchModal.tsx create mode 100644 framework/core/less/common/InfoTile.less create mode 100644 framework/core/less/common/Input.less diff --git a/extensions/emoji/js/src/forum/addComposerAutocomplete.js b/extensions/emoji/js/src/forum/addComposerAutocomplete.js index 30a8626850..a0e43e415c 100644 --- a/extensions/emoji/js/src/forum/addComposerAutocomplete.js +++ b/extensions/emoji/js/src/forum/addComposerAutocomplete.js @@ -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); }} > {emoji} @@ -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(); diff --git a/extensions/lock/js/src/common/query/discussions/LockedGambit.ts b/extensions/lock/js/src/common/query/discussions/LockedGambit.ts index b3639fd35d..6a91875086 100644 --- a/extensions/lock/js/src/common/query/discussions/LockedGambit.ts +++ b/extensions/lock/js/src/common/query/discussions/LockedGambit.ts @@ -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 { - 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`; - } } diff --git a/extensions/lock/locale/en.yml b/extensions/lock/locale/en.yml index ed13f3bb84..5690fd0b0d 100644 --- a/extensions/lock/locale/en.yml +++ b/extensions/lock/locale/en.yml @@ -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 diff --git a/extensions/mentions/js/src/forum/addComposerAutocomplete.js b/extensions/mentions/js/src/forum/addComposerAutocomplete.js index b7344c1f49..ddcc7a2a0c 100644 --- a/extensions/mentions/js/src/forum/addComposerAutocomplete.js +++ b/extensions/mentions/js/src/forum/addComposerAutocomplete.js @@ -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('
'); 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); - - 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 autocompleteReader = new AutocompleteReader((character) => !!(activeFormat = app.mentionFormats.get(character))); + const autocompleting = autocompleteReader.check(this.attrs.composer.editor.getLastNChars(30), cursor, /\S+/); + + 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); } }; diff --git a/extensions/mentions/js/src/forum/mentionables/MentionableModels.tsx b/extensions/mentions/js/src/forum/mentionables/MentionableModels.tsx index 4a4435ef23..a738476dec 100644 --- a/extensions/mentions/js/src/forum/mentionables/MentionableModels.tsx +++ b/extensions/mentions/js/src/forum/mentionables/MentionableModels.tsx @@ -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 => { + public readonly search = async (): Promise => { 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() || ''); diff --git a/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts b/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts index d1ab9f688c..96ac8856d7 100644 --- a/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts +++ b/extensions/sticky/js/src/common/query/discussions/StickyGambit.ts @@ -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 { - 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`; - } } diff --git a/extensions/sticky/locale/en.yml b/extensions/sticky/locale/en.yml index 414c04a888..64407a52f7 100644 --- a/extensions/sticky/locale/en.yml +++ b/extensions/sticky/locale/en.yml @@ -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 diff --git a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts index 6d1a137078..4fd449a7c5 100644 --- a/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts +++ b/extensions/subscriptions/js/src/common/query/discussions/SubscriptionGambit.ts @@ -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 { - 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; + } } diff --git a/extensions/subscriptions/js/src/forum/addSubscriptionFilter.js b/extensions/subscriptions/js/src/forum/addSubscriptionFilter.js index 02e6d912b7..5f02e1287e 100644 --- a/extensions/subscriptions/js/src/forum/addSubscriptionFilter.js +++ b/extensions/subscriptions/js/src/forum/addSubscriptionFilter.js @@ -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', diff --git a/extensions/subscriptions/locale/en.yml b/extensions/subscriptions/locale/en.yml index 135e30e970..a6b507d361 100644 --- a/extensions/subscriptions/locale/en.yml +++ b/extensions/subscriptions/locale/en.yml @@ -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 diff --git a/extensions/suspend/extend.php b/extensions/suspend/extend.php index fe4c1c87e3..57670b38bb 100644 --- a/extensions/suspend/extend.php +++ b/extensions/suspend/extend.php @@ -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 @@ (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()) diff --git a/extensions/suspend/js/src/@types/shims.d.ts b/extensions/suspend/js/src/@types/shims.d.ts new file mode 100644 index 0000000000..f14b955bdb --- /dev/null +++ b/extensions/suspend/js/src/@types/shims.d.ts @@ -0,0 +1,7 @@ +import 'flarum/common/models/User'; + +declare module 'flarum/common/models/User' { + export default interface User { + canSuspend: () => boolean; + } +} diff --git a/extensions/suspend/js/src/common/extend.ts b/extensions/suspend/js/src/common/extend.ts index c576d539fb..f1a55ca147 100644 --- a/extensions/suspend/js/src/common/extend.ts +++ b/extensions/suspend/js/src/common/extend.ts @@ -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('canSuspend'), ]; diff --git a/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts b/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts index f8c2c4c6c0..c530fbe268 100644 --- a/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts +++ b/extensions/suspend/js/src/common/query/users/SuspendedGambit.ts @@ -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 { - 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('canSuspendUsers'); } } diff --git a/extensions/suspend/js/src/forum/extend.ts b/extensions/suspend/js/src/forum/extend.ts index d4de0dec11..8432a3462b 100644 --- a/extensions/suspend/js/src/forum/extend.ts +++ b/extensions/suspend/js/src/forum/extend.ts @@ -8,7 +8,6 @@ export default [ ...commonExtend, new Extend.Model(User) - .attribute('canSuspend') .attribute('suspendedUntil', Model.transformDate) .attribute('suspendReason') .attribute('suspendMessage'), diff --git a/extensions/suspend/locale/en.yml b/extensions/suspend/locale/en.yml index 0e4d81d538..900ea57531 100644 --- a/extensions/suspend/locale/en.yml +++ b/extensions/suspend/locale/en.yml @@ -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 diff --git a/extensions/tags/js/src/common/query/discussions/TagGambit.ts b/extensions/tags/js/src/common/query/discussions/TagGambit.ts index 0cae0b17bc..137e5f3f5b 100644 --- a/extensions/tags/js/src/common/query/discussions/TagGambit.ts +++ b/extensions/tags/js/src/common/query/discussions/TagGambit.ts @@ -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; - toFilter(matches: string[], negate: boolean): Record { - const key = (negate ? '-' : '') + 'tag'; + key(): string { + return app.translator.trans('flarum-tags.lib.gambits.discussions.tag.key', {}, true); + } - 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; } } diff --git a/extensions/tags/js/src/forum/addTagFilter.tsx b/extensions/tags/js/src/forum/addTagFilter.tsx index 2c321247c4..34fee46280 100644 --- a/extensions/tags/js/src/forum/addTagFilter.tsx +++ b/extensions/tags/js/src/forum/addTagFilter.tsx @@ -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) { diff --git a/extensions/tags/js/src/forum/addTagList.js b/extensions/tags/js/src/forum/addTagList.js index 590aae3d4d..a26c9ae8f5 100644 --- a/extensions/tags/js/src/forum/addTagList.js +++ b/extensions/tags/js/src/forum/addTagList.js @@ -24,7 +24,7 @@ export default function addTagList() { items.add('separator', , -12); - const params = app.search.stickyParams(); + const params = app.search.state.stickyParams(); const tags = app.store.all('tags'); const currentTag = app.currentTag(); diff --git a/extensions/tags/locale/en.yml b/extensions/tags/locale/en.yml index 3890539d2d..f07144a1e2 100644 --- a/extensions/tags/locale/en.yml +++ b/extensions/tags/locale/en.yml @@ -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 diff --git a/extensions/tags/src/Search/Filter/TagFilter.php b/extensions/tags/src/Search/Filter/TagFilter.php index 7bc43e828c..5c82c6bfde 100644 --- a/extensions/tags/src/Search/Filter/TagFilter.php +++ b/extensions/tags/src/Search/Filter/TagFilter.php @@ -43,30 +43,36 @@ public function filter(SearchState $state, string|array $value, bool $negate): v 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->whereIn('discussions.id', function (Builder $query) use ($id) { - $query->select('discussion_id') - ->from('discussion_tag') - ->where('tag_id', $id); - }, 'or', $negate); + $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); + } } - } - }); + }); + } } } diff --git a/framework/core/js/src/@types/translator-icu-rich.d.ts b/framework/core/js/src/@types/translator-icu-rich.d.ts index 08fb527651..dbecd881ca 100644 --- a/framework/core/js/src/@types/translator-icu-rich.d.ts +++ b/framework/core/js/src/@types/translator-icu-rich.d.ts @@ -11,7 +11,7 @@ declare module '@askvortsov/rich-icu-message-formatter' { type IRichHandler = (tag: any, values: IValues, contents: string) => any; type ValueOrArray = T | ValueOrArray[]; - type NestedStringArray = ValueOrArray; + export type NestedStringArray = ValueOrArray; export class RichMessageFormatter { locale: string | null; diff --git a/framework/core/js/src/admin/AdminApplication.tsx b/framework/core/js/src/admin/AdminApplication.tsx index 751a9a3fb2..2098d3847e 100644 --- a/framework/core/js/src/admin/AdminApplication.tsx +++ b/framework/core/js/src/admin/AdminApplication.tsx @@ -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 = new SearchManager(new SearchState()); + /** * Settings are serialized to the admin dashboard as strings. * Additional encoding/decoding is possible, but must take diff --git a/framework/core/js/src/admin/components/AdminNav.js b/framework/core/js/src/admin/components/AdminNav.js index 3972bc4140..bc0f64c70b 100644 --- a/framework/core/js/src/admin/components/AdminNav.js +++ b/framework/core/js/src/admin/components/AdminNav.js @@ -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', -
- -
, + , 0 ); diff --git a/framework/core/js/src/admin/components/UserListPage.tsx b/framework/core/js/src/admin/components/UserListPage.tsx index 8cd8ff4da7..9c4cad77e7 100644 --- a/framework/core/js/src/admin/components/UserListPage.tsx +++ b/framework/core/js/src/admin/components/UserListPage.tsx @@ -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 { const items = new ItemList(); + const onchange = (value: string) => { + this.isLoadingPage = true; + this.query = value; + this.throttledSearch(); + }; + items.add( 'search', -
- + { - this.isLoadingPage = true; - this.query = (e?.target as HTMLInputElement)?.value; - this.throttledSearch(); - }} + clearable={true} + loading={this.isLoadingPage} + value={this.query} + onchange={onchange} /> -
, + , 100 ); diff --git a/framework/core/js/src/common/Application.tsx b/framework/core/js/src/common/Application.tsx index 606b6e2b47..e15c4cf4b0 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 10774c0920..3609dc5b6f 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); + + 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]; + } + }); + }); + + return filter; + } - if (gambits.length === 0) return filter; + 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()); - const bits: string[] = filter.q.split(' '); + if (gambits.length === 0) return query; - for (const gambitClass of gambits) { - const gambit = new gambitClass(); + 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 0000000000..6b2010015d --- /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 f1d9e8784e..09051d18aa 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 3ac1896def..29498fb3a5 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 13c450dc53..70fcb68372 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 0000000000..88f01c90f5 --- /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 0000000000..18f9b9ef64 --- /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 0000000000..7e70f81292 --- /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 ( +
    + {this.icon()} +
    {vnode.children}
    +
    + ); + } + + icon(): Mithril.Children { + if (this.attrs.iconElement) return this.attrs.iconElement; + + if (!this.attrs.icon) return null; + + return ; + } +} diff --git a/framework/core/js/src/common/components/Input.tsx b/framework/core/js/src/common/components/Input.tsx new file mode 100644 index 0000000000..886a54cf25 --- /dev/null +++ b/framework/core/js/src/common/components/Input.tsx @@ -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; + 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 extends Component { + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + } + + view(vnode: Mithril.Vnode): Mithril.Children { + const { className: inputClassName, ...inputAttrs } = this.attrs.inputAttrs || {}; + + const value = this.attrs.value || this.attrs.stream?.() || ''; + + return ( +
    + {this.attrs.prefixIcon && } + {this.input({ inputClassName, value, inputAttrs })} + {this.attrs.loading && } + {this.attrs.clearable && value && !this.attrs.loading && ( +
    + ); + } + + 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 ; + } + + onchange(value: string) { + if (this.attrs.stream) { + this.attrs.stream(value); + } else { + this.attrs.onchange?.(value); + } + } + + clear() { + this.onchange(''); + } +} diff --git a/framework/core/js/src/common/extenders/Search.ts b/framework/core/js/src/common/extenders/Search.ts index c2c54cc0b8..a6c8315601 100644 --- a/framework/core/js/src/common/extenders/Search.ts +++ b/framework/core/js/src/common/extenders/Search.ts @@ -7,7 +7,7 @@ export default class Search implements IExtender { protected gambits: Record 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); } } } diff --git a/framework/core/js/src/common/query/IGambit.ts b/framework/core/js/src/common/query/IGambit.ts index a10e8bdb11..5f152f5c4d 100644 --- a/framework/core/js/src/common/query/IGambit.ts +++ b/framework/core/js/src/common/query/IGambit.ts @@ -1,6 +1,171 @@ -export default interface IGambit { +import app from '../app'; + +export default interface IGambit { + 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; + + /** + * 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 { + 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 { + 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 { + type = GambitType.KeyValue; + predicates = false; + + abstract key(): string; + abstract hint(): string; + abstract filterKey(): string; + + valuePattern(): string { + return '(.+)'; + } + + gambitValueToFilterValue(value: string): string | number | boolean | Array { + 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 { + 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; + } } diff --git a/framework/core/js/src/common/query/discussions/AuthorGambit.ts b/framework/core/js/src/common/query/discussions/AuthorGambit.ts index f38f71cda6..10842a8f08 100644 --- a/framework/core/js/src/common/query/discussions/AuthorGambit.ts +++ b/framework/core/js/src/common/query/discussions/AuthorGambit.ts @@ -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 { - 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}`; - } } diff --git a/framework/core/js/src/common/query/discussions/CreatedGambit.ts b/framework/core/js/src/common/query/discussions/CreatedGambit.ts index 087196165d..b7d4374aae 100644 --- a/framework/core/js/src/common/query/discussions/CreatedGambit.ts +++ b/framework/core/js/src/common/query/discussions/CreatedGambit.ts @@ -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 { - 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}`; - } } diff --git a/framework/core/js/src/common/query/discussions/HiddenGambit.ts b/framework/core/js/src/common/query/discussions/HiddenGambit.ts index fb0f448df7..a29579cc8a 100644 --- a/framework/core/js/src/common/query/discussions/HiddenGambit.ts +++ b/framework/core/js/src/common/query/discussions/HiddenGambit.ts @@ -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 { - 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; } } diff --git a/framework/core/js/src/common/query/discussions/UnreadGambit.ts b/framework/core/js/src/common/query/discussions/UnreadGambit.ts index 9466b76be4..2dee136e26 100644 --- a/framework/core/js/src/common/query/discussions/UnreadGambit.ts +++ b/framework/core/js/src/common/query/discussions/UnreadGambit.ts @@ -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 { - 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; } } diff --git a/framework/core/js/src/common/query/users/EmailGambit.ts b/framework/core/js/src/common/query/users/EmailGambit.ts index 5a9d432abd..4db9bc92b0 100644 --- a/framework/core/js/src/common/query/users/EmailGambit.ts +++ b/framework/core/js/src/common/query/users/EmailGambit.ts @@ -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 { - 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('canEditUserCredentials')); } } diff --git a/framework/core/js/src/common/query/users/GroupGambit.ts b/framework/core/js/src/common/query/users/GroupGambit.ts index 6293367198..4966581427 100644 --- a/framework/core/js/src/common/query/users/GroupGambit.ts +++ b/framework/core/js/src/common/query/users/GroupGambit.ts @@ -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 { - 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}`; - } } diff --git a/framework/core/js/src/forum/states/SearchState.ts b/framework/core/js/src/common/states/SearchState.ts similarity index 100% rename from framework/core/js/src/forum/states/SearchState.ts rename to framework/core/js/src/common/states/SearchState.ts diff --git a/framework/core/js/src/common/utils/AutocompleteReader.ts b/framework/core/js/src/common/utils/AutocompleteReader.ts new file mode 100644 index 0000000000..1e9270a9f0 --- /dev/null +++ b/framework/core/js/src/common/utils/AutocompleteReader.ts @@ -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; +}; diff --git a/framework/core/js/src/common/utils/GambitsAutocomplete.tsx b/framework/core/js/src/common/utils/GambitsAutocomplete.tsx new file mode 100644 index 0000000000..2daa8ab785 --- /dev/null +++ b/framework/core/js/src/common/utils/GambitsAutocomplete.tsx @@ -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, + 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[] = gambits.filter((gambit) => gambit.type === GambitType.Grouped); + const keyValueGambits: IGambit[] = 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[] = []; + 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[], + 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 ( +
  • + + + {!!value && ( + +
  • + ); + } + + 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); + } +} diff --git a/framework/core/js/src/common/utils/KeyboardNavigatable.ts b/framework/core/js/src/common/utils/KeyboardNavigatable.ts index 667ac10b50..4966a836c2 100644 --- a/framework/core/js/src/common/utils/KeyboardNavigatable.ts +++ b/framework/core/js/src/common/utils/KeyboardNavigatable.ts @@ -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); }); diff --git a/framework/core/js/src/forum/ForumApplication.tsx b/framework/core/js/src/forum/ForumApplication.tsx index c37d0c1395..4bd195ca15 100644 --- a/framework/core/js/src/forum/ForumApplication.tsx +++ b/framework/core/js/src/forum/ForumApplication.tsx @@ -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 = new SearchManager(new GlobalSearchState()); /** * An object which controls the state of the composer. diff --git a/framework/core/js/src/forum/components/DiscussionListItem.tsx b/framework/core/js/src/forum/components/DiscussionListItem.tsx index fd5decba8e..25d9ecc071 100644 --- a/framework/core/js/src/forum/components/DiscussionListItem.tsx +++ b/framework/core/js/src/forum/components/DiscussionListItem.tsx @@ -54,7 +54,7 @@ export default class DiscussionListItem{listItems(discussion.badges().toArray())}; + return
      {listItems(discussion.badges().toArray())}
    ; } mainView(): Mithril.Children { diff --git a/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx b/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx index 84de850396..478bcf7869 100644 --- a/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx +++ b/framework/core/js/src/forum/components/DiscussionsSearchSource.tsx @@ -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(); - async search(query: string): Promise { + 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 { 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('discussions', params).then((results) => { @@ -33,34 +43,34 @@ export default class DiscussionsSearchSource implements SearchSource { view(query: string): Array { query = query.toLowerCase(); - const results = (this.results.get(query) || []).map((discussion) => { - const mostRelevantPost = discussion.mostRelevantPost(); - + return (this.results.get(query) || []).map((discussion) => { return ( -
  • - -
    {highlight(discussion.title(), query)}
    - {!!mostRelevantPost && ( -
    {highlight(mostRelevantPost.contentPlain() ?? '', query, 100)}
    - )} - +
  • +
  • ); }) as Array; + } - 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 [ -
  • {app.translator.trans('core.forum.search.discussions_heading')}
  • , + return (
  • - {app.translator.trans('core.forum.search.all_discussions_button', { query })} + {app.translator.trans('core.lib.search_source.discussions.all_button', { query })} -
  • , - ...results, - ]; + + ); + } + + gotoItem(id: string): string | null { + const discussion = app.store.getById('discussions', id); + + if (!discussion) return null; + + return app.route.discussion(discussion); } } diff --git a/framework/core/js/src/forum/components/HeaderSecondary.js b/framework/core/js/src/forum/components/HeaderSecondary.js index 95806cb5dd..56a929439f 100644 --- a/framework/core/js/src/forum/components/HeaderSecondary.js +++ b/framework/core/js/src/forum/components/HeaderSecondary.js @@ -26,7 +26,7 @@ export default class HeaderSecondary extends Component { items() { const items = new ItemList(); - items.add('search', , 30); + items.add('search', , 30); if (app.forum.attribute('showLanguageSelector') && Object.keys(app.data.locales).length > 1) { const locales = []; diff --git a/framework/core/js/src/forum/components/IndexPage.tsx b/framework/core/js/src/forum/components/IndexPage.tsx index f236d502ea..7b6fad76c0 100644 --- a/framework/core/js/src/forum/components/IndexPage.tsx +++ b/framework/core/js/src/forum/components/IndexPage.tsx @@ -41,7 +41,7 @@ export default class IndexPage 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 ( - ); @@ -184,6 +184,7 @@ export default class IndexPage { @@ -201,6 +202,7 @@ export default class IndexPage(); - const params = app.search.stickyParams(); + const params = app.search.state.stickyParams(); items.add( 'allDiscussions', diff --git a/framework/core/js/src/forum/components/MinimalDiscussionListItem.tsx b/framework/core/js/src/forum/components/MinimalDiscussionListItem.tsx new file mode 100644 index 0000000000..30162a4445 --- /dev/null +++ b/framework/core/js/src/forum/components/MinimalDiscussionListItem.tsx @@ -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 { + elementAttrs() { + const attrs = super.elementAttrs(); + + attrs.className = classList(attrs.className, 'MinimalDiscussionListItem'); + + return attrs; + } + + viewItems(): ItemList { + return super.viewItems().remove('controls').remove('slidableUnderneath'); + } + + contentItems(): ItemList { + return super.contentItems().remove('stats'); + } + + authorItems(): ItemList { + return super.authorItems().remove('badges'); + } + + mainView(): Mithril.Children { + const discussion = this.attrs.discussion; + const jumpTo = this.getJumpTo(); + + return ( + +

    + {this.badgesView()} +
    {highlight(discussion.title(), this.highlightRegExp)}
    +

    +
      {listItems(this.infoItems().toArray())}
    + + ); + } +} diff --git a/framework/core/js/src/forum/components/PostUser.js b/framework/core/js/src/forum/components/PostUser.js index 3e335fcaa1..8c0f9dd00f 100644 --- a/framework/core/js/src/forum/components/PostUser.js +++ b/framework/core/js/src/forum/components/PostUser.js @@ -38,7 +38,7 @@ export default class PostUser extends Component { {username(user)} -
      {listItems(user.badges().toArray())}
    +
      {listItems(user.badges().toArray())}
    ); } diff --git a/framework/core/js/src/forum/components/ReplyPlaceholder.js b/framework/core/js/src/forum/components/ReplyPlaceholder.js index dd018ce3e9..1f13f4b47b 100644 --- a/framework/core/js/src/forum/components/ReplyPlaceholder.js +++ b/framework/core/js/src/forum/components/ReplyPlaceholder.js @@ -27,7 +27,7 @@ export default class ReplyPlaceholder extends Component {

    {username(app.session.user)}

    -
      {listItems(app.session.user.badges().toArray())}
    +
      {listItems(app.session.user.badges().toArray())}
    diff --git a/framework/core/js/src/forum/components/Search.tsx b/framework/core/js/src/forum/components/Search.tsx index c77744f0d3..3f6cb19495 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 ( -
    -
    - - this.searchState.setValue((e?.target as HTMLInputElement)?.value)} - onfocus={() => (this.hasFocus = true)} - onblur={() => (this.hasFocus = false)} - /> - {!!this.loadingSources && } - {shouldShowClearButton && ( - - )} -
    -
      - {shouldShowResults && this.sources.map((source) => source.view(this.searchState.getValue()))} -
    +
    + { + 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), + }} + />
    ); } - 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('.Search-results')?.style?.setProperty('max-height', `${maxHeight}px`); - } - - onupdate(vnode: Mithril.VnodeDOM) { - 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) { - 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; - - 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) { - 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 { const items = new ItemList(); - 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
  • 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); + if (app.forum.attribute('canViewForum')) { + items.add('discussions', new DiscussionsSearchSource()); } - 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; + if (app.forum.attribute('canSearchUsers')) { + items.add('users', new UsersSearchSource()); } - 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); - } - } + return items; } } diff --git a/framework/core/js/src/forum/components/SearchModal.tsx b/framework/core/js/src/forum/components/SearchModal.tsx new file mode 100644 index 0000000000..e6b193398e --- /dev/null +++ b/framework/core/js/src/forum/components/SearchModal.tsx @@ -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 extends FormModal { + public static LIMIT = 6; + + protected searchState!: SearchState; + + protected query!: Stream; + + /** + * An array of SearchSources. + */ + protected sources!: SearchSource[]; + + /** + * The key of the currently-active search source. + */ + protected activeSource!: Stream; + + /** + * The sources that are still loading results. + */ + protected loadingSources: string[] = []; + + /** + * 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; + + protected inputScroll = Stream(0); + + protected gambitsAutocomplete: Record = {}; + + oninit(vnode: Mithril.Vnode) { + 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 ( +
    +
    + { + this.query(value); + this.inputScroll(this.inputElement()[0]?.scrollLeft ?? 0); + }} + inputAttrs={{ className: 'SearchModal-input' }} + renderInput={(attrs: any) => ( + <> + this.inputScroll((e.target as HTMLInputElement).scrollLeft)} /> +
    +
    + {this.gambifyInput()} +
    +
    + + )} + /> +
    + {this.tabs()} +
    + ); + } + + tabs(): JSX.Element { + return ( +
    +
    {this.tabItems().toArray()}
    +
    {this.activeTabItems().toArray()}
    +
    + ); + } + + tabItems(): ItemList { + const items = new ItemList(); + + this.sources?.map((source, index) => + items.add( + source.resource, + , + 100 - index + ) + ); + + return items; + } + + activeTabItems(): ItemList { + const items = new ItemList(); + + 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', +
    +
    +
      {fullPageLink}
    +
    , + 80 + ); + } + + if (!!gambits.length) { + items.add( + 'gambits', +
    +
    +
      +
    • {app.translator.trans('core.forum.search.options_heading')}
    • + {gambits} +
    +
    , + 60 + ); + } + + items.add( + 'results', +
    +
    +
      +
    • {app.translator.trans('core.forum.search.preview_heading')}
    • + {!shouldShowResults && ( +
    • + {app.translator.trans('core.forum.search.no_search_text')} +
    • + )} + {shouldShowResults && results} + {shouldShowResults && !results?.length && ( +
    • + {app.translator.trans('core.forum.search.no_results_text')} +
    • + )} + {loading && ( +
    • + +
    • + )} +
    +
    , + 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 is:unread 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, `${bit}`); + }); + + const jsx: Mithril.ChildArray = []; + marked.split(/(.*?<\/mark>)/).forEach((chunk) => { + if (chunk.startsWith('')) { + jsx.push({chunk.replace(/<\/?mark>/g, '')}); + } else { + jsx.push(chunk); + } + }); + + return jsx; + } + + onupdate(vnode: Mithril.VnodeDOM) { + 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) { + 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; + + 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) { + 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
  • 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 { + return this.$('.SearchModal-input') as JQuery; + } +} diff --git a/framework/core/js/src/forum/components/UsersSearchSource.tsx b/framework/core/js/src/forum/components/UsersSearchSource.tsx index 1ae165bc62..60f739f510 100644 --- a/framework/core/js/src/forum/components/UsersSearchSource.tsx +++ b/framework/core/js/src/forum/components/UsersSearchSource.tsx @@ -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(); - async search(query: string): Promise { + 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 { return app.store .find('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 [ -
  • {app.translator.trans('core.forum.search.users_heading')}
  • , - ...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 ( -
  • - - + return ( +
  • + + +
    {name} - -
  • - ); - }), - ]; +
    {listItems(user.badges().toArray())}
    +
    + + + ); + }); + } + + fullPage(query: string): null { + return null; + } + + gotoItem(id: string): string | null { + const user = app.store.getById('users', id); + + if (!user) return null; + + return app.route.user(user); } } diff --git a/framework/core/js/src/forum/forum.ts b/framework/core/js/src/forum/forum.ts index 7a74d158b0..09fc502868 100644 --- a/framework/core/js/src/forum/forum.ts +++ b/framework/core/js/src/forum/forum.ts @@ -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'; diff --git a/framework/core/js/src/forum/states/GlobalSearchState.ts b/framework/core/js/src/forum/states/GlobalSearchState.ts index b4ca4e5c26..edfcd68c1c 100644 --- a/framework/core/js/src/forum/states/GlobalSearchState.ts +++ b/framework/core/js/src/forum/states/GlobalSearchState.ts @@ -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; @@ -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(); } /** diff --git a/framework/core/less/admin/AdminNav.less b/framework/core/less/admin/AdminNav.less index 91ca57f6fc..3dcbb70c8a 100644 --- a/framework/core/less/admin/AdminNav.less +++ b/framework/core/less/admin/AdminNav.less @@ -146,7 +146,6 @@ font-weight: normal; } - .Search-input, .SearchBar { max-width: 215px; margin: 0 auto; diff --git a/framework/core/less/admin/UsersListPage.less b/framework/core/less/admin/UsersListPage.less index dd0cd61850..54ee110926 100644 --- a/framework/core/less/admin/UsersListPage.less +++ b/framework/core/less/admin/UsersListPage.less @@ -4,6 +4,13 @@ &-header { margin-bottom: 16px; + display: flex; + align-items: center; + column-gap: 8px; + } + + &-totalUsers { + margin: 0; } &-actions { diff --git a/framework/core/less/common/Badge.less b/framework/core/less/common/Badge.less index 29e62d0c8c..a1f94183c0 100644 --- a/framework/core/less/common/Badge.less +++ b/framework/core/less/common/Badge.less @@ -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 { diff --git a/framework/core/less/common/Button.less b/framework/core/less/common/Button.less index 2c8d5555b2..bb4f0f014d 100644 --- a/framework/core/less/common/Button.less +++ b/framework/core/less/common/Button.less @@ -208,7 +208,7 @@ border-radius: 18px; .Avatar { - margin: -2px 5px -2px -6px; + margin: -2px 0 -2px -6px; .Avatar--size(24px); } } diff --git a/framework/core/less/common/Dropdown.less b/framework/core/less/common/Dropdown.less index d62efc72bb..aa73d90249 100644 --- a/framework/core/less/common/Dropdown.less +++ b/framework/core/less/common/Dropdown.less @@ -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; +} diff --git a/framework/core/less/common/FormControl.less b/framework/core/less/common/FormControl.less index 1216a66719..b5eded09db 100644 --- a/framework/core/less/common/FormControl.less +++ b/framework/core/less/common/FormControl.less @@ -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); diff --git a/framework/core/less/common/InfoTile.less b/framework/core/less/common/InfoTile.less new file mode 100644 index 0000000000..8c2cbad5da --- /dev/null +++ b/framework/core/less/common/InfoTile.less @@ -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; + } +} diff --git a/framework/core/less/common/Input.less b/framework/core/less/common/Input.less new file mode 100644 index 0000000000..a19830033a --- /dev/null +++ b/framework/core/less/common/Input.less @@ -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; + } + } +} diff --git a/framework/core/less/common/Modal.less b/framework/core/less/common/Modal.less index 3e9b7014ab..4dfd6cd5eb 100644 --- a/framework/core/less/common/Modal.less +++ b/framework/core/less/common/Modal.less @@ -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; diff --git a/framework/core/less/common/Search.less b/framework/core/less/common/Search.less index 7e72fc3b77..ba423e72d8 100644 --- a/framework/core/less/common/Search.less +++ b/framework/core/less/common/Search.less @@ -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; - &-clear { - // It looks very weird due to the padding given to the button.. - &:focus { - outline: none; + > .DiscussionListItem-content { + opacity: 1; } + } - // ...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; } } -.Search-results { - overflow: auto; - left: auto; - right: 0; - @media @phone { - left: 0; +.SearchModal { + &-body { + display: flex; + flex-direction: column; + row-gap: 6px; } - > li > a { - white-space: normal; - } + &-tabs { + &-nav + .Modal-divider { + margin-top: 0; + } - mark { - background: none; - padding: 0; - font-weight: bold; - color: inherit; - box-shadow: none; + &-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-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 { + background: transparent !important; + height: 42px; + border-color: var(--form-control-color); } - input { - width: 225px; - padding-left: 32px; - padding-right: 32px; - transition: var(--transition), width 0.4s; - box-sizing: inherit !important; + + &-results { + mark { + background: none; + padding: 0; + font-weight: bold; + color: inherit; + box-shadow: none; + } + + .Badge { + --packing-space: 8px; + --size: 18px; + box-shadow: none; + } } - .LoadingIndicator-container { - height: 36px; + &-fullPage .LinkButton { + font-weight: bold; } - .Button { - position: absolute; - right: 0; - top: 0; - margin-left: -36px; - width: 36px !important; + .Dropdown-menu>li>a, .Dropdown-menu>li>button, .Dropdown-menu>li>span { + border-radius: var(--border-radius); + } - &.LoadingIndicator { - width: var(--size) !important; - padding: 0; - } + .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; } + diff --git a/framework/core/less/common/common.less b/framework/core/less/common/common.less index 32c48a8e7c..e2eb37bb83 100644 --- a/framework/core/less/common/common.less +++ b/framework/core/less/common/common.less @@ -20,6 +20,8 @@ @import "EditUserModal"; @import "Form"; @import "FormControl"; +@import "Input"; +@import "InfoTile"; @import "LoadingIndicator"; @import "Modal"; @import "Navigation"; diff --git a/framework/core/less/common/root.less b/framework/core/less/common/root.less index 3f20666248..0fad22d8eb 100644 --- a/framework/core/less/common/root.less +++ b/framework/core/less/common/root.less @@ -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; diff --git a/framework/core/less/forum/DiscussionListItem.less b/framework/core/less/forum/DiscussionListItem.less index dd10a7e8ff..c04312591c 100644 --- a/framework/core/less/forum/DiscussionListItem.less +++ b/framework/core/less/forum/DiscussionListItem.less @@ -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; diff --git a/framework/core/less/forum/Post.less b/framework/core/less/forum/Post.less index 264a1f3a98..addc96aaf2 100644 --- a/framework/core/less/forum/Post.less +++ b/framework/core/less/forum/Post.less @@ -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 } diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 015f790862..d62b3499d5 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -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: ", " diff --git a/framework/core/src/Api/Serializer/ForumSerializer.php b/framework/core/src/Api/Serializer/ForumSerializer.php index 06489a8c24..29f467e3a3 100644 --- a/framework/core/src/Api/Serializer/ForumSerializer.php +++ b/framework/core/src/Api/Serializer/ForumSerializer.php @@ -89,6 +89,7 @@ protected function getDefaultAttributes(object|array $model): array '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'), ]; diff --git a/framework/core/src/User/Search/Filter/EmailFilter.php b/framework/core/src/User/Search/Filter/EmailFilter.php index c0e4eb49fe..90e446c2f4 100644 --- a/framework/core/src/User/Search/Filter/EmailFilter.php +++ b/framework/core/src/User/Search/Filter/EmailFilter.php @@ -29,7 +29,7 @@ public function getFilterKey(): string public function filter(SearchState $state, string|array $value, bool $negate): void { - if (! $state->getActor()->hasPermission('user.edit')) { + if (! $state->getActor()->hasPermission('user.editCredentials')) { return; } diff --git a/js-packages/webpack-config/src/autoExportLoader.cjs b/js-packages/webpack-config/src/autoExportLoader.cjs index 0a8b3bab37..40bab278a9 100644 --- a/js-packages/webpack-config/src/autoExportLoader.cjs +++ b/js-packages/webpack-config/src/autoExportLoader.cjs @@ -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) => {