feat: Add Decor plugin (#910)
parent
8ef1882d43
commit
b47a5f569e
@ -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;
|
||||
}
|
Loading…
Reference in new issue