mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): rank permission override member search by the worker order (#1944)
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user