From d6c43986fd665b60a8a83d41ef907dab22e990e7 Mon Sep 17 00:00:00 2001 From: megumin Date: Fri, 4 Aug 2023 18:52:20 +0100 Subject: [PATCH] Add proper user-friendly theme manager (#635) Co-authored-by: Justice Almanzar Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com> Co-authored-by: V --- browser/VencordNativeStub.ts | 18 +- src/VencordNative.ts | 13 + src/api/Settings.ts | 8 +- src/components/PluginSettings/index.tsx | 29 +- src/components/PluginSettings/styles.css | 53 ---- src/components/VencordSettings/AddonCard.tsx | 77 +++++ src/components/VencordSettings/ThemesTab.tsx | 299 +++++++++++++++--- src/components/VencordSettings/addonCard.css | 63 ++++ .../VencordSettings/settingsStyles.css | 3 +- src/components/VencordSettings/shared.tsx | 1 + .../VencordSettings/themesStyles.css | 29 ++ src/main/index.ts | 16 +- src/main/ipcMain.ts | 45 ++- src/main/themes/LICENSE | 177 +++++++++++ src/main/themes/index.ts | 81 +++++ src/main/utils/constants.ts | 1 + src/utils/IpcEvents.ts | 6 + src/utils/quickCss.ts | 24 +- 18 files changed, 813 insertions(+), 130 deletions(-) create mode 100644 src/components/VencordSettings/AddonCard.tsx create mode 100644 src/components/VencordSettings/addonCard.css create mode 100644 src/components/VencordSettings/themesStyles.css create mode 100644 src/main/themes/LICENSE create mode 100644 src/main/themes/index.ts diff --git a/browser/VencordNativeStub.ts b/browser/VencordNativeStub.ts index 515ccc3f..664e9eef 100644 --- a/browser/VencordNativeStub.ts +++ b/browser/VencordNativeStub.ts @@ -23,6 +23,7 @@ import monacoHtml from "~fileContent/../src/components/monacoWin.html"; import * as DataStore from "../src/api/DataStore"; import { debounce } from "../src/utils"; import { getTheme, Theme } from "../src/utils/discord"; +import { getThemeInfo } from "../src/main/themes"; // Discord deletes this so need to store in variable const { localStorage } = window; @@ -34,8 +35,20 @@ const NOOP_ASYNC = async () => { }; const setCssDebounced = debounce((css: string) => VencordNative.quickCss.set(css)); +const themeStore = DataStore.createStore("VencordThemes", "VencordThemeData"); + // probably should make this less cursed at some point window.VencordNative = { + themes: { + uploadTheme: (fileName: string, fileData: string) => DataStore.set(fileName, fileData, themeStore), + deleteTheme: (fileName: string) => DataStore.del(fileName, themeStore), + getThemesDir: async () => "", + getThemesList: () => DataStore.entries(themeStore).then(entries => + entries.map(([name, css]) => getThemeInfo(css, name.toString())) + ), + getThemeData: (fileName: string) => DataStore.get(fileName, themeStore) + }, + native: { getVersions: () => ({}), openExternal: async (url) => void open(url, "_blank") @@ -57,6 +70,7 @@ window.VencordNative = { addChangeListener(cb) { cssListeners.add(cb); }, + addThemeChangeListener: NOOP, openFile: NOOP_ASYNC, async openEditor() { const features = `popup,width=${Math.min(window.innerWidth, 1000)},height=${Math.min(window.innerHeight, 1000)}`; @@ -81,5 +95,7 @@ window.VencordNative = { get: () => localStorage.getItem("VencordSettings") || "{}", set: async (s: string) => localStorage.setItem("VencordSettings", s), getSettingsDir: async () => "LocalStorage" - } + }, + + pluginHelpers: {} as any, }; diff --git a/src/VencordNative.ts b/src/VencordNative.ts index ed0686da..da09ade5 100644 --- a/src/VencordNative.ts +++ b/src/VencordNative.ts @@ -19,6 +19,7 @@ import { IpcEvents } from "@utils/IpcEvents"; import { IpcRes } from "@utils/types"; import { ipcRenderer } from "electron"; +import type { UserThemeHeader } from "main/themes"; function invoke(event: IpcEvents, ...args: any[]) { return ipcRenderer.invoke(event, ...args) as Promise; @@ -29,6 +30,14 @@ export function sendSync(event: IpcEvents, ...args: any[]) { } export default { + themes: { + uploadTheme: (fileName: string, fileData: string) => invoke(IpcEvents.UPLOAD_THEME, fileName, fileData), + deleteTheme: (fileName: string) => invoke(IpcEvents.DELETE_THEME, fileName), + getThemesDir: () => invoke(IpcEvents.GET_THEMES_DIR), + getThemesList: () => invoke>(IpcEvents.GET_THEMES_LIST), + getThemeData: (fileName: string) => invoke(IpcEvents.GET_THEME_DATA, fileName) + }, + updater: { getUpdates: () => invoke[]>>(IpcEvents.GET_UPDATES), update: () => invoke>(IpcEvents.UPDATE), @@ -50,6 +59,10 @@ export default { ipcRenderer.on(IpcEvents.QUICK_CSS_UPDATE, (_, css) => cb(css)); }, + addThemeChangeListener(cb: () => void) { + ipcRenderer.on(IpcEvents.THEME_UPDATE, cb); + }, + openFile: () => invoke(IpcEvents.OPEN_QUICKCSS), openEditor: () => invoke(IpcEvents.OPEN_MONACO_EDITOR), }, diff --git a/src/api/Settings.ts b/src/api/Settings.ts index 709050f5..c380f631 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -34,6 +34,7 @@ export interface Settings { useQuickCss: boolean; enableReactDevtools: boolean; themeLinks: string[]; + enabledThemes: string[]; frameless: boolean; transparent: boolean; winCtrlQ: boolean; @@ -68,6 +69,7 @@ const DefaultSettings: Settings = { autoUpdateNotification: true, useQuickCss: true, themeLinks: [], + enabledThemes: [], enableReactDevtools: false, frameless: false, transparent: false, @@ -107,7 +109,7 @@ const saveSettingsOnFrequentAction = debounce(async () => { } }, 60_000); -type SubscriptionCallback = ((newValue: any, path: string) => void) & { _path?: string; }; +type SubscriptionCallback = ((newValue: any, path: string) => void) & { _paths?: Array; }; const subscriptions = new Set(); const proxyCache = {} as Record; @@ -164,7 +166,7 @@ function makeProxy(settings: any, root = settings, path = ""): Settings { const setPath = `${path}${path && "."}${p}`; delete proxyCache[setPath]; for (const subscription of subscriptions) { - if (!subscription._path || subscription._path === setPath) { + if (!subscription._paths || subscription._paths.includes(setPath)) { subscription(v, setPath); } } @@ -235,7 +237,7 @@ type ResolvePropDeep = P extends "" ? T : export function addSettingsListener(path: Path, onUpdate: (newValue: Settings[Path], path: Path) => void): void; export function addSettingsListener(path: Path, onUpdate: (newValue: Path extends "" ? any : ResolvePropDeep, path: Path extends "" ? string : Path) => void): void; export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void) { - (onUpdate as SubscriptionCallback)._path = path; + ((onUpdate as SubscriptionCallback)._paths ??= []).push(path); subscriptions.add(onUpdate); } diff --git a/src/components/PluginSettings/index.tsx b/src/components/PluginSettings/index.tsx index 7749abd2..12487c6d 100644 --- a/src/components/PluginSettings/index.tsx +++ b/src/components/PluginSettings/index.tsx @@ -22,10 +22,8 @@ import * as DataStore from "@api/DataStore"; import { showNotice } from "@api/Notices"; import { Settings, useSettings } from "@api/Settings"; import { classNameFactory } from "@api/Styles"; -import { Flex } from "@components/Flex"; -import { Badge } from "@components/PluginSettings/components"; import PluginModal from "@components/PluginSettings/PluginModal"; -import { Switch } from "@components/Switch"; +import { AddonCard } from "@components/VencordSettings/AddonCard"; import { SettingsTab } from "@components/VencordSettings/shared"; import { ChangeList } from "@utils/ChangeList"; import { Logger } from "@utils/Logger"; @@ -152,24 +150,23 @@ function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLe } return ( - -
- - {plugin.name}{isNew && } - + openModal()} className={classes(ButtonClasses.button, cl("info-button"))}> {plugin.options ? : } - -
- {plugin.description} -
+ } + /> ); } diff --git a/src/components/PluginSettings/styles.css b/src/components/PluginSettings/styles.css index a756fa9d..66b2a215 100644 --- a/src/components/PluginSettings/styles.css +++ b/src/components/PluginSettings/styles.css @@ -23,38 +23,6 @@ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } -.vc-plugins-card { - background-color: var(--background-secondary-alt); - color: var(--interactive-active); - border-radius: 8px; - display: block; - height: 100%; - padding: 12px; - width: 100%; - transition: 0.1s ease-out; - transition-property: box-shadow, transform, background, opacity; -} - -.vc-plugins-card-disabled { - opacity: 0.6; -} - -.vc-plugins-card:hover { - background-color: var(--background-tertiary); - transform: translateY(-1px); - box-shadow: var(--elevation-high); -} - -.vc-plugins-card-header { - margin-top: auto; - display: flex; - width: 100%; - justify-content: flex-end; - height: 1.5rem; - align-items: center; - gap: 8px; -} - .vc-plugins-info-button { height: 24px; width: 24px; @@ -86,27 +54,6 @@ text-align: center; } -.vc-plugins-note { - height: 36px; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - /* stylelint-disable-next-line property-no-unknown */ - box-orient: vertical; -} - -.vc-plugins-name { - display: flex; - width: 100%; - align-items: center; - flex-grow: 1; - gap: 8px; - cursor: "default"; -} - .vc-plugins-dep-name { margin: 0 auto; } diff --git a/src/components/VencordSettings/AddonCard.tsx b/src/components/VencordSettings/AddonCard.tsx new file mode 100644 index 00000000..c4c3aaca --- /dev/null +++ b/src/components/VencordSettings/AddonCard.tsx @@ -0,0 +1,77 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import "./addonCard.css"; + +import { classNameFactory } from "@api/Styles"; +import { Badge } from "@components/Badge"; +import { Switch } from "@components/Switch"; +import { Text } from "@webpack/common"; +import type { MouseEventHandler, ReactNode } from "react"; + +const cl = classNameFactory("vc-addon-"); + +interface Props { + name: ReactNode; + description: ReactNode; + enabled: boolean; + setEnabled: (enabled: boolean) => void; + disabled?: boolean; + isNew?: boolean; + onMouseEnter?: MouseEventHandler; + onMouseLeave?: MouseEventHandler; + + infoButton?: ReactNode; + footer?: ReactNode; + author?: ReactNode; +} + +export function AddonCard({ disabled, isNew, name, infoButton, footer, author, enabled, setEnabled, description, onMouseEnter, onMouseLeave }: Props) { + return ( +
+
+
+ + {name}{isNew && } + + {!!author && ( + + {author} + + )} +
+ + {infoButton} + + +
+ + {description} + + {footer} +
+ ); +} diff --git a/src/components/VencordSettings/ThemesTab.tsx b/src/components/VencordSettings/ThemesTab.tsx index a6703947..c44ad45f 100644 --- a/src/components/VencordSettings/ThemesTab.tsx +++ b/src/components/VencordSettings/ThemesTab.tsx @@ -17,16 +17,35 @@ */ import { useSettings } from "@api/Settings"; +import { classNameFactory } from "@api/Styles"; +import { Flex } from "@components/Flex"; import { Link } from "@components/Link"; import { Margins } from "@utils/margins"; +import { classes } from "@utils/misc"; +import { showItemInFolder } from "@utils/native"; import { useAwaiter } from "@utils/react"; -import { findLazy } from "@webpack"; -import { Button, Card, Forms, React, TextArea } from "@webpack/common"; +import { findByCodeLazy, findByPropsLazy, findLazy } from "@webpack"; +import { Button, Card, FluxDispatcher, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common"; +import { UserThemeHeader } from "main/themes"; +import type { ComponentType, Ref, SyntheticEvent } from "react"; +import { AddonCard } from "./AddonCard"; import { SettingsTab, wrapTab } from "./shared"; +type FileInput = ComponentType<{ + ref: Ref; + onChange: (e: SyntheticEvent) => void; + multiple?: boolean; + filters?: { name?: string; extensions: string[]; }[]; +}>; + +const InviteActions = findByPropsLazy("resolveInvite"); +const TrashIcon = findByCodeLazy("M5 6.99902V18.999C5 20.101 5.897 20.999"); +const FileInput: FileInput = findByCodeLazy("activateUploadDialogue="); const TextAreaProps = findLazy(m => typeof m.textarea === "string"); +const cl = classNameFactory("vc-settings-theme-"); + function Validator({ link }: { link: string; }) { const [res, err, pending] = useAwaiter(() => fetch(link).then(res => { if (res.status > 300) throw `${res.status} ${res.statusText}`; @@ -75,10 +94,191 @@ function Validators({ themeLinks }: { themeLinks: string[]; }) { ); } +interface ThemeCardProps { + theme: UserThemeHeader; + enabled: boolean; + onChange: (enabled: boolean) => void; + onDelete: () => void; +} + +function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) { + return ( + + + + ) + } + footer={ + + {!!theme.website && Website} + {!!(theme.website && theme.invite) && " • "} + {!!theme.invite && ( + { + e.preventDefault(); + const { invite } = await InviteActions.resolveInvite(theme.invite, "Desktop Modal"); + if (!invite) return showToast("Invalid or expired invite"); + + FluxDispatcher.dispatch({ + type: "INVITE_MODAL_OPEN", + invite, + code: theme.invite, + context: "APP" + }); + }} + > + Discord Server + + )} + + } + /> + ); +} + +enum ThemeTab { + LOCAL, + ONLINE +} + function ThemesTab() { - const settings = useSettings(["themeLinks"]); - const [themeText, setThemeText] = React.useState(settings.themeLinks.join("\n")); + const settings = useSettings(["themeLinks", "enabledThemes"]); + + const fileInputRef = useRef(null); + const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL); + const [themeText, setThemeText] = useState(settings.themeLinks.join("\n")); + const [userThemes, setUserThemes] = useState(null); + const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir); + useEffect(() => { + refreshLocalThemes(); + }, []); + + async function refreshLocalThemes() { + const themes = await VencordNative.themes.getThemesList(); + setUserThemes(themes); + } + + // When a local theme is enabled/disabled, update the settings + function onLocalThemeChange(fileName: string, value: boolean) { + if (value) { + if (settings.enabledThemes.includes(fileName)) return; + settings.enabledThemes = [...settings.enabledThemes, fileName]; + } else { + settings.enabledThemes = settings.enabledThemes.filter(f => f !== fileName); + } + } + + async function onFileUpload(e: SyntheticEvent) { + e.stopPropagation(); + e.preventDefault(); + if (!e.currentTarget?.files?.length) return; + const { files } = e.currentTarget; + + const uploads = Array.from(files, file => { + const { name } = file; + if (!name.endsWith(".css")) return; + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + VencordNative.themes.uploadTheme(name, reader.result as string) + .then(resolve) + .catch(reject); + }; + reader.readAsText(file); + }); + }); + + await Promise.all(uploads); + refreshLocalThemes(); + } + + function renderLocalThemes() { + return ( + <> + + Find Themes: +
+ + BetterDiscord Themes + + GitHub +
+ If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder. +
+ + + + <> + {IS_WEB ? + ( + + ) : ( + + )} + + + + + +
+ {userThemes?.map(theme => ( + onLocalThemeChange(theme.fileName, enabled)} + onDelete={async () => { + onLocalThemeChange(theme.fileName, false); + await VencordNative.themes.deleteTheme(theme.fileName); + refreshLocalThemes(); + }} + theme={theme} + /> + ))} +
+
+ + ); + } + + // When the user leaves the online theme textbox, update the settings function onBlur() { settings.themeLinks = [...new Set( themeText @@ -89,51 +289,56 @@ function ThemesTab() { )]; } + function renderOnlineThemes() { + return ( + <> + + Paste links to css files here + One link per line + Make sure to use direct links to files (raw or github.io)! + + + +