Merge branch 'dev' into feat/usercss
This commit is contained in:
commit
d7e5c06e83
39 changed files with 1579 additions and 57 deletions
|
@ -17,7 +17,7 @@
|
|||
"doc": "docs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node scripts/build/build.mjs",
|
||||
"build": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs",
|
||||
"buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs",
|
||||
"generatePluginJson": "tsx scripts/generatePluginList.ts",
|
||||
"inject": "node scripts/runInstaller.mjs",
|
||||
|
@ -28,7 +28,7 @@
|
|||
"testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc",
|
||||
"testTsc": "tsc --noEmit",
|
||||
"uninject": "node scripts/runInstaller.mjs",
|
||||
"watch": "node scripts/build/build.mjs --watch"
|
||||
"watch": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sapphi-red/web-noise-suppressor": "0.3.3",
|
||||
|
@ -72,7 +72,8 @@
|
|||
"tsx": "^3.12.7",
|
||||
"type-fest": "^3.9.0",
|
||||
"typescript": "^5.0.4",
|
||||
"zip-local": "^0.3.5"
|
||||
"zip-local": "^0.3.5",
|
||||
"zustand": "^3.7.2"
|
||||
},
|
||||
"packageManager": "pnpm@8.10.2",
|
||||
"pnpm": {
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
lockfileVersion: '6.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
patchedDependencies:
|
||||
'@types/less@3.0.4':
|
||||
hash: krcufrsfhsuxuoj7hocqugs6zi
|
||||
|
@ -138,6 +134,9 @@ devDependencies:
|
|||
zip-local:
|
||||
specifier: ^0.3.5
|
||||
version: 0.3.5
|
||||
zustand:
|
||||
specifier: ^3.7.2
|
||||
version: 3.7.2
|
||||
|
||||
packages:
|
||||
|
||||
|
@ -3485,8 +3484,22 @@ packages:
|
|||
q: 1.5.1
|
||||
dev: true
|
||||
|
||||
/zustand@3.7.2:
|
||||
resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
dev: true
|
||||
|
||||
github.com/mattdesl/gifenc/64842fca317b112a8590f8fef2bf3825da8f6fe3:
|
||||
resolution: {tarball: https://codeload.github.com/mattdesl/gifenc/tar.gz/64842fca317b112a8590f8fef2bf3825da8f6fe3}
|
||||
name: gifenc
|
||||
version: 1.0.3
|
||||
dev: false
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
|
|
@ -105,7 +105,14 @@ async function printReport() {
|
|||
|
||||
console.log();
|
||||
|
||||
report.otherErrors = report.otherErrors.filter(e => !IGNORED_DISCORD_ERRORS.some(regex => e.match(regex)));
|
||||
const ignoredErrors = [] as string[];
|
||||
report.otherErrors = report.otherErrors.filter(e => {
|
||||
if (IGNORED_DISCORD_ERRORS.some(regex => e.match(regex))) {
|
||||
ignoredErrors.push(e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log("## Discord Errors");
|
||||
report.otherErrors.forEach(e => {
|
||||
|
@ -114,6 +121,13 @@ async function printReport() {
|
|||
|
||||
console.log();
|
||||
|
||||
console.log("## Ignored Discord Errors");
|
||||
ignoredErrors.forEach(e => {
|
||||
console.log(`- ${toCodeBlock(e)}`);
|
||||
});
|
||||
|
||||
console.log();
|
||||
|
||||
if (process.env.DISCORD_WEBHOOK) {
|
||||
await fetch(process.env.DISCORD_WEBHOOK, {
|
||||
method: "POST",
|
||||
|
@ -410,7 +424,7 @@ function runTime(token: string) {
|
|||
const [code, matcher] = args;
|
||||
|
||||
const module = Vencord.Webpack.findModuleFactory(...code);
|
||||
if (module) result = module.toString().match(matcher);
|
||||
if (module) result = module.toString().match(Vencord.Util.canonicalizeMatch(matcher));
|
||||
} else {
|
||||
// @ts-ignore
|
||||
result = Vencord.Webpack[method](...args);
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { MessageActions } from "@utils/discord";
|
||||
import { mergeDefaults } from "@utils/misc";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { SnowflakeUtils } from "@webpack/common";
|
||||
|
@ -24,7 +25,6 @@ import type { PartialDeep } from "type-fest";
|
|||
|
||||
import { Argument } from "./types";
|
||||
|
||||
const MessageCreator = findByPropsLazy("createBotMessage");
|
||||
const MessageSender = findByPropsLazy("receiveMessage");
|
||||
|
||||
export function generateId() {
|
||||
|
@ -38,7 +38,7 @@ export function generateId() {
|
|||
* @returns {Message}
|
||||
*/
|
||||
export function sendBotMessage(channelId: string, message: PartialDeep<Message>): Message {
|
||||
const botMessage = MessageCreator.createBotMessage({ channelId, content: "", embeds: [] });
|
||||
const botMessage = MessageActions.createBotMessage({ channelId, content: "", embeds: [] });
|
||||
|
||||
MessageSender.receiveMessage(channelId, mergeDefaults(message, botMessage));
|
||||
|
||||
|
|
|
@ -273,3 +273,38 @@ export function PluginIcon(props: IconProps) {
|
|||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
className={classes(props.className, "vc-plus-icon")}
|
||||
viewBox="0 0 18 18"
|
||||
>
|
||||
<polygon
|
||||
fill-rule="nonzero"
|
||||
fill="currentColor"
|
||||
points="15 10 10 10 10 15 8 15 8 10 3 10 3 8 8 8 8 3 10 3 10 8 15 8"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoEntrySignIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
className={classes(props.className, "vc-no-entry-sign-icon")}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -22,14 +22,13 @@ import ErrorBoundary from "@components/ErrorBoundary";
|
|||
import { Flex } from "@components/Flex";
|
||||
import { Heart } from "@components/Heart";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { isPluginDev } from "@utils/misc";
|
||||
import { closeModal, Modals, openModal } from "@utils/modal";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Forms, Toasts } from "@webpack/common";
|
||||
|
||||
const CONTRIBUTOR_BADGE = "https://cdn.discordapp.com/attachments/1033680203433660458/1092089947126780035/favicon.png";
|
||||
const CONTRIBUTOR_BADGE = "https://vencord.dev/assets/favicon.png";
|
||||
|
||||
const ContributorBadge: ProfileBadge = {
|
||||
description: "Vencord Contributor",
|
||||
|
@ -45,7 +44,7 @@ const ContributorBadge: ProfileBadge = {
|
|||
link: "https://github.com/Vendicated/Vencord"
|
||||
};
|
||||
|
||||
let DonorBadges = {} as Record<string, Pick<ProfileBadge, "image" | "description">[]>;
|
||||
let DonorBadges = {} as Record<string, Array<Record<"tooltip" | "badge", string>>>;
|
||||
|
||||
async function loadBadges(noCache = false) {
|
||||
DonorBadges = {};
|
||||
|
@ -54,19 +53,8 @@ async function loadBadges(noCache = false) {
|
|||
if (noCache)
|
||||
init.cache = "no-cache";
|
||||
|
||||
const badges = await fetch("https://gist.githubusercontent.com/Vendicated/51a3dd775f6920429ec6e9b735ca7f01/raw/badges.csv", init)
|
||||
.then(r => r.text());
|
||||
|
||||
const lines = badges.trim().split("\n");
|
||||
if (lines.shift() !== "id,tooltip,image") {
|
||||
new Logger("BadgeAPI").error("Invalid badges.csv file!");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const [id, description, image] = line.split(",");
|
||||
(DonorBadges[id] ??= []).push({ image, description });
|
||||
}
|
||||
DonorBadges = await fetch("https://badges.vencord.dev/badges.json", init)
|
||||
.then(r => r.json());
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
|
@ -127,7 +115,8 @@ export default definePlugin({
|
|||
|
||||
getDonorBadges(userId: string) {
|
||||
return DonorBadges[userId]?.map(badge => ({
|
||||
...badge,
|
||||
image: badge.badge,
|
||||
description: badge.tooltip,
|
||||
position: BadgePosition.START,
|
||||
props: {
|
||||
style: {
|
||||
|
|
|
@ -15,7 +15,7 @@ import definePlugin, { OptionType, StartAt } from "@utils/types";
|
|||
import { findComponentByCodeLazy } from "@webpack";
|
||||
import { Button, Forms } from "@webpack/common";
|
||||
|
||||
const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR");
|
||||
const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)");
|
||||
|
||||
const colorPresets = [
|
||||
"#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D",
|
||||
|
|
|
@ -63,6 +63,7 @@ export default definePlugin({
|
|||
|
||||
let fakeRenderWin: WeakRef<Window> | undefined;
|
||||
const find = newFindWrapper(f => f);
|
||||
const findByProps = newFindWrapper(filters.byProps);
|
||||
return {
|
||||
...Vencord.Webpack.Common,
|
||||
wp: Vencord.Webpack,
|
||||
|
@ -73,13 +74,13 @@ export default definePlugin({
|
|||
wpexs: (code: string) => extract(Webpack.findModuleId(code)!),
|
||||
find,
|
||||
findAll,
|
||||
findByProps: newFindWrapper(filters.byProps),
|
||||
findByProps,
|
||||
findAllByProps: (...props: string[]) => findAll(filters.byProps(...props)),
|
||||
findByCode: newFindWrapper(filters.byCode),
|
||||
findAllByCode: (code: string) => findAll(filters.byCode(code)),
|
||||
findComponentByCode: newFindWrapper(filters.componentByCode),
|
||||
findAllComponentsByCode: (...code: string[]) => findAll(filters.componentByCode(...code)),
|
||||
findExportedComponent: (...props: string[]) => find(...props)[props[0]],
|
||||
findExportedComponent: (...props: string[]) => findByProps(...props)[props[0]],
|
||||
findStore: newFindWrapper(filters.byStoreName),
|
||||
PluginsApi: Vencord.Plugins,
|
||||
plugins: Vencord.Plugins.plugins,
|
||||
|
|
|
@ -23,12 +23,26 @@ import { Logger } from "@utils/Logger";
|
|||
import { closeAllModals } from "@utils/modal";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { maybePromptToUpdate } from "@utils/updater";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { FluxDispatcher, NavigationRouter } from "@webpack/common";
|
||||
import { filters, findBulk, proxyLazyWebpack } from "@webpack";
|
||||
import { FluxDispatcher, NavigationRouter, SelectedChannelStore } from "@webpack/common";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
const CrashHandlerLogger = new Logger("CrashHandler");
|
||||
const ModalStack = findByPropsLazy("pushLazy", "popAll");
|
||||
const { ModalStack, DraftManager, DraftType, closeExpressionPicker } = proxyLazyWebpack(() => {
|
||||
const modules = findBulk(
|
||||
filters.byProps("pushLazy", "popAll"),
|
||||
filters.byProps("clearDraft", "saveDraft"),
|
||||
filters.byProps("DraftType"),
|
||||
filters.byProps("closeExpressionPicker", "openExpressionPicker"),
|
||||
);
|
||||
|
||||
return {
|
||||
ModalStack: modules[0],
|
||||
DraftManager: modules[1],
|
||||
DraftType: modules[2]?.DraftType,
|
||||
closeExpressionPicker: modules[3]?.closeExpressionPicker,
|
||||
};
|
||||
});
|
||||
|
||||
const settings = definePluginSettings({
|
||||
attemptToPreventCrashes: {
|
||||
|
@ -115,13 +129,27 @@ export default definePlugin({
|
|||
} catch { }
|
||||
}
|
||||
|
||||
try {
|
||||
const channelId = SelectedChannelStore.getChannelId();
|
||||
|
||||
DraftManager.clearDraft(channelId, DraftType.ChannelMessage);
|
||||
DraftManager.clearDraft(channelId, DraftType.FirstThreadMessage);
|
||||
} catch (err) {
|
||||
CrashHandlerLogger.debug("Failed to clear drafts.", err);
|
||||
}
|
||||
try {
|
||||
closeExpressionPicker();
|
||||
}
|
||||
catch (err) {
|
||||
CrashHandlerLogger.debug("Failed to close expression picker.", err);
|
||||
}
|
||||
try {
|
||||
FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" });
|
||||
} catch (err) {
|
||||
CrashHandlerLogger.debug("Failed to close open context menu.", err);
|
||||
}
|
||||
try {
|
||||
ModalStack?.popAll();
|
||||
ModalStack.popAll();
|
||||
} catch (err) {
|
||||
CrashHandlerLogger.debug("Failed to close old modals.", err);
|
||||
}
|
||||
|
|
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;
|
||||
}
|
|
@ -18,6 +18,7 @@
|
|||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { MessageActions } from "@utils/discord";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { ContextMenuApi, FluxDispatcher, Menu } from "@webpack/common";
|
||||
|
@ -49,7 +50,6 @@ const settings = definePluginSettings({
|
|||
unholyMultiGreetEnabled?: boolean;
|
||||
}>();
|
||||
|
||||
const MessageActions = findByPropsLazy("sendGreetMessage");
|
||||
const { WELCOME_STICKERS } = findByPropsLazy("WELCOME_STICKERS");
|
||||
|
||||
function greet(channel: Channel, message: Message, stickers: string[]) {
|
||||
|
|
|
@ -35,15 +35,13 @@ interface UserPermission {
|
|||
|
||||
type UserPermissions = Array<UserPermission>;
|
||||
|
||||
const Classes = proxyLazyWebpack(() => {
|
||||
const modules = findBulk(
|
||||
const Classes = proxyLazyWebpack(() =>
|
||||
Object.assign({}, ...findBulk(
|
||||
filters.byProps("roles", "rolePill", "rolePillBorder"),
|
||||
filters.byProps("roleCircle", "dotBorderBase", "dotBorderColor"),
|
||||
filters.byProps("roleNameOverflow", "root", "roleName", "roleRemoveButton")
|
||||
);
|
||||
|
||||
return Object.assign({}, ...modules);
|
||||
}) as Record<"roles" | "rolePill" | "rolePillBorder" | "desaturateUserColors" | "flex" | "alignCenter" | "justifyCenter" | "svg" | "background" | "dot" | "dotBorderColor" | "roleCircle" | "dotBorderBase" | "flex" | "alignCenter" | "justifyCenter" | "wrap" | "root" | "role" | "roleRemoveButton" | "roleDot" | "roleFlowerStar" | "roleRemoveIcon" | "roleRemoveIconFocused" | "roleVerifiedIcon" | "roleName" | "roleNameOverflow" | "actionButton" | "overflowButton" | "addButton" | "addButtonIcon" | "overflowRolesPopout" | "overflowRolesPopoutArrowWrapper" | "overflowRolesPopoutArrow" | "popoutBottom" | "popoutTop" | "overflowRolesPopoutHeader" | "overflowRolesPopoutHeaderIcon" | "overflowRolesPopoutHeaderText" | "roleIcon", string>;
|
||||
))
|
||||
) as Record<"roles" | "rolePill" | "rolePillBorder" | "desaturateUserColors" | "flex" | "alignCenter" | "justifyCenter" | "svg" | "background" | "dot" | "dotBorderColor" | "roleCircle" | "dotBorderBase" | "flex" | "alignCenter" | "justifyCenter" | "wrap" | "root" | "role" | "roleRemoveButton" | "roleDot" | "roleFlowerStar" | "roleRemoveIcon" | "roleRemoveIconFocused" | "roleVerifiedIcon" | "roleName" | "roleNameOverflow" | "actionButton" | "overflowButton" | "addButton" | "addButtonIcon" | "overflowRolesPopout" | "overflowRolesPopoutArrowWrapper" | "overflowRolesPopoutArrow" | "popoutBottom" | "popoutTop" | "overflowRolesPopoutHeader" | "overflowRolesPopoutHeaderIcon" | "overflowRolesPopoutHeaderText" | "roleIcon", string>;
|
||||
|
||||
function UserPermissionsComponent({ guild, guildMember, showBorder }: { guild: Guild; guildMember: GuildMember; showBorder: boolean; }) {
|
||||
const stns = settings.use(["permissionsSortOrder"]);
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
|
||||
import { ApplicationCommandInputType, sendBotMessage } from "@api/Commands";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { MessageActions } from "@utils/discord";
|
||||
import definePlugin from "@utils/types";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { FluxDispatcher } from "@webpack/common";
|
||||
|
@ -53,7 +54,6 @@ interface Track {
|
|||
}
|
||||
|
||||
const Spotify = findByPropsLazy("getPlayerState");
|
||||
const MessageCreator = findByPropsLazy("getSendMessageOptionsForReply", "sendMessage");
|
||||
const PendingReplyStore = findByPropsLazy("getPendingReply");
|
||||
|
||||
function sendMessage(channelId, message) {
|
||||
|
@ -65,7 +65,7 @@ function sendMessage(channelId, message) {
|
|||
...message
|
||||
};
|
||||
const reply = PendingReplyStore.getPendingReply(channelId);
|
||||
MessageCreator.sendMessage(channelId, message, void 0, MessageCreator.getSendMessageOptionsForReply(reply))
|
||||
MessageActions.sendMessage(channelId, message, void 0, MessageActions.getSendMessageOptionsForReply(reply))
|
||||
.then(() => {
|
||||
if (reply) {
|
||||
FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId });
|
||||
|
|
|
@ -21,6 +21,7 @@ import "./styles.css";
|
|||
import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
|
||||
import { Microphone } from "@components/Icons";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { MessageActions } from "@utils/discord";
|
||||
import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import definePlugin from "@utils/types";
|
||||
|
@ -36,7 +37,6 @@ import { VoicePreview } from "./VoicePreview";
|
|||
import { VoiceRecorderWeb } from "./WebRecorder";
|
||||
|
||||
const CloudUtils = findByPropsLazy("CloudUpload");
|
||||
const MessageCreator = findByPropsLazy("getSendMessageOptionsForReply", "sendMessage");
|
||||
const PendingReplyStore = findStoreLazy("PendingReplyStore");
|
||||
const OptionClasses = findByPropsLazy("optionName", "optionIcon", "optionLabel");
|
||||
|
||||
|
@ -100,7 +100,7 @@ function sendAudio(blob: Blob, meta: AudioMetadata) {
|
|||
waveform: meta.waveform,
|
||||
duration_secs: meta.duration,
|
||||
}],
|
||||
message_reference: reply ? MessageCreator.getSendMessageOptionsForReply(reply)?.messageReference : null,
|
||||
message_reference: reply ? MessageActions.getSendMessageOptionsForReply(reply)?.messageReference : null,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -19,8 +19,7 @@
|
|||
import * as DataStore from "@api/DataStore";
|
||||
import { showNotification } from "@api/Notifications";
|
||||
import { Settings } from "@api/Settings";
|
||||
import { findByProps } from "@webpack";
|
||||
import { UserStore } from "@webpack/common";
|
||||
import { OAuth2AuthorizeModal, UserStore } from "@webpack/common";
|
||||
|
||||
import { Logger } from "./Logger";
|
||||
import { openModal } from "./modal";
|
||||
|
@ -91,8 +90,6 @@ export async function authorizeCloud() {
|
|||
return;
|
||||
}
|
||||
|
||||
const { OAuth2AuthorizeModal } = findByProps("OAuth2AuthorizeModal");
|
||||
|
||||
openModal((props: any) => <OAuth2AuthorizeModal
|
||||
{...props}
|
||||
scopes={["identify"]}
|
||||
|
|
|
@ -17,14 +17,49 @@
|
|||
*/
|
||||
|
||||
import { MessageObject } from "@api/MessageEvents";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { findByPropsLazy, findStoreLazy } from "@webpack";
|
||||
import { ChannelStore, ComponentDispatch, FluxDispatcher, GuildStore, MaskedLink, ModalImageClasses, PrivateChannelsStore, RestAPI, SelectedChannelStore, SelectedGuildStore, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common";
|
||||
import { Guild, Message, User } from "discord-types/general";
|
||||
|
||||
import { ImageModal, ModalRoot, ModalSize, openModal } from "./modal";
|
||||
|
||||
const MessageActions = findByPropsLazy("editMessage", "sendMessage");
|
||||
const UserProfileActions = findByPropsLazy("openUserProfileModal", "closeUserProfileModal");
|
||||
export const MessageActions = findByPropsLazy("editMessage", "sendMessage");
|
||||
export const UserProfileActions = findByPropsLazy("openUserProfileModal", "closeUserProfileModal");
|
||||
export const InviteActions = findByPropsLazy("resolveInvite");
|
||||
|
||||
const InviteModalStore = findStoreLazy("InviteModalStore");
|
||||
|
||||
/**
|
||||
* Open the invite modal
|
||||
* @param code The invite code
|
||||
* @returns Whether the invite was accepted
|
||||
*/
|
||||
export async function openInviteModal(code: string) {
|
||||
const { invite } = await InviteActions.resolveInvite(code, "Desktop Modal");
|
||||
if (!invite) throw new Error("Invalid invite: " + code);
|
||||
|
||||
FluxDispatcher.dispatch({
|
||||
type: "INVITE_MODAL_OPEN",
|
||||
invite,
|
||||
code,
|
||||
context: "APP"
|
||||
});
|
||||
|
||||
return new Promise<boolean>(r => {
|
||||
let onClose: () => void, onAccept: () => void;
|
||||
let inviteAccepted = false;
|
||||
|
||||
FluxDispatcher.subscribe("INVITE_ACCEPT", onAccept = () => {
|
||||
inviteAccepted = true;
|
||||
});
|
||||
|
||||
FluxDispatcher.subscribe("INVITE_MODAL_CLOSE", onClose = () => {
|
||||
FluxDispatcher.unsubscribe("INVITE_MODAL_CLOSE", onClose);
|
||||
FluxDispatcher.unsubscribe("INVITE_ACCEPT", onAccept);
|
||||
r(inviteAccepted);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getCurrentChannel() {
|
||||
return ChannelStore.getChannel(SelectedChannelStore.getChannelId());
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
*/
|
||||
|
||||
// eslint-disable-next-line path-alias/no-relative
|
||||
import { filters, waitFor } from "@webpack";
|
||||
import { filters, findByPropsLazy, waitFor } from "@webpack";
|
||||
|
||||
import { waitForComponent } from "./internal";
|
||||
import * as t from "./types/components";
|
||||
|
@ -55,6 +55,8 @@ export const MaskedLink = waitForComponent<t.MaskedLink>("MaskedLink", m => m?.t
|
|||
export const Timestamp = waitForComponent<t.Timestamp>("Timestamp", filters.byCode(".Messages.MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL.format"));
|
||||
export const Flex = waitForComponent<t.Flex>("Flex", ["Justify", "Align", "Wrap"]);
|
||||
|
||||
export const { OAuth2AuthorizeModal } = findByPropsLazy("OAuth2AuthorizeModal");
|
||||
|
||||
waitFor(["FormItem", "Button"], m => {
|
||||
({ useToken, Card, Button, FormSwitch: Switch, Tooltip, TextInput, TextArea, Text, Select, SearchableSelect, Slider, ButtonLooks, TabBar, Popout, Dialog, Paginator, ScrollerThin, Clickable, Avatar } = m);
|
||||
Forms = m;
|
||||
|
|
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>;
|
||||
focusProps?: any;
|
||||
submitting?: boolean;
|
||||
|
||||
submittingStartedLabel?: string;
|
||||
submittingFinishedLabel?: string;
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
import type { Channel, User } from "discord-types/general";
|
||||
|
||||
// eslint-disable-next-line path-alias/no-relative
|
||||
import { _resolveReady, findByPropsLazy, findLazy, waitFor } from "../webpack";
|
||||
import { _resolveReady, filters, findByCodeLazy, findByPropsLazy, findLazy, waitFor } from "../webpack";
|
||||
import type * as t from "./types/utils";
|
||||
|
||||
export let FluxDispatcher: t.FluxDispatcher;
|
||||
|
@ -127,5 +127,9 @@ export const NavigationRouter: t.NavigationRouter = findByPropsLazy("transitionT
|
|||
export let SettingsRouter: any;
|
||||
waitFor(["open", "saveAccountChanges"], m => SettingsRouter = m);
|
||||
|
||||
const { Permissions } = findLazy(m => typeof m.Permissions?.ADMINISTRATOR === "bigint") as { Permissions: t.PermissionsBits; };
|
||||
export { Permissions as PermissionsBits };
|
||||
export const { Permissions: PermissionsBits } = findLazy(m => typeof m.Permissions?.ADMINISTRATOR === "bigint") as { Permissions: t.PermissionsBits; };
|
||||
|
||||
export const zustandCreate: typeof import("zustand").default = findByCodeLazy("will be removed in v4");
|
||||
|
||||
const persistFilter = filters.byCode("[zustand persist middleware]");
|
||||
export const { persist: zustandPersist }: typeof import("zustand/middleware") = findLazy(m => m.persist && persistFilter(m.persist));
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
|
|
Loading…
Reference in a new issue