diff --git a/package.json b/package.json index dcd640e1a..72ea2b79f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "doc": "docs" }, "scripts": { - "build": "node scripts/build/build.mjs", + "build": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs", "buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs", "generatePluginJson": "tsx scripts/generatePluginList.ts", "inject": "node scripts/runInstaller.mjs", @@ -28,7 +28,7 @@ "testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc", "testTsc": "tsc --noEmit", "uninject": "node scripts/runInstaller.mjs", - "watch": "node scripts/build/build.mjs --watch" + "watch": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs --watch" }, "dependencies": { "@sapphi-red/web-noise-suppressor": "0.3.3", @@ -72,7 +72,8 @@ "tsx": "^3.12.7", "type-fest": "^3.9.0", "typescript": "^5.0.4", - "zip-local": "^0.3.5" + "zip-local": "^0.3.5", + "zustand": "^3.7.2" }, "packageManager": "pnpm@8.10.2", "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d62be17f2..332b3f997 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - patchedDependencies: '@types/less@3.0.4': hash: krcufrsfhsuxuoj7hocqugs6zi @@ -138,6 +134,9 @@ devDependencies: zip-local: specifier: ^0.3.5 version: 0.3.5 + zustand: + specifier: ^3.7.2 + version: 3.7.2 packages: @@ -3485,8 +3484,22 @@ packages: q: 1.5.1 dev: true + /zustand@3.7.2: + resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==} + engines: {node: '>=12.7.0'} + peerDependencies: + react: '>=16.8' + peerDependenciesMeta: + react: + optional: true + dev: true + github.com/mattdesl/gifenc/64842fca317b112a8590f8fef2bf3825da8f6fe3: resolution: {tarball: https://codeload.github.com/mattdesl/gifenc/tar.gz/64842fca317b112a8590f8fef2bf3825da8f6fe3} name: gifenc version: 1.0.3 dev: false + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false diff --git a/scripts/generateReport.ts b/scripts/generateReport.ts index 719a84568..a75a5985f 100644 --- a/scripts/generateReport.ts +++ b/scripts/generateReport.ts @@ -105,7 +105,14 @@ async function printReport() { console.log(); - report.otherErrors = report.otherErrors.filter(e => !IGNORED_DISCORD_ERRORS.some(regex => e.match(regex))); + const ignoredErrors = [] as string[]; + report.otherErrors = report.otherErrors.filter(e => { + if (IGNORED_DISCORD_ERRORS.some(regex => e.match(regex))) { + ignoredErrors.push(e); + return false; + } + return true; + }); console.log("## Discord Errors"); report.otherErrors.forEach(e => { @@ -114,6 +121,13 @@ async function printReport() { console.log(); + console.log("## Ignored Discord Errors"); + ignoredErrors.forEach(e => { + console.log(`- ${toCodeBlock(e)}`); + }); + + console.log(); + if (process.env.DISCORD_WEBHOOK) { await fetch(process.env.DISCORD_WEBHOOK, { method: "POST", @@ -410,7 +424,7 @@ function runTime(token: string) { const [code, matcher] = args; const module = Vencord.Webpack.findModuleFactory(...code); - if (module) result = module.toString().match(matcher); + if (module) result = module.toString().match(Vencord.Util.canonicalizeMatch(matcher)); } else { // @ts-ignore result = Vencord.Webpack[method](...args); diff --git a/src/api/Commands/commandHelpers.ts b/src/api/Commands/commandHelpers.ts index 2fd189032..ebcc4e2fa 100644 --- a/src/api/Commands/commandHelpers.ts +++ b/src/api/Commands/commandHelpers.ts @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import { MessageActions } from "@utils/discord"; import { mergeDefaults } from "@utils/misc"; import { findByPropsLazy } from "@webpack"; import { SnowflakeUtils } from "@webpack/common"; @@ -24,7 +25,6 @@ import type { PartialDeep } from "type-fest"; import { Argument } from "./types"; -const MessageCreator = findByPropsLazy("createBotMessage"); const MessageSender = findByPropsLazy("receiveMessage"); export function generateId() { @@ -38,7 +38,7 @@ export function generateId() { * @returns {Message} */ export function sendBotMessage(channelId: string, message: PartialDeep): Message { - const botMessage = MessageCreator.createBotMessage({ channelId, content: "", embeds: [] }); + const botMessage = MessageActions.createBotMessage({ channelId, content: "", embeds: [] }); MessageSender.receiveMessage(channelId, mergeDefaults(message, botMessage)); diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 8ebe51a15..4a0938ce4 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -273,3 +273,38 @@ export function PluginIcon(props: IconProps) { ); } + +export function PlusIcon(props: IconProps) { + return ( + + + + ); +} + +export function NoEntrySignIcon(props: IconProps) { + return ( + + + + + ); +} diff --git a/src/plugins/_api/badges.tsx b/src/plugins/_api/badges.tsx index 11e843db4..16b244a19 100644 --- a/src/plugins/_api/badges.tsx +++ b/src/plugins/_api/badges.tsx @@ -22,14 +22,13 @@ import ErrorBoundary from "@components/ErrorBoundary"; import { Flex } from "@components/Flex"; import { Heart } from "@components/Heart"; import { Devs } from "@utils/constants"; -import { Logger } from "@utils/Logger"; import { Margins } from "@utils/margins"; import { isPluginDev } from "@utils/misc"; import { closeModal, Modals, openModal } from "@utils/modal"; import definePlugin from "@utils/types"; import { Forms, Toasts } from "@webpack/common"; -const CONTRIBUTOR_BADGE = "https://cdn.discordapp.com/attachments/1033680203433660458/1092089947126780035/favicon.png"; +const CONTRIBUTOR_BADGE = "https://vencord.dev/assets/favicon.png"; const ContributorBadge: ProfileBadge = { description: "Vencord Contributor", @@ -45,7 +44,7 @@ const ContributorBadge: ProfileBadge = { link: "https://github.com/Vendicated/Vencord" }; -let DonorBadges = {} as Record[]>; +let DonorBadges = {} as Record>>; async function loadBadges(noCache = false) { DonorBadges = {}; @@ -54,19 +53,8 @@ async function loadBadges(noCache = false) { if (noCache) init.cache = "no-cache"; - const badges = await fetch("https://gist.githubusercontent.com/Vendicated/51a3dd775f6920429ec6e9b735ca7f01/raw/badges.csv", init) - .then(r => r.text()); - - const lines = badges.trim().split("\n"); - if (lines.shift() !== "id,tooltip,image") { - new Logger("BadgeAPI").error("Invalid badges.csv file!"); - return; - } - - for (const line of lines) { - const [id, description, image] = line.split(","); - (DonorBadges[id] ??= []).push({ image, description }); - } + DonorBadges = await fetch("https://badges.vencord.dev/badges.json", init) + .then(r => r.json()); } export default definePlugin({ @@ -127,7 +115,8 @@ export default definePlugin({ getDonorBadges(userId: string) { return DonorBadges[userId]?.map(badge => ({ - ...badge, + image: badge.badge, + description: badge.tooltip, position: BadgePosition.START, props: { style: { diff --git a/src/plugins/clientTheme/index.tsx b/src/plugins/clientTheme/index.tsx index 7b30863e7..d75929961 100644 --- a/src/plugins/clientTheme/index.tsx +++ b/src/plugins/clientTheme/index.tsx @@ -15,7 +15,7 @@ import definePlugin, { OptionType, StartAt } from "@utils/types"; import { findComponentByCodeLazy } from "@webpack"; import { Button, Forms } from "@webpack/common"; -const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR"); +const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)"); const colorPresets = [ "#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D", diff --git a/src/plugins/consoleShortcuts/index.ts b/src/plugins/consoleShortcuts/index.ts index 10853f25a..e25e7cb30 100644 --- a/src/plugins/consoleShortcuts/index.ts +++ b/src/plugins/consoleShortcuts/index.ts @@ -63,6 +63,7 @@ export default definePlugin({ let fakeRenderWin: WeakRef | undefined; const find = newFindWrapper(f => f); + const findByProps = newFindWrapper(filters.byProps); return { ...Vencord.Webpack.Common, wp: Vencord.Webpack, @@ -73,13 +74,13 @@ export default definePlugin({ wpexs: (code: string) => extract(Webpack.findModuleId(code)!), find, findAll, - findByProps: newFindWrapper(filters.byProps), + findByProps, findAllByProps: (...props: string[]) => findAll(filters.byProps(...props)), findByCode: newFindWrapper(filters.byCode), findAllByCode: (code: string) => findAll(filters.byCode(code)), findComponentByCode: newFindWrapper(filters.componentByCode), findAllComponentsByCode: (...code: string[]) => findAll(filters.componentByCode(...code)), - findExportedComponent: (...props: string[]) => find(...props)[props[0]], + findExportedComponent: (...props: string[]) => findByProps(...props)[props[0]], findStore: newFindWrapper(filters.byStoreName), PluginsApi: Vencord.Plugins, plugins: Vencord.Plugins.plugins, diff --git a/src/plugins/crashHandler/index.ts b/src/plugins/crashHandler/index.ts index a1ba01c3d..9d38b7d17 100644 --- a/src/plugins/crashHandler/index.ts +++ b/src/plugins/crashHandler/index.ts @@ -23,12 +23,26 @@ import { Logger } from "@utils/Logger"; import { closeAllModals } from "@utils/modal"; import definePlugin, { OptionType } from "@utils/types"; import { maybePromptToUpdate } from "@utils/updater"; -import { findByPropsLazy } from "@webpack"; -import { FluxDispatcher, NavigationRouter } from "@webpack/common"; +import { filters, findBulk, proxyLazyWebpack } from "@webpack"; +import { FluxDispatcher, NavigationRouter, SelectedChannelStore } from "@webpack/common"; import type { ReactElement } from "react"; const CrashHandlerLogger = new Logger("CrashHandler"); -const ModalStack = findByPropsLazy("pushLazy", "popAll"); +const { ModalStack, DraftManager, DraftType, closeExpressionPicker } = proxyLazyWebpack(() => { + const modules = findBulk( + filters.byProps("pushLazy", "popAll"), + filters.byProps("clearDraft", "saveDraft"), + filters.byProps("DraftType"), + filters.byProps("closeExpressionPicker", "openExpressionPicker"), + ); + + return { + ModalStack: modules[0], + DraftManager: modules[1], + DraftType: modules[2]?.DraftType, + closeExpressionPicker: modules[3]?.closeExpressionPicker, + }; +}); const settings = definePluginSettings({ attemptToPreventCrashes: { @@ -115,13 +129,27 @@ export default definePlugin({ } catch { } } + try { + const channelId = SelectedChannelStore.getChannelId(); + + DraftManager.clearDraft(channelId, DraftType.ChannelMessage); + DraftManager.clearDraft(channelId, DraftType.FirstThreadMessage); + } catch (err) { + CrashHandlerLogger.debug("Failed to clear drafts.", err); + } + try { + closeExpressionPicker(); + } + catch (err) { + CrashHandlerLogger.debug("Failed to close expression picker.", err); + } try { FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" }); } catch (err) { CrashHandlerLogger.debug("Failed to close open context menu.", err); } try { - ModalStack?.popAll(); + ModalStack.popAll(); } catch (err) { CrashHandlerLogger.debug("Failed to close old modals.", err); } diff --git a/src/plugins/decor/README.md b/src/plugins/decor/README.md new file mode 100644 index 000000000..467a61457 --- /dev/null +++ b/src/plugins/decor/README.md @@ -0,0 +1,17 @@ +# Decor + +Custom avatar decorations! + +![Custom decorations in chat](https://github.com/Vendicated/Vencord/assets/30497388/b0c4c4c8-8723-42a8-b50f-195ad4e26136) + +Create and use your own custom avatar decorations, or pick your favorite from the presets. + +You'll be able to see the custom avatar decorations of other users of this plugin, and they'll be able to see your custom avatar decoration. + +You can select and manage your custom avatar decorations under the "Profiles" page in settings, or in the plugin settings. + +![Custom decorations management](https://github.com/Vendicated/Vencord/assets/30497388/74fe8a9e-a2a2-4b29-bc10-9eaa58208ad4) + +Review the [guidelines](https://github.com/decor-discord/.github/blob/main/GUIDELINES.md) before creating your own custom avatar decoration. + +Join the [Discord server](https://discord.gg/dXp2SdxDcP) for support and notifications on your decoration's review. diff --git a/src/plugins/decor/index.tsx b/src/plugins/decor/index.tsx new file mode 100644 index 000000000..4dd7aa0c9 --- /dev/null +++ b/src/plugins/decor/index.tsx @@ -0,0 +1,168 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated, FieryFlames and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./ui/styles.css"; + +import { definePluginSettings } from "@api/Settings"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Link } from "@components/Link"; +import { Devs } from "@utils/constants"; +import { Margins } from "@utils/margins"; +import { classes } from "@utils/misc"; +import { closeAllModals } from "@utils/modal"; +import definePlugin, { OptionType } from "@utils/types"; +import { findByPropsLazy } from "@webpack"; +import { FluxDispatcher, Forms, UserStore } from "@webpack/common"; + +import { CDN_URL, RAW_SKU_ID, SKU_ID } from "./lib/constants"; +import { useAuthorizationStore } from "./lib/stores/AuthorizationStore"; +import { useCurrentUserDecorationsStore } from "./lib/stores/CurrentUserDecorationsStore"; +import { useUserDecorAvatarDecoration, useUsersDecorationsStore } from "./lib/stores/UsersDecorationsStore"; +import { setDecorationGridDecoration, setDecorationGridItem } from "./ui/components"; +import DecorSection from "./ui/components/DecorSection"; + +const { isAnimatedAvatarDecoration } = findByPropsLazy("isAnimatedAvatarDecoration"); +export interface AvatarDecoration { + asset: string; + skuId: string; +} + +const settings = definePluginSettings({ + changeDecoration: { + type: OptionType.COMPONENT, + description: "Change your avatar decoration", + component() { + return + + + You can also access Decor decorations from the { + e.preventDefault(); + closeAllModals(); + FluxDispatcher.dispatch({ type: "USER_SETTINGS_MODAL_SET_SECTION", section: "Profile Customization" }); + }} + >Profiles page. + + ; + } + } +}); +export default definePlugin({ + name: "Decor", + description: "Create and use your own custom avatar decorations, or pick your favorite from the presets.", + authors: [Devs.FieryFlames], + patches: [ + // Patch MediaResolver to return correct URL for Decor avatar decorations + { + find: "getAvatarDecorationURL:", + replacement: { + match: /(?<=function \i\(\i\){)(?=let{avatarDecoration)/, + replace: "const vcDecorDecoration=$self.getDecorAvatarDecorationURL(arguments[0]);if(vcDecorDecoration)return vcDecorDecoration;" + } + }, + // Patch profile customization settings to include Decor section + { + find: "DefaultCustomizationSections", + replacement: { + match: /(?<={user:\i},"decoration"\),)/, + replace: "$self.DecorSection()," + } + }, + // Decoration modal module + { + find: ".decorationGridItem", + replacement: [ + { + match: /(?<==)\i=>{let{children.{20,100}decorationGridItem/, + replace: "$self.DecorationGridItem=$&" + }, + { + match: /(?<==)\i=>{let{user:\i,avatarDecoration.{300,600}decorationGridItemChurned/, + replace: "$self.DecorationGridDecoration=$&" + }, + // Remove NEW label from decor avatar decorations + { + match: /(?<=\.Section\.PREMIUM_PURCHASE&&\i;if\()(?<=avatarDecoration:(\i).+?)/, + replace: "$1.skuId===$self.SKU_ID||" + } + ] + }, + { + find: "isAvatarDecorationAnimating:", + group: true, + replacement: [ + // Add Decor avatar decoration hook to avatar decoration hook + { + match: /(?<=TryItOut:\i}\),)(?<=user:(\i).+?)/, + replace: "vcDecorAvatarDecoration=$self.useUserDecorAvatarDecoration($1)," + }, + // Use added hook + { + match: /(?<={avatarDecoration:).{1,20}?(?=,)(?<=avatarDecorationOverride:(\i).+?)/, + replace: "$1??vcDecorAvatarDecoration??($&)" + }, + // Make memo depend on added hook + { + match: /(?<=size:\i}\),\[)/, + replace: "vcDecorAvatarDecoration," + } + ] + }, + // Current user area, at bottom of channels/dm list + { + find: "renderAvatarWithPopout(){", + replacement: [ + // Use Decor avatar decoration hook + { + match: /(?<=getAvatarDecorationURL\)\({avatarDecoration:)(\i).avatarDecoration(?=,)/, + replace: "$self.useUserDecorAvatarDecoration($1)??$&" + } + ] + } + ], + settings, + + flux: { + CONNECTION_OPEN: () => { + useAuthorizationStore.getState().init(); + useCurrentUserDecorationsStore.getState().clear(); + useUsersDecorationsStore.getState().fetch(UserStore.getCurrentUser().id, true); + }, + USER_PROFILE_MODAL_OPEN: data => { + useUsersDecorationsStore.getState().fetch(data.userId, true); + }, + }, + + set DecorationGridItem(e: any) { + setDecorationGridItem(e); + }, + + set DecorationGridDecoration(e: any) { + setDecorationGridDecoration(e); + }, + + SKU_ID, + + useUserDecorAvatarDecoration, + + async start() { + useUsersDecorationsStore.getState().fetch(UserStore.getCurrentUser().id, true); + }, + + getDecorAvatarDecorationURL({ avatarDecoration, canAnimate }: { avatarDecoration: AvatarDecoration | null; canAnimate?: boolean; }) { + // Only Decor avatar decorations have this SKU ID + if (avatarDecoration?.skuId === SKU_ID) { + const url = new URL(`${CDN_URL}/${avatarDecoration.asset}.png`); + url.searchParams.set("animate", (!!canAnimate && isAnimatedAvatarDecoration(avatarDecoration.asset)).toString()); + return url.toString(); + } else if (avatarDecoration?.skuId === RAW_SKU_ID) { + return avatarDecoration.asset; + } + }, + + DecorSection: ErrorBoundary.wrap(DecorSection) +}); diff --git a/src/plugins/decor/lib/api.ts b/src/plugins/decor/lib/api.ts new file mode 100644 index 000000000..3719cf245 --- /dev/null +++ b/src/plugins/decor/lib/api.ts @@ -0,0 +1,83 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { API_URL } from "./constants"; +import { useAuthorizationStore } from "./stores/AuthorizationStore"; + +export interface Preset { + id: string; + name: string; + description: string | null; + decorations: Decoration[]; + authorIds: string[]; +} + +export interface Decoration { + hash: string; + animated: boolean; + alt: string | null; + authorId: string | null; + reviewed: boolean | null; + presetId: string | null; +} + +export interface NewDecoration { + file: File; + alt: string | null; +} + +export async function fetchApi(url: RequestInfo, options?: RequestInit) { + const res = await fetch(url, { + ...options, + headers: { + ...options?.headers, + Authorization: `Bearer ${useAuthorizationStore.getState().token}` + } + }); + + if (res.ok) return res; + else throw new Error(await res.text()); +} + +export const getUsersDecorations = async (ids?: string[]): Promise> => { + if (ids?.length === 0) return {}; + + const url = new URL(API_URL + "/users"); + if (ids && ids.length !== 0) url.searchParams.set("ids", JSON.stringify(ids)); + + return await fetch(url).then(c => c.json()); +}; + +export const getUserDecorations = async (id: string = "@me"): Promise => + fetchApi(API_URL + `/users/${id}/decorations`).then(c => c.json()); + +export const getUserDecoration = async (id: string = "@me"): Promise => + fetchApi(API_URL + `/users/${id}/decoration`).then(c => c.json()); + +export const setUserDecoration = async (decoration: Decoration | NewDecoration | null, id: string = "@me"): Promise => { + const formData = new FormData(); + + if (!decoration) { + formData.append("hash", "null"); + } else if ("hash" in decoration) { + formData.append("hash", decoration.hash); + } else if ("file" in decoration) { + formData.append("image", decoration.file); + formData.append("alt", decoration.alt ?? "null"); + } + + return fetchApi(API_URL + `/users/${id}/decoration`, { method: "PUT", body: formData }).then(c => + decoration && "file" in decoration ? c.json() : c.text() + ); +}; + +export const getDecoration = async (hash: string): Promise => fetch(API_URL + `/decorations/${hash}`).then(c => c.json()); + +export const deleteDecoration = async (hash: string): Promise => { + await fetchApi(API_URL + `/decorations/${hash}`, { method: "DELETE" }); +}; + +export const getPresets = async (): Promise => fetch(API_URL + "/decorations/presets").then(c => c.json()); diff --git a/src/plugins/decor/lib/constants.ts b/src/plugins/decor/lib/constants.ts new file mode 100644 index 000000000..ce0b59798 --- /dev/null +++ b/src/plugins/decor/lib/constants.ts @@ -0,0 +1,16 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export const BASE_URL = "https://decor.fieryflames.dev"; +export const API_URL = BASE_URL + "/api"; +export const AUTHORIZE_URL = API_URL + "/authorize"; +export const CDN_URL = "https://ugc.decor.fieryflames.dev"; +export const CLIENT_ID = "1096966363416899624"; +export const SKU_ID = "100101099111114"; // decor in ascii numbers +export const RAW_SKU_ID = "11497119"; // raw in ascii numbers +export const GUILD_ID = "1096357702931841148"; +export const INVITE_KEY = "dXp2SdxDcP"; +export const DECORATION_FETCH_COOLDOWN = 1000 * 60 * 60 * 4; // 4 hours diff --git a/src/plugins/decor/lib/stores/AuthorizationStore.tsx b/src/plugins/decor/lib/stores/AuthorizationStore.tsx new file mode 100644 index 000000000..e31b1f43c --- /dev/null +++ b/src/plugins/decor/lib/stores/AuthorizationStore.tsx @@ -0,0 +1,102 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { DataStore } from "@api/index"; +import { proxyLazy } from "@utils/lazy"; +import { Logger } from "@utils/Logger"; +import { openModal } from "@utils/modal"; +import { OAuth2AuthorizeModal, showToast, Toasts, UserStore, zustandCreate, zustandPersist } from "@webpack/common"; +import type { StateStorage } from "zustand/middleware"; + +import { AUTHORIZE_URL, CLIENT_ID } from "../constants"; + +interface AuthorizationState { + token: string | null; + tokens: Record; + init: () => void; + authorize: () => Promise; + setToken: (token: string) => void; + remove: (id: string) => void; + isAuthorized: () => boolean; +} + +const indexedDBStorage: StateStorage = { + async getItem(name: string): Promise { + return DataStore.get(name).then(v => v ?? null); + }, + async setItem(name: string, value: string): Promise { + await DataStore.set(name, value); + }, + async removeItem(name: string): Promise { + await DataStore.del(name); + }, +}; + +// TODO: Move switching accounts subscription inside the store? +export const useAuthorizationStore = proxyLazy(() => zustandCreate( + zustandPersist( + (set, get) => ({ + token: null, + tokens: {}, + init: () => { set({ token: get().tokens[UserStore.getCurrentUser().id] ?? null }); }, + setToken: (token: string) => set({ token, tokens: { ...get().tokens, [UserStore.getCurrentUser().id]: token } }), + remove: (id: string) => { + const { tokens, init } = get(); + const newTokens = { ...tokens }; + delete newTokens[id]; + set({ tokens: newTokens }); + + init(); + }, + async authorize() { + return new Promise((resolve, reject) => openModal(props => + { + try { + const url = new URL(response.location); + url.searchParams.append("client", "vencord"); + + const req = await fetch(url); + + if (req?.ok) { + const token = await req.text(); + get().setToken(token); + } else { + throw new Error("Request not OK"); + } + resolve(void 0); + } catch (e) { + if (e instanceof Error) { + showToast(`Failed to authorize: ${e.message}`, Toasts.Type.FAILURE); + new Logger("Decor").error("Failed to authorize", e); + reject(e); + } + } + }} + />, { + onCloseCallback() { + reject(new Error("Authorization cancelled")); + }, + } + )); + }, + isAuthorized: () => !!get().token, + }), + { + name: "decor-auth", + getStorage: () => indexedDBStorage, + partialize: state => ({ tokens: state.tokens }), + onRehydrateStorage: () => state => state?.init() + } + ) +)); diff --git a/src/plugins/decor/lib/stores/CurrentUserDecorationsStore.ts b/src/plugins/decor/lib/stores/CurrentUserDecorationsStore.ts new file mode 100644 index 000000000..1485a7438 --- /dev/null +++ b/src/plugins/decor/lib/stores/CurrentUserDecorationsStore.ts @@ -0,0 +1,56 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { proxyLazy } from "@utils/lazy"; +import { UserStore, zustandCreate } from "@webpack/common"; + +import { Decoration, deleteDecoration, getUserDecoration, getUserDecorations, NewDecoration, setUserDecoration } from "../api"; +import { decorationToAsset } from "../utils/decoration"; +import { useUsersDecorationsStore } from "./UsersDecorationsStore"; + +interface UserDecorationsState { + decorations: Decoration[]; + selectedDecoration: Decoration | null; + fetch: () => Promise; + delete: (decoration: Decoration | string) => Promise; + create: (decoration: NewDecoration) => Promise; + select: (decoration: Decoration | null) => Promise; + clear: () => void; +} + +export const useCurrentUserDecorationsStore = proxyLazy(() => zustandCreate((set, get) => ({ + decorations: [], + selectedDecoration: null, + async fetch() { + const decorations = await getUserDecorations(); + const selectedDecoration = await getUserDecoration(); + + set({ decorations, selectedDecoration }); + }, + async create(newDecoration: NewDecoration) { + const decoration = (await setUserDecoration(newDecoration)) as Decoration; + set({ decorations: [...get().decorations, decoration] }); + }, + async delete(decoration: Decoration | string) { + const hash = typeof decoration === "object" ? decoration.hash : decoration; + await deleteDecoration(hash); + + const { selectedDecoration, decorations } = get(); + const newState = { + decorations: decorations.filter(d => d.hash !== hash), + selectedDecoration: selectedDecoration?.hash === hash ? null : selectedDecoration + }; + + set(newState); + }, + async select(decoration: Decoration | null) { + if (get().selectedDecoration === decoration) return; + set({ selectedDecoration: decoration }); + setUserDecoration(decoration); + useUsersDecorationsStore.getState().set(UserStore.getCurrentUser().id, decoration ? decorationToAsset(decoration) : null); + }, + clear: () => set({ decorations: [], selectedDecoration: null }) +}))); diff --git a/src/plugins/decor/lib/stores/UsersDecorationsStore.ts b/src/plugins/decor/lib/stores/UsersDecorationsStore.ts new file mode 100644 index 000000000..7295a3b17 --- /dev/null +++ b/src/plugins/decor/lib/stores/UsersDecorationsStore.ts @@ -0,0 +1,118 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { debounce } from "@utils/debounce"; +import { proxyLazy } from "@utils/lazy"; +import { useEffect, useState, zustandCreate } from "@webpack/common"; +import { User } from "discord-types/general"; + +import { AvatarDecoration } from "../../"; +import { getUsersDecorations } from "../api"; +import { DECORATION_FETCH_COOLDOWN, SKU_ID } from "../constants"; + +interface UserDecorationData { + asset: string | null; + fetchedAt: Date; +} + +interface UsersDecorationsState { + usersDecorations: Map; + fetchQueue: Set; + bulkFetch: () => Promise; + fetch: (userId: string, force?: boolean) => Promise; + fetchMany: (userIds: string[]) => Promise; + get: (userId: string) => UserDecorationData | undefined; + getAsset: (userId: string) => string | null | undefined; + has: (userId: string) => boolean; + set: (userId: string, decoration: string | null) => void; +} + +export const useUsersDecorationsStore = proxyLazy(() => zustandCreate((set, get) => ({ + usersDecorations: new Map(), + fetchQueue: new Set(), + bulkFetch: debounce(async () => { + const { fetchQueue, usersDecorations } = get(); + + if (fetchQueue.size === 0) return; + + set({ fetchQueue: new Set() }); + + const fetchIds = Array.from(fetchQueue); + const fetchedUsersDecorations = await getUsersDecorations(fetchIds); + + const newUsersDecorations = new Map(usersDecorations); + + const now = new Date(); + for (const fetchId of fetchIds) { + const newDecoration = fetchedUsersDecorations[fetchId] ?? null; + newUsersDecorations.set(fetchId, { asset: newDecoration, fetchedAt: now }); + } + + set({ usersDecorations: newUsersDecorations }); + }), + async fetch(userId: string, force: boolean = false) { + const { usersDecorations, fetchQueue, bulkFetch } = get(); + + const { fetchedAt } = usersDecorations.get(userId) ?? {}; + if (fetchedAt) { + if (!force && Date.now() - fetchedAt.getTime() < DECORATION_FETCH_COOLDOWN) return; + } + + set({ fetchQueue: new Set(fetchQueue).add(userId) }); + bulkFetch(); + }, + async fetchMany(userIds) { + if (!userIds.length) return; + const { usersDecorations, fetchQueue, bulkFetch } = get(); + + const newFetchQueue = new Set(fetchQueue); + + const now = Date.now(); + for (const userId of userIds) { + const { fetchedAt } = usersDecorations.get(userId) ?? {}; + if (fetchedAt) { + if (now - fetchedAt.getTime() < DECORATION_FETCH_COOLDOWN) continue; + } + newFetchQueue.add(userId); + } + + set({ fetchQueue: newFetchQueue }); + bulkFetch(); + }, + get(userId: string) { return get().usersDecorations.get(userId); }, + getAsset(userId: string) { return get().usersDecorations.get(userId)?.asset; }, + has(userId: string) { return get().usersDecorations.has(userId); }, + set(userId: string, decoration: string | null) { + const { usersDecorations } = get(); + const newUsersDecorations = new Map(usersDecorations); + + newUsersDecorations.set(userId, { asset: decoration, fetchedAt: new Date() }); + set({ usersDecorations: newUsersDecorations }); + } +}))); + +export function useUserDecorAvatarDecoration(user?: User): AvatarDecoration | null | undefined { + const [decorAvatarDecoration, setDecorAvatarDecoration] = useState(user ? useUsersDecorationsStore.getState().getAsset(user.id) ?? null : null); + + useEffect(() => { + const destructor = useUsersDecorationsStore.subscribe( + state => { + if (!user) return; + const newDecorAvatarDecoration = state.getAsset(user.id); + if (!newDecorAvatarDecoration) return; + if (decorAvatarDecoration !== newDecorAvatarDecoration) setDecorAvatarDecoration(newDecorAvatarDecoration); + } + ); + + if (user) { + const { fetch: fetchUserDecorAvatarDecoration } = useUsersDecorationsStore.getState(); + fetchUserDecorAvatarDecoration(user.id); + } + return destructor; + }, []); + + return decorAvatarDecoration ? { asset: decorAvatarDecoration, skuId: SKU_ID } : null; +} diff --git a/src/plugins/decor/lib/utils/decoration.ts b/src/plugins/decor/lib/utils/decoration.ts new file mode 100644 index 000000000..176507ef8 --- /dev/null +++ b/src/plugins/decor/lib/utils/decoration.ts @@ -0,0 +1,17 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { AvatarDecoration } from "../../"; +import { Decoration } from "../api"; +import { SKU_ID } from "../constants"; + +export function decorationToAsset(decoration: Decoration) { + return `${decoration.animated ? "a_" : ""}${decoration.hash}`; +} + +export function decorationToAvatarDecoration(decoration: Decoration): AvatarDecoration { + return { asset: decorationToAsset(decoration), skuId: SKU_ID }; +} diff --git a/src/plugins/decor/ui/components/DecorDecorationGridDecoration.tsx b/src/plugins/decor/ui/components/DecorDecorationGridDecoration.tsx new file mode 100644 index 000000000..deaeef630 --- /dev/null +++ b/src/plugins/decor/ui/components/DecorDecorationGridDecoration.tsx @@ -0,0 +1,35 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { ContextMenuApi } from "@webpack/common"; +import type { HTMLProps } from "react"; + +import { Decoration } from "../../lib/api"; +import { decorationToAvatarDecoration } from "../../lib/utils/decoration"; +import { DecorationGridDecoration } from "."; +import DecorationContextMenu from "./DecorationContextMenu"; + +interface DecorDecorationGridDecorationProps extends HTMLProps { + decoration: Decoration; + isSelected: boolean; + onSelect: () => void; +} + +export default function DecorDecorationGridDecoration(props: DecorDecorationGridDecorationProps) { + const { decoration } = props; + + return { + ContextMenuApi.openContextMenu(e, () => ( + + )); + }} + avatarDecoration={decorationToAvatarDecoration(decoration)} + />; +} diff --git a/src/plugins/decor/ui/components/DecorSection.tsx b/src/plugins/decor/ui/components/DecorSection.tsx new file mode 100644 index 000000000..f11a87a53 --- /dev/null +++ b/src/plugins/decor/ui/components/DecorSection.tsx @@ -0,0 +1,59 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Flex } from "@components/Flex"; +import { findByCodeLazy } from "@webpack"; +import { Button, useEffect } from "@webpack/common"; + +import { useAuthorizationStore } from "../../lib/stores/AuthorizationStore"; +import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore"; +import { cl } from "../"; +import { openChangeDecorationModal } from "../modals/ChangeDecorationModal"; + +const CustomizationSection = findByCodeLazy(".customizationSectionBackground"); + +interface DecorSectionProps { + hideTitle?: boolean; + hideDivider?: boolean; + noMargin?: boolean; +} + +export default function DecorSection({ hideTitle = false, hideDivider = false, noMargin = false }: DecorSectionProps) { + const authorization = useAuthorizationStore(); + const { selectedDecoration, select: selectDecoration, fetch: fetchDecorations } = useCurrentUserDecorationsStore(); + + useEffect(() => { + if (authorization.isAuthorized()) fetchDecorations(); + }, [authorization.token]); + + return + + { + if (!authorization.isAuthorized()) { + authorization.authorize().then(openChangeDecorationModal).catch(() => { }); + } else openChangeDecorationModal(); + }} + size={Button.Sizes.SMALL} + > + Change Decoration + + {selectedDecoration && authorization.isAuthorized() && selectDecoration(null)} + color={Button.Colors.PRIMARY} + size={Button.Sizes.SMALL} + look={Button.Looks.LINK} + > + Remove Decoration + } + + ; +} diff --git a/src/plugins/decor/ui/components/DecorationContextMenu.tsx b/src/plugins/decor/ui/components/DecorationContextMenu.tsx new file mode 100644 index 000000000..7451bb229 --- /dev/null +++ b/src/plugins/decor/ui/components/DecorationContextMenu.tsx @@ -0,0 +1,47 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { CopyIcon, DeleteIcon } from "@components/Icons"; +import { Alerts, Clipboard, ContextMenuApi, Menu, UserStore } from "webpack/common"; + +import { Decoration } from "../../lib/api"; +import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore"; +import { cl } from "../"; + +export default function DecorationContextMenu({ decoration }: { decoration: Decoration; }) { + const { delete: deleteDecoration } = useCurrentUserDecorationsStore(); + + return + Clipboard.copy(decoration.hash)} + /> + {decoration.authorId === UserStore.getCurrentUser().id && + Alerts.show({ + title: "Delete Decoration", + body: `Are you sure you want to delete ${decoration.alt}?`, + confirmText: "Delete", + confirmColor: cl("danger-btn"), + cancelText: "Cancel", + onConfirm() { + deleteDecoration(decoration); + } + })} + /> + } + ; +} diff --git a/src/plugins/decor/ui/components/DecorationGridCreate.tsx b/src/plugins/decor/ui/components/DecorationGridCreate.tsx new file mode 100644 index 000000000..7699b23d9 --- /dev/null +++ b/src/plugins/decor/ui/components/DecorationGridCreate.tsx @@ -0,0 +1,30 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { PlusIcon } from "@components/Icons"; +import { i18n, Text } from "@webpack/common"; +import { HTMLProps } from "react"; + +import { DecorationGridItem } from "."; + +type DecorationGridCreateProps = HTMLProps & { + onSelect: () => void; +}; + +export default function DecorationGridCreate(props: DecorationGridCreateProps) { + return + + + {i18n.Messages.CREATE} + + ; +} diff --git a/src/plugins/decor/ui/components/DecorationGridNone.tsx b/src/plugins/decor/ui/components/DecorationGridNone.tsx new file mode 100644 index 000000000..b6114c674 --- /dev/null +++ b/src/plugins/decor/ui/components/DecorationGridNone.tsx @@ -0,0 +1,30 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { NoEntrySignIcon } from "@components/Icons"; +import { i18n, Text } from "@webpack/common"; +import { HTMLProps } from "react"; + +import { DecorationGridItem } from "."; + +type DecorationGridNoneProps = HTMLProps & { + isSelected: boolean; + onSelect: () => void; +}; + +export default function DecorationGridNone(props: DecorationGridNoneProps) { + return + + + {i18n.Messages.NONE} + + ; +} diff --git a/src/plugins/decor/ui/components/Grid.tsx b/src/plugins/decor/ui/components/Grid.tsx new file mode 100644 index 000000000..401802481 --- /dev/null +++ b/src/plugins/decor/ui/components/Grid.tsx @@ -0,0 +1,28 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { React } from "@webpack/common"; + +import { cl } from "../"; + +export interface GridProps { + renderItem: (item: ItemT) => JSX.Element; + getItemKey: (item: ItemT) => string; + itemKeyPrefix?: string; + items: Array; +} + +export default function Grid({ renderItem, getItemKey, itemKeyPrefix: ikp, items }: GridProps) { + return + {items.map(item => + + {renderItem(item)} + + )} + ; +} diff --git a/src/plugins/decor/ui/components/SectionedGridList.tsx b/src/plugins/decor/ui/components/SectionedGridList.tsx new file mode 100644 index 000000000..9a6ec1b8d --- /dev/null +++ b/src/plugins/decor/ui/components/SectionedGridList.tsx @@ -0,0 +1,38 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classes } from "@utils/misc"; +import { findByPropsLazy } from "@webpack"; +import { React } from "@webpack/common"; + +import { cl } from "../"; +import Grid, { GridProps } from "./Grid"; + +const ScrollerClasses = findByPropsLazy("managedReactiveScroller"); + +type Section = SectionT & { + items: Array; +}; + +interface SectionedGridListProps> extends Omit, "items"> { + renderSectionHeader: (section: SectionU) => JSX.Element; + getSectionKey: (section: SectionU) => string; + sections: SectionU[]; +} + +export default function SectionedGridList(props: SectionedGridListProps) { + return + {props.sections.map(section => + {props.renderSectionHeader(section)} + + )} + ; +} diff --git a/src/plugins/decor/ui/components/index.ts b/src/plugins/decor/ui/components/index.ts new file mode 100644 index 000000000..8f39a10ee --- /dev/null +++ b/src/plugins/decor/ui/components/index.ts @@ -0,0 +1,33 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findComponentByCode, LazyComponentWebpack } from "@webpack"; +import { React } from "@webpack/common"; +import type { ComponentType, HTMLProps, PropsWithChildren } from "react"; + +import { AvatarDecoration } from "../.."; + +type DecorationGridItemComponent = ComponentType> & { + onSelect: () => void, + isSelected: boolean, +}>; + +export let DecorationGridItem: DecorationGridItemComponent; +export const setDecorationGridItem = v => DecorationGridItem = v; + +export const AvatarDecorationModalPreview = LazyComponentWebpack(() => { + const component = findComponentByCode("AvatarDecorationModalPreview"); + return React.memo(component); +}); + +type DecorationGridDecorationComponent = React.ComponentType & { + avatarDecoration: AvatarDecoration; + onSelect: () => void, + isSelected: boolean, +}>; + +export let DecorationGridDecoration: DecorationGridDecorationComponent; +export const setDecorationGridDecoration = v => DecorationGridDecoration = v; diff --git a/src/plugins/decor/ui/index.ts b/src/plugins/decor/ui/index.ts new file mode 100644 index 000000000..52b169d77 --- /dev/null +++ b/src/plugins/decor/ui/index.ts @@ -0,0 +1,13 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@api/Styles"; +import { extractAndLoadChunksLazy } from "@webpack"; + +export const cl = classNameFactory("vc-decor-"); + +export const requireAvatarDecorationModal = extractAndLoadChunksLazy(["openAvatarDecorationModal:"]); +export const requireCreateStickerModal = extractAndLoadChunksLazy(["stickerInspected]:"]); diff --git a/src/plugins/decor/ui/modals/ChangeDecorationModal.tsx b/src/plugins/decor/ui/modals/ChangeDecorationModal.tsx new file mode 100644 index 000000000..bed007174 --- /dev/null +++ b/src/plugins/decor/ui/modals/ChangeDecorationModal.tsx @@ -0,0 +1,270 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Flex } from "@components/Flex"; +import { openInviteModal } from "@utils/discord"; +import { Margins } from "@utils/margins"; +import { classes } from "@utils/misc"; +import { closeAllModals, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { findByPropsLazy, findComponentByCodeLazy } from "@webpack"; +import { Alerts, Button, FluxDispatcher, Forms, GuildStore, NavigationRouter, Parser, Text, Tooltip, useEffect, UserStore, UserUtils, useState } from "@webpack/common"; +import { User } from "discord-types/general"; + +import { Decoration, getPresets, Preset } from "../../lib/api"; +import { GUILD_ID, INVITE_KEY } from "../../lib/constants"; +import { useAuthorizationStore } from "../../lib/stores/AuthorizationStore"; +import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore"; +import { decorationToAvatarDecoration } from "../../lib/utils/decoration"; +import { cl, requireAvatarDecorationModal } from "../"; +import { AvatarDecorationModalPreview } from "../components"; +import DecorationGridCreate from "../components/DecorationGridCreate"; +import DecorationGridNone from "../components/DecorationGridNone"; +import DecorDecorationGridDecoration from "../components/DecorDecorationGridDecoration"; +import SectionedGridList from "../components/SectionedGridList"; +import { openCreateDecorationModal } from "./CreateDecorationModal"; + +const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers"); +const DecorationModalStyles = findByPropsLazy("modalFooterShopButton"); + +function usePresets() { + const [presets, setPresets] = useState([]); + useEffect(() => { getPresets().then(setPresets); }, []); + return presets; +} + +interface Section { + title: string; + subtitle?: string; + sectionKey: string; + items: ("none" | "create" | Decoration)[]; + authorIds?: string[]; +} + +function SectionHeader({ section }: { section: Section; }) { + const hasSubtitle = typeof section.subtitle !== "undefined"; + const hasAuthorIds = typeof section.authorIds !== "undefined"; + + const [authors, setAuthors] = useState([]); + + useEffect(() => { + (async () => { + if (!section.authorIds) return; + + for (const authorId of section.authorIds) { + const author = UserStore.getUser(authorId) ?? await UserUtils.getUser(authorId); + setAuthors(authors => [...authors, author]); + } + })(); + }, [section.authorIds]); + + return + + {section.title} + {hasAuthorIds && + } + + {hasSubtitle && + + {section.subtitle} + + } + ; +} + +export default function ChangeDecorationModal(props: any) { + // undefined = not trying, null = none, Decoration = selected + const [tryingDecoration, setTryingDecoration] = useState(undefined); + const isTryingDecoration = typeof tryingDecoration !== "undefined"; + + const avatarDecorationOverride = tryingDecoration != null ? decorationToAvatarDecoration(tryingDecoration) : tryingDecoration; + + const { + decorations, + selectedDecoration, + fetch: fetchUserDecorations, + select: selectDecoration + } = useCurrentUserDecorationsStore(); + + useEffect(() => { + fetchUserDecorations(); + }, []); + + const activeSelectedDecoration = isTryingDecoration ? tryingDecoration : selectedDecoration; + const activeDecorationHasAuthor = typeof activeSelectedDecoration?.authorId !== "undefined"; + const hasDecorationPendingReview = decorations.some(d => d.reviewed === false); + + const presets = usePresets(); + const presetDecorations = presets.flatMap(preset => preset.decorations); + + const activeDecorationPreset = presets.find(preset => preset.id === activeSelectedDecoration?.presetId); + const isActiveDecorationPreset = typeof activeDecorationPreset !== "undefined"; + + const ownDecorations = decorations.filter(d => !presetDecorations.some(p => p.hash === d.hash)); + + const data = [ + { + title: "Your Decorations", + sectionKey: "ownDecorations", + items: ["none", ...ownDecorations, "create"] + }, + ...presets.map(preset => ({ + title: preset.name, + subtitle: preset.description || undefined, + sectionKey: `preset-${preset.id}`, + items: preset.decorations, + authorIds: preset.authorIds + })) + ] as Section[]; + + return + + + Change Decoration + + + + + { + if (typeof item === "string") { + switch (item) { + case "none": + return setTryingDecoration(null)} + />; + case "create": + return + {tooltipProps => { }} + />} + ; + } + } else { + return + {tooltipProps => ( + setTryingDecoration(item) : () => { }} + isSelected={activeSelectedDecoration?.hash === item.hash} + decoration={item} + /> + )} + ; + } + }} + getItemKey={item => typeof item === "string" ? item : item.hash} + getSectionKey={section => section.sectionKey} + renderSectionHeader={section => } + sections={data} + /> + + + {isActiveDecorationPreset && Part of the {activeDecorationPreset.name} Preset} + {typeof activeSelectedDecoration === "object" && + + {activeSelectedDecoration?.alt} + + } + {activeDecorationHasAuthor && Created by {Parser.parse(`<@${activeSelectedDecoration.authorId}>`)}} + + + + + { + selectDecoration(tryingDecoration!).then(props.onClose); + }} + disabled={!isTryingDecoration} + > + Apply + + + Cancel + + + + Alerts.show({ + title: "Log Out", + body: "Are you sure you want to log out of Decor?", + confirmText: "Log Out", + confirmColor: cl("danger-btn"), + cancelText: "Cancel", + onConfirm() { + useAuthorizationStore.getState().remove(UserStore.getCurrentUser().id); + props.onClose(); + } + })} + color={Button.Colors.PRIMARY} + look={Button.Looks.LINK} + > + Log Out + + + {tooltipProps => { + if (!GuildStore.getGuild(GUILD_ID)) { + const inviteAccepted = await openInviteModal(INVITE_KEY); + if (inviteAccepted) { + closeAllModals(); + FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" }); + } + } else { + props.onClose(); + FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" }); + NavigationRouter.transitionToGuild(GUILD_ID); + } + }} + color={Button.Colors.PRIMARY} + look={Button.Looks.LINK} + > + Discord Server + } + + + + ; +} + +export const openChangeDecorationModal = () => + requireAvatarDecorationModal().then(() => openModal(props => )); diff --git a/src/plugins/decor/ui/modals/CreateDecorationModal.tsx b/src/plugins/decor/ui/modals/CreateDecorationModal.tsx new file mode 100644 index 000000000..a5937b0dd --- /dev/null +++ b/src/plugins/decor/ui/modals/CreateDecorationModal.tsx @@ -0,0 +1,163 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Link } from "@components/Link"; +import { openInviteModal } from "@utils/discord"; +import { Margins } from "@utils/margins"; +import { closeAllModals, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { findByPropsLazy, findComponentByCodeLazy } from "@webpack"; +import { Button, FluxDispatcher, Forms, GuildStore, NavigationRouter, Text, TextInput, useEffect, useMemo, UserStore, useState } from "@webpack/common"; + +import { GUILD_ID, INVITE_KEY, RAW_SKU_ID } from "../../lib/constants"; +import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore"; +import { cl, requireAvatarDecorationModal, requireCreateStickerModal } from "../"; +import { AvatarDecorationModalPreview } from "../components"; + + +const DecorationModalStyles = findByPropsLazy("modalFooterShopButton"); + +const FileUpload = findComponentByCodeLazy("fileUploadInput,"); + +function useObjectURL(object: Blob | MediaSource | null) { + const [url, setUrl] = useState(null); + + useEffect(() => { + if (!object) return; + + const objectUrl = URL.createObjectURL(object); + setUrl(objectUrl); + + return () => { + URL.revokeObjectURL(objectUrl); + setUrl(null); + }; + }, [object]); + + return url; +} + +export default function CreateDecorationModal(props) { + const [name, setName] = useState(""); + const [file, setFile] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (error) setError(null); + }, [file]); + + const { create: createDecoration } = useCurrentUserDecorationsStore(); + + const fileUrl = useObjectURL(file); + + const decoration = useMemo(() => fileUrl ? { asset: fileUrl, skuId: RAW_SKU_ID } : null, [fileUrl]); + + return + + + Create Decoration + + + + + + + {error !== null && {error.message}} + + + + File should be APNG or PNG. + + + + + + This name will be used when referring to this decoration. + + + + + + + + + Make sure your decoration does not violate + the guidelines + before creating your decoration. + You can receive updates on your decoration's review by joining { + e.preventDefault(); + if (!GuildStore.getGuild(GUILD_ID)) { + const inviteAccepted = await openInviteModal(INVITE_KEY); + if (inviteAccepted) { + closeAllModals(); + FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" }); + } + } else { + closeAllModals(); + FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" }); + NavigationRouter.transitionToGuild(GUILD_ID); + } + }} + > + Decor's Discord server + . + + + + { + setSubmitting(true); + createDecoration({ alt: name, file: file! }) + .then(props.onClose).catch(e => { setSubmitting(false); setError(e); }); + }} + disabled={!file || !name} + submitting={submitting} + > + Create + + + Cancel + + + ; +} + +export const openCreateDecorationModal = () => + Promise.all([requireAvatarDecorationModal(), requireCreateStickerModal()]) + .then(() => openModal(props => )); diff --git a/src/plugins/decor/ui/styles.css b/src/plugins/decor/ui/styles.css new file mode 100644 index 000000000..ff10c82fa --- /dev/null +++ b/src/plugins/decor/ui/styles.css @@ -0,0 +1,80 @@ +.vc-decor-danger-btn { + color: var(--white-500); + background-color: var(--button-danger-background); +} + +.vc-decor-change-decoration-modal-content { + position: relative; + display: flex; + border-radius: 5px 5px 0 0; + padding: 0 16px; + gap: 4px +} + +.vc-decor-change-decoration-modal-preview { + display: flex; + flex-direction: column; + margin-top: 24px; + gap: 8px; + max-width: 280px; +} + +.vc-decor-change-decoration-modal-decoration { + width: 80px; + height: 80px; +} + +.vc-decor-change-decoration-modal-footer { + justify-content: space-between; +} + +.vc-decor-change-decoration-modal-footer-btn-container { + display: flex; + flex-direction: row-reverse; +} + +.vc-decor-create-decoration-modal-content { + display: flex; + flex-direction: column; + gap: 20px; + padding: 0 16px; +} + +.vc-decor-create-decoration-modal-form-preview-container { + display: flex; + gap: 16px; +} + +.vc-decor-modal-header { + padding: 16px; +} + +.vc-decor-modal-footer { + padding: 16px; +} + +.vc-decor-create-decoration-modal-form { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 16px; +} + +.vc-decor-sectioned-grid-list-container { + display: flex; + flex-direction: column; + overflow: hidden scroll; + max-height: 512px; + width: 352px; /* ((80 + 8 (grid gap)) * desired columns) (scrolled takes the extra 8 padding off conveniently) */ + gap: 12px; +} + +.vc-decor-sectioned-grid-list-grid { + display: flex; + flex-wrap: wrap; + gap: 8px +} + +.vc-decor-section-remove-margin { + margin-bottom: 0; +} diff --git a/src/plugins/greetStickerPicker/index.tsx b/src/plugins/greetStickerPicker/index.tsx index 9623d422e..c2104af4e 100644 --- a/src/plugins/greetStickerPicker/index.tsx +++ b/src/plugins/greetStickerPicker/index.tsx @@ -18,6 +18,7 @@ import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; +import { MessageActions } from "@utils/discord"; import definePlugin, { OptionType } from "@utils/types"; import { findByPropsLazy } from "@webpack"; import { ContextMenuApi, FluxDispatcher, Menu } from "@webpack/common"; @@ -49,7 +50,6 @@ const settings = definePluginSettings({ unholyMultiGreetEnabled?: boolean; }>(); -const MessageActions = findByPropsLazy("sendGreetMessage"); const { WELCOME_STICKERS } = findByPropsLazy("WELCOME_STICKERS"); function greet(channel: Channel, message: Message, stickers: string[]) { diff --git a/src/plugins/permissionsViewer/components/UserPermissions.tsx b/src/plugins/permissionsViewer/components/UserPermissions.tsx index b75bafdcb..3c6767713 100644 --- a/src/plugins/permissionsViewer/components/UserPermissions.tsx +++ b/src/plugins/permissionsViewer/components/UserPermissions.tsx @@ -35,15 +35,13 @@ interface UserPermission { type UserPermissions = Array; -const Classes = proxyLazyWebpack(() => { - const modules = findBulk( +const Classes = proxyLazyWebpack(() => + Object.assign({}, ...findBulk( filters.byProps("roles", "rolePill", "rolePillBorder"), filters.byProps("roleCircle", "dotBorderBase", "dotBorderColor"), filters.byProps("roleNameOverflow", "root", "roleName", "roleRemoveButton") - ); - - return Object.assign({}, ...modules); -}) as Record<"roles" | "rolePill" | "rolePillBorder" | "desaturateUserColors" | "flex" | "alignCenter" | "justifyCenter" | "svg" | "background" | "dot" | "dotBorderColor" | "roleCircle" | "dotBorderBase" | "flex" | "alignCenter" | "justifyCenter" | "wrap" | "root" | "role" | "roleRemoveButton" | "roleDot" | "roleFlowerStar" | "roleRemoveIcon" | "roleRemoveIconFocused" | "roleVerifiedIcon" | "roleName" | "roleNameOverflow" | "actionButton" | "overflowButton" | "addButton" | "addButtonIcon" | "overflowRolesPopout" | "overflowRolesPopoutArrowWrapper" | "overflowRolesPopoutArrow" | "popoutBottom" | "popoutTop" | "overflowRolesPopoutHeader" | "overflowRolesPopoutHeaderIcon" | "overflowRolesPopoutHeaderText" | "roleIcon", string>; + )) +) as Record<"roles" | "rolePill" | "rolePillBorder" | "desaturateUserColors" | "flex" | "alignCenter" | "justifyCenter" | "svg" | "background" | "dot" | "dotBorderColor" | "roleCircle" | "dotBorderBase" | "flex" | "alignCenter" | "justifyCenter" | "wrap" | "root" | "role" | "roleRemoveButton" | "roleDot" | "roleFlowerStar" | "roleRemoveIcon" | "roleRemoveIconFocused" | "roleVerifiedIcon" | "roleName" | "roleNameOverflow" | "actionButton" | "overflowButton" | "addButton" | "addButtonIcon" | "overflowRolesPopout" | "overflowRolesPopoutArrowWrapper" | "overflowRolesPopoutArrow" | "popoutBottom" | "popoutTop" | "overflowRolesPopoutHeader" | "overflowRolesPopoutHeaderIcon" | "overflowRolesPopoutHeaderText" | "roleIcon", string>; function UserPermissionsComponent({ guild, guildMember, showBorder }: { guild: Guild; guildMember: GuildMember; showBorder: boolean; }) { const stns = settings.use(["permissionsSortOrder"]); diff --git a/src/plugins/spotifyShareCommands/index.ts b/src/plugins/spotifyShareCommands/index.ts index 7634e9d57..3569dd288 100644 --- a/src/plugins/spotifyShareCommands/index.ts +++ b/src/plugins/spotifyShareCommands/index.ts @@ -18,6 +18,7 @@ import { ApplicationCommandInputType, sendBotMessage } from "@api/Commands"; import { Devs } from "@utils/constants"; +import { MessageActions } from "@utils/discord"; import definePlugin from "@utils/types"; import { findByPropsLazy } from "@webpack"; import { FluxDispatcher } from "@webpack/common"; @@ -53,7 +54,6 @@ interface Track { } const Spotify = findByPropsLazy("getPlayerState"); -const MessageCreator = findByPropsLazy("getSendMessageOptionsForReply", "sendMessage"); const PendingReplyStore = findByPropsLazy("getPendingReply"); function sendMessage(channelId, message) { @@ -65,7 +65,7 @@ function sendMessage(channelId, message) { ...message }; const reply = PendingReplyStore.getPendingReply(channelId); - MessageCreator.sendMessage(channelId, message, void 0, MessageCreator.getSendMessageOptionsForReply(reply)) + MessageActions.sendMessage(channelId, message, void 0, MessageActions.getSendMessageOptionsForReply(reply)) .then(() => { if (reply) { FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId }); diff --git a/src/plugins/voiceMessages/index.tsx b/src/plugins/voiceMessages/index.tsx index 7c8a06943..17e10a4b8 100644 --- a/src/plugins/voiceMessages/index.tsx +++ b/src/plugins/voiceMessages/index.tsx @@ -21,6 +21,7 @@ import "./styles.css"; import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; import { Microphone } from "@components/Icons"; import { Devs } from "@utils/constants"; +import { MessageActions } from "@utils/discord"; import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal"; import { useAwaiter } from "@utils/react"; import definePlugin from "@utils/types"; @@ -36,7 +37,6 @@ import { VoicePreview } from "./VoicePreview"; import { VoiceRecorderWeb } from "./WebRecorder"; const CloudUtils = findByPropsLazy("CloudUpload"); -const MessageCreator = findByPropsLazy("getSendMessageOptionsForReply", "sendMessage"); const PendingReplyStore = findStoreLazy("PendingReplyStore"); const OptionClasses = findByPropsLazy("optionName", "optionIcon", "optionLabel"); @@ -100,7 +100,7 @@ function sendAudio(blob: Blob, meta: AudioMetadata) { waveform: meta.waveform, duration_secs: meta.duration, }], - message_reference: reply ? MessageCreator.getSendMessageOptionsForReply(reply)?.messageReference : null, + message_reference: reply ? MessageActions.getSendMessageOptionsForReply(reply)?.messageReference : null, } }); }); diff --git a/src/utils/cloud.tsx b/src/utils/cloud.tsx index 029306227..f56c78dc5 100644 --- a/src/utils/cloud.tsx +++ b/src/utils/cloud.tsx @@ -19,8 +19,7 @@ import * as DataStore from "@api/DataStore"; import { showNotification } from "@api/Notifications"; import { Settings } from "@api/Settings"; -import { findByProps } from "@webpack"; -import { UserStore } from "@webpack/common"; +import { OAuth2AuthorizeModal, UserStore } from "@webpack/common"; import { Logger } from "./Logger"; import { openModal } from "./modal"; @@ -91,8 +90,6 @@ export async function authorizeCloud() { return; } - const { OAuth2AuthorizeModal } = findByProps("OAuth2AuthorizeModal"); - openModal((props: any) => (r => { + let onClose: () => void, onAccept: () => void; + let inviteAccepted = false; + + FluxDispatcher.subscribe("INVITE_ACCEPT", onAccept = () => { + inviteAccepted = true; + }); + + FluxDispatcher.subscribe("INVITE_MODAL_CLOSE", onClose = () => { + FluxDispatcher.unsubscribe("INVITE_MODAL_CLOSE", onClose); + FluxDispatcher.unsubscribe("INVITE_ACCEPT", onAccept); + r(inviteAccepted); + }); + }); +} export function getCurrentChannel() { return ChannelStore.getChannel(SelectedChannelStore.getChannelId()); diff --git a/src/webpack/common/components.ts b/src/webpack/common/components.ts index e44b1c9f6..d7bb5d759 100644 --- a/src/webpack/common/components.ts +++ b/src/webpack/common/components.ts @@ -17,7 +17,7 @@ */ // eslint-disable-next-line path-alias/no-relative -import { filters, waitFor } from "@webpack"; +import { filters, findByPropsLazy, waitFor } from "@webpack"; import { waitForComponent } from "./internal"; import * as t from "./types/components"; @@ -55,6 +55,8 @@ export const MaskedLink = waitForComponent("MaskedLink", m => m?.t export const Timestamp = waitForComponent("Timestamp", filters.byCode(".Messages.MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL.format")); export const Flex = waitForComponent("Flex", ["Justify", "Align", "Wrap"]); +export const { OAuth2AuthorizeModal } = findByPropsLazy("OAuth2AuthorizeModal"); + waitFor(["FormItem", "Button"], m => { ({ useToken, Card, Button, FormSwitch: Switch, Tooltip, TextInput, TextArea, Text, Select, SearchableSelect, Slider, ButtonLooks, TabBar, Popout, Dialog, Paginator, ScrollerThin, Clickable, Avatar } = m); Forms = m; diff --git a/src/webpack/common/types/components.d.ts b/src/webpack/common/types/components.d.ts index a3e76b273..c8e2b0457 100644 --- a/src/webpack/common/types/components.d.ts +++ b/src/webpack/common/types/components.d.ts @@ -126,6 +126,7 @@ export type Button = ComponentType; focusProps?: any; + submitting?: boolean; submittingStartedLabel?: string; submittingFinishedLabel?: string; diff --git a/src/webpack/common/utils.ts b/src/webpack/common/utils.ts index cef4d51d6..f5d2a9666 100644 --- a/src/webpack/common/utils.ts +++ b/src/webpack/common/utils.ts @@ -19,7 +19,7 @@ import type { Channel, User } from "discord-types/general"; // eslint-disable-next-line path-alias/no-relative -import { _resolveReady, findByPropsLazy, findLazy, waitFor } from "../webpack"; +import { _resolveReady, filters, findByCodeLazy, findByPropsLazy, findLazy, waitFor } from "../webpack"; import type * as t from "./types/utils"; export let FluxDispatcher: t.FluxDispatcher; @@ -127,5 +127,9 @@ export const NavigationRouter: t.NavigationRouter = findByPropsLazy("transitionT export let SettingsRouter: any; waitFor(["open", "saveAccountChanges"], m => SettingsRouter = m); -const { Permissions } = findLazy(m => typeof m.Permissions?.ADMINISTRATOR === "bigint") as { Permissions: t.PermissionsBits; }; -export { Permissions as PermissionsBits }; +export const { Permissions: PermissionsBits } = findLazy(m => typeof m.Permissions?.ADMINISTRATOR === "bigint") as { Permissions: t.PermissionsBits; }; + +export const zustandCreate: typeof import("zustand").default = findByCodeLazy("will be removed in v4"); + +const persistFilter = filters.byCode("[zustand persist middleware]"); +export const { persist: zustandPersist }: typeof import("zustand/middleware") = findLazy(m => m.persist && persistFilter(m.persist)); diff --git a/tsconfig.json b/tsconfig.json index db5407455..4563f3f86 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "allowSyntheticDefaultImports": true, "esModuleInterop": true, + "skipLibCheck": true, "lib": [ "DOM", "DOM.Iterable",