From b47a5f569e3684857048207f0da0d7f6de80c78a Mon Sep 17 00:00:00 2001
From: Jack <30497388+FieryFlames@users.noreply.github.com>
Date: Thu, 30 Nov 2023 00:10:50 -0500
Subject: [PATCH] feat: Add Decor plugin (#910)
---
package.json | 3 +-
pnpm-lock.yaml | 21 +-
src/components/Icons.tsx | 35 +++
src/plugins/decor/README.md | 17 ++
src/plugins/decor/index.tsx | 168 +++++++++++
src/plugins/decor/lib/api.ts | 83 ++++++
src/plugins/decor/lib/constants.ts | 16 ++
.../decor/lib/stores/AuthorizationStore.tsx | 102 +++++++
.../lib/stores/CurrentUserDecorationsStore.ts | 56 ++++
.../decor/lib/stores/UsersDecorationsStore.ts | 118 ++++++++
src/plugins/decor/lib/utils/decoration.ts | 17 ++
.../DecorDecorationGridDecoration.tsx | 35 +++
.../decor/ui/components/DecorSection.tsx | 59 ++++
.../ui/components/DecorationContextMenu.tsx | 47 +++
.../ui/components/DecorationGridCreate.tsx | 30 ++
.../ui/components/DecorationGridNone.tsx | 30 ++
src/plugins/decor/ui/components/Grid.tsx | 28 ++
.../decor/ui/components/SectionedGridList.tsx | 38 +++
src/plugins/decor/ui/components/index.ts | 33 +++
src/plugins/decor/ui/index.ts | 13 +
.../decor/ui/modals/ChangeDecorationModal.tsx | 270 ++++++++++++++++++
.../decor/ui/modals/CreateDecorationModal.tsx | 163 +++++++++++
src/plugins/decor/ui/styles.css | 80 ++++++
src/utils/cloud.tsx | 5 +-
src/utils/discord.tsx | 24 +-
src/webpack/common/components.ts | 4 +-
src/webpack/common/types/components.d.ts | 1 +
src/webpack/common/utils.ts | 10 +-
tsconfig.json | 1 +
29 files changed, 1493 insertions(+), 14 deletions(-)
create mode 100644 src/plugins/decor/README.md
create mode 100644 src/plugins/decor/index.tsx
create mode 100644 src/plugins/decor/lib/api.ts
create mode 100644 src/plugins/decor/lib/constants.ts
create mode 100644 src/plugins/decor/lib/stores/AuthorizationStore.tsx
create mode 100644 src/plugins/decor/lib/stores/CurrentUserDecorationsStore.ts
create mode 100644 src/plugins/decor/lib/stores/UsersDecorationsStore.ts
create mode 100644 src/plugins/decor/lib/utils/decoration.ts
create mode 100644 src/plugins/decor/ui/components/DecorDecorationGridDecoration.tsx
create mode 100644 src/plugins/decor/ui/components/DecorSection.tsx
create mode 100644 src/plugins/decor/ui/components/DecorationContextMenu.tsx
create mode 100644 src/plugins/decor/ui/components/DecorationGridCreate.tsx
create mode 100644 src/plugins/decor/ui/components/DecorationGridNone.tsx
create mode 100644 src/plugins/decor/ui/components/Grid.tsx
create mode 100644 src/plugins/decor/ui/components/SectionedGridList.tsx
create mode 100644 src/plugins/decor/ui/components/index.ts
create mode 100644 src/plugins/decor/ui/index.ts
create mode 100644 src/plugins/decor/ui/modals/ChangeDecorationModal.tsx
create mode 100644 src/plugins/decor/ui/modals/CreateDecorationModal.tsx
create mode 100644 src/plugins/decor/ui/styles.css
diff --git a/package.json b/package.json
index d035dcb6..af472093 100644
--- a/package.json
+++ b/package.json
@@ -68,7 +68,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 be7befab..43866f50 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1,9 +1,5 @@
lockfileVersion: '6.0'
-settings:
- autoInstallPeers: true
- excludeLinksFromLockfile: false
-
patchedDependencies:
eslint-plugin-path-alias@1.0.0:
hash: m6sma4g6bh67km3q6igf6uxaja
@@ -123,6 +119,9 @@ devDependencies:
zip-local:
specifier: ^0.3.5
version: 0.3.5
+ zustand:
+ specifier: ^3.7.2
+ version: 3.7.2
packages:
@@ -3450,8 +3449,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/src/components/Icons.tsx b/src/components/Icons.tsx
index 93b1323e..2eb83d4e 100644
--- a/src/components/Icons.tsx
+++ b/src/components/Icons.tsx
@@ -255,3 +255,38 @@ export function DeleteIcon(props: IconProps) {
);
}
+
+export function PlusIcon(props: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function NoEntrySignIcon(props: IconProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/plugins/decor/README.md b/src/plugins/decor/README.md
new file mode 100644
index 00000000..467a6145
--- /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 00000000..4dd7aa0c
--- /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 00000000..3719cf24
--- /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 00000000..ce0b5979
--- /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 00000000..e31b1f43
--- /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 00000000..1485a743
--- /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 00000000..7295a3b1
--- /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 00000000..176507ef
--- /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 00000000..deaeef63
--- /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 00000000..f11a87a5
--- /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
+
+
+ {selectedDecoration && authorization.isAuthorized() && }
+
+ ;
+}
diff --git a/src/plugins/decor/ui/components/DecorationContextMenu.tsx b/src/plugins/decor/ui/components/DecorationContextMenu.tsx
new file mode 100644
index 00000000..7451bb22
--- /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
+ ;
+}
diff --git a/src/plugins/decor/ui/components/DecorationGridCreate.tsx b/src/plugins/decor/ui/components/DecorationGridCreate.tsx
new file mode 100644
index 00000000..7699b23d
--- /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 00000000..b6114c67
--- /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 00000000..40180248
--- /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 00000000..9a6ec1b8
--- /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 00000000..8f39a10e
--- /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 00000000..52b169d7
--- /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 00000000..bed00717
--- /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}>`)}}
+
+
+
+
+
+
+
+
+
+
+ {tooltipProps => }
+
+
+
+ ;
+}
+
+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 00000000..a5937b0d
--- /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
+ .
+
+
+
+
+
+
+ ;
+}
+
+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 00000000..ff10c82f
--- /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/utils/cloud.tsx b/src/utils/cloud.tsx
index 02930622..f56c78dc 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() {
diff --git a/src/webpack/common/components.ts b/src/webpack/common/components.ts
index e44b1c9f..d7bb5d75 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 5d5424fe..b9bc434c 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 cef4d51d..f5d2a966 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 db540745..4563f3f8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,6 +2,7 @@
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
+ "skipLibCheck": true,
"lib": [
"DOM",
"DOM.Iterable",