Skip to content

Commit

Permalink
feat: search UI/UX revamp (#3941)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SychO9 authored Jan 9, 2024
1 parent fb1703c commit 3a34136
Show file tree
Hide file tree
Showing 79 changed files with 2,150 additions and 746 deletions.
42 changes: 13 additions & 29 deletions extensions/emoji/js/src/forum/addComposerAutocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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 (
Expand All @@ -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} />
Expand Down Expand Up @@ -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();
Expand Down
21 changes: 5 additions & 16 deletions extensions/lock/js/src/common/query/discussions/LockedGambit.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
}
9 changes: 9 additions & 0 deletions extensions/lock/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
55 changes: 21 additions & 34 deletions extensions/mentions/js/src/forum/addComposerAutocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ 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';

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();
Expand All @@ -24,52 +27,36 @@ 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();

const cursor = selection[0];

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;

Expand All @@ -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();
Expand Down Expand Up @@ -118,7 +105,7 @@ export default function addComposerAutocomplete() {
this.mentionsDropdown.setIndex(0);
this.mentionsDropdown.$().scrollTop(0);

mentionables.search()?.then(buildSuggestions);
this.searchMentions(mentionables, buildSuggestions);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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();
Expand All @@ -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() || '');
Expand Down
21 changes: 5 additions & 16 deletions extensions/sticky/js/src/common/query/discussions/StickyGambit.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
}
9 changes: 9 additions & 0 deletions extensions/sticky/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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],
};
}

Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions extensions/subscriptions/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions extensions/suspend/extend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())
Expand Down
7 changes: 7 additions & 0 deletions extensions/suspend/js/src/@types/shims.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'flarum/common/models/User';

declare module 'flarum/common/models/User' {
export default interface User {
canSuspend: () => boolean;
}
}
Loading

0 comments on commit 3a34136

Please sign in to comment.