1
0
mirror of https://github.com/flarum/core.git synced 2025-08-06 08:27:42 +02:00

feat: search UI/UX revamp (#3941)

* feat: first iteration

* chore: tweak

* feat: second iteration

* chore: incorrect code organization

* feat: gambit input suggestions

* feat: gambit keyboard navigation

* chore: bugs

* feat: negative gambits

* feat: improve gambit highlighting

* refactor: localize gambits

* feat: negative and positive gambit buttons

* fix: permissions

* chore: wat

* per: lazy load search modal

* fix: extensibility and bug fixes

* fix: bugs

* feat: reusable autocomplete dropdown

* chore: format

* fix: tag filter
This commit is contained in:
Sami Mazouz
2024-01-09 22:51:01 +01:00
committed by GitHub
parent fb1703cd9b
commit 3a34136e36
79 changed files with 2158 additions and 754 deletions

View File

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

View File

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

View File

@@ -35,3 +35,12 @@ flarum-lock:
# These translations are used in the Settings page.
settings:
notify_discussion_locked_label: Someone locks a discussion I started
# Translations in this namespace are used by the forum and admin interfaces.
lib:
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
gambits:
discussions:
locked:
key: locked

View File

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

View File

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

View File

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

View File

@@ -32,6 +32,15 @@ flarum-sticky:
# REUSED TRANSLATIONS - These keys should not be used directly in code!
##
# Translations in this namespace are used by the forum and admin interfaces.
lib:
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
gambits:
discussions:
sticky:
key: sticky
# Translations in this namespace are referenced by two or more unique keys.
ref:
sticky: Sticky

View File

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

View File

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

View File

@@ -75,6 +75,16 @@ flarum-subscriptions:
# REUSED TRANSLATIONS - These keys should not be used directly in code!
##
# Translations in this namespace are used by the forum and admin interfaces.
lib:
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
gambits:
discussions:
subscription:
following_key: following
ignoring_key: ignoring
# Translations in this namespace are referenced by two or more unique keys.
ref:
follow: Follow

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,3 +71,12 @@ flarum-suspend:
{forum_url}
html:
body: "You have been unsuspended. You can head back to [{forumTitle}]({forum_url}) when you are ready."
# Translations in this namespace are used by the forum and admin interfaces.
lib:
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
gambits:
users:
suspended:
key: suspended

View File

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

View File

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

View File

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

View File

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

View File

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