feat: Add Decor plugin (#910)

main
Jack 10 months ago committed by GitHub
parent 8ef1882d43
commit b47a5f569e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -68,7 +68,8 @@
"tsx": "^3.12.7", "tsx": "^3.12.7",
"type-fest": "^3.9.0", "type-fest": "^3.9.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"zip-local": "^0.3.5" "zip-local": "^0.3.5",
"zustand": "^3.7.2"
}, },
"packageManager": "pnpm@8.10.2", "packageManager": "pnpm@8.10.2",
"pnpm": { "pnpm": {

@ -1,9 +1,5 @@
lockfileVersion: '6.0' lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies: patchedDependencies:
eslint-plugin-path-alias@1.0.0: eslint-plugin-path-alias@1.0.0:
hash: m6sma4g6bh67km3q6igf6uxaja hash: m6sma4g6bh67km3q6igf6uxaja
@ -123,6 +119,9 @@ devDependencies:
zip-local: zip-local:
specifier: ^0.3.5 specifier: ^0.3.5
version: 0.3.5 version: 0.3.5
zustand:
specifier: ^3.7.2
version: 3.7.2
packages: packages:
@ -3450,8 +3449,22 @@ packages:
q: 1.5.1 q: 1.5.1
dev: true 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: github.com/mattdesl/gifenc/64842fca317b112a8590f8fef2bf3825da8f6fe3:
resolution: {tarball: https://codeload.github.com/mattdesl/gifenc/tar.gz/64842fca317b112a8590f8fef2bf3825da8f6fe3} resolution: {tarball: https://codeload.github.com/mattdesl/gifenc/tar.gz/64842fca317b112a8590f8fef2bf3825da8f6fe3}
name: gifenc name: gifenc
version: 1.0.3 version: 1.0.3
dev: false dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

@ -255,3 +255,38 @@ export function DeleteIcon(props: IconProps) {
</Icon> </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>
);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -19,8 +19,7 @@
import * as DataStore from "@api/DataStore"; import * as DataStore from "@api/DataStore";
import { showNotification } from "@api/Notifications"; import { showNotification } from "@api/Notifications";
import { Settings } from "@api/Settings"; import { Settings } from "@api/Settings";
import { findByProps } from "@webpack"; import { OAuth2AuthorizeModal, UserStore } from "@webpack/common";
import { UserStore } from "@webpack/common";
import { Logger } from "./Logger"; import { Logger } from "./Logger";
import { openModal } from "./modal"; import { openModal } from "./modal";
@ -91,8 +90,6 @@ export async function authorizeCloud() {
return; return;
} }
const { OAuth2AuthorizeModal } = findByProps("OAuth2AuthorizeModal");
openModal((props: any) => <OAuth2AuthorizeModal openModal((props: any) => <OAuth2AuthorizeModal
{...props} {...props}
scopes={["identify"]} scopes={["identify"]}

@ -17,7 +17,7 @@
*/ */
import { MessageObject } from "@api/MessageEvents"; 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 { ChannelStore, ComponentDispatch, FluxDispatcher, GuildStore, MaskedLink, ModalImageClasses, PrivateChannelsStore, RestAPI, SelectedChannelStore, SelectedGuildStore, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common";
import { Guild, Message, User } from "discord-types/general"; 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 UserProfileActions = findByPropsLazy("openUserProfileModal", "closeUserProfileModal");
export const InviteActions = findByPropsLazy("resolveInvite"); 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) { export async function openInviteModal(code: string) {
const { invite } = await InviteActions.resolveInvite(code, "Desktop Modal"); const { invite } = await InviteActions.resolveInvite(code, "Desktop Modal");
if (!invite) throw new Error("Invalid invite: " + code); if (!invite) throw new Error("Invalid invite: " + code);
@ -37,6 +44,21 @@ export async function openInviteModal(code: string) {
code, code,
context: "APP" 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() { export function getCurrentChannel() {

@ -17,7 +17,7 @@
*/ */
// eslint-disable-next-line path-alias/no-relative // eslint-disable-next-line path-alias/no-relative
import { filters, waitFor } from "@webpack"; import { filters, findByPropsLazy, waitFor } from "@webpack";
import { waitForComponent } from "./internal"; import { waitForComponent } from "./internal";
import * as t from "./types/components"; 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 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 Flex = waitForComponent<t.Flex>("Flex", ["Justify", "Align", "Wrap"]);
export const { OAuth2AuthorizeModal } = findByPropsLazy("OAuth2AuthorizeModal");
waitFor(["FormItem", "Button"], m => { 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); ({ useToken, Card, Button, FormSwitch: Switch, Tooltip, TextInput, TextArea, Text, Select, SearchableSelect, Slider, ButtonLooks, TabBar, Popout, Dialog, Paginator, ScrollerThin, Clickable, Avatar } = m);
Forms = m; Forms = m;

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

@ -19,7 +19,7 @@
import type { Channel, User } from "discord-types/general"; import type { Channel, User } from "discord-types/general";
// eslint-disable-next-line path-alias/no-relative // 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"; import type * as t from "./types/utils";
export let FluxDispatcher: t.FluxDispatcher; export let FluxDispatcher: t.FluxDispatcher;
@ -127,5 +127,9 @@ export const NavigationRouter: t.NavigationRouter = findByPropsLazy("transitionT
export let SettingsRouter: any; export let SettingsRouter: any;
waitFor(["open", "saveAccountChanges"], m => SettingsRouter = m); waitFor(["open", "saveAccountChanges"], m => SettingsRouter = m);
const { Permissions } = findLazy(m => typeof m.Permissions?.ADMINISTRATOR === "bigint") as { Permissions: t.PermissionsBits; }; export const { Permissions: PermissionsBits } = findLazy(m => typeof m.Permissions?.ADMINISTRATOR === "bigint") as { Permissions: t.PermissionsBits; };
export { Permissions as 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));

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

Loading…
Cancel
Save