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

@@ -29,7 +29,7 @@ interface ForwardOriginChannel {
interface BuildForwardDefaultDestinationsRequest {
readonly frequentIds: ReadonlyArray<string>;
readonly history: ReadonlyArray<string>;
readonly isValid: (row: ForwardRowIdentity) => boolean;
readonly accepts: (row: ForwardRowIdentity) => boolean;
readonly mode: ForwardResultType | null;
readonly origin: ForwardDestination | null;
readonly pinned: ReadonlyArray<ForwardDestination>;
@@ -51,7 +51,7 @@ export function resolveForwardOrigin(
export function buildForwardDefaultDestinations({
frequentIds,
history,
isValid,
accepts,
mode,
origin,
pinned,
@@ -64,7 +64,7 @@ export function buildForwardDefaultDestinations({
...history.slice(0, HISTORY_LIMIT).map((channelId) => resolveChannel(channelId)),
...frequentIds.slice(0, FREQUENT_LIMIT).map((channelId) => resolveChannel(channelId)),
];
const rows = candidates.filter((row): row is ForwardRowIdentity => row != null && isValid(row));
const rows = candidates.filter((row): row is ForwardRowIdentity => row != null && accepts(row));
const originSelected =
origin != null && selected.some((destination) => destination.type === origin.type && destination.id === origin.id);
const hiddenIds = origin == null || originSelected ? [] : [origin.id];
@@ -79,10 +79,10 @@ export function buildForwardDefaultDestinations({
export function filterForwardSearchRows(
results: ReadonlyArray<ForwardRowIdentity>,
isValid: (row: ForwardRowIdentity) => boolean,
accepts: (row: ForwardRowIdentity) => boolean,
): ReadonlyArray<ForwardDestinationRow> {
return dedupeRows(
results.filter((result) => isValid(result)),
results.filter((result) => accepts(result)),
[],
);
}
@@ -5,7 +5,7 @@ export type ForwardResultType = 'user' | 'text_channel' | 'voice_channel' | 'gro
export interface ForwardDestinationQuery {
readonly mode: ForwardResultType | null;
readonly query: string;
readonly resultTypes: ReadonlyArray<ForwardResultType>;
readonly kinds: ReadonlyArray<ForwardResultType>;
}
const FORWARD_RESULT_TYPES: ReadonlyArray<ForwardResultType> = Object.freeze([
@@ -24,10 +24,10 @@ const SIGIL_PATTERN = /^@|#|!|\*|\$/;
export function parseForwardDestinationQuery(text: string): ForwardDestinationQuery {
if (text.startsWith(GLOBAL_USER_SIGIL)) {
return Object.freeze({mode: null, query: text.slice(GLOBAL_USER_SIGIL.length), resultTypes: FORWARD_RESULT_TYPES});
return Object.freeze({mode: null, query: text.slice(GLOBAL_USER_SIGIL.length), kinds: FORWARD_RESULT_TYPES});
}
const query = text.replace(SIGIL_PATTERN, '');
const mode = SIGIL_RESULT_TYPES.get(text.charAt(0));
if (mode === undefined) return Object.freeze({mode: null, query, resultTypes: FORWARD_RESULT_TYPES});
return Object.freeze({mode, query, resultTypes: Object.freeze([mode])});
if (mode === undefined) return Object.freeze({mode: null, query, kinds: FORWARD_RESULT_TYPES});
return Object.freeze({mode, query, kinds: Object.freeze([mode])});
}
@@ -21,11 +21,12 @@ const CHANNEL_CONTEXT_SCORE_CAP = 6;
const VOICE_IN_TEXT_SEARCH_PENALTY = 1;
const VOICE_IN_TEXT_SEARCH_FLOOR = 0.5;
const CHANNEL_FRECENCY_BONUS = 3;
const SNOWFLAKE_SCORE = 10;
type ForwardChannelKind = 'text' | 'voice';
export type ForwardChannelKind = 'text' | 'voice';
export interface ForwardUserCandidate {
readonly friendNickname: string | null;
readonly friendAlias: string | null;
readonly globalName: string | null;
readonly id: string;
readonly nicknames: ReadonlyArray<string>;
@@ -48,69 +49,79 @@ export interface ForwardChannelCandidate {
readonly parentName: string | null;
}
export interface ForwardGuildCandidate {
readonly id: string;
readonly name: string;
}
export interface ForwardFrequentItem {
readonly id: string;
readonly kind: 'dm' | 'group_dm' | 'text' | 'voice' | 'other';
readonly kind: 'dm' | 'group_dm' | 'guild' | 'text' | 'voice' | 'other';
readonly recipientId?: string | null;
readonly score: number;
}
export interface ForwardSearchBoosters {
export interface ForwardSearchWeights {
readonly groupDMs: ReadonlyMap<string, number>;
readonly textChannels: ReadonlyMap<string, number>;
readonly guilds: ReadonlyMap<string, number>;
readonly textChannel: ReadonlyMap<string, number>;
readonly users: ReadonlyMap<string, number>;
readonly voiceChannels: ReadonlyMap<string, number>;
readonly voiceChannel: ReadonlyMap<string, number>;
}
export interface ForwardSearchResult {
readonly comparator: string;
readonly matchedText: string;
readonly id: string;
readonly score: number;
readonly type: ForwardResultType;
}
interface BuildForwardSearchBoostersRequest {
interface BuildForwardSearchWeightsRequest {
readonly dmUserIds: Iterable<string>;
readonly frequent: ReadonlyArray<ForwardFrequentItem>;
readonly friendIds: Iterable<string>;
}
interface SearchForwardDestinationsRequest {
readonly blacklist: ReadonlySet<string>;
readonly boosters: ForwardSearchBoosters;
readonly excludedIds: ReadonlySet<string>;
readonly weights: ForwardSearchWeights;
readonly channels: ReadonlyArray<ForwardChannelCandidate>;
readonly confusables: ReadonlyMap<string, string>;
readonly groupDMs: ReadonlyArray<ForwardGroupDMCandidate>;
readonly limit: number;
readonly query: string;
readonly resultTypes: ReadonlyArray<ForwardResultType>;
readonly kinds: ReadonlyArray<ForwardResultType>;
readonly users: ReadonlyArray<ForwardUserCandidate>;
}
export function buildForwardSearchBoosters({
export function buildForwardSearchWeights({
dmUserIds,
frequent,
friendIds,
}: BuildForwardSearchBoostersRequest): ForwardSearchBoosters {
}: BuildForwardSearchWeightsRequest): ForwardSearchWeights {
const maxScore = frequent.reduce((max, item) => Math.max(max, item.score), 0);
const users = new Map<string, number>();
const groupDMs = new Map<string, number>();
const textChannels = new Map<string, number>();
const voiceChannels = new Map<string, number>();
const guilds = new Map<string, number>();
const textChannel = new Map<string, number>();
const voiceChannel = new Map<string, number>();
for (const item of frequent) {
const boost = maxScore > 0 ? 1 + item.score / maxScore : 1;
const weight = maxScore > 0 ? 1 + item.score / maxScore : 1;
switch (item.kind) {
case 'dm':
if (item.recipientId != null) users.set(item.recipientId, boost);
if (item.recipientId != null) users.set(item.recipientId, weight);
break;
case 'group_dm':
groupDMs.set(item.id, boost);
groupDMs.set(item.id, weight);
break;
case 'guild':
guilds.set(item.id, weight);
break;
case 'text':
textChannels.set(item.id, boost);
textChannel.set(item.id, weight);
break;
case 'voice':
voiceChannels.set(item.id, boost);
voiceChannel.set(item.id, weight);
break;
case 'other':
break;
@@ -118,31 +129,27 @@ export function buildForwardSearchBoosters({
}
for (const friendId of friendIds) users.set(friendId, (users.get(friendId) ?? 1) + FRIEND_BOOST);
for (const userId of dmUserIds) users.set(userId, (users.get(userId) ?? 1) + OPEN_DM_BOOST);
return Object.freeze({groupDMs, textChannels, users, voiceChannels});
return Object.freeze({groupDMs, guilds, textChannel, users, voiceChannel});
}
export function searchForwardDestinations(
request: SearchForwardDestinationsRequest,
): ReadonlyArray<ForwardSearchResult> {
const {boosters, channels, confusables, limit, query, resultTypes} = request;
const {weights, channels, confusables, limit, query, kinds} = request;
if (query.trim() === '') return [];
const results = [
...(resultTypes.includes('user')
? searchUsers(query, request.users, boosters.users, request.blacklist, confusables, limit)
...(kinds.includes('user')
? searchUsers(query, request.users, weights.users, request.excludedIds, confusables, limit)
: []),
...(resultTypes.includes('group_dm')
? searchGroupDMs(query, request.groupDMs, boosters.groupDMs, confusables, limit)
: []),
...(resultTypes.includes('text_channel')
? searchChannels(query, channels, 'text', boosters.textChannels, limit)
: []),
...(resultTypes.includes('voice_channel')
? searchChannels(query, channels, 'voice', boosters.voiceChannels, limit)
...(kinds.includes('group_dm')
? searchGroupDMs(query, request.groupDMs, weights.groupDMs, confusables, limit)
: []),
...(kinds.includes('text_channel') ? searchChannels(query, channels, 'text', weights.textChannel, limit) : []),
...(kinds.includes('voice_channel') ? searchChannels(query, channels, 'voice', weights.voiceChannel, limit) : []),
];
const seenKeys = new Set<string>();
const merged = results.filter((result) => {
const key = `${result.type}-${result.id}`;
const key = `${result.type}|${result.id}`;
if (seenKeys.has(key)) return false;
seenKeys.add(key);
return true;
@@ -150,10 +157,10 @@ export function searchForwardDestinations(
return merged.sort(compareSearchResults);
}
function compareSearchResults(left: ForwardSearchResult, right: ForwardSearchResult): number {
export function compareSearchResults(left: ForwardSearchResult, right: ForwardSearchResult): number {
if (left.score === right.score && left.type === 'user') {
const leftName = left.comparator.toLocaleLowerCase();
const rightName = right.comparator.toLocaleLowerCase();
const leftName = left.matchedText.toLocaleLowerCase();
const rightName = right.matchedText.toLocaleLowerCase();
if (leftName < rightName) return -1;
if (leftName > rightName) return 1;
}
@@ -164,69 +171,69 @@ function sortAndLimit(results: Array<ForwardSearchResult>, limit: number): Array
return results.sort(compareSearchResults).slice(0, limit);
}
function searchUsers(
export function searchUsers(
query: string,
users: ReadonlyArray<ForwardUserCandidate>,
boosters: ReadonlyMap<string, number>,
blacklist: ReadonlySet<string>,
weights: ReadonlyMap<string, number>,
excludedIds: ReadonlySet<string>,
confusables: ReadonlyMap<string, string>,
limit: number,
): Array<ForwardSearchResult> {
const escapedQuery = escapeSearchPattern(query);
const prefixQuery = new RegExp(`^${escapedQuery}`, 'i');
const containQuery = new RegExp(escapedQuery, 'i');
const queryLower = query.toLocaleLowerCase();
const querySkeleton = toConfusableSkeleton(queryLower, confusables);
const substringPattern = new RegExp(escapedQuery, 'i');
const loweredText = query.toLocaleLowerCase();
const querySkeleton = toConfusableSkeleton(loweredText, confusables);
const scoreField = (field: string): number => {
if (prefixQuery.test(field)) return 10;
if (containQuery.test(field)) return 5;
if (substringPattern.test(field)) return 5;
const stripped = stripCombiningMarks(field.toLocaleLowerCase());
if (fuzzySearch(queryLower, stripped)) return 1;
if (fuzzySearch(loweredText, stripped)) return 1;
if (fuzzySearch(querySkeleton, toConfusableSkeleton(stripped, confusables))) return 1;
return 0;
};
const matches: Array<ForwardSearchResult> = [];
for (const user of users) {
if (blacklist.has(user.id)) continue;
const booster = boosters.get(user.id) ?? 1;
if (excludedIds.has(user.id)) continue;
const booster = weights.get(user.id) ?? 1;
if (user.id === query) {
matches.push({comparator: user.id, id: user.id, score: 10 * booster, type: 'user'});
matches.push({matchedText: user.id, id: user.id, score: 10 * booster, type: 'user'});
continue;
}
let best: ForwardSearchResult | null = null;
for (const field of [user.username, user.friendNickname, user.globalName, ...user.nicknames]) {
for (const field of [user.username, user.friendAlias, user.globalName, ...user.nicknames]) {
if (field == null) continue;
const score = scoreField(field) * booster;
if (score === 0 || (best != null && best.score >= score)) continue;
best = {comparator: field, id: user.id, score, type: 'user'};
best = {matchedText: field, id: user.id, score, type: 'user'};
}
if (best != null) matches.push(best);
}
return sortAndLimit(matches, limit).map((match) => Object.freeze({...match, score: SCORE_SCALE * match.score}));
}
function searchGroupDMs(
export function searchGroupDMs(
query: string,
groupDMs: ReadonlyArray<ForwardGroupDMCandidate>,
boosters: ReadonlyMap<string, number>,
weights: ReadonlyMap<string, number>,
confusables: ReadonlyMap<string, string>,
limit: number,
): Array<ForwardSearchResult> {
const normalize = (text: string): string =>
const foldText = (text: string): string =>
stripCombiningMarks(toConfusableSkeleton(text.toLocaleLowerCase(), confusables));
const term = createSearchTerm(normalize(query));
const term = createSearchTerm(foldText(query));
const results: Array<ForwardSearchResult> = [];
for (const groupDM of groupDMs) {
let score = scoreSearchTerm(normalize(groupDM.name), term);
let score = scoreSearchTerm(foldText(groupDM.name), term);
for (const field of groupDM.memberFields) {
score = Math.max(score, Math.min(GROUP_DM_MEMBER_SCORE_CAP, scoreSearchTerm(normalize(field), term)));
score = Math.max(score, Math.min(GROUP_DM_MEMBER_SCORE_CAP, scoreSearchTerm(foldText(field), term)));
}
if (score === 0) continue;
results.push(
Object.freeze({
comparator: groupDM.name,
matchedText: groupDM.name,
id: groupDM.id,
score: SCORE_SCALE * score * (boosters.get(groupDM.id) ?? 1),
score: SCORE_SCALE * score * (weights.get(groupDM.id) ?? 1),
type: 'group_dm',
}),
);
@@ -234,12 +241,13 @@ function searchGroupDMs(
return sortAndLimit(results, limit);
}
function searchChannels(
export function searchChannels(
query: string,
channels: ReadonlyArray<ForwardChannelCandidate>,
searchKind: ForwardChannelKind,
boosters: ReadonlyMap<string, number>,
weights: ReadonlyMap<string, number>,
limit: number,
matchExactIds = false,
): Array<ForwardSearchResult> {
const terms = buildChannelSearchTerms(query);
const results: Array<ForwardSearchResult> = [];
@@ -247,29 +255,64 @@ function searchChannels(
if (searchKind === 'voice' && channel.kind !== 'voice') continue;
if (!channel.canAccess) continue;
const remainingTerms = [...terms];
let score = consumeBestSearchTerm(channel.name.toLocaleLowerCase(), remainingTerms, true);
const isSnowflakeMatch = matchExactIds && channel.id === query;
let score = isSnowflakeMatch
? SNOWFLAKE_SCORE
: consumeBestSearchTerm(channel.name.toLocaleLowerCase(), remainingTerms, true);
if (score === 0) continue;
if (remainingTerms.length > 0) {
if (!isSnowflakeMatch && remainingTerms.length > 0) {
for (const context of [channel.guildName, channel.parentName]) {
if (context == null || context === '') continue;
score += CHANNEL_CONTEXT_WEIGHT * consumeBestSearchTerm(context.toLocaleLowerCase(), remainingTerms, false);
}
score = Math.min(CHANNEL_CONTEXT_SCORE_CAP, score);
}
if (remainingTerms.length > 1) continue;
if (remainingTerms.length === 1 && !remainingTerms[0].isFullMatch) continue;
if (!isSnowflakeMatch && remainingTerms.length > 1) continue;
if (!isSnowflakeMatch && remainingTerms.length === 1 && !remainingTerms[0].spansWholeQuery) continue;
if (searchKind === 'text' && channel.kind === 'voice') {
score = Math.max(score - VOICE_IN_TEXT_SEARCH_PENALTY, VOICE_IN_TEXT_SEARCH_FLOOR);
}
score = Math.min(score + (channel.hasFrecency ? CHANNEL_FRECENCY_BONUS : 0), score >= 7 ? 10 : 7);
results.push(
Object.freeze({
comparator: channel.name,
matchedText: channel.name,
id: channel.id,
score: SCORE_SCALE * score * (boosters.get(channel.id) ?? 1),
score: SCORE_SCALE * score * (weights.get(channel.id) ?? 1),
type: channel.kind === 'voice' ? 'voice_channel' : 'text_channel',
}),
);
}
return sortAndLimit(results, limit);
}
export interface GuildSearchResult {
readonly matchedText: string;
readonly id: string;
readonly score: number;
}
export function searchGuilds(
query: string,
guilds: ReadonlyArray<ForwardGuildCandidate>,
weights: ReadonlyMap<string, number>,
excludedIds: ReadonlySet<string>,
limit: number,
matchExactIds = false,
): Array<GuildSearchResult> {
const term = createSearchTerm(query.toLocaleLowerCase());
const results: Array<GuildSearchResult> = [];
for (const guild of guilds) {
if (excludedIds.has(guild.id)) continue;
const score =
matchExactIds && guild.id === query ? SNOWFLAKE_SCORE : scoreSearchTerm(guild.name.toLocaleLowerCase(), term);
if (score === 0) continue;
results.push(
Object.freeze({
matchedText: guild.name,
id: guild.id,
score: SCORE_SCALE * score * (weights.get(guild.id) ?? 1),
}),
);
}
return results.sort((left, right) => right.score - left.score).slice(0, limit);
}
@@ -10,7 +10,7 @@ export interface ForwardPinnedDestinations {
}
export function forwardDestinationKey(destination: ForwardDestination): string {
return `${destination.type}:${destination.id}`;
return `${destination.type}/${destination.id}`;
}
export function toggleForwardDestination(
@@ -9,13 +9,7 @@ import {
} from '@app/features/app/components/dialogs/shared/ForwardDefaultDestinations';
import {parseForwardDestinationQuery} from '@app/features/app/components/dialogs/shared/ForwardDestinationQuery';
import {
buildForwardSearchBoosters,
type ForwardChannelCandidate,
type ForwardFrequentItem,
type ForwardGroupDMCandidate,
type ForwardSearchBoosters,
type ForwardSearchResult,
type ForwardUserCandidate,
searchForwardDestinations,
} from '@app/features/app/components/dialogs/shared/ForwardDestinationSearch';
import {
@@ -33,7 +27,6 @@ import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import type {Guild} from '@app/features/guild/models/Guild';
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 type {ForwardMediaSelection} from '@app/features/messaging/commands/MessageCommands';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
@@ -41,7 +34,7 @@ import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import Permission from '@app/features/permissions/state/Permission';
import {formatPermissionLabel} from '@app/features/permissions/utils/PermissionUtils';
import {Logger} from '@app/features/platform/utils/AppLogger';
import Relationships from '@app/features/relationship/state/Relationships';
import {createForwardSearchCandidates} from '@app/features/search/utils/DestinationSearchSources';
import {getLoadedUnicodeConfusables, loadUnicodeConfusables} from '@app/features/search/utils/SearchTextMatching';
import Slowmode from '@app/features/slowmode/state/Slowmode';
import {useNow} from '@app/features/ui/state/Tick';
@@ -51,13 +44,11 @@ import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildNSFWLevel, GuildOperations} from '@fluxer/constants/src/GuildConstants';
import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import type {MessageEmbed} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {compareStructural, computed, type IComputedValue} from 'mobx';
import {useEffect, useMemo, useState} from 'react';
const GUILD_MESSAGES_DISABLED_DESCRIPTOR = msg({
@@ -110,13 +101,6 @@ interface ForwardMediaNeeds {
readonly hasEmbeds: boolean;
}
interface ForwardSearchCandidates {
readonly boosters: ForwardSearchBoosters;
readonly channels: ReadonlyArray<ForwardChannelCandidate>;
readonly groupDMs: ReadonlyArray<ForwardGroupDMCandidate>;
readonly users: ReadonlyArray<ForwardUserCandidate>;
}
export interface ForwardDestinationOption {
readonly channel: Channel | null;
readonly destination: ForwardDestination;
@@ -137,10 +121,10 @@ interface UseForwardDestinationsOptions {
interface ForwardDestinationsState {
readonly composerChannel: Channel | null;
readonly options: ReadonlyArray<ForwardDestinationOption>;
readonly searchQuery: string;
readonly filterText: string;
readonly selected: ReadonlyArray<ForwardDestination>;
readonly selectedKeys: ReadonlySet<string>;
readonly setSearchQuery: (query: string) => void;
readonly setFilterText: (query: string) => void;
readonly slowmodeActiveSelectedOptions: ReadonlyArray<ForwardDestinationOption>;
readonly slowmodeEnabledSelectedOptions: ReadonlyArray<ForwardDestinationOption>;
readonly toggleDestination: (destination: ForwardDestination) => void;
@@ -197,144 +181,6 @@ function isForwardRowValid(row: ForwardRowIdentity): boolean {
}
}
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 friendNickname: string | null = null;
if (relationship?.type === RelationshipTypes.FRIEND) friendNickname = relationship.nickname;
candidates.push(
Object.freeze({
friendNickname,
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 accessPermissions = isVoice ? Permissions.VIEW_CHANNEL | Permissions.CONNECT : Permissions.VIEW_CHANNEL;
candidates.push(
Object.freeze({
canAccess: Permission.can(accessPermissions, channel),
guildName: channel.guildId == null ? null : (Guilds.getGuild(channel.guildId)?.name ?? null),
hasFrecency: ChannelFrecency.getScore(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.getScore(id);
const channel = Guilds.getGuild(id) == null ? Channels.getChannel(id) : undefined;
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 buildForwardSearchBoostersFromStores(): ForwardSearchBoosters {
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 buildForwardSearchBoosters({
dmUserIds,
frequent: ChannelFrecency.frequentIds.map(resolveForwardFrequentItem),
friendIds,
});
}
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 boosters = computed(buildForwardSearchBoostersFromStores, options);
return computed(() =>
Object.freeze({boosters: boosters.get(), channels: channels.get(), groupDMs: groupDMs.get(), users: users.get()}),
);
}
function selectForwardedAttachments(
message: Message,
mediaSelection: ForwardMediaSelection | undefined,
@@ -531,16 +377,16 @@ export function useForwardDestinations({
message,
}: UseForwardDestinationsOptions): ForwardDestinationsState {
const {i18n} = useLingui();
const [searchQuery, setSearchQuery] = useState('');
const [filterText, setFilterText] = useState('');
const [selected, setSelected] = useState(NO_DESTINATIONS);
const [pinnedDestinations, setPinnedDestinations] = useState(INITIAL_PINNED_DESTINATIONS);
const [stickyPicks, setStickyPicks] = useState(INITIAL_PINNED_DESTINATIONS);
const [confusables, setConfusables] = useState(() => getLoadedUnicodeConfusables() ?? NO_CONFUSABLES);
const searchCandidates = useMemo(() => createForwardSearchCandidates(i18n), [i18n, i18n.locale]);
const mediaNeeds = useMemo(() => resolveForwardMediaNeeds(message, mediaSelection), [message, mediaSelection]);
const parsedQuery = useMemo(() => parseForwardDestinationQuery(searchQuery), [searchQuery]);
const parsedQuery = useMemo(() => parseForwardDestinationQuery(filterText), [filterText]);
const engineQuery = parsedQuery.query.trim() === '' ? '' : parsedQuery.query;
const currentPinned = pinForwardDestinations(pinnedDestinations, engineQuery, selected);
if (currentPinned !== pinnedDestinations) setPinnedDestinations(currentPinned);
const currentPinned = pinForwardDestinations(stickyPicks, engineQuery, selected);
if (currentPinned !== stickyPicks) setStickyPicks(currentPinned);
useEffect(() => {
let isMounted = true;
loadUnicodeConfusables().then(
@@ -559,11 +405,11 @@ export function useForwardDestinations({
if (candidates == null) return NO_SEARCH_RESULTS;
return searchForwardDestinations({
...candidates,
blacklist: new Set(currentUserId == null ? NO_STRINGS : [currentUserId]),
excludedIds: new Set(currentUserId == null ? NO_STRINGS : [currentUserId]),
confusables,
limit: parsedQuery.resultTypes.length === 1 ? SINGLE_TYPE_SEARCH_LIMIT : SEARCH_LIMIT,
limit: parsedQuery.kinds.length === 1 ? SINGLE_TYPE_SEARCH_LIMIT : SEARCH_LIMIT,
query: engineQuery,
resultTypes: parsedQuery.resultTypes,
kinds: parsedQuery.kinds,
});
}, [candidates, confusables, currentUserId, engineQuery, parsedQuery]);
const rows =
@@ -571,7 +417,7 @@ export function useForwardDestinations({
? buildForwardDefaultDestinations({
frequentIds: ChannelFrecency.frequentIds,
history: [...new Set(SelectedChannel.sortedRecentVisits.map((visit) => visit.channelId))],
isValid: isForwardRowValid,
accepts: isForwardRowValid,
mode: parsedQuery.mode,
origin: resolveForwardOrigin(message.channelId, Channels.getChannel(message.channelId)),
pinned: currentPinned.pinned,
@@ -590,16 +436,16 @@ export function useForwardDestinations({
const toggleDestination = (destination: ForwardDestination) => {
const next = toggleForwardDestination(selected, destination);
if (next === selected) return;
if (next.length > selected.length) setSearchQuery('');
if (next.length > selected.length) setFilterText('');
setSelected(next);
};
return {
composerChannel: resolveForwardComposerChannel(selected),
options,
searchQuery,
filterText,
selected,
selectedKeys: new Set(selected.map(forwardDestinationKey)),
setSearchQuery,
setFilterText,
slowmodeActiveSelectedOptions: selectedOptions.filter(isSlowmodeActive),
slowmodeEnabledSelectedOptions: selectedOptions.filter((option) => option.slowmodeEnabled),
toggleDestination,