[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:
parent
7dc0b1286a
commit
85a83e4873
15 changed files with 464 additions and 132 deletions
29
src/def.d.ts
vendored
29
src/def.d.ts
vendored
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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");
|
|
@ -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;
|
||||||
|
|
||||||
|
@ -11,4 +13,16 @@ window.React = basicFind("createElement") as typeof import("react");
|
||||||
export const ReactNative = basicFind("AppRegistry") as typeof import("react-native");
|
export const ReactNative = basicFind("AppRegistry") as typeof import("react-native");
|
||||||
|
|
||||||
// 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 () => {
|
||||||
|
|
|
@ -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
137
src/lib/themes.ts
Normal 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();
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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.
|
||||||
|
|
91
src/ui/settings/components/Card.tsx
Normal file
91
src/ui/settings/components/Card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
}
|
|
@ -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;
|
||||||
|
@ -50,75 +19,63 @@ export default function PluginCard({ plugin, index }: PluginCardProps) {
|
||||||
// This is needed because of React™
|
// This is needed because of React™
|
||||||
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
|
headerLabel={`${plugin.manifest.name} by ${plugin.manifest.authors.map(i => i.name).join(", ")}`}
|
||||||
label={`${plugin.manifest.name} by ${plugin.manifest.authors.map(i => i.name).join(", ")}`}
|
headerIcon={plugin.manifest.vendetta?.icon || "ic_application_command_24px"}
|
||||||
leading={<FormRow.Icon source={getAssetIDByName(plugin.manifest.vendetta?.icon || "ic_application_command_24px")} />}
|
toggleType="switch"
|
||||||
trailing={
|
toggleValue={plugin.enabled}
|
||||||
<FormSwitch
|
onToggleChange={(v: boolean) => {
|
||||||
style={RN.Platform.OS === "android" && { marginVertical: -15 }}
|
try {
|
||||||
value={plugin.enabled}
|
if (v) startPlugin(plugin.id); else stopPlugin(plugin.id);
|
||||||
onValueChange={(v: boolean) => {
|
} 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 {
|
try {
|
||||||
if (v) startPlugin(plugin.id); else stopPlugin(plugin.id);
|
removePlugin(plugin.id);
|
||||||
|
setRemoved(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast((e as Error).message, getAssetIDByName("Small"));
|
showToast((e as Error).message, getAssetIDByName("Small"));
|
||||||
}
|
}
|
||||||
}}
|
}
|
||||||
/>
|
}),
|
||||||
}
|
},
|
||||||
/>
|
{
|
||||||
<FormRow
|
icon: "copy",
|
||||||
label={plugin.manifest.description}
|
onPress: () => {
|
||||||
trailing={
|
clipboard.setString(plugin.id);
|
||||||
<RN.View style={styles.actions}>
|
showToast("Copied plugin URL to clipboard.", getAssetIDByName("toast_copy_link"));
|
||||||
<TouchableOpacity
|
},
|
||||||
onPress={() => showConfirmationAlert({
|
},
|
||||||
title: "Wait!",
|
{
|
||||||
content: `Are you sure you wish to delete ${plugin.manifest.name}?`,
|
icon: plugin.update ? "Check" : "Small",
|
||||||
confirmText: "Delete",
|
onPress: () => {
|
||||||
cancelText: "Cancel",
|
plugin.update = !plugin.update;
|
||||||
confirmColor: ButtonColors.RED,
|
showToast(`${plugin.update ? "Enabled" : "Disabled"} updates for ${plugin.manifest.name}.`, getAssetIDByName("toast_image_saved"));
|
||||||
onConfirm: () => {
|
}
|
||||||
try {
|
},
|
||||||
removePlugin(plugin.id);
|
...(settings ? [{
|
||||||
setRemoved(true);
|
icon: "settings",
|
||||||
} catch (e) {
|
onPress: () => navigation.push("VendettaCustomPage", {
|
||||||
showToast((e as Error).message, getAssetIDByName("Small"));
|
title: plugin.manifest.name,
|
||||||
}
|
render: settings,
|
||||||
}
|
})
|
||||||
})}
|
}] : []),
|
||||||
>
|
]}
|
||||||
<Image style={styles.icon} source={getAssetIDByName("ic_message_delete")} />
|
/>
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
copyText(plugin.id);
|
|
||||||
showToast("Copied plugin URL to clipboard.", getAssetIDByName("toast_copy_link"));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image style={styles.icon} source={getAssetIDByName("copy")} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
plugin.update = !plugin.update;
|
|
||||||
showToast(`${plugin.update ? "Enabled" : "Disabled"} updates for ${plugin.manifest.name}.`, getAssetIDByName("toast_image_saved"));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image style={styles.icon} source={getAssetIDByName(plugin.update ? "Check" : "Small")} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
{settings && <TouchableOpacity onPress={() => navigation.push("VendettaCustomPage", {
|
|
||||||
title: plugin.manifest.name,
|
|
||||||
render: settings,
|
|
||||||
})}>
|
|
||||||
<Image style={styles.icon} source={getAssetIDByName("settings")} />
|
|
||||||
</TouchableOpacity>}
|
|
||||||
</RN.View>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</RN.View>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
66
src/ui/settings/components/ThemeCard.tsx
Normal file
66
src/ui/settings/components/ThemeCard.tsx
Normal 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"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
21
src/ui/settings/pages/Themes.tsx
Normal file
21
src/ui/settings/pages/Themes.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
Loading…
Reference in a new issue