fix(search): respect scope in channel suggestions

This commit is contained in:
Hampus
2026-08-26 23:06:07 +02:00
committed by GitHub
parent c504b68354
commit 5015452280
6 changed files with 155 additions and 50 deletions
@@ -14,9 +14,9 @@ import {msg} from '@lingui/core/macro';
import type {IconProps} from '@phosphor-icons/react';
import {
ChatCenteredDotsIcon,
ChatCircleIcon,
ClockClockwiseIcon,
ClockCounterClockwiseIcon,
EnvelopeSimpleIcon,
GlobeIcon,
HashIcon,
SparkleIcon,
@@ -162,7 +162,7 @@ export const getSortModeOptions = (i18n: I18n): Array<{mode: ChannelSearchSortMo
];
const SCOPE_ICON_COMPONENTS: Record<MessageSearchScope, React.ComponentType<IconProps>> = {
current: HashIcon,
all_dms: EnvelopeSimpleIcon,
all_dms: ChatCircleIcon,
open_dms: ChatCenteredDotsIcon,
all_guilds: GlobeIcon,
all: UsersIcon,
@@ -2,9 +2,9 @@
import {AutocompleteOption} from '@app/features/channel/components/message_search_bar/AutocompleteOption';
import styles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
import {resolveChannelSuggestionDisplayName} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
import type {Channel} from '@app/features/channel/models/Channel';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -72,7 +72,7 @@ export const ChannelsSection: React.FC<ChannelsSectionProps> = observer(
className={styles.channelName}
data-flx="channel.message-search-bar.channels-section.channel-name"
>
{resolveSearchChannelDisplayName(channelOption) || 'Unnamed Channel'}
{resolveChannelSuggestionDisplayName(channelOption) || 'Unnamed Channel'}
</span>
</div>
</div>
@@ -5,25 +5,34 @@ import type {
HistoryFilterRow,
} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
import type {Channel} from '@app/features/channel/models/Channel';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import type {Guild} from '@app/features/guild/models/Guild';
import Guilds from '@app/features/guild/state/Guilds';
import type {GuildMember} from '@app/features/member/models/GuildMember';
import GuildMembers from '@app/features/member/state/GuildMembers';
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager';
import type {MessageSearchScope, SearchFilterOption} from '@app/features/search/utils/SearchUtils';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {IconProps} from '@phosphor-icons/react';
import {ChatCenteredDotsIcon, EnvelopeSimpleIcon, GlobeIcon, HashIcon, UsersIcon} from '@phosphor-icons/react';
import {ChatCenteredDotsIcon, ChatCircleIcon, GlobeIcon, HashIcon, UsersIcon} from '@phosphor-icons/react';
export const SCOPE_ICON_COMPONENTS: Record<MessageSearchScope, React.ComponentType<IconProps>> = {
current: HashIcon,
all_dms: EnvelopeSimpleIcon,
all_dms: ChatCircleIcon,
open_dms: ChatCenteredDotsIcon,
all_guilds: GlobeIcon,
all: UsersIcon,
open_dms_and_all_guilds: UsersIcon,
};
export function resolveChannelSuggestionDisplayName(channel: Channel): string {
if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return ChannelUtils.getDMDisplayName(channel);
}
return resolveSearchChannelDisplayName(channel);
}
export function filterRequiresValue(filter: SearchFilterOption): boolean {
return Boolean(filter.requiresValue) || (filter.values?.length ?? 0) > 0;
}
@@ -274,6 +283,41 @@ export function isUserFilterKey(filterKey: string): boolean {
}
}
export type DmChannelSuggestionMode = 'none' | 'current' | 'open' | 'all';
export type GuildChannelSuggestionMode = 'none' | 'current_guild' | 'all_guilds';
export interface ChannelSuggestionSearchPlan {
dmMode: DmChannelSuggestionMode;
guildMode: GuildChannelSuggestionMode;
currentGuildId?: string;
}
export function getChannelSuggestionSearchPlan(
scope: MessageSearchScope,
currentGuildId: string | undefined,
): ChannelSuggestionSearchPlan {
switch (scope) {
case 'current':
return currentGuildId
? {dmMode: 'none', guildMode: 'current_guild', currentGuildId}
: {dmMode: 'current', guildMode: 'none'};
case 'all_dms':
return {dmMode: 'all', guildMode: 'none'};
case 'open_dms':
return {dmMode: 'open', guildMode: 'none'};
case 'all':
return {dmMode: 'all', guildMode: 'all_guilds'};
case 'open_dms_and_all_guilds':
return {dmMode: 'open', guildMode: 'all_guilds'};
case 'all_guilds':
return {dmMode: 'none', guildMode: 'all_guilds'};
default: {
const exhaustiveScope: never = scope;
throw new Error(`Unsupported message search scope: ${exhaustiveScope}`);
}
}
}
export type GuildSearchMode = 'none' | 'current_guild' | 'all_guilds';
export interface UserGuildSearchPlan {
@@ -4,8 +4,8 @@ import {AutocompleteOption} from '@app/features/channel/components/message_searc
import {FilterOption} from '@app/features/channel/components/message_search_bar/FilterOption';
import styles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
import type {PlaintextAutocompleteRow} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
import {resolveChannelSuggestionDisplayName} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
@@ -143,7 +143,7 @@ export const PlaintextSection: React.FC<PlaintextSectionProps> = observer(
className={styles.channelName}
data-flx="channel.message-search-bar.plaintext-section.channel-name"
>
{resolveSearchChannelDisplayName(row.channel)}
{resolveChannelSuggestionDisplayName(row.channel)}
</span>
</span>
);
@@ -12,6 +12,7 @@ import {
buildHistoryFilterRows,
buildUserSearchBoosters,
filterRequiresValue,
getChannelSuggestionSearchPlan,
getUserGuildSearchPlan,
isDateFilterKey,
isSearchFilterOptionEligible,
@@ -22,6 +23,7 @@ import {
PLAINTEXT_SUGGESTION_FILTER_KEYS,
PLAINTEXT_SUGGESTIONS_PER_FILTER,
replaceSearchTokenAtCursor,
resolveChannelSuggestionDisplayName,
resolveMessageSearchCurrentWord,
type SearchTokenReplacementResult,
} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
@@ -32,6 +34,7 @@ import type {LexicalSearchInputHandle} from '@app/features/lexical/search/Lexica
import MemberSearch, {type SearchContext} from '@app/features/member/state/MemberSearch';
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import Permission from '@app/features/permissions/state/Permission';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import SearchHistory, {
SEARCH_HISTORY_DISPLAY_LIMIT,
@@ -41,7 +44,7 @@ import {
buildSearchSegmentsFromHints,
formatSearchHistoryEntryForStreamerMode,
} from '@app/features/search/utils/SearchPrivacyUtils';
import {matchSearchDateWords, resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
import {matchSearchDateWords} from '@app/features/search/utils/SearchQueryParser';
import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager';
import type {MessageSearchScope, SearchFilterOption} from '@app/features/search/utils/SearchUtils';
import {getSearchFilterOptions} from '@app/features/search/utils/SearchUtils';
@@ -52,7 +55,7 @@ import Users from '@app/features/user/state/Users';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {ME} from '@fluxer/constants/src/AppConstants';
import {GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {DateTime} from 'luxon';
@@ -63,6 +66,18 @@ const MAX_FILTER_VALUE_SUGGESTIONS = 12;
const MAX_MEMBER_SEARCH_RESULTS = 25;
const MAX_DATE_WORD_SUGGESTIONS = 10;
function isDmSearchChannel(channel: Channel): boolean {
return channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM;
}
function isGuildSearchChannel(channel: Channel): boolean {
return (
channel.guildId != null &&
GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) &&
Permission.can(Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY, channel)
);
}
function isHistoryFilterRow(option: AutocompleteOption): option is HistoryFilterRow {
if (typeof option !== 'object' || option === null || !('kind' in option)) {
return false;
@@ -396,27 +411,79 @@ export function useMessageSearchAutocomplete({
);
const resolveChannelSuggestions = useCallback(
(searchTerm: string, limit: number): Array<Channel> => {
const guildIdForChannels = channelGuildId ?? (routeGuildId === ME ? undefined : routeGuildId);
if (!guildIdForChannels) {
const privateChannels = Channels.getPrivateChannels().filter((c) => resolveSearchChannelDisplayName(c) !== '');
return matchSorter(privateChannels, searchTerm, {
keys: [(c: Channel) => resolveSearchChannelDisplayName(c)],
}).slice(0, limit);
const currentGuildId = channelGuildId ?? (routeGuildId === ME ? undefined : routeGuildId);
const plan = getChannelSuggestionSearchPlan(activeScope, currentGuildId);
const candidatesById = new Map<string, Channel>();
const addCandidate = (candidate: Channel | undefined) => {
if (candidate) {
candidatesById.set(candidate.id, candidate);
}
};
if (!hidePersonalInformation) {
switch (plan.dmMode) {
case 'none':
break;
case 'current':
if (channel && channel.guildId == null) {
addCandidate(channel);
}
break;
case 'open':
case 'all':
for (const privateChannel of Channels.getPrivateChannels()) {
addCandidate(privateChannel);
}
if (channel && isDmSearchChannel(channel)) {
addCandidate(channel);
}
break;
default: {
const exhaustiveDmMode: never = plan.dmMode;
throw new Error(`Unsupported DM channel suggestion mode: ${exhaustiveDmMode}`);
}
}
}
const channels = Channels.getGuildChannels(guildIdForChannels).filter((c) =>
GUILD_TEXT_BASED_CHANNEL_TYPES.has(c.type),
switch (plan.guildMode) {
case 'none':
break;
case 'current_guild':
if (!plan.currentGuildId) {
throw new Error('Current guild channel suggestion mode requires a guild ID');
}
for (const guildChannel of Channels.getGuildChannels(plan.currentGuildId)) {
if (isGuildSearchChannel(guildChannel)) {
addCandidate(guildChannel);
}
}
break;
case 'all_guilds':
for (const guildChannels of Channels.channelGroups.byGuild.values()) {
for (const guildChannel of guildChannels) {
if (isGuildSearchChannel(guildChannel)) {
addCandidate(guildChannel);
}
}
}
break;
default: {
const exhaustiveGuildMode: never = plan.guildMode;
throw new Error(`Unsupported guild channel suggestion mode: ${exhaustiveGuildMode}`);
}
}
const candidates = Array.from(candidatesById.values()).filter(
(candidate) => resolveChannelSuggestionDisplayName(candidate) !== '',
);
const recentVisitsForGuild = SelectedChannel.recentlyVisitedChannels
.filter((visit) => visit.guildId === guildIdForChannels)
.sort((a, b) => b.timestamp - a.timestamp);
const recencyRank = new Map<string, number>();
recentVisitsForGuild.forEach((visit, index) => {
SelectedChannel.sortedRecentVisits.forEach((visit, index) => {
if (!recencyRank.has(visit.channelId)) {
recencyRank.set(visit.channelId, index);
}
});
const currentChannelId = channel?.id;
const matches = matchSorter(channels, searchTerm, {keys: ['name']});
const matches = matchSorter(candidates, searchTerm, {
keys: [(candidate: Channel) => resolveChannelSuggestionDisplayName(candidate)],
});
const matchRank = new Map(matches.map((candidate, index) => [candidate.id, index]));
const orderedMatches = [...matches].sort((a, b) => {
const resolveRank = (ch: Channel) => {
if (ch.id === currentChannelId) return -1;
@@ -426,11 +493,11 @@ export function useMessageSearchAutocomplete({
if (rankDifference !== 0) {
return rankDifference;
}
return (a.name ?? '').localeCompare(b.name ?? '');
return (matchRank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (matchRank.get(b.id) ?? Number.MAX_SAFE_INTEGER);
});
return orderedMatches.slice(0, limit);
},
[channelGuildId, routeGuildId, channel],
[activeScope, channelGuildId, routeGuildId, channel, hidePersonalInformation],
);
const getPlaintextRows = useCallback((): Array<PlaintextAutocompleteRow> => {
const cursorPosition = resolveInputCursorPosition(inputRef, value);
@@ -462,7 +529,7 @@ export function useMessageSearchAutocomplete({
}
return rows;
}, [value, filterOptions, filterEligibility, resolveChannelSuggestions, resolveUserSuggestions]);
const getAutocompleteOptions = useCallback((): Array<AutocompleteOption> => {
const resolveAutocompleteOptions = useCallback((): Array<AutocompleteOption> => {
const cursorPos = resolveInputCursorPosition(inputRef, value);
const currentWord = resolveMessageSearchCurrentWord({value, cursorPosition: cursorPos});
switch (autocompleteType) {
@@ -548,13 +615,17 @@ export function useMessageSearchAutocomplete({
() => buildHistoryFilterRows(filterOptions, filterEligibility),
[filterOptions, filterEligibility],
);
const autocompleteOptions = resolveAutocompleteOptions();
const totalOptions = autocompleteType
? autocompleteOptions.length + (autocompleteType === 'history' ? historyFilterRows.length : 0)
: 0;
const getAutocompleteOptions = useCallback(
(): Array<AutocompleteOption> => autocompleteOptions,
[autocompleteOptions],
);
const getTotalOptions = useCallback((): number => {
if (!autocompleteType) return 0;
if (autocompleteType === 'history') {
return historyFilterRows.length + getAutocompleteOptions().length;
}
return getAutocompleteOptions().length;
}, [autocompleteType, getAutocompleteOptions, historyFilterRows]);
return totalOptions;
}, [totalOptions]);
const hasAnyOptions = useCallback((): boolean => {
return getTotalOptions() > 0;
}, [getTotalOptions]);
@@ -564,13 +635,11 @@ export function useMessageSearchAutocomplete({
if (selectedIndex < historyFilterRows.length) {
return historyFilterRows[selectedIndex] ?? null;
}
const historyOptions = getAutocompleteOptions();
const historyIndex = selectedIndex - historyFilterRows.length;
return historyOptions[historyIndex] ?? null;
return autocompleteOptions[historyIndex] ?? null;
}
const options = getAutocompleteOptions();
return options[selectedIndex] ?? null;
}, [selectedIndex, autocompleteType, getAutocompleteOptions, historyFilterRows]);
return autocompleteOptions[selectedIndex] ?? null;
}, [selectedIndex, autocompleteType, autocompleteOptions, historyFilterRows]);
useEffect(() => {
if (!isFocused || suppressAutoOpen) {
setAutocompleteType(null);
@@ -643,11 +712,10 @@ export function useMessageSearchAutocomplete({
setCurrentFilter(null);
}, [value, isFocused, isInGuildChannel, suppressAutoOpen, filterOptions, filterEligibility]);
useEffect(() => {
const totalOptions = getTotalOptions();
if (totalOptions > 0 && (selectedIndex >= totalOptions || selectedIndex < -1)) {
setSelectedIndex(-1);
}
}, [autocompleteType, selectedIndex, getTotalOptions]);
}, [selectedIndex, totalOptions]);
const handleOptionMouseEnter = (index: number) => {
setHoverIndex(index);
setHasInteracted(true);
@@ -729,7 +797,7 @@ export function useMessageSearchAutocomplete({
case 'channels': {
const ch = option as Channel;
const filter = requireCurrentFilter();
const displayName = resolveSearchChannelDisplayName(ch);
const displayName = resolveChannelSuggestionDisplayName(ch);
const name = displayName === '' ? i18n._(UNNAMED_DESCRIPTOR) : displayName;
replacement = replaceSearchTokenAtCursor({
value,
@@ -815,7 +883,7 @@ export function useMessageSearchAutocomplete({
shouldSubmit = true;
break;
}
const rowDisplayName = resolveSearchChannelDisplayName(row.channel);
const rowDisplayName = resolveChannelSuggestionDisplayName(row.channel);
const channelName = rowDisplayName === '' ? i18n._(UNNAMED_DESCRIPTOR) : rowDisplayName;
replacement = replaceSearchTokenAtCursor({
value,
@@ -9,14 +9,7 @@ import {Scroller} from '@app/features/ui/components/Scroller';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import type {IconProps} from '@phosphor-icons/react';
import {
ChatCenteredDotsIcon,
CheckIcon,
EnvelopeSimpleIcon,
GlobeIcon,
HashIcon,
UsersIcon,
} from '@phosphor-icons/react';
import {ChatCenteredDotsIcon, ChatCircleIcon, CheckIcon, GlobeIcon, HashIcon, UsersIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import type React from 'react';
@@ -26,7 +19,7 @@ const SEARCH_IN_DESCRIPTOR = msg({
});
const SCOPE_ICON_COMPONENTS: Record<MessageSearchScope, React.ComponentType<IconProps>> = {
current: HashIcon,
all_dms: EnvelopeSimpleIcon,
all_dms: ChatCircleIcon,
open_dms: ChatCenteredDotsIcon,
all_guilds: GlobeIcon,
all: UsersIcon,