feat: Add Decor plugin (#910)
This commit is contained in:
parent
8ef1882d43
commit
b47a5f569e
29 changed files with 1493 additions and 14 deletions
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
17
src/plugins/decor/README.md
Normal file
17
src/plugins/decor/README.md
Normal 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
168
src/plugins/decor/index.tsx
Normal 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)
|
||||||
|
});
|
83
src/plugins/decor/lib/api.ts
Normal file
83
src/plugins/decor/lib/api.ts
Normal 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());
|
16
src/plugins/decor/lib/constants.ts
Normal file
16
src/plugins/decor/lib/constants.ts
Normal 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
|
102
src/plugins/decor/lib/stores/AuthorizationStore.tsx
Normal file
102
src/plugins/decor/lib/stores/AuthorizationStore.tsx
Normal 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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
));
|
56
src/plugins/decor/lib/stores/CurrentUserDecorationsStore.ts
Normal file
56
src/plugins/decor/lib/stores/CurrentUserDecorationsStore.ts
Normal 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 })
|
||||||
|
})));
|
118
src/plugins/decor/lib/stores/UsersDecorationsStore.ts
Normal file
118
src/plugins/decor/lib/stores/UsersDecorationsStore.ts
Normal 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;
|
||||||
|
}
|
17
src/plugins/decor/lib/utils/decoration.ts
Normal file
17
src/plugins/decor/lib/utils/decoration.ts
Normal 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 };
|
||||||
|
}
|
|
@ -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)}
|
||||||
|
/>;
|
||||||
|
}
|
59
src/plugins/decor/ui/components/DecorSection.tsx
Normal file
59
src/plugins/decor/ui/components/DecorSection.tsx
Normal 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>;
|
||||||
|
}
|
47
src/plugins/decor/ui/components/DecorationContextMenu.tsx
Normal file
47
src/plugins/decor/ui/components/DecorationContextMenu.tsx
Normal 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>;
|
||||||
|
}
|
30
src/plugins/decor/ui/components/DecorationGridCreate.tsx
Normal file
30
src/plugins/decor/ui/components/DecorationGridCreate.tsx
Normal 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 >;
|
||||||
|
}
|
30
src/plugins/decor/ui/components/DecorationGridNone.tsx
Normal file
30
src/plugins/decor/ui/components/DecorationGridNone.tsx
Normal 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 >;
|
||||||
|
}
|
28
src/plugins/decor/ui/components/Grid.tsx
Normal file
28
src/plugins/decor/ui/components/Grid.tsx
Normal 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>;
|
||||||
|
}
|
38
src/plugins/decor/ui/components/SectionedGridList.tsx
Normal file
38
src/plugins/decor/ui/components/SectionedGridList.tsx
Normal 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>;
|
||||||
|
}
|
33
src/plugins/decor/ui/components/index.ts
Normal file
33
src/plugins/decor/ui/components/index.ts
Normal 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;
|
13
src/plugins/decor/ui/index.ts
Normal file
13
src/plugins/decor/ui/index.ts
Normal 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]:"]);
|
270
src/plugins/decor/ui/modals/ChangeDecorationModal.tsx
Normal file
270
src/plugins/decor/ui/modals/ChangeDecorationModal.tsx
Normal 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} />));
|
163
src/plugins/decor/ui/modals/CreateDecorationModal.tsx
Normal file
163
src/plugins/decor/ui/modals/CreateDecorationModal.tsx
Normal 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} />));
|
80
src/plugins/decor/ui/styles.css
Normal file
80
src/plugins/decor/ui/styles.css
Normal 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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
1
src/webpack/common/types/components.d.ts
vendored
1
src/webpack/common/types/components.d.ts
vendored
|
@ -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…
Reference in a new issue