diff --git a/src/def.d.ts b/src/def.d.ts index 43531a6..5ba9fef 100644 --- a/src/def.d.ts +++ b/src/def.d.ts @@ -79,16 +79,16 @@ interface InputAlertProps { initialValue: string | undefined; } -interface PluginAuthor { +interface Author { name: string; - id: string; + id?: string; } // See https://github.com/vendetta-mod/polymanifest interface PluginManifest { name: string; description: string; - authors: PluginAuthor[]; + authors: Author[]; main: string; hash: string; // Vendor-specific field, contains our own data @@ -105,6 +105,21 @@ interface Plugin { js: string; } +interface ThemeData { + name: string; + description?: string; + authors?: Author[]; + spec: number; + semanticColors?: Indexable; + rawColors?: Indexable; +} + +interface Theme { + id: string; + selected: boolean; + data: ThemeData; +} + interface Settings { debuggerUrl: string; developerSettings: boolean; @@ -302,6 +317,9 @@ interface LoaderIdentity { devtools?: { prop: string; version: string; + }, + themes?: { + prop: string; } } } @@ -399,6 +417,11 @@ interface VendettaObject { removePlugin: (id: string) => void; getSettings: (id: string) => JSX.Element; }; + themes: { + themes: Indexable; + fetchTheme: (id: string) => void; + selectTheme: (id: string) => void; + }; commands: { registerCommand: (command: ApplicationCommand) => () => void; }; diff --git a/src/lib/metro/common.ts b/src/lib/metro/common.ts index 7ac2069..3631393 100644 --- a/src/lib/metro/common.ts +++ b/src/lib/metro/common.ts @@ -1,10 +1,10 @@ import { find, findByProps } from "@metro/filters"; // Discord -export { constants } from "@metro/hoist"; +export { constants } from "@lib/preinit"; export const channels = findByProps("getVoiceChannelId"); export const i18n = findByProps("Messages"); -export const url = findByProps("openDeeplink"); +export const url = findByProps("openURL", "openDeeplink"); export const toasts = find(m => m.open && m.close && !m.startDrag && !m.init && !m.openReplay && !m.setAlwaysOnTop); export const stylesheet = findByProps("createThemedStyleSheet"); export const clipboard = findByProps("setString", "getString", "hasString") as typeof import("@react-native-clipboard/clipboard").default; @@ -21,7 +21,7 @@ export const FluxDispatcher = findByProps("_currentDispatchActionType"); // React export const React = window.React as typeof import("react"); -export { ReactNative } from "@metro/hoist"; +export { ReactNative } from "@lib/preinit"; // Moment export const moment = findByProps("isMoment") as typeof import("moment"); \ No newline at end of file diff --git a/src/lib/metro/hoist.ts b/src/lib/preinit.ts similarity index 56% rename from src/lib/metro/hoist.ts rename to src/lib/preinit.ts index cfe5d86..9ca4480 100644 --- a/src/lib/metro/hoist.ts +++ b/src/lib/preinit.ts @@ -1,6 +1,8 @@ // Hoist required modules // This used to be in filters.ts, but things became convoluted +import { initThemes } from "@lib/themes"; + // Early find logic const basicFind = (prop: string) => Object.values(window.modules).find(m => m?.publicModule.exports?.[prop])?.publicModule?.exports; @@ -11,4 +13,16 @@ window.React = basicFind("createElement") as typeof import("react"); export const ReactNative = basicFind("AppRegistry") as typeof import("react-native"); // Export Discord's constants -export const constants = basicFind("AbortCodes"); \ No newline at end of file +export const constants = basicFind("AbortCodes"); + +// Export Discord's color module +export const color = basicFind("SemanticColor"); + +// Themes +if (window.__vendetta_loader?.features.themes) { + try { + initThemes(color); + } catch (e) { + console.error("[Vendetta] Failed to initialize themes...", e); + } +} \ No newline at end of file diff --git a/src/lib/storage/backends.ts b/src/lib/storage/backends.ts index 0129258..e36cb2c 100644 --- a/src/lib/storage/backends.ts +++ b/src/lib/storage/backends.ts @@ -1,13 +1,8 @@ import { DCDFileManager, MMKVManager, StorageBackend } from "@types"; -import { ReactNative as RN } from "@metro/hoist"; +import { ReactNative as RN } from "@metro/common"; -const MMKVManager = RN.NativeModules.MMKVManager as MMKVManager; -const DCDFileManager = RN.NativeModules.DCDFileManager as DCDFileManager; - -const filePathFixer: (file: string) => string = RN.Platform.select({ - default: (f) => f, - ios: (f) => `Documents/${f}`, -}); +const MMKVManager = window.nativeModuleProxy.MMKVManager as MMKVManager; +const DCDFileManager = window.nativeModuleProxy.DCDFileManager as DCDFileManager; export const createMMKVBackend = (store: string): StorageBackend => ({ get: async () => JSON.parse((await MMKVManager.getItem(store)) ?? "{}"), @@ -15,6 +10,12 @@ export const createMMKVBackend = (store: string): StorageBackend => ({ }); export const createFileBackend = (file: string): StorageBackend => { + // TODO: Creating this function in every file backend probably isn't ideal. + const filePathFixer: (file: string) => string = RN.Platform.select({ + default: (f) => f, + ios: (f) => `Documents/${f}`, + }); + let created: boolean; return { get: async () => { diff --git a/src/lib/storage/index.ts b/src/lib/storage/index.ts index cd45159..adbae25 100644 --- a/src/lib/storage/index.ts +++ b/src/lib/storage/index.ts @@ -1,8 +1,7 @@ import { Emitter, MMKVManager, StorageBackend } from "@types"; -import { ReactNative as RN } from "@metro/hoist"; import createEmitter from "../emitter"; -const MMKVManager = RN.NativeModules.MMKVManager as MMKVManager; +const MMKVManager = window.nativeModuleProxy.MMKVManager as MMKVManager; const emitterSymbol = Symbol("emitter accessor"); const syncAwaitSymbol = Symbol("wrapSync promise accessor"); diff --git a/src/lib/themes.ts b/src/lib/themes.ts new file mode 100644 index 0000000..f1395c1 --- /dev/null +++ b/src/lib/themes.ts @@ -0,0 +1,137 @@ +import { DCDFileManager, Indexable, Theme, ThemeData } from "@types"; +import { ReactNative } from "@metro/common"; +import { after } from "@lib/patcher"; +import { createFileBackend, createMMKVBackend, createStorage, wrapSync, awaitSyncWrapper } from "@lib/storage"; +import { safeFetch } from "@utils"; + +const DCDFileManager = window.nativeModuleProxy.DCDFileManager as DCDFileManager; +export const themes = wrapSync(createStorage>(createMMKVBackend("VENDETTA_THEMES"))); + +async function writeTheme(theme: Theme | {}) { + if (typeof theme !== "object") throw new Error("Theme must be an object"); + + // Save the current theme as vendetta_theme.json. When supported by loader, + // this json will be written to window.__vendetta_theme and be used to theme the native side. + await createFileBackend("vendetta_theme.json").set(theme); +} + +function convertToRGBAString(hexString: string): string { + const color = Number(ReactNative.processColor(hexString)); + + const alpha = (color >> 24 & 0xff).toString(16).padStart(2, "0"); + const red = (color >> 16 & 0xff).toString(16).padStart(2, "0"); + const green = (color >> 8 & 0xff).toString(16).padStart(2, "0"); + const blue = (color & 0xff).toString(16).padStart(2, "0"); + + return `#${red}${green}${blue}${alpha !== "ff" ? alpha : ""}`; +} + +// Process data for some compatiblity with native side +function processData(data: ThemeData) { + if (data.semanticColors) { + const semanticColors = data.semanticColors; + + for (const key in semanticColors) { + for (const index in semanticColors[key]) { + semanticColors[key][index] = convertToRGBAString(semanticColors[key][index]); + } + } + } + + if (data.rawColors) { + const rawColors = data.rawColors; + + for (const key in rawColors) { + data.rawColors[key] = convertToRGBAString(rawColors[key]); + } + } + + return data; +} + +export async function fetchTheme(id: string, selected = false) { + let themeJSON: any; + + try { + themeJSON = await (await safeFetch(id, { cache: "no-store" })).json(); + } catch { + throw new Error(`Failed to fetch theme at ${id}`); + } + + themes[id] = { + id: id, + selected: selected, + data: processData(themeJSON), + }; + + // TODO: Should we prompt when the selected theme is updated? + if (selected) writeTheme(themes[id]); +} + +export async function installTheme(id: string) { + if (typeof id !== "string" || id in themes) throw new Error("Theme already installed"); + await fetchTheme(id); +} + +export async function selectTheme(id: string) { + if (id === "default") return await writeTheme({}); + const selectedThemeId = Object.values(themes).find(i => i.selected)?.id; + + if (selectedThemeId) themes[selectedThemeId].selected = false; + themes[id].selected = true; + await writeTheme(themes[id]); +} + +export async function removeTheme(id: string) { + const theme = themes[id]; + if (theme.selected) await selectTheme("default"); + delete themes[id]; + + return theme.selected; +} + +export function getCurrentTheme(): Theme | null { + const themeProp = window.__vendetta_loader?.features?.themes?.prop; + if (!themeProp) return null; + return window[themeProp] || null; +} + +export async function updateThemes() { + await awaitSyncWrapper(themes); + const currentTheme = getCurrentTheme(); + await Promise.allSettled(Object.keys(themes).map(id => fetchTheme(id, currentTheme?.id === id))); +} + +export async function initThemes(color: any) { + //! Native code is required here! + // Awaiting the sync wrapper is too slow, to the point where semanticColors are not correctly overwritten. + // We need a workaround, and it will unfortunately have to be done on the native side. + // await awaitSyncWrapper(themes); + + const selectedTheme = getCurrentTheme(); + if (!selectedTheme) return; + + const keys = Object.keys(color.default.colors); + const refs = Object.values(color.default.colors); + const oldRaw = color.default.unsafe_rawColors; + + color.default.unsafe_rawColors = new Proxy(oldRaw, { + get: (_, colorProp: string) => { + if (!selectedTheme) return Reflect.get(oldRaw, colorProp); + + return selectedTheme.data?.rawColors?.[colorProp] ?? Reflect.get(oldRaw, colorProp); + } + }); + + after("resolveSemanticColor", color.default.meta, (args, ret) => { + if (!selectedTheme) return ret; + + const colorSymbol = args[1] ?? ret; + const colorProp = keys[refs.indexOf(colorSymbol)]; + const themeIndex = args[0] === "amoled" ? 2 : args[0] === "light" ? 1 : 0; + + return selectedTheme?.data?.semanticColors?.[colorProp]?.[themeIndex] ?? ret; + }); + + await updateThemes(); +} \ No newline at end of file diff --git a/src/lib/windowObject.ts b/src/lib/windowObject.ts index 3608d95..340f939 100644 --- a/src/lib/windowObject.ts +++ b/src/lib/windowObject.ts @@ -5,6 +5,7 @@ import settings, { loaderConfig } from "@lib/settings"; import * as constants from "@lib/constants"; import * as debug from "@lib/debug"; import * as plugins from "@lib/plugins"; +import * as themes from "@lib/themes"; import * as commands from "@lib/commands"; import * as storage from "@lib/storage"; import * as metro from "@metro/filters"; @@ -36,6 +37,7 @@ export default async (unloads: any[]): Promise => ({ ...color, }, plugins: without(plugins, "initPlugins"), + themes: without(themes, "initThemes"), commands: without(commands, "patchCommands"), storage, settings, diff --git a/src/ui/color.ts b/src/ui/color.ts index dd83a43..76159cb 100644 --- a/src/ui/color.ts +++ b/src/ui/color.ts @@ -1,5 +1,5 @@ import { findByProps } from "@metro/filters"; -import { constants } from "@metro/hoist"; +import { constants } from "@metro/common"; //! This module is only found on 165.0+, under the assumption that iOS 165.0 is the same as Android 165.0. //* In 167.1, most if not all traces of the old color modules were removed. diff --git a/src/ui/settings/components/Card.tsx b/src/ui/settings/components/Card.tsx new file mode 100644 index 0000000..51a4fdc --- /dev/null +++ b/src/ui/settings/components/Card.tsx @@ -0,0 +1,91 @@ +import { ReactNative as RN, stylesheet } from "@metro/common"; +import { Forms } from "@ui/components"; +import { getAssetIDByName } from "@ui/assets"; +import { semanticColors } from "@ui/color"; + +const { FormRow, FormSwitch, FormRadio } = Forms; + +// TODO: These styles work weirdly. iOS has cramped text, Android with low DPI probably does too. Fix? +const styles = stylesheet.createThemedStyleSheet({ + card: { + backgroundColor: semanticColors?.BACKGROUND_SECONDARY, + borderRadius: 5, + marginHorizontal: 10, + marginBottom: 10, + }, + header: { + padding: 0, + backgroundColor: semanticColors?.BACKGROUND_TERTIARY, + borderTopLeftRadius: 5, + borderTopRightRadius: 5, + }, + actions: { + flexDirection: "row-reverse", + alignItems: "center", + }, + icon: { + width: 22, + height: 22, + marginLeft: 5, + tintColor: semanticColors?.INTERACTIVE_NORMAL, + }, +}) + +interface Action { + icon: string; + onPress: () => void; +} + +interface CardProps { + index?: number; + headerLabel: string | React.ComponentType; + headerIcon?: string; + toggleType: "switch" | "radio"; + toggleValue?: boolean; + onToggleChange?: (v: boolean) => void; + descriptionLabel?: string | React.ComponentType; + actions?: Action[]; +} + +export default function Card(props: CardProps) { + let pressableState = props.toggleValue ?? false; + + return ( + + } + trailing={props.toggleType === "switch" ? + () + : + ( { + pressableState = !pressableState; + props.onToggleChange?.(pressableState) + }}> + {/* TODO: Look into making this respect brand color */} + + ) + } + /> + + {props.actions?.map(({ icon, onPress }) => ( + + + + ))} + + } + /> + + ) +} diff --git a/src/ui/settings/components/InstallPluginButton.tsx b/src/ui/settings/components/InstallButton.tsx similarity index 54% rename from src/ui/settings/components/InstallPluginButton.tsx rename to src/ui/settings/components/InstallButton.tsx index b27d285..62d2ce4 100644 --- a/src/ui/settings/components/InstallPluginButton.tsx +++ b/src/ui/settings/components/InstallButton.tsx @@ -1,12 +1,8 @@ -import { clipboard, stylesheet } from "@metro/common"; +import { ReactNative as RN, clipboard, stylesheet } from "@metro/common"; import { HTTP_REGEX } from "@lib/constants"; -import { installPlugin } from "@lib/plugins"; import { showInputAlert } from "@ui/alerts"; import { getAssetIDByName } from "@ui/assets"; import { semanticColors } from "@ui/color"; -import { General } from "@ui/components"; - -const { TouchableOpacity, Image } = General; const styles = stylesheet.createThemedStyleSheet({ icon: { @@ -15,21 +11,27 @@ const styles = stylesheet.createThemedStyleSheet({ } }); -export default function InstallPluginButton() { +interface InstallButtonProps { + alertTitle: string; + installFunction: (id: string) => Promise; +} + +export default function InstallButton({ alertTitle, installFunction: fetchFunction }: InstallButtonProps) { return ( - + clipboard.getString().then((content) => showInputAlert({ - title: "Install Plugin", + title: alertTitle, initialValue: HTTP_REGEX.test(content) ? content : "", placeholder: "https://example.com/", - onConfirm: installPlugin, + onConfirm: (input: string) => fetchFunction(input), confirmText: "Install", confirmColor: undefined, cancelText: "Cancel" - })) + }) + ) }> - - + + ); -}; \ No newline at end of file +} \ No newline at end of file diff --git a/src/ui/settings/components/PluginCard.tsx b/src/ui/settings/components/PluginCard.tsx index d0f2c2d..45455f3 100644 --- a/src/ui/settings/components/PluginCard.tsx +++ b/src/ui/settings/components/PluginCard.tsx @@ -1,41 +1,10 @@ import { ButtonColors, Plugin } from "@types"; -import { ReactNative as RN, stylesheet, NavigationNative } from "@metro/common"; -import { Forms, General } from "@ui/components"; +import { NavigationNative, clipboard } from "@metro/common"; import { getAssetIDByName } from "@ui/assets"; import { showToast } from "@ui/toasts"; import { showConfirmationAlert } from "@ui/alerts"; -import { semanticColors } from "@ui/color"; import { removePlugin, startPlugin, stopPlugin, getSettings } from "@lib/plugins"; -import copyText from "@utils/copyText"; - -const { FormRow, FormSwitch } = Forms; -const { TouchableOpacity, Image } = General; - -// TODO: These styles work weirdly. iOS has cramped text, Android with low DPI probably does too. Fix? -const styles = stylesheet.createThemedStyleSheet({ - card: { - backgroundColor: semanticColors?.BACKGROUND_SECONDARY, - borderRadius: 5, - marginHorizontal: 10, - marginBottom: 10, - }, - header: { - padding: 0, - backgroundColor: semanticColors?.BACKGROUND_TERTIARY, - borderTopLeftRadius: 5, - borderTopRightRadius: 5, - }, - actions: { - flexDirection: "row-reverse", - alignItems: "center", - }, - icon: { - width: 22, - height: 22, - marginLeft: 5, - tintColor: semanticColors?.INTERACTIVE_NORMAL, - } -}) +import Card from "@ui/settings/components/Card"; interface PluginCardProps { plugin: Plugin; @@ -50,75 +19,63 @@ export default function PluginCard({ plugin, index }: PluginCardProps) { // This is needed because of Reactâ„¢ if (removed) return null; - return ( - - i.name).join(", ")}`} - leading={} - trailing={ - { + return ( + i.name).join(", ")}`} + headerIcon={plugin.manifest.vendetta?.icon || "ic_application_command_24px"} + toggleType="switch" + toggleValue={plugin.enabled} + onToggleChange={(v: boolean) => { + try { + if (v) startPlugin(plugin.id); else stopPlugin(plugin.id); + } catch (e) { + showToast((e as Error).message, getAssetIDByName("Small")); + } + }} + descriptionLabel={plugin.manifest.description} + actions={[ + { + icon: "ic_message_delete", + onPress: () => showConfirmationAlert({ + title: "Wait!", + content: `Are you sure you wish to delete ${plugin.manifest.name}?`, + confirmText: "Delete", + cancelText: "Cancel", + confirmColor: ButtonColors.RED, + onConfirm: () => { try { - if (v) startPlugin(plugin.id); else stopPlugin(plugin.id); + removePlugin(plugin.id); + setRemoved(true); } catch (e) { showToast((e as Error).message, getAssetIDByName("Small")); } - }} - /> - } - /> - - showConfirmationAlert({ - title: "Wait!", - content: `Are you sure you wish to delete ${plugin.manifest.name}?`, - confirmText: "Delete", - cancelText: "Cancel", - confirmColor: ButtonColors.RED, - onConfirm: () => { - try { - removePlugin(plugin.id); - setRemoved(true); - } catch (e) { - showToast((e as Error).message, getAssetIDByName("Small")); - } - } - })} - > - - - { - copyText(plugin.id); - showToast("Copied plugin URL to clipboard.", getAssetIDByName("toast_copy_link")); - }} - > - - - { - plugin.update = !plugin.update; - showToast(`${plugin.update ? "Enabled" : "Disabled"} updates for ${plugin.manifest.name}.`, getAssetIDByName("toast_image_saved")); - }} - > - - - {settings && navigation.push("VendettaCustomPage", { - title: plugin.manifest.name, - render: settings, - })}> - - } - - } - /> - + } + }), + }, + { + icon: "copy", + onPress: () => { + clipboard.setString(plugin.id); + showToast("Copied plugin URL to clipboard.", getAssetIDByName("toast_copy_link")); + }, + }, + { + icon: plugin.update ? "Check" : "Small", + onPress: () => { + plugin.update = !plugin.update; + showToast(`${plugin.update ? "Enabled" : "Disabled"} updates for ${plugin.manifest.name}.`, getAssetIDByName("toast_image_saved")); + } + }, + ...(settings ? [{ + icon: "settings", + onPress: () => navigation.push("VendettaCustomPage", { + title: plugin.manifest.name, + render: settings, + }) + }] : []), + ]} + /> ) } diff --git a/src/ui/settings/components/SettingsSection.tsx b/src/ui/settings/components/SettingsSection.tsx index 7946644..69d9f3b 100644 --- a/src/ui/settings/components/SettingsSection.tsx +++ b/src/ui/settings/components/SettingsSection.tsx @@ -26,6 +26,17 @@ export default function SettingsSection() { trailing={FormRow.Arrow} onPress={() => navigation.push("VendettaPlugins")} /> + {window.__vendetta_loader?.features.themes && ( + <> + + } + trailing={FormRow.Arrow} + onPress={() => navigation.push("VendettaThemes")} + /> + + )} {settings.developerSettings && ( <> diff --git a/src/ui/settings/components/ThemeCard.tsx b/src/ui/settings/components/ThemeCard.tsx new file mode 100644 index 0000000..c90c23b --- /dev/null +++ b/src/ui/settings/components/ThemeCard.tsx @@ -0,0 +1,66 @@ +import { ButtonColors, Theme } from "@types"; +import { clipboard } from "@metro/common"; +import { removeTheme, selectTheme } from "@lib/themes"; +import { getAssetIDByName } from "@ui/assets"; +import { showConfirmationAlert } from "@ui/alerts"; +import { showToast } from "@ui/toasts"; +import Card from "@ui/settings/components/Card"; + +interface ThemeCardProps { + theme: Theme; + index: number; +} + +async function selectAndReload(value: boolean, id: string) { + await selectTheme(value ? id : "default"); + window.nativeModuleProxy.BundleUpdaterManager.reload(); +} + +export default function ThemeCard({ theme, index }: ThemeCardProps) { + const [removed, setRemoved] = React.useState(false); + + // This is needed because of Reactâ„¢ + if (removed) return null; + + const authors = theme.data.authors; + + return ( + i.name).join(", ")}` : ""}`} + descriptionLabel={theme.data.description ?? "No description."} + toggleType="radio" + toggleValue={theme.selected} + onToggleChange={(v: boolean) => { + selectAndReload(v, theme.id); + }} + actions={[ + { + icon: "ic_message_delete", + onPress: () => showConfirmationAlert({ + title: "Wait!", + content: `Are you sure you wish to delete ${theme.data.name}?`, + confirmText: "Delete", + cancelText: "Cancel", + confirmColor: ButtonColors.RED, + onConfirm: () => { + removeTheme(theme.id).then((wasSelected) => { + setRemoved(true); + if (wasSelected) selectAndReload(false, theme.id); + }).catch((e) => { + showToast((e as Error).message, getAssetIDByName("Small")); + }) + } + }), + }, + { + icon: "copy", + onPress: () => { + clipboard.setString(theme.id); + showToast("Copied theme URL to clipboard.", getAssetIDByName("toast_copy_link")); + }, + }, + ]} + /> + ) +} diff --git a/src/ui/settings/index.tsx b/src/ui/settings/index.tsx index 0a556d2..0ab4fbb 100644 --- a/src/ui/settings/index.tsx +++ b/src/ui/settings/index.tsx @@ -1,12 +1,15 @@ import { NavigationNative, i18n } from "@metro/common"; import { findByDisplayName } from "@metro/filters"; import { after } from "@lib/patcher"; +import { installPlugin } from "@lib/plugins"; +import { installTheme } from "@lib/themes"; import findInReactTree from "@utils/findInReactTree"; import ErrorBoundary from "@ui/components/ErrorBoundary"; import SettingsSection from "@ui/settings/components/SettingsSection"; -import InstallPluginButton from "@ui/settings/components/InstallPluginButton"; +import InstallButton from "@ui/settings/components/InstallButton"; import General from "@ui/settings/pages/General"; import Plugins from "@ui/settings/pages/Plugins"; +import Themes from "@ui/settings/pages/Themes"; import Developer from "@ui/settings/pages/Developer"; import AssetBrowser from "@ui/settings/pages/AssetBrowser"; @@ -26,7 +29,12 @@ export default function initSettings() { VendettaPlugins: { title: "Plugins", render: Plugins, - headerRight: InstallPluginButton, + headerRight: () => , + }, + VendettaThemes: { + title: "Themes", + render: Themes, + headerRight: () => , }, VendettaDeveloper: { title: "Developer", diff --git a/src/ui/settings/pages/Themes.tsx b/src/ui/settings/pages/Themes.tsx new file mode 100644 index 0000000..eb58464 --- /dev/null +++ b/src/ui/settings/pages/Themes.tsx @@ -0,0 +1,21 @@ +import { themes } from "@/lib/themes"; +import { useProxy } from "@lib/storage"; +import { ReactNative as RN } from "@metro/common"; +import ErrorBoundary from "@ui/components/ErrorBoundary"; +import ThemeCard from "@ui/settings/components/ThemeCard"; + +export default function Themes() { + useProxy(themes); + + return ( + + + } + keyExtractor={item => item.id} + /> + + + ) +} \ No newline at end of file