diff --git a/eslint.config.mjs b/eslint.config.mjs index 07c70fa74..67327b938 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -105,7 +105,13 @@ export default tseslint.config( "no-invalid-regexp": "error", "no-constant-condition": ["error", { "checkLoops": false }], "no-duplicate-imports": "error", - "dot-notation": "error", + "@typescript-eslint/dot-notation": [ + "error", + { + "allowPrivateClassPropertyAccess": true, + "allowProtectedClassPropertyAccess": true + } + ], "no-useless-escape": [ "error", { diff --git a/src/components/PluginSettings/PluginModal.tsx b/src/components/PluginSettings/PluginModal.tsx index 4dc92e42a..3f7965d58 100644 --- a/src/components/PluginSettings/PluginModal.tsx +++ b/src/components/PluginSettings/PluginModal.tsx @@ -81,7 +81,8 @@ const Components: Record null, }; export default function PluginModal({ plugin, onRestartNeeded, onClose, transitionState }: PluginModalProps) { @@ -129,7 +130,8 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti for (const [key, value] of Object.entries(tempSettings)) { const option = plugin.options[key]; pluginSettings[key] = value; - option?.onChange?.(value); + + if (option.type === OptionType.CUSTOM) continue; if (option?.restartNeeded) restartNeeded = true; } if (restartNeeded) onRestartNeeded(); @@ -141,7 +143,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti return There are no settings for this plugin.; } else { const options = Object.entries(plugin.options).map(([key, setting]) => { - if (setting.hidden) return null; + if (setting.type === OptionType.CUSTOM || setting.hidden) return null; function onChange(newValue: any) { setTempSettings(s => ({ ...s, [key]: newValue })); diff --git a/src/plugins/ignoreActivities/index.tsx b/src/plugins/ignoreActivities/index.tsx index fac83687d..08e146ab9 100644 --- a/src/plugins/ignoreActivities/index.tsx +++ b/src/plugins/ignoreActivities/index.tsx @@ -4,7 +4,6 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import * as DataStore from "@api/DataStore"; import { definePluginSettings, Settings } from "@api/Settings"; import { getUserSettingLazy } from "@api/UserSettings"; import ErrorBoundary from "@components/ErrorBoundary"; @@ -62,7 +61,7 @@ const ToggleIconOff = (activity: IgnoredActivity, fill: string) => ToggleIcon(ac function ToggleActivityComponent(activity: IgnoredActivity, isPlaying = false) { const s = settings.use(["ignoredActivities"]); - const { ignoredActivities = [] } = s; + const { ignoredActivities } = s; if (ignoredActivities.some(act => act.id === activity.id)) return ToggleIconOff(activity, "var(--status-danger)"); return ToggleIconOn(activity, isPlaying ? "var(--green-300)" : "var(--primary-400)"); @@ -71,9 +70,9 @@ function ToggleActivityComponent(activity: IgnoredActivity, isPlaying = false) { function handleActivityToggle(e: React.MouseEvent, activity: IgnoredActivity) { e.stopPropagation(); - const ignoredActivityIndex = getIgnoredActivities().findIndex(act => act.id === activity.id); - if (ignoredActivityIndex === -1) settings.store.ignoredActivities = getIgnoredActivities().concat(activity); - else settings.store.ignoredActivities = getIgnoredActivities().filter((_, index) => index !== ignoredActivityIndex); + const ignoredActivityIndex = settings.store.ignoredActivities.findIndex(act => act.id === activity.id); + if (ignoredActivityIndex === -1) settings.store.ignoredActivities.push(activity); + else settings.store.ignoredActivities.splice(ignoredActivityIndex, 1); recalculateActivities(); } @@ -209,14 +208,13 @@ const settings = definePluginSettings({ description: "Ignore all competing activities (These are normally special game activities)", default: false, onChange: recalculateActivities + }, + ignoredActivities: { + type: OptionType.CUSTOM, + default: [] as IgnoredActivity[], + onChange: recalculateActivities } -}).withPrivateSettings<{ - ignoredActivities: IgnoredActivity[]; -}>(); - -function getIgnoredActivities() { - return settings.store.ignoredActivities ??= []; -} +}); function isActivityTypeIgnored(type: number, id?: string) { if (id && settings.store.idsList.includes(id)) { @@ -284,29 +282,14 @@ export default definePlugin({ ], async start() { - // Migrate allowedIds - if (Settings.plugins.IgnoreActivities.allowedIds) { - settings.store.idsList = Settings.plugins.IgnoreActivities.allowedIds; - delete Settings.plugins.IgnoreActivities.allowedIds; // Remove allowedIds - } - - const oldIgnoredActivitiesData = await DataStore.get>("IgnoreActivities_ignoredActivities"); - - if (oldIgnoredActivitiesData != null) { - settings.store.ignoredActivities = Array.from(oldIgnoredActivitiesData.values()) - .map(activity => ({ ...activity, name: "Unknown Name" })); - - DataStore.del("IgnoreActivities_ignoredActivities"); - } - - if (getIgnoredActivities().length !== 0) { + if (settings.store.ignoredActivities.length !== 0) { const gamesSeen = RunningGameStore.getGamesSeen() as { id?: string; exePath: string; }[]; - for (const [index, ignoredActivity] of getIgnoredActivities().entries()) { + for (const [index, ignoredActivity] of settings.store.ignoredActivities.entries()) { if (ignoredActivity.type !== ActivitiesTypes.Game) continue; if (!gamesSeen.some(game => game.id === ignoredActivity.id || game.exePath === ignoredActivity.id)) { - getIgnoredActivities().splice(index, 1); + settings.store.ignoredActivities.splice(index, 1); } } } @@ -316,11 +299,11 @@ export default definePlugin({ if (isActivityTypeIgnored(props.type, props.application_id)) return false; if (props.application_id != null) { - return !getIgnoredActivities().some(activity => activity.id === props.application_id) || (settings.store.listMode === FilterMode.Whitelist && settings.store.idsList.includes(props.application_id)); + return !settings.store.ignoredActivities.some(activity => activity.id === props.application_id) || (settings.store.listMode === FilterMode.Whitelist && settings.store.idsList.includes(props.application_id)); } else { const exePath = RunningGameStore.getRunningGames().find(game => game.name === props.name)?.exePath; if (exePath) { - return !getIgnoredActivities().some(activity => activity.id === exePath); + return !settings.store.ignoredActivities.some(activity => activity.id === exePath); } } diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 9d672c634..545169b1f 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -25,7 +25,7 @@ import { addMessageAccessory, removeMessageAccessory } from "@api/MessageAccesso import { addMessageDecoration, removeMessageDecoration } from "@api/MessageDecorations"; import { addMessageClickListener, addMessagePreEditListener, addMessagePreSendListener, removeMessageClickListener, removeMessagePreEditListener, removeMessagePreSendListener } from "@api/MessageEvents"; import { addMessagePopoverButton, removeMessagePopoverButton } from "@api/MessagePopover"; -import { Settings } from "@api/Settings"; +import { Settings, SettingsStore } from "@api/Settings"; import { Logger } from "@utils/Logger"; import { canonicalizeFind } from "@utils/patches"; import { Patch, Plugin, PluginDef, ReporterTestable, StartAt } from "@utils/types"; @@ -146,6 +146,10 @@ for (const p of pluginsValues) { for (const [name, def] of Object.entries(p.settings.def)) { const checks = p.settings.checks?.[name]; p.options[name] = { ...def, ...checks }; + + if (def.onChange != null) { + SettingsStore.addChangeListener(`plugins.${p.name}.${name}`, def.onChange); + } } } diff --git a/src/plugins/messageTags/index.ts b/src/plugins/messageTags/index.ts index 5ba4ab94a..5a5d03fdb 100644 --- a/src/plugins/messageTags/index.ts +++ b/src/plugins/messageTags/index.ts @@ -18,7 +18,7 @@ import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, registerCommand, sendBotMessage, unregisterCommand } from "@api/Commands"; import * as DataStore from "@api/DataStore"; -import { Settings } from "@api/Settings"; +import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; @@ -29,23 +29,23 @@ const MessageTagsMarker = Symbol("MessageTags"); interface Tag { name: string; message: string; - enabled: boolean; } -const getTags = () => DataStore.get(DATA_KEY).then(t => t ?? []); -const getTag = (name: string) => DataStore.get(DATA_KEY).then((t: Tag[]) => (t ?? []).find((tt: Tag) => tt.name === name) ?? null); -const addTag = async (tag: Tag) => { - const tags = await getTags(); - tags.push(tag); - DataStore.set(DATA_KEY, tags); - return tags; -}; -const removeTag = async (name: string) => { - let tags = await getTags(); - tags = await tags.filter((t: Tag) => t.name !== name); - DataStore.set(DATA_KEY, tags); - return tags; -}; +function getTags() { + return settings.store.tagsList; +} + +function getTag(name: string) { + return settings.store.tagsList[name] ?? null; +} + +function addTag(tag: Tag) { + settings.store.tagsList[tag.name] = tag; +} + +function removeTag(name: string) { + delete settings.store.tagsList[name]; +} function createTagCommand(tag: Tag) { registerCommand({ @@ -53,14 +53,14 @@ function createTagCommand(tag: Tag) { description: tag.name, inputType: ApplicationCommandInputType.BUILT_IN_TEXT, execute: async (_, ctx) => { - if (!await getTag(tag.name)) { + if (!getTag(tag.name)) { sendBotMessage(ctx.channel.id, { content: `${EMOTE} The tag **${tag.name}** does not exist anymore! Please reload ur Discord to fix :)` }); return { content: `/${tag.name}` }; } - if (Settings.plugins.MessageTags.clyde) sendBotMessage(ctx.channel.id, { + if (settings.store.clyde) sendBotMessage(ctx.channel.id, { content: `${EMOTE} The tag **${tag.name}** has been sent!` }); return { content: tag.message.replaceAll("\\n", "\n") }; @@ -69,22 +69,38 @@ function createTagCommand(tag: Tag) { }, "CustomTags"); } +const settings = definePluginSettings({ + clyde: { + name: "Clyde message on send", + description: "If enabled, clyde will send you an ephemeral message when a tag was used.", + type: OptionType.BOOLEAN, + default: true + }, + tagsList: { + type: OptionType.CUSTOM, + default: {} as Record, + } +}); export default definePlugin({ name: "MessageTags", description: "Allows you to save messages and to use them with a simple command.", authors: [Devs.Luna], - options: { - clyde: { - name: "Clyde message on send", - description: "If enabled, clyde will send you an ephemeral message when a tag was used.", - type: OptionType.BOOLEAN, - default: true - } - }, + settings, async start() { - for (const tag of await getTags()) createTagCommand(tag); + // TODO: Remove DataStore tags migration once enough time has passed + const oldTags = await DataStore.get(DATA_KEY); + if (oldTags != null) { + // @ts-ignore + settings.store.tagsList = Object.fromEntries(oldTags.map(oldTag => (delete oldTag.enabled, [oldTag.name, oldTag]))); + await DataStore.del(DATA_KEY); + } + + const tags = getTags(); + for (const tagName in tags) { + createTagCommand(tags[tagName]); + } }, commands: [ @@ -153,19 +169,18 @@ export default definePlugin({ const name: string = findOption(args[0].options, "tag-name", ""); const message: string = findOption(args[0].options, "message", ""); - if (await getTag(name)) + if (getTag(name)) return sendBotMessage(ctx.channel.id, { content: `${EMOTE} A Tag with the name **${name}** already exists!` }); const tag = { name: name, - enabled: true, message: message }; createTagCommand(tag); - await addTag(tag); + addTag(tag); sendBotMessage(ctx.channel.id, { content: `${EMOTE} Successfully created the tag **${name}**!` @@ -175,13 +190,13 @@ export default definePlugin({ case "delete": { const name: string = findOption(args[0].options, "tag-name", ""); - if (!await getTag(name)) + if (!getTag(name)) return sendBotMessage(ctx.channel.id, { content: `${EMOTE} A Tag with the name **${name}** does not exist!` }); unregisterCommand(name); - await removeTag(name); + removeTag(name); sendBotMessage(ctx.channel.id, { content: `${EMOTE} Successfully deleted the tag **${name}**!` @@ -192,10 +207,8 @@ export default definePlugin({ sendBotMessage(ctx.channel.id, { embeds: [ { - // @ts-ignore title: "All Tags:", - // @ts-ignore - description: (await getTags()) + description: Object.values(getTags()) .map(tag => `\`${tag.name}\`: ${tag.message.slice(0, 72).replaceAll("\\n", " ")}${tag.message.length > 72 ? "..." : ""}`) .join("\n") || `${EMOTE} Woops! There are no tags yet, use \`/tags create\` to create one!`, // @ts-ignore @@ -208,7 +221,7 @@ export default definePlugin({ } case "preview": { const name: string = findOption(args[0].options, "tag-name", ""); - const tag = await getTag(name); + const tag = getTag(name); if (!tag) return sendBotMessage(ctx.channel.id, { diff --git a/src/plugins/pinDms/components/CreateCategoryModal.tsx b/src/plugins/pinDms/components/CreateCategoryModal.tsx index 0568c1adb..17f7dfdd3 100644 --- a/src/plugins/pinDms/components/CreateCategoryModal.tsx +++ b/src/plugins/pinDms/components/CreateCategoryModal.tsx @@ -7,11 +7,10 @@ import { classNameFactory } from "@api/Styles"; import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModalLazy } from "@utils/modal"; import { extractAndLoadChunksLazy, findComponentByCodeLazy, findExportedComponentLazy } from "@webpack"; -import { Button, Forms, Text, TextInput, Toasts, useEffect, useState } from "@webpack/common"; +import { Button, Forms, Text, TextInput, Toasts, useMemo, useState } from "@webpack/common"; import { DEFAULT_COLOR, SWATCHES } from "../constants"; -import { categories, Category, createCategory, getCategory, updateCategory } from "../data"; -import { forceUpdate } from "../index"; +import { categoryLen, createCategory, getCategory } from "../data"; interface ColorPickerProps { color: number | null; @@ -39,45 +38,45 @@ const cl = classNameFactory("vc-pindms-modal-"); interface Props { categoryId: string | null; - initalChannelId: string | null; + initialChannelId: string | null; modalProps: ModalProps; } function useCategory(categoryId: string | null, initalChannelId: string | null) { - const [category, setCategory] = useState(null); - - useEffect(() => { - if (categoryId) - setCategory(getCategory(categoryId)!); - else if (initalChannelId) - setCategory({ + const category = useMemo(() => { + if (categoryId) { + return getCategory(categoryId); + } else if (initalChannelId) { + return { id: Toasts.genId(), - name: `Pin Category ${categories.length + 1}`, + name: `Pin Category ${categoryLen() + 1}`, color: DEFAULT_COLOR, collapsed: false, channels: [initalChannelId] - }); + }; + } }, [categoryId, initalChannelId]); - return { - category, - setCategory - }; + return category; } -export function NewCategoryModal({ categoryId, modalProps, initalChannelId }: Props) { - const { category, setCategory } = useCategory(categoryId, initalChannelId); - +export function NewCategoryModal({ categoryId, modalProps, initialChannelId }: Props) { + const category = useCategory(categoryId, initialChannelId); if (!category) return null; - const onSave = async (e: React.FormEvent | React.MouseEvent) => { - e.preventDefault(); - if (!categoryId) - await createCategory(category); - else - await updateCategory(category); + const [name, setName] = useState(category.name); + const [color, setColor] = useState(category.color); + + const onSave = (e: React.FormEvent | React.MouseEvent) => { + e.preventDefault(); + + category.name = name; + category.color = color; + + if (!categoryId) { + createCategory(category); + } - forceUpdate(); modalProps.onClose(); }; @@ -93,25 +92,25 @@ export function NewCategoryModal({ categoryId, modalProps, initalChannelId }: Pr Name setCategory({ ...category, name: e })} + value={name} + onChange={e => setName(e)} /> Color setCategory({ ...category, color: c! })} - value={category.color} + onChange={c => setColor(c!)} + value={color} renderDefaultButton={() => null} renderCustomButton={() => ( setCategory({ ...category, color: c! })} - key={category.name} + color={color} + onChange={c => setColor(c!)} + key={category.id} showEyeDropper={false} /> )} @@ -119,7 +118,7 @@ export function NewCategoryModal({ categoryId, modalProps, initalChannelId }: Pr - + @@ -129,6 +128,6 @@ export function NewCategoryModal({ categoryId, modalProps, initalChannelId }: Pr export const openCategoryModal = (categoryId: string | null, channelId: string | null) => openModalLazy(async () => { await requireSettingsMenu(); - return modalProps => ; + return modalProps => ; }); diff --git a/src/plugins/pinDms/components/contextMenu.tsx b/src/plugins/pinDms/components/contextMenu.tsx index aa5d1993e..6fc4e6743 100644 --- a/src/plugins/pinDms/components/contextMenu.tsx +++ b/src/plugins/pinDms/components/contextMenu.tsx @@ -7,8 +7,8 @@ import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu"; import { Menu } from "@webpack/common"; -import { addChannelToCategory, canMoveChannelInDirection, categories, isPinned, moveChannel, removeChannelFromCategory } from "../data"; -import { forceUpdate, PinOrder, settings } from "../index"; +import { addChannelToCategory, canMoveChannelInDirection, currentUserCategories, isPinned, moveChannel, removeChannelFromCategory } from "../data"; +import { PinOrder, settings } from "../index"; import { openCategoryModal } from "./CreateCategoryModal"; function createPinMenuItem(channelId: string) { @@ -31,12 +31,12 @@ function createPinMenuItem(channelId: string) { { - categories.map(category => ( + currentUserCategories.map(category => ( addChannelToCategory(channelId, category.id).then(forceUpdate)} + action={() => addChannelToCategory(channelId, category.id)} /> )) } @@ -49,7 +49,7 @@ function createPinMenuItem(channelId: string) { id="unpin-dm" label="Unpin DM" color="danger" - action={() => removeChannelFromCategory(channelId).then(forceUpdate)} + action={() => removeChannelFromCategory(channelId)} /> { @@ -57,7 +57,7 @@ function createPinMenuItem(channelId: string) { moveChannel(channelId, -1).then(forceUpdate)} + action={() => moveChannel(channelId, -1)} /> ) } @@ -67,7 +67,7 @@ function createPinMenuItem(channelId: string) { moveChannel(channelId, 1).then(forceUpdate)} + action={() => moveChannel(channelId, 1)} /> ) } diff --git a/src/plugins/pinDms/data.ts b/src/plugins/pinDms/data.ts index a4e40dde0..2f4a1156e 100644 --- a/src/plugins/pinDms/data.ts +++ b/src/plugins/pinDms/data.ts @@ -6,10 +6,10 @@ import * as DataStore from "@api/DataStore"; import { Settings } from "@api/Settings"; +import { useForceUpdater } from "@utils/react"; import { UserStore } from "@webpack/common"; -import { DEFAULT_COLOR } from "./constants"; -import { forceUpdate, PinOrder, PrivateChannelSortStore, settings } from "./index"; +import { PinOrder, PrivateChannelSortStore, settings } from "./index"; export interface Category { id: string; @@ -24,104 +24,92 @@ const CATEGORY_MIGRATED_PINDMS_KEY = "PinDMsMigratedPinDMs"; const CATEGORY_MIGRATED_KEY = "PinDMsMigratedOldCategories"; const OLD_CATEGORY_KEY = "BetterPinDMsCategories-"; - -export let categories: Category[] = []; - -export async function saveCats(cats: Category[]) { - const { id } = UserStore.getCurrentUser(); - await DataStore.set(CATEGORY_BASE_KEY + id, cats); -} +let forceUpdateDms: (() => void) | undefined = undefined; +export let currentUserCategories: Category[] = []; export async function init() { - const id = UserStore.getCurrentUser()?.id; - await initCategories(id); - await migrateData(id); - forceUpdate(); + await migrateData(); + + const userId = UserStore.getCurrentUser()?.id; + if (userId == null) return; + + currentUserCategories = settings.store.userBasedCategoryList[userId] ??= []; + forceUpdateDms?.(); } -export async function initCategories(userId: string) { - categories = await DataStore.get(CATEGORY_BASE_KEY + userId) ?? []; +export function usePinnedDms() { + forceUpdateDms = useForceUpdater(); + settings.use(["pinOrder", "canCollapseDmSection", "dmSectionCollapsed", "userBasedCategoryList"]); } export function getCategory(id: string) { - return categories.find(c => c.id === id); + return currentUserCategories.find(c => c.id === id); } -export async function createCategory(category: Category) { - categories.push(category); - await saveCats(categories); +export function getCategoryByIndex(index: number) { + return currentUserCategories[index]; } -export async function updateCategory(category: Category) { - const index = categories.findIndex(c => c.id === category.id); - if (index === -1) return; - - categories[index] = category; - await saveCats(categories); +export function createCategory(category: Category) { + currentUserCategories.push(category); } -export async function addChannelToCategory(channelId: string, categoryId: string) { - const category = categories.find(c => c.id === categoryId); - if (!category) return; +export function addChannelToCategory(channelId: string, categoryId: string) { + const category = currentUserCategories.find(c => c.id === categoryId); + if (category == null) return; if (category.channels.includes(channelId)) return; category.channels.push(channelId); - await saveCats(categories); - } -export async function removeChannelFromCategory(channelId: string) { - const category = categories.find(c => c.channels.includes(channelId)); - if (!category) return; +export function removeChannelFromCategory(channelId: string) { + const category = currentUserCategories.find(c => c.channels.includes(channelId)); + if (category == null) return; category.channels = category.channels.filter(c => c !== channelId); - await saveCats(categories); } -export async function removeCategory(categoryId: string) { - const catagory = categories.find(c => c.id === categoryId); - if (!catagory) return; +export function removeCategory(categoryId: string) { + const categoryIndex = currentUserCategories.findIndex(c => c.id === categoryId); + if (categoryIndex === -1) return; - // catagory?.channels.forEach(c => removeChannelFromCategory(c)); - categories = categories.filter(c => c.id !== categoryId); - await saveCats(categories); + currentUserCategories.splice(categoryIndex, 1); } -export async function collapseCategory(id: string, value = true) { - const category = categories.find(c => c.id === id); - if (!category) return; +export function collapseCategory(id: string, value = true) { + const category = currentUserCategories.find(c => c.id === id); + if (category == null) return; category.collapsed = value; - await saveCats(categories); } -// utils +// Utils export function isPinned(id: string) { - return categories.some(c => c.channels.includes(id)); + return currentUserCategories.some(c => c.channels.includes(id)); } export function categoryLen() { - return categories.length; + return currentUserCategories.length; } export function getAllUncollapsedChannels() { if (settings.store.pinOrder === PinOrder.LastMessage) { const sortedChannels = PrivateChannelSortStore.getPrivateChannelIds(); - return categories.filter(c => !c.collapsed).flatMap(c => sortedChannels.filter(channel => c.channels.includes(channel))); + return currentUserCategories.filter(c => !c.collapsed).flatMap(c => sortedChannels.filter(channel => c.channels.includes(channel))); } - return categories.filter(c => !c.collapsed).flatMap(c => c.channels); + return currentUserCategories.filter(c => !c.collapsed).flatMap(c => c.channels); } export function getSections() { - return categories.reduce((acc, category) => { + return currentUserCategories.reduce((acc, category) => { acc.push(category.channels.length === 0 ? 1 : category.channels.length); return acc; }, [] as number[]); } -// move categories +// Move categories export const canMoveArrayInDirection = (array: any[], index: number, direction: -1 | 1) => { const a = array[index]; const b = array[index + direction]; @@ -130,18 +118,18 @@ export const canMoveArrayInDirection = (array: any[], index: number, direction: }; export const canMoveCategoryInDirection = (id: string, direction: -1 | 1) => { - const index = categories.findIndex(m => m.id === id); - return canMoveArrayInDirection(categories, index, direction); + const categoryIndex = currentUserCategories.findIndex(m => m.id === id); + return canMoveArrayInDirection(currentUserCategories, categoryIndex, direction); }; export const canMoveCategory = (id: string) => canMoveCategoryInDirection(id, -1) || canMoveCategoryInDirection(id, 1); export const canMoveChannelInDirection = (channelId: string, direction: -1 | 1) => { - const category = categories.find(c => c.channels.includes(channelId)); - if (!category) return false; + const category = currentUserCategories.find(c => c.channels.includes(channelId)); + if (category == null) return false; - const index = category.channels.indexOf(channelId); - return canMoveArrayInDirection(category.channels, index, direction); + const channelIndex = category.channels.indexOf(channelId); + return canMoveArrayInDirection(category.channels, channelIndex, direction); }; @@ -150,70 +138,44 @@ function swapElementsInArray(array: any[], index1: number, index2: number) { [array[index1], array[index2]] = [array[index2], array[index1]]; } -// stolen from PinDMs -export async function moveCategory(id: string, direction: -1 | 1) { - const a = categories.findIndex(m => m.id === id); +export function moveCategory(id: string, direction: -1 | 1) { + const a = currentUserCategories.findIndex(m => m.id === id); const b = a + direction; - swapElementsInArray(categories, a, b); - - await saveCats(categories); + swapElementsInArray(currentUserCategories, a, b); } -export async function moveChannel(channelId: string, direction: -1 | 1) { - const category = categories.find(c => c.channels.includes(channelId)); - if (!category) return; +export function moveChannel(channelId: string, direction: -1 | 1) { + const category = currentUserCategories.find(c => c.channels.includes(channelId)); + if (category == null) return; const a = category.channels.indexOf(channelId); const b = a + direction; swapElementsInArray(category.channels, a, b); - - await saveCats(categories); } - - -// migrate data -const getPinDMsPins = () => (Settings.plugins.PinDMs.pinnedDMs || void 0)?.split(",") as string[] | undefined; - -async function migratePinDMs() { - if (categories.some(m => m.id === "oldPins")) { - return await DataStore.set(CATEGORY_MIGRATED_PINDMS_KEY, true); +// TODO: Remove DataStore PinnedDms migration once enough time has passed +async function migrateData() { + if (Settings.plugins.PinDMs.dmSectioncollapsed != null) { + settings.store.dmSectionCollapsed = Settings.plugins.PinDMs.dmSectioncollapsed; + delete Settings.plugins.PinDMs.dmSectioncollapsed; } - const pindmspins = getPinDMsPins(); + const dataStoreKeys = await DataStore.keys(); + const pinDmsKeys = dataStoreKeys.map(key => String(key)).filter(key => key.startsWith(CATEGORY_BASE_KEY)); - // we dont want duplicate pins - const difference = [...new Set(pindmspins)]?.filter(m => !categories.some(c => c.channels.includes(m))); - if (difference?.length) { - categories.push({ - id: "oldPins", - name: "Pins", - color: DEFAULT_COLOR, - channels: difference - }); + if (pinDmsKeys.length === 0) return; + + for (const pinDmsKey of pinDmsKeys) { + const categories = await DataStore.get(pinDmsKey); + if (categories == null) continue; + + const userId = pinDmsKey.replace(CATEGORY_BASE_KEY, ""); + settings.store.userBasedCategoryList[userId] = categories; + + await DataStore.del(pinDmsKey); } - await DataStore.set(CATEGORY_MIGRATED_PINDMS_KEY, true); -} - -async function migrateOldCategories(userId: string) { - const oldCats = await DataStore.get(OLD_CATEGORY_KEY + userId); - // dont want to migrate if the user has already has categories. - if (categories.length === 0 && oldCats?.length) { - categories.push(...(oldCats.filter(m => m.id !== "oldPins"))); - } - await DataStore.set(CATEGORY_MIGRATED_KEY, true); -} - -export async function migrateData(userId: string) { - const m1 = await DataStore.get(CATEGORY_MIGRATED_KEY), m2 = await DataStore.get(CATEGORY_MIGRATED_PINDMS_KEY); - if (m1 && m2) return; - - // want to migrate the old categories first and then slove any conflicts with the PinDMs pins - if (!m1) await migrateOldCategories(userId); - if (!m2) await migratePinDMs(); - - await saveCats(categories); + await Promise.all([DataStore.del(CATEGORY_MIGRATED_PINDMS_KEY), DataStore.del(CATEGORY_MIGRATED_KEY), DataStore.del(OLD_CATEGORY_KEY)]); } diff --git a/src/plugins/pinDms/index.tsx b/src/plugins/pinDms/index.tsx index 8cbb03bfc..59fee9c0f 100644 --- a/src/plugins/pinDms/index.tsx +++ b/src/plugins/pinDms/index.tsx @@ -12,13 +12,13 @@ import { Devs } from "@utils/constants"; import { classes } from "@utils/misc"; import definePlugin, { OptionType, StartAt } from "@utils/types"; import { findByPropsLazy, findStoreLazy } from "@webpack"; -import { ContextMenuApi, FluxDispatcher, Menu, React } from "@webpack/common"; +import { Clickable, ContextMenuApi, FluxDispatcher, Menu, React } from "@webpack/common"; import { Channel } from "discord-types/general"; import { contextMenus } from "./components/contextMenu"; import { openCategoryModal, requireSettingsMenu } from "./components/CreateCategoryModal"; import { DEFAULT_CHUNK_SIZE } from "./constants"; -import { canMoveCategory, canMoveCategoryInDirection, categories, Category, categoryLen, collapseCategory, getAllUncollapsedChannels, getSections, init, isPinned, moveCategory, removeCategory } from "./data"; +import { canMoveCategory, canMoveCategoryInDirection, Category, categoryLen, collapseCategory, getAllUncollapsedChannels, getCategoryByIndex, getSections, init, isPinned, moveCategory, removeCategory, usePinnedDms } from "./data"; interface ChannelComponentProps { children: React.ReactNode, @@ -26,13 +26,11 @@ interface ChannelComponentProps { selected: boolean; } - const headerClasses = findByPropsLazy("privateChannelsHeaderContainer"); export const PrivateChannelSortStore = findStoreLazy("PrivateChannelSortStore") as { getPrivateChannelIds: () => string[]; }; export let instance: any; -export const forceUpdate = () => instance?.props?._forceUpdate?.(); export const enum PinOrder { LastMessage, @@ -46,21 +44,28 @@ export const settings = definePluginSettings({ options: [ { label: "Most recent message", value: PinOrder.LastMessage, default: true }, { label: "Custom (right click channels to reorder)", value: PinOrder.Custom } - ], - onChange: () => forceUpdate() + ] }, - - dmSectioncollapsed: { + canCollapseDmSection: { type: OptionType.BOOLEAN, - description: "Collapse DM sections", + description: "Allow uncategorised DMs section to be collapsable", + default: false + }, + dmSectionCollapsed: { + type: OptionType.BOOLEAN, + description: "Collapse DM section", default: false, - onChange: () => forceUpdate() + hidden: true + }, + userBasedCategoryList: { + type: OptionType.CUSTOM, + default: {} as Record } }); export default definePlugin({ name: "PinDMs", - description: "Allows you to pin private channels to the top of your DM list. To pin/unpin or reorder pins, right click DMs", + description: "Allows you to pin private channels to the top of your DM list. To pin/unpin or re-order pins, right click DMs", authors: [Devs.Ven, Devs.Aria], settings, contextMenus, @@ -124,8 +129,8 @@ export default definePlugin({ { find: ".FRIENDS},\"friends\"", replacement: { - match: /let{showLibrary:\i,.+?showDMHeader:.+?,/, - replace: "let forceUpdate = Vencord.Util.useForceUpdater();$&_forceUpdate:forceUpdate," + match: /let{showLibrary:\i,/, + replace: "$self.usePinnedDms();$&" } }, @@ -149,6 +154,7 @@ export default definePlugin({ } }, ], + sections: null as number[] | null, set _instance(i: any) { @@ -162,6 +168,7 @@ export default definePlugin({ CONNECTION_OPEN: init, }, + usePinnedDms, isPinned, categoryLen, getSections, @@ -186,11 +193,11 @@ export default definePlugin({ }, makeSpanProps() { - return { + return settings.store.canCollapseDmSection ? { onClick: () => this.collapseDMList(), role: "button", style: { cursor: "pointer" } - }; + } : undefined; }, getChunkSize() { @@ -210,30 +217,27 @@ export default definePlugin({ }, isChannelIndex(sectionIndex: number, channelIndex: number) { - if (settings.store.dmSectioncollapsed && sectionIndex !== 0) + if (settings.store.canCollapseDmSection && settings.store.dmSectionCollapsed && sectionIndex !== 0) { return true; - const cat = categories[sectionIndex - 1]; - return this.isCategoryIndex(sectionIndex) && (cat?.channels?.length === 0 || cat?.channels[channelIndex]); - }, + } - isDMSectioncollapsed() { - return settings.store.dmSectioncollapsed; + const category = getCategoryByIndex(sectionIndex - 1); + return this.isCategoryIndex(sectionIndex) && (category?.channels?.length === 0 || category?.channels[channelIndex]); }, collapseDMList() { - settings.store.dmSectioncollapsed = !settings.store.dmSectioncollapsed; - forceUpdate(); + settings.store.dmSectionCollapsed = !settings.store.dmSectionCollapsed; }, isChannelHidden(categoryIndex: number, channelIndex: number) { if (categoryIndex === 0) return false; - if (settings.store.dmSectioncollapsed && this.getSections().length + 1 === categoryIndex) + if (settings.store.canCollapseDmSection && settings.store.dmSectionCollapsed && this.getSections().length + 1 === categoryIndex) return true; if (!this.instance || !this.isChannelIndex(categoryIndex, channelIndex)) return false; - const category = categories[categoryIndex - 1]; + const category = getCategoryByIndex(categoryIndex - 1); if (!category) return false; return category.collapsed && this.instance.props.selectedChannelId !== this.getCategoryChannels(category)[channelIndex]; @@ -251,18 +255,12 @@ export default definePlugin({ }, renderCategory: ErrorBoundary.wrap(({ section }: { section: number; }) => { - const category = categories[section - 1]; - + const category = getCategoryByIndex(section - 1); if (!category) return null; return ( -

{ - await collapseCategory(category.id, !category.collapsed); - forceUpdate(); - }} + collapseCategory(category.id, !category.collapsed)} onContextMenu={e => { ContextMenuApi.openContextMenu(e, () => ( moveCategory(category.id, -1).then(() => forceUpdate())} + action={() => moveCategory(category.id, -1)} /> } { canMoveCategoryInDirection(category.id, 1) && moveCategory(category.id, 1).then(() => forceUpdate())} + action={() => moveCategory(category.id, 1)} /> } @@ -304,7 +302,7 @@ export default definePlugin({ id="vc-pindms-delete-category" color="danger" label="Delete Category" - action={() => removeCategory(category.id).then(() => forceUpdate())} + action={() => removeCategory(category.id)} /> @@ -312,13 +310,18 @@ export default definePlugin({ )); }} > - - {category?.name ?? "uh oh"} - - -

+

+ + {category?.name ?? "uh oh"} + + +

+ ); }, { noop: true }), @@ -341,7 +344,7 @@ export default definePlugin({ }, getChannel(sectionIndex: number, index: number, channels: Record) { - const category = categories[sectionIndex - 1]; + const category = getCategoryByIndex(sectionIndex - 1); if (!category) return { channel: null, category: null }; const channelId = this.getCategoryChannels(category)[index]; diff --git a/src/plugins/textReplace/index.tsx b/src/plugins/textReplace/index.tsx index bf5d62836..3d1e891d1 100644 --- a/src/plugins/textReplace/index.tsx +++ b/src/plugins/textReplace/index.tsx @@ -22,7 +22,6 @@ import { Flex } from "@components/Flex"; import { DeleteIcon } from "@components/Icons"; import { Devs } from "@utils/constants"; import { Logger } from "@utils/Logger"; -import { useForceUpdater } from "@utils/react"; import definePlugin, { OptionType } from "@utils/types"; import { Button, Forms, React, TextInput, useState } from "@webpack/common"; @@ -34,8 +33,6 @@ type Rule = Record<"find" | "replace" | "onlyIfIncludes", string>; interface TextReplaceProps { title: string; rulesArray: Rule[]; - rulesKey: string; - update: () => void; } const makeEmptyRule: () => Rule = () => ({ @@ -45,34 +42,36 @@ const makeEmptyRule: () => Rule = () => ({ }); const makeEmptyRuleArray = () => [makeEmptyRule()]; -let stringRules = makeEmptyRuleArray(); -let regexRules = makeEmptyRuleArray(); - const settings = definePluginSettings({ replace: { type: OptionType.COMPONENT, description: "", component: () => { - const update = useForceUpdater(); + const { stringRules, regexRules } = settings.use(["stringRules", "regexRules"]); + return ( <> ); } }, + stringRules: { + type: OptionType.CUSTOM, + default: makeEmptyRuleArray(), + }, + regexRules: { + type: OptionType.CUSTOM, + default: makeEmptyRuleArray(), + } }); function stringToRegex(str: string) { @@ -119,28 +118,24 @@ function Input({ initialValue, onChange, placeholder }: { ); } -function TextReplace({ title, rulesArray, rulesKey, update }: TextReplaceProps) { +function TextReplace({ title, rulesArray }: TextReplaceProps) { const isRegexRules = title === "Using Regex"; async function onClickRemove(index: number) { if (index === rulesArray.length - 1) return; rulesArray.splice(index, 1); - - await DataStore.set(rulesKey, rulesArray); - update(); } async function onChange(e: string, index: number, key: string) { - if (index === rulesArray.length - 1) + if (index === rulesArray.length - 1) { rulesArray.push(makeEmptyRule()); + } rulesArray[index][key] = e; - if (rulesArray[index].find === "" && rulesArray[index].replace === "" && rulesArray[index].onlyIfIncludes === "" && index !== rulesArray.length - 1) + if (rulesArray[index].find === "" && rulesArray[index].replace === "" && rulesArray[index].onlyIfIncludes === "" && index !== rulesArray.length - 1) { rulesArray.splice(index, 1); - - await DataStore.set(rulesKey, rulesArray); - update(); + } } return ( @@ -207,29 +202,26 @@ function TextReplaceTesting() { } function applyRules(content: string): string { - if (content.length === 0) + if (content.length === 0) { return content; - - if (stringRules) { - for (const rule of stringRules) { - if (!rule.find) continue; - if (rule.onlyIfIncludes && !content.includes(rule.onlyIfIncludes)) continue; - - content = ` ${content} `.replaceAll(rule.find, rule.replace.replaceAll("\\n", "\n")).replace(/^\s|\s$/g, ""); - } } - if (regexRules) { - for (const rule of regexRules) { - if (!rule.find) continue; - if (rule.onlyIfIncludes && !content.includes(rule.onlyIfIncludes)) continue; + for (const rule of settings.store.stringRules) { + if (!rule.find) continue; + if (rule.onlyIfIncludes && !content.includes(rule.onlyIfIncludes)) continue; - try { - const regex = stringToRegex(rule.find); - content = content.replace(regex, rule.replace.replaceAll("\\n", "\n")); - } catch (e) { - new Logger("TextReplace").error(`Invalid regex: ${rule.find}`); - } + content = ` ${content} `.replaceAll(rule.find, rule.replace.replaceAll("\\n", "\n")).replace(/^\s|\s$/g, ""); + } + + for (const rule of settings.store.regexRules) { + if (!rule.find) continue; + if (rule.onlyIfIncludes && !content.includes(rule.onlyIfIncludes)) continue; + + try { + const regex = stringToRegex(rule.find); + content = content.replace(regex, rule.replace.replaceAll("\\n", "\n")); + } catch (e) { + new Logger("TextReplace").error(`Invalid regex: ${rule.find}`); } } @@ -253,7 +245,17 @@ export default definePlugin({ }, async start() { - stringRules = await DataStore.get(STRING_RULES_KEY) ?? makeEmptyRuleArray(); - regexRules = await DataStore.get(REGEX_RULES_KEY) ?? makeEmptyRuleArray(); + // TODO: Remove DataStore rules migrations once enough time has passed + const oldStringRules = await DataStore.get(STRING_RULES_KEY); + if (oldStringRules != null) { + settings.store.stringRules = oldStringRules; + await DataStore.del(STRING_RULES_KEY); + } + + const oldRegexRules = await DataStore.get(REGEX_RULES_KEY); + if (oldRegexRules != null) { + settings.store.regexRules = oldRegexRules; + await DataStore.del(REGEX_RULES_KEY); + } } }); diff --git a/src/shared/SettingsStore.ts b/src/shared/SettingsStore.ts index 4109704bc..25dd05b19 100644 --- a/src/shared/SettingsStore.ts +++ b/src/shared/SettingsStore.ts @@ -6,6 +6,9 @@ import { LiteralUnion } from "type-fest"; +export const SYM_IS_PROXY = Symbol("SettingsStore.isProxy"); +export const SYM_GET_RAW_TARGET = Symbol("SettingsStore.getRawTarget"); + // Resolves a possibly nested prop in the form of "some.nested.prop" to type of T.some.nested.prop type ResolvePropDeep = P extends `${infer Pre}.${infer Suf}` ? Pre extends keyof T @@ -28,6 +31,11 @@ interface SettingsStoreOptions { // merges the SettingsStoreOptions type into the class export interface SettingsStore extends SettingsStoreOptions { } +interface ProxyContext { + root: T; + path: string; +} + /** * The SettingsStore allows you to easily create a mutable store that * has support for global and path-based change listeners. @@ -35,6 +43,90 @@ export interface SettingsStore extends SettingsStoreOptions { export class SettingsStore { private pathListeners = new Map void>>(); private globalListeners = new Set<(newData: T, path: string) => void>(); + private readonly proxyContexts = new WeakMap>(); + + private readonly proxyHandler: ProxyHandler = (() => { + const self = this; + + return { + get(target, key: any, receiver) { + if (key === SYM_IS_PROXY) { + return true; + } + + if (key === SYM_GET_RAW_TARGET) { + return target; + } + + let v = Reflect.get(target, key, receiver); + + const proxyContext = self.proxyContexts.get(target); + if (proxyContext == null) { + return v; + } + + const { root, path } = proxyContext; + + if (!(key in target) && self.getDefaultValue != null) { + v = self.getDefaultValue({ + target, + key, + root, + path + }); + } + + if (typeof v === "object" && v !== null && !v[SYM_IS_PROXY]) { + const getPath = `${path}${path && "."}${key}`; + return self.makeProxy(v, root, getPath); + } + + return v; + }, + set(target, key: string, value) { + if (value?.[SYM_IS_PROXY]) { + value = value[SYM_GET_RAW_TARGET]; + } + + if (target[key] === value) { + return true; + } + + if (!Reflect.set(target, key, value)) { + return false; + } + + const proxyContext = self.proxyContexts.get(target); + if (proxyContext == null) { + return true; + } + + const { root, path } = proxyContext; + + const setPath = `${path}${path && "."}${key}`; + self.notifyListeners(setPath, value, root); + + return true; + }, + deleteProperty(target, key: string) { + if (!Reflect.deleteProperty(target, key)) { + return false; + } + + const proxyContext = self.proxyContexts.get(target); + if (proxyContext == null) { + return true; + } + + const { root, path } = proxyContext; + + const deletePath = `${path}${path && "."}${key}`; + self.notifyListeners(deletePath, undefined, root); + + return true; + } + }; + })(); /** * The store object. Making changes to this object will trigger the applicable change listeners @@ -51,39 +143,33 @@ export class SettingsStore { Object.assign(this, options); } - private makeProxy(object: any, root: T = object, path: string = "") { - const self = this; - - return new Proxy(object, { - get(target, key: string) { - let v = target[key]; - - if (!(key in target) && self.getDefaultValue) { - v = self.getDefaultValue({ - target, - key, - root, - path - }); - } - - if (typeof v === "object" && v !== null && !Array.isArray(v)) - return self.makeProxy(v, root, `${path}${path && "."}${key}`); - - return v; - }, - set(target, key: string, value) { - if (target[key] === value) return true; - - Reflect.set(target, key, value); - const setPath = `${path}${path && "."}${key}`; - - self.globalListeners.forEach(cb => cb(value, setPath)); - self.pathListeners.get(setPath)?.forEach(cb => cb(value)); - - return true; - } + private makeProxy(object: any, root: T = object, path = "") { + this.proxyContexts.set(object, { + root, + path }); + + return new Proxy(object, this.proxyHandler); + } + + private notifyListeners(pathStr: string, value: any, root: T) { + const paths = pathStr.split("."); + + // Because we support any type of settings with OptionType.CUSTOM, and those objects get proxied recursively, + // the path ends up including all the nested paths (plugins.pluginName.settingName.example.one). + // So, we need to extract the top-level setting path (plugins.pluginName.settingName), + // to be able to notify globalListeners and top-level setting name listeners (let { settingName } = settings.use(["settingName"]), + // with the new value + if (paths.length > 2 && paths[0] === "plugins") { + const settingPath = paths.slice(0, 3); + const settingPathStr = settingPath.join("."); + const settingValue = settingPath.reduce((acc, curr) => acc[curr], root); + + this.globalListeners.forEach(cb => cb(root, settingPathStr)); + this.pathListeners.get(settingPathStr)?.forEach(cb => cb(settingValue)); + } + + this.pathListeners.get(pathStr)?.forEach(cb => cb(value)); } /** diff --git a/src/utils/types.ts b/src/utils/types.ts index b2210ffa5..54de59e34 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -189,6 +189,7 @@ export const enum OptionType { SELECT, SLIDER, COMPONENT, + CUSTOM } export type SettingsDefinition = Record; @@ -197,7 +198,7 @@ export type SettingsChecks = { (IsDisabled> & IsValid, DefinedSettings>); }; -export type PluginSettingDef = ( +export type PluginSettingDef = (PluginSettingCustomDef & Pick) | (( | PluginSettingStringDef | PluginSettingNumberDef | PluginSettingBooleanDef @@ -205,7 +206,7 @@ export type PluginSettingDef = ( | PluginSettingSliderDef | PluginSettingComponentDef | PluginSettingBigIntDef -) & PluginSettingCommon; +) & PluginSettingCommon); export interface PluginSettingCommon { description: string; @@ -259,12 +260,18 @@ export interface PluginSettingSelectDef { type: OptionType.SELECT; options: readonly PluginSettingSelectOption[]; } + export interface PluginSettingSelectOption { label: string; value: string | number | boolean; default?: boolean; } +export interface PluginSettingCustomDef { + type: OptionType.CUSTOM; + default?: any; +} + export interface PluginSettingSliderDef { type: OptionType.SLIDER; /** @@ -314,7 +321,9 @@ type PluginSettingType = O extends PluginSettingStri O extends PluginSettingSelectDef ? O["options"][number]["value"] : O extends PluginSettingSliderDef ? number : O extends PluginSettingComponentDef ? any : + O extends PluginSettingCustomDef ? O extends { default: infer Default; } ? Default : any : never; + type PluginSettingDefaultType = O extends PluginSettingSelectDef ? ( O["options"] extends { default?: boolean; }[] ? O["options"][number]["value"] : undefined ) : O extends { default: infer T; } ? T : undefined; @@ -366,13 +375,15 @@ export type PluginOptionsItem = | PluginOptionBoolean | PluginOptionSelect | PluginOptionSlider - | PluginOptionComponent; + | PluginOptionComponent + | PluginOptionCustom; export type PluginOptionString = PluginSettingStringDef & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionNumber = (PluginSettingNumberDef | PluginSettingBigIntDef) & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionBoolean = PluginSettingBooleanDef & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionSelect = PluginSettingSelectDef & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionSlider = PluginSettingSliderDef & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionComponent = PluginSettingComponentDef & PluginSettingCommon; +export type PluginOptionCustom = PluginSettingCustomDef & Pick; export type PluginNative any>> = { [key in keyof PluginExports]: diff --git a/src/webpack/common/types/components.d.ts b/src/webpack/common/types/components.d.ts index 260a763a7..f6a6c4ad7 100644 --- a/src/webpack/common/types/components.d.ts +++ b/src/webpack/common/types/components.d.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import type { ComponentType, CSSProperties, FunctionComponent, HtmlHTMLAttributes, HTMLProps, KeyboardEvent, MouseEvent, PropsWithChildren, PropsWithRef, ReactNode, Ref } from "react"; +import type { ComponentPropsWithRef, ComponentType, CSSProperties, FunctionComponent, HtmlHTMLAttributes, HTMLProps, JSX, KeyboardEvent, MouseEvent, PropsWithChildren, PropsWithRef, ReactNode, Ref } from "react"; import { IconNames } from "./iconNames"; @@ -471,15 +471,9 @@ export type ScrollerThin = ComponentType>; -export type Clickable = ComponentType>; +export type Clickable = (props: PropsWithChildren> & { + tag?: T; +}) => ReactNode; export type Avatar = ComponentType