feat: Add Decor plugin (#910)

This commit is contained in:
Jack 2023-11-30 00:10:50 -05:00 committed by GitHub
parent 8ef1882d43
commit b47a5f569e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1493 additions and 14 deletions

View file

@ -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": {

View file

@ -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

View file

@ -255,3 +255,38 @@ export function DeleteIcon(props: IconProps) {
</Icon>
);
}
export function PlusIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-plus-icon")}
viewBox="0 0 18 18"
>
<polygon
fill-rule="nonzero"
fill="currentColor"
points="15 10 10 10 10 15 8 15 8 10 3 10 3 8 8 8 8 3 10 3 10 8 15 8"
/>
</Icon>
);
}
export function NoEntrySignIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-no-entry-sign-icon")}
viewBox="0 0 24 24"
>
<path
d="M0 0h24v24H0z"
fill="none"
/>
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"
/>
</Icon>
);
}

View file

@ -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.

168
src/plugins/decor/index.tsx Normal file
View file

@ -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 <div>
<DecorSection hideTitle hideDivider noMargin />
<Forms.FormText type="description" className={classes(Margins.top8, Margins.bottom8)}>
You can also access Decor decorations from the <Link
href="/settings/profile-customization"
onClick={e => {
e.preventDefault();
closeAllModals();
FluxDispatcher.dispatch({ type: "USER_SETTINGS_MODAL_SET_SECTION", section: "Profile Customization" });
}}
>Profiles</Link> page.
</Forms.FormText>
</div>;
}
}
});
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)
});

View file

@ -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<Record<string, string | null>> => {
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<Decoration[]> =>
fetchApi(API_URL + `/users/${id}/decorations`).then(c => c.json());
export const getUserDecoration = async (id: string = "@me"): Promise<Decoration | null> =>
fetchApi(API_URL + `/users/${id}/decoration`).then(c => c.json());
export const setUserDecoration = async (decoration: Decoration | NewDecoration | null, id: string = "@me"): Promise<string | Decoration> => {
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<Decoration> => fetch(API_URL + `/decorations/${hash}`).then(c => c.json());
export const deleteDecoration = async (hash: string): Promise<void> => {
await fetchApi(API_URL + `/decorations/${hash}`, { method: "DELETE" });
};
export const getPresets = async (): Promise<Preset[]> => fetch(API_URL + "/decorations/presets").then(c => c.json());

View file

@ -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

View file

@ -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<string, string>;
init: () => void;
authorize: () => Promise<void>;
setToken: (token: string) => void;
remove: (id: string) => void;
isAuthorized: () => boolean;
}
const indexedDBStorage: StateStorage = {
async getItem(name: string): Promise<string | null> {
return DataStore.get(name).then(v => v ?? null);
},
async setItem(name: string, value: string): Promise<void> {
await DataStore.set(name, value);
},
async removeItem(name: string): Promise<void> {
await DataStore.del(name);
},
};
// TODO: Move switching accounts subscription inside the store?
export const useAuthorizationStore = proxyLazy(() => zustandCreate<AuthorizationState>(
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 =>
<OAuth2AuthorizeModal
{...props}
scopes={["identify"]}
responseType="code"
redirectUri={AUTHORIZE_URL}
permissions={0n}
clientId={CLIENT_ID}
cancelCompletesFlow={false}
callback={async (response: any) => {
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()
}
)
));

View file

@ -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<void>;
delete: (decoration: Decoration | string) => Promise<void>;
create: (decoration: NewDecoration) => Promise<void>;
select: (decoration: Decoration | null) => Promise<void>;
clear: () => void;
}
export const useCurrentUserDecorationsStore = proxyLazy(() => zustandCreate<UserDecorationsState>((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 })
})));

View file

@ -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<string, UserDecorationData>;
fetchQueue: Set<string>;
bulkFetch: () => Promise<void>;
fetch: (userId: string, force?: boolean) => Promise<void>;
fetchMany: (userIds: string[]) => Promise<void>;
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<UsersDecorationsState>((set, get) => ({
usersDecorations: new Map<string, UserDecorationData>(),
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<string | null>(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;
}

View file

@ -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 };
}

View file

@ -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<HTMLDivElement> {
decoration: Decoration;
isSelected: boolean;
onSelect: () => void;
}
export default function DecorDecorationGridDecoration(props: DecorDecorationGridDecorationProps) {
const { decoration } = props;
return <DecorationGridDecoration
{...props}
onContextMenu={e => {
ContextMenuApi.openContextMenu(e, () => (
<DecorationContextMenu
decoration={decoration}
/>
));
}}
avatarDecoration={decorationToAvatarDecoration(decoration)}
/>;
}

View file

@ -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 <CustomizationSection
title={!hideTitle && "Decor"}
hasBackground={true}
hideDivider={hideDivider}
className={noMargin && cl("section-remove-margin")}
>
<Flex>
<Button
onClick={() => {
if (!authorization.isAuthorized()) {
authorization.authorize().then(openChangeDecorationModal).catch(() => { });
} else openChangeDecorationModal();
}}
size={Button.Sizes.SMALL}
>
Change Decoration
</Button>
{selectedDecoration && authorization.isAuthorized() && <Button
onClick={() => selectDecoration(null)}
color={Button.Colors.PRIMARY}
size={Button.Sizes.SMALL}
look={Button.Looks.LINK}
>
Remove Decoration
</Button>}
</Flex>
</CustomizationSection>;
}

View file

@ -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 <Menu.Menu
navId={cl("decoration-context-menu")}
onClose={ContextMenuApi.closeContextMenu}
aria-label="Decoration Options"
>
<Menu.MenuItem
id={cl("decoration-context-menu-copy-hash")}
label="Copy Decoration Hash"
icon={CopyIcon}
action={() => Clipboard.copy(decoration.hash)}
/>
{decoration.authorId === UserStore.getCurrentUser().id &&
<Menu.MenuItem
id={cl("decoration-context-menu-delete")}
label="Delete Decoration"
color="danger"
icon={DeleteIcon}
action={() => 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);
}
})}
/>
}
</Menu.Menu>;
}

View file

@ -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<HTMLDivElement> & {
onSelect: () => void;
};
export default function DecorationGridCreate(props: DecorationGridCreateProps) {
return <DecorationGridItem
{...props}
isSelected={false}
>
<PlusIcon />
<Text
variant="text-xs/normal"
color="header-primary"
>
{i18n.Messages.CREATE}
</Text>
</DecorationGridItem >;
}

View file

@ -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<HTMLDivElement> & {
isSelected: boolean;
onSelect: () => void;
};
export default function DecorationGridNone(props: DecorationGridNoneProps) {
return <DecorationGridItem
{...props}
>
<NoEntrySignIcon />
<Text
variant="text-xs/normal"
color="header-primary"
>
{i18n.Messages.NONE}
</Text>
</DecorationGridItem >;
}

View file

@ -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<ItemT> {
renderItem: (item: ItemT) => JSX.Element;
getItemKey: (item: ItemT) => string;
itemKeyPrefix?: string;
items: Array<ItemT>;
}
export default function Grid<ItemT,>({ renderItem, getItemKey, itemKeyPrefix: ikp, items }: GridProps<ItemT>) {
return <div className={cl("sectioned-grid-list-grid")}>
{items.map(item =>
<React.Fragment
key={`${ikp ? `${ikp}-` : ""}${getItemKey(item)}`}
>
{renderItem(item)}
</React.Fragment>
)}
</div>;
}

View file

@ -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, ItemT> = SectionT & {
items: Array<ItemT>;
};
interface SectionedGridListProps<ItemT, SectionT, SectionU = Section<SectionT, ItemT>> extends Omit<GridProps<ItemT>, "items"> {
renderSectionHeader: (section: SectionU) => JSX.Element;
getSectionKey: (section: SectionU) => string;
sections: SectionU[];
}
export default function SectionedGridList<ItemT, SectionU,>(props: SectionedGridListProps<ItemT, SectionU>) {
return <div className={classes(cl("sectioned-grid-list-container"), ScrollerClasses.thin)}>
{props.sections.map(section => <div key={props.getSectionKey(section)} className={cl("sectioned-grid-list-section")}>
{props.renderSectionHeader(section)}
<Grid
renderItem={props.renderItem}
getItemKey={props.getItemKey}
itemKeyPrefix={props.getSectionKey(section)}
items={section.items}
/>
</div>)}
</div>;
}

View file

@ -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<PropsWithChildren<HTMLProps<HTMLDivElement>> & {
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<HTMLProps<HTMLDivElement> & {
avatarDecoration: AvatarDecoration;
onSelect: () => void,
isSelected: boolean,
}>;
export let DecorationGridDecoration: DecorationGridDecorationComponent;
export const setDecorationGridDecoration = v => DecorationGridDecoration = v;

View file

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

View file

@ -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<Preset[]>([]);
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<User[]>([]);
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 <div>
<Flex>
<Forms.FormTitle style={{ flexGrow: 1 }}>{section.title}</Forms.FormTitle>
{hasAuthorIds && <UserSummaryItem
users={authors}
guildId={undefined}
renderIcon={false}
max={5}
showDefaultAvatarsForNullUsers
size={16}
showUserPopout
className={Margins.bottom8}
/>
}
</Flex>
{hasSubtitle &&
<Forms.FormText type="description" className={Margins.bottom8}>
{section.subtitle}
</Forms.FormText>
}
</div>;
}
export default function ChangeDecorationModal(props: any) {
// undefined = not trying, null = none, Decoration = selected
const [tryingDecoration, setTryingDecoration] = useState<Decoration | null | undefined>(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 <ModalRoot
{...props}
size={ModalSize.DYNAMIC}
className={DecorationModalStyles.modal}
>
<ModalHeader separator={false} className={cl("modal-header")}>
<Text
color="header-primary"
variant="heading-lg/semibold"
tag="h1"
style={{ flexGrow: 1 }}
>
Change Decoration
</Text>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent
className={cl("change-decoration-modal-content")}
scrollbarType="none"
>
<SectionedGridList
renderItem={item => {
if (typeof item === "string") {
switch (item) {
case "none":
return <DecorationGridNone
className={cl("change-decoration-modal-decoration")}
isSelected={activeSelectedDecoration === null}
onSelect={() => setTryingDecoration(null)}
/>;
case "create":
return <Tooltip text="You already have a decoration pending review" shouldShow={hasDecorationPendingReview}>
{tooltipProps => <DecorationGridCreate
className={cl("change-decoration-modal-decoration")}
{...tooltipProps}
onSelect={!hasDecorationPendingReview ? openCreateDecorationModal : () => { }}
/>}
</Tooltip>;
}
} else {
return <Tooltip text={"Pending review"} shouldShow={item.reviewed === false}>
{tooltipProps => (
<DecorDecorationGridDecoration
{...tooltipProps}
className={cl("change-decoration-modal-decoration")}
onSelect={item.reviewed !== false ? () => setTryingDecoration(item) : () => { }}
isSelected={activeSelectedDecoration?.hash === item.hash}
decoration={item}
/>
)}
</Tooltip>;
}
}}
getItemKey={item => typeof item === "string" ? item : item.hash}
getSectionKey={section => section.sectionKey}
renderSectionHeader={section => <SectionHeader section={section} />}
sections={data}
/>
<div className={cl("change-decoration-modal-preview")}>
<AvatarDecorationModalPreview
avatarDecorationOverride={avatarDecorationOverride}
user={UserStore.getCurrentUser()}
/>
{isActiveDecorationPreset && <Forms.FormTitle className="">Part of the {activeDecorationPreset.name} Preset</Forms.FormTitle>}
{typeof activeSelectedDecoration === "object" &&
<Text
variant="text-sm/semibold"
color="header-primary"
>
{activeSelectedDecoration?.alt}
</Text>
}
{activeDecorationHasAuthor && <Text key={`createdBy-${activeSelectedDecoration.authorId}`}>Created by {Parser.parse(`<@${activeSelectedDecoration.authorId}>`)}</Text>}
</div>
</ModalContent>
<ModalFooter className={classes(cl("change-decoration-modal-footer", cl("modal-footer")))}>
<div className={cl("change-decoration-modal-footer-btn-container")}>
<Button
onClick={() => {
selectDecoration(tryingDecoration!).then(props.onClose);
}}
disabled={!isTryingDecoration}
>
Apply
</Button>
<Button
onClick={props.onClose}
color={Button.Colors.PRIMARY}
look={Button.Looks.LINK}
>
Cancel
</Button>
</div>
<div className={cl("change-decoration-modal-footer-btn-container")}>
<Button
onClick={() => Alerts.show({
title: "Log Out",
body: "Are you sure you want to log out of Decor?",
confirmText: "Log Out",
confirmColor: cl("danger-btn"),
cancelText: "Cancel",
onConfirm() {
useAuthorizationStore.getState().remove(UserStore.getCurrentUser().id);
props.onClose();
}
})}
color={Button.Colors.PRIMARY}
look={Button.Looks.LINK}
>
Log Out
</Button>
<Tooltip text="Join Decor's Discord Server for notifications on your decoration's review, and when new presets are released">
{tooltipProps => <Button
{...tooltipProps}
onClick={async () => {
if (!GuildStore.getGuild(GUILD_ID)) {
const inviteAccepted = await openInviteModal(INVITE_KEY);
if (inviteAccepted) {
closeAllModals();
FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" });
}
} else {
props.onClose();
FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" });
NavigationRouter.transitionToGuild(GUILD_ID);
}
}}
color={Button.Colors.PRIMARY}
look={Button.Looks.LINK}
>
Discord Server
</Button>}
</Tooltip>
</div>
</ModalFooter>
</ModalRoot>;
}
export const openChangeDecorationModal = () =>
requireAvatarDecorationModal().then(() => openModal(props => <ChangeDecorationModal {...props} />));

View file

@ -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<string | null>(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<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<Error | null>(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 <ModalRoot
{...props}
size={ModalSize.MEDIUM}
className={DecorationModalStyles.modal}
>
<ModalHeader separator={false} className={cl("modal-header")}>
<Text
color="header-primary"
variant="heading-lg/semibold"
tag="h1"
style={{ flexGrow: 1 }}
>
Create Decoration
</Text>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent
className={cl("create-decoration-modal-content")}
scrollbarType="none"
>
<div className={cl("create-decoration-modal-form-preview-container")}>
<div className={cl("create-decoration-modal-form")}>
{error !== null && <Text color="text-danger" variant="text-xs/normal">{error.message}</Text>}
<Forms.FormSection title="File">
<FileUpload
filename={file?.name}
placeholder="Choose a file"
buttonText="Browse"
filters={[{ name: "Decoration file", extensions: ["png", "apng"] }]}
onFileSelect={setFile}
/>
<Forms.FormText type="description" className={Margins.top8}>
File should be APNG or PNG.
</Forms.FormText>
</Forms.FormSection>
<Forms.FormSection title="Name">
<TextInput
placeholder="Companion Cube"
value={name}
onChange={setName}
/>
<Forms.FormText type="description" className={Margins.top8}>
This name will be used when referring to this decoration.
</Forms.FormText>
</Forms.FormSection>
</div>
<div>
<AvatarDecorationModalPreview
avatarDecorationOverride={decoration}
user={UserStore.getCurrentUser()}
/>
</div>
</div>
<Forms.FormText type="description" className={Margins.bottom16}>
Make sure your decoration does not violate <Link
href="https://github.com/decor-discord/.github/blob/main/GUIDELINES.md"
>
the guidelines
</Link> before creating your decoration.
<br />You can receive updates on your decoration's review by joining <Link
href={`https://discord.gg/${INVITE_KEY}`}
onClick={async e => {
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
</Link>.
</Forms.FormText>
</ModalContent>
<ModalFooter className={cl("modal-footer")}>
<Button
onClick={() => {
setSubmitting(true);
createDecoration({ alt: name, file: file! })
.then(props.onClose).catch(e => { setSubmitting(false); setError(e); });
}}
disabled={!file || !name}
submitting={submitting}
>
Create
</Button>
<Button
onClick={props.onClose}
color={Button.Colors.PRIMARY}
look={Button.Looks.LINK}
>
Cancel
</Button>
</ModalFooter>
</ModalRoot>;
}
export const openCreateDecorationModal = () =>
Promise.all([requireAvatarDecorationModal(), requireCreateStickerModal()])
.then(() => openModal(props => <CreateDecorationModal {...props} />));

View file

@ -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;
}

View file

@ -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) => <OAuth2AuthorizeModal
{...props}
scopes={["identify"]}

View file

@ -17,7 +17,7 @@
*/
import { MessageObject } from "@api/MessageEvents";
import { findByPropsLazy } from "@webpack";
import { findByPropsLazy, findStoreLazy } from "@webpack";
import { ChannelStore, ComponentDispatch, FluxDispatcher, GuildStore, MaskedLink, ModalImageClasses, PrivateChannelsStore, RestAPI, SelectedChannelStore, SelectedGuildStore, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common";
import { Guild, Message, User } from "discord-types/general";
@ -27,6 +27,13 @@ export const MessageActions = findByPropsLazy("editMessage", "sendMessage");
export const UserProfileActions = findByPropsLazy("openUserProfileModal", "closeUserProfileModal");
export const InviteActions = findByPropsLazy("resolveInvite");
const InviteModalStore = findStoreLazy("InviteModalStore");
/**
* Open the invite modal
* @param code The invite code
* @returns Whether the invite was accepted
*/
export async function openInviteModal(code: string) {
const { invite } = await InviteActions.resolveInvite(code, "Desktop Modal");
if (!invite) throw new Error("Invalid invite: " + code);
@ -37,6 +44,21 @@ export async function openInviteModal(code: string) {
code,
context: "APP"
});
return new Promise<boolean>(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() {

View file

@ -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<t.MaskedLink>("MaskedLink", m => m?.t
export const Timestamp = waitForComponent<t.Timestamp>("Timestamp", filters.byCode(".Messages.MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL.format"));
export const Flex = waitForComponent<t.Flex>("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;

View file

@ -126,6 +126,7 @@ export type Button = ComponentType<PropsWithChildren<Omit<HTMLProps<HTMLButtonEl
buttonRef?: Ref<HTMLButtonElement>;
focusProps?: any;
submitting?: boolean;
submittingStartedLabel?: string;
submittingFinishedLabel?: string;

View file

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

View file

@ -2,6 +2,7 @@
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": [
"DOM",
"DOM.Iterable",