mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(app): rework quick switcher ranking and show origin icons (#2953)
This commit is contained in:
1 parent
6730a242db
commit
1c36a59b2c
58 files changed
+2854
-1703
No files matched your search
@@ -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])});
|
||||
}
|
||||
+108
-65
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user