mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(app): rework quick switcher ranking and show origin icons (#2953)
This commit is contained in:
1 parent
6730a242db
commit
1c36a59b2c
58 files changed
+2854
-1703
No files matched your search
@@ -15,7 +15,7 @@ import {UserSettingsModal} from '@app/features/user/components/modals/UserSettin
|
||||
import {FAVORITES_GUILD_ID, ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants';
|
||||
|
||||
const QUICK_SWITCHER_MODAL_KEY = 'nav_quick_switcher';
|
||||
const QUICK_SWITCHER_OVERLAY_ID = 'nav_quick_switcher';
|
||||
|
||||
export function hide(): void {
|
||||
QuickSwitcher.hide();
|
||||
@@ -124,5 +124,5 @@ export async function switchTo(result: QuickSwitcherExecutableResult): Promise<v
|
||||
}
|
||||
|
||||
export function getModalKey(): string {
|
||||
return QUICK_SWITCHER_MODAL_KEY;
|
||||
return QUICK_SWITCHER_OVERLAY_ID;
|
||||
}
|
||||
+6
-4
@@ -115,21 +115,23 @@
|
||||
}
|
||||
|
||||
.optionIcon {
|
||||
box-sizing: content-box;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0.125rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--qs-icon-colour, var(--text-secondary));
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.guildIcon {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
border-radius: 624.9375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -311,6 +311,12 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
|
||||
return;
|
||||
}
|
||||
setIsKeyboardNavigating(true);
|
||||
if (event.ctrlKey && !event.altKey && !event.metaKey && (event.key === 'n' || event.key === 'p')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
QuickSwitcherCommands.moveSelection(event.key === 'n' ? 'down' : 'up');
|
||||
return;
|
||||
}
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowUp':
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {onLocaleChange} from '@app/features/i18n/utils/LocaleChangeListener';
|
||||
import type {GuildMember} from '@app/features/member/models/GuildMember';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import MemberSearch, {type SearchContext, type TransformedMember} from '@app/features/member/state/MemberSearch';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
|
||||
import {parseChannelUrl} from '@app/features/navigation/utils/DeepLinkUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {loadLazyModule} from '@app/features/platform/utils/LazyModuleLoader';
|
||||
@@ -16,6 +15,8 @@ import {
|
||||
generateDefaultResults,
|
||||
generateGeneralResults,
|
||||
generateQueryModeResults,
|
||||
type QuickSwitcherModeVariant,
|
||||
type QuickSwitcherSearchContext,
|
||||
resolveTransformedMember,
|
||||
} from '@app/features/search/state/QuickSwitcherResultGenerators';
|
||||
import {hasSameResultIdentity, resolveRecomputedSelectedIndex} from '@app/features/search/state/QuickSwitcherSelection';
|
||||
@@ -27,7 +28,12 @@ import type {
|
||||
QuickSwitcherQueryMode,
|
||||
QuickSwitcherResult,
|
||||
} from '@app/features/search/state/QuickSwitcherTypes';
|
||||
import {MEMBER_SEARCH_LIMIT, QUICK_SWITCHER_MODAL_KEY} from '@app/features/search/state/QuickSwitcherTypes';
|
||||
import {MEMBER_SEARCH_LIMIT, QUICK_SWITCHER_OVERLAY_ID} from '@app/features/search/state/QuickSwitcherTypes';
|
||||
import {
|
||||
createForwardSearchCandidates,
|
||||
type ForwardSearchCandidates,
|
||||
} from '@app/features/search/utils/DestinationSearchSources';
|
||||
import {getLoadedUnicodeConfusables, loadUnicodeConfusables} from '@app/features/search/utils/SearchTextMatching';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
@@ -40,20 +46,48 @@ const GO_TO_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'Go to message',
|
||||
comment: 'Short label in the quick switcher state. Keep it concise.',
|
||||
});
|
||||
const PREFIX_MODES: ReadonlyMap<string, QuickSwitcherQueryMode> = new Map([
|
||||
['@', QuickSwitcherResultTypes.USER],
|
||||
['#', QuickSwitcherResultTypes.TEXT_CHANNEL],
|
||||
['!', QuickSwitcherResultTypes.VOICE_CHANNEL],
|
||||
['*', QuickSwitcherResultTypes.GUILD],
|
||||
]);
|
||||
const ALL_USERS_PREFIX = '@@';
|
||||
const NO_CONFUSABLES: ReadonlyMap<string, string> = new Map();
|
||||
const QUICK_SWITCHER_I18N_MISSING_ERROR = 'QuickSwitcher i18n has not been set';
|
||||
|
||||
interface ParsedPrefix {
|
||||
readonly mode: QuickSwitcherQueryMode | null;
|
||||
readonly search: string;
|
||||
readonly variant: QuickSwitcherModeVariant;
|
||||
}
|
||||
|
||||
function parsePrefix(query: string): ParsedPrefix {
|
||||
if (query.startsWith(ALL_USERS_PREFIX)) {
|
||||
return {
|
||||
mode: QuickSwitcherResultTypes.USER,
|
||||
search: query.slice(ALL_USERS_PREFIX.length).trim(),
|
||||
variant: 'all_users',
|
||||
};
|
||||
}
|
||||
const mode = PREFIX_MODES.get(query.charAt(0)) ?? null;
|
||||
return {mode, search: (mode == null ? query : query.slice(1)).trim(), variant: null};
|
||||
}
|
||||
|
||||
type QuickSwitcherModalModule = typeof import('@app/features/search/components/quick_switcher/QuickSwitcherModal');
|
||||
|
||||
class QuickSwitcher {
|
||||
private logger = new Logger('QuickSwitcher');
|
||||
private candidateSets: CandidateSets | null = null;
|
||||
private searchSources: ForwardSearchCandidates | null = null;
|
||||
private confusables: ReadonlyMap<string, string> = getLoadedUnicodeConfusables() ?? NO_CONFUSABLES;
|
||||
private candidateWarmupCancel: (() => void) | null = null;
|
||||
private defaultResults: Array<QuickSwitcherResult> | null = null;
|
||||
private modalPreloadPromise: Promise<QuickSwitcherModalModule> | null = null;
|
||||
private modalPreloadCancel: (() => void) | null = null;
|
||||
isOpen = false;
|
||||
query = '';
|
||||
queryMode: QuickSwitcherQueryMode | null = null;
|
||||
prefixMode: QuickSwitcherQueryMode | null = null;
|
||||
results: Array<QuickSwitcherResult> = [];
|
||||
selectedIndex = -1;
|
||||
private memberSearchContext: SearchContext | null = null;
|
||||
@@ -66,6 +100,8 @@ class QuickSwitcher {
|
||||
makeAutoObservable<
|
||||
this,
|
||||
| 'candidateSets'
|
||||
| 'searchSources'
|
||||
| 'confusables'
|
||||
| 'candidateWarmupCancel'
|
||||
| 'defaultResults'
|
||||
| 'logger'
|
||||
@@ -75,6 +111,8 @@ class QuickSwitcher {
|
||||
this,
|
||||
{
|
||||
candidateSets: false,
|
||||
searchSources: false,
|
||||
confusables: false,
|
||||
candidateWarmupCancel: false,
|
||||
defaultResults: false,
|
||||
logger: false,
|
||||
@@ -136,7 +174,7 @@ class QuickSwitcher {
|
||||
|
||||
get isLoadingMemberResults(): boolean {
|
||||
return (
|
||||
this.queryMode === QuickSwitcherResultTypes.USER &&
|
||||
this.prefixMode === QuickSwitcherResultTypes.USER &&
|
||||
this.query['slice'](1).trim().length > 0 &&
|
||||
(this.memberFetchDebounceTimer !== null || this.isFetchingMembersInBackground)
|
||||
);
|
||||
@@ -164,7 +202,7 @@ class QuickSwitcher {
|
||||
this.defaultResults = null;
|
||||
this.isOpen = true;
|
||||
this.query = '';
|
||||
this.queryMode = null;
|
||||
this.prefixMode = null;
|
||||
if (!MobileLayout.isMobileLayout()) {
|
||||
void this.pushModal();
|
||||
}
|
||||
@@ -181,6 +219,18 @@ class QuickSwitcher {
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
this.scheduleCandidateWarmup();
|
||||
this.ensureConfusables();
|
||||
}
|
||||
|
||||
private ensureConfusables(): void {
|
||||
if (this.confusables !== NO_CONFUSABLES) return;
|
||||
loadUnicodeConfusables().then(
|
||||
(loaded) => {
|
||||
this.confusables = loaded;
|
||||
this.recomputeIfOpen({invalidateCandidates: false});
|
||||
},
|
||||
(error: unknown) => this.logger.warn('Failed to load Unicode confusables', error),
|
||||
);
|
||||
}
|
||||
|
||||
private loadModal(): Promise<QuickSwitcherModalModule> {
|
||||
@@ -210,7 +260,7 @@ class QuickSwitcher {
|
||||
const {QuickSwitcherModal} = modalModule;
|
||||
ModalCommands.pushWithKey(
|
||||
modal(() => <QuickSwitcherModal data-flx="search.quick-switcher.quick-switcher-modal" />),
|
||||
QUICK_SWITCHER_MODAL_KEY,
|
||||
QUICK_SWITCHER_OVERLAY_ID,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,9 +271,10 @@ class QuickSwitcher {
|
||||
this.cancelCandidateWarmup();
|
||||
this.isOpen = false;
|
||||
this.candidateSets = null;
|
||||
this.searchSources = null;
|
||||
this.defaultResults = null;
|
||||
this.query = '';
|
||||
this.queryMode = null;
|
||||
this.prefixMode = null;
|
||||
this.results = [];
|
||||
this.selectedIndex = -1;
|
||||
if (this.memberSearchContext) {
|
||||
@@ -237,13 +288,14 @@ class QuickSwitcher {
|
||||
this.isFetchingMembersInBackground = false;
|
||||
this.memberSearchResults = [];
|
||||
if (!MobileLayout.isMobileLayout()) {
|
||||
ModalCommands.popWithKey(QUICK_SWITCHER_MODAL_KEY);
|
||||
ModalCommands.popWithKey(QUICK_SWITCHER_OVERLAY_ID);
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateCandidateSets(): void {
|
||||
this.cancelCandidateWarmup();
|
||||
this.candidateSets = null;
|
||||
this.searchSources = null;
|
||||
if (this.isOpen) {
|
||||
this.scheduleCandidateWarmup();
|
||||
}
|
||||
@@ -255,6 +307,12 @@ class QuickSwitcher {
|
||||
return this.candidateSets;
|
||||
}
|
||||
|
||||
private getSearchContext(i18n: I18n): QuickSwitcherSearchContext {
|
||||
const sets = this.getCandidateSets(i18n);
|
||||
this.searchSources ??= createForwardSearchCandidates(i18n).get();
|
||||
return {confusables: this.confusables, sets, sources: this.searchSources};
|
||||
}
|
||||
|
||||
private scheduleCandidateWarmup(): void {
|
||||
if (this.candidateSets || this.candidateWarmupCancel) {
|
||||
return;
|
||||
@@ -294,16 +352,16 @@ class QuickSwitcher {
|
||||
return;
|
||||
}
|
||||
this.defaultResults = null;
|
||||
const {queryMode, results, selectedIndex} = this.computeResultsForQuery(query);
|
||||
const {prefixMode, results, selectedIndex} = this.computeResultsForQuery(query);
|
||||
this.query = query;
|
||||
this.queryMode = queryMode;
|
||||
this.prefixMode = prefixMode;
|
||||
this.results = results;
|
||||
this.selectedIndex = selectedIndex;
|
||||
this.triggerMemberSearchIfNeeded(query, queryMode);
|
||||
this.triggerMemberSearchIfNeeded(query, prefixMode);
|
||||
}
|
||||
|
||||
private triggerMemberSearchIfNeeded(query: string, queryMode: QuickSwitcherQueryMode | null): void {
|
||||
if (queryMode !== QuickSwitcherResultTypes.USER) {
|
||||
private triggerMemberSearchIfNeeded(query: string, prefixMode: QuickSwitcherQueryMode | null): void {
|
||||
if (prefixMode !== QuickSwitcherResultTypes.USER || query.startsWith(ALL_USERS_PREFIX)) {
|
||||
if (this.memberSearchContext) {
|
||||
this.memberSearchContext.destroy();
|
||||
this.memberSearchContext = null;
|
||||
@@ -336,7 +394,7 @@ class QuickSwitcher {
|
||||
.filter((member): member is GuildMember => member !== null);
|
||||
runInAction(() => {
|
||||
this.memberSearchResults = guildMemberRecords;
|
||||
if (this.isOpen && this.queryMode === QuickSwitcherResultTypes.USER) {
|
||||
if (this.isOpen && this.prefixMode === QuickSwitcherResultTypes.USER) {
|
||||
this.recomputeIfOpen({invalidateCandidates: false});
|
||||
}
|
||||
});
|
||||
@@ -346,13 +404,9 @@ class QuickSwitcher {
|
||||
if (this.memberFetchDebounceTimer) {
|
||||
clearTimeout(this.memberFetchDebounceTimer);
|
||||
}
|
||||
const currentChannelId = SelectedChannel.currentChannelId;
|
||||
const currentChannel = currentChannelId ? Channels.getChannel(currentChannelId) : null;
|
||||
const guildId = currentChannel?.guildId ?? null;
|
||||
const allGuilds = Guilds.getGuilds();
|
||||
const guildsToFetch = allGuilds
|
||||
.filter((guild) => !GuildMembers.isGuildFullyLoaded(guild.id))
|
||||
.map((guild) => guild.id);
|
||||
const selectedGuildId = SelectedGuild.selectedGuildId;
|
||||
const guildId = selectedGuildId != null && Guilds.getGuild(selectedGuildId) != null ? selectedGuildId : null;
|
||||
const guildsToFetch = guildId != null && !GuildMembers.isGuildFullyLoaded(guildId) ? [guildId] : [];
|
||||
if (guildsToFetch.length === 0) {
|
||||
this.memberFetchDebounceTimer = null;
|
||||
this.isFetchingMembersInBackground = false;
|
||||
@@ -396,11 +450,11 @@ class QuickSwitcher {
|
||||
this.invalidateCandidateSets();
|
||||
}
|
||||
const previous = this.results[this.selectedIndex];
|
||||
const {queryMode, results, selectedIndex} = this.computeResultsForQuery(this.query);
|
||||
if (!options.force && this.queryMode === queryMode && hasSameResultIdentity(this.results, results)) {
|
||||
const {prefixMode, results, selectedIndex} = this.computeResultsForQuery(this.query);
|
||||
if (!options.force && this.prefixMode === prefixMode && hasSameResultIdentity(this.results, results)) {
|
||||
return;
|
||||
}
|
||||
this.queryMode = queryMode;
|
||||
this.prefixMode = prefixMode;
|
||||
this.results = results;
|
||||
this.selectedIndex = resolveRecomputedSelectedIndex(previous, results, selectedIndex);
|
||||
}
|
||||
@@ -415,6 +469,7 @@ class QuickSwitcher {
|
||||
private computeResultsForQuery(query: string): ComputeResultsForQueryResult {
|
||||
const i18n = this.getI18n();
|
||||
const channelPath = parseChannelUrl(query);
|
||||
const linkResults: Array<QuickSwitcherResult> = [];
|
||||
if (channelPath) {
|
||||
const linkResult: LinkResult = {
|
||||
type: QuickSwitcherResultTypes.LINK,
|
||||
@@ -423,54 +478,30 @@ class QuickSwitcher {
|
||||
subtitle: query,
|
||||
path: channelPath,
|
||||
};
|
||||
return {
|
||||
queryMode: null,
|
||||
results: [linkResult],
|
||||
selectedIndex: 0,
|
||||
};
|
||||
linkResults.push(linkResult);
|
||||
}
|
||||
if (query['trim']().length === 0) {
|
||||
const defaultResults = this.getDefaultResults(i18n);
|
||||
return {
|
||||
queryMode: null,
|
||||
results: defaultResults,
|
||||
selectedIndex: getFirstSelectableIndex(defaultResults),
|
||||
};
|
||||
}
|
||||
const queryMode = this.getQueryMode(query);
|
||||
const rawSearch = queryMode ? query['slice'](1) : query;
|
||||
const trimmedSearch = rawSearch.trim();
|
||||
const {mode, search, variant} = parsePrefix(query);
|
||||
let results: Array<QuickSwitcherResult>;
|
||||
if (queryMode) {
|
||||
const sets = this.getCandidateSets(i18n);
|
||||
results = generateQueryModeResults(queryMode, trimmedSearch, sets, i18n, this.memberSearchResults);
|
||||
} else if (trimmedSearch.length === 0) {
|
||||
if (mode != null) {
|
||||
results = generateQueryModeResults(
|
||||
mode,
|
||||
variant,
|
||||
search,
|
||||
this.getSearchContext(i18n),
|
||||
i18n,
|
||||
this.memberSearchResults,
|
||||
);
|
||||
} else if (search.length === 0) {
|
||||
results = this.getDefaultResults(i18n);
|
||||
} else {
|
||||
const sets = this.getCandidateSets(i18n);
|
||||
results = generateGeneralResults(trimmedSearch, sets, i18n);
|
||||
results = [...linkResults, ...generateGeneralResults(search, this.getSearchContext(i18n), i18n)];
|
||||
}
|
||||
return {
|
||||
queryMode,
|
||||
prefixMode: mode,
|
||||
results,
|
||||
selectedIndex: getFirstSelectableIndex(results),
|
||||
};
|
||||
}
|
||||
|
||||
private getQueryMode(query: string): QuickSwitcherQueryMode | null {
|
||||
switch (query.charAt(0)) {
|
||||
case '@':
|
||||
return QuickSwitcherResultTypes.USER;
|
||||
case '#':
|
||||
return QuickSwitcherResultTypes.TEXT_CHANNEL;
|
||||
case '!':
|
||||
return QuickSwitcherResultTypes.VOICE_CHANNEL;
|
||||
case '*':
|
||||
return QuickSwitcherResultTypes.GUILD;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new QuickSwitcher();
|
||||
@@ -1,49 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {
|
||||
COMMUNITIES_DESCRIPTOR,
|
||||
FAVORITES_DESCRIPTOR,
|
||||
SETTINGS_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {FAVORITES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import type {
|
||||
Candidate,
|
||||
CandidateSets,
|
||||
HeaderResult,
|
||||
QuickSwitcherExecutableResult,
|
||||
QuickSwitcherResult,
|
||||
} from '@app/features/search/state/QuickSwitcherTypes';
|
||||
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const PEOPLE_DESCRIPTOR = msg({
|
||||
message: 'People',
|
||||
comment: 'Quick switcher section header for user results.',
|
||||
});
|
||||
const GROUP_MESSAGES_DESCRIPTOR = msg({
|
||||
message: 'Group messages',
|
||||
comment: 'Quick switcher section header for group DM results.',
|
||||
});
|
||||
const TEXT_CHANNELS_DESCRIPTOR = msg({
|
||||
message: 'Text channels',
|
||||
comment: 'Quick switcher section header for text channel results.',
|
||||
});
|
||||
const VOICE_CHANNELS_DESCRIPTOR = msg({
|
||||
message: 'Voice channels',
|
||||
comment: 'Quick switcher section header for voice channel results.',
|
||||
});
|
||||
|
||||
export type HeaderTitleType =
|
||||
| typeof QuickSwitcherResultTypes.USER
|
||||
| typeof QuickSwitcherResultTypes.GROUP_DM
|
||||
| typeof QuickSwitcherResultTypes.TEXT_CHANNEL
|
||||
| typeof QuickSwitcherResultTypes.VOICE_CHANNEL
|
||||
| typeof QuickSwitcherResultTypes.GUILD
|
||||
| typeof QuickSwitcherResultTypes.VIRTUAL_GUILD
|
||||
| typeof QuickSwitcherResultTypes.SETTINGS;
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected quick switcher type: ${String(value)}`);
|
||||
@@ -127,61 +93,6 @@ function getCandidateSubtitle(subtitle: string | undefined, i18n: I18n, viewCont
|
||||
return subtitle;
|
||||
}
|
||||
|
||||
export function getHeaderTitle(type: HeaderTitleType, i18n: I18n): string {
|
||||
switch (type) {
|
||||
case QuickSwitcherResultTypes.USER:
|
||||
return i18n._(PEOPLE_DESCRIPTOR);
|
||||
case QuickSwitcherResultTypes.GROUP_DM:
|
||||
return i18n._(GROUP_MESSAGES_DESCRIPTOR);
|
||||
case QuickSwitcherResultTypes.TEXT_CHANNEL:
|
||||
return i18n._(TEXT_CHANNELS_DESCRIPTOR);
|
||||
case QuickSwitcherResultTypes.VOICE_CHANNEL:
|
||||
return i18n._(VOICE_CHANNELS_DESCRIPTOR);
|
||||
case QuickSwitcherResultTypes.GUILD:
|
||||
case QuickSwitcherResultTypes.VIRTUAL_GUILD:
|
||||
return i18n._(COMMUNITIES_DESCRIPTOR);
|
||||
case QuickSwitcherResultTypes.SETTINGS:
|
||||
return i18n._(SETTINGS_DESCRIPTOR);
|
||||
default:
|
||||
return assertNever(type);
|
||||
}
|
||||
}
|
||||
|
||||
export function createResultFromChannel(
|
||||
channel: Channel,
|
||||
sets: CandidateSets,
|
||||
i18n: I18n,
|
||||
viewContext?: string,
|
||||
): QuickSwitcherExecutableResult | null {
|
||||
switch (channel.type) {
|
||||
case ChannelTypes.DM:
|
||||
case ChannelTypes.DM_PERSONAL_NOTES: {
|
||||
const candidate = sets.userByChannelId.get(channel.id);
|
||||
return candidate ? candidateToResult(candidate, i18n, viewContext) : null;
|
||||
}
|
||||
case ChannelTypes.GROUP_DM: {
|
||||
const candidate = sets.groupDMByChannelId.get(channel.id);
|
||||
return candidate ? candidateToResult(candidate, i18n, viewContext) : null;
|
||||
}
|
||||
case ChannelTypes.GUILD_TEXT: {
|
||||
const candidate = sets.channelById.get(channel.id);
|
||||
if (candidate && candidate.type === QuickSwitcherResultTypes.TEXT_CHANNEL) {
|
||||
return candidateToResult(candidate, i18n, viewContext);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case ChannelTypes.GUILD_VOICE: {
|
||||
const candidate = sets.channelById.get(channel.id);
|
||||
if (candidate && candidate.type === QuickSwitcherResultTypes.VOICE_CHANNEL) {
|
||||
return candidateToResult(candidate, i18n, viewContext);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFirstSelectableIndex(results: ReadonlyArray<QuickSwitcherResult>): number {
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
if (results[i].type !== QuickSwitcherResultTypes.HEADER) {
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
compareSearchResults,
|
||||
type ForwardChannelCandidate,
|
||||
type ForwardSearchResult,
|
||||
type ForwardUserCandidate,
|
||||
searchChannels,
|
||||
searchGroupDMs,
|
||||
searchGuilds,
|
||||
searchUsers,
|
||||
} from '@app/features/app/components/dialogs/shared/ForwardDestinationSearch';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import {compareChannels} from '@app/features/channel/utils/ChannelUtils';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {MENTIONS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import type {GuildMember} from '@app/features/member/models/GuildMember';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import Messages from '@app/features/messaging/state/MessagingMessages';
|
||||
import Navigation from '@app/features/navigation/state/Navigation';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import ReadStates from '@app/features/read_state/state/ReadStates';
|
||||
import Relationships from '@app/features/relationship/state/Relationships';
|
||||
import {buildChannelCandidate} from '@app/features/search/state/QuickSwitcherCandidateBuilder';
|
||||
import {
|
||||
candidateToResult,
|
||||
createHeaderResult,
|
||||
getHeaderTitle,
|
||||
type HeaderTitleType,
|
||||
} from '@app/features/search/state/QuickSwitcherResultConverters';
|
||||
import {candidateToResult, createHeaderResult} from '@app/features/search/state/QuickSwitcherResultConverters';
|
||||
import type {
|
||||
Candidate,
|
||||
CandidateSets,
|
||||
@@ -23,235 +34,467 @@ import type {
|
||||
QuickSwitcherResult,
|
||||
UserCandidate,
|
||||
} from '@app/features/search/state/QuickSwitcherTypes';
|
||||
import {
|
||||
MAX_GENERAL_RESULTS,
|
||||
MAX_QUERY_MODE_RESULTS,
|
||||
MAX_RECENT_RESULTS,
|
||||
MAX_UNREAD_RESULTS,
|
||||
} from '@app/features/search/state/QuickSwitcherTypes';
|
||||
import type {ForwardSearchCandidates} from '@app/features/search/utils/DestinationSearchSources';
|
||||
import {createSearchTerm, scoreSearchTerm} from '@app/features/search/utils/SearchTextMatching';
|
||||
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants';
|
||||
import {QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants';
|
||||
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {matchSorter, rankings} from 'match-sorter';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
function getChannelRecency(channel: {id: string; lastMessageId: string | null}): number {
|
||||
if (channel.lastMessageId) {
|
||||
return SnowflakeUtils.extractTimestamp(channel.lastMessageId);
|
||||
}
|
||||
return SnowflakeUtils.extractTimestamp(channel.id);
|
||||
const PREVIOUS_CHANNELS_DESCRIPTOR = msg({
|
||||
message: 'Previous channels',
|
||||
comment: 'Quick switcher section header above the channels the user visited most recently.',
|
||||
});
|
||||
const UNREAD_CHANNELS_DESCRIPTOR = msg({
|
||||
message: 'Unread channels',
|
||||
comment: 'Quick switcher section header above the unread channels of the current community.',
|
||||
});
|
||||
const SEARCHING_TEXT_CHANNELS_DESCRIPTOR = msg({
|
||||
message: 'Searching text channels',
|
||||
comment: 'Quick switcher header shown while the query starts with # and only text channels are searched.',
|
||||
});
|
||||
const SEARCHING_VOICE_CHANNELS_DESCRIPTOR = msg({
|
||||
message: 'Searching voice channels',
|
||||
comment: 'Quick switcher header shown while the query starts with ! and only voice channels are searched.',
|
||||
});
|
||||
const SEARCHING_COMMUNITIES_DESCRIPTOR = msg({
|
||||
message: 'Searching communities',
|
||||
comment: 'Quick switcher header shown while the query starts with * and only communities are searched.',
|
||||
});
|
||||
const SEARCHING_ALL_USERS_DESCRIPTOR = msg({
|
||||
message: 'Searching all users',
|
||||
comment: 'Quick switcher header shown while the query starts with @ outside a community, or with @@.',
|
||||
});
|
||||
const SEARCHING_USERS_IN_COMMUNITY_DESCRIPTOR = msg({
|
||||
message: 'Searching friends and members of {communityName}',
|
||||
comment:
|
||||
'Quick switcher header shown while the query starts with @ inside a community. communityName is the community name.',
|
||||
});
|
||||
|
||||
const HISTORY_LIMIT = 8;
|
||||
const PREVIOUS_CHANNELS_WITH_SECTIONS = 3;
|
||||
const PREVIOUS_CHANNELS_ALONE = 7;
|
||||
const HISTORY_HIDDEN_FROM_SECTIONS = 3;
|
||||
const CATEGORY_LIMIT = 5;
|
||||
const MODE_LIMIT = 100;
|
||||
const RECENT_AUTHOR_LIMIT = 100;
|
||||
const SCORE_SCALE = 1000;
|
||||
const LISTING_TEXT_SCORE = 7;
|
||||
const LISTING_VOICE_IN_TEXT_SCORE = 6;
|
||||
const LISTING_FRECENCY_BONUS = 3;
|
||||
|
||||
export type QuickSwitcherModeVariant = 'all_users' | null;
|
||||
|
||||
interface RankedCandidate {
|
||||
readonly candidate: Candidate;
|
||||
readonly matchedText: string;
|
||||
readonly score: number;
|
||||
}
|
||||
|
||||
function getExcludedChannelIds(): Set<string> {
|
||||
const excluded = new Set<string>();
|
||||
const currentChannelId = Navigation.channelId ?? SelectedChannel.currentChannelId;
|
||||
if (!currentChannelId) return excluded;
|
||||
excluded.add(currentChannelId);
|
||||
const currentChannel = Channels.getChannel(currentChannelId);
|
||||
if (currentChannel?.parentId) {
|
||||
excluded.add(currentChannel.parentId);
|
||||
}
|
||||
return excluded;
|
||||
interface SearchContext {
|
||||
readonly confusables: ReadonlyMap<string, string>;
|
||||
readonly sets: CandidateSets;
|
||||
readonly sources: ForwardSearchCandidates;
|
||||
}
|
||||
|
||||
function matchCandidates<T extends Candidate>(candidates: Array<T>, search: string, limit: number): Array<T> {
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (search.length === 0) {
|
||||
return sortCandidatesByWeight(candidates).slice(0, limit);
|
||||
}
|
||||
const results = matchSorter(candidates, search, {
|
||||
keys: [
|
||||
'title',
|
||||
{minRanking: rankings.CONTAINS, key: 'subtitle'},
|
||||
{minRanking: rankings.CONTAINS, key: (item) => item.searchValues},
|
||||
],
|
||||
});
|
||||
return results.slice(0, limit);
|
||||
function getCurrentChannelId(): string | null {
|
||||
return Navigation.channelId ?? SelectedChannel.currentChannelId ?? null;
|
||||
}
|
||||
|
||||
function sortCandidatesByWeight<T extends Candidate>(candidates: Array<T>): Array<T> {
|
||||
return [...candidates].sort((a, b) => {
|
||||
if (b.sortWeight !== a.sortWeight) {
|
||||
return b.sortWeight - a.sortWeight;
|
||||
}
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
function getCurrentGuildId(): string | null {
|
||||
const guildId = SelectedGuild.selectedGuildId;
|
||||
if (guildId == null || guildId === ME || Guilds.getGuild(guildId) == null) return null;
|
||||
return guildId;
|
||||
}
|
||||
|
||||
function createDefaultResultFromChannel(
|
||||
channel: Channel,
|
||||
i18n: I18n,
|
||||
viewContext?: string,
|
||||
): QuickSwitcherExecutableResult | null {
|
||||
function isChannelVisible(channel: Channel): boolean {
|
||||
if (channel.guildId == null) return true;
|
||||
return Permission.can(Permissions.VIEW_CHANNEL, channel);
|
||||
}
|
||||
|
||||
function channelResult(channel: Channel, i18n: I18n): QuickSwitcherExecutableResult | null {
|
||||
const candidate = buildChannelCandidate(channel, i18n);
|
||||
return candidate ? candidateToResult(candidate, i18n, viewContext) : null;
|
||||
return candidate == null ? null : candidateToResult(candidate, i18n);
|
||||
}
|
||||
|
||||
function collectChannelResults(
|
||||
channelIds: ReadonlyArray<string>,
|
||||
i18n: I18n,
|
||||
seen: Set<string>,
|
||||
): Array<QuickSwitcherExecutableResult> {
|
||||
const results: Array<QuickSwitcherExecutableResult> = [];
|
||||
for (const channelId of channelIds) {
|
||||
if (seen.has(channelId)) continue;
|
||||
const channel = Channels.getChannel(channelId);
|
||||
if (channel == null || !isChannelVisible(channel)) continue;
|
||||
const result = channelResult(channel, i18n);
|
||||
if (result == null) continue;
|
||||
seen.add(channelId);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function isUnreadInCurrentCommunity(channel: Channel): boolean {
|
||||
if (channel.type !== ChannelTypes.GUILD_TEXT || !isChannelVisible(channel)) return false;
|
||||
if (UserGuildSettings.isMutedAtAnyLevel(channel.guildId ?? null, channel.id)) return false;
|
||||
if (!ReadStates.hasUnread(channel.id)) return false;
|
||||
const unreadLevel = UserGuildSettings.resolvedGuildUnreadBadgesLevel({
|
||||
id: channel.id,
|
||||
guildId: channel.guildId ?? undefined,
|
||||
parentId: channel.parentId ?? undefined,
|
||||
type: channel.type,
|
||||
});
|
||||
return unreadLevel === MessageNotifications.ALL_MESSAGES;
|
||||
}
|
||||
|
||||
export function generateDefaultResults(i18n: I18n): Array<QuickSwitcherResult> {
|
||||
const recentVisits = SelectedChannel.recentChannelVisits;
|
||||
const excludedIds = getExcludedChannelIds();
|
||||
const recentEntries: Array<{
|
||||
channelId: string;
|
||||
result: QuickSwitcherExecutableResult;
|
||||
}> = [];
|
||||
for (const visit of recentVisits) {
|
||||
if (excludedIds.has(visit.channelId)) continue;
|
||||
const channel = Channels.getChannel(visit.channelId);
|
||||
if (!channel) continue;
|
||||
const result = createDefaultResultFromChannel(channel, i18n, visit.guildId);
|
||||
if (result) {
|
||||
recentEntries.push({channelId: visit.channelId, result});
|
||||
const currentChannelId = getCurrentChannelId();
|
||||
const history = [...new Set(SelectedChannel.sortedRecentVisits.map((visit) => visit.channelId))]
|
||||
.filter((channelId) => channelId !== currentChannelId)
|
||||
.slice(0, HISTORY_LIMIT - 1);
|
||||
const previous = collectChannelResults(history, i18n, new Set());
|
||||
const seen = new Set(previous.slice(0, HISTORY_HIDDEN_FROM_SECTIONS).map((result) => resultChannelId(result)));
|
||||
if (currentChannelId != null) seen.add(currentChannelId);
|
||||
const sections: Array<QuickSwitcherResult> = [];
|
||||
const mentions = collectChannelResults([...ReadStates.mentionChannelIds].reverse(), i18n, seen);
|
||||
if (mentions.length > 0) {
|
||||
sections.push(createHeaderResult('section-mentions', i18n._(MENTIONS_DESCRIPTOR)), ...mentions);
|
||||
}
|
||||
const guildId = getCurrentGuildId();
|
||||
if (guildId != null) {
|
||||
const unreadIds = [...Channels.getGuildChannels(guildId)]
|
||||
.filter(isUnreadInCurrentCommunity)
|
||||
.sort(compareChannels)
|
||||
.map((channel) => channel.id);
|
||||
const unread = collectChannelResults(unreadIds, i18n, seen);
|
||||
if (unread.length > 0) {
|
||||
sections.push(createHeaderResult('section-unread', i18n._(UNREAD_CHANNELS_DESCRIPTOR)), ...unread);
|
||||
}
|
||||
}
|
||||
const recentSlicedEntries = recentEntries.slice(0, MAX_RECENT_RESULTS);
|
||||
const recentSliced = recentSlicedEntries.map(({result}) => result);
|
||||
const recentChannelIds = new Set(recentSlicedEntries.map(({channelId}) => channelId));
|
||||
const unreadResults = generateUnreadResults(i18n, recentChannelIds);
|
||||
return [...recentSliced, ...unreadResults];
|
||||
const previousCap = sections.length > 0 ? PREVIOUS_CHANNELS_WITH_SECTIONS : PREVIOUS_CHANNELS_ALONE;
|
||||
const shownPrevious = previous.slice(0, previousCap);
|
||||
if (shownPrevious.length === 0) return sections;
|
||||
return [createHeaderResult('section-previous', i18n._(PREVIOUS_CHANNELS_DESCRIPTOR)), ...shownPrevious, ...sections];
|
||||
}
|
||||
|
||||
function generateUnreadResults(
|
||||
function resultChannelId(result: QuickSwitcherExecutableResult): string {
|
||||
if (result.type === QuickSwitcherResultTypes.USER) return result.dmChannelId ?? result.id;
|
||||
return result.id;
|
||||
}
|
||||
|
||||
function getModeHeader(
|
||||
prefixMode: QuickSwitcherQueryMode,
|
||||
variant: QuickSwitcherModeVariant,
|
||||
i18n: I18n,
|
||||
additionalExcludedChannelIds: ReadonlySet<string>,
|
||||
): Array<QuickSwitcherExecutableResult> {
|
||||
const excludedIds = getExcludedChannelIds();
|
||||
const unreadChannels = ReadStates.getChannelIds()
|
||||
.filter((channelId) => {
|
||||
if (excludedIds.has(channelId) || additionalExcludedChannelIds.has(channelId)) {
|
||||
return false;
|
||||
}
|
||||
return ReadStates.isUnreadOrMentioned(channelId);
|
||||
})
|
||||
.map((channelId) => Channels.getChannel(channelId))
|
||||
.filter((channel): channel is Channel => channel != null)
|
||||
.sort((a, b) => getChannelRecency(b) - getChannelRecency(a))
|
||||
.slice(0, MAX_UNREAD_RESULTS);
|
||||
const results: Array<QuickSwitcherExecutableResult> = [];
|
||||
for (const channel of unreadChannels) {
|
||||
const result = createDefaultResultFromChannel(channel, i18n);
|
||||
if (result) {
|
||||
results.push(result);
|
||||
): QuickSwitcherResult {
|
||||
switch (prefixMode) {
|
||||
case QuickSwitcherResultTypes.USER: {
|
||||
const guild = variant === 'all_users' ? null : getCurrentGuildId();
|
||||
const title =
|
||||
guild == null
|
||||
? i18n._(SEARCHING_ALL_USERS_DESCRIPTOR)
|
||||
: i18n._(SEARCHING_USERS_IN_COMMUNITY_DESCRIPTOR, {communityName: Guilds.getGuild(guild)?.name ?? ''});
|
||||
return createHeaderResult(`query-${prefixMode}`, title);
|
||||
}
|
||||
case QuickSwitcherResultTypes.TEXT_CHANNEL:
|
||||
return createHeaderResult(`query-${prefixMode}`, i18n._(SEARCHING_TEXT_CHANNELS_DESCRIPTOR));
|
||||
case QuickSwitcherResultTypes.VOICE_CHANNEL:
|
||||
return createHeaderResult(`query-${prefixMode}`, i18n._(SEARCHING_VOICE_CHANNELS_DESCRIPTOR));
|
||||
default:
|
||||
return createHeaderResult(`query-${prefixMode}`, i18n._(SEARCHING_COMMUNITIES_DESCRIPTOR));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function buildUserCandidate(userId: string, sets: CandidateSets): UserCandidate | null {
|
||||
const existing = sets.users.find((candidate) => candidate.id === userId);
|
||||
if (existing != null) return existing;
|
||||
const user = Users.getUser(userId);
|
||||
if (user == null) return null;
|
||||
const title = NicknameUtils.getNickname(user, null);
|
||||
const subtitle = NicknameUtils.formatUserTagForStreamerMode(user);
|
||||
return {
|
||||
type: QuickSwitcherResultTypes.USER,
|
||||
id: user.id,
|
||||
title,
|
||||
subtitle,
|
||||
user,
|
||||
dmChannelId: null,
|
||||
searchValues: [title, subtitle],
|
||||
sortWeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSearchCandidate(result: ForwardSearchResult, sets: CandidateSets): Candidate | null {
|
||||
switch (result.type) {
|
||||
case 'user':
|
||||
return buildUserCandidate(result.id, sets);
|
||||
case 'group_dm':
|
||||
return sets.groupDMByChannelId.get(result.id) ?? sets.userByChannelId.get(result.id) ?? null;
|
||||
default:
|
||||
return sets.channelById.get(result.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function toRanked(results: ReadonlyArray<ForwardSearchResult>, sets: CandidateSets): Array<RankedCandidate> {
|
||||
const ranked: Array<RankedCandidate> = [];
|
||||
for (const result of results) {
|
||||
const candidate = resolveSearchCandidate(result, sets);
|
||||
if (candidate != null) ranked.push({candidate, matchedText: result.matchedText, score: result.score});
|
||||
}
|
||||
return ranked;
|
||||
}
|
||||
|
||||
function compareRanked(left: RankedCandidate, right: RankedCandidate): number {
|
||||
return compareSearchResults(
|
||||
{matchedText: left.matchedText, id: left.candidate.id, score: left.score, type: rankedType(left)},
|
||||
{matchedText: right.matchedText, id: right.candidate.id, score: right.score, type: rankedType(right)},
|
||||
);
|
||||
}
|
||||
|
||||
function rankedType(ranked: RankedCandidate): ForwardSearchResult['type'] {
|
||||
return ranked.candidate.type === QuickSwitcherResultTypes.USER ? 'user' : 'text_channel';
|
||||
}
|
||||
|
||||
function rankedToResult(ranked: RankedCandidate, i18n: I18n): QuickSwitcherExecutableResult {
|
||||
const result = candidateToResult(ranked.candidate, i18n);
|
||||
if (result.type !== QuickSwitcherResultTypes.USER) return result;
|
||||
const {user} = result;
|
||||
const tag = NicknameUtils.formatUserTagForStreamerMode(user);
|
||||
const matched = ranked.matchedText;
|
||||
if (matched === '' || matched === user.username || matched === user.id || matched === tag) return result;
|
||||
return {...result, title: NicknameUtils.formatNicknameForStreamerMode(matched)};
|
||||
}
|
||||
|
||||
function currentUserBlacklist(): ReadonlySet<string> {
|
||||
const currentUserId = Users.currentUserId;
|
||||
return new Set(currentUserId == null ? [] : [currentUserId]);
|
||||
}
|
||||
|
||||
function mergeMemberSearchUsers(
|
||||
users: ReadonlyArray<ForwardUserCandidate>,
|
||||
memberSearchResults: ReadonlyArray<GuildMember>,
|
||||
): ReadonlyArray<ForwardUserCandidate> {
|
||||
if (memberSearchResults.length === 0) return users;
|
||||
const known = new Set(users.map((user) => user.id));
|
||||
const extra: Array<ForwardUserCandidate> = [];
|
||||
for (const member of memberSearchResults) {
|
||||
if (known.has(member.user.id)) continue;
|
||||
known.add(member.user.id);
|
||||
extra.push({
|
||||
friendAlias: null,
|
||||
globalName: member.user.globalName,
|
||||
id: member.user.id,
|
||||
nicknames: member.nick == null ? [] : [member.nick],
|
||||
username: member.user.username,
|
||||
});
|
||||
}
|
||||
return [...users, ...extra];
|
||||
}
|
||||
|
||||
function filterUsersToCommunity(
|
||||
users: ReadonlyArray<ForwardUserCandidate>,
|
||||
guildId: string,
|
||||
): ReadonlyArray<ForwardUserCandidate> {
|
||||
return users.filter((user) => {
|
||||
if (Relationships.getRelationship(user.id)?.type === RelationshipTypes.FRIEND) return true;
|
||||
return GuildMembers.getMember(guildId, user.id) != null;
|
||||
});
|
||||
}
|
||||
|
||||
function scoreNavigationCandidates(query: string, candidates: ReadonlyArray<Candidate>, limit: number) {
|
||||
const term = createSearchTerm(query.toLocaleLowerCase());
|
||||
const ranked: Array<RankedCandidate> = [];
|
||||
for (const candidate of candidates) {
|
||||
const score = scoreSearchTerm(candidate.title.toLocaleLowerCase(), term);
|
||||
if (score > 0) ranked.push({candidate, matchedText: candidate.title, score: SCORE_SCALE * score});
|
||||
}
|
||||
return ranked.sort((left, right) => right.score - left.score).slice(0, limit);
|
||||
}
|
||||
|
||||
function searchGuildCandidates(
|
||||
query: string,
|
||||
context: SearchContext,
|
||||
limit: number,
|
||||
excludedIds: ReadonlySet<string>,
|
||||
): Array<RankedCandidate> {
|
||||
const guildById = new Map(context.sets.guilds.map((candidate) => [candidate.id, candidate]));
|
||||
const ranked: Array<RankedCandidate> = [];
|
||||
for (const result of searchGuilds(
|
||||
query,
|
||||
context.sources.guilds,
|
||||
context.sources.weights.guilds,
|
||||
excludedIds,
|
||||
limit,
|
||||
true,
|
||||
)) {
|
||||
const candidate = guildById.get(result.id);
|
||||
if (candidate != null) ranked.push({candidate, matchedText: result.matchedText, score: result.score});
|
||||
}
|
||||
return ranked;
|
||||
}
|
||||
|
||||
function currentGuildBlacklist(): ReadonlySet<string> {
|
||||
const guildId = getCurrentGuildId();
|
||||
return new Set(guildId == null ? [] : [guildId]);
|
||||
}
|
||||
|
||||
export function generateGeneralResults(search: string, context: SearchContext, i18n: I18n): Array<QuickSwitcherResult> {
|
||||
const {confusables, sets, sources} = context;
|
||||
const runCategories = (limit: number): Array<Array<RankedCandidate>> => [
|
||||
toRanked(
|
||||
searchUsers(search, sources.users, sources.weights.users, currentUserBlacklist(), confusables, limit),
|
||||
sets,
|
||||
),
|
||||
toRanked(searchGroupDMs(search, sources.groupDMs, sources.weights.groupDMs, confusables, limit), sets),
|
||||
toRanked(searchChannels(search, sources.channels, 'text', sources.weights.textChannel, limit, true), sets),
|
||||
searchGuildCandidates(search, context, limit, currentGuildBlacklist()),
|
||||
scoreNavigationCandidates(search, sets.virtualGuilds, limit),
|
||||
scoreNavigationCandidates(search, sets.settings, limit),
|
||||
];
|
||||
let categories = runCategories(CATEGORY_LIMIT);
|
||||
if (categories.filter((category) => category.length > 0).length === 1) {
|
||||
const index = categories.findIndex((category) => category.length > 0);
|
||||
categories = categories.map((category, categoryIndex) =>
|
||||
categoryIndex === index ? runCategories(MODE_LIMIT)[index] : category,
|
||||
);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return categories
|
||||
.flat()
|
||||
.filter((ranked) => {
|
||||
const key = `${ranked.candidate.type}|${ranked.candidate.id}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort(compareRanked)
|
||||
.map((ranked) => rankedToResult(ranked, i18n));
|
||||
}
|
||||
|
||||
function listRecentAuthors(sets: CandidateSets): Array<Candidate> {
|
||||
const channelId = getCurrentChannelId();
|
||||
if (channelId == null) return [];
|
||||
const channel = Channels.getChannel(channelId);
|
||||
const currentUserId = Users.currentUserId;
|
||||
const authors: Array<Candidate> = [];
|
||||
const seen = new Set<string>();
|
||||
Messages.getCachedMessages(channelId)?.searchFromNewest((message) => {
|
||||
const author = message.author;
|
||||
if (author.id === currentUserId || author.bot || seen.has(author.id)) return false;
|
||||
if (channel?.guildId != null && GuildMembers.getMember(channel.guildId, author.id) == null) return false;
|
||||
seen.add(author.id);
|
||||
const candidate = buildUserCandidate(author.id, sets);
|
||||
if (candidate != null) authors.push(candidate);
|
||||
return authors.length >= RECENT_AUTHOR_LIMIT;
|
||||
});
|
||||
return authors;
|
||||
}
|
||||
|
||||
function listChannels(kind: 'text' | 'voice', context: SearchContext): Array<Candidate> {
|
||||
const guildId = getCurrentGuildId();
|
||||
const guildOrder = new Map(Guilds.getGuilds().map((guild, index) => [guild.id, index]));
|
||||
const pool: Array<ForwardChannelCandidate> = context.sources.channels.filter((channel) => {
|
||||
if (!channel.canAccess) return false;
|
||||
if (kind === 'voice' && channel.kind !== 'voice') return false;
|
||||
if (kind === 'text' && channel.kind === 'voice' && guildId != null) return false;
|
||||
if (guildId == null) return true;
|
||||
return Channels.getChannel(channel.id)?.guildId === guildId;
|
||||
});
|
||||
const scoreOf = (channel: ForwardChannelCandidate): number => {
|
||||
const base = kind === 'text' && channel.kind === 'voice' ? LISTING_VOICE_IN_TEXT_SCORE : LISTING_TEXT_SCORE;
|
||||
return channel.hasFrecency ? Math.min(base + LISTING_FRECENCY_BONUS, base >= 7 ? 10 : 7) : base;
|
||||
};
|
||||
const channelOf = (id: string): Channel | undefined => Channels.getChannel(id);
|
||||
return pool
|
||||
.map((channel) => ({channel, model: channelOf(channel.id), score: scoreOf(channel)}))
|
||||
.filter((entry): entry is {channel: ForwardChannelCandidate; model: Channel; score: number} => entry.model != null)
|
||||
.sort((left, right) => {
|
||||
if (left.score !== right.score) return right.score - left.score;
|
||||
const leftGuild = guildOrder.get(left.model.guildId ?? '') ?? 0;
|
||||
const rightGuild = guildOrder.get(right.model.guildId ?? '') ?? 0;
|
||||
if (leftGuild !== rightGuild) return leftGuild - rightGuild;
|
||||
return compareChannels(left.model, right.model);
|
||||
})
|
||||
.slice(0, MODE_LIMIT)
|
||||
.map((entry) => context.sets.channelById.get(entry.channel.id))
|
||||
.filter((candidate): candidate is NonNullable<typeof candidate> => candidate != null);
|
||||
}
|
||||
|
||||
function searchModeUsers(
|
||||
search: string,
|
||||
variant: QuickSwitcherModeVariant,
|
||||
context: SearchContext,
|
||||
memberSearchResults: ReadonlyArray<GuildMember>,
|
||||
): Array<RankedCandidate> {
|
||||
const guildId = variant === 'all_users' ? null : getCurrentGuildId();
|
||||
let users = mergeMemberSearchUsers(context.sources.users, memberSearchResults);
|
||||
if (guildId != null) users = filterUsersToCommunity(users, guildId);
|
||||
return toRanked(
|
||||
searchUsers(search, users, context.sources.weights.users, currentUserBlacklist(), context.confusables, MODE_LIMIT),
|
||||
context.sets,
|
||||
);
|
||||
}
|
||||
|
||||
export function generateQueryModeResults(
|
||||
queryMode: QuickSwitcherQueryMode,
|
||||
prefixMode: QuickSwitcherQueryMode,
|
||||
variant: QuickSwitcherModeVariant,
|
||||
search: string,
|
||||
sets: CandidateSets,
|
||||
context: SearchContext,
|
||||
i18n: I18n,
|
||||
memberSearchResults: Array<GuildMember>,
|
||||
memberSearchResults: ReadonlyArray<GuildMember>,
|
||||
): Array<QuickSwitcherResult> {
|
||||
let candidates: Array<Candidate>;
|
||||
switch (queryMode) {
|
||||
const header = getModeHeader(prefixMode, variant, i18n);
|
||||
const {sets, sources} = context;
|
||||
if (search.length === 0) {
|
||||
let listed: Array<Candidate>;
|
||||
switch (prefixMode) {
|
||||
case QuickSwitcherResultTypes.USER:
|
||||
listed = listRecentAuthors(sets);
|
||||
break;
|
||||
case QuickSwitcherResultTypes.TEXT_CHANNEL:
|
||||
listed = listChannels('text', context);
|
||||
break;
|
||||
case QuickSwitcherResultTypes.VOICE_CHANNEL:
|
||||
listed = listChannels('voice', context);
|
||||
break;
|
||||
default:
|
||||
listed = [...sets.guilds, ...sets.virtualGuilds];
|
||||
break;
|
||||
}
|
||||
return [header, ...listed.map((candidate) => candidateToResult(candidate, i18n))];
|
||||
}
|
||||
let ranked: Array<RankedCandidate>;
|
||||
switch (prefixMode) {
|
||||
case QuickSwitcherResultTypes.USER:
|
||||
candidates = buildUserCandidatesWithMemberSearch(sets.users, memberSearchResults);
|
||||
ranked = searchModeUsers(search, variant, context, memberSearchResults);
|
||||
break;
|
||||
case QuickSwitcherResultTypes.TEXT_CHANNEL:
|
||||
candidates = sets.textChannels;
|
||||
ranked = toRanked(
|
||||
searchChannels(search, sources.channels, 'text', sources.weights.textChannel, MODE_LIMIT, true),
|
||||
sets,
|
||||
);
|
||||
break;
|
||||
case QuickSwitcherResultTypes.VOICE_CHANNEL:
|
||||
candidates = sets.voiceChannels;
|
||||
break;
|
||||
case QuickSwitcherResultTypes.GUILD:
|
||||
candidates = [...sets.guilds, ...sets.virtualGuilds];
|
||||
break;
|
||||
case QuickSwitcherResultTypes.VIRTUAL_GUILD:
|
||||
candidates = sets.virtualGuilds;
|
||||
break;
|
||||
case QuickSwitcherResultTypes.SETTINGS:
|
||||
candidates = sets.settings;
|
||||
ranked = toRanked(
|
||||
searchChannels(search, sources.channels, 'voice', sources.weights.voiceChannel, MODE_LIMIT, true),
|
||||
sets,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates = [];
|
||||
ranked = [
|
||||
...searchGuildCandidates(search, context, MODE_LIMIT, currentGuildBlacklist()),
|
||||
...scoreNavigationCandidates(search, sets.virtualGuilds, MODE_LIMIT),
|
||||
].sort(compareRanked);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
search.length === 0 &&
|
||||
(queryMode === QuickSwitcherResultTypes.TEXT_CHANNEL || queryMode === QuickSwitcherResultTypes.VOICE_CHANNEL)
|
||||
) {
|
||||
const excludedIds = getExcludedChannelIds();
|
||||
candidates = candidates.filter((c) => !excludedIds.has(c.id));
|
||||
}
|
||||
const matches = matchCandidates(candidates, search, MAX_QUERY_MODE_RESULTS);
|
||||
if (matches.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
createHeaderResult(`query-${queryMode}`, getHeaderTitle(queryMode, i18n)),
|
||||
...matches.map((c) => candidateToResult(c, i18n)),
|
||||
];
|
||||
}
|
||||
|
||||
export function generateGeneralResults(search: string, sets: CandidateSets, i18n: I18n): Array<QuickSwitcherResult> {
|
||||
const sections: Array<{
|
||||
type: HeaderTitleType;
|
||||
headerId: string;
|
||||
candidates: Array<Candidate>;
|
||||
}> = [
|
||||
{type: QuickSwitcherResultTypes.USER, headerId: 'people', candidates: sets.users},
|
||||
{type: QuickSwitcherResultTypes.GROUP_DM, headerId: 'group-dm', candidates: sets.groupDMs},
|
||||
{type: QuickSwitcherResultTypes.TEXT_CHANNEL, headerId: 'text-channels', candidates: sets.textChannels},
|
||||
{type: QuickSwitcherResultTypes.VOICE_CHANNEL, headerId: 'voice-channels', candidates: sets.voiceChannels},
|
||||
{type: QuickSwitcherResultTypes.GUILD, headerId: 'guilds', candidates: [...sets.guilds, ...sets.virtualGuilds]},
|
||||
{type: QuickSwitcherResultTypes.SETTINGS, headerId: 'settings', candidates: sets.settings},
|
||||
];
|
||||
const results: Array<QuickSwitcherResult> = [];
|
||||
for (const section of sections) {
|
||||
const matches = matchCandidates(section.candidates, search, MAX_GENERAL_RESULTS);
|
||||
if (matches.length === 0) continue;
|
||||
results.push(createHeaderResult(`section-${section.headerId}`, getHeaderTitle(section.type, i18n)));
|
||||
results.push(...matches.map((candidate) => candidateToResult(candidate, i18n)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function buildUserCandidatesWithMemberSearch(
|
||||
baseCandidates: Array<UserCandidate>,
|
||||
memberSearchResults: Array<GuildMember>,
|
||||
): Array<UserCandidate> {
|
||||
if (memberSearchResults.length === 0) {
|
||||
return baseCandidates;
|
||||
}
|
||||
const candidateMap = new Map<string, UserCandidate>();
|
||||
for (const candidate of baseCandidates) {
|
||||
candidateMap.set(candidate.user.id, candidate);
|
||||
}
|
||||
const currentUserId = Users.getCurrentUser()?.id ?? null;
|
||||
for (const member of memberSearchResults) {
|
||||
const userId = member.user.id;
|
||||
if (currentUserId && userId === currentUserId) {
|
||||
continue;
|
||||
}
|
||||
if (candidateMap.has(userId)) {
|
||||
continue;
|
||||
}
|
||||
candidateMap.set(userId, createUserCandidateFromMember(member));
|
||||
}
|
||||
return Array.from(candidateMap.values());
|
||||
}
|
||||
|
||||
function createUserCandidateFromMember(member: GuildMember): UserCandidate {
|
||||
const title = member.nick
|
||||
? NicknameUtils.formatNicknameForStreamerMode(member.nick)
|
||||
: NicknameUtils.getNickname(member.user, member.guildId);
|
||||
const subtitle = NicknameUtils.formatUserTagForStreamerMode(member.user);
|
||||
const searchValues = [title, subtitle, member.user.username, member.user.id, member.nick].filter(
|
||||
Boolean,
|
||||
) as Array<string>;
|
||||
return {
|
||||
type: QuickSwitcherResultTypes.USER,
|
||||
id: member.user.id,
|
||||
title,
|
||||
subtitle,
|
||||
user: member.user,
|
||||
dmChannelId: null,
|
||||
searchValues,
|
||||
sortWeight: member.joinedAt ? member.joinedAt.getTime() : 0,
|
||||
};
|
||||
return [header, ...ranked.map((entry) => rankedToResult(entry, i18n))];
|
||||
}
|
||||
|
||||
export function resolveTransformedMember(member: {id: string; guildIds?: Array<string>}): GuildMember | null {
|
||||
@@ -270,3 +513,5 @@ export function resolveTransformedMember(member: {id: string; guildIds?: Array<s
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type {SearchContext as QuickSwitcherSearchContext};
|
||||
@@ -7,12 +7,8 @@ import type {User} from '@app/features/user/models/User';
|
||||
import type {QuickSwitcherResultType, QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants';
|
||||
import {DAYS_PER_WEEK, MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
|
||||
|
||||
export const MAX_GENERAL_RESULTS = 5;
|
||||
export const MAX_QUERY_MODE_RESULTS = 20;
|
||||
export const MAX_RECENT_RESULTS = 8;
|
||||
export const MAX_UNREAD_RESULTS = 8;
|
||||
export const UNREAD_SORT_WEIGHT_BOOST = DAYS_PER_WEEK * MS_PER_DAY;
|
||||
export const QUICK_SWITCHER_MODAL_KEY = 'nav_quick_switcher';
|
||||
export const QUICK_SWITCHER_OVERLAY_ID = 'nav_quick_switcher';
|
||||
export const MEMBER_SEARCH_LIMIT = 25;
|
||||
|
||||
export type QuickSwitcherQueryMode =
|
||||
@@ -24,7 +20,7 @@ export type QuickSwitcherQueryMode =
|
||||
| typeof QuickSwitcherResultTypes.SETTINGS;
|
||||
|
||||
export interface ComputeResultsForQueryResult {
|
||||
queryMode: QuickSwitcherQueryMode | null;
|
||||
prefixMode: QuickSwitcherQueryMode | null;
|
||||
results: Array<QuickSwitcherResult>;
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
buildForwardSearchWeights,
|
||||
type ForwardChannelCandidate,
|
||||
type ForwardFrequentItem,
|
||||
type ForwardGroupDMCandidate,
|
||||
type ForwardGuildCandidate,
|
||||
type ForwardSearchWeights,
|
||||
type ForwardUserCandidate,
|
||||
} from '@app/features/app/components/dialogs/shared/ForwardDestinationSearch';
|
||||
import ChannelFrecency from '@app/features/channel/state/ChannelFrecency';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import Relationships from '@app/features/relationship/state/Relationships';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {compareStructural, computed, type IComputedValue} from 'mobx';
|
||||
|
||||
const NO_STRINGS: ReadonlyArray<string> = Object.freeze([]);
|
||||
|
||||
export interface ForwardSearchCandidates {
|
||||
readonly weights: ForwardSearchWeights;
|
||||
readonly channels: ReadonlyArray<ForwardChannelCandidate>;
|
||||
readonly groupDMs: ReadonlyArray<ForwardGroupDMCandidate>;
|
||||
readonly guilds: ReadonlyArray<ForwardGuildCandidate>;
|
||||
readonly users: ReadonlyArray<ForwardUserCandidate>;
|
||||
}
|
||||
|
||||
function collectGuildNicknames(): ReadonlyMap<string, Array<string>> {
|
||||
const nicknames = new Map<string, Array<string>>();
|
||||
for (const guild of Guilds.getGuilds()) {
|
||||
for (const member of GuildMembers.getMembers(guild.id)) {
|
||||
if (member.nick == null) continue;
|
||||
const userNicknames = nicknames.get(member.user.id);
|
||||
if (userNicknames === undefined) {
|
||||
nicknames.set(member.user.id, [member.nick]);
|
||||
} else {
|
||||
userNicknames.push(member.nick);
|
||||
}
|
||||
}
|
||||
}
|
||||
return nicknames;
|
||||
}
|
||||
|
||||
function buildForwardUserCandidates(): ReadonlyArray<ForwardUserCandidate> {
|
||||
const nicknames = collectGuildNicknames();
|
||||
const candidates: Array<ForwardUserCandidate> = [];
|
||||
for (const user of Users.getUsers()) {
|
||||
const relationship = Relationships.getRelationship(user.id);
|
||||
if (relationship?.type === RelationshipTypes.BLOCKED) continue;
|
||||
let friendAlias: string | null = null;
|
||||
if (relationship?.type === RelationshipTypes.FRIEND) friendAlias = relationship.nickname;
|
||||
candidates.push(
|
||||
Object.freeze({
|
||||
friendAlias,
|
||||
globalName: user.globalName,
|
||||
id: user.id,
|
||||
nicknames: nicknames.get(user.id) ?? NO_STRINGS,
|
||||
username: user.discriminator === '0' ? user.username : `${user.username}#${user.discriminator}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze(candidates);
|
||||
}
|
||||
|
||||
function collectRecipientSearchFields(recipientIds: ReadonlyArray<string>): ReadonlyArray<string> {
|
||||
const fields: Array<string> = [];
|
||||
for (const recipientId of recipientIds) {
|
||||
const recipient = Users.getUser(recipientId);
|
||||
if (recipient == null) continue;
|
||||
fields.push(recipient.username);
|
||||
if (recipient.globalName != null) fields.push(recipient.globalName);
|
||||
const relationshipNickname = Relationships.getRelationship(recipientId)?.nickname;
|
||||
if (relationshipNickname != null) fields.push(relationshipNickname);
|
||||
}
|
||||
return Object.freeze(fields);
|
||||
}
|
||||
|
||||
function buildForwardGroupDMCandidates(i18n: I18n): ReadonlyArray<ForwardGroupDMCandidate> {
|
||||
const candidates: Array<ForwardGroupDMCandidate> = [];
|
||||
for (const channel of Channels.getPrivateChannels()) {
|
||||
if (channel.type !== ChannelTypes.GROUP_DM) continue;
|
||||
candidates.push(
|
||||
Object.freeze({
|
||||
id: channel.id,
|
||||
memberFields: collectRecipientSearchFields(channel.recipientIds),
|
||||
name: ChannelUtils.getDMDisplayName(channel),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const currentUserId = Users.currentUserId;
|
||||
const personalNotes = currentUserId == null ? undefined : Channels.getChannel(currentUserId);
|
||||
if (personalNotes?.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
candidates.push(
|
||||
Object.freeze({id: personalNotes.id, memberFields: NO_STRINGS, name: i18n._(PERSONAL_NOTES_DESCRIPTOR)}),
|
||||
);
|
||||
}
|
||||
return Object.freeze(candidates);
|
||||
}
|
||||
|
||||
function buildForwardChannelCandidates(): ReadonlyArray<ForwardChannelCandidate> {
|
||||
const candidates: Array<ForwardChannelCandidate> = [];
|
||||
for (const channel of Channels.allChannels) {
|
||||
if (channel.type !== ChannelTypes.GUILD_TEXT && channel.type !== ChannelTypes.GUILD_VOICE) continue;
|
||||
const isVoice = channel.type === ChannelTypes.GUILD_VOICE;
|
||||
const requiredPermissions = isVoice ? Permissions.VIEW_CHANNEL | Permissions.CONNECT : Permissions.VIEW_CHANNEL;
|
||||
candidates.push(
|
||||
Object.freeze({
|
||||
canAccess: Permission.can(requiredPermissions, channel),
|
||||
guildName: channel.guildId == null ? null : (Guilds.getGuild(channel.guildId)?.name ?? null),
|
||||
hasFrecency: ChannelFrecency.scoreFor(channel.id) > 0,
|
||||
id: channel.id,
|
||||
kind: isVoice ? 'voice' : 'text',
|
||||
name: channel.name ?? '',
|
||||
parentName: channel.parentId == null ? null : (Channels.getChannel(channel.parentId)?.name ?? null),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze(candidates);
|
||||
}
|
||||
|
||||
function resolveForwardFrequentItem(id: string): ForwardFrequentItem {
|
||||
const score = ChannelFrecency.scoreFor(id);
|
||||
if (Guilds.getGuild(id) != null) return {id, kind: 'guild', score};
|
||||
const channel = Channels.getChannel(id);
|
||||
switch (channel?.type) {
|
||||
case ChannelTypes.DM:
|
||||
return {id, kind: 'dm', recipientId: channel.recipientIds.length > 0 ? channel.recipientIds[0] : null, score};
|
||||
case ChannelTypes.GROUP_DM:
|
||||
case ChannelTypes.DM_PERSONAL_NOTES:
|
||||
return {id, kind: 'group_dm', score};
|
||||
case ChannelTypes.GUILD_TEXT:
|
||||
return {id, kind: 'text', score};
|
||||
case ChannelTypes.GUILD_VOICE:
|
||||
return {id, kind: 'voice', score};
|
||||
default:
|
||||
return {id, kind: 'other', score};
|
||||
}
|
||||
}
|
||||
|
||||
function buildForwardSearchWeightsFromStores(): ForwardSearchWeights {
|
||||
const friendIds: Array<string> = [];
|
||||
for (const relationship of Relationships.getRelationships()) {
|
||||
if (relationship.type === RelationshipTypes.FRIEND) friendIds.push(relationship.userId);
|
||||
}
|
||||
const dmUserIds: Array<string> = [];
|
||||
for (const channel of Channels.getPrivateChannels()) {
|
||||
if (channel.type === ChannelTypes.DM && channel.recipientIds.length > 0) dmUserIds.push(channel.recipientIds[0]);
|
||||
}
|
||||
return buildForwardSearchWeights({
|
||||
dmUserIds,
|
||||
frequent: ChannelFrecency.frequentIds.map(resolveForwardFrequentItem),
|
||||
friendIds,
|
||||
});
|
||||
}
|
||||
|
||||
function buildForwardGuildCandidates(): ReadonlyArray<ForwardGuildCandidate> {
|
||||
return Object.freeze(Guilds.getGuilds().map((guild) => Object.freeze({id: guild.id, name: guild.name})));
|
||||
}
|
||||
|
||||
export function createForwardSearchCandidates(i18n: I18n): IComputedValue<ForwardSearchCandidates> {
|
||||
const options = {equals: compareStructural};
|
||||
const users = computed(buildForwardUserCandidates, options);
|
||||
const groupDMs = computed(() => buildForwardGroupDMCandidates(i18n), options);
|
||||
const channels = computed(buildForwardChannelCandidates, options);
|
||||
const guilds = computed(buildForwardGuildCandidates, options);
|
||||
const weights = computed(buildForwardSearchWeightsFromStores, options);
|
||||
return computed(() =>
|
||||
Object.freeze({
|
||||
weights: weights.get(),
|
||||
channels: channels.get(),
|
||||
groupDMs: groupDMs.get(),
|
||||
guilds: guilds.get(),
|
||||
users: users.get(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar';
|
||||
import {ChannelOriginIcon} from '@app/features/channel/components/ChannelOriginIcon';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
|
||||
import {COMMUNITIES_DESCRIPTOR, MENTION_COUNT_ARIA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
@@ -37,7 +38,7 @@ import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {ArrowRightIcon, HashIcon, HouseIcon, SpeakerHighIcon, StarIcon, UsersIcon} from '@phosphor-icons/react';
|
||||
import {ArrowRightIcon, HouseIcon, StarIcon, UsersIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
import {useEffect, useLayoutEffect, useRef} from 'react';
|
||||
@@ -131,6 +132,8 @@ export function getViewContext(result: QuickSwitcherExecutableResult): string |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const QUICK_SWITCHER_ICON_SIZE = 24;
|
||||
|
||||
export function renderIcon(
|
||||
result: QuickSwitcherExecutableResult,
|
||||
isHighlight: boolean,
|
||||
@@ -146,7 +149,7 @@ export function renderIcon(
|
||||
content: (
|
||||
<StatusAwareAvatar
|
||||
user={userResult.user}
|
||||
size={24}
|
||||
size={QUICK_SWITCHER_ICON_SIZE}
|
||||
data-flx="search.quick-switcher-modal-utils.render-icon.status-aware-avatar"
|
||||
/>
|
||||
),
|
||||
@@ -159,34 +162,27 @@ export function renderIcon(
|
||||
content: (
|
||||
<GroupDMAvatar
|
||||
channel={groupDMResult.channel}
|
||||
size={24}
|
||||
size={QUICK_SWITCHER_ICON_SIZE}
|
||||
data-flx="search.quick-switcher-modal-utils.render-icon.group-dm-avatar"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
case QuickSwitcherResultTypes.TEXT_CHANNEL:
|
||||
case QuickSwitcherResultTypes.VOICE_CHANNEL: {
|
||||
const channelResult = result as TextChannelResult | VoiceChannelResult;
|
||||
return {
|
||||
type: 'icon' as const,
|
||||
type: 'avatar' as const,
|
||||
content: (
|
||||
<HashIcon
|
||||
weight="bold"
|
||||
className={iconClass}
|
||||
data-flx="search.quick-switcher-modal-utils.render-icon.hash-icon"
|
||||
/>
|
||||
),
|
||||
};
|
||||
case QuickSwitcherResultTypes.VOICE_CHANNEL:
|
||||
return {
|
||||
type: 'icon' as const,
|
||||
content: (
|
||||
<SpeakerHighIcon
|
||||
weight="fill"
|
||||
className={iconClass}
|
||||
data-flx="search.quick-switcher-modal-utils.render-icon.speaker-high-icon"
|
||||
<ChannelOriginIcon
|
||||
channel={channelResult.channel}
|
||||
highlighted={isHighlight}
|
||||
size={QUICK_SWITCHER_ICON_SIZE}
|
||||
data-flx="search.quick-switcher-modal-utils.render-icon.channel-origin-icon"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
case QuickSwitcherResultTypes.GUILD: {
|
||||
const guildResult = result as GuildResult;
|
||||
return {
|
||||
@@ -196,7 +192,7 @@ export function renderIcon(
|
||||
id={guildResult.guild.id}
|
||||
name={guildResult.guild.name}
|
||||
icon={guildResult.guild.icon}
|
||||
sizePx={24}
|
||||
sizePx={QUICK_SWITCHER_ICON_SIZE}
|
||||
data-flx="search.quick-switcher-modal-utils.render-icon.guild-icon"
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -5,10 +5,10 @@ const COMBINING_MARKS = /[\u{300}-\u{36f}]/gu;
|
||||
const QUERY_WORD_SEPARATORS = /[, ]+/;
|
||||
|
||||
interface SearchTerm {
|
||||
readonly containQuery: RegExp;
|
||||
readonly exactQuery: RegExp;
|
||||
readonly isFullMatch: boolean;
|
||||
readonly queryLower: string;
|
||||
readonly substringPattern: RegExp;
|
||||
readonly prefixPattern: RegExp;
|
||||
readonly spansWholeQuery: boolean;
|
||||
readonly loweredText: string;
|
||||
}
|
||||
|
||||
let loadedUnicodeConfusables: ReadonlyMap<string, string> | null = null;
|
||||
@@ -42,11 +42,11 @@ export function fuzzySearch(needle: string, haystack: string): boolean {
|
||||
}
|
||||
|
||||
export function scoreSearchTerm(value: string, term: SearchTerm, fuzzy = true): number {
|
||||
if (term.exactQuery.test(value)) return value.toLocaleLowerCase() === term.queryLower ? 10 : 7;
|
||||
if (term.containQuery.test(value)) return 5;
|
||||
const words = term.queryLower.split(QUERY_WORD_SEPARATORS);
|
||||
if (term.prefixPattern.test(value)) return value.toLocaleLowerCase() === term.loweredText ? 10 : 7;
|
||||
if (term.substringPattern.test(value)) return 5;
|
||||
const words = term.loweredText.split(QUERY_WORD_SEPARATORS);
|
||||
if (words.every((word) => new RegExp(escapeSearchPattern(word), 'i').test(value))) return 3;
|
||||
if (fuzzy && fuzzySearch(term.queryLower, value)) return 1;
|
||||
if (fuzzy && fuzzySearch(term.loweredText, value)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function consumeBestSearchTerm(value: string, terms: Array<SearchTerm>, f
|
||||
bestIndex = index;
|
||||
}
|
||||
if (bestIndex === -1) return 0;
|
||||
if (terms[bestIndex].isFullMatch) {
|
||||
if (terms[bestIndex].spansWholeQuery) {
|
||||
terms.length = 0;
|
||||
} else {
|
||||
terms.splice(bestIndex, 1);
|
||||
@@ -68,17 +68,17 @@ export function consumeBestSearchTerm(value: string, terms: Array<SearchTerm>, f
|
||||
return bestScore;
|
||||
}
|
||||
|
||||
function buildSearchTerm(queryLower: string, pattern: string, isFullMatch: boolean): SearchTerm {
|
||||
function buildSearchTerm(loweredText: string, pattern: string, spansWholeQuery: boolean): SearchTerm {
|
||||
return Object.freeze({
|
||||
containQuery: new RegExp(pattern, 'i'),
|
||||
exactQuery: new RegExp(`^${pattern}`, 'i'),
|
||||
isFullMatch,
|
||||
queryLower,
|
||||
substringPattern: new RegExp(pattern, 'i'),
|
||||
prefixPattern: new RegExp(`^${pattern}`, 'i'),
|
||||
spansWholeQuery,
|
||||
loweredText,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSearchTerm(queryLower: string): SearchTerm {
|
||||
return buildSearchTerm(queryLower, escapeSearchPattern(queryLower), false);
|
||||
export function createSearchTerm(loweredText: string): SearchTerm {
|
||||
return buildSearchTerm(loweredText, escapeSearchPattern(loweredText), false);
|
||||
}
|
||||
|
||||
export function buildChannelSearchTerms(query: string): ReadonlyArray<SearchTerm> {
|
||||
@@ -87,8 +87,8 @@ export function buildChannelSearchTerms(query: string): ReadonlyArray<SearchTerm
|
||||
.filter((token) => token !== '')
|
||||
.map((token) => createSearchTerm(token.toLocaleLowerCase()));
|
||||
if (query.includes(' ')) {
|
||||
const queryLower = query.toLocaleLowerCase();
|
||||
terms.unshift(buildSearchTerm(queryLower, escapeSearchPattern(queryLower).replace(' ', '( |-)'), true));
|
||||
const loweredText = query.toLocaleLowerCase();
|
||||
terms.unshift(buildSearchTerm(loweredText, escapeSearchPattern(loweredText).replace(' ', '( |-)'), true));
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user