feat(app): rework quick switcher ranking and show origin icons (#2953)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-25 13:59:25 +02:00
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;
}
@@ -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;
}