fix(app): rank permission override member search by the worker order (#1944)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:50:27 +02:00
1 parent 9219b886fe
commit 9cbe0efbbb
2 files changed
+133 -111

No files matched your search

@@ -0,0 +1,117 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildMember} from '@app/features/member/models/GuildMember';
import {buildMemberSearchRank} from '@app/features/messaging/utils/AutocompleteOptionBuilders';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {matchSorter} from 'match-sorter';
export interface ParsedOverrideMemberQuery {
usernameQuery: string;
tagQuery: string | null;
hasTagSeparator: boolean;
}
export interface OverrideMemberSelection {
cachedMembers: ReadonlyArray<GuildMember>;
workerMemberIds: ReadonlyArray<string>;
resolveMember: (userId: string) => GuildMember | null;
excludedIds: ReadonlySet<string>;
guildId: string;
query: string;
limit: number;
}
export function parseOverrideMemberQuery(query: string): ParsedOverrideMemberQuery {
const hashIndex = query.indexOf('#');
if (hashIndex === -1) {
return {usernameQuery: query, tagQuery: null, hasTagSeparator: false};
}
return {
usernameQuery: query.slice(0, hashIndex),
tagQuery: query.slice(hashIndex + 1),
hasTagSeparator: true,
};
}
export function getOverrideMemberLabel(member: GuildMember, guildId: string): string {
return NicknameUtils.getNickname(member.user, guildId);
}
function compareByLabel(a: GuildMember, b: GuildMember, guildId: string): number {
return getOverrideMemberLabel(a, guildId)
.toLowerCase()
.localeCompare(getOverrideMemberLabel(b, guildId).toLowerCase());
}
function matchOverrideMembers(
members: ReadonlyArray<GuildMember>,
guildId: string,
parsed: ParsedOverrideMemberQuery,
): Array<GuildMember> {
if (parsed.hasTagSeparator) {
const usernameLower = parsed.usernameQuery.toLowerCase();
const tagLower = parsed.tagQuery?.toLowerCase() ?? '';
return members.filter((member) => {
const nick = member.nick?.toLowerCase() ?? '';
const username = member.user.username.toLowerCase();
const matchesUsername =
usernameLower.length === 0 || username.startsWith(usernameLower) || nick.startsWith(usernameLower);
const matchesTag = tagLower.length === 0 || member.user.discriminator.startsWith(tagLower);
return matchesUsername && matchesTag;
});
}
const trimmed = parsed.usernameQuery.trim();
if (trimmed.length === 0) {
return [...members];
}
return matchSorter([...members], trimmed, {
keys: [(member: GuildMember) => getOverrideMemberLabel(member, guildId), 'nick', 'user.username', 'user.tag'],
});
}
export function selectOverrideMembers({
cachedMembers,
workerMemberIds,
resolveMember,
excludedIds,
guildId,
query,
limit,
}: OverrideMemberSelection): Array<GuildMember> {
const candidates = new Map<string, GuildMember>();
for (const member of cachedMembers) {
if (excludedIds.has(member.user.id)) continue;
candidates.set(member.user.id, member);
}
const workerRanked: Array<GuildMember> = [];
for (const userId of workerMemberIds) {
if (excludedIds.has(userId)) continue;
const cached = candidates.get(userId);
if (cached != null) {
workerRanked.push(cached);
continue;
}
const member = resolveMember(userId);
if (member == null) continue;
candidates.set(userId, member);
workerRanked.push(member);
}
const trimmed = query.trim();
if (trimmed.length === 0) {
return [...candidates.values()].sort((a, b) => compareByLabel(a, b, guildId)).slice(0, limit);
}
const matched = matchOverrideMembers([...candidates.values()], guildId, parseOverrideMemberQuery(trimmed));
const searchRank = buildMemberSearchRank(workerRanked);
if (searchRank.size === 0) {
return [...matched].sort((a, b) => compareByLabel(a, b, guildId)).slice(0, limit);
}
const noRankSentinel = Number.MAX_SAFE_INTEGER;
return [...matched]
.sort((a, b) => {
const rankA = searchRank.get(a.user.id) ?? noRankSentinel;
const rankB = searchRank.get(b.user.id) ?? noRankSentinel;
if (rankA !== rankB) return rankA - rankB;
return compareByLabel(a, b, guildId);
})
.slice(0, limit);
}
@@ -1,10 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
getOverrideMemberLabel,
parseOverrideMemberQuery,
selectOverrideMembers,
} from '@app/features/app/components/dialogs/shared/AddOverrideMemberSearch';
import styles from '@app/features/app/components/dialogs/shared/AddOverridePopout.module.css';
import {DEFAULT_ROLE_COLOR_HEX, getRoleColor} from '@app/features/app/components/dialogs/shared/PermissionComponents';
import Guilds from '@app/features/guild/state/Guilds';
import {ROLES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import type {GuildMember} from '@app/features/member/models/GuildMember';
import GuildMembers from '@app/features/member/state/GuildMembers';
import MemberSearch, {type SearchContext} from '@app/features/member/state/MemberSearch';
import {openRoleContextMenu} from '@app/features/ui/action_menu/RoleContextMenu';
@@ -14,7 +18,6 @@ import {
type SearchableListPopoutItem,
type SearchableListPopoutSection,
} from '@app/features/ui/popover/searchable_list_popout/SearchableListPopout';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {matchSorter} from 'match-sorter';
@@ -46,74 +49,6 @@ const MEMBERS_LIMIT = 10;
const WORKER_RESULT_LIMIT = 25;
const SERVER_DEBOUNCE_MS = 300;
interface ParsedMemberQuery {
usernameQuery: string;
tagQuery: string | null;
hasTagSeparator: boolean;
}
function parseMemberQuery(query: string): ParsedMemberQuery {
const hashIndex = query.indexOf('#');
if (hashIndex === -1) {
return {usernameQuery: query, tagQuery: null, hasTagSeparator: false};
}
return {
usernameQuery: query.slice(0, hashIndex),
tagQuery: query.slice(hashIndex + 1),
hasTagSeparator: true,
};
}
function getMemberDisplayName(member: GuildMember, guildId: string): string {
return NicknameUtils.getNickname(member.user, guildId);
}
function compareByDisplayName(a: GuildMember, b: GuildMember, guildId: string): number {
const aName = getMemberDisplayName(a, guildId).toLowerCase();
const bName = getMemberDisplayName(b, guildId).toLowerCase();
return aName.localeCompare(bName);
}
function filterMembers(
members: Array<GuildMember>,
guildId: string,
parsed: ParsedMemberQuery,
stableOrder?: Map<string, number>,
): Array<GuildMember> {
let matched: Array<GuildMember>;
if (parsed.hasTagSeparator) {
const usernameLower = parsed.usernameQuery.toLowerCase();
const tagLower = parsed.tagQuery?.toLowerCase() ?? '';
matched = members.filter((member) => {
const nick = member.nick?.toLowerCase() ?? '';
const username = member.user.username.toLowerCase();
const matchesUsername =
usernameLower.length === 0 || username.startsWith(usernameLower) || nick.startsWith(usernameLower);
const matchesTag = tagLower.length === 0 || member.user.discriminator.startsWith(tagLower);
return matchesUsername && matchesTag;
});
} else {
const trimmed = parsed.usernameQuery.trim();
if (trimmed.length === 0) {
matched = [...members];
} else {
matched = matchSorter(members, trimmed, {
keys: [(member) => getMemberDisplayName(member, guildId), 'nick', 'user.username', 'user.tag'],
});
}
}
if (stableOrder && stableOrder.size > 0) {
const NEW_RANK = Number.MAX_SAFE_INTEGER;
return [...matched].sort((a, b) => {
const ra = stableOrder.get(a.user.id) ?? NEW_RANK;
const rb = stableOrder.get(b.user.id) ?? NEW_RANK;
if (ra !== rb) return ra - rb;
return compareByDisplayName(a, b, guildId);
});
}
return [...matched].sort((a, b) => compareByDisplayName(a, b, guildId));
}
export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(function AddOverridePopout({
guildId,
existingOverwriteIds,
@@ -126,11 +61,6 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
const [serverMemberIds, setServerMemberIds] = useState<Array<string>>([]);
const searchContextRef = useRef<SearchContext | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const sessionRef = useRef<{key: string; order: Map<string, number>; nextRank: number}>({
key: '',
order: new Map(),
nextRank: 0,
});
useEffect(() => {
const context = MemberSearch.getSearchContext((results) => {
setServerMemberIds(results.map((result) => result.id));
@@ -147,7 +77,7 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
}, []);
useEffect(() => {
const trimmed = searchQuery.trim();
const parsed = parseMemberQuery(trimmed);
const parsed = parseOverrideMemberQuery(trimmed);
const queryForServer = parsed.usernameQuery.trim();
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
@@ -176,40 +106,15 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
}, [guild, existingOverwriteIds]);
const members = useMemo(() => {
if (!guild) return [];
const cached = GuildMembers.getMembers(guildId).filter((member) => !existingOverwriteIds.has(member.user.id));
const trimmed = searchQuery.trim();
const parsed = parseMemberQuery(trimmed);
const hasQuery = trimmed.length > 0;
if (!hasQuery) {
sessionRef.current = {key: `${guildId}:`, order: new Map(), nextRank: 0};
return [...cached].sort((a, b) => compareByDisplayName(a, b, guildId)).slice(0, MEMBERS_LIMIT);
}
const sessionKey = `${guildId}:${trimmed}`;
if (sessionRef.current.key !== sessionKey) {
sessionRef.current = {key: sessionKey, order: new Map(), nextRank: 0};
}
const merged = new Map<string, GuildMember>();
for (const member of cached) {
merged.set(member.user.id, member);
}
for (const id of serverMemberIds) {
if (existingOverwriteIds.has(id) || merged.has(id)) continue;
const member = GuildMembers.getMember(guildId, id);
if (member) {
merged.set(id, member);
}
}
const result = filterMembers(Array.from(merged.values()), guildId, parsed, sessionRef.current.order).slice(
0,
MEMBERS_LIMIT,
);
const session = sessionRef.current;
for (const member of result) {
if (!session.order.has(member.user.id)) {
session.order.set(member.user.id, session.nextRank++);
}
}
return result;
return selectOverrideMembers({
cachedMembers: GuildMembers.getMembers(guildId),
workerMemberIds: serverMemberIds,
resolveMember: (userId) => GuildMembers.getMember(guildId, userId),
excludedIds: existingOverwriteIds,
guildId,
query: searchQuery,
limit: MEMBERS_LIMIT,
});
}, [guild, guildId, existingOverwriteIds, searchQuery, serverMemberIds]);
const filteredRoles = useMemo(() => {
const trimmed = searchQuery.trim();
@@ -244,7 +149,7 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
}, [filteredRoles, onClose, onSelect]);
const memberItems = useMemo<Array<SearchableListPopoutItem>>(() => {
return members.map((member) => {
const displayName = getMemberDisplayName(member, guildId);
const displayName = getOverrideMemberLabel(member, guildId);
return {
id: `member-${member.user.id}`,
ariaLabel: displayName,