diff --git a/src/api/Settings.ts b/src/api/Settings.ts index 08d2f8cacf9..b03b43ec1e8 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -146,6 +146,10 @@ export const SettingsStore = new SettingsStoreClass(settings, { // normal setting with a default value return (target[key] = setting.default); + else if (setting.type === OptionType.ARRAY || setting.type === OptionType.USERS || setting.type === OptionType.GUILDS || setting.type === OptionType.CHANNELS) + // if there is no default value we initialize it as an empty array + return (target[key] = []); + if (setting.type === OptionType.SELECT) { const def = setting.options.find(o => o.default); if (def) diff --git a/src/components/PluginSettings/PluginModal.tsx b/src/components/PluginSettings/PluginModal.tsx index 7baeba081f3..a539e7eadab 100644 --- a/src/components/PluginSettings/PluginModal.tsx +++ b/src/components/PluginSettings/PluginModal.tsx @@ -39,12 +39,13 @@ import { PluginMeta } from "~plugins"; import { ISettingCustomElementProps, ISettingElementProps, + SettingArrayComponent, SettingBooleanComponent, SettingCustomComponent, SettingNumericComponent, SettingSelectComponent, SettingSliderComponent, - SettingTextComponent + SettingTextComponent, } from "./components"; import { openContributorModal } from "./ContributorModal"; import { GithubButton, WebsiteButton } from "./LinkIconButton"; @@ -84,6 +85,10 @@ const Components: Record null, + [OptionType.ARRAY]: SettingArrayComponent, + [OptionType.USERS]: SettingArrayComponent, + [OptionType.CHANNELS]: SettingArrayComponent, + [OptionType.GUILDS]: SettingArrayComponent }; export default function PluginModal({ plugin, onRestartNeeded, onClose, transitionState }: PluginModalProps) { diff --git a/src/components/PluginSettings/components/SettingArrayComponent.tsx b/src/components/PluginSettings/components/SettingArrayComponent.tsx new file mode 100644 index 00000000000..9b8f9d1f659 --- /dev/null +++ b/src/components/PluginSettings/components/SettingArrayComponent.tsx @@ -0,0 +1,415 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@api/Styles"; +import ErrorBoundary from "@components/ErrorBoundary"; +import SearchModal from "@components/SearchModal"; +import { Margins } from "@utils/margins"; +import { openModal } from "@utils/modal"; +import { wordsFromCamel, wordsToTitle } from "@utils/text"; +import { OptionType, PluginOptionArray } from "@utils/types"; +import { findByCodeLazy, findComponentByCodeLazy } from "@webpack"; +import { + Avatar, + Button, + ChannelStore, + Flex, + Forms, + GuildStore, + IconUtils, + React, + Text, + TextInput, + useEffect, + useState, +} from "@webpack/common"; +import { Channel, Guild } from "discord-types/general"; + +import { ISettingElementProps } from "."; + +const cl = classNameFactory("vc-plugin-modal-"); + +const UserMentionComponent = findComponentByCodeLazy(".USER_MENTION)"); +const getDMChannelIcon = findByCodeLazy(".getChannelIconURL({"); +const GroupDMAvatars = findComponentByCodeLazy(".AvatarSizeSpecs[", "getAvatarURL"); + + +const CloseIcon = () => { + return ; +}; + +const CheckMarkIcon = () => { + return + + ; +}; + +const SearchIcon = () => { + return + + + ; +}; + +export const SettingArrayComponent = ErrorBoundary.wrap(function SettingArrayComponent({ + option, + pluginSettings, + definedSettings, + onChange, + onError, + id +}: ISettingElementProps) { + const [error, setError] = useState(null); + const [items, setItems] = useState(ensureSettingsMigrated() || []); + const [text, setText] = useState(""); + + function ensureSettingsMigrated(): string[] | undefined { + // in case the settings get manually overridden without a restart of Vencord itself this will prevent crashing + if (pluginSettings[id] == null || Array.isArray(pluginSettings[id])) { + return pluginSettings[id]; + } + let migrated: string[]; + if (typeof option.oldStringSeparator === "string" || option.oldStringSeparator instanceof RegExp) { + migrated = pluginSettings[id]?.split(option.oldStringSeparator); + } else if (typeof option.oldStringSeparator === "function") { + migrated = option.oldStringSeparator(pluginSettings[id]); + } else { + throw new Error(`Invalid oldStringSeparator for in setting ${id} for plugin ${definedSettings?.pluginName || "Unknown plugin"}`); + } + onChange(migrated); + return migrated; + } + + useEffect(() => { + if (text === "") { + setError(null); + return; + } + + if (items.includes(text)) { + setError("This item is already added"); + return; + } + + if (option.type !== OptionType.ARRAY && !isNaN(Number(text)) && text !== "") { + if (text.length >= 18 && text.length <= 19) { + setError(null); + } else { + setError("Invalid ID"); + } + } else { + const isValid = option.isValid?.call(definedSettings, text) ?? true; + if (typeof isValid === "string") setError(isValid); + else if (!isValid) setError("Invalid input provided."); + else setError(null); + } + }, [text]); + + + useEffect(() => { + pluginSettings[id] = items; + onChange(items); + }, [items]); + + useEffect(() => { + onError(error !== null); + }, [error]); + + function openSearchModal(val?: string) { + return openModal(modalProps => ( + setItems([...items, ...values.map(v => v.id)])} + excludeIds={items} + /> + )); + } + + const removeButton = (id: string) => { + return ( + + ); + }; + + const guildIcon = (guild: Guild) => { + const icon = guild?.icon == null ? undefined : IconUtils.getGuildIconURL({ + id: guild.id, + icon: guild.icon, + size: 16, + }); + return icon != null && ; + + }; + + const removeItem = (itemId: string) => { + if (items.length === 1) { + setItems([]); + return; + } + setItems(items.filter(item => item !== itemId)); + }; + + function renderGuildView() { + return items.map(item => GuildStore.getGuild(item) || item) + .map((guild, index) => ( + + {typeof guild !== "string" ? ( +
+ + {guildIcon(guild)} + {guild.name} + +
+ ) : {`Unknown Guild (${guild})`}} + {removeButton(typeof guild !== "string" ? guild.id : guild)} +
+ )); + } + + function renderChannelView() { + + const getChannelSymbol = (type: number) => { + switch (type) { + case 2: + return ; + + case 5: + return ; + + case 13: + return ; + + case 15: + return ; + + default: // Text channel icon + return ; + } + }; + + const channels: Record = {}; + const dmChannels: Channel[] = []; + const elements: React.JSX.Element[] = []; + + // to not remove items while iterating + const invalidChannels: string[] = []; + + for (const item of items) { + const channel = ChannelStore.getChannel(item); + if (!channel) { + invalidChannels.push(item); + continue; + } + if (channel.isDM() || channel.isGroupDM()) { + dmChannels.push(channel); + continue; + } + if (!channels[channel.guild_id]) { + channels[channel.guild_id] = []; + } + channels[channel.guild_id].push(channel); + } + + for (const channel of invalidChannels) { + removeItem(channel); + } + + const userMention = (channel: Channel) => { + return ; + }; + + const gdmComponent = (channel: Channel) => { + return + {channel.recipients.length >= 2 && channel.icon == null ? ( + + ) : ( + + )} + {channel.name} + ; + }; + + let idx = -1; + + if (dmChannels.length > 0) { + elements.push( +
+ DMs +
+ {dmChannels.map(channel => { + idx += 1; + return + {channel.recipients.length === 1 ? userMention(channel) : gdmComponent(channel)} + {removeButton(channel.id)} + ; + })} +
+
+ ); + } + + const guilds: { name: string; guild: React.JSX.Element }[] = []; + + Object.keys(channels).forEach(guildId => { + const guild = GuildStore.getGuild(guildId); + guilds.push( + { name: guild?.name ?? `Unknown Guild (${guildId})`, guild: ( +
+ {!guild ? {`Unknown Guild (${guildId})`} : ( + + + {guildIcon(guild)} + {guild.name} + + + )} +
+ {channels[guildId].map(channel => { + idx += 1; + return + + {getChannelSymbol(channel.type)} + {channel.name} + {removeButton(channel.id)} + + ; + })} +
+
) } + ); + }); + + guilds.sort((a, b) => a.name.localeCompare(b.name)); + + for (const guild of guilds) { + elements.push(guild.guild); + } + + return elements; + } + + + return ( + + {wordsToTitle(wordsFromCamel(id))} + {option.description} + {option.type === OptionType.ARRAY || option.type === OptionType.USERS ? + items.map((item, index) => ( + + {option.type === OptionType.USERS ? ( + + ) : ( + {item} + )} + {removeButton(item)} + + )) : option.type === OptionType.CHANNELS ? + renderChannelView() : renderGuildView() + } + + setText(v)} + value={text} + /> + {option.type === OptionType.ARRAY || (!isNaN(Number(text)) && text !== "") ? + : + < Button + id={cl("search-button")} + size={Button.Sizes.MIN} + onClick={() => openSearchModal(text)} + style={ + { background: "none" } + } + > + + + } + + {error && {error}} + + ); +},); diff --git a/src/components/PluginSettings/components/index.ts b/src/components/PluginSettings/components/index.ts index c38f209b716..1ea661f32d7 100644 --- a/src/components/PluginSettings/components/index.ts +++ b/src/components/PluginSettings/components/index.ts @@ -34,6 +34,7 @@ export type ISettingElementProps = ISettingElementPr export type ISettingCustomElementProps> = ISettingElementPropsBase; export * from "../../Badge"; +export * from "./SettingArrayComponent"; export * from "./SettingBooleanComponent"; export * from "./SettingCustomComponent"; export * from "./SettingNumericComponent"; diff --git a/src/components/SearchModal.css b/src/components/SearchModal.css new file mode 100644 index 00000000000..e322a788a98 --- /dev/null +++ b/src/components/SearchModal.css @@ -0,0 +1,67 @@ +.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 +} + +.vc-search-modal-header-text { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + margin-bottom: 8px; +} + +.vc-search-modal-no-results-container { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx new file mode 100644 index 00000000000..278abaaf1c1 --- /dev/null +++ b/src/components/SearchModal.tsx @@ -0,0 +1,712 @@ +/* + * 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 { ErrorBoundary } from "@components/index"; +import { + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalListContent, + ModalProps, + ModalRoot, + ModalSize +} from "@utils/modal"; +import { findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack"; +import { + Avatar, + Button, + ChannelStore, + Checkbox, + Clickable, + Flex, + GuildStore, + Heading, + PresenceStore, + React, + RelationshipStore, + SearchBar, + Text, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + UsernameUtils, + UserStore, + useState, + useStateFromStores, +} from "@webpack/common"; +import { Channel, Guild, User } from "discord-types/general"; +import { JSX } from "react"; + +const cl = classNameFactory("vc-search-modal-"); + +const ColorModule = findByPropsLazy("colors", "modules", "themes"); +const SearchBarWrapper = findByPropsLazy("SearchBar"); +const TextTypes = findByPropsLazy("APPLICATION", "GROUP_DM", "GUILD"); +const SearchHandler = findByCodeLazy("createSearchContext", "setLimit"); + +const convertItem = findByCodeLazy("GROUP_DM:return{", "GUILD_VOICE:case"); +const loadFrecency = findByCodeLazy(".frecencyWithoutFetchingLatest)"); +const getChannelLabel = findByCodeLazy("recipients.map(", "getNickname("); + +const ChannelIcon = findComponentByCodeLazy("channelGuildIcon,"); +const GroupDMAvatars = findComponentByCodeLazy("facepileSizeOverride", "recipients.length"); + +const FrecencyStore = findStoreLazy("FrecencyStore"); +const QuickSwitcherStore = findStoreLazy("QuickSwitcherStore"); + + +interface DestinationItem { + type: "channel" | "user" | "guild"; + id: string; +} + +interface UnspecificRowProps { + key: string, + destination: DestinationItem, + rowMode: string, + disabled: boolean, + isSelected: boolean, + onPressDestination: (destination: DestinationItem) => void, + "aria-posinset": number, + "aria-setsize": number, +} + +interface SpecificRowProps extends UnspecificRowProps { + icon: JSX.Element, + label: string, + subLabel: string | JSX.Element, +} + +interface UserIconProps { + user: User; + animate?: boolean; + "aria-hidden"?: boolean; + [key: string]: any; +} + +interface UserResult { + type: "USER"; + record: User; + score: number; + comparator: string; + sortable?: string; +} + +interface ChannelResult { + type: "TEXT_CHANNEL" | "VOICE_CHANNEL" | "GROUP_DM"; + record: Channel; + score: number; + comparator: string; + sortable?: string; +} + +interface GuildResult { + type: "GUILD"; + record: Guild; + score: number; + comparator: string; + sortable?: string; +} + +type Result = UserResult | ChannelResult | GuildResult; +type SearchType = ("USERS" | "CHANNELS" | "GUILDS")[] | "USERS" | "CHANNELS" | "GUILDS" | "ALL"; + +export interface SearchModalProps { + modalProps: ModalProps; + onSubmit(selected: DestinationItem[]): void; + input?: string; + searchType?: SearchType; + subText?: string; + excludeIds?: string[], +} + +const searchTypesToResultTypes = (type: SearchType) => { + 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: SearchType) { + if (type === undefined || type === "ALL") return "Users, Channels, and Servers"; + if (typeof type === "string") { + if (type === "GUILDS") 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"; + } + } +} + +/** + * SearchModal component for displaying a modal with search functionality, built after Discord's forwarding Modal. + * + * @param {SearchModalProps} props - The props for the SearchModal component. + * @param {ModalProps} props.modalProps - The modal props. You get these from the `openModal` function. + * @param {function} props.onSubmit - Callback function invoked when the user submits their selection. + * @param {string} [props.input] - The initial input value for the search bar. + * @param {SearchType} [props.searchType="ALL"] - The type of items to search for. + * @param {string} [props.subText] - Additional text to display below the heading. + * @param {string[]} [props.excludeIds] - An array of IDs to exclude from the search results. + * @returns The rendered SearchModal component. + */ +export default ErrorBoundary.wrap(function SearchModal({ modalProps, onSubmit, input, searchType = "ALL", subText, excludeIds }: SearchModalProps) { + + const UserIcon = React.memo(function ({ + user, + animate = false, + "aria-hidden": ariaHidden = false, + ...rest + }: UserIconProps) { + + // FIXME + const avatarSrc = user.getAvatarURL(void 0, 32, animate); + + return ( + + ); + }); + + const resultTypes = searchTypesToResultTypes(searchType); + + const [selected, setSelected] = useState([]); + + const Row = (props: SpecificRowProps) => { + const { + destination, + rowMode, + icon, + label, + subLabel, + isSelected, + disabled, + onPressDestination, + ...rest + } = props; + + const interactionProps = { + role: "listitem", + "data-list-item-id": `NO_LIST___${destination.id}`, + tabIndex: -1, + }; + return ( + { onPressDestination?.(destination); }} + aria-selected={isSelected} + {...interactionProps} + {...rest} + > +
+
{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 generateChannelItem(channel: Channel, otherProps: UnspecificRowProps) { + const guild = GuildStore.getGuild(channel?.guild_id); + + const svgProps = { + "aria-hidden": true, + className: cl("sub-label-icon"), + role: "img", + xmlns: "http://www.w3.org/2000/svg", + width: 24, + height: 24, + fill: "none", + viewBox: "0 0 24 24" + }; + const ForumChannelSvg = () => + + + ; + + const TextIcon = () => + + ; + + const channelLabel = getChannelLabel(channel, UserStore, RelationshipStore, false); + + const parentChannelLabel = (): string | null => { + const parentChannel = ChannelStore.getChannel(channel.parent_id); + return parentChannel ? getChannelLabel(parentChannel, UserStore, RelationshipStore, false) : null; + }; + + let subLabel: string | JSX.Element = guild?.name; + + // @ts-ignore isForumPost is not in the types but exists + if (channel.isThread() || channel.isForumPost()) { + subLabel = ( +
+ { + // @ts-ignore isForumPost is not in the types but exists + channel.isForumPost() ? : + } + + {parentChannelLabel()} + +
+ ); + } + + return ( + + } + label={channelLabel} + subLabel={subLabel} + /> + ); + } + + function generateGuildItem(guild: Guild, otherProps: UnspecificRowProps) { + const guildName = guild.name; + const guildIcon = guild.getIconURL("SIZE_32", false); + + return ( + } + label={guildName} + 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 ""; + 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 [searchText, setSearchText] = useState(input || ""); + const ref = {}; + + function getItem(e: DestinationItem): Result { + if (e.type === "guild") { + const guild = GuildStore.getGuild(e.id); + return { + type: TextTypes.GUILD, + record: guild, + score: 0, + comparator: guild.name, + }; + } + 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, + }; + } + + const filterItems = (items: any[]) => { + return items.filter( + item => item != null && resultTypes.includes(item.type) && !excludeIds?.includes(item.record.id) + ); + }; + + function filterResults(props: { + results: Result[]; + hasQuery: boolean; + frequentChannels: Channel[]; + channelHistory: string[]; + }): Result[] { + const removeDuplicates = (arr: Result[]): Result[] => { + const clean: any[] = []; + const seenIds = new Set(); + arr.forEach(item => { + if (item == null || item.record == null) return; + if (!seenIds.has(item.record.id)) { + seenIds.add(item.record.id); + clean.push(item); + } + }); + return clean; + }; + + let guilds: GuildResult[] = []; + + if (resultTypes.includes("GUILD")) + guilds = Object.values(GuildStore.getGuilds()).map( + guild => { + return { + type: TextTypes.GUILD, + record: guild, + score: 0, + comparator: guild.name + }; + } + ); + + const { results, hasQuery, frequentChannels, channelHistory } = props; + if (hasQuery) return filterItems(results); + + const recentDestinations = filterItems([ + ...(channelHistory.length > 0 ? channelHistory.map(e => convertItem(e)) : []), + ...(frequentChannels.length > 0 ? frequentChannels.map(e => convertItem(e.id)) : []), + ...guilds + ]); + + + return removeDuplicates( + [...selected.map(e => getItem(e)), + ...recentDestinations + ]); + } + + function getRef(e: () => T): T { + const ref_ = useRef(ref as T); + if (ref_.current === ref) + ref_.current = e(); + return ref_.current; + } + + function getSearchHandler(searchOptions: Record): { search: (e: { query: string, resultTypes: string[]; }) => void, results: Result[], query: string; } { + const [results, setResults] = useState<{ results: Result[], query: string; }>({ + results: [], + query: "" + }); + + const searchHandler: InstanceType = getRef(() => { + const searchHandler = new SearchHandler((r: Result[], q: string) => { + setResults({ + results: r, + query: q + }); + } + ); + searchHandler.setOptions(searchOptions); + searchHandler.setLimit(20); + searchHandler.search(""); + return searchHandler; + } + ); + useEffect(() => () => searchHandler.destroy(), [searchHandler]); + 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() { + 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); + } + + useLayoutEffect(() => { + search({ + query: queryData, + resultTypes: resultTypes, + }); + }, [search, queryData]); + + loadFrecency(); + + const frequentChannels: Channel[] = useStateFromStores([FrecencyStore], () => FrecencyStore.getFrequentlyWithoutFetchingLatest()); + const channelHistory: string[] = useStateFromStores([QuickSwitcherStore], () => QuickSwitcherStore.getChannelHistory()); + + const hasQuery = query !== ""; + + return { + results: useMemo(() => filterResults({ + results: results, + hasQuery: hasQuery, + frequentChannels: frequentChannels, + channelHistory: channelHistory, + }), [results, hasQuery, frequentChannels, channelHistory]), + updateSearchText: updateSearch + }; + } + + const { results, updateSearchText } = generateResults(); + + function ModalScroller({ rowData, handleToggleDestination, paddingBottom, paddingTop }: { rowData: Result[], handleToggleDestination: (destination: DestinationItem) => void, paddingBottom?: number, paddingTop?: number; }) { + + const sectionCount: number[] = useMemo(() => [rowData.length], [rowData.length]); + + const callback = useCallback((e: { section: number, row: number; }) => { + const { section, row } = e; + if (section > 0) + return; + const { type, record } = results[row]; + if (type === TextTypes.HEADER) + return; + + const destination: DestinationItem = { + type: type === TextTypes.USER ? "user" : type === TextTypes.GUILD ? "guild" : "channel", + id: record.id + }; + + const key = `${destination.type}-${destination.id}`; + + const rowProps: UnspecificRowProps = { + key, + destination, + rowMode: "toggle", + disabled: false, + isSelected: selected.some(e => e.type === destination.type && e.id === destination.id), + onPressDestination: handleToggleDestination, + "aria-posinset": row + 1, + "aria-setsize": results.length + }; + + if (type === "USER") + return generateUserItem(record, rowProps); + if (type === "GROUP_DM") + return generateGdmItem(record, rowProps); + if (type === "TEXT_CHANNEL" || type === "VOICE_CHANNEL") + return generateChannelItem(record, rowProps); + if (type === "GUILD") + return generateGuildItem(record, rowProps); + else throw new Error("Unknown type " + type); + }, []); + + return ; + } + + + const setSelectedCallback = useCallback((e: DestinationItem) => { + setSelected((currentSelected: DestinationItem[]) => { + const index = currentSelected.findIndex(item => { + const { type, id } = item; + return type === e.type && id === e.id; + }); + + if (index === -1) { + return [e, ...currentSelected]; + } + + currentSelected.splice(index, 1); + return [...currentSelected]; + }); + }, []); + + return ( + + +
+
+ {"Search for " + searchTypeToText(searchType)} + {subText !== undefined && {subText}} +
+ +
+ { + setSearchText(v); + updateSearchText(v); + }} + onClear={() => { + setSearchText(""); + updateSearchText(""); + }} + /> +
+ { + results.length > 0 ? : +
+ + + + No results found +
+
+ } + + + + + +
+ ); +}, { + noop: true, + onError: ({ props }) => props.modalProps.onClose() +}); diff --git a/src/plugins/_api/settingArrays.tsx b/src/plugins/_api/settingArrays.tsx new file mode 100644 index 00000000000..28a884f90a7 --- /dev/null +++ b/src/plugins/_api/settingArrays.tsx @@ -0,0 +1,135 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { Devs } from "@utils/constants"; +import { getIntlMessage } from "@utils/discord"; +import definePlugin, { OptionType } from "@utils/types"; +import { Menu, React } from "@webpack/common"; + +function createContextMenu(name: string, value: any) { + return ( + + {renderRegisteredPlugins(name, value)} + + ); +} + + +function renderRegisteredPlugins(name: string, value: any) { + const type = name === "Guild" ? OptionType.GUILDS : name === "User" ? OptionType.USERS : OptionType.CHANNELS; + const plugins = registeredPlugins[type]; + + + const [checkedItems, setCheckedItems] = React.useState>( + Object.fromEntries( + Object.keys(plugins).flatMap(plugin => + plugins[plugin].map(setting => [`${plugin}-${setting}-${value.id}`, Vencord.Plugins.plugins[plugin].settings?.store[setting].includes(value.id)]) + ) + ) + ); + + const handleCheckboxClick = (plugin: string, setting: string) => { + const key = `${plugin}-${setting}-${value.id}`; + setCheckedItems(prevState => ({ + ...prevState, + [key]: !prevState[key] + })); + + // settings must be defined otherwise the checkbox wouldn't exist in the first place + const s = Vencord.Plugins.plugins[plugin].settings!; + s.store[setting] = s.store[setting].includes(value.id) + ? s.store[setting].filter((id: string) => id !== value.id) + : [...s.store[setting], value.id]; + + s.def[setting].onChange?.(s.store[setting]); + + }; + return Object.keys(plugins).map(plugin => ( + + {plugins[plugin].map(setting => ( + handleCheckboxClick(plugin, setting)} + checked={checkedItems[`${plugin}-${setting}-${value.id}`]} + /> + ))} + + )); +} + + +function MakeContextCallback(name: "Guild" | "User" | "Channel"): NavContextMenuPatchCallback { + return (children, props) => { + if (!registeredPlugins[OptionType.CHANNELS] && !registeredPlugins[OptionType.USERS] && !registeredPlugins[OptionType.GUILDS]) + return; + const value = props[name.toLowerCase()]; + if (!value) return; + if (props.label === getIntlMessage("CHANNEL_ACTIONS_MENU_LABEL")) return; // random shit like notification settings + + const lastChild = children.at(-1); + if (lastChild?.key === "developer-actions") { + const p = lastChild.props; + if (!Array.isArray(p.children)) + p.children = [p.children]; + + children = p.children; + } + children.splice(-1, 0, + createContextMenu(name, value) + ); + }; +} + + +// {type: {plugin: [setting, setting, setting]}} +const registeredPlugins: Record>> = { + [OptionType.USERS]: {}, + [OptionType.GUILDS]: {}, + [OptionType.CHANNELS]: {} +}; + + +export default definePlugin({ + name: "SettingArraysAPI", + description: "API that automatically adds context menus for User/Guild/Channel arrays of plugins", + authors: [Devs.Elvyra], + contextMenus: { + "channel-context": MakeContextCallback("Channel"), + "thread-context": MakeContextCallback("Channel"), + "gdm-context": MakeContextCallback("Channel"), + "guild-context": MakeContextCallback("Guild"), + "user-context": MakeContextCallback("User") + }, + required: true, + + start() { + for (const plugin of Object.values(Vencord.Plugins.plugins)) { + if (!Vencord.Plugins.isPluginEnabled(plugin.name) || !plugin.settings) continue; + const settings = plugin.settings.def; + for (const settingKey of Object.keys(settings)) { + const setting = settings[settingKey]; + if ((setting.type === OptionType.USERS || setting.type === OptionType.GUILDS || setting.type === OptionType.CHANNELS) && !setting.hidePopout) { + if (!registeredPlugins[setting.type][plugin.name]) { + registeredPlugins[setting.type][plugin.name] = []; + } + registeredPlugins[setting.type][plugin.name].push(settingKey); + } + } + } + } +}); + diff --git a/src/plugins/consoleJanitor/index.ts b/src/plugins/consoleJanitor/index.ts index 34f5653c41d..677566aa3c2 100644 --- a/src/plugins/consoleJanitor/index.ts +++ b/src/plugins/consoleJanitor/index.ts @@ -38,12 +38,13 @@ const settings = definePluginSettings({ restartNeeded: true }, whitelistedLoggers: { - type: OptionType.STRING, - description: "Semi colon separated list of loggers to allow even if others are hidden", - default: "GatewaySocket; Routing/Utils", - onChange(newVal: string) { + type: OptionType.ARRAY, + description: "List of loggers to allow even if others are hidden", + default: ["GatewaySocket", "Routing/Utils"], + oldStringSeparator: s => s.split(";").map(x => x.trim()), + onChange(newVal: string[]) { logAllow.clear(); - newVal.split(";").map(x => x.trim()).forEach(logAllow.add.bind(logAllow)); + newVal.forEach(logAllow.add.bind(logAllow)); } } }); @@ -57,7 +58,7 @@ export default definePlugin({ startAt: StartAt.Init, start() { logAllow.clear(); - this.settings.store.whitelistedLoggers?.split(";").map(x => x.trim()).forEach(logAllow.add.bind(logAllow)); + settings.store.whitelistedLoggers.forEach(logAllow.add.bind(logAllow)); }, NoopLogger: () => NoopLogger, diff --git a/src/plugins/index.ts b/src/plugins/index.ts index e1899b74397..44d8d73d652 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -23,13 +23,20 @@ import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu"; import { addMemberListDecorator, removeMemberListDecorator } from "@api/MemberListDecorators"; import { addMessageAccessory, removeMessageAccessory } from "@api/MessageAccessories"; import { addMessageDecoration, removeMessageDecoration } from "@api/MessageDecorations"; -import { addMessageClickListener, addMessagePreEditListener, addMessagePreSendListener, removeMessageClickListener, removeMessagePreEditListener, removeMessagePreSendListener } from "@api/MessageEvents"; +import { + addMessageClickListener, + addMessagePreEditListener, + addMessagePreSendListener, + removeMessageClickListener, + removeMessagePreEditListener, + removeMessagePreSendListener +} from "@api/MessageEvents"; import { addMessagePopoverButton, removeMessagePopoverButton } from "@api/MessagePopover"; import { Settings, SettingsStore } from "@api/Settings"; import { disableStyle, enableStyle } from "@api/Styles"; import { Logger } from "@utils/Logger"; import { canonicalizeFind, canonicalizeReplacement } from "@utils/patches"; -import { Patch, Plugin, PluginDef, ReporterTestable, StartAt } from "@utils/types"; +import { OptionType, Patch, Plugin, PluginDef, ReporterTestable, StartAt } from "@utils/types"; import { FluxDispatcher } from "@webpack/common"; import { FluxEvents } from "@webpack/types"; @@ -151,6 +158,28 @@ for (const p of pluginsValues) { const def = p.settings.def[name]; const checks = p.settings.checks?.[name]; p.options[name] = { ...def, ...checks }; + + // TODO remove this in a few months when everyone has updated. + if ( + (def.type === OptionType.ARRAY || def.type === OptionType.USERS || def.type === OptionType.GUILDS || def.type === OptionType.CHANNELS) + && typeof p.settings.store[name] === "string" + ) { + if (p.settings.store[name] === "") + p.settings.store[name] = def.default ?? []; + else { + logger.info(`Converting string values of setting ${name} of plugin ${p.name} to array`); + + const sep = def.oldStringSeparator ?? ","; + let newVal: string[]; + if (typeof sep === "string" || sep instanceof RegExp) newVal = p.settings.store[name].split(sep); + else newVal = sep(p.settings.store[name]); + + // additional safeguard to prevent the new array to be an empty string, looks weird in the UI. + if (newVal.length > 1 || newVal[0] !== "") p.settings.store[name] = newVal; + else p.settings.store[name] = []; + + } + } } } diff --git a/src/plugins/invisibleChat.desktop/index.tsx b/src/plugins/invisibleChat.desktop/index.tsx index d6a39cbafce..2c086d32b30 100644 --- a/src/plugins/invisibleChat.desktop/index.tsx +++ b/src/plugins/invisibleChat.desktop/index.tsx @@ -91,11 +91,13 @@ const ChatBarIcon: ChatBarButtonFactory = ({ isMainChat }) => { ); }; + const settings = definePluginSettings({ savedPasswords: { - type: OptionType.STRING, - default: "password, Password", - description: "Saved Passwords (Seperated with a , )" + type: OptionType.ARRAY, + default: ["password", "Password"], + description: "Saved Passwords", + oldStringSeparator: s => s.split(",").map(s => s.trim()), } }); @@ -200,7 +202,7 @@ export function isCorrectPassword(result: string): boolean { } export async function iteratePasswords(message: Message): Promise { - const passwords = settings.store.savedPasswords.split(",").map(s => s.trim()); + const passwords = settings.store.savedPasswords; if (!message?.content || !passwords?.length) return false; diff --git a/src/plugins/loadingQuotes/index.ts b/src/plugins/loadingQuotes/index.ts index 9bfc88a73c9..84043817762 100644 --- a/src/plugins/loadingQuotes/index.ts +++ b/src/plugins/loadingQuotes/index.ts @@ -25,6 +25,7 @@ import presetQuotesText from "file://quotes.txt"; const presetQuotes = presetQuotesText.split("\n").map(quote => /^\s*[^#\s]/.test(quote) && quote.trim()).filter(Boolean) as string[]; const noQuotesQuote = "Did you really disable all loading quotes? What a buffoon you are..."; + const settings = definePluginSettings({ replaceEvents: { description: "Should this plugin also apply during events with special event themed quotes? (e.g. Halloween)", @@ -42,14 +43,16 @@ const settings = definePluginSettings({ default: false }, additionalQuotes: { - description: "Additional custom quotes to possibly appear, separated by the below delimiter", - type: OptionType.STRING, - default: "", - }, - additionalQuotesDelimiter: { - description: "Delimiter for additional quotes", - type: OptionType.STRING, - default: "|", + description: "Additional custom quotes to possibly appear", + type: OptionType.ARRAY, + oldStringSeparator: s => { + if ("additionalQuotesDelimiter" in settings.store) { + const deli = settings.store.additionalQuotesDelimiter ?? "|"; + delete settings.store.additionalQuotesDelimiter; + return s.split(deli); + } + return s.split("|"); + }, }, }); @@ -79,16 +82,15 @@ export default definePlugin({ mutateQuotes(quotes: string[]) { try { - const { enableDiscordPresetQuotes, additionalQuotes, additionalQuotesDelimiter, enablePluginPresetQuotes } = settings.store; + const { enableDiscordPresetQuotes, additionalQuotes, enablePluginPresetQuotes } = settings.store; if (!enableDiscordPresetQuotes) quotes.length = 0; - if (enablePluginPresetQuotes) quotes.push(...presetQuotes); - quotes.push(...additionalQuotes.split(additionalQuotesDelimiter).filter(Boolean)); + quotes.push(...additionalQuotes); if (!quotes.length) quotes.push(noQuotesQuote); diff --git a/src/plugins/messageLogger/index.tsx b/src/plugins/messageLogger/index.tsx index dee58f2f9c1..ae62379d3e0 100644 --- a/src/plugins/messageLogger/index.tsx +++ b/src/plugins/messageLogger/index.tsx @@ -20,7 +20,7 @@ import "./messageLogger.css"; import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; import { updateMessage } from "@api/MessageUpdater"; -import { Settings } from "@api/Settings"; +import { definePluginSettings, Settings } from "@api/Settings"; import { disableStyle, enableStyle } from "@api/Styles"; import ErrorBoundary from "@components/ErrorBoundary"; import { Devs } from "@utils/constants"; @@ -36,6 +36,64 @@ import overlayStyle from "./deleteStyleOverlay.css?managed"; import textStyle from "./deleteStyleText.css?managed"; import { openHistoryModal } from "./HistoryModal"; +const settings = definePluginSettings({ + deleteStyle: { + type: OptionType.SELECT, + description: "The style of deleted messages", + options: [ + { label: "Red text", value: "text", default: true }, + { label: "Red overlay", value: "overlay" } + ], + onChange: () => addDeleteStyle() + }, + logDeletes: { + type: OptionType.BOOLEAN, + description: "Whether to log deleted messages", + default: true, + }, + collapseDeleted: { + type: OptionType.BOOLEAN, + restartNeeded: true, + description: "Whether to collapse deleted messages, similar to blocked messages", + default: false + }, + logEdits: { + type: OptionType.BOOLEAN, + description: "Whether to log edited messages", + default: true, + }, + inlineEdits: { + type: OptionType.BOOLEAN, + description: "Whether to display edit history as part of message content", + default: true + }, + ignoreBots: { + type: OptionType.BOOLEAN, + description: "Whether to ignore messages by bots", + default: false + }, + ignoreSelf: { + type: OptionType.BOOLEAN, + description: "Whether to ignore messages by yourself", + default: false + }, + ignoreUsers: { + type: OptionType.USERS, + description: "List of users to ignore", + popoutText: "Ignore deleted messages from this user", + }, + ignoreChannels: { + type: OptionType.CHANNELS, + description: "List of channels to ignore", + popoutText: "Ignore deleted messages in this channel" + }, + ignoreGuilds: { + type: OptionType.GUILDS, + description: "List of guilds to ignore", + popoutText: "Ignore deleted messages in this guild", + }, +}); + interface MLMessage extends Message { deleted?: boolean; editHistory?: { timestamp: Date; content: string; }[]; @@ -191,65 +249,7 @@ export default definePlugin({ content: oldMessage.content }; }, - - options: { - deleteStyle: { - type: OptionType.SELECT, - description: "The style of deleted messages", - default: "text", - options: [ - { label: "Red text", value: "text", default: true }, - { label: "Red overlay", value: "overlay" } - ], - onChange: () => addDeleteStyle() - }, - logDeletes: { - type: OptionType.BOOLEAN, - description: "Whether to log deleted messages", - default: true, - }, - collapseDeleted: { - type: OptionType.BOOLEAN, - description: "Whether to collapse deleted messages, similar to blocked messages", - default: false, - restartNeeded: true, - }, - logEdits: { - type: OptionType.BOOLEAN, - description: "Whether to log edited messages", - default: true, - }, - inlineEdits: { - type: OptionType.BOOLEAN, - description: "Whether to display edit history as part of message content", - default: true - }, - ignoreBots: { - type: OptionType.BOOLEAN, - description: "Whether to ignore messages by bots", - default: false - }, - ignoreSelf: { - type: OptionType.BOOLEAN, - description: "Whether to ignore messages by yourself", - default: false - }, - ignoreUsers: { - type: OptionType.STRING, - description: "Comma-separated list of user IDs to ignore", - default: "" - }, - ignoreChannels: { - type: OptionType.STRING, - description: "Comma-separated list of channel IDs to ignore", - default: "" - }, - ignoreGuilds: { - type: OptionType.STRING, - description: "Comma-separated list of guild IDs to ignore", - default: "" - }, - }, + settings: settings, handleDelete(cache: any, data: { ids: string[], id: string; mlDeleted?: boolean; }, isBulk: boolean) { try { diff --git a/src/plugins/noReplyMention/index.tsx b/src/plugins/noReplyMention/index.tsx index 16b3a3e00ca..ca4bec4e741 100644 --- a/src/plugins/noReplyMention/index.tsx +++ b/src/plugins/noReplyMention/index.tsx @@ -24,9 +24,9 @@ import type { Message } from "discord-types/general"; const settings = definePluginSettings({ userList: { description: - "List of users to allow or exempt pings for (separated by commas or spaces)", - type: OptionType.STRING, - default: "1234567890123445,1234567890123445", + "List of users to allow or exempt pings for", + type: OptionType.USERS, + oldStringSeparator: s => s.split(/[\s,]+/).filter(v => v !== "1234567890123445") }, shouldPingListed: { description: "Behaviour", diff --git a/src/utils/modal.tsx b/src/utils/modal.tsx index d06e58036e7..0edb6ebbfd1 100644 --- a/src/utils/modal.tsx +++ b/src/utils/modal.tsx @@ -75,6 +75,12 @@ interface Modals { }>>; /** This also accepts Scroller props but good luck with that */ ModalContent: ComponentType; + scrollbarType?: unknown; + [prop: string]: any; + }>>; + ModalListContent: ComponentType; [prop: string]: any; @@ -106,7 +112,8 @@ export const Modals: Modals = mapMangledModuleLazy(':"thin")', { ModalHeader: filters.componentByCode(",id:"), ModalContent: filters.componentByCode(".content,"), ModalFooter: filters.componentByCode(".footer,"), - ModalCloseButton: filters.componentByCode(".close]:") + ModalCloseButton: filters.componentByCode(".close]:"), + ModalListContent: filters.componentByCode(",scrollerRef:") }); export const ModalRoot = LazyComponent(() => Modals.ModalRoot); @@ -114,6 +121,7 @@ export const ModalHeader = LazyComponent(() => Modals.ModalHeader); export const ModalContent = LazyComponent(() => Modals.ModalContent); export const ModalFooter = LazyComponent(() => Modals.ModalFooter); export const ModalCloseButton = LazyComponent(() => Modals.ModalCloseButton); +export const ModalListContent = LazyComponent(() => Modals.ModalListContent); export type MediaModalItem = { url: string; diff --git a/src/utils/types.ts b/src/utils/types.ts index 4ff30b78c5d..d859fb1ec84 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -202,7 +202,23 @@ export const enum OptionType { SELECT, SLIDER, COMPONENT, - CUSTOM + CUSTOM, + ARRAY, + /** + * Array of users. + * A user context menu will be automatically added for this setting. + */ + USERS, + /** + * Array of channels. + * A channel context menu will be automatically added for this setting. + */ + CHANNELS, + /** + * Array of guilds. + * A guild context menu will be automatically added for this setting. + */ + GUILDS } export type SettingsDefinition = Record; @@ -219,8 +235,9 @@ export type PluginSettingDef = | PluginSettingBooleanDef | PluginSettingSelectDef | PluginSettingSliderDef - | PluginSettingBigIntDef - ) & PluginSettingCommon); + | PluginSettingBigIntDef + | PluginSettingArrayDef +) & PluginSettingCommon); export interface PluginSettingCommon { description: string; @@ -304,6 +321,29 @@ export interface PluginSettingSliderDef { stickToMarkers?: boolean; } +export interface PluginSettingArrayDef { + type: OptionType.ARRAY | OptionType.CHANNELS | OptionType.GUILDS | OptionType.USERS; + /** + * The text to show in the context-menu. + * If not specified, the setting name will be used. + * Only applies to User, Channel, and Guild arrays. + */ + popoutText?: string; + /** + * If the context-menu entry should be hidden. + * Only applies to User, Channel, and Guild arrays. + */ + hidePopout?: boolean; + default?: string[]; + /** + * If the setting used to be a string with a custom delimiter, you can specify the delimiter or a function to split the string + * @default "," + */ + oldStringSeparator?: string | ((value: string) => string[]) | RegExp; + + onChange?(newValue: string[]): void; +} + export interface IPluginOptionComponentProps { /** * Run this when the value changes. @@ -338,9 +378,10 @@ type PluginSettingType = O extends PluginSettingStri O extends PluginSettingSliderDef ? number : O extends PluginSettingComponentDef ? O extends { default: infer Default; } ? Default : any : O extends PluginSettingCustomDef ? O extends { default: infer Default; } ? Default : any : + O extends PluginSettingArrayDef ? any[] : never; -type PluginSettingDefaultType = O extends PluginSettingSelectDef ? ( +type PluginSettingDefaultType = O extends PluginSettingArrayDef ? any[] : O extends PluginSettingSelectDef ? ( O["options"] extends { default?: boolean; }[] ? O["options"][number]["value"] : undefined ) : O extends { default: infer T; } ? T : undefined; @@ -392,7 +433,8 @@ export type PluginOptionsItem = | PluginOptionSelect | PluginOptionSlider | PluginOptionComponent - | PluginOptionCustom; + | PluginOptionCustom + | PluginOptionArray; export type PluginOptionString = PluginSettingStringDef & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionNumber = (PluginSettingNumberDef | PluginSettingBigIntDef) & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionBoolean = PluginSettingBooleanDef & PluginSettingCommon & IsDisabled & IsValid; @@ -400,6 +442,7 @@ export type PluginOptionSelect = PluginSettingSelectDef & PluginSettingCommon & export type PluginOptionSlider = PluginSettingSliderDef & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionComponent = PluginSettingComponentDef & Omit; export type PluginOptionCustom = PluginSettingCustomDef & Pick; +export type PluginOptionArray = PluginSettingArrayDef & PluginSettingCommon & IsDisabled & IsValid; export type PluginNative any>> = { [key in keyof PluginExports]: diff --git a/src/webpack/common/components.ts b/src/webpack/common/components.ts index e31e167e861..51b7e306107 100644 --- a/src/webpack/common/components.ts +++ b/src/webpack/common/components.ts @@ -63,6 +63,8 @@ export const TabBar = waitForComponent("TabBar", filters.componentByCode("ref:th export const Paginator = waitForComponent("Paginator", filters.componentByCode('rel:"prev",children:')); export const Clickable = waitForComponent("Clickable", filters.componentByCode("this.context?this.renderNonInteractive():")); export const Avatar = waitForComponent("Avatar", filters.componentByCode(".size-1.375*")); +export const Checkbox = waitForComponent("Checkbox", filters.componentByCode(".checkboxWrapper")); +export const SearchBar = waitForComponent("SearchBar", filters.componentByCode(".containerRef", ".inputRef", ".handleOnChange")); export let createScroller: (scrollbarClassName: string, fadeClassName: string, customThemeClassName: string) => t.ScrollerThin; export let scrollerClasses: Record; diff --git a/src/webpack/common/types/components.d.ts b/src/webpack/common/types/components.d.ts index 8113e826a37..eb5eb19d089 100644 --- a/src/webpack/common/types/components.d.ts +++ b/src/webpack/common/types/components.d.ts @@ -504,3 +504,40 @@ export type Icon = ComponentType>; +type Checkbox = ComponentType> & Record; + + +type SearchBar = ComponentType void; + className?: string; + placeholder?: string; + iconClassName?: string; + onKeyDown?: (event: KeyboardEvent) => void; + onKeyUp?: (event: KeyboardEvent) => void; + onKeyPress?: (event: KeyboardEvent) => void; + isLoading?: boolean; + size?: string; + disabled?: boolean; + onChange?: (value: string) => void; + onBlur?: () => void; + onFocus?: () => void; + autoComplete?: string; + inputProps?: HTMLProps; + hideSearchIcon?: boolean; + "aria-label"?: string; +}>> & Record; +