This commit is contained in:
thororen1234 2024-06-01 14:32:22 -04:00
parent 268e053d68
commit 7da91d94d8
77 changed files with 3175 additions and 1964 deletions

View file

@ -83,7 +83,7 @@ An enhanced version of [Vencord](https://github.com/Vendicated/Vencord) by [Vend
- ReplyPingControl by ant0n and MrDiamond
- ScreenRecorder by AutumnVN
- Search by JacobTm and thororen
- SearchFix by jaxx
- SearchFix by Jaxx
- Sekai Stickers by MaiKokain
- ServerProfilesToolbox by D3SOX
- ShowBadgesInChat by Inbestigator and KrystalSkull

View file

@ -29,10 +29,6 @@ export interface AllowedMentions {
};
}
export interface EditAttachments {
attachments: File[];
}
export interface AllowedMentionsProps {
mentions: AllowedMentions,
channel: Channel;
@ -279,7 +275,7 @@ function Popout({
export function AllowedMentionsBar({ mentions, channel, trailingSeparator }: AllowedMentionsProps) {
const [users, setUsers] = useState(new Set(mentions.users));
const [roles, setRoles] = useState(new Set(mentions.users));
const [roles, setRoles] = useState(new Set(mentions.roles));
const [everyone, setEveryone] = useState(mentions.parse.has("everyone"));
const [allUsers, setAllUsers] = useState(users.size === mentions.meta.userIds.size);
const [allRoles, setAllRoles] = useState(roles.size === mentions.meta.roleIds.size);

View file

@ -18,7 +18,7 @@ import { AllowedMentions, AllowedMentionsBar, AllowedMentionsProps, AllowedMenti
export default definePlugin({
name: "AllowedMentions",
authors: [Devs.arHSM, Devs.amia],
description: "Fine grained control over whom to ping when sending or editing a message.",
description: "Fine grained control over whom to ping when sending a message.",
dependencies: ["MessageEventsAPI"],
settings: definePluginSettings({
pingEveryone: {
@ -39,11 +39,11 @@ export default definePlugin({
}),
patches: [
{
find: ".slateContainer)",
find: ".AnalyticEvents.APPLICATION_COMMAND_VALIDATION_FAILED,",
replacement: [
// Pass type prop to slate wrapper
{
match: /,children:\(0,\i.jsx\)\(\i.\i,{/,
match: /className:\i\(\i,\i.slateContainer\),children:\(0,\i.jsx\)\(\i.\i,{/,
replace: "$& type: arguments[0].type,"
}
]
@ -121,7 +121,7 @@ export default definePlugin({
// Fail creating forum if tooManyUsers or tooManyRoles
{
match: /applyChatRestrictions\)\(\{.+?channel:(\i)\}\);if\(!\i/,
replace: "$& || !$self.validateForum($1.id)"
replace: "$& && !$self.validateForum($1.id)"
}
]
}
@ -217,7 +217,7 @@ export default definePlugin({
},
validateForum(channelId: string) {
const mentions = this.getAllowedMentions(channelId, true);
if (!isNonNullish(mentions)) return;
if (!isNonNullish(mentions)) return true;
if (mentions.meta.tooManyUsers || mentions.meta.tooManyRoles) {
this.tooManyAlert(mentions.meta.tooManyUsers, mentions.meta.tooManyRoles);

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import "./index.css";
import { definePluginSettings } from "@api/Settings";
import { makeRange } from "@components/PluginSettings/components";
@ -35,6 +35,11 @@ export const settings = definePluginSettings({
description: "Scales the buttons to 75% of their original scale, whilst increasing the inner emoji to 125% scale. Emojis will be 93.75% of the original size. Recommended to have a minimum of 5 columns",
type: OptionType.BOOLEAN,
default: false
},
scroll: {
description: "Enable scrolling the list of emojis",
type: OptionType.BOOLEAN,
default: true
}
});
@ -58,7 +63,7 @@ export default definePlugin({
find: "default.Messages.ADD_REACTION_NAMED.format",
replacement: {
match: /(\i)\.length>4&&\((\i)\.length=4\);/,
replace: "$1.length>$self.getMaxQuickReactions()&&($2.length=$self.getMaxQuickReactions());"
replace: "let [betterQuickReactScrollValue,setBetterQuickReactScrollValue]=Vencord.Webpack.Common.React.useState(0);betterQuickReactScrollValue;"
}
},
// Add a custom class to identify the quick reactions have been modified and a CSS variable for the number of columns to display
@ -69,16 +74,38 @@ export default definePlugin({
replace: "className:\"vc-better-quick-react \"+($self.settings.store.compactMode?\"vc-better-quick-react-compact \":\"\")+$1.wrapper,style:{\"--vc-better-quick-react-columns\":$self.settings.store.columns},"
}
},
{
find: "default.Messages.ADD_REACTION_NAMED.format",
replacement: {
match: /children:(\i)\.map\(/,
replace: "onWheel:$self.onWheelWrapper(betterQuickReactScrollValue,setBetterQuickReactScrollValue,$1.length),children:$self.applyScroll($1,betterQuickReactScrollValue).map("
}
},
// MenuGroup doesn't accept styles or anything special by default :/
{
find: "{MenuGroup:function()",
replacement: {
match: /role:"group",/,
replace: "role:\"group\",style:arguments[0].style,"
replace: "role:\"group\",style:arguments[0].style,onWheel:arguments[0].onWheel,"
}
}
],
getMaxQuickReactions() {
return settings.store.rows * settings.store.columns;
},
applyScroll(emojis: any[], index: number) {
return emojis.slice(index, index + this.getMaxQuickReactions());
},
onWheelWrapper(currentScrollValue: number, setScrollHook: (value: number) => void, emojisLength: number) {
if (settings.store.scroll) return (e: WheelEvent) => {
if (e.deltaY === 0 || e.shiftKey) return;
e.stopPropagation(); // does this do anything?
const modifier = e.deltaY < 0 ? -1 : 1;
const newValue = currentScrollValue + (modifier * settings.store.columns);
setScrollHook(Math.max(0, Math.min(newValue, emojisLength - this.getMaxQuickReactions())));
};
},
AddReactionsButton() {
}
});

View file

@ -27,7 +27,7 @@ const settings = definePluginSettings({
default: {
type: OptionType.BOOLEAN,
description: "Enable avatar preview by default.",
default: false
default: true
}
});
@ -61,18 +61,18 @@ const PreviewToggle = () => {
};
export default definePlugin({
name: "BetterShopPreview",
description: "Uses your avatar for avatar decoration previews in the Discord Shop.",
name: "Better Shop Preview",
description: "Uses your avatar for avatar decoration previews in the Discord Shop (without hovering).",
authors: [EquicordDevs.Tolgchu],
settings,
patches: [
{
find: "default.Messages.COLLECTIBLES_SHOP})]})",
replacement: {
match: /(className:\i\.title,children:)(\i\.default\.Messages\.COLLECTIBLES_SHOP)/,
replace: "$1[$2,$self.PreviewToggle()]"
},
},
replacement: [{
match: "{className:en.title,children:er.default.Messages.COLLECTIBLES_SHOP}",
replace: "{className:en.title,children:[er.default.Messages.COLLECTIBLES_SHOP,$self.PreviewToggle()]}"
}]
}
],
PreviewToggle,
async start() {

View file

@ -1,23 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "BlockKrisp",
description: "Prevent Krisp from loading",
authors: [Devs.D3SOX],
patches: [
{
find: "Failed to load Krisp module",
replacement: {
match: /await (\i).default.ensureModule\("discord_krisp"\)/,
replace: "throw new Error();await $1.default.ensureModule(\"discord_krisp\")"
}
}
],
});

View file

@ -0,0 +1,40 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "BlockKrisp",
description: "Prevent Krisp from loading",
authors: [Devs.D3SOX],
patches: [
// Block loading modules on Desktop
{
find: "Failed to load Krisp module",
replacement: {
match: /await (\i).default.ensureModule\("discord_krisp"\)/,
replace: "throw new Error();$&"
}
},
// Block loading modules on Web
{
find: "krisp_browser_models",
replacement: {
match: /getKrispSDK:function\(\)\{/,
replace: "$&return null;"
}
},
// Set Krisp to not supported
{
find: "\"shouldSkipMuteUnmuteSound\"",
replacement: {
match: /isNoiseCancellationSupported\(\)\{/,
replace: "$&return false;"
}
}
],
});

View file

@ -12,11 +12,7 @@ import { getCurrentChannel } from "@utils/discord";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { ChannelStore, Menu, MessageStore, NavigationRouter, PresenceStore, PrivateChannelsStore, UserStore, WindowStore } from "@webpack/common";
import type { Message, User as DiscordUser } from "discord-types/general";
interface User extends DiscordUser {
globalName: string;
}
import type { Message } from "discord-types/general";
interface IMessageCreate {
channelId: string;
@ -39,26 +35,34 @@ function processIds(value: string): string {
}
async function showNotification(message: Message, guildId: string | undefined): Promise<void> {
const channel = ChannelStore.getChannel(message.channel_id);
const channelRegex = /<#(\d{19})>/g;
const userRegex = /<@(\d{18})>/g;
try {
const channel = ChannelStore.getChannel(message.channel_id);
const channelRegex = /<#(\d{19})>/g;
const userRegex = /<@(\d{18})>/g;
message.content = message.content.replace(channelRegex, (match, channelId: string) => {
return `#${ChannelStore.getChannel(channelId)?.name}`;
});
message.content = message.content.replace(channelRegex, (match: any, channelId: string) => {
return `#${ChannelStore.getChannel(channelId)?.name}`;
});
message.content = message.content.replace(userRegex, (match, userId: string) => {
return `@${(UserStore.getUser(userId) as User).globalName}`;
});
message.content = message.content.replace(userRegex, (match: any, userId: string) => {
return `@${(UserStore.getUser(userId) as any).globalName}`;
});
await Notifications.showNotification({
title: `${(message.author as User).globalName} ${guildId ? `(#${channel?.name}, ${ChannelStore.getChannel(channel?.parent_id)?.name})` : ""}`,
body: message.content,
icon: UserStore.getUser(message.author.id).getAvatarURL(undefined, undefined, false),
onClick: function (): void {
NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${message.channel_id}/${message.id}`);
await Notifications.showNotification({
title: `${(message.author as any).globalName} ${guildId ? `(#${channel?.name}, ${ChannelStore.getChannel(channel?.parent_id)?.name})` : ""}`,
body: message.content,
icon: UserStore.getUser(message.author.id).getAvatarURL(undefined, undefined, false),
onClick: function (): void {
NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${message.channel_id}/${message.id}`);
}
});
if (settings.store.notificationSound) {
new Audio("https://discord.com/assets/9422aef94aa931248105.mp3").play();
}
});
} catch (error) {
new Logger("BypassDND").error("Failed to notify user: ", error);
}
}
function ContextCallback(name: "guild" | "user" | "channel"): NavContextMenuPatchCallback {
@ -110,6 +114,11 @@ const settings = definePluginSettings({
allowOutsideOfDms: {
type: OptionType.BOOLEAN,
description: "Allow selected users to bypass DND outside of DMs too (acts like a channel/guild bypass, but it's for all messages sent by the selected users)"
},
notificationSound: {
type: OptionType.BOOLEAN,
description: "Whether the notification sound should be played",
default: true,
}
});
@ -136,7 +145,7 @@ export default definePlugin({
}
}
} catch (error) {
new Logger("BypassDND").error("Failed to handle message", error);
new Logger("BypassDND").error("Failed to handle message: ", error);
}
}
},

View file

@ -1,14 +1,12 @@
/* stylelint-disable indentation */
/* stylelint-disable custom-property-pattern */
:root {
--98-message-color-saturation: /*DYNAMIC*/ ;
--98-message-color-saturation: /*DYNAMIC*/;
}
div[class*="messageContent_"] {
color: color-mix(
in lab,
var(--98-message-color, var(--text-normal))
calc(var(--98-message-color-saturation) * 1%),
var(--98-message-color, var(--text-normal)) calc(var(--98-message-color-saturation) * 1%),
var(--text-normal)
);
)
}

View file

@ -1,78 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { Clipboard, Menu, showToast, Toasts } from "@webpack/common";
interface Emoji {
type: string;
id: string;
name: string;
}
interface Target {
dataset: Emoji;
firstChild: HTMLImageElement;
}
function removeCountingPostfix(name: string): string {
return name.replace(/~\d+$/, "");
}
function getEmojiFormattedString(target: Target): string {
const { dataset } = target;
if (!dataset.id) {
const fiberKey = Object.keys(target).find(key =>
/^__reactFiber\$\S+$/gm.test(key)
);
if (!fiberKey) return `:${dataset.name}:`;
const emojiUnicode =
target[fiberKey]?.child?.memoizedProps?.emoji?.surrogates;
return emojiUnicode || `:${dataset.name}:`;
}
const extension = target?.firstChild.src.match(
/https:\/\/cdn\.discordapp\.com\/emojis\/\d+\.(\w+)/
)?.[1];
const emojiName = removeCountingPostfix(dataset.name);
const emojiId = dataset.id;
return extension === "gif"
? `<a:${emojiName}:${emojiId}>`
: `<:${emojiName}:${emojiId}>`;
}
export default definePlugin({
name: "CopyEmojiAsString",
description: "Add's button to copy emoji as formatted string!",
authors: [EquicordDevs.HAPPY_ENDERMAN, EquicordDevs.VISHNYA_NET_CHERESHNYA],
contextMenus: {
"expression-picker"(children, { target }: { target: Target; }) {
if (target.dataset.type !== "emoji") return;
children.push(
<Menu.MenuItem
id="copy-formatted-string"
key="copy-formatted-string"
label={"Copy as formatted string"}
action={() => {
Clipboard.copy(getEmojiFormattedString(target));
showToast(
"Success! Copied to clipboard as formatted string.",
Toasts.Type.SUCCESS
);
}}
/>
);
},
},
});

View file

@ -0,0 +1,71 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { Clipboard, Menu, React } from "@webpack/common";
interface Emoji {
type: "emoji",
id: string,
name: string;
}
const settings = definePluginSettings({
formattedString: {
type: OptionType.BOOLEAN,
description: "Use formatted string instead of emoji ID.",
default: false
}
});
export default definePlugin({
name: "CopyEmojiID",
description: "Adds button to copy emoji ID!",
authors: [EquicordDevs.HAPPY_ENDERMAN, EquicordDevs.ANIKEIPS],
settings,
expressionPickerPatch(children, props) {
if (!children.find(element => element.props.id === "copy-emoji-id")) {
const data = props.target.dataset as Emoji;
const firstChild = props.target.firstChild as HTMLImageElement;
const isAnimated = firstChild && new URL(firstChild.src).pathname.endsWith(".gif");
if (data.type === "emoji" && data.id) {
children.push((
<Menu.MenuItem
id="copy-emoji-id"
key="copy-emoji-id"
label={settings.store.formattedString ? "Copy as formatted string" : "Copy Emoji ID"}
action={() => {
const formatted_emoji_string = settings.store.formattedString ? `${isAnimated ? "<a:" : "<:"}${data.name}:${data.id}>` : `${data.id}`;
Clipboard.copy(formatted_emoji_string);
}}
/>
));
}
}
},
start() {
addContextMenuPatch("expression-picker", this.expressionPickerPatch);
},
stop() {
removeContextMenuPatch("expression-picker", this.expressionPickerPatch);
}
});

View file

@ -68,6 +68,7 @@ export default definePlugin({
start() {
console.log("Well hello there!, CustomAppIcons has started :)");
const appIcons = JSON.parse(localStorage.getItem("vc_app_icons") ?? "[]");
for (const icon of appIcons) {
findByProps("ICONS", "ICONS_BY_ID").ICONS.push(icon);
@ -99,12 +100,12 @@ export default definePlugin({
<><Forms.FormTitle>
<Forms.FormTitle>How to use?</Forms.FormTitle>
</Forms.FormTitle>
<Forms.FormText>
<Forms.FormText>Go to <Link href="/settings/appearance" onClick={e => { e.preventDefault(); closeAllModals(); FluxDispatcher.dispatch({ type: "USER_SETTINGS_MODAL_SET_SECTION", section: "Appearance" }); }}>Appearance Settings</Link> tab.</Forms.FormText>
<Forms.FormText>Scroll down to "In-app Icons" and click on "Preview App Icon".</Forms.FormText>
<Forms.FormText>And upload your own custom icon!</Forms.FormText>
<Forms.FormText>You can only use links when you are uploading your Custom Icon.</Forms.FormText>
</Forms.FormText></>
<Forms.FormText>
<Forms.FormText>Go to <Link href="/settings/appearance" onClick={e => { e.preventDefault(); closeAllModals(); FluxDispatcher.dispatch({ type: "USER_SETTINGS_MODAL_SET_SECTION", section: "Appearance" }); }}>Appearance Settings</Link> tab.</Forms.FormText>
<Forms.FormText>Scroll down to "In-app Icons" and click on "Preview App Icon".</Forms.FormText>
<Forms.FormText>And upload your own custom icon!</Forms.FormText>
<Forms.FormText>You can only use links when you are uploading your Custom Icon.</Forms.FormText>
</Forms.FormText></>
);
}
});

View file

@ -23,7 +23,7 @@ import {
useState,
} from "@webpack/common";
import { ColorPicker } from "..";
import { ColorPicker, versionData } from "..";
import { knownThemeVars } from "../constants";
import { generateCss, getPreset, gradientPresetIds, PrimarySatDiffs, pureGradientBase } from "../css";
import { Colorway } from "../types";
@ -214,17 +214,23 @@ export default function ({
);
} else {
gradientPresetIds.includes(getPreset()[preset].id) ?
customColorwayCSS = (getPreset(
primaryColor,
secondaryColor,
tertiaryColor,
accentColor
)[preset].preset(discordSaturation) as { full: string; }).full : customColorwayCSS = (getPreset(
primaryColor,
secondaryColor,
tertiaryColor,
accentColor
)[preset].preset(discordSaturation) as string);
customColorwayCSS = `/**
* @name ${colorwayName || "Colorway"}
* @version ${versionData.creatorVersion}
* @description Automatically generated Colorway.
* @author ${UserStore.getCurrentUser().username}
* @authorId ${UserStore.getCurrentUser().id}
* @preset Gradient
*/
${(getPreset(primaryColor, secondaryColor, tertiaryColor, accentColor)[preset].preset(discordSaturation) as { full: string; }).full}` : customColorwayCSS = `/**
* @name ${colorwayName || "Colorway"}
* @version ${versionData.creatorVersion}
* @description Automatically generated Colorway.
* @author ${UserStore.getCurrentUser().username}
* @authorId ${UserStore.getCurrentUser().id}
* @preset ${getPreset()[preset].name}
*/
${(getPreset(primaryColor, secondaryColor, tertiaryColor, accentColor)[preset].preset(discordSaturation) as string)}`;
}
const customColorway: Colorway = {
name: (colorwayName || "Colorway"),
@ -243,7 +249,8 @@ export default function ({
tertiaryColor,
accentColor
)[preset].preset(discordSaturation) as { base: string; }).base : "",
preset: getPreset()[preset].id
preset: getPreset()[preset].id,
creatorVersion: versionData.creatorVersion
};
openModal(props => <SaveColorwayModal modalProps={props} colorways={[customColorway]} onFinish={() => {
modalProps.onClose();

View file

@ -16,10 +16,11 @@ import {
ModalRoot,
openModal,
} from "@utils/modal";
import { saveFile } from "@utils/web";
import { findComponentByCodeLazy } from "@webpack";
import { Button, Clipboard, Forms, Text, TextInput, Toasts, UserStore, useState, useStateFromStores } from "@webpack/common";
import { ColorwayCSS } from "..";
import { ColorwayCSS, versionData } from "..";
import { generateCss, pureGradientBase } from "../css";
import { Colorway } from "../types";
import { colorToHex, stringToHex } from "../utils";
@ -212,6 +213,43 @@ export default function ({
>
Show CSS
</Button>
<Button
color={Button.Colors.PRIMARY}
size={Button.Sizes.MEDIUM}
look={Button.Looks.OUTLINED}
style={{ width: "100%" }}
onClick={() => {
if (!colorway["dc-import"].includes("@name")) {
if (IS_DISCORD_DESKTOP) {
DiscordNative.fileManager.saveWithDialog(`/**
* @name ${colorway.name || "Colorway"}
* @version ${versionData.creatorVersion}
* @description Automatically generated Colorway.
* @author ${UserStore.getCurrentUser().username}
* @authorId ${UserStore.getCurrentUser().id}
*/
${colorway["dc-import"].replace((colorway["dc-import"].match(/\/\*.+\*\//) || [""])[0], "").replaceAll("url(//", "url(https://").replaceAll("url(\"//", "url(\"https://")}`, `${colorway.name.replaceAll(" ", "-").toLowerCase()}.theme.css`);
} else {
saveFile(new File([`/**
* @name ${colorway.name || "Colorway"}
* @version ${versionData.creatorVersion}
* @description Automatically generated Colorway.
* @author ${UserStore.getCurrentUser().username}
* @authorId ${UserStore.getCurrentUser().id}
*/
${colorway["dc-import"].replace((colorway["dc-import"].match(/\/\*.+\*\//) || [""])[0], "").replaceAll("url(//", "url(https://").replaceAll("url(\"//", "url(\"https://")}`], `${colorway.name.replaceAll(" ", "-").toLowerCase()}.theme.css`, { type: "text/plain" }));
}
} else {
if (IS_DISCORD_DESKTOP) {
DiscordNative.fileManager.saveWithDialog(colorway["dc-import"], `${colorway.name.replaceAll(" ", "-").toLowerCase()}.theme.css`);
} else {
saveFile(new File([colorway["dc-import"]], `${colorway.name.replaceAll(" ", "-").toLowerCase()}.theme.css`, { type: "text/plain" }));
}
}
}}
>
Download CSS
</Button>
<Button
color={Button.Colors.PRIMARY}
size={Button.Sizes.MEDIUM}

View file

@ -80,11 +80,11 @@ function SelectorContent({ children, isSettings }: { children: ReactNode, isSett
export default function ({
modalProps,
isSettings,
onSelected
settings = { selectorType: "normal" }
}: {
modalProps: ModalProps,
isSettings?: boolean,
onSelected?: (colorways: Colorway[]) => void;
settings?: { selectorType: "preview" | "multiple-selection" | "normal", previewSource?: string, onSelected?: (colorways: Colorway[]) => void; };
}): JSX.Element | any {
const [colorwayData, setColorwayData] = useState<SourceObject[]>([]);
const [searchValue, setSearchValue] = useState<string>("");
@ -97,6 +97,8 @@ export default function ({
const [viewMode, setViewMode] = useState<"list" | "grid">("grid");
const [showLabelsInSelectorGridView, setShowLabelsInSelectorGridView] = useState<boolean>(false);
const [showSortingMenu, setShowSotringMenu] = useState<boolean>(false);
const [selectedColorways, setSelectedColorways] = useState<Colorway[]>([]);
const [errorCode, setErrorCode] = useState<number>(0);
const { item: radioBarItem, itemFilled: radioBarItemFilled } = findByProps("radioBar");
@ -123,29 +125,44 @@ export default function ({
setViewMode(await DataStore.get("selectorViewMode") as "list" | "grid");
setShowLabelsInSelectorGridView(await DataStore.get("showLabelsInSelectorGridView") as boolean);
setLoaderHeight("0px");
setCustomColorwayData((await DataStore.get("customColorways") as { name: string, colorways: Colorway[], id?: string; }[]).map((colorSrc: { name: string, colorways: Colorway[], id?: string; }) => ({ type: "offline", source: colorSrc.name, colorways: colorSrc.colorways })));
const onlineSources: { name: string, url: string; }[] = await DataStore.get("colorwaySourceFiles") as { name: string, url: string; }[];
if (settings.previewSource) {
const responses: Response[] = await Promise.all(
onlineSources.map((source) =>
fetch(source.url, force ? { cache: "no-store" } : {})
)
);
const res: Response = await fetch(settings.previewSource);
setColorwayData(await Promise.all(
responses
.map((res, i) => ({ response: res, name: onlineSources[i].name }))
.map((res: { response: Response, name: string; }) =>
res.response.json().then(dt => ({ colorways: dt.colorways as Colorway[], source: res.name, type: "online" })).catch(() => ({ colorways: [] as Colorway[], source: res.name, type: "online" }))
)) as { type: "online" | "offline" | "temporary", source: string, colorways: Colorway[]; }[]);
const dataPromise = res.json().then(data => data).catch(() => ({ colorways: [], errorCode: 1, errorMsg: "Colorway Source format is invalid" }));
const data = await dataPromise;
if (data.errorCode) {
setErrorCode(data.errorCode);
}
const colorwayList: Colorway[] = data.css ? data.css.map(customStore => customStore.colorways).flat() : data.colorways;
setColorwayData([{ colorways: colorwayList || [], source: res.url, type: "online" }] as { type: "online" | "offline" | "temporary", source: string, colorways: Colorway[]; }[]);
} else {
setCustomColorwayData((await DataStore.get("customColorways") as { name: string, colorways: Colorway[], id?: string; }[]).map((colorSrc: { name: string, colorways: Colorway[], id?: string; }) => ({ type: "offline", source: colorSrc.name, colorways: colorSrc.colorways })));
const onlineSources: { name: string, url: string; }[] = await DataStore.get("colorwaySourceFiles") as { name: string, url: string; }[];
const responses: Response[] = await Promise.all(
onlineSources.map((source) =>
fetch(source.url, force ? { cache: "no-store" } : {})
)
);
setColorwayData(await Promise.all(
responses
.map((res, i) => ({ response: res, name: onlineSources[i].name }))
.map((res: { response: Response, name: string; }) =>
res.response.json().then(dt => ({ colorways: dt.colorways as Colorway[], source: res.name, type: "online" })).catch(() => ({ colorways: [] as Colorway[], source: res.name, type: "online" }))
)) as { type: "online" | "offline" | "temporary", source: string, colorways: Colorway[]; }[]);
}
}
useEffect(() => {
if (!searchValue) {
loadUI();
}
}, []);
useEffect(() => { loadUI(); }, [searchValue]);
function ReloadPopout(onClose: () => void) {
return (
@ -227,131 +244,136 @@ export default function ({
return (
<SelectorContainer modalProps={modalProps} isSettings={isSettings}>
<SelectorHeader isSettings={isSettings}>
<TextInput
className="colorwaySelector-search"
placeholder="Search for Colorways..."
value={searchValue}
onChange={setSearchValue}
/>
<Tooltip text="Refresh Colorways...">
{({ onMouseEnter, onMouseLeave }) => <Popout
position="bottom"
align="right"
animation={Popout.Animation.NONE}
shouldShow={showReloadMenu}
onRequestClose={() => setShowReloadMenu(false)}
renderPopout={() => ReloadPopout(() => setShowReloadMenu(false))}
>
{(_, { isShown }) => <Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
style={{ marginLeft: "8px" }}
id="colorway-refreshcolorway"
onMouseEnter={isShown ? () => { } : onMouseEnter}
onMouseLeave={isShown ? () => { } : onMouseLeave}
onClick={() => {
setLoaderHeight("2px");
loadUI().then(() => setLoaderHeight("0px"));
}}
onContextMenu={() => { onMouseLeave(); setShowReloadMenu(!showReloadMenu); }}
{settings.selectorType !== "preview" ? <>
<TextInput
className="colorwaySelector-search"
placeholder="Search for Colorways..."
value={searchValue}
onChange={setSearchValue}
/>
<Tooltip text="Refresh Colorways...">
{({ onMouseEnter, onMouseLeave }) => <Popout
position="bottom"
align="right"
animation={Popout.Animation.NONE}
shouldShow={showReloadMenu}
onRequestClose={() => setShowReloadMenu(false)}
renderPopout={() => ReloadPopout(() => setShowReloadMenu(false))}
>
<svg
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
width="20"
height="20"
style={{ padding: "6px", boxSizing: "content-box" }}
viewBox="0 0 24 24"
fill="currentColor"
{(_, { isShown }) => <Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
style={{ marginLeft: "8px" }}
id="colorway-refreshcolorway"
onMouseEnter={isShown ? () => { } : onMouseEnter}
onMouseLeave={isShown ? () => { } : onMouseLeave}
onClick={() => {
setLoaderHeight("2px");
loadUI().then(() => setLoaderHeight("0px"));
}}
onContextMenu={() => { onMouseLeave(); setShowReloadMenu(!showReloadMenu); }}
>
<rect
y="0"
fill="none"
width="24"
height="24"
/>
<path
d="M6.351,6.351C7.824,4.871,9.828,4,12,4c4.411,0,8,3.589,8,8h2c0-5.515-4.486-10-10-10 C9.285,2,6.779,3.089,4.938,4.938L3,3v6h6L6.351,6.351z"
/>
<path
d="M17.649,17.649C16.176,19.129,14.173,20,12,20c-4.411,0-8-3.589-8-8H2c0,5.515,4.486,10,10,10 c2.716,0,5.221-1.089,7.062-2.938L21,21v-6h-6L17.649,17.649z"
/>
</svg>
</Button>}
</Popout>}
</Tooltip>
<Tooltip text="Create Colorway...">
{({ onMouseEnter, onMouseLeave }) => <Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
style={{ marginLeft: "8px" }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={() => openModal((props) => <CreatorModal
modalProps={props}
loadUIProps={loadUI}
/>)}
>
<PlusIcon width={20} height={20} style={{ padding: "6px", boxSizing: "content-box" }} />
</Button>}
</Tooltip>
<Tooltip text="Selector Options">
{({ onMouseEnter, onMouseLeave }) => <Popout
position="bottom"
align="right"
animation={Popout.Animation.NONE}
shouldShow={showSortingMenu}
onRequestClose={() => setShowSotringMenu(false)}
renderPopout={() => SortingPopout(() => setShowSotringMenu(false))}
>
{(_, { isShown }) => <Button
<svg
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
width="20"
height="20"
style={{ padding: "6px", boxSizing: "content-box" }}
viewBox="0 0 24 24"
fill="currentColor"
>
<rect
y="0"
fill="none"
width="24"
height="24"
/>
<path
d="M6.351,6.351C7.824,4.871,9.828,4,12,4c4.411,0,8,3.589,8,8h2c0-5.515-4.486-10-10-10 C9.285,2,6.779,3.089,4.938,4.938L3,3v6h6L6.351,6.351z"
/>
<path
d="M17.649,17.649C16.176,19.129,14.173,20,12,20c-4.411,0-8-3.589-8-8H2c0,5.515,4.486,10,10,10 c2.716,0,5.221-1.089,7.062-2.938L21,21v-6h-6L17.649,17.649z"
/>
</svg>
</Button>}
</Popout>}
</Tooltip>
<Tooltip text="Create Colorway...">
{({ onMouseEnter, onMouseLeave }) => <Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
style={{ marginLeft: "8px" }}
onMouseEnter={isShown ? () => { } : onMouseEnter}
onMouseLeave={isShown ? () => { } : onMouseLeave}
onClick={() => { onMouseLeave(); setShowSotringMenu(!showSortingMenu); }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={() => openModal((props) => <CreatorModal
modalProps={props}
loadUIProps={loadUI}
/>)}
>
<MoreIcon width={20} height={20} style={{ padding: "6px", boxSizing: "content-box" }} />
<PlusIcon width={20} height={20} style={{ padding: "6px", boxSizing: "content-box" }} />
</Button>}
</Popout>}
</Tooltip>
<Tooltip text="Open Color Stealer">
{({ onMouseEnter, onMouseLeave }) => <Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
style={{ marginLeft: "8px" }}
id="colorway-opencolorstealer"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={() => openModal((props) => <ColorPickerModal modalProps={props} />)}
>
<PalleteIcon width={20} height={20} style={{ padding: "6px", boxSizing: "content-box" }} />
</Button>}
</Tooltip>
{isSettings ? <Select
className={"colorwaySelector-sources " + ButtonLooks.OUTLINED + " colorwaySelector-sources_settings"}
look={1}
popoutClassName="colorwaySelector-sourceSelect"
options={filters.map(filter => ({ label: filter.name, value: (filter.id as string) }))}
select={value => setVisibleSources(value)}
isSelected={value => visibleSources === value}
serialize={String}
popoutPosition="bottom" /> : <></>}
</Tooltip>
<Tooltip text="Selector Options">
{({ onMouseEnter, onMouseLeave }) => <Popout
position="bottom"
align="right"
animation={Popout.Animation.NONE}
shouldShow={showSortingMenu}
onRequestClose={() => setShowSotringMenu(false)}
renderPopout={() => SortingPopout(() => setShowSotringMenu(false))}
>
{(_, { isShown }) => <Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
style={{ marginLeft: "8px" }}
onMouseEnter={isShown ? () => { } : onMouseEnter}
onMouseLeave={isShown ? () => { } : onMouseLeave}
onClick={() => { onMouseLeave(); setShowSotringMenu(!showSortingMenu); }}
>
<MoreIcon width={20} height={20} style={{ padding: "6px", boxSizing: "content-box" }} />
</Button>}
</Popout>}
</Tooltip>
<Tooltip text="Open Color Stealer">
{({ onMouseEnter, onMouseLeave }) => <Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
style={{ marginLeft: "8px" }}
id="colorway-opencolorstealer"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={() => openModal((props) => <ColorPickerModal modalProps={props} />)}
>
<PalleteIcon width={20} height={20} style={{ padding: "6px", boxSizing: "content-box" }} />
</Button>}
</Tooltip>
{isSettings ? <Select
className={"colorwaySelector-sources " + ButtonLooks.OUTLINED + " colorwaySelector-sources_settings"}
look={1}
popoutClassName="colorwaySelector-sourceSelect"
options={filters.map(filter => ({ label: filter.name, value: (filter.id as string) }))}
select={value => setVisibleSources(value)}
isSelected={value => visibleSources === value}
serialize={String}
popoutPosition="bottom" /> : <></>}
</> : <Text variant="heading-lg/semibold" tag="h1">
Preview...
</Text>}
</SelectorHeader>
<SelectorContent isSettings={isSettings}>
<div className="colorwaysLoader-barContainer"><div className="colorwaysLoader-bar" style={{ height: loaderHeight }} /></div>
<ScrollerThin style={{ maxHeight: isSettings ? "unset" : "450px" }} className={"ColorwaySelectorWrapper " + (viewMode === "grid" ? "ColorwaySelectorWrapper-grid" : "ColorwaySelectorWrapper-list") + (showLabelsInSelectorGridView ? " colorwaySelector-gridWithLabels" : "")}>
{activeColorwayObject.sourceType === "temporary" && <Tooltip text="Temporary Colorway">
{settings.selectorType === "multiple-selection" && <Forms.FormTitle>Available</Forms.FormTitle>}
<ScrollerThin style={{ maxHeight: settings.selectorType === "multiple-selection" ? "50%" : (isSettings ? "unset" : "450px") }} className={"ColorwaySelectorWrapper " + (viewMode === "grid" ? "ColorwaySelectorWrapper-grid" : "ColorwaySelectorWrapper-list") + (showLabelsInSelectorGridView ? " colorwaySelector-gridWithLabels" : "")}>
{(activeColorwayObject.sourceType === "temporary" && settings.selectorType === "normal" && settings.selectorType === "normal") && <Tooltip text="Temporary Colorway">
{({ onMouseEnter, onMouseLeave }) => <div
className={viewMode === "grid" ? "discordColorway" : `${radioBarItem} ${radioBarItemFilled} discordColorway-listItem`}
id="colorway-Temporary"
@ -407,7 +429,7 @@ export default function ({
</>}
</div>}
</Tooltip>}
{getComputedStyle(document.body).getPropertyValue("--os-accent-color") && ["all", "official"].includes(visibleSources) && "auto".includes(searchValue.toLowerCase()) ? <Tooltip text="Auto">
{getComputedStyle(document.body).getPropertyValue("--os-accent-color") && ["all", "official"].includes(visibleSources) && settings.selectorType === "normal" && "auto".includes(searchValue.toLowerCase()) ? <Tooltip text="Auto">
{({ onMouseEnter, onMouseLeave }) => <div
className={viewMode === "grid" ? "discordColorway" : `${radioBarItem} ${radioBarItemFilled} discordColorway-listItem`}
id="colorway-Auto"
@ -476,6 +498,15 @@ export default function ({
>
No colorways...
</Forms.FormTitle> : <></>}
{errorCode !== 0 && <Forms.FormTitle
style={{
marginBottom: 0,
width: "100%",
textAlign: "center"
}}
>
{errorCode === 1 && "Error: Invalid Colorway Source Format. If this error persists, contact the source author to resolve the issue."}
</Forms.FormTitle>}
{filters.map(filter => filter.id).includes(visibleSources) && (
filters
.filter(filter => filter.id === visibleSources)[0].sources
@ -496,7 +527,7 @@ export default function ({
}
})
.map((color: Colorway) => {
var colors: Array<string> = color.colors || [
const colors: string[] = color.colors || [
"accent",
"primary",
"secondary",
@ -513,45 +544,50 @@ export default function ({
onMouseLeave={viewMode === "grid" ? onMouseLeave : () => { }}
aria-checked={activeColorwayObject.id === color.name && activeColorwayObject.source === color.source}
onClick={async () => {
const [
onDemandWays,
onDemandWaysTintedText,
onDemandWaysDiscordSaturation,
onDemandWaysOsAccentColor
] = await DataStore.getMany([
"onDemandWays",
"onDemandWaysTintedText",
"onDemandWaysDiscordSaturation",
"onDemandWaysOsAccentColor"
]);
if (activeColorwayObject.id === color.name && activeColorwayObject.source === color.source) {
DataStore.set("activeColorwayObject", { id: null, css: null, sourceType: null, source: null });
setActiveColorwayObject({ id: null, css: null, sourceType: null, source: null });
ColorwayCSS.remove();
} else {
if (onDemandWays) {
const demandedColorway = !color.isGradient ? generateCss(
colorToHex(color.primary),
colorToHex(color.secondary),
colorToHex(color.tertiary),
colorToHex(onDemandWaysOsAccentColor ? getComputedStyle(document.body).getPropertyValue("--os-accent-color") : color.accent).slice(0, 6),
onDemandWaysTintedText,
onDemandWaysDiscordSaturation,
undefined,
color.name
) : gradientBase(colorToHex(onDemandWaysOsAccentColor ? getComputedStyle(document.body).getPropertyValue("--os-accent-color") : color.accent), onDemandWaysDiscordSaturation) + `:root:root {--custom-theme-background: linear-gradient(${color.linearGradient})}`;
ColorwayCSS.set(demandedColorway);
setActiveColorwayObject({ id: color.name, css: demandedColorway, sourceType: color.type, source: color.source });
DataStore.set("activeColorwayObject", { id: color.name, css: demandedColorway, sourceType: color.type, source: color.source });
if (settings.selectorType === "normal") {
const [
onDemandWays,
onDemandWaysTintedText,
onDemandWaysDiscordSaturation,
onDemandWaysOsAccentColor
] = await DataStore.getMany([
"onDemandWays",
"onDemandWaysTintedText",
"onDemandWaysDiscordSaturation",
"onDemandWaysOsAccentColor"
]);
if (activeColorwayObject.id === color.name && activeColorwayObject.source === color.source) {
DataStore.set("activeColorwayObject", { id: null, css: null, sourceType: null, source: null });
setActiveColorwayObject({ id: null, css: null, sourceType: null, source: null });
ColorwayCSS.remove();
} else {
ColorwayCSS.set(color["dc-import"]);
setActiveColorwayObject({ id: color.name, css: color["dc-import"], sourceType: color.type, source: color.source });
DataStore.set("activeColorwayObject", { id: color.name, css: color["dc-import"], sourceType: color.type, source: color.source });
if (onDemandWays) {
const demandedColorway = !color.isGradient ? generateCss(
colorToHex(color.primary),
colorToHex(color.secondary),
colorToHex(color.tertiary),
colorToHex(onDemandWaysOsAccentColor ? getComputedStyle(document.body).getPropertyValue("--os-accent-color") : color.accent).slice(0, 6),
onDemandWaysTintedText,
onDemandWaysDiscordSaturation,
undefined,
color.name
) : gradientBase(colorToHex(onDemandWaysOsAccentColor ? getComputedStyle(document.body).getPropertyValue("--os-accent-color") : color.accent), onDemandWaysDiscordSaturation) + `:root:root {--custom-theme-background: linear-gradient(${color.linearGradient})}`;
ColorwayCSS.set(demandedColorway);
setActiveColorwayObject({ id: color.name, css: demandedColorway, sourceType: color.type, source: color.source });
DataStore.set("activeColorwayObject", { id: color.name, css: demandedColorway, sourceType: color.type, source: color.source });
} else {
ColorwayCSS.set(color["dc-import"]);
setActiveColorwayObject({ id: color.name, css: color["dc-import"], sourceType: color.type, source: color.source });
DataStore.set("activeColorwayObject", { id: color.name, css: color["dc-import"], sourceType: color.type, source: color.source });
}
}
}
if (settings.selectorType === "multiple-selection") {
setSelectedColorways([...selectedColorways, color]);
}
}}
>
{viewMode === "list" && <svg aria-hidden="true" role="img" width="24" height="24" viewBox="0 0 24 24">
{(viewMode === "list" && settings.selectorType === "normal") && <svg aria-hidden="true" role="img" width="24" height="24" viewBox="0 0 24 24">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="currentColor" />
{activeColorwayObject.id === color.name && activeColorwayObject.source === color.source && <circle cx="12" cy="12" r="5" className="radioIconForeground-3wH3aU" fill="currentColor" />}
</svg>}
@ -568,11 +604,11 @@ export default function ({
}}
/>}
</div>
<div className="colorwaySelectionCircle">
{settings.selectorType === "normal" && <div className="colorwaySelectionCircle">
{(activeColorwayObject.id === color.name && activeColorwayObject.source === color.source && viewMode === "grid") && <SelectionCircle />}
</div>
</div>}
{(showLabelsInSelectorGridView || viewMode === "list") && <Text className={"colorwayLabel" + ((showLabelsInSelectorGridView && viewMode === "grid") ? " labelInGrid" : "")}>{color.name}</Text>}
<div
{settings.selectorType === "normal" && <div
className="colorwayInfoIconContainer"
onClick={(e) => {
e.stopPropagation();
@ -592,7 +628,7 @@ export default function ({
>
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" />
</svg>
</div>
</div>}
{viewMode === "list" && <>
<Tooltip text="Copy Colorway CSS">
{({ onMouseEnter, onMouseLeave }) => <Button
@ -637,7 +673,7 @@ export default function ({
<IDIcon width={20} height={20} />
</Button>}
</Tooltip>
{color.sourceType === "offline" && <Tooltip text="Delete Colorway">
{(color.sourceType === "offline" && settings.selectorType !== "preview") && <Tooltip text="Delete Colorway">
{({ onMouseEnter, onMouseLeave }) => <Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
@ -672,8 +708,102 @@ export default function ({
})
)}
</ScrollerThin>
{settings.selectorType === "multiple-selection" && <>
<Forms.FormTitle style={{ marginTop: "8px" }}>Selected</Forms.FormTitle>
<ScrollerThin style={{ maxHeight: "50%" }} className={"ColorwaySelectorWrapper " + (viewMode === "grid" ? "ColorwaySelectorWrapper-grid" : "ColorwaySelectorWrapper-list") + (showLabelsInSelectorGridView ? " colorwaySelector-gridWithLabels" : "")}>
{selectedColorways.map((color: Colorway, i: number) => {
const colors: string[] = color.colors || [
"accent",
"primary",
"secondary",
"tertiary",
];
return <Tooltip text={color.name}>
{({ onMouseEnter, onMouseLeave }) => {
return (
<div
className={viewMode === "grid" ? "discordColorway" : `${radioBarItem} ${radioBarItemFilled} discordColorway-listItem`}
id={"colorway-" + color.name}
onMouseEnter={viewMode === "grid" ? onMouseEnter : () => { }}
onMouseLeave={viewMode === "grid" ? onMouseLeave : () => { }}
aria-checked={activeColorwayObject.id === color.name && activeColorwayObject.source === color.source}
onClick={() => setSelectedColorways(selectedColorways.filter((colorway, ii) => ii !== i))}
>
{viewMode === "list" && <svg aria-hidden="true" role="img" width="24" height="24" viewBox="0 0 24 24">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="currentColor" />
{activeColorwayObject.id === color.name && activeColorwayObject.source === color.source && <circle cx="12" cy="12" r="5" className="radioIconForeground-3wH3aU" fill="currentColor" />}
</svg>}
<div className="discordColorwayPreviewColorContainer">
{!color.isGradient ? colors.map((colorItm) => <div
className="discordColorwayPreviewColor"
style={{
backgroundColor: color[colorItm],
}}
/>) : <div
className="discordColorwayPreviewColor"
style={{
background: `linear-gradient(${color.linearGradient})`,
}}
/>}
</div>
<div className="colorwaySelectionCircle">
{(activeColorwayObject.id === color.name && activeColorwayObject.source === color.source && viewMode === "grid") && <SelectionCircle />}
</div>
{(showLabelsInSelectorGridView || viewMode === "list") && <Text className={"colorwayLabel" + ((showLabelsInSelectorGridView && viewMode === "grid") ? " labelInGrid" : "")}>{color.name}</Text>}
{viewMode === "list" && <>
<Tooltip text="Copy Colorway CSS">
{({ onMouseEnter, onMouseLeave }) => <Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={async e => {
e.stopPropagation();
Clipboard.copy(color["dc-import"]);
Toasts.show({
message: "Copied Colorway CSS Successfully",
type: 1,
id: "copy-colorway-css-notify",
});
}}
>
<CodeIcon width={20} height={20} />
</Button>}</Tooltip>
<Tooltip text="Copy Colorway ID">
{({ onMouseEnter, onMouseLeave }) => <Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={async e => {
e.stopPropagation();
const colorwayIDArray = `${color.accent},${color.primary},${color.secondary},${color.tertiary}|n:${color.name}${color.preset ? `|p:${color.preset}` : ""}`;
const colorwayID = stringToHex(colorwayIDArray);
Clipboard.copy(colorwayID);
Toasts.show({
message: "Copied Colorway ID Successfully",
type: 1,
id: "copy-colorway-id-notify",
});
}}
>
<IDIcon width={20} height={20} />
</Button>}
</Tooltip>
</>}
</div>
);
}}
</Tooltip>;
})}
</ScrollerThin>
</>}
</SelectorContent>
{!isSettings ? <ModalFooter>
{(!isSettings && settings.selectorType !== "preview") ? <ModalFooter>
<Button
size={Button.Sizes.MEDIUM}
color={Button.Colors.PRIMARY}

View file

@ -18,7 +18,7 @@ import {
} from "@webpack/common";
import { FluxEvents } from "@webpack/types";
import { versionData } from "../../.";
import { versionData } from "../../";
import { fallbackColorways } from "../../constants";
import { Colorway } from "../../types";

View file

@ -6,7 +6,7 @@
import { DataStore } from "@api/index";
import { Flex } from "@components/Flex";
import { CopyIcon, DeleteIcon } from "@components/Icons";
import { CopyIcon, DeleteIcon, PlusIcon } from "@components/Icons";
import { SettingsTab } from "@components/VencordSettings/shared";
import { Logger } from "@utils/Logger";
import { closeModal, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
@ -66,6 +66,7 @@ function AddOnlineStoreModal({ modalProps, onFinish }: { modalProps: ModalProps,
const [colorwaySourceURL, setColorwaySourceURL] = useState<string>("");
const [nameError, setNameError] = useState<string>("");
const [URLError, setURLError] = useState<string>("");
const [nameReadOnly, setNameReadOnly] = useState<boolean>(false);
return <ModalRoot {...modalProps}>
<ModalHeader separator={false}>
<Text variant="heading-lg/semibold" tag="h1">
@ -79,11 +80,19 @@ function AddOnlineStoreModal({ modalProps, onFinish }: { modalProps: ModalProps,
onChange={setColorwaySourceName}
value={colorwaySourceName}
error={nameError}
readOnly={nameReadOnly}
disabled={nameReadOnly}
/>
<Forms.FormTitle style={{ marginTop: "8px" }}>URL:</Forms.FormTitle>
<TextInput
placeholder="Enter a valid URL..."
onChange={setColorwaySourceURL}
onChange={value => {
setColorwaySourceURL(value);
if (value === defaultColorwaySource) {
setNameReadOnly(true);
setColorwaySourceName("Project Colorway");
}
}}
value={colorwaySourceURL}
error={URLError}
style={{ marginBottom: "16px" }}
@ -120,7 +129,7 @@ function AddOnlineStoreModal({ modalProps, onFinish }: { modalProps: ModalProps,
style={{ marginLeft: 8 }}
color={Button.Colors.PRIMARY}
size={Button.Sizes.MEDIUM}
look={Button.Looks.FILLED}
look={Button.Looks.OUTLINED}
onClick={() => modalProps.onClose()}
>
Cancel
@ -130,14 +139,14 @@ function AddOnlineStoreModal({ modalProps, onFinish }: { modalProps: ModalProps,
}
export default function () {
const [colorwaySourceFiles, setColorwaySourceFiles] = useState<{ name: string, url: string; }[]>();
const [colorwaySourceFiles, setColorwaySourceFiles] = useState<{ name: string, url: string; }[]>([]);
const [customColorwayStores, setCustomColorwayStores] = useState<{ name: string, colorways: Colorway[]; }[]>([]);
const { item: radioBarItem, itemFilled: radioBarItemFilled } = findByProps("radioBar");
useEffect(() => {
(async function () {
setColorwaySourceFiles(await DataStore.get("colorwaySourceFiles"));
setColorwaySourceFiles(await DataStore.get("colorwaySourceFiles") as { name: string, url: string; }[]);
setCustomColorwayStores(await DataStore.get("customColorways") as { name: string, colorways: Colorway[]; }[]);
})();
}, []);
@ -171,8 +180,17 @@ export default function () {
Add...
</Button>
</Flex>
<ScrollerThin orientation="vertical" style={{ maxHeight: "250px" }} className="colorwaysSettings-sourceScroller">
{colorwaySourceFiles?.map((colorwaySourceFile: { name: string, url: string; }, i: number) => <div className={`${radioBarItem} ${radioBarItemFilled} colorwaysSettings-colorwaySource`}>
<ScrollerThin orientation="vertical" style={{ maxHeight: "50%" }} className="colorwaysSettings-sourceScroller">
{!colorwaySourceFiles.length && <div className={`${radioBarItem} ${radioBarItemFilled} colorwaysSettings-colorwaySource`} style={{ flexDirection: "column", padding: "16px", alignItems: "start" }} onClick={() => {
DataStore.set("colorwaySourceFiles", [{ name: "Project Colorway", url: defaultColorwaySource }]);
setColorwaySourceFiles([{ name: "Project Colorway", url: defaultColorwaySource }]);
}}>
<PlusIcon width={24} height={24} />
<Text className="colorwaysSettings-colorwaySourceLabel">
Add Project Colorway Source
</Text>
</div>}
{colorwaySourceFiles.map((colorwaySourceFile: { name: string, url: string; }, i: number) => <div className={`${radioBarItem} ${radioBarItemFilled} colorwaysSettings-colorwaySource`} style={{ flexDirection: "column", padding: "16px", alignItems: "start" }}>
<div className="hoverRoll">
<Text className="colorwaysSettings-colorwaySourceLabel hoverRoll_normal">
{colorwaySourceFile.name} {colorwaySourceFile.url === defaultColorwaySource && <div className="colorways-badge">Built-In</div>}
@ -181,48 +199,50 @@ export default function () {
{colorwaySourceFile.url}
</Text>
</div>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
onClick={() => { Clipboard.copy(colorwaySourceFile.url); }}
>
<CopyIcon width={20} height={20} />
</Button>
{colorwaySourceFile.url !== defaultColorwaySource
&& <>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
onClick={async () => {
openModal(props => <StoreNameModal conflicting={false} modalProps={props} originalName={colorwaySourceFile.name || ""} onFinish={async e => {
const modal = openModal(propss => <ModalRoot {...propss} className="colorwaysLoadingModal"><Spinner style={{ color: "#ffffff" }} /></ModalRoot>);
const res = await fetch(colorwaySourceFile.url);
const data = await res.json();
DataStore.set("customColorways", [...await DataStore.get("customColorways"), { name: e, colorways: data.colorways || [] }]);
setCustomColorwayStores(await DataStore.get("customColorways") as { name: string, colorways: Colorway[]; }[]);
closeModal(modal);
}} />);
}}
>
<DownloadIcon width={20} height={20} />
</Button>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.RED}
look={Button.Looks.OUTLINED}
onClick={async () => {
DataStore.set("colorwaySourceFiles", (await DataStore.get("colorwaySourceFiles") as { name: string, url: string; }[]).filter((src, ii) => ii !== i));
setColorwaySourceFiles((await DataStore.get("colorwaySourceFiles") as { name: string, url: string; }[]).filter((src, ii) => ii !== i));
}}
>
<DeleteIcon width={20} height={20} />
</Button>
</>}
<Flex style={{ marginLeft: "auto", gap: "8px" }}>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.SMALL}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
onClick={() => { Clipboard.copy(colorwaySourceFile.url); }}
>
<CopyIcon width={14} height={14} /> Copy URL
</Button>
{colorwaySourceFile.url !== defaultColorwaySource
&& <>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.SMALL}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
onClick={async () => {
openModal(props => <StoreNameModal conflicting={false} modalProps={props} originalName={colorwaySourceFile.name || ""} onFinish={async e => {
const modal = openModal(propss => <ModalRoot {...propss} className="colorwaysLoadingModal"><Spinner style={{ color: "#ffffff" }} /></ModalRoot>);
const res = await fetch(colorwaySourceFile.url);
const data = await res.json();
DataStore.set("customColorways", [...await DataStore.get("customColorways"), { name: e, colorways: data.colorways || [] }]);
setCustomColorwayStores(await DataStore.get("customColorways") as { name: string, colorways: Colorway[]; }[]);
closeModal(modal);
}} />);
}}
>
<DownloadIcon width={14} height={14} /> Download...
</Button>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
look={Button.Looks.OUTLINED}
onClick={async () => {
DataStore.set("colorwaySourceFiles", (await DataStore.get("colorwaySourceFiles") as { name: string, url: string; }[]).filter((src, ii) => ii !== i));
setColorwaySourceFiles((await DataStore.get("colorwaySourceFiles") as { name: string, url: string; }[]).filter((src, ii) => ii !== i));
}}
>
<DeleteIcon width={14} height={14} /> Remove
</Button>
</>}
</Flex>
</div>
)}
</ScrollerThin>
@ -312,56 +332,56 @@ export default function () {
New...
</Button>
</Flex>
<Flex flexDirection="column" style={{ gap: 0 }}>
{getComputedStyle(document.body).getPropertyValue("--os-accent-color") ? <div className={`${radioBarItem} ${radioBarItemFilled} colorwaysSettings-colorwaySource`}>
<ScrollerThin orientation="vertical" style={{ maxHeight: "50%" }} className="colorwaysSettings-sourceScroller">
{getComputedStyle(document.body).getPropertyValue("--os-accent-color") ? <div className={`${radioBarItem} ${radioBarItemFilled} colorwaysSettings-colorwaySource`} style={{ flexDirection: "column", padding: "16px", alignItems: "start" }}>
<Flex style={{ gap: 0, alignItems: "center", width: "100%", height: "30px" }}>
<Text className="colorwaysSettings-colorwaySourceLabel">OS Accent Color{" "}
<div className="colorways-badge">Built-In</div>
</Text>
</Flex>
</div> : <></>}
{customColorwayStores.map(({ name: customColorwaySourceName, colorways: offlineStoreColorways }) => <div className={`${radioBarItem} ${radioBarItemFilled} colorwaysSettings-colorwaySource`}>
{customColorwayStores.map(({ name: customColorwaySourceName, colorways: offlineStoreColorways }) => <div className={`${radioBarItem} ${radioBarItemFilled} colorwaysSettings-colorwaySource`} style={{ flexDirection: "column", padding: "16px", alignItems: "start" }}>
<Text className="colorwaysSettings-colorwaySourceLabel">
{customColorwaySourceName}
</Text>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
onClick={async () => {
console.log(offlineStoreColorways);
if (IS_DISCORD_DESKTOP) {
DiscordNative.fileManager.saveWithDialog(JSON.stringify({ "name": customColorwaySourceName, "colorways": [...offlineStoreColorways] }), `${customColorwaySourceName.replaceAll(" ", "-").toLowerCase()}.colorways.json`);
} else {
saveFile(new File([JSON.stringify({ "name": customColorwaySourceName, "colorways": [...offlineStoreColorways] })], `${customColorwaySourceName.replaceAll(" ", "-").toLowerCase()}.colorways.json`, { type: "application/json" }));
}
}}
>
<DownloadIcon width={20} height={20} />
</Button>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={Button.Colors.RED}
look={Button.Looks.OUTLINED}
onClick={async () => {
var sourcesArr: { name: string, colorways: Colorway[]; }[] = [];
const customColorwaySources = await DataStore.get("customColorways");
customColorwaySources.map((source: { name: string, colorways: Colorway[]; }) => {
if (source.name !== customColorwaySourceName) {
sourcesArr.push(source);
<Flex style={{ marginLeft: "auto", gap: "8px" }}>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.SMALL}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
onClick={async () => {
if (IS_DISCORD_DESKTOP) {
DiscordNative.fileManager.saveWithDialog(JSON.stringify({ "name": customColorwaySourceName, "colorways": [...offlineStoreColorways] }), `${customColorwaySourceName.replaceAll(" ", "-").toLowerCase()}.colorways.json`);
} else {
saveFile(new File([JSON.stringify({ "name": customColorwaySourceName, "colorways": [...offlineStoreColorways] })], `${customColorwaySourceName.replaceAll(" ", "-").toLowerCase()}.colorways.json`, { type: "application/json" }));
}
});
DataStore.set("customColorways", sourcesArr);
setCustomColorwayStores(sourcesArr);
}}
>
<DeleteIcon width={20} height={20} />
</Button>
}}
>
<DownloadIcon width={14} height={14} /> Export as...
</Button>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
look={Button.Looks.OUTLINED}
onClick={async () => {
var sourcesArr: { name: string, colorways: Colorway[]; }[] = [];
const customColorwaySources = await DataStore.get("customColorways");
customColorwaySources.map((source: { name: string, colorways: Colorway[]; }) => {
if (source.name !== customColorwaySourceName) {
sourcesArr.push(source);
}
});
DataStore.set("customColorways", sourcesArr);
setCustomColorwayStores(sourcesArr);
}}
>
<DeleteIcon width={20} height={20} /> Remove
</Button>
</Flex>
</div>
)}
</Flex>
</ScrollerThin>
</SettingsTab>;
}

View file

@ -9,11 +9,22 @@ import { Flex } from "@components/Flex";
import { DeleteIcon } from "@components/Icons";
import { Link } from "@components/Link";
import { SettingsTab } from "@components/VencordSettings/shared";
import { getTheme, Theme } from "@utils/discord";
import { openModal } from "@utils/modal";
import { findByProps } from "@webpack";
import { Button, ScrollerThin, Text, TextInput, Tooltip, useEffect, useState } from "@webpack/common";
import { StoreItem } from "../../types";
import { DownloadIcon } from "../Icons";
import { DownloadIcon, PalleteIcon } from "../Icons";
import Selector from "../Selector";
const GithubIconLight = "/assets/3ff98ad75ac94fa883af5ed62d17c459.svg";
const GithubIconDark = "/assets/6a853b4c87fce386cbfef4a2efbacb09.svg";
function GithubIcon() {
const src = getTheme() === Theme.Light ? GithubIconLight : GithubIconDark;
return <img src={src} alt="GitHub" />;
}
export default function () {
const [storeObject, setStoreObject] = useState<StoreItem[]>([]);
@ -85,35 +96,52 @@ export default function () {
</Flex>
<ScrollerThin orientation="vertical" className="colorwaysSettings-sourceScroller">
{storeObject.map((item: StoreItem) =>
item.name.toLowerCase().includes(searchValue.toLowerCase()) ? <div className={`${radioBarItem} ${radioBarItemFilled} colorwaysSettings-colorwaySource`}>
<Flex flexDirection="column" style={{ gap: ".5rem" }}>
item.name.toLowerCase().includes(searchValue.toLowerCase()) ? <div className={`${radioBarItem} ${radioBarItemFilled} colorwaysSettings-colorwaySource`} style={{ flexDirection: "column", padding: "16px", alignItems: "start" }}>
<Flex flexDirection="column" style={{ gap: ".5rem", marginBottom: "8px" }}>
<Text className="colorwaysSettings-colorwaySourceLabelHeader">
{item.name}
</Text>
<Text className="colorwaysSettings-colorwaySourceDesc">
{item.description}
</Text>
<Link className="colorwaysSettings-colorwaySourceDesc" href={"https://github.com/" + item.authorGh}>by {item.authorGh}</Link>
<Text className="colorwaysSettings-colorwaySourceDesc" style={{ opacity: ".8" }}>
by {item.authorGh}
</Text>
</Flex>
<Flex style={{ gap: "8px", alignItems: "center", width: "100%" }}>
<Link href={"https://github.com/" + item.authorGh}><GithubIcon /></Link>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.SMALL}
color={colorwaySourceFiles.map(source => source.name).includes(item.name) ? Button.Colors.RED : Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
style={{ marginLeft: "auto" }}
onClick={async () => {
if (colorwaySourceFiles.map(source => source.name).includes(item.name)) {
const sourcesArr: { name: string, url: string; }[] = colorwaySourceFiles.filter(source => source.name !== item.name);
DataStore.set("colorwaySourceFiles", sourcesArr);
setColorwaySourceFiles(sourcesArr);
} else {
const sourcesArr: { name: string, url: string; }[] = [...colorwaySourceFiles, { name: item.name, url: item.url }];
DataStore.set("colorwaySourceFiles", sourcesArr);
setColorwaySourceFiles(sourcesArr);
}
}}
>
{colorwaySourceFiles.map(source => source.name).includes(item.name) ? <><DeleteIcon width={14} height={14} /> Remove</> : <><DownloadIcon width={14} height={14} /> Add to Sources</>}
</Button>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.SMALL}
color={Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
onClick={async () => {
openModal(props => <Selector modalProps={props} settings={{ selectorType: "preview", previewSource: item.url }} />);
}}
>
<PalleteIcon width={14} height={14} />{" "}Preview
</Button>
</Flex>
<Button
innerClassName="colorwaysSettings-iconButtonInner"
size={Button.Sizes.ICON}
color={colorwaySourceFiles.map(source => source.name).includes(item.name) ? Button.Colors.RED : Button.Colors.PRIMARY}
look={Button.Looks.OUTLINED}
onClick={async () => {
if (colorwaySourceFiles.map(source => source.name).includes(item.name)) {
const sourcesArr: { name: string, url: string; }[] = colorwaySourceFiles.filter(source => source.name !== item.name);
DataStore.set("colorwaySourceFiles", sourcesArr);
setColorwaySourceFiles(sourcesArr);
} else {
const sourcesArr: { name: string, url: string; }[] = [...colorwaySourceFiles, { name: item.name, url: item.url }];
DataStore.set("colorwaySourceFiles", sourcesArr);
setColorwaySourceFiles(sourcesArr);
}
}}
>
{colorwaySourceFiles.map(source => source.name).includes(item.name) ? <DeleteIcon width={20} height={20} /> : <DownloadIcon width={20} height={20} />}
</Button>
</div> : <></>
)}
</ScrollerThin>

View file

@ -9,7 +9,7 @@ import { addAccessory, removeAccessory } from "@api/MessageAccessories";
import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList";
import { disableStyle, enableStyle } from "@api/Styles";
import { Flex } from "@components/Flex";
import { Devs, EquicordDevs } from "@utils/constants";
import { Devs } from "@utils/constants";
import { ModalProps, openModal } from "@utils/modal";
import definePlugin from "@utils/types";
import { findByProps } from "@webpack";
@ -112,28 +112,30 @@ export let ColorPicker: React.FunctionComponent<ColorPickerProps> = () => {
})();
export const ColorwayCSS = {
get: () => document.getElementById("activeColorwayCSS")?.textContent || "",
get: () => document.getElementById("activeColorwayCSS")!.textContent || "",
set: (e: string) => {
if (!document.getElementById("activeColorwayCSS")) {
var activeColorwayCSS: HTMLStyleElement =
document.createElement("style");
activeColorwayCSS.id = "activeColorwayCSS";
activeColorwayCSS.textContent = e;
document.head.append(activeColorwayCSS);
document.head.append(Object.assign(document.createElement("style"), {
id: "activeColorwayCSS",
textContent: e
}));
} else document.getElementById("activeColorwayCSS")!.textContent = e;
},
remove: () => document.getElementById("activeColorwayCSS")!.remove(),
};
export const versionData = {
pluginVersion: "5.7.0b1",
pluginVersion: "5.7.0",
creatorVersion: "1.20",
};
export default definePlugin({
name: "DiscordColorways",
description: "A plugin that offers easy access to simple color schemes/themes for Discord, also known as Colorways",
authors: [EquicordDevs.DaBluLite, Devs.ImLvna],
authors: [{
name: "DaBluLite",
id: 582170007505731594n
}, Devs.ImLvna],
dependencies: ["ServerListAPI", "MessageAccessoriesAPI"],
pluginVersion: versionData.pluginVersion,
creatorVersion: versionData.creatorVersion,
@ -368,31 +370,35 @@ export default definePlugin({
</Button>
<Button
onClick={() => {
if (!hexToString(colorID).includes(",")) {
if (!colorID.includes(",")) {
throw new Error("Invalid Colorway ID");
} else {
DataStore.set("activeColorwayObject", {
id: "Temporary Colorway", css: generateCss(
colorToHex(hexToString(colorID).split(/,#/)[1]),
colorToHex(hexToString(colorID).split(/,#/)[2]),
colorToHex(hexToString(colorID).split(/,#/)[3]),
colorToHex(hexToString(colorID).split(/,#/)[0]),
true,
true,
undefined,
"Temporary Colorway"
), sourceType: "temporary", source: null
colorID.split("|").forEach((prop: string) => {
if (prop.includes(",#")) {
DataStore.set("activeColorwayObject", {
id: "Temporary Colorway", css: generateCss(
colorToHex(prop.split(/,#/)[1]),
colorToHex(prop.split(/,#/)[2]),
colorToHex(prop.split(/,#/)[3]),
colorToHex(prop.split(/,#/)[0]),
true,
true,
32,
"Temporary Colorway"
), sourceType: "temporary", source: null
});
ColorwayCSS.set(generateCss(
colorToHex(prop.split(/,#/)[1]),
colorToHex(prop.split(/,#/)[2]),
colorToHex(prop.split(/,#/)[3]),
colorToHex(prop.split(/,#/)[0]),
true,
true,
32,
"Temporary Colorway"
));
}
});
ColorwayCSS.set(generateCss(
colorToHex(hexToString(colorID).split(/,#/)[1]),
colorToHex(hexToString(colorID).split(/,#/)[2]),
colorToHex(hexToString(colorID).split(/,#/)[3]),
colorToHex(hexToString(colorID).split(/,#/)[0]),
true,
true,
undefined,
"Temporary Colorway"
));
}
}}
size={Button.Sizes.SMALL}

View file

@ -918,7 +918,7 @@
gap: 5px;
border-radius: 4px;
box-sizing: border-box;
align-items: start;
align-items: center;
}
.discordColorway-listItem {

View file

@ -20,7 +20,8 @@ export interface Colorway {
sourceType?: "online" | "offline" | "temporary" | null,
source?: string,
linearGradient?: string,
preset?: string;
preset?: string,
creatorVersion: string;
}
export interface ColorPickerProps {

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings, migratePluginSettings } from "@api/Settings";
import { definePluginSettings } from "@api/Settings";
import { disableStyle, enableStyle } from "@api/Styles";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
@ -30,7 +30,8 @@ const settings = definePluginSettings({
default: false,
restartNeeded: false,
onChange: () => {
updateClassList("hoverToView", settings.store.hoverToView);
console.log(settings.store.hoverToView);
updateClassList("hover-to-view", settings.store.hoverToView);
},
},
keybind: {
@ -45,22 +46,22 @@ const settings = definePluginSettings({
default: false,
restartNeeded: false,
onChange: () => {
updateClassList("hideinstreamermode", settings.store.enableForStream);
console.log(settings.store.enableForStream);
updateClassList("hide-in-streamer-mode", settings.store.enableForStream);
},
},
});
migratePluginSettings("DoNotLeak", "Do Not Leak!");
export default definePlugin({
name: "DoNotLeak",
name: "Do Not Leak!",
description: "Hide all message contents and attachments when you're streaming or sharing your screen.",
authors: [EquicordDevs.Perny],
settings,
start() {
document.addEventListener("keyup", keyUpHandler);
document.addEventListener("keydown", keyDownHandler);
updateClassList("hoverToView", settings.store.hoverToView);
updateClassList("hideinstreamermode", settings.store.enableForStream);
updateClassList("hover-to-view", settings.store.hoverToView);
updateClassList("hide-in-streamer-mode", settings.store.enableForStream);
enableStyle(styles);
},
stop() {
@ -72,18 +73,18 @@ export default definePlugin({
function updateClassList(className, condition) {
if (condition) {
document.body.classList.add(className);
document.body.classList.add(`vc-dnl-${className}`);
return;
}
document.body.classList.remove(className);
document.body.classList.remove(`vc-dnl-${className}`);
}
function keyUpHandler(e: KeyboardEvent) {
if (e.key !== settings.store.keybind) return;
document.body.classList.remove("youcanleaknow");
updateClassList("show-messages", false);
}
function keyDownHandler(e: KeyboardEvent) {
if (e.key !== settings.store.keybind) return;
document.body.classList.add("youcanleaknow");
updateClassList("show-messages", true);
}

View file

@ -1,50 +1,75 @@
/* stylelint-disable length-zero-no-unit */
/* stylelint-disable no-descending-specificity */
/* stylelint-disable indentation */
/* stylelint-disable selector-class-pattern */
body.youcanleaknow .attachmentContentItem_ef9fc2 {
filter: blur(0px) brightness(1)!important;
body:has(
div.sidebar_e031be
> section
div.wrapper_e832ee
div.actionButtons__85e3c
> button:nth-child(2).buttonActive_ae686f
)
.messageContent_abea64 {
filter: blur(12px);
}
body.youcanleaknow .messageContent__21e69 {
filter: blur(0px)!important;
body:has(
div.sidebar_e031be
> section
div.wrapper_e832ee
div.actionButtons__85e3c
> button:nth-child(2).buttonActive_ae686f
)
.visualMediaItemContainer__582ad {
filter: blur(50px) brightness(0.1);
}
body.youcanleaknow .embed_d3cbe3 {
filter: blur(0px)!important;
body:has(
div.sidebar_e031be
> section
div.wrapper_e832ee
div.actionButtons__85e3c
> button:nth-child(2).buttonActive_ae686f
)
.embedWrapper__47b23 {
filter: blur(50px);
}
body:has([aria-label="Share Your Screen"].buttonActive__407a7) .attachmentContentItem_ef9fc2 {
filter: blur(30px) brightness(0.1);
body.vc-dnl-hide-in-streamer-mode:has(.notice__5fd4c.colorStreamerMode_cb7f84)
.visualMediaItemContainer__582ad {
filter: blur(50px) brightness(0.1);
}
body:has([aria-label="Share Your Screen"].buttonActive__407a7) .messageContent__21e69 {
filter: blur(6px)
body.vc-dnl-hide-in-streamer-mode:has(.notice__5fd4c.colorStreamerMode_cb7f84)
.messageContent_abea64 {
filter: blur(12px);
}
body:has([aria-label="Share Your Screen"].buttonActive__407a7) .embed_d3cbe3 {
filter: blur(6px)
body.vc-dnl-hide-in-streamer-mode:has(.notice__5fd4c.colorStreamerMode_cb7f84)
.embedWrapper__47b23 {
filter: blur(50px);
}
body.hideinstreamermode:has(.colorStreamerMode_e927c0) .attachmentContentItem_ef9fc2 {
filter: blur(30px) brightness(0.1);
body.vc-dnl-show-messages .visualMediaItemContainer__582ad {
filter: blur(0px) brightness(1) !important;
}
body.hideinstreamermode:has(.colorStreamerMode_e927c0) .messageContent__21e69 {
filter: blur(6px)
body.vc-dnl-show-messages .messageContent_abea64 {
filter: blur(0px) !important;
}
body.hideinstreamermode:has(.colorStreamerMode_e927c0) .embed_d3cbe3 {
filter: blur(6px)
body.vc-dnl-show-messages .embedWrapper__47b23 {
filter: blur(0px) !important;
}
body.hoverToView .messageContent__21e69:hover {
filter: blur(0px) brightness(1)!important;
body.vc-dnl-hover-to-view .messageContent_abea64:hover {
filter: blur(0px) brightness(1) !important;
}
body.hoverToView .embed_d3cbe3:hover {
filter: blur(0px) brightness(1)!important;
body.vc-dnl-hover-to-view .embedWrapper__47b23:hover {
filter: blur(0px) brightness(1) !important;
}
body.hoverToView .attachmentContentItem_ef9fc2:hover {
filter: blur(0px) brightness(1)!important;
body.vc-dnl-hover-to-view .visualMediaItemContainer__582ad:hover {
filter: blur(0px) brightness(1) !important;
}

View file

@ -142,7 +142,7 @@ export default definePlugin({
{
find: "executeMessageComponentInteraction:",
replacement: {
match: /await \i\.HTTP\.post\({url:\i\.Endpoints\.INTERACTIONS,body:\i,timeout:/,
match: /await\s+l\.default\.post\({\s*url:\s*A\.Endpoints\.INTERACTIONS,\s*body:\s*C,\s*timeout:\s*3e3\s*},\s*t\s*=>\s*{\s*h\(T,\s*p,\s*f,\s*t\)\s*}\s*\)/,
replace: "await $self.joinGroup(C);$&"
}
}

View file

@ -0,0 +1,105 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { copyWithToast } from "@utils/misc";
import { Button, showToast, Switch, UserStore, useState } from "@webpack/common";
import { buildFPTE } from "../lib/fpte";
import { useAccentColor, usePrimaryColor, useProfileEffect, useShowPreview } from "../lib/profilePreview";
import { BuilderButton, BuilderColorButton, CustomizationSection, openProfileEffectModal, useAvatarColors } from ".";
export interface BuilderProps {
guildId?: string | undefined;
}
export function Builder({ guildId }: BuilderProps) {
const [primaryColor, setPrimaryColor] = usePrimaryColor(null);
const [accentColor, setAccentColor] = useAccentColor(null);
const [effect, setEffect] = useProfileEffect(null);
const [preview, setPreview] = useShowPreview(true);
const [buildLegacy, setBuildLegacy] = useState(false);
const avatarColors = useAvatarColors(UserStore.getCurrentUser().getAvatarURL(guildId, 80));
return (
<>
<CustomizationSection title="FPTE Builder">
<div style={{ display: "flex", justifyContent: "space-between" }}>
<BuilderColorButton
label="Primary"
color={primaryColor}
setColor={setPrimaryColor}
suggestedColors={avatarColors}
/>
<BuilderColorButton
label="Accent"
color={accentColor}
setColor={setAccentColor}
suggestedColors={avatarColors}
/>
<BuilderButton
label="Effect"
tooltip={effect?.title}
selectedStyle={effect ? {
background: `top / cover url(${effect.thumbnailPreviewSrc}), top / cover url(/assets/f328a6f8209d4f1f5022.png)`
} : undefined}
buttonProps={{
onClick() {
openProfileEffectModal(effect?.id, setEffect);
}
}}
/>
<div
style={{
display: "flex",
alignItems: "center",
flexDirection: "column"
}}
>
<Button
size={Button.Sizes.SMALL}
onClick={() => {
const strToCopy = buildFPTE(primaryColor ?? -1, accentColor ?? -1, effect?.id ?? "", buildLegacy);
if (strToCopy)
copyWithToast(strToCopy, "FPTE copied to clipboard!");
else
showToast("FPTE Builder is empty; nothing to copy!");
}}
>
Copy FPTE
</Button>
<Button
look={Button.Looks.LINK}
color={Button.Colors.PRIMARY}
size={Button.Sizes.SMALL}
style={primaryColor === null && accentColor === null && !effect ? { visibility: "hidden" } : undefined}
onClick={() => {
setPrimaryColor(null);
setAccentColor(null);
setEffect(null);
}}
>
Reset
</Button>
</div>
</div>
</CustomizationSection>
<Switch
value={preview}
onChange={setPreview}
>
FPTE Builder Preview
</Switch>
<Switch
value={buildLegacy}
note="Will use more characters"
onChange={setBuildLegacy}
>
Build backwards compatible FPTE
</Switch>
</>
);
}

View file

@ -1,62 +1,63 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Text, Tooltip } from "@webpack/common";
import type { CSSProperties } from "react";
import type { ComponentProps } from "react";
interface BuilderButtonProps {
label?: string;
tooltip?: string;
selectedStyle?: CSSProperties;
onClick?: () => void;
export interface BuilderButtonProps {
label?: string | undefined;
tooltip?: string | undefined;
selectedStyle?: ComponentProps<"div">["style"];
buttonProps?: ComponentProps<"div"> | undefined;
}
export function BuilderButton({ label, tooltip, selectedStyle, onClick }: BuilderButtonProps) {
return (
<Tooltip text={tooltip} shouldShow={!!tooltip}>
{({ onMouseLeave, onMouseEnter }) => (
<div style={{ width: "60px" }}>
<div
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
role="button"
tabIndex={0}
style={{
...selectedStyle || { border: "2px dashed var(--header-secondary)" },
borderRadius: "4px",
cursor: "pointer",
display: "grid",
height: "60px",
placeItems: "center"
}}
onClick={onClick}
>
{!selectedStyle && (
<svg
fill="var(--header-secondary)"
width="40%"
height="40%"
viewBox="0 0 144 144"
>
<path d="M144 64H80V0H64v64H0v16h64v64h16V80h64Z" />
</svg>
)}
</div>
{!!label && (
<Text
color="header-secondary"
variant="text-xs/normal"
tag="div"
style={{ textAlign: "center" }}
export const BuilderButton = ({ label, tooltip, selectedStyle, buttonProps }: BuilderButtonProps) => (
<Tooltip text={tooltip} shouldShow={!!tooltip}>
{tooltipProps => (
<div style={{ width: "60px" }}>
<div
{...tooltipProps}
{...buttonProps}
aria-label={label}
role="button"
tabIndex={0}
style={{
...selectedStyle ?? { border: "2px dashed var(--header-secondary)" },
display: "grid",
placeItems: "center",
height: "60px",
borderRadius: "4px",
cursor: "pointer"
}}
>
{!selectedStyle && (
<svg
fill="var(--header-secondary)"
width="40%"
height="40%"
viewBox="0 0 144 144"
>
{label}
</Text>
<path d="M144 64H80V0H64v64H0v16h64v64h16V80h64Z" />
</svg>
)}
</div>
)}
</Tooltip>
);
}
{!!label && (
<Text
color="header-secondary"
variant="text-xs/normal"
tag="div"
style={{
marginTop: "4px",
textAlign: "center"
}}
>
{label}
</Text>
)}
</div>
)}
</Tooltip>
);

View file

@ -0,0 +1,41 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Popout } from "@webpack/common";
import { BuilderButton, type BuilderButtonProps, CustomColorPicker, type CustomColorPickerProps } from ".";
export interface BuilderColorButtonProps extends Pick<BuilderButtonProps, "label">, Pick<CustomColorPickerProps, "suggestedColors"> {
color: number | null;
setColor: (color: number | null) => void;
}
export const BuilderColorButton = ({ label, color, setColor, suggestedColors }: BuilderColorButtonProps) => (
<Popout
position="bottom"
renderPopout={() => (
<CustomColorPicker
value={color}
onChange={setColor}
showEyeDropper={true}
suggestedColors={suggestedColors}
/>
)}
>
{popoutProps => {
const hexColor = color?.toString(16).padStart(6, "0").padStart(7, "#");
return (
<BuilderButton
label={label}
tooltip={hexColor}
selectedStyle={hexColor ? { background: hexColor } : undefined}
buttonProps={popoutProps}
/>
);
}}
</Popout>
);

View file

@ -1,95 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { closeModal, ModalCloseButton, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Flex, Text, useRef, useState } from "@webpack/common";
import type { ColorPicker } from "../types";
interface ColorPickerModalProps {
modalProps: ModalProps;
ColorPicker: ColorPicker;
onClose: () => void;
onSubmit: (v: number) => void;
initialColor: number;
suggestedColors: string[];
}
export function ColorPickerModal({ modalProps, ColorPicker, onClose, onSubmit, initialColor = 0, suggestedColors = [] }: ColorPickerModalProps) {
const [color, setColor] = useState(initialColor);
const [pos, setPos] = useState<[number, number]>([-1, -1]);
const header = useRef<HTMLDivElement>(null);
return (
<div
style={{
position: pos[0] === -1 || pos[1] === -1 ? "revert" : "fixed",
left: `clamp(0px, ${pos[0]}px, calc(100vw - ${header.current?.getBoundingClientRect().width ?? 0}px))`,
top: `clamp(22px, ${pos[1]}px, calc(100vh - ${header.current?.getBoundingClientRect().height ?? 0}px))`
}}
>
<ModalRoot {...modalProps} size={ModalSize.DYNAMIC}>
<style>{":has(>:not([class*=hidden__]) [class*=customColorPicker__])>[class*=backdrop__]{display:none!important}[class*=root_] [class*=customColorPicker__]{border:none!important;box-shadow:none!important}"}</style>
<div
ref={header}
style={{ cursor: "move" }}
onMouseDown={e => {
const ref = header.current;
if (ref === null) return;
const rect = ref.getBoundingClientRect();
const offsetX = e.pageX - rect.left;
const offsetY = e.pageY - rect.top;
const onDrag = (e: MouseEvent) => setPos([e.pageX - offsetX, e.pageY - offsetY]);
document.addEventListener("mousemove", onDrag);
document.addEventListener("mouseup",
() => { document.removeEventListener("mousemove", onDrag); },
{ once: true }
);
}}
>
<ModalHeader justify={Flex.Justify.BETWEEN}>
<Text color="header-primary" variant="heading-lg/semibold" tag="h1">
Color Picker
</Text>
<div onMouseDown={e => e.stopPropagation()}>
<ModalCloseButton onClick={onClose} />
</div>
</ModalHeader>
</div>
<ColorPicker
value={color}
showEyeDropper={true}
suggestedColors={suggestedColors}
onChange={(e: number) => setColor(e)}
/>
<ModalFooter>
<Button onClick={() => onSubmit(color)}>
Apply
</Button>
</ModalFooter>
</ModalRoot>
</div>
);
}
export function openColorPickerModal(
ColorPicker: ColorPicker,
onSubmit: (v: number) => void,
initialColor: number = 0,
suggestedColors: string[] = []
) {
const key = openModal(modalProps =>
<ColorPickerModal
modalProps={modalProps}
ColorPicker={ColorPicker}
onClose={() => closeModal(key)}
onSubmit={onSubmit}
initialColor={initialColor}
suggestedColors={suggestedColors}
/>
);
return key;
}

View file

@ -1,103 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Flex, showToast, Text, useState } from "@webpack/common";
import type { ProfileEffect } from "../types";
interface ProfileEffectModalProps {
modalProps: ModalProps;
onClose: () => void;
onSubmit: (v: ProfileEffect) => void;
classNames: { [k: string]: string; };
profileEffects: ProfileEffect[];
initialEffectID?: string;
}
export function ProfileEffectModal({ modalProps, onClose, onSubmit, profileEffects, classNames = {}, initialEffectID }: ProfileEffectModalProps) {
const [selected, setSelected] = useState(initialEffectID ? profileEffects.findIndex(e => e.id === initialEffectID) : -1);
return (
<ModalRoot {...modalProps} size={ModalSize.SMALL}>
<ModalHeader justify={Flex.Justify.BETWEEN}>
<Text color="header-primary" variant="heading-lg/semibold" tag="h1">
Add Profile Effect
</Text>
<ModalCloseButton onClick={onClose} />
</ModalHeader>
<ModalContent
paddingFix={false}
style={{
display: "flex",
flexWrap: "wrap",
gap: "12px",
justifyContent: "center",
padding: "16px 8px 16px 16px"
}}
>
{profileEffects.map((e, i) => (
<div
className={classNames.effectGridItem + (i === selected ? " " + classNames.selected : "")}
role="button"
tabIndex={0}
style={{ width: "80px", height: "80px" }}
onClick={() => setSelected(i)}
>
<img
className={classNames.presetEffectBackground}
src="/assets/f328a6f8209d4f1f5022.png"
alt={e.accessibilityLabel}
/>
<img
className={classNames.presetEffectImg}
src={e.thumbnailPreviewSrc}
alt={e.title}
/>
</div>
))}
</ModalContent>
<ModalFooter
justify={Flex.Justify.BETWEEN}
direction={Flex.Direction.HORIZONTAL}
align={Flex.Align.CENTER}
>
<Text color="header-primary" variant="heading-lg/semibold" tag="h1">
{selected === -1 ? "" : profileEffects[selected].title}
</Text>
<Button
onClick={() => {
if (selected !== -1)
onSubmit(profileEffects[selected]);
else
showToast("No effect selected!");
}}
>
Apply
</Button>
</ModalFooter>
</ModalRoot>
);
}
export function openProfileEffectModal(
onSubmit: (v: ProfileEffect) => void,
profileEffects: ProfileEffect[],
classNames: { [k: string]: string; } = {},
initialEffectID?: string
) {
const key = openModal(modalProps =>
<ProfileEffectModal
modalProps={modalProps}
onClose={() => closeModal(key)}
onSubmit={onSubmit}
profileEffects={profileEffects}
classNames={classNames}
initialEffectID={initialEffectID}
/>
);
return key;
}

View file

@ -0,0 +1,85 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { type ModalProps, openModal } from "@utils/modal";
import { extractAndLoadChunksLazy } from "@webpack";
import type { ComponentType, FunctionComponent, PropsWithChildren, ReactNode } from "react";
import type { ProfileEffectConfig } from "../lib/profileEffects";
export * from "./Builder";
export * from "./BuilderButton";
export * from "./BuilderColorButton";
export * from "./settingsAboutComponent";
export interface CustomColorPickerProps {
value?: number | null | undefined;
onChange: (color: number) => void;
onClose?: (() => void) | undefined;
suggestedColors?: string[] | undefined;
middle?: ReactNode;
footer?: ReactNode;
showEyeDropper?: boolean | undefined;
}
export let CustomColorPicker: ComponentType<CustomColorPickerProps> = () => null;
export function setCustomColorPicker(comp: typeof CustomColorPicker) {
CustomColorPicker = comp;
}
export let useAvatarColors: (avatarURL: string, fillerColor?: string | undefined, desaturateColors?: boolean | undefined) => string[] = () => [];
export function setUseAvatarColors(hook: typeof useAvatarColors) {
useAvatarColors = hook;
}
export interface CustomizationSectionProps {
title?: ReactNode;
titleIcon?: ReactNode;
titleId?: string | undefined;
description?: ReactNode;
className?: string | undefined;
errors?: string[] | undefined;
disabled?: boolean | undefined;
hideDivider?: boolean | undefined;
showBorder?: boolean | undefined;
borderType?: "limited" | "premium" | undefined;
hasBackground?: boolean | undefined;
forcedDivider?: boolean | undefined;
showPremiumIcon?: boolean | undefined;
}
export let CustomizationSection: ComponentType<PropsWithChildren<CustomizationSectionProps>> = () => null;
export function setCustomizationSection(comp: typeof CustomizationSection) {
CustomizationSection = comp;
}
export interface ProfileEffectModalProps extends ModalProps {
initialSelectedEffectId?: string | undefined;
onApply: (effect: ProfileEffectConfig | null) => void;
}
export let ProfileEffectModal: FunctionComponent<ProfileEffectModalProps> = () => null;
export function setProfileEffectModal(comp: typeof ProfileEffectModal) {
ProfileEffectModal = comp;
}
const requireProfileEffectModal = extractAndLoadChunksLazy(["openProfileEffectModal:function(){"]);
export function openProfileEffectModal(initialEffectId: ProfileEffectModalProps["initialSelectedEffectId"], onApply: ProfileEffectModalProps["onApply"]) {
requireProfileEffectModal().then(() => {
openModal(modalProps => (
<ProfileEffectModal
{...modalProps}
initialSelectedEffectId={initialEffectId}
onApply={onApply}
/>
));
});
}

View file

@ -0,0 +1,29 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Margins } from "@utils/margins";
import { Forms } from "@webpack/common";
export const settingsAboutComponent = () => (
<Forms.FormSection>
<Forms.FormTitle tag="h3">Usage</Forms.FormTitle>
<Forms.FormText>
After enabling this plugin, you will see custom theme colors and effects in the profiles of other people using this plugin.
<div className={Margins.top8}>
<b>To set your own profile theme colors and effect:</b>
</div>
<ol
className={Margins.bottom8}
style={{ listStyle: "decimal", paddingLeft: "40px" }}
>
<li>Go to your profile settings</li>
<li>Use the FPTE Builder to choose your profile theme colors and effect</li>
<li>Click the "Copy FPTE" button</li>
<li>Paste the invisible text anywhere in your About Me</li>
</ol>
</Forms.FormText>
</Forms.FormSection>
);

View file

@ -1,574 +1,235 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import { Margins } from "@utils/margins";
import { copyWithToast } from "@utils/misc";
import { closeModal } from "@utils/modal";
import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches";
import definePlugin, { OptionType } from "@utils/types";
import { Button, FluxDispatcher, Forms, RestAPI, showToast, Switch, Toasts, useEffect, useRef, UserStore, useState } from "@webpack/common";
import { useMemo } from "@webpack/common";
import { BuilderButton } from "./components/BuilderButton";
import { openColorPickerModal } from "./components/ColorPickerModal";
import { openProfileEffectModal } from "./components/ProfileEffectModal";
import type { ColorPicker, CustomizationSection, ProfileEffect, RGBColor, UserProfile } from "./types";
import { Builder, type BuilderProps, setCustomColorPicker, setCustomizationSection, setProfileEffectModal, settingsAboutComponent, setUseAvatarColors } from "./components";
import { ProfileEffectRecord, ProfileEffectStore, setProfileEffectRecord, setProfileEffectStore } from "./lib/profileEffects";
import { profilePreviewHook } from "./lib/profilePreview";
import { decodeAboutMeFPTEHook } from "./lib/userProfile";
let CustomizationSection: CustomizationSection = () => null;
let ColorPicker: ColorPicker = () => null;
let getPaletteForAvatar = (v: string) => Promise.resolve<RGBColor[]>([]);
let getComplimentaryPaletteForColor = (v: RGBColor): RGBColor[] => [];
const profileEffectModalClassNames: { [k: string]: string; } = {};
let [primaryColor, setPrimaryColor] = [-1, (v: number) => { }];
let [accentColor, setAccentColor] = [-1, (v: number) => { }];
let [effect, setEffect]: [ProfileEffect | null, (v: ProfileEffect | null) => void] = [null, () => { }];
let [preview, setPreview] = [true, (v: boolean) => { }];
/**
* Builds a profile theme color string in the legacy format, [#primary,#accent] where
* primary and accent are base-16 24-bit colors, with each code point offset by +0xE0000
* @param primary The base-10 24-bit primary color to be encoded
* @param accent The base-10 24-bit accent color to be encoded
* @returns The legacy encoded profile theme color string
*/
function encodeColorsLegacy(primary: number, accent: number) {
return String.fromCodePoint(...[...`[#${primary.toString(16)},#${accent.toString(16)}]`]
.map(c => c.codePointAt(0)! + 0xE0000));
function replaceHelper(string: string, replaceArgs: [searchRegExp: RegExp, replaceString: string][]) {
let result = string;
replaceArgs.forEach(([searchRegExp, replaceString]) => {
const beforeReplace = result;
result = result.replace(
canonicalizeMatch(searchRegExp),
canonicalizeReplace(replaceString, "FakeProfileThemesAndEffects") as string
);
if (beforeReplace === result)
throw new Error("Replace had no effect: " + searchRegExp);
});
return result;
}
/**
* Extracts profile theme colors from given legacy-format string
* @param str The legacy-format string to extract profile theme colors from
* @returns The profile theme colors. Colors will be -1 if not found.
*/
function decodeColorsLegacy(str: string): [number, number] {
const colors = str.matchAll(/(?<=#)[\dA-Fa-f]{1,6}/g);
return [parseInt(colors.next().value?.[0], 16) || -1, parseInt(colors.next().value?.[0], 16) || -1];
}
/**
* Converts the given base-10 24-bit color to a base-4096 string with each code point offset by +0xE0000
* @param color The base-10 24-bit color to be converted
* @returns The converted base-4096 string with +0xE0000 offset
*/
function encodeColor(color: number) {
if (color === 0) return "\u{e0000}";
let str = "";
for (; color > 0; color = Math.trunc(color / 4096))
str = String.fromCodePoint(color % 4096 + 0xE0000) + str;
return str;
}
/**
* Converts the given no-offset base-4096 string to a base-10 24-bit color
* @param str The no-offset base-4096 string to be converted
* @returns The converted base-10 24-bit color
* Will be -1 if the given string is empty and -2 if greater than the maximum 24-bit color, 16,777,215
*/
function decodeColor(str: string) {
if (str === "") return -1;
let color = 0;
for (let i = 0; i < str.length; i++) {
if (color > 16_777_215) return -2;
color += str.codePointAt(i)! * 4096 ** (str.length - 1 - i);
}
return color;
}
/**
* Converts the given base-10 profile effect ID to a base-4096 string with each code point offset by +0xE0000
* @param id The base-10 profile effect ID to be converted
* @returns The converted base-4096 string with +0xE0000 offset
*/
function encodeEffect(id: bigint) {
if (id === 0n) return "\u{e0000}";
let str = "";
for (; id > 0n; id /= 4096n)
str = String.fromCodePoint(Number(id % 4096n) + 0xE0000) + str;
return str;
}
/**
* Converts the given no-offset base-4096 string to a base-10 profile effect ID
* @param str The no-offset base-4096 string to be converted
* @returns The converted base-10 profile effect ID
* Will be -1n if the given string is empty and -2n if greater than the maximum profile effect ID, 1.2 quintillion
*/
function decodeEffect(str: string) {
if (str === "") return -1n;
let id = 0n;
for (let i = 0; i < str.length; i++) {
if (id > 1_200_000_000_000_000_000n) return -2n;
id += BigInt(str.codePointAt(i)!) * 4096n ** BigInt(str.length - 1 - i);
}
return id;
}
/**
* Builds a FPTE string containing the given primary / accent colors and effect ID. If the FPTE Builder is NOT set to
* backwards compatibility mode, the primary and accent colors will be converted to base-4096 before they are encoded.
* @param primary The primary profile theme color. Must be -1 if unset.
* @param accent The accent profile theme color. Must be -1 if unset.
* @param effect The profile effect ID. Must be empty if unset.
* @param legacy Whether the primary and accent colors should be legacy encoded
* @returns The built FPTE string. Will be empty if the given colors and effect are all unset.
*/
function buildFPTE(primary: number, accent: number, effect: string, legacy: boolean) {
const DELIM = "\u200b"; // The FPTE delimiter (zero-width space)
let fpte = ""; // The FPTE string to be returned
// If the FPTE Builder is set to backwards compatibility mode,
// the primary and accent colors, if set, will be legacy encoded.
if (legacy) {
// Legacy FPTE strings must include both the primary and accent colors even if they are the same.
if (primary !== -1) {
// If both the primary and accent colors are set, they will be legacy encoded and added to the
// string; otherwise, if the accent color is unset, the primary color will be used in its place.
if (accent !== -1)
fpte = encodeColorsLegacy(primary, accent);
else
fpte = encodeColorsLegacy(primary, primary);
// If the effect ID is set, it will be encoded and added to the string prefixed by one delimiter.
if (effect !== "")
fpte += DELIM + encodeEffect(BigInt(effect));
return fpte;
}
// Since the primary color is unset, the accent color, if set, will be used in its place.
if (accent !== -1) {
fpte = encodeColorsLegacy(accent, accent);
// If the effect ID is set, it will be encoded and added to the string prefixed by one delimiter.
if (effect !== "")
fpte += DELIM + encodeEffect(BigInt(effect));
return fpte;
}
}
// If the primary color is set, it will be encoded and added to the string.
else if (primary !== -1) {
fpte = encodeColor(primary);
// If the accent color is set and different from the primary color, it
// will be encoded and added to the string prefixed by one delimiter.
if (accent !== -1 && primary !== accent) {
fpte += DELIM + encodeColor(accent);
// If the effect ID is set, it will be encoded and added to the string prefixed by one delimiter.
if (effect !== "")
fpte += DELIM + encodeEffect(BigInt(effect));
return fpte;
}
}
// If only the accent color is set, it will be encoded and added to the string.
else if (accent !== -1)
fpte = encodeColor(accent);
// Since either the primary / accent colors are the same, both are unset, or just one is set, only one color will be added
// to the string; therefore, the effect ID, if set, will be encoded and added to the string prefixed by two delimiters.
if (effect !== "")
fpte += DELIM + DELIM + encodeEffect(BigInt(effect));
return fpte;
}
/**
* Extracts the delimiter-separated values of the first FPTE string found in the given string
* @param str The string to be searched for a FPTE string
* @returns An array of the extracted FPTE string's values. Values will be empty if not found.
*/
function extractFPTE(str: string) {
const fpte: [string, string, string] = ["", "", ""]; // The array containing extracted FPTE values
let i = 0; // The current index of fpte getting extracted
for (const char of str) {
const cp = char.codePointAt(0)!; // The current character's code point
// If the current character is a delimiter, then the current index of fpte has been completed.
if (cp === 0x200B) {
// If the current index of fpte is the last, then the extraction is done.
if (i >= 2) break;
i++; // Start extracting the next index of fpte
}
// If the current character is not a delimiter but a valid FPTE
// character, it will be added to the current index of fpte.
else if (cp >= 0xE0000 && cp <= 0xE0FFF)
fpte[i] += String.fromCodePoint(cp - 0xE0000);
// If an FPTE string has been found and its end has been reached, then the extraction is done.
else if (i > 0 || fpte[0] !== "") break;
}
return fpte;
}
/**
* Converts the given RGB color to a hexadecimal string
* @param rgb The RGB color to be converted
* @returns The converted hexadecimal string
* @example
* // returns #ff0000
* RGBtoHex([255, 0, 0])
*/
function RGBtoHex(rgb: RGBColor) {
return "#" + ((rgb[0] << 16) + (rgb[1] << 8) + rgb[2]).toString(16).padStart(6, "0");
}
function getSuggestedColors(callback: (v: string[]) => void) {
const user = UserStore.getCurrentUser();
getPaletteForAvatar(`https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.webp?size=80`)
.then(avatarColors => {
callback([
...avatarColors.slice(0, 2),
...getComplimentaryPaletteForColor(avatarColors[0]).slice(0, 3)
].map(e => RGBtoHex(e)));
})
.catch(e => {
console.error(e);
showToast("Unable to retrieve suggested colors.", Toasts.Type.FAILURE);
callback([]);
});
}
function fetchProfileEffects(callback: (v: ProfileEffect[]) => void) {
RestAPI.get({ url: "/user-profile-effects" })
.then(res => callback(res.body.profile_effect_configs))
.catch(e => {
console.error(e);
showToast("Unable to retrieve the list of profile effects.", Toasts.Type.FAILURE);
});
}
function updateUserThemeColors(user: UserProfile, primary: number, accent: number) {
if (primary > -1) {
user.themeColors = [primary, accent > -1 ? accent : primary];
user.premiumType = 2;
} else if (accent > -1) {
user.themeColors = [accent, accent];
user.premiumType = 2;
}
}
function updateUserEffectId(user: UserProfile, id: bigint) {
if (id > -1n) {
user.profileEffectId = id.toString();
user.premiumType = 2;
}
}
function updatePreview() {
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SUBMIT_SUCCESS" });
}
const settings = definePluginSettings({
export const settings = definePluginSettings({
prioritizeNitro: {
description: "Source to use if profile theme colors / effects are set by both Nitro and About Me",
description: "Source to prioritize",
type: OptionType.SELECT,
options: [
{ label: "Nitro", value: true },
{ label: "About Me", value: false, default: true },
{ label: "About Me", value: false, default: true }
]
},
hideBuilder: {
description: "Hide the FPTE Builder in the profiles settings page",
description: "Hide the FPTE Builder in the User Profile and Server Profiles settings pages",
type: OptionType.BOOLEAN,
default: false
}
});
export default definePlugin({
name: "FakeProfileThemes",
name: "FakeProfileThemesAndEffects",
description: "Allows profile theming and the usage of profile effects by hiding the colors and effect ID in your About Me using invisible, zero-width characters",
authors: [EquicordDevs.ryan],
patches: [
// Patches UserProfileStore.getUserProfile()
{
find: '"UserProfileStore"',
replacement: {
match: /(?<=getUserProfile\(\i\){return )\i\[\i](?=})/,
replace: "$self.decodeUserBioFPTEHook($&)"
}
},
{
find: '"DefaultCustomizationSections"',
replacement: {
match: /\.sectionsContainer,children:\[/,
replace: "$&$self.addFPTEBuilder(),"
}
},
{
find: ".customizationSectionBackground",
replacement: {
match: /default:function\(\){return (\i)}.*?;/,
replace: "$&$self.CustomizationSection=$1;"
}
},
{
find: "CustomColorPicker:function(){",
replacement: {
match: /CustomColorPicker:function\(\){return (\i)}.*? \1=(?=[^=])/,
replace: "$&$self.ColorPicker="
}
},
{
find: "getPaletteForAvatar:function(){",
replacement: {
match: /getPaletteForAvatar:function\(\){return (\i)}.*? \1=(?=[^=])/,
replace: "$&$self.getPaletteForAvatar="
}
},
{
find: "getComplimentaryPaletteForColor:function(){",
replacement: {
match: /getComplimentaryPaletteForColor:function\(\){return (\i)}.*?;/,
replace: "$&$self.getComplimentaryPaletteForColor=$1;"
}
},
{
find: 'effectGridItem:"',
noWarn: true,
replacement: {
match: /(\i):"(.+?)"/g,
replace: (m, k, v) => { profileEffectModalClassNames[k] = v; return m; }
replace: "$self.decodeAboutMeFPTEHook($&)"
}
},
// Patches ProfileCustomizationPreview
{
find: '"ProfileCustomizationPreview"',
replacement: {
match: /let{(?=(?:[^}]+,)?pendingThemeColors:)(?=(?:[^}]+,)?pendingProfileEffectId:)[^}]+}=(\i)[,;]/,
match: /(?:var|let|const){(?=(?:[^}]+,)?pendingThemeColors:)(?:[^}]+,)?pendingProfileEffectId:[^}]+}=(\i)/,
replace: "$self.profilePreviewHook($1);$&"
}
},
// Adds the FPTE Builder to the User Profile settings page
{
find: '"DefaultCustomizationSections"',
replacement: {
match: /\.sectionsContainer,.*?children:\[/,
replace: "$&$self.addFPTEBuilder(),"
}
},
// Adds the FPTE Builder to the Server Profiles settings page
{
find: ".setNewPendingGuildIdentity",
replacement: {
match: /\.sectionsContainer,.*?children:\[(?=.+?[{,]guild:(\i))/,
replace: "$&$self.addFPTEBuilder($1),"
}
},
// CustomizationSection
{
find: ".customizationSectionBackground",
replacement: {
match: /default:function\(\){return (\i)}.+?;/,
replace: "$&$self.CustomizationSection=$1;"
}
},
// CustomColorPicker
{
find: "CustomColorPicker:function(){",
replacement: {
match: /CustomColorPicker:function\(\){return (\i)}.+?[ ,;}]\1=(?!=)/,
replace: "$&$self.CustomColorPicker="
}
},
// useAvatarColors
{
find: "useAvatarColors:function(){",
replacement: {
match: /useAvatarColors:function\(\){return (\i)}.+?;/,
replace: "$&$self.useAvatarColors=$1;"
}
},
// ProfileEffectRecord
{
find: "isProfileEffectRecord:function(){",
replacement: {
match: /default:function\(\){return (\i)}.+(?=}$)/,
replace: "$&;$self.ProfileEffectRecord=$1"
}
},
// ProfileEffectStore
{
find: '"ProfileEffectStore"',
replacement: {
match: /function\(\i,(\i),.+[,;}]\1\.default=(?!=)/,
replace: "$&$self.ProfileEffectStore="
}
},
// ProfileEffectModal
{
find: "initialSelectedProfileEffectId",
replacement: {
match: /default:function\(\){return (\i)}(?=.+?(function \1\((?:.(?!function |}$))+\.jsxs?\)\((\i),.+?})(?:function |}$)).+?(function \3\(.+?})(?=function |}$).*(?=}$)/,
replace: (wpModule, modalRootName, ModalRoot, _modalInnerName, ModalInner) => (
`${wpModule}{$self.ProfileEffectModal=${modalRootName};`
+ replaceHelper(ModalRoot, [
// Required for the profile preview to show profile effects
[
/(?<=[{,]purchases:.+?}=).+?(?=,\i=|,{\i:|;)/,
"{isFetching:!1,categories:new Map,purchases:$self.getPurchases()}"
]
])
+ replaceHelper(ModalInner, [
// Required to show the apply button
[
/(?<=[{,]purchase:.+?}=).+?(?=,\i=|,{\i:|;)/,
"{purchase:{purchasedAt:new Date}}"
],
// Replaces the profile effect list with the modified version
[
/(?<=\.jsxs?\)\()[^,]+(?=,{(?:(?:.(?!\.jsxs?\)))+,)?onSelect:)/,
"$self.ProfileEffectModalList"
],
// Replaces the apply profile effect function with the modified version
[
/(?<=[{,]onApply:).+?\.setNewPendingProfileEffectId\)\((\i).+?(?=,\i:|}\))/,
"()=>$self.onApply($1)"
],
// Required to show the apply button
[
/(?<=[{,]canUseCollectibles:).+?(?=,\i:|}\))/,
"!0"
],
// Required to enable the apply button
[
/(?<=[{,]disableApplyButton:).+?(?=,\i:|}\))/,
"!1"
]
])
+ "}"
)
}
},
// ProfileEffectModalList
{
find: "selectedProfileEffectRef",
replacement: {
match: /function\(\i,(\i),.+[,;}]\1\.default=([^=].+?})(?=;|}$).*(?=}$)/,
replace: (wpModule, _wpModuleVar, List) => (
`${wpModule};$self.ProfileEffectModalList=`
+ replaceHelper(List, [
// Removes the "Exclusive to Nitro" and "Preview The Shop" sections
// Adds every profile effect to the "Your Decorations" section and removes the "Shop" button
[
/(?<=[ ,](\i)=).+?(?=(?:,\i=|,{\i:|;).+?:\1\.map\()/,
"$self.getListSections($&)"
]
])
)
}
}
],
set CustomizationSection(c: CustomizationSection) {
CustomizationSection = c;
},
set ColorPicker(c: ColorPicker) {
ColorPicker = c;
},
set getPaletteForAvatar(f: (v: string) => Promise<RGBColor[]>) {
getPaletteForAvatar = f;
},
set getComplimentaryPaletteForColor(f: (v: RGBColor) => RGBColor[]) {
getComplimentaryPaletteForColor = f;
},
settingsAboutComponent: () => {
return (
<Forms.FormSection>
<Forms.FormTitle tag="h3">Usage</Forms.FormTitle>
<Forms.FormText>
After enabling this plugin, you will see custom theme colors and effects in the profiles of other people using this plugin.
<div className={Margins.top8}>
<b>To set your own profile theme colors and effect:</b>
</div>
<ol
className={Margins.bottom8}
style={{ listStyle: "decimal", paddingLeft: "40px" }}
>
<li>Go to your profile settings</li>
<li>Use the FPTE Builder to choose your profile theme colors and effect</li>
<li>Click the "Copy FPTE" button</li>
<li>Paste the invisible text anywhere in your About Me</li>
</ol>
</Forms.FormText>
</Forms.FormSection>
);
addFPTEBuilder: (guildId?: BuilderProps["guildId"]) => settings.store.hideBuilder ? null : <Builder guildId={guildId} />,
onApply(_effectId: string | undefined) { },
set ProfileEffectModal(comp: Parameters<typeof setProfileEffectModal>[0]) {
setProfileEffectModal(props => {
this.onApply = effectId => {
props.onApply(effectId ? ProfileEffectStore.getProfileEffectById(effectId)!.config : null);
props.onClose();
};
return comp(props);
});
},
ProfileEffectModalList: () => null,
getPurchases: () => useMemo(
() => new Map(ProfileEffectStore.profileEffects.map(effect => [
effect.id,
{ items: new ProfileEffectRecord(effect) }
])),
[ProfileEffectStore.profileEffects]
),
getListSections: (origSections: any[]) => useMemo(
() => {
origSections.splice(1);
origSections[0].items.splice(1);
ProfileEffectStore.profileEffects.forEach(effect => {
origSections[0].items.push(new ProfileEffectRecord(effect));
});
return origSections;
},
[ProfileEffectStore.profileEffects]
),
set CustomizationSection(comp: Parameters<typeof setCustomizationSection>[0]) { setCustomizationSection(comp); },
set CustomColorPicker(comp: Parameters<typeof setCustomColorPicker>[0]) { setCustomColorPicker(comp); },
set useAvatarColors(hook: Parameters<typeof setUseAvatarColors>[0]) { setUseAvatarColors(hook); },
set ProfileEffectRecord(obj: Parameters<typeof setProfileEffectRecord>[0]) { setProfileEffectRecord(obj); },
set ProfileEffectStore(store: Parameters<typeof setProfileEffectStore>[0]) { setProfileEffectStore(store); },
settingsAboutComponent,
settings,
decodeUserBioFPTEHook(user: UserProfile | undefined) {
if (user === undefined) return user;
if (settings.store.prioritizeNitro) {
if (user.themeColors !== undefined) {
if (user.profileEffectId === undefined) {
const fpte = extractFPTE(user.bio);
if (decodeColor(fpte[0]) === -2)
updateUserEffectId(user, decodeEffect(fpte[1]));
else
updateUserEffectId(user, decodeEffect(fpte[2]));
}
return user;
} else if (user.profileEffectId !== undefined) {
const fpte = extractFPTE(user.bio);
const primaryColor = decodeColor(fpte[0]);
if (primaryColor === -2)
updateUserThemeColors(user, ...decodeColorsLegacy(fpte[0]));
else
updateUserThemeColors(user, primaryColor, decodeColor(fpte[1]));
return user;
}
}
const fpte = extractFPTE(user.bio);
const primaryColor = decodeColor(fpte[0]);
if (primaryColor === -2) {
updateUserThemeColors(user, ...decodeColorsLegacy(fpte[0]));
updateUserEffectId(user, decodeEffect(fpte[1]));
} else {
updateUserThemeColors(user, primaryColor, decodeColor(fpte[1]));
updateUserEffectId(user, decodeEffect(fpte[2]));
}
return user;
},
profilePreviewHook(props: any) {
if (preview) {
if (primaryColor !== -1) {
props.pendingThemeColors = [primaryColor, accentColor === -1 ? primaryColor : accentColor];
props.canUsePremiumCustomization = true;
} else if (accentColor !== -1) {
props.pendingThemeColors = [accentColor, accentColor];
props.canUsePremiumCustomization = true;
}
if (effect) {
props.pendingProfileEffectId = effect.id;
props.canUsePremiumCustomization = true;
}
}
},
addFPTEBuilder() {
if (settings.store.hideBuilder) return null;
[primaryColor, setPrimaryColor] = useState(-1);
[accentColor, setAccentColor] = useState(-1);
[effect, setEffect] = useState<ProfileEffect | null>(null);
[preview, setPreview] = useState(true);
const [buildLegacy, setBuildLegacy] = useState(false);
const currModal = useRef("");
useEffect(() => () => closeModal(currModal.current), []);
return (
<>
<CustomizationSection title="FPTE Builder">
<div style={{ display: "flex", justifyContent: "space-between" }}>
<BuilderButton
label="Primary"
{...primaryColor !== -1 ? (c => ({
tooltip: c,
selectedStyle: { background: c }
}))("#" + primaryColor.toString(16).padStart(6, "0")) : {}}
onClick={() => {
getSuggestedColors(colors => {
closeModal(currModal.current);
currModal.current = openColorPickerModal(
ColorPicker,
c => {
setPrimaryColor(c);
if (preview) updatePreview();
},
primaryColor === -1 ? parseInt(colors[0]?.slice(1), 16) || 0 : primaryColor,
colors
);
});
}}
/>
<BuilderButton
label="Accent"
{...accentColor !== -1 ? (c => ({
tooltip: c,
selectedStyle: { background: c }
}))("#" + accentColor.toString(16).padStart(6, "0")) : {}}
onClick={() => {
getSuggestedColors(colors => {
closeModal(currModal.current);
currModal.current = openColorPickerModal(
ColorPicker,
c => {
setAccentColor(c);
if (preview) updatePreview();
},
accentColor === -1 ? parseInt(colors[1]?.slice(1), 16) || 0 : accentColor,
colors
);
});
}}
/>
<BuilderButton
label="Effect"
{...effect && {
tooltip: effect.title,
selectedStyle: {
background: `top / cover url(${effect.thumbnailPreviewSrc}), top / cover url(/assets/f328a6f8209d4f1f5022.png)`
}
}}
onClick={() => {
fetchProfileEffects(effects => {
if (effects) {
closeModal(currModal.current);
currModal.current = openProfileEffectModal(
e => {
setEffect(e);
if (preview) updatePreview();
},
effects,
profileEffectModalClassNames,
effect?.id
);
} else
showToast("The retrieved data did not match the expected format.", Toasts.Type.FAILURE);
});
}}
/>
<div
style={{
display: "flex",
alignItems: "center",
flexDirection: "column",
}}
>
<Button
size={Button.Sizes.SMALL}
onClick={() => {
const strToCopy = buildFPTE(primaryColor, accentColor, effect?.id ?? "", buildLegacy);
if (strToCopy === "")
showToast("FPTE Builder is empty; nothing to copy!");
else
copyWithToast(strToCopy, "FPTE copied to clipboard!");
}}
>
Copy FPTE
</Button>
<Button
look={Button.Looks.LINK}
color={Button.Colors.PRIMARY}
size={Button.Sizes.SMALL}
style={{ display: primaryColor === -1 && accentColor === -1 && !effect ? "none" : "revert" }}
onClick={() => {
setPrimaryColor(-1);
setAccentColor(-1);
setEffect(null);
if (preview) updatePreview();
}}
>
Reset
</Button>
</div>
</div>
</CustomizationSection>
<Switch
value={preview}
onChange={value => {
setPreview(value);
updatePreview();
}}
>
FPTE Builder Preview
</Switch>
<Switch
value={buildLegacy}
note="Will use more characters"
onChange={value => setBuildLegacy(value)}
>
Build backwards compatible FPTE
</Switch>
</>
);
}
decodeAboutMeFPTEHook,
profilePreviewHook
});

View file

@ -0,0 +1,201 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/** The FPTE delimiter codepoint (codepoint of zero-width space). */
const DELIMITER_CODEPOINT = 0x200B;
/** The FPTE delimiter (zero-width space). */
const DELIMITER = String.fromCodePoint(DELIMITER_CODEPOINT);
/** The FPTE radix (number of default-ignorable codepoints in the SSP plane). */
const RADIX = 0x1000;
/** The FPTE starting codepoint (first codepoint in the SSP plane). */
const STARTING_CODEPOINT = 0xE0000;
/** The FPTE ending codepoint (last default-ignorable codepoint in the SSP plane). */
const ENDING_CODEPOINT = STARTING_CODEPOINT + RADIX - 1;
/**
* Builds a theme color string in the legacy format: `[#primary,#accent]`, where primary and accent are
* 24-bit colors as base-16 strings, with each codepoint of the string offset by +{@link STARTING_CODEPOINT}.
* @param primary The 24-bit primary color.
* @param accent The 24-bit accent color.
* @returns The built legacy-format theme color string.
*/
export function encodeColorsLegacy(primary: number, accent: number) {
return String.fromCodePoint(...[...`[#${primary.toString(16)},#${accent.toString(16)}]`]
.map(c => c.codePointAt(0)! + STARTING_CODEPOINT));
}
/**
* Extracts the theme colors from a legacy-format string.
* @param str The legacy-format string to extract the theme colors from.
* @returns The profile theme colors. Colors will be -1 if not found.
* @see {@link encodeColorsLegacy}
*/
export function decodeColorsLegacy(str: string): [primaryColor: number, accentColor: number] {
const [primary, accent] = str.matchAll(/(?<=#)[\dA-Fa-f]{1,6}/g);
return [primary ? parseInt(primary[0], 16) : -1, accent ? parseInt(accent[0], 16) : -1];
}
/**
* Converts a 24-bit color to a base-{@link RADIX} string with each codepoint offset by +{@link STARTING_CODEPOINT}.
* @param color The 24-bit color to be converted.
* @returns The converted base-{@link RADIX} string with +{@link STARTING_CODEPOINT} offset.
*/
export function encodeColor(color: number) {
if (color === 0) return String.fromCodePoint(STARTING_CODEPOINT);
let str = "";
for (; color > 0; color = Math.trunc(color / RADIX))
str = String.fromCodePoint(color % RADIX + STARTING_CODEPOINT) + str;
return str;
}
/**
* Converts a no-offset base-{@link RADIX} string to a 24-bit color.
* @param str The no-offset base-{@link RADIX} string to be converted.
* @returns The converted 24-bit color.
* Will be -1 if `str` is empty and -2 if the color is greater than the maximum 24-bit color, 0xFFFFFF.
*/
export function decodeColor(str: string) {
if (str === "") return -1;
let color = 0;
for (let i = 0; i < str.length; i++) {
if (color > 0xFFF_FFF) return -2;
color += str.codePointAt(i)! * RADIX ** (str.length - 1 - i);
}
return color;
}
/**
* Converts an effect ID to a base-{@link RADIX} string with each code point offset by +{@link STARTING_CODEPOINT}.
* @param id The effect ID to be converted.
* @returns The converted base-{@link RADIX} string with +{@link STARTING_CODEPOINT} offset.
*/
export function encodeEffect(id: bigint) {
if (id === 0n) return String.fromCodePoint(STARTING_CODEPOINT);
let str = "";
for (; id > 0n; id /= BigInt(RADIX))
str = String.fromCodePoint(Number(id % BigInt(RADIX)) + STARTING_CODEPOINT) + str;
return str;
}
/**
* Converts a no-offset base-{@link RADIX} string to an effect ID.
* @param str The no-offset base-{@link RADIX} string to be converted.
* @returns The converted effect ID.
* Will be -1n if `str` is empty and -2n if the color is greater than the maximum effect ID.
*/
export function decodeEffect(str: string) {
if (str === "") return -1n;
let id = 0n;
for (let i = 0; i < str.length; i++) {
if (id >= 10_000_000_000_000_000_000n) return -2n;
id += BigInt(str.codePointAt(i)!) * BigInt(RADIX) ** BigInt(str.length - 1 - i);
}
return id;
}
/**
* Builds a FPTE string containing the given primary/accent colors and effect ID. If the FPTE Builder is NOT set to backwards
* compatibility mode, the primary and accent colors will be converted to base-{@link RADIX} before they are encoded.
* @param primary The primary profile theme color. Must be negative if unset.
* @param accent The accent profile theme color. Must be negative if unset.
* @param effect The profile effect ID. Must be empty if unset.
* @param legacy Whether the primary and accent colors should be legacy encoded.
* @returns The built FPTE string. Will be empty if the given colors and effect are all unset.
*/
export function buildFPTE(primary: number, accent: number, effect: string, legacy: boolean) {
/** The FPTE string to be returned. */
let fpte = "";
// If the FPTE Builder is set to backwards compatibility mode,
// the primary and accent colors, if set, will be legacy encoded.
if (legacy) {
// Legacy FPTE strings must include both the primary and accent colors, even if they are the same.
if (primary >= 0) {
// If both the primary and accent colors are set, they will be legacy encoded and added to the
// string; otherwise, if the accent color is unset, the primary color will be used in its place.
if (accent >= 0)
fpte = encodeColorsLegacy(primary, accent);
else
fpte = encodeColorsLegacy(primary, primary);
// If the effect ID is set, it will be encoded and added to the string prefixed by one delimiter.
if (effect)
fpte += DELIMITER + encodeEffect(BigInt(effect));
return fpte;
}
// Since the primary color is unset, the accent color, if set, will be used in its place.
if (accent >= 0) {
fpte = encodeColorsLegacy(accent, accent);
// If the effect ID is set, it will be encoded and added to the string prefixed by one delimiter.
if (effect)
fpte += DELIMITER + encodeEffect(BigInt(effect));
return fpte;
}
}
// If the primary color is set, it will be encoded and added to the string.
else if (primary >= 0) {
fpte = encodeColor(primary);
// If the accent color is set and different from the primary color, it
// will be encoded and added to the string prefixed by one delimiter.
if (accent >= 0 && primary !== accent) {
fpte += DELIMITER + encodeColor(accent);
// If the effect ID is set, it will be encoded and added to the string prefixed by one delimiter.
if (effect)
fpte += DELIMITER + encodeEffect(BigInt(effect));
return fpte;
}
}
// If only the accent color is set, it will be encoded and added to the string.
else if (accent >= 0)
fpte = encodeColor(accent);
// Since either the primary/accent colors are the same, both are unset, or just one is set, only one color will be added
// to the string; therefore, the effect ID, if set, will be encoded and added to the string prefixed by two delimiters.
if (effect)
fpte += DELIMITER + DELIMITER + encodeEffect(BigInt(effect));
return fpte;
}
/**
* Extracts the delimiter-separated values of the first FPTE substring in a string.
* @param str The string to be searched for a FPTE substring.
* @returns An array of the found FPTE substring's extracted values. Values will be empty if not found.
*/
export function extractFPTE(str: string) {
/** The array of extracted FPTE values to be returned. */
const fpte: [maybePrimaryOrLegacy: string, maybeAccentOrEffect: string, maybeEffect: string] = ["", "", ""];
/** The current index of {@link fpte} getting extracted. */
let i = 0;
for (const char of str) {
/** The current character's codepoint. */
const cp = char.codePointAt(0)!;
// If the current character is a delimiter, then the current index of fpte has been completed.
if (cp === DELIMITER_CODEPOINT) {
// If the current index of fpte is the last, then the extraction is done.
if (i >= 2) break;
i++; // Start extracting the next index of fpte.
}
// If the current character is not a delimiter but a valid FPTE
// character, it will be added to the current index of fpte.
else if (cp >= STARTING_CODEPOINT && cp <= ENDING_CODEPOINT)
fpte[i] += String.fromCodePoint(cp - STARTING_CODEPOINT);
// If an FPTE string has been found and its end has been reached, then the extraction is done.
else if (i > 0 || fpte[0]) break;
}
return fpte;
}

View file

@ -0,0 +1,60 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { FluxStore } from "@webpack/types";
export interface ProfileEffectConfig {
accessibilityLabel: string;
animationType: number;
description: string;
effects: {
duartion: number;
height: number;
loop: boolean;
loopDelay: number;
position: {
x: number;
y: number;
};
src: string;
start: number;
width: number;
zIndex: number;
}[];
id: string;
reducedMotionSrc: string;
sku_id: string;
staticFrameSrc?: string;
thumbnailPreviewSrc: string;
title: string;
type: 1;
}
export interface ProfileEffect extends Pick<ProfileEffectConfig, "id"> {
config: ProfileEffectConfig;
skuId: ProfileEffectConfig["sku_id"];
}
export let ProfileEffectRecord: {
new(effect: Omit<ProfileEffect, "config">): typeof effect & Pick<ProfileEffectConfig, "type">;
fromServer: (effect: Pick<ProfileEffectConfig, "id" | "sku_id">) => Omit<ProfileEffect, "config"> & Pick<ProfileEffectConfig, "type">;
};
export function setProfileEffectRecord(obj: typeof ProfileEffectRecord) {
ProfileEffectRecord = obj;
}
export let ProfileEffectStore: FluxStore & {
readonly isFetching: boolean;
readonly fetchError: Error | undefined;
readonly profileEffects: ProfileEffect[];
readonly tryItOutId: string | null;
getProfileEffectById: (effectId: string) => ProfileEffect | undefined;
};
export function setProfileEffectStore(store: typeof ProfileEffectStore) {
ProfileEffectStore = store;
}

View file

@ -0,0 +1,77 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { FluxDispatcher, useState } from "@webpack/common";
import type { ProfileEffectConfig } from "./profileEffects";
function updatePreview() {
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SUBMIT_SUCCESS" });
}
let primaryColor: number | null = null;
export function usePrimaryColor(initialState: typeof primaryColor) {
const [state, setState] = useState(() => primaryColor = initialState);
return [
state,
(color: typeof primaryColor) => {
setState(primaryColor = color);
if (showPreview) updatePreview();
}
] as const;
}
let accentColor: number | null = null;
export function useAccentColor(initialState: typeof accentColor) {
const [state, setState] = useState(() => accentColor = initialState);
return [
state,
(color: typeof accentColor) => {
setState(accentColor = color);
if (showPreview) updatePreview();
}
] as const;
}
let profileEffect: ProfileEffectConfig | null = null;
export function useProfileEffect(initialState: typeof profileEffect) {
const [state, setState] = useState(() => profileEffect = initialState);
return [
state,
(effect: typeof profileEffect) => {
setState(profileEffect = effect);
if (showPreview) updatePreview();
}
] as const;
}
let showPreview = true;
export function useShowPreview(initialState: typeof showPreview) {
const [state, setState] = useState(() => showPreview = initialState);
return [
state,
(preview: typeof showPreview) => {
setState(showPreview = preview);
updatePreview();
}
] as const;
}
export function profilePreviewHook(props: any) {
if (showPreview) {
if (primaryColor !== null) {
props.pendingThemeColors = [primaryColor, accentColor ?? primaryColor];
props.canUsePremiumCustomization = true;
} else if (accentColor !== null) {
props.pendingThemeColors = [accentColor, accentColor];
props.canUsePremiumCustomization = true;
}
if (!props.forProfileEffectModal && profileEffect) {
props.pendingProfileEffectId = profileEffect.id;
props.canUsePremiumCustomization = true;
}
}
}

View file

@ -0,0 +1,69 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { settings } from "..";
import { decodeColor, decodeColorsLegacy, decodeEffect, extractFPTE } from "./fpte";
export interface UserProfile {
bio: string;
premiumType: number | null | undefined;
profileEffectId: string | undefined;
themeColors: [primaryColor: number, accentColor: number] | undefined;
}
function updateProfileThemeColors(profile: UserProfile, primary: number, accent: number) {
if (primary > -1) {
profile.themeColors = [primary, accent > -1 ? accent : primary];
profile.premiumType = 2;
} else if (accent > -1) {
profile.themeColors = [accent, accent];
profile.premiumType = 2;
}
}
function updateProfileEffectId(profile: UserProfile, id: bigint) {
if (id > -1n) {
profile.profileEffectId = id.toString();
profile.premiumType = 2;
}
}
export function decodeAboutMeFPTEHook(profile: UserProfile | undefined) {
if (!profile) return profile;
if (settings.store.prioritizeNitro) {
if (profile.themeColors) {
if (!profile.profileEffectId) {
const fpte = extractFPTE(profile.bio);
if (decodeColor(fpte[0]) === -2)
updateProfileEffectId(profile, decodeEffect(fpte[1]));
else
updateProfileEffectId(profile, decodeEffect(fpte[2]));
}
return profile;
} else if (profile.profileEffectId) {
const fpte = extractFPTE(profile.bio);
const primaryColor = decodeColor(fpte[0]);
if (primaryColor === -2)
updateProfileThemeColors(profile, ...decodeColorsLegacy(fpte[0]));
else
updateProfileThemeColors(profile, primaryColor, decodeColor(fpte[1]));
return profile;
}
}
const fpte = extractFPTE(profile.bio);
const primaryColor = decodeColor(fpte[0]);
if (primaryColor === -2) {
updateProfileThemeColors(profile, ...decodeColorsLegacy(fpte[0]));
updateProfileEffectId(profile, decodeEffect(fpte[1]));
} else {
updateProfileThemeColors(profile, primaryColor, decodeColor(fpte[1]));
updateProfileEffectId(profile, decodeEffect(fpte[2]));
}
return profile;
}

View file

@ -1,68 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { User } from "discord-types/general";
import type { ComponentType, PropsWithChildren, ReactNode } from "react";
export interface UserProfile extends User {
themeColors: [number, number] | undefined;
profileEffectId: string | undefined;
}
export interface ProfileEffect {
accessibilityLabel: string;
animationType: number;
description: string;
effects: {
duartion: number;
height: number;
loop: boolean;
loopDelay: number;
position: {
x: number;
y: number;
};
src: string;
start: number;
width: number;
zIndex: number;
}[];
id: string;
reducedMotionSrc: string;
sku_id: string;
staticFrameSrc?: string;
thumbnailPreviewSrc: string;
title: string;
type: number;
}
export type CustomizationSection = ComponentType<PropsWithChildren<{
title?: ReactNode;
titleIcon?: ReactNode;
titleId?: string;
description?: ReactNode;
className?: string;
errors?: string[];
disabled?: boolean;
hideDivider?: boolean;
showBorder?: boolean;
borderType?: "limited" | "premium";
hasBackground?: boolean;
forcedDivider?: boolean;
showPremiumIcon?: boolean;
}>>;
export type ColorPicker = ComponentType<{
value?: number | null;
onChange: (v: number) => void;
onClose?: () => void;
suggestedColors?: string[];
middle?: ReactNode;
footer?: ReactNode;
showEyeDropper?: boolean;
}>;
export type RGBColor = [number, number, number];

View file

@ -24,6 +24,7 @@ export default function ReplyNavigator({ replies }: { replies: Message[]; }) {
setVisible(true);
}, [replies]);
React.useEffect(() => {
// https://stackoverflow.com/a/42234988
function onMouseDown(event: MouseEvent) {
if (ref.current && event.target instanceof Element && !ref.current.contains(event.target)) {
setVisible(false);
@ -45,8 +46,7 @@ export default function ReplyNavigator({ replies }: { replies: Message[]; }) {
flexDirection: "row",
alignItems: "center",
paddingLeft: "1em",
paddingRight: "1em",
opacity: "80%"
paddingRight: "1em"
}}>
<Paginator
className={"vc-findreply-paginator"}

View file

@ -1,8 +1,20 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { addButton, removeButton } from "@api/MessagePopover";
import { disableStyle, enableStyle } from "@api/Styles";
@ -88,7 +100,7 @@ export default definePlugin({
if (!madeComponent) {
madeComponent = true;
element = document.createElement("div");
document.querySelector("[class^=base__]")!.appendChild(element);
document.querySelector("[class^=base_]")!.appendChild(element);
root = ReactDOM.createRoot(element);
}
root!.render(<ReplyNavigator replies={replies} />);

View file

@ -5,4 +5,4 @@
.vc-findreply-close {
align-items: inherit !important;
line-height: 0 !important;
}
}

View file

@ -0,0 +1,294 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export function sproutIcon(props) {
return (
<svg width={props.height} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="shape-gradient-Circle" gradientTransform="rotate(90)">
<stop offset="0%" stopColor="#5865F1" />
</linearGradient>
</defs>
<defs>
<mask id="mask-Circle" fill="black">
<rect width="100%" height="100%" fill="white" />
<g opacity="100" style={{ transformOrigin: "center center" }}></g>
</mask>
</defs>
<g fill="#5865F1">
<g>
<circle
cx="151.5"
cy="151.5"
r="151.5"
stroke="#ffffff"
strokeLinejoin="round"
strokeWidth="0"
style={{ transform: "none", transformOrigin: "151.5px 151.5px" }}
/>
</g>
</g>
<defs>
<linearGradient id="icon-gradient-0" gradientTransform="rotate(0)">
<stop offset="0%" stopColor="#fff" />
</linearGradient>
</defs>
<g
fill="#FFFFFF"
stroke="#FFFFFF"
strokeLinejoin="round"
strokeWidth="0"
style={{ transformOrigin: "151.5px 151.5px", transform: "translateX(61.5px) translateY(70.5px) scale(1) rotate(0deg)" }}
filter="false"
>
<path
d="M180,36 L180,58.5 C180,74.0151647 173.836625,88.8948686 162.865747,99.8657467 C151.894869,110.836625 137.015165,117 121.5,117 L99,117 L99,162 L81,162 L81,99 L81.171,90 C83.5220892,59.5264011 108.93584,36 139.5,36 L180,36 Z M36,0 C63.119888,0.00205254816 87.1987774,17.349872 95.787,43.074 C81.8722924,54.865561 73.3399721,71.8002844 72.144,90 L63,90 C28.2060608,90 0,61.7939392 0,27 L0,0 L36,0 Z"
viewBox="0 0 180 162"
/>
</g>
</svg>
);
}
export function bloomingIcon(props) {
return (
<svg width={props.height} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient gradientTransform="rotate(90)">
<stop offset="0%" stopColor="#5865F1" />
</linearGradient>
</defs>
<defs>
<mask fill="black">
<rect width="100%" height="100%" fill="white" />
<g opacity="100" style={{ transformOrigin: "center center" }}></g>
</mask>
</defs>
<g fill="#5865F1">
<g>
<circle
cx="151.5"
cy="151.5"
r="151.5"
stroke="#ffffff"
strokeLinejoin="round"
strokeWidth="0"
style={{ transform: "none", transformOrigin: "151.5px 151.5px" }}
/>
</g>
</g>
<defs>
<linearGradient id="icon-gradient-0" gradientTransform="rotate(0)">
<stop offset="0%" stopColor="#fff" />
</linearGradient>
</defs>
<g
fill="#FFFFFF"
stroke="#FFFFFF"
strokeLinejoin="round"
strokeWidth="0"
style={{ transformOrigin: "90px 81px", transform: "translateX(66.5px) translateY(77.5px) scale(1) rotate(0deg)" }}
>
<path
d="M84.9998992,147.027244 C84.1399412,147.027244 83.2803153,146.805116 82.5100055,146.360196 C81.6732896,145.877424 61.7943156,134.335394 41.6301277,116.944321 C29.6790363,106.637111 20.1391475,96.413904 13.2760854,86.5592506 C4.39494432,73.8073027 -0.0711925371,61.5414468 4.71843149e-15,50.1020138 C0.0851936394,36.7909271 4.85281383,24.2727283 13.426495,14.8530344 C22.1449414,5.27463015 33.7799401,0 46.188901,0 C62.0921466,0 76.6320767,8.90836755 85.0002312,23.0203108 C93.3683858,8.90869958 107.908316,0 123.811562,0 C135.53488,0 146.719978,4.75931943 155.307605,13.4013988 C164.731947,22.8851746 170.086596,36.2852453 170,50.1650995 C169.926558,61.5846107 165.376749,73.8318729 156.476019,86.5662233 C149.591706,96.4158962 140.065099,106.634454 128.160824,116.938677 C108.070347,134.328089 88.3341455,145.869787 87.5037382,146.352559 C86.7432923,146.79448 85.8794285,147.027244 84.9998992,147.027244 Z"
viewBox="0 0 170 148"
/>
</g>
</svg>
);
}
export function burningIcon(props) {
return (
<svg width={props.width} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient gradientTransform="rotate(90)">
<stop offset="0%" stopColor="#5865F1" />
</linearGradient>
</defs>
<defs>
<mask id="mask-Circle" fill="black">
<rect width="100%" height="100%" fill="white" />
<g opacity="100" style={{ transformOrigin: "center center" }}></g>
</mask>
</defs>
<g>
<g mask="#5865F1" fill="#5865F1">
<circle
cx="151.5"
cy="151.5"
r="151.5"
stroke="#ffffff"
strokeLinejoin="round"
strokeWidth="0"
style={{ transform: "none", transformOrigin: "151.5px 151.5px" }}
/>
</g>
</g>
<defs>
<linearGradient id="icon-gradient-0" gradientTransform="rotate(0)">
<stop offset="0%" stopColor="#fff" />
</linearGradient>
</defs>
<g
fill="#FFFFFF"
stroke="#FFFFFF"
strokeLinejoin="round"
strokeWidth="0"
style={{ transformOrigin: "90px 81px", transform: "translateX(84px) translateY(65.5px) scale(1) rotate(0deg)" }}
>
<path
d="M129.94 91.26C116.835 57.271 70.148 55.43 81.41 5.674a4.32 4.32 0 00-6.348-4.71c-30.51 18.019-52.215 53.85-33.991 100.943a4.094 4.094 0 01-6.348 4.914C19.571 95.355 17.933 78.975 19.366 67.1a4.095 4.095 0 00-7.577-2.867C6.056 73.037.322 86.96.322 108.05c3.277 46.888 42.797 61.22 57.13 63.063 20.272 2.662 42.387-1.228 58.154-15.766A56.503 56.503 0 00129.94 91.26zM52.538 133.44a26.495 26.495 0 0020.067-19.451c2.867-12.081-7.986-23.547-.819-42.589C74.448 86.96 99.02 96.79 99.02 113.988c.614 21.089-22.32 39.312-46.482 19.553v-.102z"
viewBox="0 0 135 172"
/>
</g>
</svg>
);
}
export function starIcon(props) {
return (
<svg width={props.width} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="shape-gradient-Circle" gradientTransform="rotate(90)">
<stop offset="0%" stopColor="#5865F1" />
</linearGradient>
</defs>
<defs>
<mask id="mask-Circle" fill="black">
<rect width="100%" height="100%" fill="white" />
<g opacity="100" style={{ transformOrigin: "center center" }}></g>
</mask>
</defs>
<g fill="#5865F1">
<g>
<circle
cx="151.5"
cy="151.5"
r="151.5"
stroke="#ffffff"
strokeLinejoin="round"
strokeWidth="0"
style={{ transform: "none", transformOrigin: "151.5px 151.5px" }}
/>
</g>
</g>
<defs>
<linearGradient id="icon-gradient-0" gradientTransform="rotate(0)">
<stop offset="0%" stopColor="#fff" />
</linearGradient>
</defs>
<g
fill="#FFFFFF"
stroke="#FFFFFF"
strokeLinejoin="round"
strokeWidth="0"
style={{ transformOrigin: "90px 81px", transform: "translateX(64px) translateY(68.5px) scale(1) rotate(0deg)" }}
>
<path
d="M169.456 73.514l-32.763 28.256a9.375 9.375 0 00-3.071 10.033l10.238 41.155a10.115 10.115 0 01-15.358 11.056l-36.858-22.113a9.298 9.298 0 00-10.238 0l-36.858 22.113c-8.191 4.095-17.406-2.047-15.358-11.056l10.238-41.155c1.024-4.095 0-8.19-3.071-10.033L3.594 73.514a10.667 10.667 0 016.144-18.222l43-3.072c4.096-1.024 7.167-3.071 8.191-5.938l18.43-40.13a9.992 9.992 0 0118.428 0l16.382 39.311a8.926 8.926 0 008.19 6.143l43.001 3.89c8.949.82 13.044 11.876 4.096 18.018z"
viewBox="0 0 175 166"
/>
</g>
</svg>
);
}
export function royalIcon(props) {
return (
<svg width={props.width} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="shape-gradient-Circle" gradientTransform="rotate(90)">
<stop offset="0%" stopColor="#5865F1" />
</linearGradient>
</defs>
<defs>
<mask id="mask-Circle" fill="black">
<rect width="100%" height="100%" fill="white" />
<g opacity="100" style={{ transformOrigin: "center center" }}></g>
</mask>
</defs>
<g fill="#5865F1">
<g>
<circle
cx="151.5"
cy="151.5"
r="151.5"
stroke="#ffffff"
strokeLinejoin="round"
strokeWidth="0"
style={{ transform: "none", transformOrigin: "151.5px 151.5px" }}
/>
</g>
</g>
<defs>
<linearGradient id="icon-gradient-0" gradientTransform="rotate(0)">
<stop offset="0%" stopColor="#fff" />
</linearGradient>
</defs>
<g
fill="#FFFFFF"
stroke="#FFFFFF"
strokeLinejoin="round"
strokeWidth="0"
style={{ transformOrigin: "90px 81px", transform: "translateX(71px) translateY(91px) scale(1) rotate(0deg)" }}
>
<path
d="M16.6 113.59c.9 4.2 4.1 7.1 7.8 7.1h112.5c3.7 0 6.9-2.9 7.8-7.1l16-74.794a10.002 10.002 0 00-2.9-9.7 7.09 7.09 0 00-8.9-.5l-33.6 23.499L87.4 3.398a1.85 1.85 0 00-.7-.8l-.1-.1c-.1-.1-.2-.3-.4-.4a6.71 6.71 0 00-4.5-1.8h-2a6.91 6.91 0 00-4.5 1.8 2.177 2.177 0 00-.5.4c-.3.3-.6.5-.7.8l-.2.4-.1.1-.4.6L46 51.995 12.4 28.497a7.09 7.09 0 00-8.9.5 10.248 10.248 0 00-3 9.799l16.1 74.794z"
viewBox="0 0 161 121"
/>
</g>
</svg>
);
}
export function bestiesIcon(props) {
return (
<svg width={props.width} height={props.height} viewBox="0 0 303 303" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="shape-gradient-Circle" gradientTransform="rotate(90)">
<stop offset="0%" stopColor="#5865F1" />
</linearGradient>
</defs>
<defs>
<mask id="mask-Circle" fill="black">
<rect width="100%" height="100%" fill="white" />
<g opacity="100" style={{ transformOrigin: "center center" }}></g>
</mask>
</defs>
<g fill="#5865F1">
<g>
<circle
cx="151.5"
cy="151.5"
r="151.5"
stroke="#ffffff"
strokeLinejoin="round"
strokeWidth="0"
style={{ transform: "none", transformOrigin: "151.5px 151.5px" }}
/>
</g>
</g>
<defs>
<linearGradient id="icon-gradient-0" gradientTransform="rotate(0)">
<stop offset="0%" stopColor="#fff" />
</linearGradient>
</defs>
<g
fill="#FFFFFF"
stroke="#FFFFFF"
strokeLinejoin="round"
strokeWidth="0"
style={{ transformOrigin: "90px 81px", transform: "translateX(66.5px) translateY(66.5px) scale(1) rotate(0deg)" }}
>
<path
d="M166.102378,3.92112065 C161.691624,-0.489633516 154.908699,-1.19387158 145.975996,1.88253679 C142.047089,3.21688259 137.599269,5.29253161 132.595472,8.10948386 C129.593194,9.81448127 126.368525,11.7418696 123.032661,13.9657793 C122.143097,14.5588219 121.253533,15.1518645 120.326904,15.7819722 C125.441896,18.3765335 130.223302,21.5270722 134.634056,25.2335883 C135.671881,24.6034805 136.635575,23.9733728 137.599269,23.4173954 C146.050126,18.3765335 151.350444,16.4491451 154.130331,15.8931677 C153.389028,19.636749 150.164359,28.0505405 140.082635,42.5800837 C139.267201,43.7291037 138.451768,44.9151888 137.599269,46.101274 C135.708946,43.5437779 133.596232,41.0604121 131.261127,38.7623721 C105.723231,13.2244761 64.2843806,13.2244761 38.7464846,38.7623721 C13.2085886,64.3002681 13.2085886,105.739118 38.7464846,131.277014 C41.0815898,133.612119 43.5278904,135.724833 46.0853865,137.615157 C44.8993014,138.467655 43.7132162,139.283089 42.5641962,140.098523 C28.0346531,150.143181 19.6208615,153.404915 15.8772802,154.146219 C16.3220622,151.885244 17.6934731,147.845141 20.9922725,141.766455 C22.1412924,139.653741 23.5127034,137.28157 25.1806357,134.649944 C21.5111847,130.23919 18.360646,125.457784 15.7290196,120.342792 C15.0989118,121.232356 14.5058693,122.158985 13.9128267,123.048548 C10.8364183,127.718759 8.2789222,132.055383 6.24033834,136.021355 C4.35001512,139.653741 2.90447384,142.989605 1.86664933,145.991883 C-1.17269388,154.924587 -0.505520981,161.707511 3.90523319,166.118266 C8.31598736,170.52902 15.0989118,171.233258 24.0316157,168.156849 C30.332693,166.00707 38.0422466,161.929902 46.9749504,156.073607 C64.6179671,144.472212 85.6709786,126.79213 106.205078,106.25803 C126.739177,85.7239312 144.456324,64.6709197 156.020654,47.027903 C161.87695,38.0951992 165.954117,30.3856457 168.103897,24.0845683 C171.180305,15.1147993 170.513132,8.36893998 166.102378,3.92112065 Z M114.952456,114.968343 C101.979649,127.94115 88.7844521,139.802001 76.2934928,149.84666 C95.7897675,152.441221 116.286802,146.251339 131.261127,131.277014 C146.272517,116.265624 152.462399,95.805655 149.830772,76.3093803 C139.786114,88.8003396 127.925262,101.995537 114.952456,114.968343 L114.952456,114.968343 Z"
viewBox="0 0 170 170"
/>
</g>
</svg>
);
}

View file

@ -10,13 +10,15 @@ import { Devs } from "@utils/constants";
import { Margins } from "@utils/margins";
import { Modals, ModalSize, openModal } from "@utils/modal";
import definePlugin from "@utils/types";
import { Flex, Forms, RelationshipStore } from "@webpack/common";
import { Button, Flex, Forms, RelationshipStore } from "@webpack/common";
import { bestiesIcon, bloomingIcon, burningIcon, royalIcon, sproutIcon, starIcon } from "./icons";
interface rankInfo {
title: string;
description: string;
requirement: number;
assetURL: string;
assetSVG: any;
}
function daysSince(dateString: string): number {
@ -36,37 +38,37 @@ const ranks: rankInfo[] =
title: "Sprout",
description: "Your friendship is just starting",
requirement: 0,
assetURL: "https://files.catbox.moe/d6gis2.png"
assetSVG: sproutIcon
},
{
title: "Blooming",
description: "Your friendship is getting there! (1 Month)",
requirement: 30,
assetURL: "https://files.catbox.moe/z7fxjq.png"
assetSVG: bloomingIcon
},
{
title: "Burning",
description: "Your friendship has reached terminal velocity :o (3 Months)",
description: "Your friendship has reached terminal velocity (3 Months)",
requirement: 90,
assetURL: "https://files.catbox.moe/8oiu0o.png"
assetSVG: burningIcon
},
{
title: "Star",
description: "Your friendship has been going on for a WHILE (1 Year)",
requirement: 365,
assetURL: "https://files.catbox.moe/7bpe7v.png"
assetSVG: starIcon
},
{
title: "Royal",
description: "Your friendship has gone through thick and thin- a whole 2 years!",
requirement: 730,
assetURL: "https://files.catbox.moe/0yp9mp.png"
assetSVG: royalIcon
},
{
title: "Besties",
description: "How do you even manage this??? (5 Years)",
assetURL: "https://files.catbox.moe/qojb7d.webp",
requirement: 1826.25
requirement: 1826.25,
assetSVG: bestiesIcon
}
];
@ -84,16 +86,13 @@ function openRankModal(rank: rankInfo) {
margin: 0
}}
>
Your friendship rank
{rank.title}
</Forms.FormTitle>
</Flex>
</Modals.ModalHeader>
<Modals.ModalContent>
<div style={{ padding: "1em", textAlign: "center" }}>
<Forms.FormText className={Margins.bottom20}>
{rank.title}
</Forms.FormText>
<img src={rank.assetURL} style={{ height: "150px" }} />
<rank.assetSVG height="150px"></rank.assetSVG>
<Forms.FormText className={Margins.top16}>
{rank.description}
</Forms.FormText>
@ -104,18 +103,23 @@ function openRankModal(rank: rankInfo) {
));
}
function getBadgesToApply() {
function getBadgeComponent(rank,) {
// there may be a better button component to do this with
return (
<div style={{ transform: "scale(0.80)" }}>
<Button onClick={() => openRankModal(rank)} width={"21.69px"} height={"21.69px"} size={Button.Sizes.NONE} look={Button.Looks.BLANK}>
<rank.assetSVG height={"21.69px"} />
</Button>
</div>
);
}
function getBadgesToApply() {
const badgesToApply: ProfileBadge[] = ranks.map((rank, index, self) => {
return (
{
description: rank.title,
image: rank.assetURL,
props: {
style: {
transform: "scale(0.8)"
}
},
component: () => getBadgeComponent(rank),
shouldShow: (info: BadgeUserArgs) => {
if (!RelationshipStore.isFriend(info.user.id)) { return false; }
@ -127,9 +131,9 @@ function getBadgesToApply() {
return (days > rank.requirement && days < self[index + 1].requirement);
},
onClick: () => openRankModal(rank)
});
});
return badgesToApply;
}

View file

@ -109,4 +109,3 @@ export const deleteCollection = async (name: string): Promise<void> => {
export const refreshCacheCollection = async (): Promise<void> => {
cache_collections = await getCollections();
};

View file

@ -18,7 +18,7 @@
// Plugin idea by brainfreeze (668137937333911553) 😎
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { addContextMenuPatch, findGroupChildrenByChildId, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
@ -93,40 +93,29 @@ export default definePlugin({
// need better description eh
description: "Allows you to have collections of gifs",
authors: [Devs.Aria],
contextMenus: {
"message": addCollectionContextMenuPatch
},
patches: [
{
find: "renderCategoryExtras",
replacement: [
// This patch adds the collections to the gif part yk
{
match: /(render\(\){)(.{1,50}getItemGrid)/,
match: /(\i\.render=function\(\){)(.{1,50}getItemGrid)/,
replace: "$1;$self.insertCollections(this);$2"
},
// Hides the gc: from the name gc:monkeh -> monkeh
// https://regex101.com/r/uEjLFq/1
{
match: /(className:\w\.categoryName,children:)(\i)/,
replace: "$1$self.hidePrefix($2),"
match: /(\i\.renderCategoryExtras=function\((?<props>\i)\){)var (?<varName>\i)=\i\.name,/,
replace: "$1var $<varName>=$self.hidePrefix($<props>),"
},
]
},
{
find: "renderEmptyFavorite",
replacement: {
match: /render\(\){.{1,500}onClick:this\.handleClick,/,
replace: "$&onContextMenu: (e) => $self.collectionContextMenu(e, this),"
}
},
{
find: "renderHeaderContent()",
replacement: [
// Replaces this.props.resultItems with the collection.gifs
{
match: /(renderContent\(\){)(.{1,50}resultItems)/,
replace: "$1$self.renderContent(this);$2"
match: /(\i\.renderContent=function\(\){)(.{1,50}resultItems)/,
replace: "$1;$self.renderContent(this);$2"
},
// Delete context menu for collection
{
match: /(\i\.render=function\(\){.{1,100}renderExtras.{1,200}onClick:this\.handleClick,)/,
replace: "$1onContextMenu: (e) => $self.collectionContextMenu(e, this),"
},
]
},
@ -140,9 +129,9 @@ export default definePlugin({
{
find: "type:\"GIF_PICKER_QUERY\"",
replacement: {
match: /(function \i\(.{1,10}\){)(.{1,100}.GIFS_SEARCH,query:)/,
match: /(function \i\((?<query>\i),\i\){.{1,200}dispatch\({type:"GIF_PICKER_QUERY".{1,20};)/,
replace:
"$1if($self.shouldStopFetch(arguments[0])) return;$2"
"$&if($self.shouldStopFetch($<query>)) return;"
}
},
],
@ -152,8 +141,13 @@ export default definePlugin({
start() {
CollectionManager.refreshCacheCollection();
addContextMenuPatch("message", addCollectionContextMenuPatch);
},
stop() {
removeContextMenuPatch("message", addCollectionContextMenuPatch);
},
CollectionManager,
oldTrendingCat: null as Category[] | null,
@ -182,8 +176,8 @@ export default definePlugin({
},
hidePrefix(name: string) {
return name.split(":").length > 1 ? name.replace(/.+?:/, "") : name;
hidePrefix(props: Category) {
return props.name.split(":").length > 1 ? props.name.replace(/.+?:/, "") : props.name;
},
insertCollections(instance: { props: Props; }) {
@ -218,18 +212,13 @@ export default definePlugin({
onConfirm={() => { this.sillyInstance && this.sillyInstance.forceUpdate(); }}
nameOrId={instance.props.item.name} />
);
if (item?.id?.startsWith(GIF_ITEM_PREFIX)) {
ContextMenuApi.openContextMenu(e, () =>
if (item?.id?.startsWith(GIF_ITEM_PREFIX))
return ContextMenuApi.openContextMenu(e, () =>
<RemoveItemContextMenu
type="gif"
onConfirm={() => { this.sillyContentInstance && this.sillyContentInstance.forceUpdate(); }}
nameOrId={instance.props.item.id}
/>);
instance.props.focused = false;
instance.forceUpdate();
this.sillyContentInstance && this.sillyContentInstance.forceUpdate();
return;
}
const { src, url, height, width } = item;
if (src && url && height != null && width != null && !item.id?.startsWith(GIF_ITEM_PREFIX))
@ -361,5 +350,3 @@ function CreateCollectionModal({ gif, onClose, modalProps }: CreateCollectionMod
</ModalRoot>
);
}

View file

@ -51,24 +51,10 @@ export function getGifByMessageAndUrl(url: string, message: Message): Gif | null
if (!message.embeds.length && !message.attachments.length || isAudio(url))
return null;
const cleanedUrl = cleanUrl(url);
url = cleanUrl(url);
// find embed with matching url or image/thumbnail url
const embed = message.embeds.find(e => {
const hasMatchingUrl = e.url && cleanUrl(e.url) === cleanedUrl;
const hasMatchingImage = e.image && cleanUrl(e.image.url) === cleanedUrl;
const hasMatchingImageProxy = e.image?.proxyURL === cleanedUrl;
const hasMatchingVideoProxy = e.video?.proxyURL === cleanedUrl;
const hasMatchingThumbnailProxy = e.thumbnail?.proxyURL === cleanedUrl;
return (
hasMatchingUrl ||
hasMatchingImage ||
hasMatchingImageProxy ||
hasMatchingVideoProxy ||
hasMatchingThumbnailProxy
);
});
const embed = message.embeds.find(e => e.url === url || e.image?.url === url || e.image?.proxyURL === url || e.video?.proxyURL === url || e.thumbnail?.proxyURL === url); // no point in checking thumbnail/video url because no way of getting it eh. discord renders the img/video element with proxy urls
if (embed) {
if (embed.image)
return {
@ -98,7 +84,7 @@ export function getGifByMessageAndUrl(url: string, message: Message): Gif | null
}
const attachment = message.attachments.find(a => cleanUrl(a.url) === cleanedUrl || a.proxy_url === cleanedUrl);
const attachment = message.attachments.find(a => a.url === url || a.proxy_url === url);
if (attachment) return {
id: uuidv4(),
height: attachment.height ?? 50,

View file

@ -21,7 +21,7 @@ import definePlugin from "@utils/types";
import { PermissionStore } from "@webpack/common";
export default definePlugin({
name: "GodMode",
name: "God Mode",
description: "Get all permissions (client-side).",
authors: [EquicordDevs.Tolgchu],

View file

@ -160,5 +160,5 @@ export default definePlugin({
style.remove();
hiddenMessages.clear();
}
},
});

View file

@ -128,17 +128,6 @@ export default new (class NoteHandler {
});
};
public deleteEverything = async () => {
noteHandlerCache.clear();
await DeleteEntireStore();
Toasts.show({
id: Toasts.genId(),
message: "Successfully deleted all notes.",
type: Toasts.Type.SUCCESS,
});
};
public deleteNotebook = async (notebookName: string) => {
noteHandlerCache.delete(notebookName);
deleteCacheFromDataStore(notebookName);
@ -182,6 +171,17 @@ export default new (class NoteHandler {
};
public deleteEverything = async () => {
noteHandlerCache.clear();
await DeleteEntireStore();
Toasts.show({
id: Toasts.genId(),
message: "Successfully deleted all notes.",
type: Toasts.Type.SUCCESS,
});
};
public exportNotes = async () => {
return this.getAllNotes();
};

View file

@ -8,7 +8,7 @@ import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, M
import { findByProps } from "@webpack";
import { Button, Forms, Text } from "@webpack/common";
import noteHandler from "../../NoteHandler";
import noteHandler from "../../noteHandler";
import { downloadNotes, uploadNotes } from "../../utils";
export default ({ onClose, ...modalProps }: ModalProps & { onClose: () => void; }) => {

View file

@ -10,7 +10,7 @@ import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, M
import { findByProps } from "@webpack";
import { ContextMenuApi, Flex, FluxDispatcher, Menu, React, Text, TextInput } from "@webpack/common";
import noteHandler from "../../NoteHandler";
import noteHandler from "../../noteHandler";
import { HolyNotes } from "../../types";
import HelpIcon from "../icons/HelpIcon";
import Errors from "./Error";

View file

@ -7,7 +7,7 @@
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal";
import { Button, React, Text, TextInput } from "@webpack/common";
import noteHandler from "../../NoteHandler";
import noteHandler from "../../noteHandler";
export default (props: ModalProps & { onClose: () => void; }) => {
const [notebookName, setNotebookName] = React.useState("");

View file

@ -8,7 +8,7 @@ import ErrorBoundary from "@components/ErrorBoundary";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal";
import { Button, React, Text } from "@webpack/common";
import noteHandler from "../../NoteHandler";
import noteHandler from "../../noteHandler";
import Error from "./Error";
import { RenderMessage } from "./RenderMessage";

View file

@ -9,7 +9,7 @@ import { ModalProps } from "@utils/modal";
import { findByCode, findByProps } from "@webpack";
import { Clipboard, ContextMenuApi, FluxDispatcher, Menu, NavigationRouter, React } from "@webpack/common";
import noteHandler from "../../NoteHandler";
import noteHandler from "../../noteHandler";
import { HolyNotes } from "../../types";

View file

@ -22,7 +22,6 @@ import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { DataStore } from "@api/index";
import { addButton, removeButton } from "@api/MessagePopover";
import ErrorBoundary from "@components/ErrorBoundary";
import { EquicordDevs } from "@utils/constants";
import { classes } from "@utils/misc";
import { openModal } from "@utils/modal";
import definePlugin from "@utils/types";
@ -32,7 +31,7 @@ import { Message } from "discord-types/general";
import { Popover as NoteButtonPopover, Popover } from "./components/icons/NoteButton";
import { NoteModal } from "./components/modals/Notebook";
import noteHandler, { noteHandlerCache } from "./NoteHandler";
import noteHandler, { noteHandlerCache } from "./noteHandler";
import { DataStoreToCache, HolyNoteStore } from "./utils";
const HeaderBarIcon = findExportedComponentLazy("Icon", "Divider");
@ -71,7 +70,7 @@ function ToolBarHeader() {
export default definePlugin({
name: "HolyNotes",
description: "Holy Notes allows you to save messages",
authors: [EquicordDevs.Wolfie],
authors: [{ id: 347096063569559553n, name: "wolfieeeeeeee" }],
dependencies: ["MessagePopoverAPI", "ChatInputButtonAPI"],
patches: [
@ -129,4 +128,3 @@ export default definePlugin({
removeButton("HolyNotes");
}
});

View file

@ -8,7 +8,7 @@ import { createStore } from "@api/DataStore";
import { DataStore } from "@api/index";
import { Toasts } from "@webpack/common";
import noteHandler, { noteHandlerCache } from "./NoteHandler";
import noteHandler, { noteHandlerCache } from "./noteHandler";
import { HolyNotes } from "./types";
export const HolyNoteStore = createStore("HolyNoteData", "HolyNoteStore");

View file

@ -8,19 +8,10 @@ import { DataStore } from "@api/index";
import { addPreSendListener, removePreSendListener } from "@api/MessageEvents";
import { ExpandableHeader } from "@components/ExpandableHeader";
import { Heart } from "@components/Heart";
import { EquicordDevs } from "@utils/constants";
import { openUserProfile } from "@utils/discord";
import * as Modal from "@utils/modal";
import definePlugin from "@utils/types";
import {
Avatar, Button, ChannelStore,
Clickable, Flex, GuildMemberStore,
GuildStore,
MessageStore,
React,
Text, TextArea, TextInput, Tooltip,
UserStore
} from "@webpack/common";
import { Avatar, Button, ChannelStore, Clickable, Flex, GuildMemberStore, GuildStore, MessageStore, React, Text, TextArea, TextInput, Tooltip, UserStore, } from "@webpack/common";
import { Guild, User } from "discord-types/general";
interface IUserExtra {
@ -44,7 +35,7 @@ interface GroupData {
const constants = {
pluginLabel: "IRememberYou",
pluginId: "irememberyou",
pluginId: "iremeberyou",
DM: "dm",
DataUIDescription:
@ -368,10 +359,17 @@ class DataUI {
}
export default definePlugin({
name: "IRememberYou",
description: "Locally saves everyone you've been communicating with (including servers), in case of lose",
authors: [EquicordDevs.zoodogood],
name: constants.pluginLabel,
description:
"Locally saves everyone you've been communicating with (including servers), in case of lose",
authors: [
{
name: "zoodogood",
id: 921403577539387454n,
},
],
dependencies: ["MessageEventsAPI"],
patches: [],
async start() {

View file

@ -11,81 +11,240 @@ import { definePluginSettings } from "@api/Settings";
import { Flex } from "@components/Flex";
import { DeleteIcon } from "@components/Icons";
import { EquicordDevs } from "@utils/constants";
import { Margins } from "@utils/margins";
import { useForceUpdater } from "@utils/react";
import definePlugin, { OptionType } from "@utils/types";
import { Button, Forms, TextInput, UserStore, UserUtils, useState } from "@webpack/common";
import { User } from "discord-types/general";
import { findByCodeLazy, findByPropsLazy } from "@webpack";
import { Button, ChannelStore, Forms, SearchableSelect, SelectedChannelStore, TabBar, TextInput, UserStore, UserUtils, useState } from "@webpack/common";
import { Message, User } from "discord-types/general/index.js";
let regexes: string[] = [];
let me: User | null = null;
let keywordEntries: Array<{ regex: string, listIds: Array<string>, listType: ListType; }> = [];
let currentUser: User;
let keywordLog: Array<any> = [];
async function setRegexes(idx: number, reg: string) {
regexes[idx] = reg;
await DataStore.set("KeywordNotify_rules", regexes);
}
const MenuHeader = findByCodeLazy(".useInDesktopNotificationCenterExperiment)()?");
const Popout = findByPropsLazy("ItemsPopout");
const recentMentionsPopoutClass = findByPropsLazy("recentMentionsPopout");
const KEYWORD_ENTRIES_KEY = "KeywordNotify_keywordEntries";
const KEYWORD_LOG_KEY = "KeywordNotify_log";
async function removeRegex(idx: number, updater: () => void) {
regexes.splice(idx, 1);
await DataStore.set("KeywordNotify_rules", regexes);
const { createMessageRecord } = findByPropsLazy("createMessageRecord", "updateMessageRecord");
async function addKeywordEntry(updater: () => void) {
keywordEntries.push({ regex: "", listIds: [], listType: ListType.BlackList });
await DataStore.set(KEYWORD_ENTRIES_KEY, keywordEntries);
updater();
}
async function addRegex(updater: () => void) {
regexes.push("");
await DataStore.set("KeywordNotify_rules", regexes);
async function removeKeywordEntry(idx: number, updater: () => void) {
keywordEntries.splice(idx, 1);
await DataStore.set(KEYWORD_ENTRIES_KEY, keywordEntries);
updater();
}
function safeMatchesRegex(s: string, r: string) {
try {
return s.match(new RegExp(r));
} catch {
return false;
}
}
enum ListType {
BlackList = "BlackList",
Whitelist = "Whitelist"
}
function highlightKeywords(s: string, r: Array<string>) {
let regex: RegExp;
try {
regex = new RegExp(r.join("|"), "g");
} catch {
return [s];
}
const matches = s.match(regex);
if (!matches)
return [s];
const parts = [...matches.map(e => {
const idx = s.indexOf(e);
const before = s.substring(0, idx);
s = s.substring(idx + e.length);
return before;
}, s), s];
return parts.map(e => [
(<span>{e}</span>),
matches!.length ? (<span className="highlight">{matches!.splice(0, 1)[0]}</span>) : []
]);
}
function Collapsible({ title, children }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<Button
onClick={() => setIsOpen(!isOpen)}
look={Button.Looks.BLANK}
size={Button.Sizes.ICON}
className="keywordnotify-collapsible">
<div style={{ display: "flex", alignItems: "center" }}>
<div style={{ marginLeft: "auto", color: "var(--text-muted)" }}>{isOpen ? "▲" : "▼"}</div>
<Forms.FormTitle tag="h4">{title}</Forms.FormTitle>
</div>
</Button>
{isOpen && children}
</div>
);
}
function ListedIds({ listIds, setListIds }) {
const update = useForceUpdater();
const [values] = useState(listIds);
async function onChange(e: string, index: number) {
values[index] = e;
setListIds(values);
update();
}
const elements = values.map((currentValue: string, index: number) => {
return (
<Flex flexDirection="row" style={{ marginBottom: "5px" }}>
<div style={{ flexGrow: 1 }}>
<TextInput
placeholder="ID"
spellCheck={false}
value={currentValue}
onChange={e => onChange(e, index)}
/>
</div>
<Button
onClick={() => {
values.splice(index, 1);
setListIds(values);
update();
}}
look={Button.Looks.BLANK}
size={Button.Sizes.ICON}
className="keywordnotify-delete">
<DeleteIcon />
</Button>
</Flex>
);
});
return (
<>
{elements}
</>
);
}
function ListTypeSelector({ listType, setListType }) {
return (
<SearchableSelect
options={[
{ label: "Whitelist", value: ListType.Whitelist },
{ label: "Blacklist", value: ListType.BlackList }
]}
placeholder={"Select a list type"}
maxVisibleItems={2}
closeOnSelect={true}
value={listType}
onChange={setListType}
/>
);
}
function KeywordEntries() {
const update = useForceUpdater();
const [values] = useState(keywordEntries);
async function setRegex(index: number, value: string) {
keywordEntries[index].regex = value;
await DataStore.set(KEYWORD_ENTRIES_KEY, keywordEntries);
update();
}
async function setListType(index: number, value: ListType) {
keywordEntries[index].listType = value;
await DataStore.set(KEYWORD_ENTRIES_KEY, keywordEntries);
update();
}
async function setListIds(index: number, value: Array<string>) {
keywordEntries[index].listIds = value ?? [];
await DataStore.set(KEYWORD_ENTRIES_KEY, keywordEntries);
update();
}
const elements = keywordEntries.map((entry, i) => {
return (
<>
<Collapsible title={`Keyword Entry ${i + 1}`}>
<Flex flexDirection="row">
<div style={{ flexGrow: 1 }}>
<TextInput
placeholder="example|regex"
spellCheck={false}
value={values[i].regex}
onChange={e => setRegex(i, e)}
/>
</div>
<Button
onClick={() => removeKeywordEntry(i, update)}
look={Button.Looks.BLANK}
size={Button.Sizes.ICON}
className="keywordnotify-delete">
<DeleteIcon />
</Button>
</Flex>
<Forms.FormDivider className={Margins.top8 + " " + Margins.bottom8} />
<Forms.FormTitle tag="h5">Whitelist/Blacklist</Forms.FormTitle>
<Flex flexDirection="row">
<div style={{ flexGrow: 1 }}>
<ListedIds listIds={values[i].listIds} setListIds={e => setListIds(i, e)} />
</div>
</Flex>
<div className={Margins.top8 + " " + Margins.bottom8} />
<Flex flexDirection="row">
<Button onClick={() => {
values[i].listIds.push("");
update();
}}>Add ID</Button>
<div style={{ flexGrow: 1 }}>
<ListTypeSelector listType={values[i].listType} setListType={e => setListType(i, e)} />
</div>
</Flex>
</Collapsible>
</>
);
});
return (
<>
{elements}
<div><Button onClick={() => addKeywordEntry(update)}>Add Keyword Entry</Button></div>
</>
);
}
const settings = definePluginSettings({
replace: {
ignoreBots: {
type: OptionType.BOOLEAN,
description: "Ignore messages from bots",
default: true
},
keywords: {
type: OptionType.COMPONENT,
description: "",
component: () => {
const update = useForceUpdater();
const [values, setValues] = useState(regexes);
const elements = regexes.map((a, i) => {
const setValue = (v: string) => {
const valuesCopy = [...values];
valuesCopy[i] = v;
setValues(valuesCopy);
};
return (
<>
<Forms.FormTitle tag="h4">Keyword Regex {i + 1}</Forms.FormTitle>
<Flex flexDirection="row">
<div style={{ flexGrow: 1 }}>
<TextInput
placeholder={"example|regex"}
spellCheck={false}
value={values[i]}
onChange={setValue}
onBlur={() => setRegexes(i, values[i])}
/>
</div>
<Button
onClick={() => removeRegex(i, update)}
look={Button.Looks.BLANK}
size={Button.Sizes.ICON}
className="keywordnotify-delete">
<DeleteIcon />
</Button>
</Flex>
</>
);
});
return (
<>
{elements}
<div><Button onClick={() => addRegex(update)}>Add Regex</Button></div>
</>
);
}
},
component: () => <KeywordEntries />
}
});
export default definePlugin({
@ -97,29 +256,184 @@ export default definePlugin({
{
find: "}_dispatch(",
replacement: {
match: /}_dispatch\((.{1,2}),.{1,2}\){/,
match: /}_dispatch\((\i),\i\){/,
replace: "$&$1=$self.modify($1);"
}
},
{
find: "Messages.UNREADS_TAB_LABEL}",
replacement: {
match: /\i\?\(0,\i\.jsxs\)\(\i\.TabBar\.Item/,
replace: "$self.keywordTabBar(),$&"
}
},
{
find: "InboxTab.TODOS?(",
replacement: {
match: /:\i&&(\i)===\i\.InboxTab\.TODOS.{1,50}setTab:(\i),onJump:(\i),closePopout:(\i)/,
replace: ": $1 === 5 ? $self.tryKeywordMenu($2, $3, $4) $&"
}
},
{
find: ".guildFilter:null",
replacement: {
match: /function (\i)\(\i\){let{message:\i,gotoMessage/,
replace: "$self.renderMsg = $1; $&"
}
}
],
async start() {
regexes = await DataStore.get("KeywordNotify_rules") ?? [];
me = await UserUtils.getUser(UserStore.getCurrentUser().id);
keywordEntries = await DataStore.get(KEYWORD_ENTRIES_KEY) ?? [];
currentUser = await UserUtils.getUser(UserStore.getCurrentUser().id);
this.onUpdate = () => null;
(await DataStore.get(KEYWORD_LOG_KEY) ?? []).map(e => JSON.parse(e)).forEach(e => {
this.addToLog(e);
});
},
applyRegexes(m) {
if (regexes.some(r => m.content.match(new RegExp(r)))) {
m.mentions.push(me);
applyKeywordEntries(m: Message) {
let matches = false;
keywordEntries.forEach(entry => {
if (entry.regex === "") {
return;
}
let listed = entry.listIds.some(id => id === m.channel_id || id === m.author.id);
if (!listed) {
const channel = ChannelStore.getChannel(m.channel_id);
if (channel != null) {
listed = entry.listIds.some(id => id === channel.guild_id);
}
}
const whitelistMode = entry.listType === ListType.Whitelist;
if (!whitelistMode && listed) {
return;
}
if (whitelistMode && !listed) {
return;
}
if (settings.store.ignoreBots && m.author.bot) {
if (!whitelistMode || !entry.listIds.includes(m.author.id)) {
return;
}
}
if (safeMatchesRegex(m.content, entry.regex)) {
matches = true;
}
for (const embed of m.embeds as any) {
if (safeMatchesRegex(embed.description, entry.regex) || safeMatchesRegex(embed.title, entry.regex)) {
matches = true;
} else if (embed.fields != null) {
for (const field of embed.fields as Array<{ name: string, value: string; }>) {
if (safeMatchesRegex(field.value, entry.regex) || safeMatchesRegex(field.name, entry.regex)) {
matches = true;
}
}
}
}
});
if (matches) {
// @ts-ignore
m.mentions.push(currentUser);
if (m.author.id !== currentUser.id)
this.addToLog(m);
}
},
addToLog(m: Message) {
if (m == null || keywordLog.some(e => e.id === m.id))
return;
const thing = createMessageRecord(m);
keywordLog.push(thing);
keywordLog.sort((a, b) => b.timestamp - a.timestamp);
if (keywordLog.length > 50)
keywordLog.pop();
this.onUpdate();
},
keywordTabBar() {
return (
<TabBar.Item className="vc-settings-tab-bar-item" id={5}>
Keywords
</TabBar.Item>
);
},
tryKeywordMenu(setTab, onJump, closePopout) {
const header = (
<MenuHeader tab={5} setTab={setTab} closePopout={closePopout} badgeState={{ badgeForYou: false }} />
);
const channel = ChannelStore.getChannel(SelectedChannelStore.getChannelId());
const [tempLogs, setKeywordLog] = useState(keywordLog);
this.onUpdate = () => {
const newLog = [...keywordLog];
setKeywordLog(newLog);
DataStore.set(KEYWORD_LOG_KEY, newLog.map(e => JSON.stringify(e)));
};
const onDelete = m => {
keywordLog = keywordLog.filter(e => e.id !== m.id);
this.onUpdate();
};
const messageRender = (e, t) => {
const msg = this.renderMsg({
message: e,
gotoMessage: t,
dismissible: true
});
if (msg == null)
return [null];
msg.props.children[0].props.children.props.onClick = () => onDelete(e);
msg.props.children[1].props.children[1].props.message.customRenderedContent = {
content: highlightKeywords(e.content, keywordEntries.map(e => e.regex))
};
return [msg];
};
return (
<>
<Popout.default
className={recentMentionsPopoutClass.recentMentionsPopout}
renderHeader={() => header}
renderMessage={messageRender}
channel={channel}
onJump={onJump}
onFetch={() => null}
onCloseMessage={onDelete}
loadMore={() => null}
messages={tempLogs}
renderEmptyState={() => null}
/>
</>
);
},
modify(e) {
if (e.type === "MESSAGE_CREATE") {
this.applyRegexes(e.message);
this.applyKeywordEntries(e.message);
} else if (e.type === "LOAD_MESSAGES_SUCCESS") {
for (let msg = 0; msg < e.messages.length; ++msg) {
this.applyRegexes(e.messages[msg]);
this.applyKeywordEntries(e.messages[msg]);
}
}
return e;

View file

@ -1,9 +1,17 @@
/* stylelint-disable no-descending-specificity */
.keywordnotify-delete:hover {
color: var(--status-danger);
}
.keywordnotify-delete {
padding: 0;
color: var(--primary-400);
transition: color 0.2s ease-in-out;
}
.keywordnotify-delete:hover {
color: var(--status-danger);
}
.keywordnotify-collapsible {
display: flex;
align-items: center;
padding: 8px;
cursor: pointer;
}

View file

@ -99,7 +99,6 @@ function MessagePreview({ channelId, messageId }) {
function useMessage(channelId, messageId) {
const cachedMessage = useStateFromStores(
// @ts-ignore
[MessageStore],
() => MessageStore.getMessage(channelId, messageId)
);

View file

@ -4,13 +4,12 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "noAppsAllowed",
description: "returns the bot's tag :skulk:",
authors: [EquicordDevs.kvba],
authors: [{ name: "kvba", id: 105170831130234880n }],
patches: [
{

View file

@ -48,6 +48,5 @@ export default definePlugin({
case "web":
return { browser: "Chrome" };
}
}
});

View file

@ -36,16 +36,6 @@ const messagePatch: NavContextMenuPatchCallback = (children, { message }) => ()
);
};
export default definePlugin({
name: "Quoter",
description: "Adds the ability to create a quote image from a message",
authors: [Devs.Samwich],
contextMenus: {
"message": messagePatch
}
});
export function QuoteIcon({
height = 24,
width = 24,
@ -70,11 +60,9 @@ function sizeUpgrade(url) {
return u.toString();
}
let preparingSentence: string[] = [];
const lines: string[] = [];
async function createQuoteImage(avatarUrl: string, name: string, quoteOld: string, grayScale: boolean): Promise<Blob> {
const quote = removeCustomEmojis(quoteOld);
const canvas = document.createElement("canvas");
@ -141,7 +129,6 @@ async function createQuoteImage(avatarUrl: string, name: string, quoteOld: strin
return new Promise<Blob>(resolve => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
throw new Error("Failed to create Blob");
@ -198,10 +185,8 @@ function QuoteModal(props: ModalProps) {
useEffect(() => {
grayscale = gray;
GeneratePreview();
}, [gray]);
return (
<ModalRoot {...props} size={ModalSize.MEDIUM}>
<ModalHeader separator={false}>
<Text color="header-primary" variant="heading-lg/semibold" tag="h1" style={{ flexGrow: 1 }}>
@ -215,7 +200,6 @@ function QuoteModal(props: ModalProps) {
<Switch value={gray} onChange={setGray}>Grayscale</Switch>
<Button color={Button.Colors.BRAND_NEW} size={Button.Sizes.SMALL} onClick={() => Export()} style={{ display: "inline-block", marginRight: "5px" }}>Export</Button>
<Button color={Button.Colors.BRAND_NEW} size={Button.Sizes.SMALL} onClick={() => SendInChat(props.onClose)} style={{ display: "inline-block" }}>Send</Button>
</ModalContent>
<br></br>
</ModalRoot>
@ -223,17 +207,14 @@ function QuoteModal(props: ModalProps) {
}
async function SendInChat(onClose) {
const image = await createQuoteImage(sizeUpgrade(recentmessage.author.getAvatarURL()), recentmessage.author.username, recentmessage.content, grayscale);
const preview = generateFileNamePreview(recentmessage.content);
const imageName = `${preview} - ${recentmessage.author.username}`;
const file = new File([image], `${imageName}.png`, { type: "image/png" });
UploadHandler.promptToUpload([file], getCurrentChannel(), 0);
onClose();
}
async function Export() {
const image = await createQuoteImage(sizeUpgrade(recentmessage.author.getAvatarURL()), recentmessage.author.username, recentmessage.content, grayscale);
const link = document.createElement("a");
@ -246,22 +227,27 @@ async function Export() {
link.remove();
}
async function GeneratePreview() {
const image = await createQuoteImage(sizeUpgrade(recentmessage.author.getAvatarURL()), recentmessage.author.username, recentmessage.content, grayscale);
document.getElementById("quoterPreview")?.setAttribute("src", URL.createObjectURL(image));
}
function generateFileNamePreview(message) {
const words = message.split(" ");
let preview;
if (words.length >= 6) {
preview = words.slice(0, 6).join(" ");
}
else {
} else {
preview = words.slice(0, words.length).join(" ");
}
return preview;
}
export default definePlugin({
name: "Quoter",
description: "Adds the ability to create a quote image from a message",
authors: [Devs.Samwich],
contextMenus: {
"message": messagePatch
}
});

View file

@ -1,17 +1,15 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* Copyright (c) 2023 Vendicated, MrDiamond, ant0n, and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { MessageStore, showToast, UserStore } from "@webpack/common";
import { MessageStore, UserStore } from "@webpack/common";
import { MessageJSON } from "discord-types/general";
let cachedWhitelist: string[] = [];
export const settings = definePluginSettings({
alwaysPingOnReply: {
type: OptionType.BOOLEAN,
@ -23,20 +21,6 @@ export const settings = definePluginSettings({
description: "Comma-separated list of User IDs to always receive reply pings from",
default: "",
disabled: () => settings.store.alwaysPingOnReply,
onChange: newValue => {
const originalIDs = newValue.split(",")
.map(id => id.trim())
.filter(id => id !== "");
const isInvalid = originalIDs.some(id => !isValidUserId(id));
if (isInvalid) {
showToast("Invalid User ID: One or more User IDs in the whitelist are invalid. Please check your input.");
} else {
cachedWhitelist = originalIDs;
showToast("Whitelist Updated: Reply ping whitelist has been successfully updated.");
}
}
}
});
@ -56,18 +40,18 @@ export default definePlugin({
modifyMentions(message: MessageJSON) {
const user = UserStore.getCurrentUser();
if (message.author.id === user.id)
return;
if (message.author.id === user.id) return;
const repliedMessage = this.getRepliedMessage(message);
if (!repliedMessage || repliedMessage.author.id !== user.id)
return;
if (!repliedMessage || repliedMessage.author.id !== user.id) return;
const isWhitelisted = cachedWhitelist.includes(message.author.id);
const whitelist = settings.store.replyPingWhitelist.split(",").map(id => id.trim());
const isWhitelisted = settings.store.replyPingWhitelist.includes(message.author.id);
if (isWhitelisted || settings.store.alwaysPingOnReply) {
if (!message.mentions.some(mention => mention.id === user.id))
if (!message.mentions.some(mention => mention.id === user.id)) {
message.mentions.push(user as any);
}
} else {
message.mentions = message.mentions.filter(mention => mention.id !== user.id);
}
@ -78,13 +62,3 @@ export default definePlugin({
return ref && MessageStore.getMessage(ref.channel_id, ref.message_id);
},
});
function parseWhitelist(value: string) {
return value.split(",")
.map(id => id.trim())
.filter(id => id !== "");
}
function isValidUserId(id: string) {
return /^\d+$/.test(id);
}

View file

@ -23,10 +23,10 @@ export default definePlugin({
name: "SearchFix",
description: 'Fixes the annoying "We dropped the magnifying glass!" error.',
settingsAboutComponent: () => <span style={{ color: "white" }}><i><b>This fix isn't perfect, so you may have to reload the search bar to fix issues.</b></i> Discord only allows a max offset of 5000 (this is what causes the magnifying glass error). This means that you can only see precisely 5000 messages into the past, and 5000 messages into the future (when sorting by old). This plugin just jumps to the opposite sorting method to try get around Discord's restriction, but if there is a large search result, and you try to view a message that is unobtainable with both methods of sorting, the plugin will simply show offset 0 (either newest or oldest message depending on the sorting method).</span>,
authors: [EquicordDevs.jaxx],
authors: [EquicordDevs.Jaxx],
patches: [
{
find: '"SearchStore"',
find: ".displayName=\"SearchStore\";",
replacement: {
match: /(\i)\.offset=null!==\((\i)=(\i)\.offset\)&&void 0!==(\i)\?(\i):0/i,
replace: (_, v, v1, query, v3, v4) => `$self.main(${query}), ${v}.offset = null !== (${v1} = ${query}.offset) && void 0 !== ${v3} ? ${v4} : 0`
@ -34,13 +34,14 @@ export default definePlugin({
}
],
main(query) {
if (query.offset <= 5000) return;
query.sort_order = query.sort_order === "asc" ? "desc" : "asc";
if (query.offset > 5000) {
query.sort_order = query.sort_order === "asc" ? "desc" : "asc";
if (query.offset > 5000 - 5000) {
query.offset = 0;
} else {
query.offset -= 5000;
if (query.offset > 5000 - 5000) {
query.offset = 0;
} else {
query.offset -= 5000;
}
}
}
});

View file

@ -18,7 +18,7 @@ const settings = definePluginSettings({
type: OptionType.BOOLEAN,
description: "Auto close modal when done",
default: true
},
}
});
const SekaiStickerChatButton: ChatBarButton = () => {

View file

@ -7,24 +7,59 @@
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Button, GuildMemberStore, UserProfileStore, UserStore } from "@webpack/common";
import {
Button,
Clipboard,
GuildMemberStore,
Text,
Toasts,
UserProfileStore,
UserStore
} from "@webpack/common";
import { GuildMember } from "discord-types/general";
const SummaryItem = findComponentByCodeLazy("borderType", "showBorder", "hideDivider");
let savedNick: string | null = null;
let savedPronouns: string | null = null;
let savedBio: string | undefined = undefined;
let savedThemeColors: number[] | undefined = undefined;
let savedBanner: string | undefined = undefined;
let savedAvatar: string | undefined = undefined;
interface SavedProfile {
nick: string | null;
pronouns: string | null;
bio: string | null;
themeColors: number[] | undefined;
banner: string | undefined;
avatar: string | undefined;
profileEffectId: string | undefined;
avatarDecoration: string | undefined;
}
const { setPendingAvatar, setPendingBanner, setPendingBio, setPendingNickname, setPendingPronouns, setPendingThemeColors }: {
const savedProfile: SavedProfile = {
nick: null,
pronouns: null,
bio: null,
themeColors: undefined,
banner: undefined,
avatar: undefined,
profileEffectId: undefined,
avatarDecoration: undefined,
};
const {
setPendingAvatar,
setPendingBanner,
setPendingBio,
setPendingNickname,
setPendingPronouns,
setPendingThemeColors,
setPendingProfileEffectId,
setPendingAvatarDecoration,
}: {
setPendingAvatar: (a: string | undefined) => void;
setPendingBanner: (a: string | undefined) => void;
setPendingBio: (a: string | undefined) => void;
setPendingBio: (a: string | null) => void;
setPendingNickname: (a: string | null) => void;
setPendingPronouns: (a: string | null) => void;
setPendingThemeColors: (a: number[] | undefined) => void;
setPendingProfileEffectId: (a: string | undefined) => void;
setPendingAvatarDecoration: (a: string | undefined) => void;
} = findByPropsLazy("setPendingNickname", "setPendingPronouns");
export default definePlugin({
@ -36,47 +71,108 @@ export default definePlugin({
const currentUser = UserStore.getCurrentUser();
const premiumType = currentUser.premiumType ?? 0;
const copy = () => {
const profile = UserProfileStore.getGuildMemberProfile(currentUser.id, guildId);
const nick = GuildMemberStore.getNick(guildId, currentUser.id);
const selfMember = GuildMemberStore.getMember(guildId, currentUser.id) as GuildMember & { avatarDecoration: string | undefined; };
savedProfile.nick = nick ?? "";
savedProfile.pronouns = profile.pronouns;
savedProfile.bio = profile.bio;
savedProfile.themeColors = profile.themeColors;
savedProfile.banner = profile.banner;
savedProfile.avatar = selfMember.avatar;
savedProfile.profileEffectId = profile.profileEffectId;
savedProfile.avatarDecoration = selfMember.avatarDecoration;
};
const paste = () => {
setPendingNickname(savedProfile.nick);
setPendingPronouns(savedProfile.pronouns);
if (premiumType === 2) {
setPendingBio(savedProfile.bio);
setPendingThemeColors(savedProfile.themeColors);
setPendingBanner(savedProfile.banner);
setPendingAvatar(savedProfile.avatar);
setPendingProfileEffectId(savedProfile.profileEffectId);
setPendingAvatarDecoration(savedProfile.avatarDecoration);
}
};
const reset = () => {
setPendingNickname(null);
setPendingPronouns("");
if (premiumType === 2) {
setPendingBio(null);
setPendingThemeColors([]);
setPendingBanner(undefined);
setPendingAvatar(undefined);
setPendingProfileEffectId(undefined);
setPendingAvatarDecoration(undefined);
}
};
const copyToClipboard = () => {
copy();
Clipboard.copy(JSON.stringify(savedProfile));
};
const pasteFromClipboard = async () => {
try {
const clip = await navigator.clipboard.readText();
if (!clip) {
Toasts.show({
message: "Clipboard is empty",
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
});
return;
}
const clipboardProfile: SavedProfile = JSON.parse(clip);
if (!("nick" in clipboardProfile)) {
Toasts.show({
message: "Data is not in correct format",
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
});
return;
}
Object.assign(savedProfile, JSON.parse(clip));
paste();
} catch (e) {
Toasts.show({
message: `Failed to read clipboard data: ${e}`,
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
});
}
};
return <SummaryItem title="Server Profiles Toolbox" hideDivider={false} forcedDivider>
<div style={{ display: "flex", gap: "5px" }}>
<Button onClick={() => {
const profile = UserProfileStore.getGuildMemberProfile(currentUser.id, guildId);
const nick = GuildMemberStore.getNick(guildId, currentUser.id);
const selfMember = GuildMemberStore.getMember(guildId, currentUser.id);
savedNick = nick ?? "";
savedPronouns = profile.pronouns;
savedBio = profile.bio;
savedThemeColors = profile.themeColors;
savedBanner = profile.banner;
savedAvatar = selfMember.avatar;
}}>
Copy profile
</Button>
<Button onClick={() => {
// set pending
setPendingNickname(savedNick);
setPendingPronouns(savedPronouns);
if (premiumType === 2) {
setPendingBio(savedBio);
setPendingThemeColors(savedThemeColors);
setPendingBanner(savedBanner);
setPendingAvatar(savedAvatar);
}
}}>
Paste profile
</Button>
<Button onClick={() => {
// reset
setPendingNickname("");
setPendingPronouns("");
if (premiumType === 2) {
setPendingBio("");
setPendingThemeColors([]);
setPendingBanner("");
setPendingAvatar("");
}
}}>
Reset profile
</Button>
<div style={{ display: "flex", alignItems: "center", flexDirection: "column", gap: "5px" }}>
<Text variant="text-md/normal">
Use the following buttons to mange the currently selected server
</Text>
<div style={{ display: "flex", gap: "5px" }}>
<Button onClick={copy}>
Copy profile
</Button>
<Button onClick={paste}>
Paste profile
</Button>
<Button onClick={reset}>
Reset profile
</Button>
</div>
<div style={{ display: "flex", gap: "5px" }}>
<Button onClick={copyToClipboard}>
Copy to clipboard
</Button>
<Button onClick={pasteFromClipboard}>
Paste from clipboard
</Button>
</div>
</div>
</SummaryItem>;
},

View file

@ -8,14 +8,16 @@ import { addDecoration, removeDecoration } from "@api/MessageDecorations";
import { Devs, EquicordDevs } from "@utils/constants";
import { isEquicordPluginDev, isPluginDev } from "@utils/misc";
import definePlugin from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import badges from "plugins/_api/badges";
const roleIconClassName = findByPropsLazy("roleIcon", "separator").roleIcon;
const RoleIconComponent = findComponentByCodeLazy(".Messages.ROLE_ICON_ALT_TEXT");
import "./styles.css";
import { User } from "discord-types/general";
import badges from "../../plugins/_api/badges";
import settings from "./settings";
let RoleIconComponent: React.ComponentType<any> = () => null;
let roleIconClassName: string;
const discordBadges: readonly [number, string, string][] = Object.freeze([
[0, "Discord Staff", "5e74e9b61934fc1f67c65515d1f7e60d"],
[1, "Partnered Server Owner", "3f9748e53446a137a052f3454e2de41e"],
@ -31,7 +33,8 @@ const discordBadges: readonly [number, string, string][] = Object.freeze([
[18, "Moderator Programs Alumni", "fee1624003e2fee35cb398e125dc479b"]
]);
function CheckBadge({ badge, author }: { badge: string; author: any; }): JSX.Element | null {
function CheckBadge({ badge, author }: { badge: string; author: User; }): JSX.Element | null {
switch (badge) {
case "EquicordDonor":
return (
@ -51,7 +54,7 @@ function CheckBadge({ badge, author }: { badge: string; author: any; }): JSX.Ele
<span style={{ order: settings.store.EquicordContributorPosition }}>
<RoleIconComponent
className={roleIconClassName}
name={"Equicord Contributor"}
name="Equicord Contributor"
size={20}
src={"https://i.imgur.com/UpcDwX0.png"}
/>
@ -60,7 +63,7 @@ function CheckBadge({ badge, author }: { badge: string; author: any; }): JSX.Ele
case "VencordDonor":
return (
<span style={{ order: settings.store.VencordDonorPosition }}>
{badges.getDonorBadges(author.id)?.map((badge: any) => (
{badges.getDonorBadges(author.id)?.map(badge => (
<RoleIconComponent
className={roleIconClassName}
name={badge.description}
@ -75,7 +78,7 @@ function CheckBadge({ badge, author }: { badge: string; author: any; }): JSX.Ele
<span style={{ order: settings.store.VencordContributorPosition }}>
<RoleIconComponent
className={roleIconClassName}
name={"Vencord Contributor"}
name="Vencord Contributor"
size={20}
src={"https://vencord.dev/assets/favicon.png"}
/>
@ -83,8 +86,9 @@ function CheckBadge({ badge, author }: { badge: string; author: any; }): JSX.Ele
) : null;
case "DiscordProfile":
const chatBadges = discordBadges
.filter((badge: any) => (author.flags || author.publicFlags) & (1 << badge[0]))
.map((badge: any) => (
.filter(badge => (author.flags || author.publicFlags) & (1 << badge[0]))
.map(badge => (
<RoleIconComponent
className={roleIconClassName}
name={badge[1]}
@ -98,7 +102,7 @@ function CheckBadge({ badge, author }: { badge: string; author: any; }): JSX.Ele
</span>
) : null;
case "DiscordNitro":
return author.premiumType > 0 ? (
return (author?.premiumType ?? 0) > 0 ? (
<span style={{ order: settings.store.DiscordNitroPosition }}>
<RoleIconComponent
className={roleIconClassName}
@ -116,9 +120,10 @@ function CheckBadge({ badge, author }: { badge: string; author: any; }): JSX.Ele
}
}
function ChatBadges({ author }: any) {
function ChatBadges({ author }: { author: User; }) {
return (
<span style={{ display: "inline-flex", marginLeft: 2, verticalAlign: "top" }}>
<span className="vc-sbic-badge-row">
{settings.store.showEquicordDonor && <CheckBadge badge={"EquicordDonor"} author={author} />}
{settings.store.showEquicordContributor && <CheckBadge badge={"EquicordContributer"} author={author} />}
{settings.store.showVencordDonor && <CheckBadge badge={"VencordDonor"} author={author} />}
@ -134,22 +139,9 @@ export default definePlugin({
authors: [Devs.Inbestigator, EquicordDevs.KrystalSkull],
description: "Shows the message author's badges beside their name in chat.",
dependencies: ["MessageDecorationsAPI"],
patches: [
{
find: "Messages.ROLE_ICON_ALT_TEXT",
replacement: {
match: /function\s+\w+?\(\w+?\)\s*{let\s+\w+?,\s*{className:.+}\)}/,
replace: "$self.RoleIconComponent=$&;$&",
}
}
],
settings,
set RoleIconComponent(component: any) {
RoleIconComponent = component;
},
start: () => {
roleIconClassName = findByPropsLazy("roleIcon", "separator").roleIcon;
addDecoration("vc-show-badges-in-chat", props => <ChatBadges author={props.message?.author} />);
addDecoration("vc-show-badges-in-chat", props => props.message?.author ? <ChatBadges author={props.message.author} /> : null);
},
stop: () => {
removeDecoration("vc-show-badges-in-chat");

View file

@ -4,8 +4,6 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
import { Text, useEffect, UserStore, useState } from "@webpack/common";
@ -37,7 +35,7 @@ const settings = definePluginSettings({
},
showVencordDonor: {
type: OptionType.BOOLEAN,
description: "Enable to show Vencord Donor badges in chat.",
description: "Enable to show Vencord donor badges in chat.",
hidden: true,
default: true
},
@ -49,7 +47,7 @@ const settings = definePluginSettings({
},
showVencordContributor: {
type: OptionType.BOOLEAN,
description: "Enable to show Vencord Contributor badges in chat.",
description: "Enable to show Vencord contributor badges in chat.",
hidden: true,
default: true
},
@ -171,22 +169,21 @@ const BadgeSettings = () => {
return (
<>
<Text>Drag the badges to reorder them, you can click to enable/disable a specific badge type.</Text>
<div className="badge-settings">
<img style={{ width: "55px", height: "55px", borderRadius: "50%", marginRight: "15px" }} src={UserStore.getCurrentUser().getAvatarURL()}></img>
<Text style={{ fontSize: "22px", color: "white", marginRight: "7.5px" }}>{(UserStore.getCurrentUser() as any).globalName}</Text>
<div className="vc-sbic-badge-settings">
<img className="vc-sbic-settings-avatar" src={UserStore.getCurrentUser().getAvatarURL()}></img>
<Text className="vc-sbic-settings-username">{(UserStore.getCurrentUser() as any).globalName}</Text>
{images
.sort((a, b) => a.position - b.position)
.map((image, index) => (
<div
key={image.key}
className={`image-container ${!image.shown ? "disabled" : ""}`}
className={`vc-sbic-image-container ${!image.shown ? "vc-sbic-disabled" : ""}`}
onDragOver={e => handleDragOver(e)}
onDrop={e => handleDrop(e, index)}
onClick={() => toggleDisable(index)}
>
<img
src={image.src}
alt={image.title}
draggable={image.shown}
onDragStart={e => handleDragStart(e, index)}
title={image.title}

View file

@ -1,21 +1,40 @@
.badge-settings {
.vc-sbic-badge-settings {
display: flex;
gap: 5px;
flex-direction: row;
}
.image-container {
.vc-sbic-image-container {
position: relative;
transition: 0.15s;
}
.image-container img {
.vc-sbic-image-container img {
width: 25px;
height: 25px;
cursor: pointer;
}
.disabled {
.vc-sbic-disabled {
opacity: 0.5;
scale: 0.95;
}
.vc-sbic-settings-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
margin-right: 12px;
}
.vc-sbic-settings-username {
font-size: 22px;
color: white;
margin-right: 5px;
}
.vc-sbic-badge-row {
display: inline-flex;
margin-left: 2;
vertical-align: top;
}

View file

@ -21,7 +21,7 @@ export default definePlugin({
name: "SoundBoardLogger",
authors: [
Devs.Moxxie,
EquicordDevs.fres,
EquicordDevs.Fres,
Devs.echo,
EquicordDevs.thororen
],

View file

@ -11,6 +11,7 @@ import { generateId } from "@api/Commands";
import { Settings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { CodeBlock } from "@components/CodeBlock";
import { ErrorCard } from "@components/ErrorCard";
import { OpenExternalIcon } from "@components/Icons";
import { SettingsTab, wrapTab } from "@components/VencordSettings/shared";
import { proxyLazy } from "@utils/lazy";
@ -160,229 +161,256 @@ function ThemeTab() {
fontSize: "1.5em",
color: "var(--text-muted)"
}}>Loading Themes...</div>
) : (<>
<div className={`${Margins.bottom8} ${Margins.top16}`}>
<Forms.FormTitle tag="h2"
style={{
overflowWrap: "break-word",
marginTop: 8,
}}>
Newest Additions
</Forms.FormTitle>
) : (
<>
<ErrorCard id="vc-themetab-warning">
<Forms.FormTitle tag="h4">Want your theme removed?</Forms.FormTitle>
<Forms.FormText className={Margins.top8}>
If you want your theme(s) permanently removed, please open an issue on <a href="https://github.com/Faf4a/plugins/issues/new?labels=removal&projects=&template=request_removal.yml&title=Theme+Removal">GitHub <OpenExternalIcon height={16} width={16} /></a>
</Forms.FormText>
</ErrorCard >
<div className={`${Margins.bottom8} ${Margins.top16}`}>
<Forms.FormTitle tag="h2"
style={{
overflowWrap: "break-word",
marginTop: 8,
}}>
Newest Additions
</Forms.FormTitle>
{themes.slice(0, 2).map((theme: Theme) => (
<Card style={{
padding: ".5rem",
marginBottom: ".5em",
marginTop: ".5em",
display: "flex",
flexDirection: "column",
backgroundColor: "var(--background-secondary-alt)"
}} key={theme.id}>
<Forms.FormTitle tag="h2" style={{
overflowWrap: "break-word",
marginTop: 8,
}}
className="vce-theme-text">
{theme.name}
</Forms.FormTitle>
<Forms.FormText className="vce-theme-text">
{theme.description}
</Forms.FormText>
<div className="vce-theme-info">
<div style={{
justifyContent: "flex-start",
flexDirection: "column"
}}>
{theme.tags && (
<Forms.FormText>
{theme.tags.map(tag => (
<span className="vce-theme-info-tag">
{tag}
</span>
))}
</Forms.FormText>
)}
<div style={{ marginTop: "8px", display: "flex", flexDirection: "row" }}>
{themeLinks.includes(API_TYPE(theme)) ? (
<Button
onClick={() => {
const onlineThemeLinks = themeLinks.filter(x => x !== API_TYPE(theme));
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.RED}
look={Button.Looks.FILLED}
className={Margins.right8}
>
Remove Theme
</Button>
) : (
<Button
onClick={() => {
const onlineThemeLinks = [...themeLinks, API_TYPE(theme)];
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.GREEN}
look={Button.Looks.FILLED}
className={Margins.right8}
>
Add Theme
</Button>
{themes.slice(0, 2).map((theme: Theme) => (
<Card style={{
padding: ".5rem",
marginBottom: ".5em",
marginTop: ".5em",
display: "flex",
flexDirection: "column",
backgroundColor: "var(--background-secondary-alt)"
}} key={theme.id}>
<Forms.FormTitle tag="h2" style={{
overflowWrap: "break-word",
marginTop: 8,
}}
className="vce-theme-text">
{theme.name}
</Forms.FormTitle>
<Forms.FormText className="vce-theme-text">
{theme.description}
</Forms.FormText>
<div className="vce-theme-info">
<div style={{
justifyContent: "flex-start",
flexDirection: "column"
}}>
{theme.tags && (
<Forms.FormText>
{theme.tags.map(tag => (
<span className="vce-theme-info-tag">
{tag}
</span>
))}
</Forms.FormText>
)}
<Button
onClick={async () => {
const author = await getUser(theme.author.discord_snowflake, theme.author.discord_name);
openModal(props => <ThemeInfoModal {...props} author={author} theme={theme} />);
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.BRAND}
look={Button.Looks.FILLED}
>
Theme Info
</Button>
<Button
onClick={() => VencordNative.native.openExternal(API_TYPE(theme).replace("?raw=true", ""))}
size={Button.Sizes.MEDIUM}
color={Button.Colors.LINK}
look={Button.Looks.LINK}
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
View Source <OpenExternalIcon height={16} width={16} />
</Button>
<div style={{ marginTop: "8px", display: "flex", flexDirection: "row" }}>
{themeLinks.includes(API_TYPE(theme)) ? (
<Button
onClick={() => {
const onlineThemeLinks = themeLinks.filter(x => x !== API_TYPE(theme));
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.RED}
look={Button.Looks.FILLED}
className={Margins.right8}
>
Remove Theme
</Button>
) : (
<Button
onClick={() => {
const onlineThemeLinks = [...themeLinks, API_TYPE(theme)];
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.GREEN}
look={Button.Looks.FILLED}
className={Margins.right8}
>
Add Theme
</Button>
)}
<Button
onClick={async () => {
const author = await getUser(theme.author.discord_snowflake, theme.author.discord_name);
openModal(props => <ThemeInfoModal {...props} author={author} theme={theme} />);
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.BRAND}
look={Button.Looks.FILLED}
>
Theme Info
</Button>
<Button
onClick={() => {
const content = atob(theme.content);
const metadata = content.match(/\/\*\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g)?.[0] || "";
const source = metadata.match(/@source\s+(.+)/)?.[1] || "";
if (source) {
VencordNative.native.openExternal(source);
} else {
VencordNative.native.openExternal(API_TYPE(theme).replace("?raw=true", ""));
}
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.LINK}
look={Button.Looks.LINK}
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
View Source <OpenExternalIcon height={16} width={16} />
</Button>
</div>
</div>
</div>
</div>
</Card>
))}
</div>
<Forms.FormTitle tag="h2" style={{
overflowWrap: "break-word",
marginTop: 20,
}}>
Themes
</Forms.FormTitle>
<div className={cl("filter-controls")}>
<TextInput value={searchValue.value} placeholder="Search for a theme..." onChange={onSearch} />
<div className={InputStyles.inputWrapper}>
<Select
options={[
{ label: "Show All", value: SearchStatus.ALL, default: true },
{ label: "Show Themes", value: SearchStatus.THEME },
{ label: "Show Snippets", value: SearchStatus.SNIPPET },
{ label: "Show Enabled", value: SearchStatus.ENABLED },
{ label: "Show Disabled", value: SearchStatus.DISABLED },
{ label: "Show Dark", value: SearchStatus.DARK },
{ label: "Show Light", value: SearchStatus.LIGHT },
]}
serialize={String}
select={onStatusChange}
isSelected={v => v === searchValue.status}
closeOnSelect={true}
/>
</Card>
))}
</div>
</div>
<div>
{filteredThemes.map((theme: Theme) => (
<Card style={{
padding: ".5rem",
marginBottom: ".5em",
marginTop: ".5em",
display: "flex",
flexDirection: "column",
backgroundColor: "var(--background-secondary-alt)"
}} key={theme.id}>
<Forms.FormTitle tag="h2" style={{
overflowWrap: "break-word",
marginTop: 8,
}}
className="vce-theme-text">
{theme.name}
</Forms.FormTitle>
<Forms.FormText className="vce-theme-text">
{theme.description}
</Forms.FormText>
<img
role="presentation"
src={theme.thumbnail_url}
loading="lazy"
alt={theme.name}
className="vce-theme-info-preview"
<Forms.FormTitle tag="h2" style={{
overflowWrap: "break-word",
marginTop: 20,
}}>
Themes
</Forms.FormTitle>
<div className={cl("filter-controls")}>
<TextInput value={searchValue.value} placeholder="Search for a theme..." onChange={onSearch} />
<div className={InputStyles.inputWrapper}>
<Select
options={[
{ label: "Show All", value: SearchStatus.ALL, default: true },
{ label: "Show Themes", value: SearchStatus.THEME },
{ label: "Show Snippets", value: SearchStatus.SNIPPET },
{ label: "Show Enabled", value: SearchStatus.ENABLED },
{ label: "Show Disabled", value: SearchStatus.DISABLED },
{ label: "Show Dark", value: SearchStatus.DARK },
{ label: "Show Light", value: SearchStatus.LIGHT },
]}
serialize={String}
select={onStatusChange}
isSelected={v => v === searchValue.status}
closeOnSelect={true}
/>
<div className="vce-theme-info">
<div style={{
justifyContent: "flex-start",
flexDirection: "column"
}}>
{theme.tags && (
<Forms.FormText>
{theme.tags.map(tag => (
<span className="vce-theme-info-tag">
{tag}
</span>
))}
</Forms.FormText>
)}
<div style={{ marginTop: "8px", display: "flex", flexDirection: "row" }}>
{themeLinks.includes(API_TYPE(theme)) ? (
<Button
onClick={() => {
const onlineThemeLinks = themeLinks.filter(x => x !== API_TYPE(theme));
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.RED}
look={Button.Looks.FILLED}
className={Margins.right8}
>
Remove Theme
</Button>
) : (
<Button
onClick={() => {
const onlineThemeLinks = [...themeLinks, API_TYPE(theme)];
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.GREEN}
look={Button.Looks.FILLED}
className={Margins.right8}
>
Add Theme
</Button>
</div>
</div>
<div>
{filteredThemes.map((theme: Theme) => (
<Card style={{
padding: ".5rem",
marginBottom: ".5em",
marginTop: ".5em",
display: "flex",
flexDirection: "column",
backgroundColor: "var(--background-secondary-alt)"
}} key={theme.id}>
<Forms.FormTitle tag="h2" style={{
overflowWrap: "break-word",
marginTop: 8,
}}
className="vce-theme-text">
{theme.name}
</Forms.FormTitle>
<Forms.FormText className="vce-theme-text">
{theme.description}
</Forms.FormText>
<img
role="presentation"
src={theme.thumbnail_url}
loading="lazy"
alt={theme.name}
className="vce-theme-info-preview"
/>
<div className="vce-theme-info">
<div style={{
justifyContent: "flex-start",
flexDirection: "column"
}}>
{theme.tags && (
<Forms.FormText>
{theme.tags.map(tag => (
<span className="vce-theme-info-tag">
{tag}
</span>
))}
</Forms.FormText>
)}
<Button
onClick={async () => {
const author = await getUser(theme.author.discord_snowflake, theme.author.discord_name);
openModal(props => <ThemeInfoModal {...props} author={author} theme={theme} />);
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.BRAND}
look={Button.Looks.FILLED}
>
Theme Info
</Button>
<Button
onClick={() => VencordNative.native.openExternal(API_TYPE(theme).replace("?raw=true", ""))}
size={Button.Sizes.MEDIUM}
color={Button.Colors.LINK}
look={Button.Looks.LINK}
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
View Source <OpenExternalIcon height={16} width={16} />
</Button>
<div style={{ marginTop: "8px", display: "flex", flexDirection: "row" }}>
{themeLinks.includes(API_TYPE(theme)) ? (
<Button
onClick={() => {
const onlineThemeLinks = themeLinks.filter(x => x !== API_TYPE(theme));
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.RED}
look={Button.Looks.FILLED}
className={Margins.right8}
>
Remove Theme
</Button>
) : (
<Button
onClick={() => {
const onlineThemeLinks = [...themeLinks, API_TYPE(theme)];
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.GREEN}
look={Button.Looks.FILLED}
className={Margins.right8}
>
Add Theme
</Button>
)}
<Button
onClick={async () => {
const author = await getUser(theme.author.discord_snowflake, theme.author.discord_name);
openModal(props => <ThemeInfoModal {...props} author={author} theme={theme} />);
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.BRAND}
look={Button.Looks.FILLED}
>
Theme Info
</Button>
<Button
onClick={() => {
const content = atob(theme.content);
const metadata = content.match(/\/\*\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g)?.[0] || "";
const source = metadata.match(/@source\s+(.+)/)?.[1] || "";
if (source) {
VencordNative.native.openExternal(source);
} else {
VencordNative.native.openExternal(API_TYPE(theme).replace("?raw=true", ""));
}
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.LINK}
look={Button.Looks.LINK}
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
View Source <OpenExternalIcon height={16} width={16} />
</Button>
</div>
</div>
</div>
</div>
</Card>
))}
</div>
</>)}
</Card>
))}
</div>
</>)}
</>
</div >
);

View file

@ -1,5 +1,5 @@
/* stylelint-disable property-no-vendor-prefix */
[data-tab-id="ThemeLibrary"]::before {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-mask: var(--si-widget) center/contain no-repeat !important;
mask: var(--si-widget) center/contain no-repeat !important;
}

View file

@ -5,7 +5,6 @@
*/
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { SettingsRouter } from "@webpack/common";
@ -21,7 +20,10 @@ const settings = definePluginSettings({
export default definePlugin({
name: "ThemeLibrary",
description: "A library of themes for Vencord.",
authors: [EquicordDevs.Fafa],
authors: [{
name: "Fafa",
id: 428188716641812481n,
}],
settings,
toolboxActions: {
"Open Theme Library": () => {

View file

@ -5,30 +5,51 @@
*/
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { makeRange } from "@components/PluginSettings/components";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import definePlugin, { OptionType } from "@utils/types";
import { findStoreLazy } from "@webpack";
import { GuildChannelStore, Menu, React, RestAPI, UserStore } from "@webpack/common";
import type { Channel } from "discord-types/general";
const VoiceStateStore = findStoreLazy("VoiceStateStore");
async function runSequential<T>(promises: Promise<T>[]): Promise<T[]> {
const results: T[] = [];
for (let i = 0; i < promises.length; i++) {
const promise = promises[i];
const result = await promise;
results.push(result);
if (i % settings.store.waitAfter === 0) {
await new Promise(resolve => setTimeout(resolve, settings.store.waitSeconds * 1000));
}
}
return results;
}
function sendPatch(channel: Channel, body: Record<string, any>, bypass = false) {
const usersVoice = VoiceStateStore.getVoiceStatesForChannel(channel.id); // Get voice states by channel id
const myId = UserStore.getCurrentUser().id; // Get my user id
const promises: Promise<any>[] = [];
Object.keys(usersVoice).forEach((key, index) => {
const userVoice = usersVoice[key];
if (bypass || userVoice.userId !== myId) {
setTimeout(() => {
RestAPI.patch({
url: `/guilds/${channel.guild_id}/members/${userVoice.userId}`,
body: body
});
}, index * 500);
promises.push(RestAPI.patch({
url: `/guilds/${channel.guild_id}/members/${userVoice.userId}`,
body: body
}));
}
});
runSequential(promises).catch(error => {
console.error("VoiceChatUtilities failed to run", error);
});
}
interface VoiceChannelContextProps {
@ -120,12 +141,26 @@ const VoiceChannelContext: NavContextMenuPatchCallback = (children, { channel }:
);
};
const settings = definePluginSettings({
waitAfter: {
type: OptionType.SLIDER,
description: "Amount of API actions to perform before waiting (to avoid rate limits)",
default: 5,
markers: makeRange(1, 20),
},
waitSeconds: {
type: OptionType.SLIDER,
description: "Time to wait between each action (in seconds)",
default: 2,
markers: makeRange(1, 10, .5),
}
});
export default definePlugin({
name: "VoiceChatUtilities",
description: "This plugin allows you to perform multiple actions on an entire channel (move, mute, disconnect, etc.) (originally by dutake)",
authors: [EquicordDevs.Dams, Devs.D3SOX],
settings,
contextMenus: {
"channel-context": VoiceChannelContext
},

View file

@ -4,12 +4,18 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { findByPropsLazy, findStoreLazy } from "@webpack";
import { Forms, i18n, RelationshipStore, Tooltip, UserStore, useStateFromStores } from "@webpack/common";
import { openUserProfile } from "@utils/discord";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { Clickable, Forms, i18n, RelationshipStore, Tooltip, UserStore, useStateFromStores } from "@webpack/common";
import { User } from "discord-types/general";
interface WatchingProps {
userIds: string[];
@ -22,6 +28,15 @@ function getUsername(user: any): string {
return RelationshipStore.getNickname(user.id) || user.globalName || user.username;
}
const settings = definePluginSettings({
showPanel: {
description: "Show spectators under screenshare panel",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true
},
});
function Watching({ userIds, guildId }: WatchingProps): JSX.Element {
// Missing Users happen when UserStore.getUser(id) returns null -- The client should automatically cache spectators, so this might not be possible but it's better to be sure just in case
let missingUsers = 0;
@ -51,19 +66,97 @@ const { encodeStreamKey }: {
encodeStreamKey: (any) => string;
} = findByPropsLazy("encodeStreamKey");
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
const AvatarStyles = findByPropsLazy("moreUsers", "emptyUser", "avatarContainer", "clickableAvatar");
export default definePlugin({
name: "WhosWatching",
description: "Lets you view what users are watching your stream by hovering over the screenshare icon",
authors: [EquicordDevs.fres],
description: "Hover over the screenshare icon to view what users are watching your stream",
authors: [
EquicordDevs.Fres
],
settings: settings,
patches: [
{
find: ".Masks.STATUS_SCREENSHARE,width:32",
replacement: {
match: /default:function\(\)\{return ([a-zA-Z0-9_]{0,5})\}/,
match: /default:function\(\)\{return (\i)\}/,
replace: "default:function(){return $self.component({OriginalComponent:$1})}"
}
},
{
predicate: () => settings.store.showPanel,
find: "this.isJoinableActivity()||",
replacement: {
match: /(this\.isJoinableActivity\(\).{0,200}children:.{0,50})"div"/,
replace: "$1$self.WrapperComponent"
}
}
],
WrapperComponent: ErrorBoundary.wrap(props => {
const stream = useStateFromStores([ApplicationStreamingStore], () => ApplicationStreamingStore.getCurrentUserActiveStream());
if (!stream) return <div {...props}>{props.children}</div>;
const userIds = ApplicationStreamingStore.getViewerIds(encodeStreamKey(stream));
let missingUsers = 0;
const users = userIds.map(id => UserStore.getUser(id)).filter(user => Boolean(user) ? true : (missingUsers += 1, false));
function renderMoreUsers(_label: string, count: number) {
const sliced = users.slice(count - 1);
return (
<Tooltip text={<Watching userIds={userIds} guildId={stream.guildId} />}>
{({ onMouseEnter, onMouseLeave }) => (
<div
className={AvatarStyles.moreUsers}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
+{sliced.length + missingUsers}
</div>
)}
</Tooltip>
);
}
return (
<>
<div {...props}>{props.children}</div>
<div className={classes(cl("spectators_panel"), Margins.top8)}>
{users.length ?
<>
<Forms.FormTitle tag="h3" style={{ marginTop: 8, marginBottom: 0, textTransform: "uppercase" }}>{i18n.Messages.SPECTATORS.format({ numViewers: userIds.length })}</Forms.FormTitle>
<UserSummaryItem
users={users}
count={userIds.length}
renderIcon={false}
max={12}
showDefaultAvatarsForNullUsers
showUserPopout
guildId={stream.guildId}
renderMoreUsers={renderMoreUsers}
renderUser={(user: User) => (
<Clickable
className={AvatarStyles.clickableAvatar}
onClick={() => openUserProfile(user.id)}
>
<img
className={AvatarStyles.avatar}
src={user.getAvatarURL(void 0, 80, true)}
alt={user.username}
title={user.username}
/>
</Clickable>
)}
/>
</>
: <Forms.FormText>No spectators</Forms.FormText>
}
</div>
</>
);
}),
component: function ({ OriginalComponent }) {
return (props: any) => {
const stream = useStateFromStores([ApplicationStreamingStore], () => ApplicationStreamingStore.getCurrentUserActiveStream());

View file

@ -4,8 +4,13 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { useState } from "@webpack/common";
import { Embed } from "discord-types/general";
interface ToggleableDescriptionProps { embed: Embed, original: () => any; }
export default definePlugin({
name: "YoutubeDescription",
@ -15,9 +20,31 @@ export default definePlugin({
{
find: ".default.Messages.SUPPRESS_ALL_EMBEDS",
replacement: {
match: /case \i\.MessageEmbedTypes\.VIDEO:(case \i\.MessageEmbedTypes\.\i:)*break;default:(\i=this\.renderDescription\(\))\}/,
replace: "$1 break; default: $2 }"
match: /case (\i\.MessageEmbedTypes\.VIDEO):(case \i\.MessageEmbedTypes\.\i:)*break;default:(\i)=(?:(this\.renderDescription)\(\))\}/,
replace: "$2 break; case $1: $3 = $self.ToggleableDescriptionWrapper({ embed: this.props.embed, original: $4.bind(this) }); break; default: $3 = $4() }"
}
}
]
],
ToggleableDescription: ErrorBoundary.wrap(({ embed, original }: ToggleableDescriptionProps) => {
const [isOpen, setOpen] = useState(false);
if (!embed.rawDescription)
return null;
if (embed.rawDescription.length <= 20)
return original();
return (
<div
style={{ cursor: "pointer", marginTop: isOpen ? "0px" : "8px" }}
onClick={() => setOpen(o => !o)}
>
{isOpen
? original()
: embed.rawDescription.substring(0, 20) + "..."}
</div>
);
}),
ToggleableDescriptionWrapper(props: ToggleableDescriptionProps) {
return <this.ToggleableDescription {...props} ></this.ToggleableDescription >;
}
});

View file

@ -551,7 +551,7 @@ export const EquicordDevs = Object.freeze({
name: "MrDiamond",
id: 523338295644782592n
},
fres: {
Fres: {
name: "fres",
id: 843448897737064448n
},
@ -567,7 +567,7 @@ export const EquicordDevs = Object.freeze({
name: "Perny",
id: 1101508982570504244n,
},
jaxx: {
Jaxx: {
name: "Jaxx",
id: 901016640253227059n,
},
@ -659,6 +659,10 @@ export const EquicordDevs = Object.freeze({
name: "Fafa",
id: 428188716641812481n,
},
ANIKEIPS: {
name: "AniKeiPS",
id: 472052944582213634n
},
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly