From c61fd96df67a46d357dda2c071e90b44f665fb88 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 17 Aug 2026 11:51:07 +0200 Subject: [PATCH] fix(app): search forward targets by username and every known nickname (#1691) --- .../dialogs/shared/ForwardChannelIndex.ts | 9 +-- .../shared/ForwardChannelSearch.test.ts | 69 +++++++++++++++++++ .../shared/ForwardChannelSearchMatch.ts | 27 ++++++++ .../shared/UseForwardChannelObservations.ts | 36 ++++++++++ .../lexical/composer/LexicalRichInput.tsx | 4 +- .../components/modals/ForwardModal.tsx | 1 + 6 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 fluxer_app/src/features/app/components/dialogs/shared/ForwardChannelSearch.test.ts create mode 100644 fluxer_app/src/features/app/components/dialogs/shared/ForwardChannelSearchMatch.ts diff --git a/fluxer_app/src/features/app/components/dialogs/shared/ForwardChannelIndex.ts b/fluxer_app/src/features/app/components/dialogs/shared/ForwardChannelIndex.ts index de30cae7c..823eff90c 100644 --- a/fluxer_app/src/features/app/components/dialogs/shared/ForwardChannelIndex.ts +++ b/fluxer_app/src/features/app/components/dialogs/shared/ForwardChannelIndex.ts @@ -9,6 +9,7 @@ import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants' import type {I18n} from '@lingui/core'; import {msg} from '@lingui/core/macro'; import {isForwardableChannelType} from './ForwardChannelEligibility'; +import {matchesForwardChannelSearch} from './ForwardChannelSearchMatch'; const GUILD_MESSAGES_DISABLED_DESCRIPTOR = msg({ message: 'Sending messages is disabled in this community', @@ -92,6 +93,7 @@ export interface ForwardChannelObservation { readonly guildMessagesDisabled: boolean; readonly guildName: string | null; readonly memberTimedOut: boolean; + readonly searchAliases: ReadonlyArray; readonly slowmodeEnabled: boolean; readonly slowmodeRemainingMs: number; } @@ -113,6 +115,7 @@ interface IndexedForwardChannelOption extends ForwardChannelOption { readonly isPersonalNotes: boolean; readonly isSource: boolean; readonly recentRank: number | null; + readonly searchAliasValues: ReadonlyArray; } interface BuildForwardChannelIndexRequest { @@ -194,10 +197,7 @@ export class ForwardChannelIndex { readonly personalNotesSearchValue: string; readonly option: IndexedForwardChannelOption; }): boolean { - if (option.isPersonalNotes && personalNotesSearchValue.includes(normalizedQuery)) return true; - if (option.displayNameSearchValue.includes(normalizedQuery)) return true; - if (option.channelNameSearchValue.includes(normalizedQuery)) return true; - return option.guildNameSearchValue.includes(normalizedQuery); + return matchesForwardChannelSearch({normalizedQuery, personalNotesSearchValue, values: option}); } private buildOption({ @@ -232,6 +232,7 @@ export class ForwardChannelIndex { isPersonalNotes: observation.channel.type === ChannelTypes.DM_PERSONAL_NOTES, isSource: observation.channel.id === excludedChannelId, recentRank, + searchAliasValues: Object.freeze(observation.searchAliases.map((alias) => alias.toLowerCase())), slowmodeEnabled: observation.slowmodeEnabled, slowmodeRemainingMs: observation.slowmodeRemainingMs, }); diff --git a/fluxer_app/src/features/app/components/dialogs/shared/ForwardChannelSearch.test.ts b/fluxer_app/src/features/app/components/dialogs/shared/ForwardChannelSearch.test.ts new file mode 100644 index 000000000..96504366e --- /dev/null +++ b/fluxer_app/src/features/app/components/dialogs/shared/ForwardChannelSearch.test.ts @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describe, expect, it} from 'vitest'; +import {type ForwardChannelSearchValues, matchesForwardChannelSearch} from './ForwardChannelSearchMatch'; + +const PERSONAL_NOTES = 'personal notes'; + +function recipient(overrides: Partial = {}): ForwardChannelSearchValues { + return { + channelNameSearchValue: '', + displayNameSearchValue: 'ada lovelace', + guildNameSearchValue: '', + isPersonalNotes: false, + searchAliasValues: ['ada_dev', 'ada lovelace', 'countess', 'raid lead'], + ...overrides, + }; +} + +function matches(query: string, values: ForwardChannelSearchValues = recipient()): boolean { + return matchesForwardChannelSearch({ + normalizedQuery: query, + personalNotesSearchValue: PERSONAL_NOTES, + values, + }); +} + +describe('forward channel search matching', () => { + it('matches the rendered global display name', () => { + expect(matches('ada lovelace')).toBe(true); + }); + + it('matches the username', () => { + expect(matches('ada_dev')).toBe(true); + }); + + it('matches a nickname from a guild the reader is not currently viewing', () => { + expect(matches('raid lead')).toBe(true); + }); + + it('matches a relationship nickname', () => { + expect(matches('countess')).toBe(true); + }); + + it('does not match an unrelated query', () => { + expect(matches('grace hopper')).toBe(false); + }); + + it('does not match a recipient with no aliases indexed beyond the display name', () => { + const values = recipient({searchAliasValues: []}); + expect(matches('ada_dev', values)).toBe(false); + expect(matches('ada lovelace', values)).toBe(true); + }); + + it('still matches channel and guild names', () => { + const guildChannel = recipient({ + channelNameSearchValue: 'general', + displayNameSearchValue: 'general', + guildNameSearchValue: 'fluxer hq', + searchAliasValues: [], + }); + expect(matches('general', guildChannel)).toBe(true); + expect(matches('fluxer hq', guildChannel)).toBe(true); + }); + + it('still matches personal notes', () => { + const notes = recipient({displayNameSearchValue: '', isPersonalNotes: true, searchAliasValues: []}); + expect(matches('personal', notes)).toBe(true); + }); +}); diff --git a/fluxer_app/src/features/app/components/dialogs/shared/ForwardChannelSearchMatch.ts b/fluxer_app/src/features/app/components/dialogs/shared/ForwardChannelSearchMatch.ts new file mode 100644 index 000000000..a01211709 --- /dev/null +++ b/fluxer_app/src/features/app/components/dialogs/shared/ForwardChannelSearchMatch.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export interface ForwardChannelSearchValues { + readonly channelNameSearchValue: string; + readonly displayNameSearchValue: string; + readonly guildNameSearchValue: string; + readonly isPersonalNotes: boolean; + readonly searchAliasValues: ReadonlyArray; +} + +interface MatchesForwardChannelSearchRequest { + readonly normalizedQuery: string; + readonly personalNotesSearchValue: string; + readonly values: ForwardChannelSearchValues; +} + +export function matchesForwardChannelSearch({ + normalizedQuery, + personalNotesSearchValue, + values, +}: MatchesForwardChannelSearchRequest): boolean { + if (values.isPersonalNotes && personalNotesSearchValue.includes(normalizedQuery)) return true; + if (values.displayNameSearchValue.includes(normalizedQuery)) return true; + if (values.channelNameSearchValue.includes(normalizedQuery)) return true; + if (values.searchAliasValues.some((alias) => alias.includes(normalizedQuery))) return true; + return values.guildNameSearchValue.includes(normalizedQuery); +} diff --git a/fluxer_app/src/features/app/components/dialogs/shared/UseForwardChannelObservations.ts b/fluxer_app/src/features/app/components/dialogs/shared/UseForwardChannelObservations.ts index 4492f54c9..92d5c8d1c 100644 --- a/fluxer_app/src/features/app/components/dialogs/shared/UseForwardChannelObservations.ts +++ b/fluxer_app/src/features/app/components/dialogs/shared/UseForwardChannelObservations.ts @@ -9,8 +9,10 @@ import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; import Guilds from '@app/features/guild/state/Guilds'; import GuildMembers from '@app/features/member/state/GuildMembers'; import Permission from '@app/features/permissions/state/Permission'; +import Relationships from '@app/features/relationship/state/Relationships'; import Slowmode from '@app/features/slowmode/state/Slowmode'; import {useNow} from '@app/features/ui/state/Tick'; +import Users from '@app/features/user/state/Users'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {GuildOperations} from '@fluxer/constants/src/GuildConstants'; import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants'; @@ -19,6 +21,8 @@ import {msg} from '@lingui/core/macro'; import {computed, type IComputedValue} from 'mobx'; import {useMemo, useRef} from 'react'; +const EMPTY_SEARCH_ALIASES: ReadonlyArray = Object.freeze([]); + const CHANNEL_DESCRIPTOR = msg({ message: 'Channel {id}', comment: 'Short label in the settings dialog forward channel selection. Preserve {id}; it is inserted by code.', @@ -40,6 +44,7 @@ interface ForwardChannelObservationBase { readonly guildMessagesDisabled: boolean; readonly guildName: string | null; readonly memberTimedOut: boolean; + readonly searchAliases: ReadonlyArray; readonly slowmodeEnabled: boolean; } @@ -55,6 +60,10 @@ interface ForwardChannelObservationSelection { } function resolveChannelDisplayName(channel: Channel, i18n: I18n): string { + if (channel.type === ChannelTypes.DM) { + const recipient = channel.recipientIds.length > 0 ? Users.getUser(channel.recipientIds[0]) : null; + if (recipient) return recipient.displayName; + } if ( channel.type === ChannelTypes.DM_PERSONAL_NOTES || channel.type === ChannelTypes.DM || @@ -66,6 +75,28 @@ function resolveChannelDisplayName(channel: Channel, i18n: I18n): string { return i18n._(CHANNEL_DESCRIPTOR, {id: channel.id}); } +function collectRecipientSearchAliases(channel: Channel): ReadonlyArray { + if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) { + return EMPTY_SEARCH_ALIASES; + } + const aliases = new Set(); + const guilds = Guilds.getGuilds(); + for (const recipientId of channel.recipientIds) { + const recipient = Users.getUser(recipientId); + if (!recipient) continue; + aliases.add(recipient.username); + aliases.add(recipient.displayName); + if (recipient.globalName) aliases.add(recipient.globalName); + const relationshipNickname = Relationships.getRelationship(recipientId)?.nickname; + if (relationshipNickname) aliases.add(relationshipNickname); + for (const guild of guilds) { + const nick = GuildMembers.getMember(guild.id, recipientId)?.nick; + if (nick) aliases.add(nick); + } + } + return Object.freeze([...aliases].filter((alias) => alias.length > 0)); +} + function resolveChannelCategoryName(channel: Channel): string | null { if (!channel.parentId) return null; const category = Channels.getChannel(channel.parentId); @@ -99,6 +130,8 @@ function areForwardChannelObservationBasesEqual( return false; } if (left.canSendMessages !== right.canSendMessages || left.canEmbedLinks !== right.canEmbedLinks) return false; + if (left.searchAliases.length !== right.searchAliases.length) return false; + if (left.searchAliases.some((alias, index) => alias !== right.searchAliases[index])) return false; return left.canAttachFiles === right.canAttachFiles && left.slowmodeEnabled === right.slowmodeEnabled; } @@ -185,6 +218,7 @@ class ForwardChannelObservationsOwner { guildMessagesDisabled: false, guildName: null, memberTimedOut: false, + searchAliases: collectRecipientSearchAliases(channel), slowmodeEnabled: false, }); } @@ -213,6 +247,7 @@ class ForwardChannelObservationsOwner { guildMessagesDisabled, guildName, memberTimedOut: isCurrentMemberTimedOut(channel, this.request.currentUserId), + searchAliases: collectRecipientSearchAliases(channel), slowmodeEnabled, }); } @@ -243,6 +278,7 @@ function buildForwardChannelObservations( displayName: base.displayName, guildMessagesDisabled: base.guildMessagesDisabled, guildName: base.guildName, + searchAliases: base.searchAliases, memberTimedOut: base.memberTimedOut, slowmodeEnabled: base.slowmodeEnabled, slowmodeRemainingMs: remainingMs === undefined ? 0 : remainingMs, diff --git a/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx b/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx index b7df93df5..8a0cc382e 100644 --- a/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx +++ b/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx @@ -51,6 +51,7 @@ export interface LexicalRichInputProps { richInputRef?: React.Ref; onChange?: (display: string, segments: Array, wire: string) => void; onSubmit?: () => void; + submitOnEnter?: boolean; onKeyDown?: (event: React.KeyboardEvent) => void; onFocus?: () => void; onBlur?: () => void; @@ -85,6 +86,7 @@ export const LexicalRichInput = ({ richInputRef, onChange, onSubmit, + submitOnEnter, onKeyDown, onFocus, onBlur, @@ -226,7 +228,7 @@ export const LexicalRichInput = ({ emojiShortcodeResolver={emojiShortcodeResolver} channelId={channel == null ? undefined : channel.id} guildId={channel == null ? undefined : channel.guildId} - submitOnEnter={singleLine} + submitOnEnter={submitOnEnter ?? singleLine} autocompleteOptions={autocompleteOptions} autocompleteType={autocompleteType} autocompleteQuery={autocompleteQuery} diff --git a/fluxer_app/src/features/messaging/components/modals/ForwardModal.tsx b/fluxer_app/src/features/messaging/components/modals/ForwardModal.tsx index 96592da89..9cdd28a5c 100644 --- a/fluxer_app/src/features/messaging/components/modals/ForwardModal.tsx +++ b/fluxer_app/src/features/messaging/components/modals/ForwardModal.tsx @@ -513,6 +513,7 @@ export const ForwardModal = observer( disabled={isCommentComposerDisabled} markdown={true} singleLine={false} + submitOnEnter={true} size="form" autocompleteAnchor={containerElement} maxLength={user.maxMessageLength}