fix(app): search forward targets by username and every known nickname (#1691)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-17 11:51:07 +02:00
1 parent 6c68202222
commit c61fd96df6
6 files changed
+141 -5

No files matched your search

@@ -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<string>;
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<string>;
}
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,
});
@@ -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> = {}): 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);
});
});
@@ -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<string>;
}
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);
}
@@ -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<string> = 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<string>;
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<string> {
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
return EMPTY_SEARCH_ALIASES;
}
const aliases = new Set<string>();
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,
@@ -51,6 +51,7 @@ export interface LexicalRichInputProps {
richInputRef?: React.Ref<LexicalRichInputHandle>;
onChange?: (display: string, segments: Array<MentionSegment>, wire: string) => void;
onSubmit?: () => void;
submitOnEnter?: boolean;
onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => 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}
@@ -513,6 +513,7 @@ export const ForwardModal = observer(
disabled={isCommentComposerDisabled}
markdown={true}
singleLine={false}
submitOnEnter={true}
size="form"
autocompleteAnchor={containerElement}
maxLength={user.maxMessageLength}