diff --git a/src/components/SearchModal.css b/src/components/SearchModal.css new file mode 100644 index 000000000..1d4e2ef71 --- /dev/null +++ b/src/components/SearchModal.css @@ -0,0 +1,52 @@ +.vc-search-modal-destination-row { + display: flex; + min-height: 48px; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 0 8px; + margin-left: 16px; + margin-right: 8px; + border-radius: 4px; + cursor: pointer +} + +.vc-search-modal-identity { + display: flex; + flex-shrink: 1; + flex-direction: row; + align-items: center; + gap: 12px; + overflow: hidden +} + +.vc-search-modal-labels { + display: flex; + flex-direction: column; + overflow: hidden; + margin: 4px 0 +} + +.vc-search-modal-checkbox { + flex: 0; + margin-left: 16px +} + +.vc-search-modal-label { + color: var(--header-primary) +} + +.vc-search-modal-sub-label { + color: var(--header-muted) +} + +.vc-search-modal-sub-label-icon { + width: 12px; + height: 12px; + margin-right: 2px +} + +.vc-search-modal-thread-sub-label { + display: flex; + align-items: center +} diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx new file mode 100644 index 000000000..c87a0a067 --- /dev/null +++ b/src/components/SearchModal.tsx @@ -0,0 +1,731 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./SearchModal.css"; + +import { classNameFactory } from "@api/Styles"; +import { + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalProps, + ModalRoot, + ModalSize +} from "@utils/modal"; +import { findByCodeLazy, findByPropsLazy, findComponentByCodeLazy } from "@webpack"; +import { + Button, ChannelStore, + Flex, GuildStore, + Heading, + PresenceStore, + React, + RelationshipStore, + Text, + useCallback, + useMemo, + useRef, + UsernameUtils, UserStore, + useState +} from "@webpack/common"; +import { Channel, User } from "discord-types/general"; + +const cl = classNameFactory("vc-search-modal-"); + +// TODO make guilds work +// FIXME fix the no results display + +// TODO add all channel types + +// TODO filter for input type +// TODO setting for max amount of selected items + +// FIXME remove scrolling up onclick +// FIXME move selected items to the top of the list. + +const SearchBarModule = findByPropsLazy("SearchBar", "Checkbox", "AvatarSizes"); +const SearchBarWrapper = findByPropsLazy("SearchBar", "Item"); +const TextTypes = findByPropsLazy("APPLICATION", "GROUP_DM", "GUILD"); +const FrequencyModule = findByPropsLazy("getFrequentlyWithoutFetchingLatest"); +const ConnectionModule = findByPropsLazy("isConnected", "getSocket"); +const FrequentsModule = findByPropsLazy("getChannelHistory", "getFrequentGuilds"); + +const wrapperFn = findByCodeLazy("prevDeps:void 0,"); +const convertItem = findByCodeLazy("GROUP_DM:return{", "GUILD_VOICE:case"); +const loadFunction = findByCodeLazy(".frecencyWithoutFetchingLatest)"); +const SearchHandler = findByCodeLazy("createSearchContext", "setLimit"); +const navigatorWrapper = findByCodeLazy("useMemo(()=>({onKeyDown:"); +const createNavigator = findByCodeLazy(".keyboardModeEnabled)", "useCallback(()=>new Promise(", "Number.MAX_SAFE_INTEGER"); +const getChannelLabel = findByCodeLazy("recipients.map(", "getNickname("); +const ChannelIcon = findByCodeLazy("channelGuildIcon,"); + +const GroupDMAvatars = findComponentByCodeLazy("facepileSizeOverride", "recipients.length"); + +interface DestinationItemProps { + type: string; + id: string; +} + +interface UnspecificRowProps { + key: string + destination: DestinationItemProps, + rowMode: string + disabled: boolean, + isSelected: boolean, + onPressDestination: (destination: DestinationItemProps) => void, + "aria-posinset": number, + "aria-setsize": number +} +interface SpecificRowProps extends UnspecificRowProps { + icon: React.JSX.Element, + label: string, + subLabel: string | React.JSX.Element +} + +interface UserIconProps { + user: User; + size?: number; + animate?: boolean; + "aria-hidden"?: boolean; + [key: string]: any; // To allow any additional props +} + +const searchTypesToResultTypes = (type: string | string[]) => { + if (type === "ALL") return ["USER", "TEXT_CHANNEL", "VOICE_CHANNEL", "GROUP_DM", "GUILD"]; + if (typeof type === "string") { + if (type === "USERS") return ["USER"]; + else if (type === "CHANNELS") return ["TEXT_CHANNEL", "VOICE_CHANNEL", "GROUP_DM"]; + else if (type === "GUILDS") return ["GUILD"]; + } else { + return type.flatMap(searchTypesToResultTypes); + } +}; + +function searchTypeToText(type: string | string[]) { + if (type === undefined || type === "ALL") return "Users, Channels, and Servers"; + if (typeof type === "string") { + if (type === "GUILD") return "Servers"; + else return type.charAt(0) + type.slice(1).toLowerCase(); + } else { + if (type.length === 1) { + return searchTypeToText(type[0]); + } else if (type.length === 2) { + return `${searchTypeToText(type[0])} and ${searchTypeToText(type[1])}`; + } else { + return "Users, Channels, and Servers"; + } + } +} + +export default function SearchModal({ modalProps, onSubmit, input, searchType = "ALL", subText }: { + modalProps: ModalProps; + onSubmit(selected: DestinationItemProps[]): void; + input?: string; + searchType?: ("USERS" | "CHANNELS" | "GUILDS")[] | "USERS" | "CHANNELS" | "GUILDS" | "ALL"; + subText?: string +}) { + + const callbacks = new Map(); + + function registerCallback(key, callback) { + let currentCallbacks = callbacks.get(key); + if (!currentCallbacks) { + currentCallbacks = new Set(); + callbacks.set(key, currentCallbacks); + } + + currentCallbacks.add(callback); + + return () => { + currentCallbacks.delete(callback); + if (currentCallbacks.size === 0) { + callbacks.delete(key); + } + }; + + } + + const UserIcon = React.memo(function ({ + user, + size = SearchBarModule.AvatarSizes.SIZE_32, + animate = false, + "aria-hidden": ariaHidden = false, + ...rest + }: UserIconProps) { + + const avatarSrc = user.getAvatarURL(void 0, SearchBarModule.getAvatarSize(size), animate); + + return ( + + ); + }); + + const resultTypes = searchTypesToResultTypes(searchType); + + const [selected, setSelected] = useState([]); + + const refCounter = useRef(0); + + const rowContext = React.createContext({ + id: "NO_LIST", + setFocus(id: string) { + } + }); + + const Row = (props: SpecificRowProps) => { + const { + destination, + rowMode, + icon, + label, + subLabel, + isSelected, + disabled, + onPressDestination, + ...rest + } = props; + + const interactionProps = generateRowData(destination.id); + + const handlePress = useCallback(() => { + onPressDestination?.(destination); + }, [onPressDestination, destination]); + + return ( + + + {icon} + + {label} + {subLabel} + + + + + ); + }; + + function generateUserItem(user: User, otherProps: UnspecificRowProps) { + const username = UsernameUtils.getName(user); + const userTag = UsernameUtils.getUserTag(user, { decoration: "never" }); + const nickname = RelationshipStore.getNickname(user.id); + const userStatus = PresenceStore.getStatus(user.id); + + return ( + } + label={nickname ?? username} + subLabel={userTag} + /> + ); + } + + function generateChannelLabel(channel: Channel) { + return getChannelLabel(channel, UserStore, RelationshipStore, false); + } + + function generateChannelItem(channel: Channel, otherProps: UnspecificRowProps) { + const guild = GuildStore.getGuild(channel?.guild_id); + + const channelLabel = generateChannelLabel(channel); + + const parentChannelLabel = () => { + const parentChannel = ChannelStore.getChannel(channel.parent_id); + return parentChannel ? getChannelLabel(parentChannel, UserStore, RelationshipStore, false) : null; + }; + + let subLabel: string | React.JSX.Element = guild?.name; + + // @ts-ignore isForumPost is not in the types but exists + if (channel.isThread() || channel.isForumPost()) { + // @ts-ignore + const IconComponent = channel.isForumPost() ? SearchBarModule.ForumIcon : SearchBarModule.TextIcon; + + subLabel = ( + + + + {parentChannelLabel()} + + + ); + } + + return ( + + } + label={channelLabel} + subLabel={subLabel} + /> + ); + } + + function generateGdmItem(channel: Channel, otherProps: UnspecificRowProps) { + function getParticipants(channel: Channel) { + const userNames = channel.recipients + .map(recipient => UserStore.getUser(recipient)) + .filter(user => user != null) + .map(user => UsernameUtils.getName(user)); + + if (!userNames || userNames.length === 0 || channel.name === "") + return null; + if (userNames.length <= 3) + return userNames.join(", "); + const amount = userNames.length - 3; + return userNames?.slice(0, 3).join(", ") + " and " + (amount === 1 ? "1 other" : amount + " others"); + } + + const label = getChannelLabel(channel, UserStore, RelationshipStore, false); + const subLabelValue = getParticipants(channel); + + return ( + } + label={label} + subLabel={subLabelValue ?? ""} + /> + ); + } + + const navigatorContext = React.createContext({ + id: "NO_LIST", + onKeyDown() { + }, + orientation: "vertical", + ref: React.createRef(), + tabIndex: -1 + }); + + function generateNavigatorData() { + const { id: id, onKeyDown, ref, tabIndex } = React.useContext(navigatorContext); + return { + role: "list", + tabIndex, + "data-list-id": id, + onKeyDown: onKeyDown, + ref: ref, + }; + } + + function navigatorData(e) { + const { children } = e; + return children(generateNavigatorData()); + } + + + function generateRowData(rowId: string) { + const [tabIndex, setTabIndex] = useState(-1); + + const { id, setFocus } = React.useContext(rowContext); + + const handleFocus = useCallback(() => setFocus(rowId), [rowId, setFocus]); + + React.useLayoutEffect(() => { + return registerCallback(id, (tabIndex, id) => { + setTabIndex(id && tabIndex === rowId ? 0 : -1); + }); + }, [rowId, id]); + + return { + role: "listitem", + "data-list-item-id": `${id}___${rowId}`, + tabIndex, + onFocus: handleFocus, + }; + } + + const [searchText, setSearchText] = useState(input || ""); + const ref = {}; + + function getSearchHandler(e) { + const { searchOptions } = e; + const [results, setResults] = useState({ + results: [], + query: "" + }); + + function getRef(e) { // FIXME probably should use a proper type for this + const ref_ = useRef(ref); + if (ref_.current === ref) + ref_.current = e(); + return ref_.current; + } + + const searchHandler: typeof SearchHandler = getRef(() => { + const searchHandler = new SearchHandler((r, q) => { + setResults({ + results: r, + query: q + }); + } + ); + searchHandler.setLimit(20); + searchHandler.search(""); + return searchHandler; + } + ); + React.useEffect(() => () => searchHandler.destroy(), [searchHandler]); + React.useEffect(() => { + searchOptions != null && searchOptions !== searchHandler.options && searchHandler.setOptions(searchOptions); + }, [searchHandler, searchOptions] + ); + return { + search: useCallback(e => { + const { query, resultTypes } = e; + if (searchHandler.resultTypes == null || !(resultTypes.length === searchHandler.resultTypes.size && resultTypes.every(e => searchHandler.resultTypes.has(e)))) { + searchHandler.setResultTypes(resultTypes); + searchHandler.setLimit(resultTypes.length === 1 ? 50 : 20); + } + searchHandler.search(query.trim() === "" ? "" : query); + } + , [searchHandler]), + ...results + }; + } + + function generateResults({ selectedDestinations }) { + const { search, query, results } = getSearchHandler({ + blacklist: null, + frecencyBoosters: !0, + userFilters: null + }); + + const [queryData, setQueryData] = useState(""); + + const updateSearch = useCallback((e: string) => setQueryData(e), [setQueryData]); + + if (queryData === "" && searchText !== "") { + updateSearch(searchText); + } + + const [pinned, setPinned] = useState(selectedDestinations != null ? selectedDestinations : []); + React.useLayoutEffect(() => { + search({ + query: queryData, + resultTypes: resultTypes, + }); + setPinned(selectedDestinations != null ? selectedDestinations : []); + } + , [search, queryData]); + + loadFunction(); + + const frequentChannels = wrapperFn([FrequencyModule], () => FrequencyModule.getFrequentlyWithoutFetchingLatest()); + const isConnected = wrapperFn([ConnectionModule], () => ConnectionModule.isConnected()); + const hasQuery = query !== ""; + + function getItem(e) { + if (e.type !== "user") + return convertItem(e.id); + { + const user = UserStore.getUser(e.id); + return { + type: TextTypes.USER, + record: user, + score: 0, + // @ts-ignore globalName is not in the types but exists + comparator: user.globalName, + }; + } + } + + function processItems(items, existingItems?) { + let temp: null; + const set = new Set(existingItems || []); + + const array: any[] = []; + + items.forEach(item => { + if (item != null) { + if (item.type === TextTypes.HEADER) temp = item; + else { + const { id } = item.record; + if (!set.has(id)) { + set.add(item); + if (temp != null) { + array.push(temp); + temp = null; + } + array.push(item); + } + } + } + }); + return array; + } + + const filterItems = (items: any[]) => { + return items.filter( + item => item != null && (item.type === TextTypes.HEADER || resultTypes.includes(item.type)) + ); + }; + + function filterResults(e) { + const removeDuplicates = (arr: any[]) => { + const clean: any[] = []; + const seenIds = new Set(); + arr.forEach(item => { + if (item == null || item.record == null) return; + const id = item.type === "user" ? item.id : item.record.id; + if (!seenIds.has(id)) { + seenIds.add(id); + clean.push(item); + } + }); + return clean; + }; + + const { results, hasQuery, frequentChannels, pinnedDestinations } = e; + if (hasQuery) return processItems(filterItems(results)); + + const channelHistory = FrequentsModule.getChannelHistory(); + + const recentDestinations = filterItems([ + ...(channelHistory.length > 0 ? channelHistory.map(e => convertItem(e)) : []), + ...(frequentChannels.length > 0 ? frequentChannels.map(e => convertItem(e.id)) : []) + ]); + + const destinations = removeDuplicates( + [...(pinnedDestinations.length > 0 ? pinnedDestinations.map(e => getItem(e)) : []), + ...recentDestinations + ]); + + return processItems(destinations).slice(0, 15); + } + + return { + results: useMemo(() => filterResults({ + results: results, + hasQuery: hasQuery, + frequentChannels: frequentChannels, + pinnedDestinations: pinned, + isConnected: isConnected + }), [results, hasQuery, frequentChannels, pinned, isConnected]), + updateSearchText: updateSearch + }; + } + + const { results, updateSearchText } = generateResults({ + selectedDestinations: selected, + }); + + const selectedDestinationKeys = useMemo(() => { + return selected?.map(destination => `${destination.type}-${destination.id}`) || []; + }, [selected]); + + const rowHeight = useCallback(() => 48, []); + + function ModalScroller(e) { + + const { rowData: t, handleToggleDestination, ...extraProps } = e; + const sectionCount = useMemo(() => [t.length], [t.length]); + + const callback = useCallback(e => { + const { section, row } = e; + if (section > 0) + return; + const { type, record } = results[row]; + if (type === TextTypes.HEADER) + return; + + const destination = { + type: type === TextTypes.USER ? "user" : "channel", + id: record.id + }; + + const key = `${destination.type}-${destination.id}`; + + + const rowProps: UnspecificRowProps = { + key, + destination, + rowMode: "toggle", + disabled: false, + isSelected: selectedDestinationKeys.includes(key), + onPressDestination: handleToggleDestination, + "aria-posinset": row + 1, + "aria-setsize": results.length + }; + + if (type === TextTypes.USER) + return generateUserItem(record, rowProps); + if (type === TextTypes.GROUP_DM) + return generateGdmItem(record, rowProps); + if (type === TextTypes.TEXT_CHANNEL || type === TextTypes.VOICE_CHANNEL) { + return generateChannelItem(record, rowProps); + } else throw new Error("Unknown type " + type); + }, [results, selectedDestinationKeys, handleToggleDestination]); + const navRef = useRef(null); + const nav = createNavigator(cl("search-modal"), navRef); + + return navigatorWrapper({ + navigator: nav, + children: navigatorData({ + children: e => { + const { ref, ...data } = e; + return { + navRef.current = elem; + ref.current = elem?.getScrollerNode() ?? null; + } + } + {...data} + {...extraProps} + sections={sectionCount} + sectionHeight={0} + renderRow={callback} + rowHeight={rowHeight}/>; + } + }) + }); + } + + + const setSelectedCallback = useCallback(e => { + setSelected(currentSelected => { + const index = currentSelected.findIndex(item => { + const { type, id } = item; + return type === e.type && id === e.id; + }); + + if (index === -1) { + /* if (currentSelected.length >= 5) { TODO add this later + $(""); // Handle the case when max selection is reached + return currentSelected; + } */ + + refCounter.current += 1; + return [e, ...currentSelected]; + } + + refCounter.current += 1; + currentSelected.splice(index, 1); + return [...currentSelected]; + }); + }, [selected]); + + + return ( + + + + + {"Search for " + searchTypeToText(searchType)} + {subText !== undefined && {subText}} + + + + { + setSearchText(v); + updateSearchText(v); + }} + onClear={() => { + setSearchText(""); + updateSearchText(""); + }} + setFocus={true} + /> + + { + results.length > 0 ? : + No results found + + } + + + { + onSubmit(selected); + modalProps.onClose(); + }} + > + Confirm + + + Cancel + + + + + ); +} diff --git a/src/plugins/consoleJanitor/index.ts b/src/plugins/consoleJanitor/index.ts index 29b225cab..e7bbecedd 100644 --- a/src/plugins/consoleJanitor/index.ts +++ b/src/plugins/consoleJanitor/index.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { definePluginSettings, migratePluginSettings, migrateSettingsToArrays } from "@api/Settings"; +import { definePluginSettings, migrateSettingsToArrays } from "@api/Settings"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType, StartAt } from "@utils/types";