mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15: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
|
// 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 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 {DEFAULT_ROLE_COLOR_HEX, getRoleColor} from '@app/features/app/components/dialogs/shared/PermissionComponents';
|
||||||
import Guilds from '@app/features/guild/state/Guilds';
|
import Guilds from '@app/features/guild/state/Guilds';
|
||||||
import {ROLES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
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 GuildMembers from '@app/features/member/state/GuildMembers';
|
||||||
import MemberSearch, {type SearchContext} from '@app/features/member/state/MemberSearch';
|
import MemberSearch, {type SearchContext} from '@app/features/member/state/MemberSearch';
|
||||||
import {openRoleContextMenu} from '@app/features/ui/action_menu/RoleContextMenu';
|
import {openRoleContextMenu} from '@app/features/ui/action_menu/RoleContextMenu';
|
||||||
@@ -14,7 +18,6 @@ import {
|
|||||||
type SearchableListPopoutItem,
|
type SearchableListPopoutItem,
|
||||||
type SearchableListPopoutSection,
|
type SearchableListPopoutSection,
|
||||||
} from '@app/features/ui/popover/searchable_list_popout/SearchableListPopout';
|
} 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 {msg} from '@lingui/core/macro';
|
||||||
import {Trans, useLingui} from '@lingui/react/macro';
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
import {matchSorter} from 'match-sorter';
|
import {matchSorter} from 'match-sorter';
|
||||||
@@ -46,74 +49,6 @@ const MEMBERS_LIMIT = 10;
|
|||||||
const WORKER_RESULT_LIMIT = 25;
|
const WORKER_RESULT_LIMIT = 25;
|
||||||
const SERVER_DEBOUNCE_MS = 300;
|
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({
|
export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(function AddOverridePopout({
|
||||||
guildId,
|
guildId,
|
||||||
existingOverwriteIds,
|
existingOverwriteIds,
|
||||||
@@ -126,11 +61,6 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
|
|||||||
const [serverMemberIds, setServerMemberIds] = useState<Array<string>>([]);
|
const [serverMemberIds, setServerMemberIds] = useState<Array<string>>([]);
|
||||||
const searchContextRef = useRef<SearchContext | null>(null);
|
const searchContextRef = useRef<SearchContext | null>(null);
|
||||||
const debounceTimerRef = useRef<NodeJS.Timeout | 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(() => {
|
useEffect(() => {
|
||||||
const context = MemberSearch.getSearchContext((results) => {
|
const context = MemberSearch.getSearchContext((results) => {
|
||||||
setServerMemberIds(results.map((result) => result.id));
|
setServerMemberIds(results.map((result) => result.id));
|
||||||
@@ -147,7 +77,7 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
|
|||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const trimmed = searchQuery.trim();
|
const trimmed = searchQuery.trim();
|
||||||
const parsed = parseMemberQuery(trimmed);
|
const parsed = parseOverrideMemberQuery(trimmed);
|
||||||
const queryForServer = parsed.usernameQuery.trim();
|
const queryForServer = parsed.usernameQuery.trim();
|
||||||
if (debounceTimerRef.current) {
|
if (debounceTimerRef.current) {
|
||||||
clearTimeout(debounceTimerRef.current);
|
clearTimeout(debounceTimerRef.current);
|
||||||
@@ -176,40 +106,15 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
|
|||||||
}, [guild, existingOverwriteIds]);
|
}, [guild, existingOverwriteIds]);
|
||||||
const members = useMemo(() => {
|
const members = useMemo(() => {
|
||||||
if (!guild) return [];
|
if (!guild) return [];
|
||||||
const cached = GuildMembers.getMembers(guildId).filter((member) => !existingOverwriteIds.has(member.user.id));
|
return selectOverrideMembers({
|
||||||
const trimmed = searchQuery.trim();
|
cachedMembers: GuildMembers.getMembers(guildId),
|
||||||
const parsed = parseMemberQuery(trimmed);
|
workerMemberIds: serverMemberIds,
|
||||||
const hasQuery = trimmed.length > 0;
|
resolveMember: (userId) => GuildMembers.getMember(guildId, userId),
|
||||||
if (!hasQuery) {
|
excludedIds: existingOverwriteIds,
|
||||||
sessionRef.current = {key: `${guildId}:`, order: new Map(), nextRank: 0};
|
guildId,
|
||||||
return [...cached].sort((a, b) => compareByDisplayName(a, b, guildId)).slice(0, MEMBERS_LIMIT);
|
query: searchQuery,
|
||||||
}
|
limit: 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;
|
|
||||||
}, [guild, guildId, existingOverwriteIds, searchQuery, serverMemberIds]);
|
}, [guild, guildId, existingOverwriteIds, searchQuery, serverMemberIds]);
|
||||||
const filteredRoles = useMemo(() => {
|
const filteredRoles = useMemo(() => {
|
||||||
const trimmed = searchQuery.trim();
|
const trimmed = searchQuery.trim();
|
||||||
@@ -244,7 +149,7 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
|
|||||||
}, [filteredRoles, onClose, onSelect]);
|
}, [filteredRoles, onClose, onSelect]);
|
||||||
const memberItems = useMemo<Array<SearchableListPopoutItem>>(() => {
|
const memberItems = useMemo<Array<SearchableListPopoutItem>>(() => {
|
||||||
return members.map((member) => {
|
return members.map((member) => {
|
||||||
const displayName = getMemberDisplayName(member, guildId);
|
const displayName = getOverrideMemberLabel(member, guildId);
|
||||||
return {
|
return {
|
||||||
id: `member-${member.user.id}`,
|
id: `member-${member.user.id}`,
|
||||||
ariaLabel: displayName,
|
ariaLabel: displayName,
|
||||||
|
|||||||
Reference in new issue
Block a user