[Themes] Implement (#34)

* [Themes] Initial work

* [Themes] Read from __vendetta_themes and early UI (#36)

* [Themes] Read from `__vendetta_themes`

* [Themes] Save as JSON and native theming

* [Themes] Basic UI

* [Themes] Merge processed theme data

* [Themes] Import ReactNative from `@lib/preinit`, oraganize imports

* [Themes] Some minor cleanup

* [Themes] UI overhaul

* [Themes] Minor adjustments

* [Themes] Implement updates, make UI reactive-ish

* [Themes] Move to new format

* [Themes > UI] Last-minute ThemeCard changes

* [Themes] Properly support AMOLED

---------

Co-authored-by: Amsyar Rasyiq <82711525+amsyarasyiq@users.noreply.github.com>
This commit is contained in:
Beef 2023-03-17 21:58:37 +00:00 committed by GitHub
parent 7dc0b1286a
commit 85a83e4873
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 464 additions and 132 deletions

29
src/def.d.ts vendored
View file

@ -79,16 +79,16 @@ interface InputAlertProps {
initialValue: string | undefined; initialValue: string | undefined;
} }
interface PluginAuthor { interface Author {
name: string; name: string;
id: string; id?: string;
} }
// See https://github.com/vendetta-mod/polymanifest // See https://github.com/vendetta-mod/polymanifest
interface PluginManifest { interface PluginManifest {
name: string; name: string;
description: string; description: string;
authors: PluginAuthor[]; authors: Author[];
main: string; main: string;
hash: string; hash: string;
// Vendor-specific field, contains our own data // Vendor-specific field, contains our own data
@ -105,6 +105,21 @@ interface Plugin {
js: string; js: string;
} }
interface ThemeData {
name: string;
description?: string;
authors?: Author[];
spec: number;
semanticColors?: Indexable<string[]>;
rawColors?: Indexable<string>;
}
interface Theme {
id: string;
selected: boolean;
data: ThemeData;
}
interface Settings { interface Settings {
debuggerUrl: string; debuggerUrl: string;
developerSettings: boolean; developerSettings: boolean;
@ -302,6 +317,9 @@ interface LoaderIdentity {
devtools?: { devtools?: {
prop: string; prop: string;
version: string; version: string;
},
themes?: {
prop: string;
} }
} }
} }
@ -399,6 +417,11 @@ interface VendettaObject {
removePlugin: (id: string) => void; removePlugin: (id: string) => void;
getSettings: (id: string) => JSX.Element; getSettings: (id: string) => JSX.Element;
}; };
themes: {
themes: Indexable<Theme>;
fetchTheme: (id: string) => void;
selectTheme: (id: string) => void;
};
commands: { commands: {
registerCommand: (command: ApplicationCommand) => () => void; registerCommand: (command: ApplicationCommand) => () => void;
}; };

View file

@ -1,10 +1,10 @@
import { find, findByProps } from "@metro/filters"; import { find, findByProps } from "@metro/filters";
// Discord // Discord
export { constants } from "@metro/hoist"; export { constants } from "@lib/preinit";
export const channels = findByProps("getVoiceChannelId"); export const channels = findByProps("getVoiceChannelId");
export const i18n = findByProps("Messages"); 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 toasts = find(m => m.open && m.close && !m.startDrag && !m.init && !m.openReplay && !m.setAlwaysOnTop);
export const stylesheet = findByProps("createThemedStyleSheet"); export const stylesheet = findByProps("createThemedStyleSheet");
export const clipboard = findByProps("setString", "getString", "hasString") as typeof import("@react-native-clipboard/clipboard").default; 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 // React
export const React = window.React as typeof import("react"); export const React = window.React as typeof import("react");
export { ReactNative } from "@metro/hoist"; export { ReactNative } from "@lib/preinit";
// Moment // Moment
export const moment = findByProps("isMoment") as typeof import("moment"); export const moment = findByProps("isMoment") as typeof import("moment");

View file

@ -1,6 +1,8 @@
// Hoist required modules // Hoist required modules
// This used to be in filters.ts, but things became convoluted // This used to be in filters.ts, but things became convoluted
import { initThemes } from "@lib/themes";
// Early find logic // Early find logic
const basicFind = (prop: string) => Object.values(window.modules).find(m => m?.publicModule.exports?.[prop])?.publicModule?.exports; const basicFind = (prop: string) => Object.values(window.modules).find(m => m?.publicModule.exports?.[prop])?.publicModule?.exports;
@ -12,3 +14,15 @@ export const ReactNative = basicFind("AppRegistry") as typeof import("react-nati
// Export Discord's constants // Export Discord's constants
export const constants = basicFind("AbortCodes"); 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);
}
}

View file

@ -1,13 +1,8 @@
import { DCDFileManager, MMKVManager, StorageBackend } from "@types"; 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 MMKVManager = window.nativeModuleProxy.MMKVManager as MMKVManager;
const DCDFileManager = RN.NativeModules.DCDFileManager as DCDFileManager; const DCDFileManager = window.nativeModuleProxy.DCDFileManager as DCDFileManager;
const filePathFixer: (file: string) => string = RN.Platform.select({
default: (f) => f,
ios: (f) => `Documents/${f}`,
});
export const createMMKVBackend = (store: string): StorageBackend => ({ export const createMMKVBackend = (store: string): StorageBackend => ({
get: async () => JSON.parse((await MMKVManager.getItem(store)) ?? "{}"), get: async () => JSON.parse((await MMKVManager.getItem(store)) ?? "{}"),
@ -15,6 +10,12 @@ export const createMMKVBackend = (store: string): StorageBackend => ({
}); });
export const createFileBackend = (file: 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; let created: boolean;
return { return {
get: async () => { get: async () => {

View file

@ -1,8 +1,7 @@
import { Emitter, MMKVManager, StorageBackend } from "@types"; import { Emitter, MMKVManager, StorageBackend } from "@types";
import { ReactNative as RN } from "@metro/hoist";
import createEmitter from "../emitter"; import createEmitter from "../emitter";
const MMKVManager = RN.NativeModules.MMKVManager as MMKVManager; const MMKVManager = window.nativeModuleProxy.MMKVManager as MMKVManager;
const emitterSymbol = Symbol("emitter accessor"); const emitterSymbol = Symbol("emitter accessor");
const syncAwaitSymbol = Symbol("wrapSync promise accessor"); const syncAwaitSymbol = Symbol("wrapSync promise accessor");

137
src/lib/themes.ts Normal file
View file

@ -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<Indexable<Theme>>(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();
}

View file

@ -5,6 +5,7 @@ import settings, { loaderConfig } from "@lib/settings";
import * as constants from "@lib/constants"; import * as constants from "@lib/constants";
import * as debug from "@lib/debug"; import * as debug from "@lib/debug";
import * as plugins from "@lib/plugins"; import * as plugins from "@lib/plugins";
import * as themes from "@lib/themes";
import * as commands from "@lib/commands"; import * as commands from "@lib/commands";
import * as storage from "@lib/storage"; import * as storage from "@lib/storage";
import * as metro from "@metro/filters"; import * as metro from "@metro/filters";
@ -36,6 +37,7 @@ export default async (unloads: any[]): Promise<VendettaObject> => ({
...color, ...color,
}, },
plugins: without(plugins, "initPlugins"), plugins: without(plugins, "initPlugins"),
themes: without(themes, "initThemes"),
commands: without(commands, "patchCommands"), commands: without(commands, "patchCommands"),
storage, storage,
settings, settings,

View file

@ -1,5 +1,5 @@
import { findByProps } from "@metro/filters"; 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. //! 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. //* In 167.1, most if not all traces of the old color modules were removed.

View file

@ -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 (
<RN.View style={[styles.card, {marginTop: props.index === 0 ? 10 : 0}]}>
<FormRow
style={styles.header}
label={props.headerLabel}
leading={props.headerIcon && <FormRow.Icon source={getAssetIDByName(props.headerIcon)} />}
trailing={props.toggleType === "switch" ?
(<FormSwitch
style={RN.Platform.OS === "android" && { marginVertical: -15 }}
value={props.toggleValue}
onValueChange={props.onToggleChange}
/>)
:
(<RN.Pressable onPress={() => {
pressableState = !pressableState;
props.onToggleChange?.(pressableState)
}}>
{/* TODO: Look into making this respect brand color */}
<FormRadio selected={props.toggleValue} />
</RN.Pressable>)
}
/>
<FormRow
label={props.descriptionLabel}
trailing={
<RN.View style={styles.actions}>
{props.actions?.map(({ icon, onPress }) => (
<RN.TouchableOpacity
onPress={onPress}
>
<RN.Image style={styles.icon} source={getAssetIDByName(icon)} />
</RN.TouchableOpacity>
))}
</RN.View>
}
/>
</RN.View>
)
}

View file

@ -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 { HTTP_REGEX } from "@lib/constants";
import { installPlugin } from "@lib/plugins";
import { showInputAlert } from "@ui/alerts"; import { showInputAlert } from "@ui/alerts";
import { getAssetIDByName } from "@ui/assets"; import { getAssetIDByName } from "@ui/assets";
import { semanticColors } from "@ui/color"; import { semanticColors } from "@ui/color";
import { General } from "@ui/components";
const { TouchableOpacity, Image } = General;
const styles = stylesheet.createThemedStyleSheet({ const styles = stylesheet.createThemedStyleSheet({
icon: { icon: {
@ -15,21 +11,27 @@ const styles = stylesheet.createThemedStyleSheet({
} }
}); });
export default function InstallPluginButton() { interface InstallButtonProps {
alertTitle: string;
installFunction: (id: string) => Promise<void>;
}
export default function InstallButton({ alertTitle, installFunction: fetchFunction }: InstallButtonProps) {
return ( return (
<TouchableOpacity onPress={() => <RN.TouchableOpacity onPress={() =>
clipboard.getString().then((content) => clipboard.getString().then((content) =>
showInputAlert({ showInputAlert({
title: "Install Plugin", title: alertTitle,
initialValue: HTTP_REGEX.test(content) ? content : "", initialValue: HTTP_REGEX.test(content) ? content : "",
placeholder: "https://example.com/", placeholder: "https://example.com/",
onConfirm: installPlugin, onConfirm: (input: string) => fetchFunction(input),
confirmText: "Install", confirmText: "Install",
confirmColor: undefined, confirmColor: undefined,
cancelText: "Cancel" cancelText: "Cancel"
})) })
)
}> }>
<Image style={styles.icon} source={getAssetIDByName("ic_add_24px")} /> <RN.Image style={styles.icon} source={getAssetIDByName("ic_add_24px")} />
</TouchableOpacity > </RN.TouchableOpacity>
); );
}; }

View file

@ -1,41 +1,10 @@
import { ButtonColors, Plugin } from "@types"; import { ButtonColors, Plugin } from "@types";
import { ReactNative as RN, stylesheet, NavigationNative } from "@metro/common"; import { NavigationNative, clipboard } from "@metro/common";
import { Forms, General } from "@ui/components";
import { getAssetIDByName } from "@ui/assets"; import { getAssetIDByName } from "@ui/assets";
import { showToast } from "@ui/toasts"; import { showToast } from "@ui/toasts";
import { showConfirmationAlert } from "@ui/alerts"; import { showConfirmationAlert } from "@ui/alerts";
import { semanticColors } from "@ui/color";
import { removePlugin, startPlugin, stopPlugin, getSettings } from "@lib/plugins"; import { removePlugin, startPlugin, stopPlugin, getSettings } from "@lib/plugins";
import copyText from "@utils/copyText"; import Card from "@ui/settings/components/Card";
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,
}
})
interface PluginCardProps { interface PluginCardProps {
plugin: Plugin; plugin: Plugin;
@ -51,32 +20,25 @@ export default function PluginCard({ plugin, index }: PluginCardProps) {
if (removed) return null; if (removed) return null;
return ( return (
<RN.View style={[styles.card, {marginTop: index === 0 ? 10 : 0}]}> <Card
<FormRow index={index}
style={styles.header}
// TODO: Actually make use of user IDs // TODO: Actually make use of user IDs
label={`${plugin.manifest.name} by ${plugin.manifest.authors.map(i => i.name).join(", ")}`} headerLabel={`${plugin.manifest.name} by ${plugin.manifest.authors.map(i => i.name).join(", ")}`}
leading={<FormRow.Icon source={getAssetIDByName(plugin.manifest.vendetta?.icon || "ic_application_command_24px")} />} headerIcon={plugin.manifest.vendetta?.icon || "ic_application_command_24px"}
trailing={ toggleType="switch"
<FormSwitch toggleValue={plugin.enabled}
style={RN.Platform.OS === "android" && { marginVertical: -15 }} onToggleChange={(v: boolean) => {
value={plugin.enabled}
onValueChange={(v: boolean) => {
try { try {
if (v) startPlugin(plugin.id); else stopPlugin(plugin.id); if (v) startPlugin(plugin.id); else stopPlugin(plugin.id);
} catch (e) { } catch (e) {
showToast((e as Error).message, getAssetIDByName("Small")); showToast((e as Error).message, getAssetIDByName("Small"));
} }
}} }}
/> descriptionLabel={plugin.manifest.description}
} actions={[
/> {
<FormRow icon: "ic_message_delete",
label={plugin.manifest.description} onPress: () => showConfirmationAlert({
trailing={
<RN.View style={styles.actions}>
<TouchableOpacity
onPress={() => showConfirmationAlert({
title: "Wait!", title: "Wait!",
content: `Are you sure you wish to delete ${plugin.manifest.name}?`, content: `Are you sure you wish to delete ${plugin.manifest.name}?`,
confirmText: "Delete", confirmText: "Delete",
@ -90,35 +52,30 @@ export default function PluginCard({ plugin, index }: PluginCardProps) {
showToast((e as Error).message, getAssetIDByName("Small")); showToast((e as Error).message, getAssetIDByName("Small"));
} }
} }
})} }),
> },
<Image style={styles.icon} source={getAssetIDByName("ic_message_delete")} /> {
</TouchableOpacity> icon: "copy",
<TouchableOpacity onPress: () => {
onPress={() => { clipboard.setString(plugin.id);
copyText(plugin.id);
showToast("Copied plugin URL to clipboard.", getAssetIDByName("toast_copy_link")); showToast("Copied plugin URL to clipboard.", getAssetIDByName("toast_copy_link"));
}} },
> },
<Image style={styles.icon} source={getAssetIDByName("copy")} /> {
</TouchableOpacity> icon: plugin.update ? "Check" : "Small",
<TouchableOpacity onPress: () => {
onPress={() => {
plugin.update = !plugin.update; plugin.update = !plugin.update;
showToast(`${plugin.update ? "Enabled" : "Disabled"} updates for ${plugin.manifest.name}.`, getAssetIDByName("toast_image_saved")); showToast(`${plugin.update ? "Enabled" : "Disabled"} updates for ${plugin.manifest.name}.`, getAssetIDByName("toast_image_saved"));
}} }
> },
<Image style={styles.icon} source={getAssetIDByName(plugin.update ? "Check" : "Small")} /> ...(settings ? [{
</TouchableOpacity> icon: "settings",
{settings && <TouchableOpacity onPress={() => navigation.push("VendettaCustomPage", { onPress: () => navigation.push("VendettaCustomPage", {
title: plugin.manifest.name, title: plugin.manifest.name,
render: settings, render: settings,
})}> })
<Image style={styles.icon} source={getAssetIDByName("settings")} /> }] : []),
</TouchableOpacity>} ]}
</RN.View>
}
/> />
</RN.View>
) )
} }

View file

@ -26,6 +26,17 @@ export default function SettingsSection() {
trailing={FormRow.Arrow} trailing={FormRow.Arrow}
onPress={() => navigation.push("VendettaPlugins")} onPress={() => navigation.push("VendettaPlugins")}
/> />
{window.__vendetta_loader?.features.themes && (
<>
<FormDivider />
<FormRow
label="Themes"
leading={<FormRow.Icon source={getAssetIDByName("ic_theme_24px")} />}
trailing={FormRow.Arrow}
onPress={() => navigation.push("VendettaThemes")}
/>
</>
)}
{settings.developerSettings && ( {settings.developerSettings && (
<> <>
<FormDivider /> <FormDivider />

View file

@ -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 (
<Card
index={index}
headerLabel={`${theme.data.name} ${authors ? `by ${authors.map(i => 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"));
},
},
]}
/>
)
}

View file

@ -1,12 +1,15 @@
import { NavigationNative, i18n } from "@metro/common"; import { NavigationNative, i18n } from "@metro/common";
import { findByDisplayName } from "@metro/filters"; import { findByDisplayName } from "@metro/filters";
import { after } from "@lib/patcher"; import { after } from "@lib/patcher";
import { installPlugin } from "@lib/plugins";
import { installTheme } from "@lib/themes";
import findInReactTree from "@utils/findInReactTree"; import findInReactTree from "@utils/findInReactTree";
import ErrorBoundary from "@ui/components/ErrorBoundary"; import ErrorBoundary from "@ui/components/ErrorBoundary";
import SettingsSection from "@ui/settings/components/SettingsSection"; 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 General from "@ui/settings/pages/General";
import Plugins from "@ui/settings/pages/Plugins"; import Plugins from "@ui/settings/pages/Plugins";
import Themes from "@ui/settings/pages/Themes";
import Developer from "@ui/settings/pages/Developer"; import Developer from "@ui/settings/pages/Developer";
import AssetBrowser from "@ui/settings/pages/AssetBrowser"; import AssetBrowser from "@ui/settings/pages/AssetBrowser";
@ -26,7 +29,12 @@ export default function initSettings() {
VendettaPlugins: { VendettaPlugins: {
title: "Plugins", title: "Plugins",
render: Plugins, render: Plugins,
headerRight: InstallPluginButton, headerRight: () => <InstallButton alertTitle="Install Plugin" installFunction={installPlugin} />,
},
VendettaThemes: {
title: "Themes",
render: Themes,
headerRight: () => <InstallButton alertTitle="Install Theme" installFunction={installTheme} />,
}, },
VendettaDeveloper: { VendettaDeveloper: {
title: "Developer", title: "Developer",

View file

@ -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 (
<ErrorBoundary>
<RN.View style={{ flex: 1 }}>
<RN.FlatList
data={Object.values(themes)}
renderItem={({ item, index }) => <ThemeCard theme={item} index={index} />}
keyExtractor={item => item.id}
/>
</RN.View>
</ErrorBoundary>
)
}