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 type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import {isForwardableChannelType} from './ForwardChannelEligibility'; import {isForwardableChannelType} from './ForwardChannelEligibility';
import {matchesForwardChannelSearch} from './ForwardChannelSearchMatch';
const GUILD_MESSAGES_DISABLED_DESCRIPTOR = msg({ const GUILD_MESSAGES_DISABLED_DESCRIPTOR = msg({
message: 'Sending messages is disabled in this community', message: 'Sending messages is disabled in this community',
@@ -92,6 +93,7 @@ export interface ForwardChannelObservation {
readonly guildMessagesDisabled: boolean; readonly guildMessagesDisabled: boolean;
readonly guildName: string | null; readonly guildName: string | null;
readonly memberTimedOut: boolean; readonly memberTimedOut: boolean;
readonly searchAliases: ReadonlyArray<string>;
readonly slowmodeEnabled: boolean; readonly slowmodeEnabled: boolean;
readonly slowmodeRemainingMs: number; readonly slowmodeRemainingMs: number;
} }
@@ -113,6 +115,7 @@ interface IndexedForwardChannelOption extends ForwardChannelOption {
readonly isPersonalNotes: boolean; readonly isPersonalNotes: boolean;
readonly isSource: boolean; readonly isSource: boolean;
readonly recentRank: number | null; readonly recentRank: number | null;
readonly searchAliasValues: ReadonlyArray<string>;
} }
interface BuildForwardChannelIndexRequest { interface BuildForwardChannelIndexRequest {
@@ -194,10 +197,7 @@ export class ForwardChannelIndex {
readonly personalNotesSearchValue: string; readonly personalNotesSearchValue: string;
readonly option: IndexedForwardChannelOption; readonly option: IndexedForwardChannelOption;
}): boolean { }): boolean {
if (option.isPersonalNotes && personalNotesSearchValue.includes(normalizedQuery)) return true; return matchesForwardChannelSearch({normalizedQuery, personalNotesSearchValue, values: option});
if (option.displayNameSearchValue.includes(normalizedQuery)) return true;
if (option.channelNameSearchValue.includes(normalizedQuery)) return true;
return option.guildNameSearchValue.includes(normalizedQuery);
} }
private buildOption({ private buildOption({
@@ -232,6 +232,7 @@ export class ForwardChannelIndex {
isPersonalNotes: observation.channel.type === ChannelTypes.DM_PERSONAL_NOTES, isPersonalNotes: observation.channel.type === ChannelTypes.DM_PERSONAL_NOTES,
isSource: observation.channel.id === excludedChannelId, isSource: observation.channel.id === excludedChannelId,
recentRank, recentRank,
searchAliasValues: Object.freeze(observation.searchAliases.map((alias) => alias.toLowerCase())),
slowmodeEnabled: observation.slowmodeEnabled, slowmodeEnabled: observation.slowmodeEnabled,
slowmodeRemainingMs: observation.slowmodeRemainingMs, 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 Guilds from '@app/features/guild/state/Guilds';
import GuildMembers from '@app/features/member/state/GuildMembers'; import GuildMembers from '@app/features/member/state/GuildMembers';
import Permission from '@app/features/permissions/state/Permission'; 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 Slowmode from '@app/features/slowmode/state/Slowmode';
import {useNow} from '@app/features/ui/state/Tick'; 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 {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildOperations} from '@fluxer/constants/src/GuildConstants'; import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants'; 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 {computed, type IComputedValue} from 'mobx';
import {useMemo, useRef} from 'react'; import {useMemo, useRef} from 'react';
const EMPTY_SEARCH_ALIASES: ReadonlyArray<string> = Object.freeze([]);
const CHANNEL_DESCRIPTOR = msg({ const CHANNEL_DESCRIPTOR = msg({
message: 'Channel {id}', message: 'Channel {id}',
comment: 'Short label in the settings dialog forward channel selection. Preserve {id}; it is inserted by code.', 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 guildMessagesDisabled: boolean;
readonly guildName: string | null; readonly guildName: string | null;
readonly memberTimedOut: boolean; readonly memberTimedOut: boolean;
readonly searchAliases: ReadonlyArray<string>;
readonly slowmodeEnabled: boolean; readonly slowmodeEnabled: boolean;
} }
@@ -55,6 +60,10 @@ interface ForwardChannelObservationSelection {
} }
function resolveChannelDisplayName(channel: Channel, i18n: I18n): string { 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 ( if (
channel.type === ChannelTypes.DM_PERSONAL_NOTES || channel.type === ChannelTypes.DM_PERSONAL_NOTES ||
channel.type === ChannelTypes.DM || channel.type === ChannelTypes.DM ||
@@ -66,6 +75,28 @@ function resolveChannelDisplayName(channel: Channel, i18n: I18n): string {
return i18n._(CHANNEL_DESCRIPTOR, {id: channel.id}); 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 { function resolveChannelCategoryName(channel: Channel): string | null {
if (!channel.parentId) return null; if (!channel.parentId) return null;
const category = Channels.getChannel(channel.parentId); const category = Channels.getChannel(channel.parentId);
@@ -99,6 +130,8 @@ function areForwardChannelObservationBasesEqual(
return false; return false;
} }
if (left.canSendMessages !== right.canSendMessages || left.canEmbedLinks !== right.canEmbedLinks) 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; return left.canAttachFiles === right.canAttachFiles && left.slowmodeEnabled === right.slowmodeEnabled;
} }
@@ -185,6 +218,7 @@ class ForwardChannelObservationsOwner {
guildMessagesDisabled: false, guildMessagesDisabled: false,
guildName: null, guildName: null,
memberTimedOut: false, memberTimedOut: false,
searchAliases: collectRecipientSearchAliases(channel),
slowmodeEnabled: false, slowmodeEnabled: false,
}); });
} }
@@ -213,6 +247,7 @@ class ForwardChannelObservationsOwner {
guildMessagesDisabled, guildMessagesDisabled,
guildName, guildName,
memberTimedOut: isCurrentMemberTimedOut(channel, this.request.currentUserId), memberTimedOut: isCurrentMemberTimedOut(channel, this.request.currentUserId),
searchAliases: collectRecipientSearchAliases(channel),
slowmodeEnabled, slowmodeEnabled,
}); });
} }
@@ -243,6 +278,7 @@ function buildForwardChannelObservations(
displayName: base.displayName, displayName: base.displayName,
guildMessagesDisabled: base.guildMessagesDisabled, guildMessagesDisabled: base.guildMessagesDisabled,
guildName: base.guildName, guildName: base.guildName,
searchAliases: base.searchAliases,
memberTimedOut: base.memberTimedOut, memberTimedOut: base.memberTimedOut,
slowmodeEnabled: base.slowmodeEnabled, slowmodeEnabled: base.slowmodeEnabled,
slowmodeRemainingMs: remainingMs === undefined ? 0 : remainingMs, slowmodeRemainingMs: remainingMs === undefined ? 0 : remainingMs,
@@ -51,6 +51,7 @@ export interface LexicalRichInputProps {
richInputRef?: React.Ref<LexicalRichInputHandle>; richInputRef?: React.Ref<LexicalRichInputHandle>;
onChange?: (display: string, segments: Array<MentionSegment>, wire: string) => void; onChange?: (display: string, segments: Array<MentionSegment>, wire: string) => void;
onSubmit?: () => void; onSubmit?: () => void;
submitOnEnter?: boolean;
onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void; onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void;
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void; onBlur?: () => void;
@@ -85,6 +86,7 @@ export const LexicalRichInput = ({
richInputRef, richInputRef,
onChange, onChange,
onSubmit, onSubmit,
submitOnEnter,
onKeyDown, onKeyDown,
onFocus, onFocus,
onBlur, onBlur,
@@ -226,7 +228,7 @@ export const LexicalRichInput = ({
emojiShortcodeResolver={emojiShortcodeResolver} emojiShortcodeResolver={emojiShortcodeResolver}
channelId={channel == null ? undefined : channel.id} channelId={channel == null ? undefined : channel.id}
guildId={channel == null ? undefined : channel.guildId} guildId={channel == null ? undefined : channel.guildId}
submitOnEnter={singleLine} submitOnEnter={submitOnEnter ?? singleLine}
autocompleteOptions={autocompleteOptions} autocompleteOptions={autocompleteOptions}
autocompleteType={autocompleteType} autocompleteType={autocompleteType}
autocompleteQuery={autocompleteQuery} autocompleteQuery={autocompleteQuery}
@@ -513,6 +513,7 @@ export const ForwardModal = observer(
disabled={isCommentComposerDisabled} disabled={isCommentComposerDisabled}
markdown={true} markdown={true}
singleLine={false} singleLine={false}
submitOnEnter={true}
size="form" size="form"
autocompleteAnchor={containerElement} autocompleteAnchor={containerElement}
maxLength={user.maxMessageLength} maxLength={user.maxMessageLength}