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 + 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 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",