fluxer/fluxer_app/src/stores/QuickSwitcherStore.tsx
2026-02-21 16:41:56 +00:00

1388 lines
41 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as ThemePreferenceActionCreators from '@app/actions/ThemePreferenceActionCreators';
import {
getSettingsSubtabs,
getSettingsTabs,
type SettingsSubtab,
type SettingsTab,
} from '@app/components/modals/utils/SettingsConstants';
import {QuickSwitcherModal} from '@app/components/quick_switcher/QuickSwitcherModal';
import {Logger} from '@app/lib/Logger';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import type {GuildMemberRecord} from '@app/records/GuildMemberRecord';
import type {GuildRecord} from '@app/records/GuildRecord';
import type {UserRecord} from '@app/records/UserRecord';
import AccessibilityStore from '@app/stores/AccessibilityStore';
import ChannelStore from '@app/stores/ChannelStore';
import DeveloperModeStore from '@app/stores/DeveloperModeStore';
import FavoritesStore from '@app/stores/FavoritesStore';
import GuildMemberStore from '@app/stores/GuildMemberStore';
import GuildStore from '@app/stores/GuildStore';
import MemberSearchStore, {type SearchContext, type TransformedMember} from '@app/stores/MemberSearchStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import NavigationStore from '@app/stores/NavigationStore';
import ReadStateStore from '@app/stores/ReadStateStore';
import RelationshipStore from '@app/stores/RelationshipStore';
import SelectedChannelStore from '@app/stores/SelectedChannelStore';
import SelectedGuildStore from '@app/stores/SelectedGuildStore';
import ThemeStore from '@app/stores/ThemeStore';
import UserSettingsStore from '@app/stores/UserSettingsStore';
import UserStore from '@app/stores/UserStore';
import * as ChannelUtils from '@app/utils/ChannelUtils';
import {parseChannelUrl} from '@app/utils/DeepLinkUtils';
import * as NicknameUtils from '@app/utils/NicknameUtils';
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {QuickSwitcherResultType} from '@fluxer/constants/src/QuickSwitcherConstants';
import {QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants';
import {RelationshipTypes, ThemeTypes} from '@fluxer/constants/src/UserConstants';
import {DAYS_PER_WEEK, MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {matchSorter, rankings} from 'match-sorter';
import {action, makeAutoObservable, reaction, runInAction} from 'mobx';
const MAX_GENERAL_RESULTS = 5;
const MAX_QUERY_MODE_RESULTS = 20;
const MAX_RECENT_RESULTS = 8;
const MAX_UNREAD_RESULTS = 8;
const UNREAD_SORT_WEIGHT_BOOST = DAYS_PER_WEEK * MS_PER_DAY;
const QUICK_SWITCHER_MODAL_KEY = 'quick_switcher';
const MEMBER_SEARCH_LIMIT = 25;
type QuickSwitcherQueryMode =
| typeof QuickSwitcherResultTypes.USER
| typeof QuickSwitcherResultTypes.TEXT_CHANNEL
| typeof QuickSwitcherResultTypes.VOICE_CHANNEL
| typeof QuickSwitcherResultTypes.GUILD
| typeof QuickSwitcherResultTypes.VIRTUAL_GUILD
| typeof QuickSwitcherResultTypes.SETTINGS
| typeof QuickSwitcherResultTypes.QUICK_ACTION
| typeof QuickSwitcherResultTypes.LINK;
interface ComputeResultsForQueryResult {
queryMode: QuickSwitcherQueryMode | null;
results: Array<QuickSwitcherResult>;
selectedIndex: number;
}
export interface HeaderResult {
type: typeof QuickSwitcherResultTypes.HEADER;
id: string;
title: string;
}
export interface UserResult {
type: typeof QuickSwitcherResultTypes.USER;
id: string;
title: string;
subtitle?: string;
user: UserRecord;
dmChannelId: string | null;
viewContext?: string;
}
export interface GroupDMResult {
type: typeof QuickSwitcherResultTypes.GROUP_DM;
id: string;
title: string;
subtitle?: string;
channel: ChannelRecord;
viewContext?: string;
}
export interface TextChannelResult {
type: typeof QuickSwitcherResultTypes.TEXT_CHANNEL;
id: string;
title: string;
subtitle?: string;
channel: ChannelRecord;
guild: GuildRecord | null;
viewContext?: string;
}
export interface VoiceChannelResult {
type: typeof QuickSwitcherResultTypes.VOICE_CHANNEL;
id: string;
title: string;
subtitle?: string;
channel: ChannelRecord;
guild: GuildRecord | null;
viewContext?: string;
}
export interface GuildResult {
type: typeof QuickSwitcherResultTypes.GUILD;
id: string;
title: string;
subtitle?: string;
guild: GuildRecord;
}
export interface VirtualGuildResult {
type: typeof QuickSwitcherResultTypes.VIRTUAL_GUILD;
id: string;
title: string;
subtitle?: string;
virtualGuildType: 'favorites' | 'home';
}
export interface SettingsResult {
type: typeof QuickSwitcherResultTypes.SETTINGS;
id: string;
title: string;
subtitle?: string;
settingsTab: SettingsTab;
settingsSubtab?: SettingsSubtab;
}
export interface QuickActionResult {
type: typeof QuickSwitcherResultTypes.QUICK_ACTION;
id: string;
title: string;
subtitle?: string;
action: () => void;
}
export interface LinkResult {
type: typeof QuickSwitcherResultTypes.LINK;
id: string;
title: string;
subtitle?: string;
path: string;
}
export type QuickSwitcherResult =
| HeaderResult
| UserResult
| GroupDMResult
| TextChannelResult
| VoiceChannelResult
| GuildResult
| VirtualGuildResult
| SettingsResult
| QuickActionResult
| LinkResult;
export type QuickSwitcherExecutableResult = Exclude<QuickSwitcherResult, HeaderResult>;
interface CandidateBase<T extends QuickSwitcherResultType> {
type: T;
id: string;
title: string;
subtitle?: string;
searchValues: Array<string>;
sortWeight: number;
}
export interface UserCandidate extends CandidateBase<typeof QuickSwitcherResultTypes.USER> {
user: UserRecord;
dmChannelId: string | null;
}
export interface GroupDMCandidate extends CandidateBase<typeof QuickSwitcherResultTypes.GROUP_DM> {
channel: ChannelRecord;
}
export interface TextChannelCandidate extends CandidateBase<typeof QuickSwitcherResultTypes.TEXT_CHANNEL> {
channel: ChannelRecord;
guild: GuildRecord | null;
}
export interface VoiceChannelCandidate extends CandidateBase<typeof QuickSwitcherResultTypes.VOICE_CHANNEL> {
channel: ChannelRecord;
guild: GuildRecord | null;
}
export interface GuildCandidate extends CandidateBase<typeof QuickSwitcherResultTypes.GUILD> {
guild: GuildRecord;
}
export interface VirtualGuildCandidate extends CandidateBase<typeof QuickSwitcherResultTypes.VIRTUAL_GUILD> {
virtualGuildType: 'favorites' | 'home';
}
export interface SettingsCandidate extends CandidateBase<typeof QuickSwitcherResultTypes.SETTINGS> {
settingsTab: SettingsTab;
settingsSubtab?: SettingsSubtab;
}
export interface QuickActionCandidate extends CandidateBase<typeof QuickSwitcherResultTypes.QUICK_ACTION> {
action: () => void;
}
export type Candidate =
| UserCandidate
| GroupDMCandidate
| TextChannelCandidate
| VoiceChannelCandidate
| GuildCandidate
| VirtualGuildCandidate
| SettingsCandidate
| QuickActionCandidate;
export interface CandidateSets {
users: Array<UserCandidate>;
userByChannelId: Map<string, UserCandidate>;
groupDMs: Array<GroupDMCandidate>;
groupDMByChannelId: Map<string, GroupDMCandidate>;
textChannels: Array<TextChannelCandidate>;
voiceChannels: Array<VoiceChannelCandidate>;
guilds: Array<GuildCandidate>;
virtualGuilds: Array<VirtualGuildCandidate>;
settings: Array<SettingsCandidate>;
quickActions: Array<QuickActionCandidate>;
channelById: Map<string, TextChannelCandidate | VoiceChannelCandidate>;
}
class QuickSwitcherStore {
private logger = new Logger('QuickSwitcherStore');
isOpen = false;
query = '';
queryMode: QuickSwitcherQueryMode | null = null;
results: Array<QuickSwitcherResult> = [];
selectedIndex = -1;
private memberSearchContext: SearchContext | null = null;
private memberFetchDebounceTimer: NodeJS.Timeout | null = null;
private memberSearchResults: Array<GuildMemberRecord> = [];
private i18n: I18n | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
reaction(
() => SelectedChannelStore.recentChannelVisits,
() => {
if (this.isOpen) {
this.recomputeIfOpen();
}
},
);
reaction(
() => [NavigationStore.guildId, NavigationStore.channelId],
() => {
if (this.isOpen) {
this.recomputeIfOpen();
}
},
);
}
setI18n(i18n: I18n): void {
this.i18n = i18n;
}
getIsOpen(): boolean {
return this.isOpen;
}
getResults(): ReadonlyArray<QuickSwitcherResult> {
return this.results;
}
getSelectedResult(): QuickSwitcherExecutableResult | null {
if (this.selectedIndex < 0 || this.selectedIndex >= this.results.length) {
return null;
}
const result = this.results[this.selectedIndex];
if (result.type === QuickSwitcherResultTypes.HEADER) {
return null;
}
return result;
}
findNextSelectableIndex(direction: 'up' | 'down', startIndex?: number): number {
if (this.results.length === 0) return -1;
let index = startIndex ?? this.selectedIndex;
const step = direction === 'down' ? 1 : -1;
for (let i = 0; i < this.results.length; i += 1) {
index += step;
if (index < 0) index = this.results.length - 1;
if (index >= this.results.length) index = 0;
if (this.results[index].type !== QuickSwitcherResultTypes.HEADER) {
return index;
}
}
return this.selectedIndex;
}
@action
show(): void {
if (this.isOpen) return;
this.isOpen = true;
this.query = '';
this.queryMode = null;
try {
const {results, selectedIndex} = this.computeResultsForQuery('');
this.results = results;
this.selectedIndex = selectedIndex;
} catch (error) {
this.logger.error('Quick switcher failed to precompute results', error);
this.results = [];
this.selectedIndex = -1;
}
if (!MobileLayoutStore.isMobileLayout()) {
void this.pushModal();
}
}
private pushModal(): void {
ModalActionCreators.pushWithKey(
modal(() => <QuickSwitcherModal />),
QUICK_SWITCHER_MODAL_KEY,
);
}
@action
hide(): void {
if (!this.isOpen) {
return;
}
this.isOpen = false;
this.query = '';
this.queryMode = null;
this.results = [];
this.selectedIndex = -1;
if (this.memberSearchContext) {
this.memberSearchContext.destroy();
this.memberSearchContext = null;
}
if (this.memberFetchDebounceTimer) {
clearTimeout(this.memberFetchDebounceTimer);
this.memberFetchDebounceTimer = null;
}
this.memberSearchResults = [];
if (!MobileLayoutStore.isMobileLayout()) {
ModalActionCreators.popWithKey(QUICK_SWITCHER_MODAL_KEY);
}
}
@action
search(query: string): void {
if (!this.isOpen && query.length === 0) {
return;
}
const {queryMode, results, selectedIndex} = this.computeResultsForQuery(query);
this.query = query;
this.queryMode = queryMode;
this.results = results;
this.selectedIndex = selectedIndex;
this.triggerMemberSearchIfNeeded(query, queryMode);
}
private triggerMemberSearchIfNeeded(query: string, queryMode: QuickSwitcherQueryMode | null): void {
if (queryMode !== QuickSwitcherResultTypes.USER) {
if (this.memberSearchContext) {
this.memberSearchContext.destroy();
this.memberSearchContext = null;
}
if (this.memberFetchDebounceTimer) {
clearTimeout(this.memberFetchDebounceTimer);
this.memberFetchDebounceTimer = null;
}
this.memberSearchResults = [];
return;
}
const rawSearch = query['slice'](1).trim();
if (rawSearch.length === 0) {
if (this.memberSearchContext) {
this.memberSearchContext.clearQuery();
}
if (this.memberFetchDebounceTimer) {
clearTimeout(this.memberFetchDebounceTimer);
this.memberFetchDebounceTimer = null;
}
this.memberSearchResults = [];
return;
}
if (!this.memberSearchContext) {
this.memberSearchContext = MemberSearchStore.getSearchContext((results) => {
const guildMemberRecords: Array<GuildMemberRecord> = results
.map((transformed) => this.resolveTransformedMember(transformed))
.filter((member): member is GuildMemberRecord => member !== null);
runInAction(() => {
this.memberSearchResults = guildMemberRecords;
if (this.isOpen && this.queryMode === QuickSwitcherResultTypes.USER) {
this.recomputeIfOpen();
}
});
}, MEMBER_SEARCH_LIMIT);
}
this.memberSearchContext.setQuery(rawSearch);
if (this.memberFetchDebounceTimer) {
clearTimeout(this.memberFetchDebounceTimer);
}
const currentChannelId = SelectedChannelStore.currentChannelId;
const currentChannel = currentChannelId ? ChannelStore.getChannel(currentChannelId) : null;
const guildId = currentChannel?.guildId ?? null;
const allGuilds = GuildStore.getGuilds();
const guildsToFetch = allGuilds
.filter((guild) => !GuildMemberStore.isGuildFullyLoaded(guild.id))
.map((guild) => guild.id);
if (guildsToFetch.length === 0) {
this.memberFetchDebounceTimer = null;
return;
}
this.memberFetchDebounceTimer = setTimeout(() => {
void MemberSearchStore.fetchMembersInBackground(rawSearch, guildsToFetch, guildId ?? undefined);
this.memberFetchDebounceTimer = null;
}, 300);
}
@action
select(selectedIndex: number): void {
if (!this.isOpen) {
return;
}
if (selectedIndex < 0) {
this.selectedIndex = -1;
return;
}
if (selectedIndex >= this.results.length) {
return;
}
const result = this.results[selectedIndex];
if (result.type === QuickSwitcherResultTypes.HEADER) {
this.selectedIndex = -1;
return;
}
this.selectedIndex = selectedIndex;
}
recomputeIfOpen(): void {
if (!this.isOpen || !this.i18n) {
return;
}
const {queryMode, results, selectedIndex} = this.computeResultsForQuery(this.query);
this.queryMode = queryMode;
this.results = results;
this.selectedIndex = selectedIndex;
}
private computeResultsForQuery(query: string): ComputeResultsForQueryResult {
const sets = this.buildCandidateSets();
const channelPath = parseChannelUrl(query);
if (channelPath) {
if (!this.i18n) {
return {
queryMode: null,
results: [],
selectedIndex: -1,
};
}
const linkResult: LinkResult = {
type: QuickSwitcherResultTypes.LINK,
id: 'link-jump',
title: this.i18n._(msg`Go to message`),
subtitle: query,
path: channelPath,
};
return {
queryMode: null,
results: [linkResult],
selectedIndex: 0,
};
}
if (query['trim']().length === 0) {
const results = this.generateDefaultResults(sets);
return {
queryMode: null,
results,
selectedIndex: this.getFirstSelectableIndex(results),
};
}
const queryMode = this.getQueryMode(query);
const rawSearch = queryMode ? query['slice'](1) : query;
const trimmedSearch = rawSearch.trim();
let results: Array<QuickSwitcherResult>;
if (queryMode) {
results = this.generateQueryModeResults(queryMode, trimmedSearch, sets);
} else if (trimmedSearch.length === 0) {
results = this.generateDefaultResults(sets);
} else {
results = this.generateGeneralResults(trimmedSearch, sets);
}
return {
queryMode,
results,
selectedIndex: this.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;
case '>':
return QuickSwitcherResultTypes.QUICK_ACTION;
default:
return null;
}
}
private buildCandidateSets(): CandidateSets {
if (!this.i18n) {
return {
users: [],
userByChannelId: new Map(),
groupDMs: [],
groupDMByChannelId: new Map(),
textChannels: [],
voiceChannels: [],
guilds: [],
virtualGuilds: [],
settings: [],
quickActions: [],
channelById: new Map(),
};
}
const guilds = GuildStore.getGuilds();
const guildMap = new Map<string, GuildRecord>(guilds.map((guild) => [guild.id, guild]));
const userCandidates = new Map<string, UserCandidate>();
const userByChannelId = new Map<string, UserCandidate>();
const groupDMCandidates: Array<GroupDMCandidate> = [];
const groupDMByChannelId = new Map<string, GroupDMCandidate>();
const textChannelCandidates: Array<TextChannelCandidate> = [];
const voiceChannelCandidates: Array<VoiceChannelCandidate> = [];
const channelById = new Map<string, TextChannelCandidate | VoiceChannelCandidate>();
const currentUserId = UserStore.getCurrentUser()?.id ?? null;
for (const channel of ChannelStore.allChannels) {
switch (channel.type) {
case ChannelTypes.DM:
case ChannelTypes.DM_PERSONAL_NOTES: {
const recipientId =
channel.recipientIds.find((recipientId) => recipientId !== currentUserId) ?? channel.recipientIds.at(0);
if (!recipientId) break;
const user = UserStore.getUser(recipientId);
if (!user) break;
const title = ChannelUtils.getDMDisplayName(channel);
const subtitle = user.tag;
const searchValues = [title, subtitle, user.username, user.id].filter(Boolean);
const baseWeight = this.getChannelRecency(channel);
const sortWeight = this.getChannelSortWeight(channel.id, baseWeight);
const existing = userCandidates.get(user.id);
const candidate: UserCandidate = {
type: QuickSwitcherResultTypes.USER,
id: user.id,
title,
subtitle,
user,
dmChannelId: channel.id,
searchValues,
sortWeight,
};
if (!existing || existing.sortWeight < candidate.sortWeight) {
userCandidates.set(user.id, candidate);
} else if (existing.dmChannelId == null) {
userCandidates.set(user.id, {
...existing,
dmChannelId: channel.id,
sortWeight: Math.max(existing.sortWeight, sortWeight),
});
}
const resolvedCandidate = userCandidates.get(user.id);
if (resolvedCandidate) {
userByChannelId.set(channel.id, resolvedCandidate);
}
break;
}
case ChannelTypes.GROUP_DM: {
const title = ChannelUtils.getDMDisplayName(channel);
const participantNames = channel.recipientIds
.map((recipientId) => {
const user = UserStore.getUser(recipientId);
return user ? NicknameUtils.getNickname(user) : null;
})
.filter(Boolean) as Array<string>;
const subtitle = participantNames.length > 0 ? participantNames.join(', ') : this.i18n._(msg`Group message`);
const searchValues = [title, ...participantNames];
const baseWeight = this.getChannelRecency(channel);
const sortWeight = this.getChannelSortWeight(channel.id, baseWeight);
const candidate: GroupDMCandidate = {
type: QuickSwitcherResultTypes.GROUP_DM,
id: channel.id,
title,
subtitle,
channel,
searchValues,
sortWeight,
};
groupDMCandidates.push(candidate);
groupDMByChannelId.set(channel.id, candidate);
break;
}
case ChannelTypes.GUILD_TEXT: {
if (!channel.guildId) break;
const guild = guildMap.get(channel.guildId) ?? null;
const title = channel.name ? channel.name : this.i18n._(msg`Unknown channel`);
const subtitle = guild?.name;
const searchValues = [
channel.name ?? '',
channel.topic ?? '',
guild?.name ?? '',
channel.parentId ?? '',
].filter(Boolean);
const baseWeight = this.getChannelRecency(channel);
const sortWeight = this.getChannelSortWeight(channel.id, baseWeight);
const candidate: TextChannelCandidate = {
type: QuickSwitcherResultTypes.TEXT_CHANNEL,
id: channel.id,
title,
subtitle,
channel,
guild,
searchValues,
sortWeight,
};
textChannelCandidates.push(candidate);
channelById.set(channel.id, candidate);
break;
}
case ChannelTypes.GUILD_VOICE: {
if (!channel.guildId) break;
const guild = guildMap.get(channel.guildId) ?? null;
const title = channel.name ?? this.i18n._(msg`Voice channel`);
const subtitle = guild?.name;
const searchValues = [channel.name ?? '', guild?.name ?? ''].filter(Boolean);
const baseWeight = this.getChannelRecency(channel);
const sortWeight = this.getChannelSortWeight(channel.id, baseWeight);
const candidate: VoiceChannelCandidate = {
type: QuickSwitcherResultTypes.VOICE_CHANNEL,
id: channel.id,
title,
subtitle,
channel,
guild,
searchValues,
sortWeight,
};
voiceChannelCandidates.push(candidate);
channelById.set(channel.id, candidate);
break;
}
default:
break;
}
}
for (const relationship of RelationshipStore.getRelationships()) {
if (relationship.type !== RelationshipTypes.FRIEND) {
continue;
}
const user = relationship.user;
if (!userCandidates.has(user.id)) {
const title = NicknameUtils.getNickname(user);
const subtitle = user.tag;
const searchValues = [title, subtitle, user.username, user.id].filter(Boolean);
userCandidates.set(user.id, {
type: QuickSwitcherResultTypes.USER,
id: user.id,
title,
subtitle,
user,
dmChannelId: null,
searchValues,
sortWeight: relationship.since.getTime(),
});
}
}
for (const user of UserStore.getUsers()) {
if (user.id === currentUserId) continue;
if (!userCandidates.has(user.id)) {
const title = NicknameUtils.getNickname(user);
const subtitle = user.tag;
const searchValues = [title, subtitle, user.username, user.id].filter(Boolean);
userCandidates.set(user.id, {
type: QuickSwitcherResultTypes.USER,
id: user.id,
title,
subtitle,
user,
dmChannelId: null,
searchValues,
sortWeight: SnowflakeUtils.extractTimestamp(user.id),
});
}
}
const selectedGuildId = SelectedGuildStore.selectedGuildId;
if (selectedGuildId) {
const guildMembers = GuildMemberStore.getMembers(selectedGuildId);
for (const member of guildMembers) {
if (member.user.id === currentUserId) continue;
if (!userCandidates.has(member.user.id)) {
const title = member.nick ?? NicknameUtils.getNickname(member.user);
const subtitle = member.user.tag;
const searchValues = [title, subtitle, member.user.username, member.user.id, member.nick].filter(
Boolean,
) as Array<string>;
userCandidates.set(member.user.id, {
type: QuickSwitcherResultTypes.USER,
id: member.user.id,
title,
subtitle,
user: member.user,
dmChannelId: null,
searchValues,
sortWeight: member.joinedAt ? new Date(member.joinedAt).getTime() : 0,
});
}
}
}
const guildCandidates: Array<GuildCandidate> = guilds.map((guild) => ({
type: QuickSwitcherResultTypes.GUILD,
id: guild.id,
title: guild.name,
subtitle: undefined,
guild,
searchValues: [guild.name, guild.vanityURLCode ?? '', guild.id].filter(Boolean),
sortWeight: guild.joinedAt ? new Date(guild.joinedAt).getTime() : 0,
}));
const virtualGuildCandidates: Array<VirtualGuildCandidate> = [];
virtualGuildCandidates.push({
type: QuickSwitcherResultTypes.VIRTUAL_GUILD,
id: 'home',
title: this.i18n._(msg`Home`),
subtitle: this.i18n._(msg`Direct Messages`),
virtualGuildType: 'home',
searchValues: ['Home', 'DM', 'DMs', 'Direct Messages', 'Messages'],
sortWeight: Date.now(),
});
if (FavoritesStore.hasAnyFavorites) {
virtualGuildCandidates.push({
type: QuickSwitcherResultTypes.VIRTUAL_GUILD,
id: 'favorites',
title: this.i18n._(msg`Favorites`),
subtitle: undefined,
virtualGuildType: 'favorites',
searchValues: ['Favorites', 'Fav', 'Starred', FAVORITES_GUILD_ID],
sortWeight: Date.now(),
});
}
const settingsCandidates: Array<SettingsCandidate> = [];
const hasExpressionPackAccess = UserStore.getCurrentUser()?.isStaff() ?? false;
const accessibleTabs = getSettingsTabs(this.i18n!).filter((tab) => {
if (!DeveloperModeStore.isDeveloper && tab.category === 'staff_only') {
return false;
}
if (!hasExpressionPackAccess && tab.type === 'expression_packs') {
return false;
}
return true;
});
for (const tab of accessibleTabs) {
settingsCandidates.push({
type: QuickSwitcherResultTypes.SETTINGS,
id: tab.type,
title: tab.label,
subtitle: undefined,
settingsTab: tab,
settingsSubtab: undefined,
searchValues: [tab.label, 'settings', 'preferences', tab.type],
sortWeight: 0,
});
}
for (const subtab of getSettingsSubtabs(this.i18n!)) {
const parentTab = accessibleTabs.find((t) => t.type === subtab.parentTab);
if (!parentTab) continue;
settingsCandidates.push({
type: QuickSwitcherResultTypes.SETTINGS,
id: `${subtab.parentTab}_${subtab.type}`,
title: subtab.label,
subtitle: parentTab.label,
settingsTab: parentTab,
settingsSubtab: subtab,
searchValues: [subtab.label, parentTab.label, 'settings', subtab.type],
sortWeight: 0,
});
}
const quickActionCandidates: Array<QuickActionCandidate> = [];
quickActionCandidates.push({
type: QuickSwitcherResultTypes.QUICK_ACTION,
id: 'toggle_theme',
title: this.i18n._(msg`Toggle Theme`),
subtitle: this.i18n._(msg`Switch between Light and Dark mode`),
action: () => {
const currentTheme = ThemeStore.effectiveTheme;
const newTheme = currentTheme === ThemeTypes.DARK ? ThemeTypes.LIGHT : ThemeTypes.DARK;
ThemePreferenceActionCreators.updateThemePreference(newTheme);
},
searchValues: ['theme', 'light', 'dark', 'mode', 'switch', 'toggle'],
sortWeight: 0,
});
quickActionCandidates.push({
type: QuickSwitcherResultTypes.QUICK_ACTION,
id: 'toggle_compact_mode',
title: this.i18n._(msg`Toggle Compact Mode`),
subtitle: UserSettingsStore.getMessageDisplayCompact()
? this.i18n._(msg`Disable Compact Mode`)
: this.i18n._(msg`Enable Compact Mode`),
action: () => {
UserSettingsStore.saveSettings({
messageDisplayCompact: !UserSettingsStore.getMessageDisplayCompact(),
});
},
searchValues: ['compact', 'mode', 'display', 'message', 'toggle'],
sortWeight: 0,
});
quickActionCandidates.push({
type: QuickSwitcherResultTypes.QUICK_ACTION,
id: 'toggle_reduced_motion',
title: this.i18n._(msg`Toggle Reduced Motion`),
subtitle: AccessibilityStore.useReducedMotion
? this.i18n._(msg`Disable Reduced Motion`)
: this.i18n._(msg`Enable Reduced Motion`),
action: () => {
AccessibilityStore.updateSettings({
reducedMotionOverride: !AccessibilityStore.useReducedMotion,
syncReducedMotionWithSystem: false,
});
},
searchValues: ['reduced', 'motion', 'animation', 'toggle'],
sortWeight: 0,
});
return {
users: Array.from(userCandidates.values()),
userByChannelId,
groupDMs: groupDMCandidates,
groupDMByChannelId,
textChannels: textChannelCandidates,
voiceChannels: voiceChannelCandidates,
guilds: guildCandidates,
virtualGuilds: virtualGuildCandidates,
settings: settingsCandidates,
quickActions: quickActionCandidates,
channelById,
};
}
private getExcludedChannelIds(): Set<string> {
const excluded = new Set<string>();
const currentChannelId = this.getCurrentChannelId();
if (!currentChannelId) return excluded;
excluded.add(currentChannelId);
const currentChannel = ChannelStore.getChannel(currentChannelId);
if (currentChannel?.parentId) {
excluded.add(currentChannel.parentId);
}
return excluded;
}
private getCurrentChannelId(): string | null {
return NavigationStore.channelId ?? SelectedChannelStore.currentChannelId;
}
private generateDefaultResults(sets: CandidateSets): Array<QuickSwitcherResult> {
if (!this.i18n) {
return [];
}
const recentVisits = SelectedChannelStore.recentChannelVisits;
const excludedIds = this.getExcludedChannelIds();
const recentEntries: Array<{channelId: string; result: QuickSwitcherExecutableResult}> = [];
for (const visit of recentVisits) {
if (excludedIds.has(visit.channelId)) continue;
const channel = ChannelStore.getChannel(visit.channelId);
if (!channel) continue;
const result = this.createResultFromChannel(channel, sets, visit.guildId);
if (result) {
recentEntries.push({channelId: visit.channelId, result});
}
}
const recentSlicedEntries = recentEntries.slice(0, MAX_RECENT_RESULTS);
const recentSliced = recentSlicedEntries.map(({result}) => result);
const recentChannelIds = new Set(recentSlicedEntries.map(({channelId}) => channelId));
const unreadResults = this.generateUnreadResults(sets, recentChannelIds);
return [...recentSliced, ...unreadResults];
}
private generateUnreadResults(
sets: CandidateSets,
additionalExcludedChannelIds: ReadonlySet<string>,
): Array<QuickSwitcherExecutableResult> {
const excludedIds = this.getExcludedChannelIds();
const unreadChannels = ChannelStore.allChannels
.filter((channel) => {
if (excludedIds.has(channel.id) || additionalExcludedChannelIds.has(channel.id)) {
return false;
}
const unreadCount = ReadStateStore.getUnreadCount(channel.id);
const mentionCount = ReadStateStore.getMentionCount(channel.id);
return unreadCount > 0 || mentionCount > 0;
})
.sort((a, b) => this.getChannelRecency(b) - this.getChannelRecency(a))
.slice(0, MAX_UNREAD_RESULTS);
const results: Array<QuickSwitcherExecutableResult> = [];
for (const channel of unreadChannels) {
const result = this.createResultFromChannel(channel, sets);
if (result) {
results.push(result);
}
}
return results;
}
private generateQueryModeResults(
queryMode: QuickSwitcherQueryMode,
search: string,
sets: CandidateSets,
): Array<QuickSwitcherResult> {
let candidates: Array<Candidate>;
switch (queryMode) {
case QuickSwitcherResultTypes.USER:
candidates = this.buildUserCandidatesWithMemberSearch(sets.users);
break;
case QuickSwitcherResultTypes.TEXT_CHANNEL:
candidates = sets.textChannels;
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;
break;
case QuickSwitcherResultTypes.QUICK_ACTION:
candidates = sets.quickActions;
break;
default:
candidates = [];
}
if (
search.length === 0 &&
(queryMode === QuickSwitcherResultTypes.TEXT_CHANNEL || queryMode === QuickSwitcherResultTypes.VOICE_CHANNEL)
) {
const excludedIds = this.getExcludedChannelIds();
candidates = candidates.filter((c) => !excludedIds.has(c.id));
}
const matches = this.matchCandidates(candidates, search, MAX_QUERY_MODE_RESULTS);
if (matches.length === 0) {
return [];
}
return [
this.createHeaderResult(`query-${queryMode}`, this.getHeaderTitle(queryMode)),
...matches.map((c) => this.candidateToResult(c)),
];
}
private generateGeneralResults(search: string, sets: CandidateSets): Array<QuickSwitcherResult> {
const sections: Array<{type: QuickSwitcherResultType; 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},
{type: QuickSwitcherResultTypes.QUICK_ACTION, headerId: 'quick-actions', candidates: sets.quickActions},
];
const results: Array<QuickSwitcherResult> = [];
for (const section of sections) {
const matches = this.matchCandidates(section.candidates, search, MAX_GENERAL_RESULTS);
if (matches.length === 0) continue;
results.push(this.createHeaderResult(`section-${section.headerId}`, this.getHeaderTitle(section.type)));
results.push(...matches.map((candidate) => this.candidateToResult(candidate)));
}
return results;
}
private buildUserCandidatesWithMemberSearch(baseCandidates: Array<UserCandidate>): Array<UserCandidate> {
if (this.memberSearchResults.length === 0) {
return baseCandidates;
}
const candidateMap = new Map<string, UserCandidate>();
for (const candidate of baseCandidates) {
candidateMap.set(candidate.user.id, candidate);
}
const currentUserId = UserStore.getCurrentUser()?.id ?? null;
for (const member of this.memberSearchResults) {
const userId = member.user.id;
if (currentUserId && userId === currentUserId) {
continue;
}
if (candidateMap.has(userId)) {
continue;
}
candidateMap.set(userId, this.createUserCandidateFromMember(member));
}
return Array.from(candidateMap.values());
}
private createUserCandidateFromMember(member: GuildMemberRecord): UserCandidate {
const title = member.nick ?? NicknameUtils.getNickname(member.user);
const subtitle = member.user.tag;
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,
};
}
private resolveTransformedMember(member: TransformedMember): GuildMemberRecord | null {
const guildIds = member.guildIds ?? [];
for (const guildId of guildIds) {
const record = GuildMemberStore.getMember(guildId, member.id);
if (record) {
return record;
}
}
for (const guild of GuildStore.getGuilds()) {
const record = GuildMemberStore.getMember(guild.id, member.id);
if (record) {
return record;
}
}
return null;
}
private matchCandidates<T extends Candidate>(candidates: Array<T>, search: string, limit: number): Array<T> {
if (candidates.length === 0) {
return [];
}
if (search.length === 0) {
return this.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);
}
private 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);
});
}
private createResultFromChannel(
channel: ChannelRecord,
sets: CandidateSets,
viewContext?: string,
): QuickSwitcherExecutableResult | null {
switch (channel.type) {
case ChannelTypes.DM:
case ChannelTypes.DM_PERSONAL_NOTES: {
const candidate = sets.userByChannelId.get(channel.id);
return candidate ? this.candidateToResult(candidate, viewContext) : null;
}
case ChannelTypes.GROUP_DM: {
const candidate = sets.groupDMByChannelId.get(channel.id);
return candidate ? this.candidateToResult(candidate, viewContext) : null;
}
case ChannelTypes.GUILD_TEXT: {
const candidate = sets.channelById.get(channel.id);
if (candidate && candidate.type === QuickSwitcherResultTypes.TEXT_CHANNEL) {
return this.candidateToResult(candidate, viewContext);
}
return null;
}
case ChannelTypes.GUILD_VOICE: {
const candidate = sets.channelById.get(channel.id);
if (candidate && candidate.type === QuickSwitcherResultTypes.VOICE_CHANNEL) {
return this.candidateToResult(candidate, viewContext);
}
return null;
}
default:
return null;
}
}
private candidateToResult(candidate: Candidate, viewContext?: string): QuickSwitcherExecutableResult {
switch (candidate.type) {
case QuickSwitcherResultTypes.USER:
return {
type: QuickSwitcherResultTypes.USER,
id: candidate.id,
title: candidate.title,
subtitle: candidate.subtitle,
user: candidate.user,
dmChannelId: candidate.dmChannelId,
viewContext,
};
case QuickSwitcherResultTypes.GROUP_DM:
return {
type: QuickSwitcherResultTypes.GROUP_DM,
id: candidate.id,
title: candidate.title,
subtitle: candidate.subtitle,
channel: candidate.channel,
viewContext,
};
case QuickSwitcherResultTypes.TEXT_CHANNEL: {
const isFavoritesContext = viewContext === FAVORITES_GUILD_ID;
return {
type: QuickSwitcherResultTypes.TEXT_CHANNEL,
id: candidate.id,
title: candidate.title,
subtitle: isFavoritesContext ? (this.i18n?._(msg`Favorites`) ?? 'Favorites') : candidate.subtitle,
channel: candidate.channel,
guild: candidate.guild,
viewContext,
};
}
case QuickSwitcherResultTypes.VOICE_CHANNEL: {
const isFavoritesContext = viewContext === FAVORITES_GUILD_ID;
return {
type: QuickSwitcherResultTypes.VOICE_CHANNEL,
id: candidate.id,
title: candidate.title,
subtitle: isFavoritesContext ? (this.i18n?._(msg`Favorites`) ?? 'Favorites') : candidate.subtitle,
channel: candidate.channel,
guild: candidate.guild,
viewContext,
};
}
case QuickSwitcherResultTypes.GUILD:
return {
type: QuickSwitcherResultTypes.GUILD,
id: candidate.id,
title: candidate.title,
subtitle: candidate.subtitle,
guild: candidate.guild,
};
case QuickSwitcherResultTypes.VIRTUAL_GUILD:
return {
type: QuickSwitcherResultTypes.VIRTUAL_GUILD,
id: candidate.id,
title: candidate.title,
subtitle: candidate.subtitle,
virtualGuildType: candidate.virtualGuildType,
};
case QuickSwitcherResultTypes.SETTINGS:
return {
type: QuickSwitcherResultTypes.SETTINGS,
id: candidate.id,
title: candidate.title,
subtitle: candidate.subtitle,
settingsTab: candidate.settingsTab,
settingsSubtab: candidate.settingsSubtab,
};
case QuickSwitcherResultTypes.QUICK_ACTION:
return {
type: QuickSwitcherResultTypes.QUICK_ACTION,
id: candidate.id,
title: candidate.title,
subtitle: candidate.subtitle,
action: candidate.action,
};
default:
return candidate as never;
}
}
private createHeaderResult(id: string, title: string): HeaderResult {
return {type: QuickSwitcherResultTypes.HEADER, id, title};
}
private getHeaderTitle(type: QuickSwitcherResultType): string {
if (!this.i18n) {
switch (type) {
case QuickSwitcherResultTypes.USER:
return 'People';
case QuickSwitcherResultTypes.GROUP_DM:
return 'Group messages';
case QuickSwitcherResultTypes.TEXT_CHANNEL:
return 'Text channels';
case QuickSwitcherResultTypes.VOICE_CHANNEL:
return 'Voice channels';
case QuickSwitcherResultTypes.GUILD:
case QuickSwitcherResultTypes.VIRTUAL_GUILD:
return 'Communities';
case QuickSwitcherResultTypes.SETTINGS:
return 'Settings';
case QuickSwitcherResultTypes.QUICK_ACTION:
return 'Quick Actions';
default:
return '';
}
}
switch (type) {
case QuickSwitcherResultTypes.USER:
return this.i18n._(msg`People`);
case QuickSwitcherResultTypes.GROUP_DM:
return this.i18n._(msg`Group messages`);
case QuickSwitcherResultTypes.TEXT_CHANNEL:
return this.i18n._(msg`Text channels`);
case QuickSwitcherResultTypes.VOICE_CHANNEL:
return this.i18n._(msg`Voice channels`);
case QuickSwitcherResultTypes.GUILD:
case QuickSwitcherResultTypes.VIRTUAL_GUILD:
return this.i18n._(msg`Communities`);
case QuickSwitcherResultTypes.SETTINGS:
return this.i18n._(msg`Settings`);
case QuickSwitcherResultTypes.QUICK_ACTION:
return this.i18n._(msg`Quick Actions`);
default:
return '';
}
}
private getFirstSelectableIndex(results: ReadonlyArray<QuickSwitcherResult>): number {
for (let i = 0; i < results.length; i += 1) {
if (results[i].type !== QuickSwitcherResultTypes.HEADER) {
return i;
}
}
return -1;
}
private getChannelRecency(channel: ChannelRecord): number {
if (channel.lastMessageId) {
return SnowflakeUtils.extractTimestamp(channel.lastMessageId);
}
return SnowflakeUtils.extractTimestamp(channel.id);
}
private getChannelSortWeight(channelId: string, baseWeight: number): number {
const unreadCount = ReadStateStore.getUnreadCount(channelId);
const mentionCount = ReadStateStore.getMentionCount(channelId);
const hasUnread = unreadCount > 0 || mentionCount > 0;
return hasUnread ? baseWeight + UNREAD_SORT_WEIGHT_BOOST : baseWeight;
}
}
export default new QuickSwitcherStore();