[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

View file

@ -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");

View file

@ -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");
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 { 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 () => {

View file

@ -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");

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 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<VendettaObject> => ({
...color,
},
plugins: without(plugins, "initPlugins"),
themes: without(themes, "initThemes"),
commands: without(commands, "patchCommands"),
storage,
settings,