parent
7dd37b53bc
commit
a480ab0517
87 changed files with 8742 additions and 162 deletions
98
src/equicordplugins/copyProfileColors/index.tsx
Normal file
98
src/equicordplugins/copyProfileColors/index.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { copyToClipboard } from "@utils/clipboard";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Menu, Toasts, UserProfileStore } from "@webpack/common";
|
||||
|
||||
function getProfileColors(userId) {
|
||||
try {
|
||||
const profile = UserProfileStore.getUserProfile(userId);
|
||||
|
||||
if (!profile || !profile.themeColors || profile.themeColors.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primaryColor = profile.themeColors[0].toString(16).padStart(6, "0");
|
||||
const secondaryColor = profile.themeColors[1].toString(16).padStart(6, "0");
|
||||
|
||||
return { primaryColor, secondaryColor };
|
||||
} catch (e) {
|
||||
console.error("Failed to get profile colors:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function copyProfileColors(userId) {
|
||||
const colors = getProfileColors(userId);
|
||||
|
||||
if (!colors) {
|
||||
Toasts.show({
|
||||
type: Toasts.Type.FAILURE,
|
||||
message: "No profile colors found!",
|
||||
id: Toasts.genId()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { primaryColor, secondaryColor } = colors;
|
||||
|
||||
// Formatting
|
||||
const formattedColors = `Primary-color #${primaryColor}, Secondary-Color #${secondaryColor}`;
|
||||
|
||||
try {
|
||||
copyToClipboard(formattedColors);
|
||||
Toasts.show({
|
||||
type: Toasts.Type.SUCCESS,
|
||||
message: "Profile colors copied to clipboard!",
|
||||
id: Toasts.genId()
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to copy to clipboard:", e);
|
||||
Toasts.show({
|
||||
type: Toasts.Type.FAILURE,
|
||||
message: "Error copying profile colors!",
|
||||
id: Toasts.genId()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function ColorIcon() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fill="currentColor" d="M17,4H15.82A3,3,0,0,0,13,2H11A3,3,0,0,0,8.18,4H7A3,3,0,0,0,4,7V19a3,3,0,0,0,3,3H17a3,3,0,0,0,3-3V7A3,3,0,0,0,17,4ZM10,5a1,1,0,0,1,1-1h2a1,1,0,0,1,1,1V6H10Zm8,14a1,1,0,0,1-1,1H7a1,1,0,0,1-1-1V7A1,1,0,0,1,7,6H8V7A1,1,0,0,0,9,8h6a1,1,0,0,0,1-1V6h1a1,1,0,0,1,1,1Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
// spawn in the context menu
|
||||
const userContextMenuPatch: NavContextMenuPatchCallback = (children, { user }) => {
|
||||
if (!user) return;
|
||||
children.push(
|
||||
<Menu.MenuItem
|
||||
id="CopyProfileColors"
|
||||
icon={ColorIcon}
|
||||
label="Copy Profile Colors"
|
||||
action={() => copyProfileColors(user.id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "CopyProfileColors",
|
||||
description: "A plugin to copy people's profile gradient colors to clipboard.",
|
||||
authors: [EquicordDevs.Crxa, EquicordDevs.Cortex],
|
||||
contextMenus: {
|
||||
"user-context": userContextMenuPatch,
|
||||
"user-profile-actions": userContextMenuPatch
|
||||
}
|
||||
});
|
||||
47
src/equicordplugins/equissant/index.ts
Normal file
47
src/equicordplugins/equissant/index.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
|
||||
let clickCount = 0;
|
||||
let croissant: AudioPlayerInterface | null = null;
|
||||
|
||||
function play() {
|
||||
const triggerAmount = settings.store.amount;
|
||||
clickCount++;
|
||||
|
||||
if (clickCount % triggerAmount === 0) {
|
||||
croissant?.play();
|
||||
clickCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
amount: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "amount of clicks to trigger crossant",
|
||||
default: 10,
|
||||
}
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "Equissant",
|
||||
description: "Crossant every specified amount of clicks :trolley:",
|
||||
authors: [EquicordDevs.SomeAspy, Devs.thororen],
|
||||
dependencies: ["AudioPlayerAPI"],
|
||||
settings,
|
||||
start() {
|
||||
croissant = createAudioPlayer("https://github.com/Equicord/Equibored/raw/main/sounds/equissant/croissant.mp3", { persistent: true });
|
||||
document.addEventListener("click", play);
|
||||
},
|
||||
stop() {
|
||||
croissant?.delete();
|
||||
document.removeEventListener("click", play);
|
||||
}
|
||||
});
|
||||
147
src/equicordplugins/exitSounds/index.tsx
Normal file
147
src/equicordplugins/exitSounds/index.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { playAudio } from "@api/AudioPlayer";
|
||||
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Button } from "@components/Button";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { findComponentByCodeLazy } from "@webpack";
|
||||
import { ChannelActions, Constants, GuildStore, IconUtils, MediaEngineStore, Menu, RestAPI, SearchableSelect, SelectedChannelStore, TextInput, Toasts } from "@webpack/common";
|
||||
|
||||
const cl = classNameFactory("vc-exitsounds-");
|
||||
|
||||
const PlayIcon = findComponentByCodeLazy("4.96v14.08c0");
|
||||
|
||||
function GuildSelector() {
|
||||
const { soundGuildId } = settings.use(["soundGuildId"]);
|
||||
const options = Object.values(GuildStore.getGuilds()).map(g => ({
|
||||
value: g.id,
|
||||
label: g.name
|
||||
}));
|
||||
|
||||
return (
|
||||
<SearchableSelect
|
||||
options={options}
|
||||
value={options.find(o => o.value === soundGuildId)?.value}
|
||||
placeholder="Select a server..."
|
||||
maxVisibleItems={6}
|
||||
closeOnSelect={true}
|
||||
onChange={v => settings.store.soundGuildId = v}
|
||||
renderOptionPrefix={o => {
|
||||
const guild = GuildStore.getGuild(o?.value);
|
||||
if (!guild?.icon) return null;
|
||||
return (
|
||||
<img
|
||||
className={cl("guild-icon")}
|
||||
src={IconUtils.getGuildIconURL({ id: guild.id, icon: guild.icon, size: 32 })!}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SoundIdInput() {
|
||||
const { soundId } = settings.use(["soundId"]);
|
||||
|
||||
return (
|
||||
<div className={cl("input-row")}>
|
||||
<div className={cl("input-wrapper")}>
|
||||
<TextInput
|
||||
value={soundId}
|
||||
onChange={v => settings.store.soundId = v}
|
||||
placeholder="Enter sound ID..."
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => playAudio(`https://${window.GLOBAL_ENV.CDN_HOST}/soundboard-sounds/${soundId}`, { volume: 50 })}
|
||||
disabled={!soundId}
|
||||
>
|
||||
<PlayIcon color="currentColor" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
soundGuildId: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: "Select the server containing the sound.",
|
||||
component: GuildSelector
|
||||
},
|
||||
soundId: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: "Enter the ID of the sound you want to play.",
|
||||
component: SoundIdInput
|
||||
}
|
||||
});
|
||||
|
||||
const SoundButtonContext: NavContextMenuPatchCallback = (children, { sound }: { sound: { soundId: string; guildId: string; }; }) => {
|
||||
children.splice(1, 0,
|
||||
<Menu.MenuGroup>
|
||||
<Menu.MenuItem
|
||||
id="set-global-exit-sound"
|
||||
label="Set as global exit sound"
|
||||
action={() => {
|
||||
settings.store.soundGuildId = sound.guildId;
|
||||
settings.store.soundId = sound.soundId;
|
||||
}}
|
||||
/>
|
||||
</Menu.MenuGroup>
|
||||
);
|
||||
};
|
||||
|
||||
let original: typeof ChannelActions.selectVoiceChannel;
|
||||
|
||||
export default definePlugin({
|
||||
name: "ExitSounds",
|
||||
description: "Play soundboard sounds when you disconnect from voice.",
|
||||
authors: [Devs.prism],
|
||||
dependencies: ["AudioPlayerAPI"],
|
||||
settings,
|
||||
|
||||
contextMenus: {
|
||||
"sound-button-context": SoundButtonContext
|
||||
},
|
||||
|
||||
start() {
|
||||
original = ChannelActions.selectVoiceChannel;
|
||||
ChannelActions.selectVoiceChannel = async (id: string | null, ...args: unknown[]) => {
|
||||
const { soundGuildId, soundId } = settings.store;
|
||||
const voiceId = SelectedChannelStore.getVoiceChannelId();
|
||||
|
||||
if (soundGuildId && soundId && voiceId !== id && !MediaEngineStore.isDeaf()) {
|
||||
try {
|
||||
await RestAPI.post({
|
||||
url: Constants.Endpoints.SEND_SOUNDBOARD_SOUND(voiceId),
|
||||
body: {
|
||||
sound_id: soundId,
|
||||
source_guild_id: soundGuildId
|
||||
}
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
} catch {
|
||||
Toasts.show({
|
||||
message: "Oops! Something went wrong.",
|
||||
id: Toasts.genId(),
|
||||
type: Toasts.Type.FAILURE
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return original(id, ...args);
|
||||
};
|
||||
},
|
||||
|
||||
stop() {
|
||||
ChannelActions.selectVoiceChannel = original;
|
||||
}
|
||||
});
|
||||
17
src/equicordplugins/exitSounds/styles.css
Normal file
17
src/equicordplugins/exitSounds/styles.css
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
.vc-exitsounds-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vc-exitsounds-input-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vc-exitsounds-guild-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
128
src/equicordplugins/fastDeleteChannels/index.tsx
Normal file
128
src/equicordplugins/fastDeleteChannels/index.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Constants, PermissionsBits, PermissionStore, React, RestAPI, useCallback, useEffect, useState } from "@webpack/common";
|
||||
|
||||
const validKeycodes = [
|
||||
"Backspace", "Tab", "Enter", "ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "Pause", "CapsLock",
|
||||
"Escape", "Space", "PageUp", "PageDown", "End", "Home", "ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown", "PrintScreen", "Insert",
|
||||
"Delete", "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "KeyA", "KeyB", "KeyC",
|
||||
"KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK", "KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT",
|
||||
"KeyU", "KeyV", "KeyW", "KeyX", "KeyY", "KeyZ", "MetaLeft", "MetaRight", "ContextMenu", "Numpad0", "Numpad1", "Numpad2", "Numpad3",
|
||||
"Numpad4", "Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9", "NumpadMultiply", "NumpadAdd", "NumpadSubtract", "NumpadDecimal",
|
||||
"NumpadDivide", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "NumLock", "ScrollLock",
|
||||
];
|
||||
|
||||
function showIcon() {
|
||||
const [show, setShow] = useState(false);
|
||||
const { keyBind, reqCtrl, reqShift, reqAlt } = settings.store;
|
||||
|
||||
const handleKeys = useCallback((e: KeyboardEvent) => {
|
||||
const isMatchingKey =
|
||||
e.code === keyBind &&
|
||||
(!reqCtrl || e.ctrlKey) &&
|
||||
(!reqShift || e.shiftKey) &&
|
||||
(!reqAlt || e.altKey);
|
||||
|
||||
setShow(isMatchingKey);
|
||||
}, [keyBind, reqCtrl, reqShift, reqAlt]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeys);
|
||||
window.addEventListener("keyup", handleKeys);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeys);
|
||||
window.removeEventListener("keyup", handleKeys);
|
||||
};
|
||||
}, [handleKeys]);
|
||||
|
||||
return show;
|
||||
}
|
||||
|
||||
// TY ToggleVideoBind
|
||||
const settings = definePluginSettings({
|
||||
keyBind: {
|
||||
description: "The key to toggle trash when pressed.",
|
||||
type: OptionType.STRING,
|
||||
default: "KeyZ",
|
||||
isValid: (value: string) => validKeycodes.includes(value),
|
||||
},
|
||||
reqCtrl: {
|
||||
description: "Require control to be held.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
reqShift: {
|
||||
description: "Require shift to be held.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
reqAlt: {
|
||||
description: "Require alt to be held.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "FastDeleteChannels",
|
||||
description: "Adds a trash icon to delete channels",
|
||||
authors: [Devs.thororen],
|
||||
settings,
|
||||
patches: [
|
||||
// TY TypingIndicator
|
||||
// Normal Channels
|
||||
{
|
||||
find: "UNREAD_IMPORTANT:",
|
||||
replacement: {
|
||||
match: /\.Children\.count.+?:null(?<=,channel:(\i).+?)/,
|
||||
replace: "$&,$self.TrashIcon($1)"
|
||||
}
|
||||
},
|
||||
// Threads
|
||||
{
|
||||
find: "18V16H9v2H6Zm3",
|
||||
replacement: {
|
||||
match: /mentionsCount:\i.+?null(?<=channel:(\i).+?)/,
|
||||
replace: "$&,$self.TrashIcon($1)"
|
||||
}
|
||||
}
|
||||
],
|
||||
TrashIcon: channel => {
|
||||
const show = showIcon();
|
||||
|
||||
if (!show || !PermissionStore.can(PermissionsBits.MANAGE_CHANNELS, channel)) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={() => RestAPI.del({ url: Constants.Endpoints.CHANNEL(channel.id) })}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
color="#ed4245"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14.25 1c.41 0 .75.34.75.75V3h5.25c.41 0 .75.34.75.75v.5c0 .41-.34.75-.75.75H3.75A.75.75 0 0 1 3 4.25v-.5c0-.41.34-.75.75-.75H9V1.75c0-.41.34-.75.75-.75h4.5Z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.06 7a1 1 0 0 0-1 1.06l.76 12.13a3 3 0 0 0 3 2.81h8.36a3 3 0 0 0 3-2.81l.75-12.13a1 1 0 0 0-1-1.06H5.07ZM11 12a1 1 0 1 0-2 0v6a1 1 0 1 0 2 0v-6Zm3-1a1 1 0 1 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
});
|
||||
115
src/equicordplugins/followVoiceUser/index.tsx
Normal file
115
src/equicordplugins/followVoiceUser/index.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Notice } from "@components/Notice";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Channel, User, VoiceState } from "@vencord/discord-types";
|
||||
import { findByPropsLazy, findStoreLazy } from "@webpack";
|
||||
import { Menu, React, VoiceStateStore } from "@webpack/common";
|
||||
|
||||
type TFollowedUserInfo = {
|
||||
lastChannelId: string;
|
||||
userId: string;
|
||||
} | null;
|
||||
|
||||
interface UserContextProps {
|
||||
channel: Channel;
|
||||
user: User;
|
||||
guildId?: string;
|
||||
}
|
||||
|
||||
let followedUserInfo: TFollowedUserInfo = null;
|
||||
|
||||
const voiceChannelAction = findByPropsLazy("selectVoiceChannel");
|
||||
const UserStore = findStoreLazy("UserStore");
|
||||
const RelationshipStore = findStoreLazy("RelationshipStore");
|
||||
|
||||
const settings = definePluginSettings({
|
||||
onlyWhenInVoice: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Only follow the user when you are in a voice channel"
|
||||
},
|
||||
leaveWhenUserLeaves: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
description: "Leave the voice channel when the user leaves. (That can cause you to sometimes enter infinite leave/join loop)"
|
||||
}
|
||||
});
|
||||
|
||||
const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { channel, user }: UserContextProps) => {
|
||||
if (UserStore.getCurrentUser().id === user.id || !RelationshipStore.getFriendIDs().includes(user.id)) return;
|
||||
|
||||
const [checked, setChecked] = React.useState(followedUserInfo?.userId === user.id);
|
||||
|
||||
children.push(
|
||||
<Menu.MenuSeparator />,
|
||||
<Menu.MenuCheckboxItem
|
||||
id="fvu-follow-user"
|
||||
label="Follow User"
|
||||
checked={checked}
|
||||
action={() => {
|
||||
if (followedUserInfo?.userId === user.id) {
|
||||
followedUserInfo = null;
|
||||
setChecked(false);
|
||||
return;
|
||||
}
|
||||
|
||||
followedUserInfo = {
|
||||
lastChannelId: UserStore.getCurrentUser().id,
|
||||
userId: user.id
|
||||
};
|
||||
setChecked(true);
|
||||
}}
|
||||
></Menu.MenuCheckboxItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "FollowVoiceUser",
|
||||
description: "Follow a friend in voice chat.",
|
||||
authors: [EquicordDevs.TheArmagan],
|
||||
settings,
|
||||
settingsAboutComponent: () => (
|
||||
<Notice.Info>
|
||||
This Plugin is used to follow a Friend/Friends into voice chat(s).
|
||||
</Notice.Info>
|
||||
),
|
||||
flux: {
|
||||
async VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
|
||||
if (!followedUserInfo) return;
|
||||
if (!RelationshipStore.getFriendIDs().includes(followedUserInfo.userId)) return;
|
||||
|
||||
if (
|
||||
settings.store.onlyWhenInVoice
|
||||
&& !VoiceStateStore.getVoiceStateForUser(UserStore.getCurrentUser().id)
|
||||
) return;
|
||||
|
||||
voiceStates.forEach(voiceState => {
|
||||
if (
|
||||
voiceState.userId === followedUserInfo!.userId
|
||||
&& voiceState.channelId
|
||||
&& voiceState.channelId !== followedUserInfo!.lastChannelId
|
||||
) {
|
||||
followedUserInfo!.lastChannelId = voiceState.channelId;
|
||||
voiceChannelAction.selectVoiceChannel(followedUserInfo!.lastChannelId);
|
||||
} else if (
|
||||
voiceState.userId === followedUserInfo!.userId
|
||||
&& !voiceState.channelId
|
||||
&& settings.store.leaveWhenUserLeaves
|
||||
) {
|
||||
voiceChannelAction.selectVoiceChannel(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
contextMenus: {
|
||||
"user-context": UserContextMenuPatch
|
||||
}
|
||||
});
|
||||
219
src/equicordplugins/fontLoader/index.tsx
Normal file
219
src/equicordplugins/fontLoader/index.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { definePluginSettings, migratePluginSetting } from "@api/Settings";
|
||||
import { Card } from "@components/Card";
|
||||
import { HeadingSecondary, HeadingTertiary } from "@components/Heading";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { debounce } from "@shared/debounce";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { classes } from "@utils/misc";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { React, TextInput } from "@webpack/common";
|
||||
|
||||
interface GoogleFontMetadata {
|
||||
family: string;
|
||||
displayName: string;
|
||||
authors: string[];
|
||||
category?: number;
|
||||
popularity?: number;
|
||||
variants: Array<{
|
||||
axes: Array<{
|
||||
tag: string;
|
||||
min: number;
|
||||
max: number;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
const createGoogleFontUrl = (family: string, options = "") =>
|
||||
`https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}${options}&display=swap`;
|
||||
|
||||
const loadFontStyle = (url: string) => {
|
||||
document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" href="${url}">`);
|
||||
return document.createElement("style");
|
||||
};
|
||||
|
||||
async function searchGoogleFonts(query: string) {
|
||||
try {
|
||||
const response = await fetch("https://fonts.google.com/$rpc/fonts.fe.catalog.actions.metadata.MetadataService/FontSearch", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json+protobuf",
|
||||
"x-user-agent": "grpc-web-javascript/0.1"
|
||||
},
|
||||
body: JSON.stringify([[query, null, null, null, null, null, 1], [5], null, 16])
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!data?.[1]) return [];
|
||||
return data[1].map(([_, fontData]: [string, any[]]) => ({
|
||||
family: fontData[0],
|
||||
displayName: fontData[1],
|
||||
authors: fontData[2],
|
||||
category: fontData[3],
|
||||
variants: fontData[6].map((variant: any[]) => ({
|
||||
axes: variant[0].map(([tag, min, max]: [string, number, number]) => ({
|
||||
tag, min, max
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch fonts:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const preloadFont = (family: string) =>
|
||||
loadFontStyle(createGoogleFontUrl(family, "&text=The quick brown fox jumps over the lazy dog"));
|
||||
|
||||
let styleElement: HTMLStyleElement | null = null;
|
||||
|
||||
const applyFont = async (fontFamily: string) => {
|
||||
if (!fontFamily) {
|
||||
styleElement?.remove();
|
||||
styleElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!styleElement) {
|
||||
styleElement = document.createElement("style");
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
|
||||
loadFontStyle(createGoogleFontUrl(fontFamily, ":wght@300;400;500;600;700"));
|
||||
styleElement.textContent = `
|
||||
* {
|
||||
--font-primary: '${fontFamily}', sans-serif !important;
|
||||
--font-display: '${fontFamily}', sans-serif !important;
|
||||
--font-headline: '${fontFamily}', sans-serif !important;
|
||||
${settings.store.applyOnCodeBlocks ? "--font-code: '${fontFamily}', monospace !important;" : ""}
|
||||
}
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error("Failed to load font:", err);
|
||||
}
|
||||
};
|
||||
|
||||
function GoogleFontSearch({ onSelect }: { onSelect: (font: GoogleFontMetadata) => void; }) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [results, setResults] = React.useState<GoogleFontMetadata[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const previewStyles = React.useRef<HTMLStyleElement[]>([]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
previewStyles.current.forEach(style => style.remove());
|
||||
}, []);
|
||||
|
||||
const debouncedSearch = debounce(async (value: string) => {
|
||||
setLoading(true);
|
||||
if (!value) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fonts = await searchGoogleFonts(value);
|
||||
previewStyles.current.forEach(style => style.remove());
|
||||
previewStyles.current = await Promise.all(fonts.map(f => preloadFont(f.family)));
|
||||
setResults(fonts);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
|
||||
const handleSearch = (e: string) => {
|
||||
setQuery(e);
|
||||
debouncedSearch(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<HeadingSecondary>Search Google Fonts</HeadingSecondary>
|
||||
<Paragraph>Click on any font to apply it.</Paragraph>
|
||||
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={e => handleSearch(e)}
|
||||
placeholder="Search fonts..."
|
||||
disabled={loading}
|
||||
className={Margins.bottom16}
|
||||
/>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className={classes(Margins.top8, "eq-googlefonts-results")}>
|
||||
{results.map(font => (
|
||||
<Card
|
||||
key={font.family}
|
||||
className={classes("eq-googlefonts-card", Margins.bottom8)}
|
||||
onClick={() => onSelect(font)}
|
||||
>
|
||||
<div className="eq-googlefonts-preview" style={{ fontFamily: font.family }}>
|
||||
<HeadingTertiary>{font.displayName}</HeadingTertiary>
|
||||
<Paragraph>The quick brown fox jumps over the lazy dog</Paragraph>
|
||||
</div>
|
||||
{font.authors?.length && (
|
||||
<Paragraph className={Margins.top8} style={{ opacity: 0.7 }}>
|
||||
by {font.authors.join(", ")}
|
||||
</Paragraph>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
migratePluginSetting("FontLoader", "applyOnCodeBlocks", "applyOnClodeBlocks");
|
||||
const settings = definePluginSettings({
|
||||
selectedFont: {
|
||||
type: OptionType.STRING,
|
||||
description: "Currently selected font",
|
||||
default: "",
|
||||
hidden: true
|
||||
},
|
||||
fontSearch: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: "Search and select Google Fonts",
|
||||
component: () => (
|
||||
<GoogleFontSearch
|
||||
onSelect={font => {
|
||||
settings.store.selectedFont = font.family;
|
||||
applyFont(font.family);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
applyOnCodeBlocks: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Apply the font to code blocks",
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "FontLoader",
|
||||
description: "Loads any font from Google Fonts",
|
||||
authors: [EquicordDevs.vmohammad],
|
||||
settings,
|
||||
|
||||
async start() {
|
||||
const savedFont = settings.store.selectedFont;
|
||||
if (savedFont) {
|
||||
await applyFont(savedFont);
|
||||
}
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
styleElement = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
23
src/equicordplugins/fontLoader/styles.css
Normal file
23
src/equicordplugins/fontLoader/styles.css
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
.eq-googlefonts-results {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.eq-googlefonts-card {
|
||||
padding: 16px;
|
||||
cursor: pointer !important;
|
||||
background: var(--background-secondary-alt);
|
||||
border: 1px solid var(--background-base-low);
|
||||
}
|
||||
|
||||
.eq-googlefonts-card:hover {
|
||||
background: var(--background-mod-subtle);
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.eq-googlefonts-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
51
src/equicordplugins/frequentQuickSwitcher/index.tsx
Normal file
51
src/equicordplugins/frequentQuickSwitcher/index.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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";
|
||||
import { ChannelStore, UserSettingsActionCreators } from "@webpack/common";
|
||||
|
||||
function generateSearchResults(query) {
|
||||
const frequentChannelsWithQuery = Object.entries(UserSettingsActionCreators.FrecencyUserSettingsActionCreators.getCurrentValue().guildAndChannelFrecency.guildAndChannels)
|
||||
.map(([key, value]) => key)
|
||||
.filter(id => ChannelStore.getChannel(id) != null)
|
||||
.filter(id => ChannelStore.getChannel(id).name.includes(query))
|
||||
.sort((id1, id2) => {
|
||||
const channel1 = UserSettingsActionCreators.FrecencyUserSettingsActionCreators.getCurrentValue().guildAndChannelFrecency.guildAndChannels[id1];
|
||||
const channel2 = UserSettingsActionCreators.FrecencyUserSettingsActionCreators.getCurrentValue().guildAndChannelFrecency.guildAndChannels[id2];
|
||||
return channel2.totalUses - channel1.totalUses;
|
||||
})
|
||||
.slice(0, 20);
|
||||
|
||||
return frequentChannelsWithQuery.map(channelID => {
|
||||
const channel = ChannelStore.getChannel(channelID);
|
||||
return (
|
||||
{
|
||||
"type": "TEXT_CHANNEL",
|
||||
"record": channel,
|
||||
"score": 20,
|
||||
"comparator": query,
|
||||
"sortable": query
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "FrequentQuickSwitcher",
|
||||
description: "Rewrites and filters the quick switcher results to be your most frequent channels",
|
||||
authors: [Devs.Samwich],
|
||||
generateSearchResults: generateSearchResults,
|
||||
patches: [
|
||||
{
|
||||
find: "#{intl::QUICKSWITCHER_PLACEHOLDER}",
|
||||
replacement: {
|
||||
match: /let{selectedIndex:\i,results:\i}/,
|
||||
replace: "this.props.results = $self.generateSearchResults(this.state.query);$&"
|
||||
},
|
||||
}
|
||||
]
|
||||
});
|
||||
230
src/equicordplugins/friendTags/index.tsx
Normal file
230
src/equicordplugins/friendTags/index.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "styles.css?managed";
|
||||
|
||||
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { DataStore } from "@api/index";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { Divider } from "@components/Divider";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { useForceUpdater } from "@utils/react";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Button, ChannelStore, Menu, RelationshipStore, TextInput, useEffect, UserStore, useState } from "@webpack/common";
|
||||
|
||||
interface UserTagData {
|
||||
tagName: string;
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
let SavedData: UserTagData[] = [];
|
||||
const tagStoreName = "vc-friendtags-tags";
|
||||
|
||||
function parseUsertags(text: string): string[] {
|
||||
const matches = text.match(/&([^&]+)/g);
|
||||
if (!matches) return [];
|
||||
const tags = matches.map(match => match.substring(1).trim());
|
||||
return tags.filter(tag => tag !== "");
|
||||
}
|
||||
|
||||
function queryFriendTags(query) {
|
||||
const tags = parseUsertags(query);
|
||||
if (!tags.length) return [];
|
||||
|
||||
const filteredTagObjects = SavedData
|
||||
.filter(data => data.tagName.length && data.userIds.length)
|
||||
.filter(data => tags.some(tag => tag.toLowerCase() === data.tagName.toLowerCase()));
|
||||
if (!filteredTagObjects.length) return [];
|
||||
|
||||
const users = Array.from(new Set([...ChannelStore.getDMUserIds(), ...RelationshipStore.getFriendIDs()]))
|
||||
.filter(user => filteredTagObjects.some(tag => tag.userIds.includes(user)));
|
||||
|
||||
return users.map(user => {
|
||||
const userObject: any = UserStore.getUser(user);
|
||||
return {
|
||||
type: "USER",
|
||||
record: userObject,
|
||||
score: 20,
|
||||
comparator: userObject.globalName || userObject.username,
|
||||
sortable: userObject.globalName || userObject.username
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function SetData() {
|
||||
const fetchData = await DataStore.get(tagStoreName);
|
||||
if (SavedData !== fetchData) {
|
||||
await DataStore.set(tagStoreName, JSON.stringify(SavedData));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function GetData() {
|
||||
const fetchData = await DataStore.get(tagStoreName);
|
||||
if (!fetchData) {
|
||||
DataStore.set(tagStoreName, JSON.stringify([]));
|
||||
SavedData = [];
|
||||
return;
|
||||
}
|
||||
SavedData = JSON.parse(fetchData);
|
||||
}
|
||||
|
||||
function TagConfigCard(props) {
|
||||
const { tag } = props;
|
||||
const [tagName, setTagName] = useState(tag.tagName);
|
||||
const [userIds, setUserIDs] = useState(tag.userIds.join(", "));
|
||||
const update = useForceUpdater();
|
||||
|
||||
useEffect(() => {
|
||||
const dataTag = SavedData.find(obj => obj.tagName === tag.tagName);
|
||||
if (dataTag) {
|
||||
dataTag.tagName = tagName;
|
||||
}
|
||||
SetData();
|
||||
update();
|
||||
}, [tagName]);
|
||||
|
||||
useEffect(() => {
|
||||
const dataTag = SavedData.find(obj => obj.userIds === tag.userIds);
|
||||
if (dataTag) {
|
||||
dataTag.userIds = userIds.split(", ");
|
||||
}
|
||||
SetData();
|
||||
update();
|
||||
}, [userIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseText size="md" tag="h5">Name</BaseText>
|
||||
<TextInput value={tagName} onChange={setTagName}></TextInput>
|
||||
<BaseText size="md" tag="h5">Users (Seperated by comma)</BaseText>
|
||||
<TextInput value={userIds} onChange={setUserIDs}></TextInput>
|
||||
<div className={"vc-friend-tags-user-header-container"}>
|
||||
<BaseText>User List (Click A User To Remove)</BaseText>
|
||||
<div className={"vc-friend-tags-user-header-btns"}>
|
||||
{
|
||||
userIds.split(", ").map(user => {
|
||||
const userData: any = UserStore.getUser(user);
|
||||
if (!userData) return null;
|
||||
return (
|
||||
<div style={{ display: "flex" }} key={user.id}>
|
||||
<img src={userData.getAvatarURL()} style={{ height: "20px", borderRadius: "50%", marginRight: "5px" }}></img>
|
||||
<BaseText style={{ cursor: "pointer" }} size="md" onClick={() => setUserIDs(userIds.replace(`, ${user}`, "").replace(user, ""))}>{userData.globalName || userData.username}</BaseText>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
SavedData = SavedData.filter(data => (data.tagName !== tagName));
|
||||
await SetData();
|
||||
update();
|
||||
}}
|
||||
color={Button.Colors.RED}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TagConfigurationComponent() {
|
||||
const update = useForceUpdater();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
{
|
||||
SavedData?.map(e => (
|
||||
<>
|
||||
<TagConfigCard tag={e} />
|
||||
<Divider />
|
||||
</>
|
||||
))
|
||||
}
|
||||
<Button onClick={() => {
|
||||
SavedData.push(
|
||||
{
|
||||
tagName: "",
|
||||
userIds: []
|
||||
});
|
||||
SetData();
|
||||
update();
|
||||
}}>Add</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
tagConfiguration: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: "The tag configuration component",
|
||||
component: () => {
|
||||
return (
|
||||
<TagConfigurationComponent />
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function UserToTagID(user, tag, remove) {
|
||||
if (remove) {
|
||||
SavedData.filter(e => e.tagName === tag)[0].userIds = SavedData.filter(e => e.tagName === tag)[0].userIds.filter(e => e !== user);
|
||||
}
|
||||
else {
|
||||
SavedData.filter(e => e.tagName === tag)[0]?.userIds.push(user);
|
||||
}
|
||||
SetData();
|
||||
}
|
||||
|
||||
const userPatch: NavContextMenuPatchCallback = (children, { user }) => {
|
||||
const buttonElement =
|
||||
<Menu.MenuItem
|
||||
id="vc-tag-group"
|
||||
label="Tag"
|
||||
>
|
||||
{SavedData.map(tag => {
|
||||
const isTagged = SavedData.filter(e => e.tagName === tag.tagName)[0].userIds.includes(user.id);
|
||||
|
||||
return (
|
||||
<Menu.MenuItem
|
||||
label={`${isTagged ? "Remove from" : "Add to"} ${tag.tagName}`}
|
||||
key={`vc-tag-${tag.tagName}`}
|
||||
id={`vc-tag-${tag.tagName}`}
|
||||
action={() => { UserToTagID(user.id, tag.tagName, isTagged); }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu.MenuItem>;
|
||||
|
||||
children.push({ ...buttonElement });
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "FriendTags",
|
||||
description: "Allows you to filter by custom tags in the quick switcher by starting a search with &",
|
||||
authors: [Devs.Samwich],
|
||||
settings,
|
||||
contextMenus: {
|
||||
"user-context": userPatch
|
||||
},
|
||||
patches: [
|
||||
{
|
||||
find: "#{intl::QUICKSWITCHER_PLACEHOLDER}",
|
||||
replacement: {
|
||||
match: /let{selectedIndex:\i,results:\i}/,
|
||||
replace: "if(this.state.query.includes(\"&\")){ this.props.results = $self.queryFriendTags(this.state.query); }$&"
|
||||
},
|
||||
}
|
||||
],
|
||||
async start() {
|
||||
GetData();
|
||||
},
|
||||
queryFriendTags,
|
||||
});
|
||||
11
src/equicordplugins/friendTags/styles.css
Normal file
11
src/equicordplugins/friendTags/styles.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.vc-permviewer-user-header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vc-permviewer-user-header-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
154
src/equicordplugins/friendshipRanks/index.tsx
Normal file
154
src/equicordplugins/friendshipRanks/index.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { BadgePosition, BadgeUserArgs, ProfileBadge } from "@api/Badges";
|
||||
import { Badges } from "@api/index";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Forms, RelationshipStore } from "@webpack/common";
|
||||
|
||||
interface rankInfo {
|
||||
title: string;
|
||||
description: string;
|
||||
requirement: number;
|
||||
iconSrc: string;
|
||||
}
|
||||
|
||||
const cl = classNameFactory("vc-friendship-ranks-");
|
||||
|
||||
function daysSince(dateString: string): number {
|
||||
const date = new Date(dateString);
|
||||
const currentDate = new Date();
|
||||
|
||||
const differenceInMs = currentDate.getTime() - date.getTime();
|
||||
|
||||
const days = differenceInMs / (1000 * 60 * 60 * 24);
|
||||
|
||||
return Math.floor(days);
|
||||
}
|
||||
|
||||
const ranks: rankInfo[] =
|
||||
[
|
||||
{
|
||||
title: "Sprout",
|
||||
description: "Your friendship is just starting",
|
||||
requirement: 0,
|
||||
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/sprout.png"
|
||||
},
|
||||
{
|
||||
title: "Blooming",
|
||||
description: "Your friendship is getting there! (1 Month)",
|
||||
requirement: 30,
|
||||
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/blooming.png"
|
||||
},
|
||||
{
|
||||
title: "Burning",
|
||||
description: "Your friendship has reached terminal velocity (3 Months)",
|
||||
requirement: 90,
|
||||
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/burning.png"
|
||||
},
|
||||
{
|
||||
title: "Fighter",
|
||||
description: "Your friendship is strong (6 Months)",
|
||||
requirement: 182.5,
|
||||
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/fighter.png"
|
||||
},
|
||||
{
|
||||
title: "Star",
|
||||
description: "Your friendship has been going on for a WHILE (1 Year)",
|
||||
requirement: 365,
|
||||
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/star.png"
|
||||
},
|
||||
{
|
||||
title: "Royal",
|
||||
description: "Your friendship has gone through thick and thin- a whole 2 years!",
|
||||
requirement: 730,
|
||||
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/royal.png"
|
||||
},
|
||||
{
|
||||
title: "Besties",
|
||||
description: "How do you even manage this??? (5 Years)",
|
||||
requirement: 1826.25,
|
||||
iconSrc: "https://equicord.org/assets/plugins/friendshipRanks/besties.png"
|
||||
}
|
||||
];
|
||||
|
||||
function openRankModal(rank: rankInfo) {
|
||||
openModal(props => (
|
||||
<ErrorBoundary>
|
||||
<ModalRoot {...props} size={ModalSize.DYNAMIC}>
|
||||
<ModalHeader>
|
||||
<Flex className={cl("flex")}>
|
||||
<Forms.FormTitle
|
||||
className={cl("img")}
|
||||
tag="h2"
|
||||
>
|
||||
<img src={rank.iconSrc} alt="rank icon" />
|
||||
{rank.title}
|
||||
</Forms.FormTitle>
|
||||
</Flex>
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<div className={cl("text")}>
|
||||
<Paragraph>
|
||||
{rank.description}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
</ErrorBoundary >
|
||||
));
|
||||
}
|
||||
|
||||
function shouldShowBadge(userId: string, requirement: number, index: number) {
|
||||
if (!RelationshipStore.isFriend(userId)) return false;
|
||||
|
||||
const days = daysSince(RelationshipStore.getSince(userId));
|
||||
|
||||
if (ranks[index + 1] == null) return days > requirement;
|
||||
|
||||
return (days > requirement && days < ranks[index + 1].requirement);
|
||||
}
|
||||
|
||||
function getBadgesToApply() {
|
||||
const badgesToApply: ProfileBadge[] = ranks.map((rank, index) => {
|
||||
return ({
|
||||
description: rank.title,
|
||||
iconSrc: rank.iconSrc,
|
||||
position: BadgePosition.END,
|
||||
onClick: () => openRankModal(rank),
|
||||
shouldShow: (info: BadgeUserArgs) => shouldShowBadge(info.userId, rank.requirement, index),
|
||||
props: {
|
||||
style: {
|
||||
borderRadius: "50%",
|
||||
transform: "scale(0.9)"
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return badgesToApply;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "FriendshipRanks",
|
||||
description: "Adds badges showcasing how long you have been friends with a user for",
|
||||
authors: [Devs.Samwich],
|
||||
start() {
|
||||
getBadgesToApply().forEach(b => Badges.addProfileBadge(b));
|
||||
|
||||
},
|
||||
stop() {
|
||||
getBadgesToApply().forEach(b => Badges.removeProfileBadge(b));
|
||||
},
|
||||
});
|
||||
19
src/equicordplugins/friendshipRanks/styles.css
Normal file
19
src/equicordplugins/friendshipRanks/styles.css
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
.vc-friendship-ranks-flex {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.vc-friendship-ranks-img {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
place-content: center center;
|
||||
margin: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.vc-friendship-ranks-text {
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -7,197 +7,179 @@
|
|||
import "./styles.css";
|
||||
|
||||
import { findGroupChildrenByChildId } from "@api/ContextMenu";
|
||||
import {
|
||||
addServerListElement,
|
||||
removeServerListElement,
|
||||
ServerListRenderPosition,
|
||||
} from "@api/ServerList";
|
||||
import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { closeModal, openModal } from "@utils/modal";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import type { Channel } from "@vencord/discord-types";
|
||||
import { Channel } from "@vencord/discord-types";
|
||||
import { Menu, Tooltip, useEffect, useState } from "@webpack/common";
|
||||
|
||||
import {
|
||||
Boo,
|
||||
clearChannelFromGhost,
|
||||
getBooCount,
|
||||
getGhostedChannels,
|
||||
onBooCountChange,
|
||||
} from "./Boo";
|
||||
import { getChannelDisplayName,GhostedUsersModal } from "./GhostedUsersModal";
|
||||
import { Boo, clearChannelFromGhost, getBooCount, getGhostedChannels, onBooCountChange } from "./Boo";
|
||||
import { getChannelDisplayName, GhostedUsersModal } from "./GhostedUsersModal";
|
||||
import { IconGhost } from "./IconGhost";
|
||||
|
||||
export const cl = classNameFactory("vc-boo-");
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
showIndicator: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show the ghost counter at the top of the server list",
|
||||
default: true,
|
||||
restartNeeded: false,
|
||||
},
|
||||
showDmIcons: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show ghost icons next to individual DMs",
|
||||
default: true,
|
||||
restartNeeded: false,
|
||||
},
|
||||
ignoreGroupDms: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Exclude all group dms from ghosting",
|
||||
default: false,
|
||||
},
|
||||
exemptedChannels: {
|
||||
type: OptionType.STRING,
|
||||
description:
|
||||
"Comma-separated list of channel IDs to exempt from ghosting (right-click a DM channel to copy its ID)",
|
||||
default: "",
|
||||
restartNeeded: false,
|
||||
},
|
||||
ignoreBots: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Ignore DMs from bots",
|
||||
default: true,
|
||||
restartNeeded: false,
|
||||
},
|
||||
showIndicator: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show the ghost counter at the top of the server list",
|
||||
default: true,
|
||||
restartNeeded: false
|
||||
},
|
||||
showDmIcons: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show ghost icons next to individual DMs",
|
||||
default: true,
|
||||
restartNeeded: false
|
||||
},
|
||||
ignoreGroupDms: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Exclude all group dms from ghosting",
|
||||
default: false
|
||||
},
|
||||
exemptedChannels: {
|
||||
type: OptionType.STRING,
|
||||
description: "Comma-separated list of channel IDs to exempt from ghosting (right-click a DM channel to copy its ID)",
|
||||
default: "",
|
||||
restartNeeded: false
|
||||
},
|
||||
ignoreBots: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Ignore DMs from bots",
|
||||
default: true,
|
||||
restartNeeded: false
|
||||
}
|
||||
});
|
||||
|
||||
function BooIndicator() {
|
||||
const [count, setCount] = useState(getBooCount());
|
||||
const [count, setCount] = useState(getBooCount());
|
||||
const [showJumpscare, setShowJumpscare] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onBooCountChange(newCount => {
|
||||
setCount(newCount);
|
||||
});
|
||||
useEffect(() => {
|
||||
const unsubscribe = onBooCountChange(newCount => {
|
||||
setCount(newCount);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!settings.store.showIndicator) return null;
|
||||
if (!settings.store.showIndicator && !showJumpscare) return null;
|
||||
|
||||
const handleClick = () => {
|
||||
const ghostedChannels = getGhostedChannels();
|
||||
const modalKey = openModal(modalProps => (
|
||||
<ErrorBoundary>
|
||||
<GhostedUsersModal
|
||||
modalProps={modalProps}
|
||||
ghostedChannels={ghostedChannels}
|
||||
onClose={() => closeModal(modalKey)}
|
||||
onClearGhost={clearChannelFromGhost}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
));
|
||||
};
|
||||
const handleClick = () => {
|
||||
const ghostedChannels = getGhostedChannels();
|
||||
const modalKey = openModal(modalProps => (
|
||||
<ErrorBoundary>
|
||||
<GhostedUsersModal
|
||||
modalProps={modalProps}
|
||||
ghostedChannels={ghostedChannels}
|
||||
onClose={() => closeModal(modalKey)}
|
||||
onClearGhost={clearChannelFromGhost}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
));
|
||||
};
|
||||
|
||||
const getTooltipText = () => {
|
||||
const ghostedChannels = getGhostedChannels();
|
||||
if (ghostedChannels.length === 0) {
|
||||
return "No Ghosted Users";
|
||||
}
|
||||
if (ghostedChannels.length <= 5) {
|
||||
return ghostedChannels.map(id => getChannelDisplayName(id)).join(", ");
|
||||
}
|
||||
return `${ghostedChannels.length} Ghosted Users`;
|
||||
};
|
||||
const getTooltipText = () => {
|
||||
const ghostedChannels = getGhostedChannels();
|
||||
if (ghostedChannels.length === 0) {
|
||||
return "No Ghosted Users";
|
||||
}
|
||||
if (ghostedChannels.length <= 5) {
|
||||
return ghostedChannels
|
||||
.map(id => getChannelDisplayName(id))
|
||||
.join(", ");
|
||||
}
|
||||
return `${ghostedChannels.length} Ghosted Users`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{settings.store.showIndicator && getGhostedChannels().length > 0 && (
|
||||
<div id={cl("container")}>
|
||||
<Tooltip text={getTooltipText()} position="right">
|
||||
{({ onMouseEnter, onMouseLeave }) => (
|
||||
<div
|
||||
id={cl("container")}
|
||||
className={cl("clickable")}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{count} <IconGhost fill="currentColor" />
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{settings.store.showIndicator && getGhostedChannels().length > 0 && (
|
||||
<div id={cl("container")}>
|
||||
<Tooltip text={getTooltipText()} position="right">
|
||||
{({ onMouseEnter, onMouseLeave }) => (
|
||||
<div
|
||||
id={cl("container")}
|
||||
className={cl("clickable")}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{count} <IconGhost fill="currentColor" />
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function makeContextItem(props) {
|
||||
return (
|
||||
<Menu.MenuItem
|
||||
id="ec-ghosted-clear"
|
||||
key="ec-ghosted-clear"
|
||||
label="unghost"
|
||||
action={() => {
|
||||
clearChannelFromGhost(props.channel.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <Menu.MenuItem
|
||||
id="ec-ghosted-clear"
|
||||
key="ec-ghosted-clear"
|
||||
label="unghost"
|
||||
action={() => {
|
||||
clearChannelFromGhost(props.channel.id);
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "Ghosted",
|
||||
description: "A cute ghost will appear if you don't answer their DMs",
|
||||
authors: [
|
||||
EquicordDevs.vei,
|
||||
Devs.sadan,
|
||||
EquicordDevs.justjxke,
|
||||
EquicordDevs.iamme,
|
||||
],
|
||||
settings,
|
||||
dependencies: ["AudioPlayerAPI", "ServerListAPI"],
|
||||
contextMenus: {
|
||||
"gdm-context": (menuItems, props) => {
|
||||
const group = findGroupChildrenByChildId("leave", menuItems, true);
|
||||
group?.unshift(makeContextItem(props));
|
||||
},
|
||||
"user-context": (menuItems, props) => {
|
||||
const group = findGroupChildrenByChildId("close-dm", menuItems);
|
||||
group?.push(makeContextItem(props));
|
||||
},
|
||||
},
|
||||
name: "Ghosted",
|
||||
description: "A cute ghost will appear if you don't answer their DMs",
|
||||
authors: [EquicordDevs.vei, Devs.sadan, EquicordDevs.justjxke, EquicordDevs.iamme],
|
||||
settings,
|
||||
dependencies: ["AudioPlayerAPI", "ServerListAPI"],
|
||||
contextMenus: {
|
||||
"gdm-context": (menuItems, props) => {
|
||||
const group = findGroupChildrenByChildId("leave", menuItems, true);
|
||||
group?.unshift(makeContextItem(props));
|
||||
},
|
||||
"user-context": (menuItems, props) => {
|
||||
const group = findGroupChildrenByChildId("close-dm", menuItems);
|
||||
group?.push(makeContextItem(props));
|
||||
}
|
||||
},
|
||||
|
||||
patches: [
|
||||
{
|
||||
find: "PrivateChannel.renderAvatar",
|
||||
replacement: {
|
||||
match: /\]:i\|\|i.{0,50}children:\[/,
|
||||
replace: "$&$self.renderBoo(arguments[0]),",
|
||||
},
|
||||
},
|
||||
],
|
||||
patches: [
|
||||
{
|
||||
find: "PrivateChannel.renderAvatar",
|
||||
replacement: {
|
||||
match: /\]:\i\|\|\i.{0,50}children:\[/,
|
||||
replace: "$&$self.renderBoo(arguments[0]),"
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
renderBoo(props: { channel: Channel }) {
|
||||
return (
|
||||
<ErrorBoundary noop>
|
||||
<Boo {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
},
|
||||
renderBoo(props: { channel: Channel; }) {
|
||||
return (
|
||||
<ErrorBoundary noop>
|
||||
<Boo {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
},
|
||||
|
||||
renderIndicator() {
|
||||
return (
|
||||
<ErrorBoundary noop>
|
||||
<BooIndicator />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
},
|
||||
renderIndicator() {
|
||||
return (
|
||||
<ErrorBoundary noop>
|
||||
<BooIndicator />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
},
|
||||
|
||||
start() {
|
||||
addServerListElement(ServerListRenderPosition.Above, this.renderIndicator);
|
||||
},
|
||||
start() {
|
||||
addServerListElement(ServerListRenderPosition.Above, this.renderIndicator);
|
||||
},
|
||||
|
||||
stop() {
|
||||
removeServerListElement(
|
||||
ServerListRenderPosition.Above,
|
||||
this.renderIndicator,
|
||||
);
|
||||
},
|
||||
stop() {
|
||||
removeServerListElement(ServerListRenderPosition.Above, this.renderIndicator);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,6 +53,93 @@
|
|||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Jumpscare overlay styling */
|
||||
.vc-boo-jumpscare-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgb(0 0 0 / 50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-boo-jumpscare-image {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* job application */
|
||||
@keyframes boo-jumpscare-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes boo-jumpscare-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes boo-jumpscare-shake {
|
||||
|
||||
/* stylelint-disable-next-line rule-empty-line-before */
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: translate(-5px, -5px) rotate(-2deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(5px, 5px) rotate(2deg);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(-5px, 5px) rotate(-1deg);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translate(5px, -5px) rotate(1deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-3px, -3px) rotate(-1deg);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translate(3px, 3px) rotate(1deg);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: translate(-3px, 3px) rotate(-0.5deg);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translate(3px, -3px) rotate(0.5deg);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: translate(-2px, -2px) rotate(-0.5deg);
|
||||
}
|
||||
}
|
||||
|
||||
.vc-boo-clear {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
|
|
|||
48
src/equicordplugins/hopOn/index.tsx
Normal file
48
src/equicordplugins/hopOn/index.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Message } from "@vencord/discord-types";
|
||||
import { RelationshipStore, SelectedChannelStore } from "@webpack/common";
|
||||
|
||||
interface IMessageCreate {
|
||||
type: "MESSAGE_CREATE";
|
||||
optimistic: boolean;
|
||||
channelId: string;
|
||||
message: Message;
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
regex: {
|
||||
type: OptionType.STRING,
|
||||
description: "Regex to trigger on",
|
||||
default: "hop on (?:fortnite|fn)"
|
||||
},
|
||||
url: {
|
||||
type: OptionType.STRING,
|
||||
description: "URL to open",
|
||||
default: "com.epicgames.launcher://apps/fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite?action=launch&silent=true"
|
||||
}
|
||||
});
|
||||
export default definePlugin({
|
||||
name: "HopOn",
|
||||
description: "Hop on! Opens a configurable URL when a message matches a custom regex in the current channel.",
|
||||
authors: [Devs.ImLvna],
|
||||
settings,
|
||||
flux: {
|
||||
async MESSAGE_CREATE({ optimistic, type, message, channelId }: IMessageCreate) {
|
||||
if (optimistic || type !== "MESSAGE_CREATE") return;
|
||||
if (message.state === "SENDING") return;
|
||||
if (RelationshipStore.isBlocked(message.author?.id)) return;
|
||||
if (channelId !== SelectedChannelStore.getChannelId()) return;
|
||||
if (!message.content?.match(new RegExp(settings.store.regex, "i"))) return;
|
||||
|
||||
VencordNative.native.openExternal(settings.store.url);
|
||||
}
|
||||
}
|
||||
});
|
||||
122
src/equicordplugins/husk/index.tsx
Normal file
122
src/equicordplugins/husk/index.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/* eslint-disable simple-header/header */
|
||||
|
||||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2023 your mom lol
|
||||
*
|
||||
* 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 { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { classes } from "@utils/misc";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { ChannelStore, EmojiStore, RestAPI } from "@webpack/common";
|
||||
import type { SVGProps } from "react";
|
||||
// eslint-disable-next-line no-duplicate-imports
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
interface BaseIconProps extends IconProps {
|
||||
viewBox: string;
|
||||
}
|
||||
|
||||
interface IconProps extends SVGProps<SVGSVGElement> {
|
||||
className?: string;
|
||||
height?: string | number;
|
||||
width?: string | number;
|
||||
}
|
||||
|
||||
function Icon({ height = 24, width = 24, className, children, viewBox, ...svgProps }: PropsWithChildren<BaseIconProps>) {
|
||||
return (
|
||||
<svg
|
||||
className={classes(className, "vc-icon")}
|
||||
role="img"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={viewBox}
|
||||
{...svgProps}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Husk(props: IconProps) {
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
className={classes(props.className, "vc-husk")}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12,2C6.47,2 2,6.47 2,12C2,17.53 6.47,22 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M16.18,7.76L15.12,8.82L14.06,7.76L13,8.82L14.06,9.88L13,10.94L14.06,12L15.12,10.94L16.18,12L17.24,10.94L16.18,9.88L17.24,8.82L16.18,7.76M7.82,12L8.88,10.94L9.94,12L11,10.94L9.94,9.88L11,8.82L9.94,7.76L8.88,8.82L7.82,7.76L6.76,8.82L7.82,9.88L6.76,10.94L7.82,12M12,14C9.67,14 7.69,15.46 6.89,17.5H17.11C16.31,15.46 14.33,14 12,14Z"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
findInServer: {
|
||||
description: "Attempt to find emoji of same name in server before using ID in settings (useful if no nitro)",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true
|
||||
},
|
||||
emojiName: {
|
||||
description: "Emoji name (default (from Vencord Server): husk)",
|
||||
type: OptionType.STRING,
|
||||
default: "husk"
|
||||
},
|
||||
emojiID: {
|
||||
description: "Emoji ID (default (from Vencord Server): 1026532993923293184)",
|
||||
type: OptionType.BIGINT,
|
||||
default: 1026532993923293184n
|
||||
}
|
||||
});
|
||||
|
||||
function getEmojiIdThatShouldBeUsed(guildId: string) {
|
||||
if (!settings.store.findInServer || guildId === "") return settings.store.emojiID;
|
||||
let id = "";
|
||||
EmojiStore.getGuildEmoji(guildId).forEach(emoji => {
|
||||
if (emoji.name === settings.store.emojiName) {
|
||||
id = emoji.id;
|
||||
}
|
||||
});
|
||||
return id !== "" ? id : settings.store.emojiID;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "Husk",
|
||||
description: "Adds Husk button (check settings to change used emoji)",
|
||||
authors: [Devs.nin0dev],
|
||||
settings,
|
||||
messagePopoverButton: {
|
||||
icon: Husk,
|
||||
render(msg) {
|
||||
return {
|
||||
label: "Husk",
|
||||
icon: Husk,
|
||||
message: msg,
|
||||
channel: ChannelStore.getChannel(msg.channel_id),
|
||||
onClick: () => {
|
||||
const guildId = ChannelStore.getChannel(msg.channel_id).guild_id !== null ? ChannelStore.getChannel(msg.channel_id).guild_id : "";
|
||||
RestAPI.put({
|
||||
url: `/channels/${msg.channel_id}/messages/${msg.id}/reactions/${settings.store.emojiName}:${getEmojiIdThatShouldBeUsed(guildId)}/@me`
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
},
|
||||
}
|
||||
});
|
||||
217
src/equicordplugins/iRememberYou/components/data.tsx
Normal file
217
src/equicordplugins/iRememberYou/components/data.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { DataStore } from "@api/index";
|
||||
import { Guild, User } from "@vencord/discord-types";
|
||||
import { ChannelStore, GuildMemberStore, GuildStore, InviteActions, MessageStore, PermissionsBits, PermissionStore, UserStore, } from "@webpack/common";
|
||||
|
||||
export interface IUserExtra {
|
||||
isOwner?: boolean;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface IStorageUser {
|
||||
id: string;
|
||||
username: string,
|
||||
tag: string,
|
||||
iconURL?: string;
|
||||
extra?: IUserExtra;
|
||||
}
|
||||
|
||||
export interface GroupData {
|
||||
id: string;
|
||||
users: { [key: string]: IStorageUser; };
|
||||
name: string;
|
||||
inviteLink?: string;
|
||||
}
|
||||
|
||||
export class Data {
|
||||
declare usersCollection: Record<string, GroupData>;
|
||||
declare _storageAutoSaveProtocol_interval;
|
||||
declare _onMessagePreSend_preSend;
|
||||
|
||||
withStart() {
|
||||
return this;
|
||||
}
|
||||
|
||||
onMessagePreSend(channelId, message, extra) {
|
||||
const target: Set<{ user: User; source?: Guild, extra: IUserExtra; }> = new Set();
|
||||
const now = Date.now();
|
||||
const { replyOptions } = extra;
|
||||
|
||||
const guild = (() => {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
return GuildStore.getGuild(channel.guild_id) || undefined;
|
||||
})();
|
||||
|
||||
if (replyOptions.messageReference) {
|
||||
const { channel_id, message_id } = replyOptions.messageReference;
|
||||
const message = MessageStore.getMessage(channel_id, message_id);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
const { author } = message;
|
||||
|
||||
target.add({ user: author, source: guild, extra: { updatedAt: now } });
|
||||
}
|
||||
|
||||
if (message.content) {
|
||||
const { content } = message;
|
||||
const ids = [...content.matchAll(/<@!?(?<id>\d{17,23})>/g)].map(
|
||||
({ groups }) => groups.id
|
||||
);
|
||||
|
||||
const users = ids
|
||||
.map(id => UserStore.getUser(id))
|
||||
.filter(Boolean);
|
||||
for (const user of users) {
|
||||
target.add({ user, source: guild, extra: { updatedAt: now } });
|
||||
}
|
||||
}
|
||||
|
||||
this.processUsersToCollection([...target]);
|
||||
}
|
||||
|
||||
async processUsersToCollection(
|
||||
array: { user: User; source?: Guild; extra?: IUserExtra; }[]
|
||||
) {
|
||||
const target = this.usersCollection;
|
||||
const processedGuilds = new Set<string>();
|
||||
|
||||
for (const { user, source, extra } of array) {
|
||||
if (user.bot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupKey = source?.id ?? "dm";
|
||||
const group = (target[groupKey] ||= {
|
||||
name: source?.name || "dm",
|
||||
id: source?.id || user.id,
|
||||
users: {},
|
||||
inviteLink: undefined
|
||||
});
|
||||
const usersField = group.users;
|
||||
const previouExtra = usersField[user.id]?.extra ?? {};
|
||||
const { id, username } = user;
|
||||
|
||||
usersField[id] = {
|
||||
id,
|
||||
username,
|
||||
tag: user.discriminator === "0" ? user.username : user.tag,
|
||||
extra: { ...previouExtra, ...extra },
|
||||
iconURL: user.getAvatarURL(),
|
||||
};
|
||||
|
||||
if (source && !processedGuilds.has(source.id) && this.hasInvitePermissions(source.id)) {
|
||||
this.collectInviteLink(source.id);
|
||||
processedGuilds.add(source.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateStorage() {
|
||||
await DataStore.set("irememberyou.data", this.usersCollection);
|
||||
}
|
||||
|
||||
async initializeUsersCollection() {
|
||||
const data = await DataStore.get("irememberyou.data");
|
||||
this.usersCollection = data ?? {};
|
||||
}
|
||||
|
||||
writeMembersFromUserGuildsToCollection() {
|
||||
const target: Set<{ user: User; source?: Guild, extra: IUserExtra; }> =
|
||||
new Set();
|
||||
|
||||
const now = Date.now();
|
||||
const LIMIT = 1_000;
|
||||
|
||||
const clientId = UserStore.getCurrentUser().id;
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
for (const guild of Object.values(GuildStore.getGuilds())) {
|
||||
const { ownerId } = guild;
|
||||
if (ownerId !== clientId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const members = GuildMemberStore.getMembers(guild.id);
|
||||
if (members.length > LIMIT) {
|
||||
members.length = LIMIT;
|
||||
}
|
||||
for (const member of members) {
|
||||
const user = UserStore.getUser(member.userId);
|
||||
target.add({ user, source: guild, extra: { updatedAt: now } });
|
||||
}
|
||||
|
||||
this.processUsersToCollection([...target]);
|
||||
|
||||
if (guild && this.hasInvitePermissions(guild.id)) {
|
||||
this.collectInviteLink(guild.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeGuildsOwnersToCollection() {
|
||||
const target: Set<{ user: User; source?: Guild; extra: IUserExtra; }> =
|
||||
new Set();
|
||||
const now = Date.now();
|
||||
|
||||
for (const guild of Object.values(GuildStore.getGuilds())) {
|
||||
const { ownerId } = guild;
|
||||
const owner = UserStore.getUser(ownerId);
|
||||
if (!owner) {
|
||||
continue;
|
||||
}
|
||||
target.add({
|
||||
user: owner,
|
||||
source: guild,
|
||||
extra: { isOwner: true, updatedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
this.processUsersToCollection([...target]);
|
||||
}
|
||||
|
||||
hasInvitePermissions(guildId: string): boolean {
|
||||
const guild = GuildStore.getGuild(guildId);
|
||||
if (!guild) return false;
|
||||
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) return false;
|
||||
|
||||
if (guild.ownerId === currentUser.id) return true;
|
||||
|
||||
const member = GuildMemberStore.getMember(guildId, currentUser.id);
|
||||
if (!member) return false;
|
||||
|
||||
return PermissionStore.can(PermissionsBits.CREATE_INSTANT_INVITE, guild);
|
||||
}
|
||||
|
||||
async collectInviteLink(guildId: string) {
|
||||
try {
|
||||
const invites = await InviteActions.getInvites(guildId);
|
||||
if (invites && invites.length > 0) {
|
||||
const invite = invites[0];
|
||||
const inviteCode = invite.code;
|
||||
const inviteLink = `https://discord.gg/${inviteCode}`;
|
||||
|
||||
if (this.usersCollection[guildId]) {
|
||||
this.usersCollection[guildId].inviteLink = inviteLink;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to collect invite link for guild:", guildId, error);
|
||||
}
|
||||
}
|
||||
|
||||
storageAutoSaveProtocol() {
|
||||
this._storageAutoSaveProtocol_interval = setInterval(
|
||||
this.updateStorage.bind(this),
|
||||
60_000 * 3
|
||||
);
|
||||
}
|
||||
}
|
||||
141
src/equicordplugins/iRememberYou/components/ui.tsx
Normal file
141
src/equicordplugins/iRememberYou/components/ui.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { Card } from "@components/Card";
|
||||
import { HeadingPrimary } from "@components/Heading";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { SettingsTab, wrapTab } from "@components/settings";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { copyWithToast, openUserProfile } from "@utils/discord";
|
||||
import { Avatar, Clickable, ContextMenuApi, Menu, React, TextInput, Tooltip, UserStore } from "@webpack/common";
|
||||
|
||||
import { Data, IStorageUser } from "./data";
|
||||
|
||||
const cl = classNameFactory("vc-i-remember-you-");
|
||||
|
||||
function tooltipText(user: IStorageUser) {
|
||||
const { updatedAt } = user.extra || {};
|
||||
const updatedAtContent = updatedAt ? new Intl.DateTimeFormat().format(updatedAt) : null;
|
||||
return `${user.username ?? user.tag}, updated at ${updatedAtContent}`;
|
||||
}
|
||||
|
||||
function UsersCollectionRows({ usersCollection }: { usersCollection: Data["usersCollection"]; }) {
|
||||
if (Object.keys(usersCollection).length === 0) return (
|
||||
<BaseText>
|
||||
It's empty right now
|
||||
</BaseText>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(usersCollection)
|
||||
.map(([_key, { users, name }]) => ({ name, users: Object.values(users) }))
|
||||
.sort((a, b) => b.users.length - a.users.length)
|
||||
.map(({ name, users }) => (
|
||||
<aside key={name}>
|
||||
<div className={cl("header-container")}>
|
||||
<HeadingPrimary className={cl("header-name")}>{name}</HeadingPrimary>
|
||||
<div className={cl("header-btns")}>
|
||||
{users.map(u => <UserRow key={u.id} user={u} />)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({ user, allowOwner = true }: { user: IStorageUser, allowOwner?: boolean; }) {
|
||||
return (
|
||||
<div key={user.id} className={cl("user-row")}>
|
||||
<div className={cl("user")}>
|
||||
<Clickable onClick={() => openUserProfile(user.id)}>
|
||||
<span className={cl("user-avatar")}>
|
||||
<Avatar src={user.iconURL} size="SIZE_24" />
|
||||
</span>
|
||||
</Clickable>
|
||||
<div className={cl("user-tooltip")}>
|
||||
<Tooltip text={tooltipText(user)}>
|
||||
{props =>
|
||||
<Paragraph {...props} className={cl("user-username")}>
|
||||
{user.tag} {allowOwner && user.extra?.isOwner && "(owner)"}
|
||||
</Paragraph>
|
||||
}
|
||||
</Tooltip>
|
||||
<span
|
||||
className={cl("user-id")}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
const userObj = UserStore.getUser(user.id);
|
||||
if (userObj) {
|
||||
ContextMenuApi.openContextMenu(e, () => (
|
||||
<Menu.Menu
|
||||
navId="user-context-menu"
|
||||
onClose={ContextMenuApi.closeContextMenu}
|
||||
aria-label="User Options"
|
||||
>
|
||||
<Menu.MenuItem
|
||||
id="copy-user-id"
|
||||
label="Copy User ID"
|
||||
action={() => copyWithToast(user.id, "User ID copied to clipboard")}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
id="view-profile"
|
||||
label="View Profile"
|
||||
action={() => openUserProfile(user.id)}
|
||||
/>
|
||||
</Menu.Menu>
|
||||
));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Clickable onClick={() => copyWithToast(user.id, "User ID copied to clipboard")}>
|
||||
<Paragraph>
|
||||
{user.id}
|
||||
</Paragraph>
|
||||
</Clickable>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchElement({ usersCollection }: { usersCollection: Data["usersCollection"]; }) {
|
||||
const [current, setCurrent] = React.useState("");
|
||||
const list = Object.values(usersCollection).flatMap(col => Object.values(col.users)) as IStorageUser[];
|
||||
|
||||
return (
|
||||
<section className={cl("search")}>
|
||||
<TextInput placeholder="Filter by tag, username" name="Filter" onChange={setCurrent} />
|
||||
{current && (
|
||||
<div className={cl("search-user")}>
|
||||
{list.filter(user => user.tag.includes(current) || user.username.includes(current))
|
||||
.map(user => <UserRow key={user.id} user={user} allowOwner={false} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataUI({ usersCollection }: { usersCollection: Data["usersCollection"]; }) {
|
||||
return (
|
||||
<SettingsTab>
|
||||
<Card>
|
||||
<Paragraph>
|
||||
Provides a list of users you have mentioned or replied to, or those who own the servers you belong to (owner*), or are members of your guild
|
||||
</Paragraph>
|
||||
<SearchElement usersCollection={usersCollection} />
|
||||
</Card>
|
||||
<div className={cl("rows")}>
|
||||
<UsersCollectionRows usersCollection={usersCollection} />
|
||||
</div>
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
export default wrapTab(DataUI, "IRememberYouTab");
|
||||
55
src/equicordplugins/iRememberYou/index.tsx
Normal file
55
src/equicordplugins/iRememberYou/index.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { addMessagePreSendListener, removeMessagePreSendListener } from "@api/MessageEvents";
|
||||
import { EyeIcon } from "@components/Icons";
|
||||
import SettingsPlugin from "@plugins/_core/settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { removeFromArray } from "@utils/misc";
|
||||
import definePlugin from "@utils/types";
|
||||
|
||||
import { Data } from "./components/data";
|
||||
import DataUI from "./components/ui";
|
||||
|
||||
export default definePlugin({
|
||||
name: "IRememberYou",
|
||||
description: "Locally saves everyone you've been communicating with (including servers), in case of lose",
|
||||
authors: [EquicordDevs.zoodogood, EquicordDevs.keircn],
|
||||
dependencies: ["MessageEventsAPI"],
|
||||
|
||||
patches: [],
|
||||
|
||||
async start() {
|
||||
SettingsPlugin.customEntries.push({
|
||||
key: "equicord_i_remember_you",
|
||||
title: "I Remember You",
|
||||
Component: () => <DataUI usersCollection={data.usersCollection} />,
|
||||
Icon: EyeIcon
|
||||
});
|
||||
SettingsPlugin.settingsSectionMap.push(["EquicordIRememberYou", "equicord_i_remember_you"]);
|
||||
|
||||
const data = (this.dataManager = await new Data().withStart());
|
||||
|
||||
await data.initializeUsersCollection();
|
||||
data.writeGuildsOwnersToCollection();
|
||||
data.writeMembersFromUserGuildsToCollection();
|
||||
data._onMessagePreSend_preSend = addMessagePreSendListener(
|
||||
data.onMessagePreSend.bind(data)
|
||||
);
|
||||
data.storageAutoSaveProtocol();
|
||||
},
|
||||
|
||||
stop() {
|
||||
removeFromArray(SettingsPlugin.customEntries, e => e.key === "equicord_i_remember_you");
|
||||
removeFromArray(SettingsPlugin.settingsSectionMap, entry => entry[1] === "equicord_i_remember_you");
|
||||
|
||||
const dataManager = this.dataManager as Data;
|
||||
removeMessagePreSendListener(dataManager._onMessagePreSend_preSend);
|
||||
clearInterval(dataManager._storageAutoSaveProtocol_interval);
|
||||
},
|
||||
});
|
||||
78
src/equicordplugins/iRememberYou/styles.css
Normal file
78
src/equicordplugins/iRememberYou/styles.css
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
.vc-i-remember-you-header-container {
|
||||
display: contents;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vc-i-remember-you-header-name {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.vc-i-remember-you-header-btns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 350px));
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.vc-i-remember-you-header {
|
||||
color: var(--text-default);
|
||||
}
|
||||
|
||||
.vc-i-remember-you-rows {
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vc-i-remember-you-search {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.vc-i-remember-you-search-user {
|
||||
flex-direction: column;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.vc-i-remember-you-user-avatar {
|
||||
cursor: pointer;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.vc-i-remember-you-user-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.vc-i-remember-you-user-id {
|
||||
align-self: flex-start;
|
||||
opacity: 0.75;
|
||||
height: min-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-i-remember-you-user-id:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vc-i-remember-you-user {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.vc-i-remember-you-user-username {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.vc-i-remember-you-user-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
85
src/equicordplugins/idleAutoRestart/index.tsx
Normal file
85
src/equicordplugins/idleAutoRestart/index.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Menu, VoiceStateStore } from "@webpack/common";
|
||||
|
||||
const logger = new Logger("IdleAutoRestart");
|
||||
let lastActivity = 0;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const settings = definePluginSettings({
|
||||
isEnabled: {
|
||||
description: "Enable automatic restart after idle",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
idleMinutes: {
|
||||
description: "Minutes of inactivity before restarting (when not in VC)",
|
||||
type: OptionType.SLIDER,
|
||||
markers: [5, 10, 15, 30, 60, 120],
|
||||
default: 30,
|
||||
stickToMarkers: false,
|
||||
},
|
||||
});
|
||||
|
||||
function onActivity() {
|
||||
lastActivity = Date.now();
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "IdleAutoRestart",
|
||||
description: "Automatically restarts the client after being idle for a configurable amount of time, but avoids restarting while you are in VC.",
|
||||
authors: [EquicordDevs.SteelTech],
|
||||
settings,
|
||||
|
||||
toolboxActions() {
|
||||
return (
|
||||
<Menu.MenuItem
|
||||
id="auto-idle-restart-toggle-toolbox"
|
||||
label={settings.store.isEnabled ? "Disable Auto Idle Restart" : "Enable Auto Idle Restart"}
|
||||
action={() => {
|
||||
settings.store.isEnabled = !settings.store.isEnabled;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
start() {
|
||||
lastActivity = Date.now();
|
||||
|
||||
document.addEventListener("mousemove", onActivity);
|
||||
document.addEventListener("keydown", onActivity);
|
||||
document.addEventListener("mousedown", onActivity);
|
||||
document.addEventListener("wheel", onActivity, { passive: true });
|
||||
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
intervalId = setInterval(() => {
|
||||
if (!settings.store.isEnabled || VoiceStateStore.isCurrentClientInVoiceChannel()) return;
|
||||
|
||||
const idleMs = settings.store.idleMinutes * 60_000;
|
||||
if (Date.now() - lastActivity >= idleMs) {
|
||||
logger.info("Idle timeout reached, reloading client");
|
||||
location.reload();
|
||||
}
|
||||
}, 30_000);
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
|
||||
document.removeEventListener("mousemove", onActivity);
|
||||
document.removeEventListener("keydown", onActivity);
|
||||
document.removeEventListener("mousedown", onActivity);
|
||||
document.removeEventListener("wheel", onActivity);
|
||||
},
|
||||
});
|
||||
44
src/equicordplugins/inRole/RoleMembersModal.tsx
Normal file
44
src/equicordplugins/inRole/RoleMembersModal.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import { GuildMember } from "@vencord/discord-types";
|
||||
import { Parser } from "@webpack/common";
|
||||
|
||||
const cl = classNameFactory("vc-inrole-");
|
||||
|
||||
export function showInRoleModal(members: GuildMember[], roleId: string, channelId: string) {
|
||||
openModal(props =>
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<ModalRoot {...props} size={ModalSize.DYNAMIC} fullscreenOnMobile={true} >
|
||||
<ModalHeader className={cl("header")}>
|
||||
<Paragraph style={{ fontSize: "1.2rem", fontWeight: "bold", marginRight: "7px" }}>Members of role {
|
||||
Parser.parse(`<@&${roleId}>`, true, { channelId, viewingChannelId: channelId })
|
||||
} ({members.length})</Paragraph>
|
||||
<ModalCloseButton onClick={props.onClose} className={cl("close")} />
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<div style={{ padding: "13px 20px" }} className={cl("member-list")}>
|
||||
{
|
||||
members.length !== 0 ? members.map(member =>
|
||||
<>
|
||||
<Paragraph className={cl("modal-member")}>
|
||||
{Parser.parse(`<@${member.userId}>`, true, { channelId, viewingChannelId: channelId })}
|
||||
</Paragraph>
|
||||
</>
|
||||
) : <Paragraph>Looks like no online cached members with that role were found. Try scrolling down on your member list to cache more users!</Paragraph>
|
||||
}
|
||||
</div>
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
);
|
||||
}
|
||||
127
src/equicordplugins/inRole/index.tsx
Normal file
127
src/equicordplugins/inRole/index.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 nin0dev
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./style.css";
|
||||
|
||||
import { ApplicationCommandInputType, ApplicationCommandOptionType, sendBotMessage } from "@api/Commands";
|
||||
import { getUserSettingLazy } from "@api/UserSettings";
|
||||
import { InfoIcon } from "@components/Icons";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { getCurrentChannel, getCurrentGuild } from "@utils/discord";
|
||||
import definePlugin from "@utils/types";
|
||||
import { GuildMember } from "@vencord/discord-types";
|
||||
import { GuildMemberStore, GuildRoleStore, Menu, Parser } from "@webpack/common";
|
||||
|
||||
import { showInRoleModal } from "./RoleMembersModal";
|
||||
|
||||
const DeveloperMode = getUserSettingLazy("appearance", "developerMode")!;
|
||||
|
||||
function getMembersInRole(roleId: string, guildId: string) {
|
||||
const members = GuildMemberStore.getMembers(guildId);
|
||||
const membersInRole: GuildMember[] = [];
|
||||
members.forEach(member => {
|
||||
if (member.roles.includes(roleId)) {
|
||||
membersInRole.push(member);
|
||||
}
|
||||
});
|
||||
return membersInRole;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "InRole",
|
||||
description: "Know who is in a role with the role context menu or /inrole command (read plugin info!)",
|
||||
authors: [Devs.nin0dev],
|
||||
dependencies: ["UserSettingsAPI"],
|
||||
start() {
|
||||
// DeveloperMode needs to be enabled for the context menu to be shown
|
||||
DeveloperMode.updateSetting(true);
|
||||
},
|
||||
settingsAboutComponent: () => {
|
||||
return (
|
||||
<>
|
||||
<Paragraph style={{ fontSize: "1.2rem", marginTop: "15px", fontWeight: "bold" }}>{Parser.parse(":warning:")} Limitations</Paragraph>
|
||||
<Paragraph style={{ marginTop: "10px", fontWeight: "500" }} >If you don't have mod permissions on the server, and that server is large (over 100 members), the plugin may be limited in the following ways:</Paragraph>
|
||||
<Paragraph>• Offline members won't be listed</Paragraph>
|
||||
<Paragraph>• Up to 100 members will be listed by default. To get more, scroll down in the member list to load more members.</Paragraph>
|
||||
<Paragraph>• However, friends will always be shown regardless of their status.</Paragraph>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
commands: [
|
||||
{
|
||||
name: "inrole",
|
||||
description: "Know who is in a role",
|
||||
inputType: ApplicationCommandInputType.BUILT_IN,
|
||||
options: [
|
||||
{
|
||||
name: "role",
|
||||
description: "The role",
|
||||
type: ApplicationCommandOptionType.ROLE,
|
||||
required: true
|
||||
},
|
||||
],
|
||||
execute: (args, ctx) => {
|
||||
// Guild check
|
||||
if (!ctx.guild) {
|
||||
return sendBotMessage(ctx.channel.id, { content: "Make sure that you are in a server." });
|
||||
}
|
||||
const role = args[0].value;
|
||||
showInRoleModal(getMembersInRole(role, ctx.guild.id), role, ctx.channel.id);
|
||||
}
|
||||
}
|
||||
],
|
||||
contextMenus: {
|
||||
"dev-context"(children, { id }: { id: string; }) {
|
||||
const guild = getCurrentGuild();
|
||||
if (!guild) return;
|
||||
|
||||
const channel = getCurrentChannel();
|
||||
if (!channel) return;
|
||||
|
||||
const role = GuildRoleStore.getRole(guild.id, id);
|
||||
if (!role) return;
|
||||
|
||||
children.push(
|
||||
<Menu.MenuItem
|
||||
id="vc-view-inrole"
|
||||
label="View Members in Role"
|
||||
action={() => {
|
||||
showInRoleModal(getMembersInRole(role.id, guild.id), role.id, channel.id);
|
||||
}}
|
||||
icon={InfoIcon}
|
||||
/>
|
||||
);
|
||||
},
|
||||
"message"(children, { message }: { message: any; }) {
|
||||
const guild = getCurrentGuild();
|
||||
if (!guild) return;
|
||||
|
||||
const roleMentions = message.content.match(/<@&(\d+)>/g);
|
||||
if (!roleMentions?.length) return;
|
||||
|
||||
const channel = getCurrentChannel();
|
||||
if (!channel) return;
|
||||
|
||||
const roleIds = roleMentions.map(mention => mention.match(/<@&(\d+)>/)![1]);
|
||||
|
||||
const role = GuildRoleStore.getRole(guild.id, roleIds);
|
||||
if (!role) return;
|
||||
|
||||
children.push(
|
||||
<Menu.MenuItem
|
||||
id="vc-view-inrole"
|
||||
label="View Members in Role"
|
||||
action={() => {
|
||||
showInRoleModal(getMembersInRole(role.id, guild.id), role.id, channel.id);
|
||||
}}
|
||||
icon={InfoIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
29
src/equicordplugins/inRole/style.css
Normal file
29
src/equicordplugins/inRole/style.css
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
.vc-inrole-member-list {
|
||||
max-height: 400px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 13px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.vc-inrole-member-list::-webkit-scrollbar {
|
||||
background-color: #fff1;
|
||||
border-radius: 100px;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.vc-inrole-member-list::-webkit-scrollbar-thumb {
|
||||
background-color: #fff3;
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
.vc-inrole-modal-member {
|
||||
margin: 11px 0;
|
||||
}
|
||||
|
||||
.vc-inrole-header {
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.vc-inrole-close {
|
||||
margin-left: auto;
|
||||
}
|
||||
161
src/equicordplugins/ingtoninator/index.tsx
Normal file
161
src/equicordplugins/ingtoninator/index.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
|
||||
import { addMessagePreSendListener, removeMessagePreSendListener } from "@api/MessageEvents";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { React } from "@webpack/common";
|
||||
|
||||
type WordMatch = {
|
||||
word: string,
|
||||
startIndex: number,
|
||||
};
|
||||
|
||||
const settings = definePluginSettings({
|
||||
showIcon: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Show a button to toggle the Ingtoninator plugin",
|
||||
restartNeeded: true
|
||||
},
|
||||
isEnabled: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Enable or disable the Ingtoninator"
|
||||
}
|
||||
});
|
||||
|
||||
const isLegal = (word: string): boolean => {
|
||||
if (word === "i" || word === "I") return false;
|
||||
|
||||
const lastChar = word.slice(-1).toLowerCase();
|
||||
if (["a", "e", "o", "u", "y"].includes(lastChar)) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getWords = (string: string): WordMatch[] => {
|
||||
const linkRegex = /https?:\/\/[^\s]+\.[^\s]+/g;
|
||||
const linkRanges = new Set<number>();
|
||||
|
||||
for (const match of string.matchAll(linkRegex)) {
|
||||
const start = match.index!;
|
||||
const end = start + match[0].length;
|
||||
for (let i = start; i < end; i++) {
|
||||
linkRanges.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(string.matchAll(/[\p{L}]+/gu), match => ({
|
||||
word: match[0],
|
||||
startIndex: match.index!
|
||||
})).filter(match => !linkRanges.has(match.startIndex));
|
||||
};
|
||||
|
||||
const chooseRandomWord = (message: string): WordMatch | null => {
|
||||
const words: WordMatch[] = getWords(message);
|
||||
|
||||
while (words.length > 0) {
|
||||
const index: number = Math.floor(Math.random() * words.length);
|
||||
const wordMatch: WordMatch = words[index];
|
||||
|
||||
if (!isLegal(wordMatch.word)) {
|
||||
words.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
return wordMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const ington = (word: string): string => {
|
||||
if (word.endsWith("INGTON")) return "";
|
||||
if (word.endsWith("INGTO")) return "N";
|
||||
if (word.endsWith("INGT")) return "ON";
|
||||
if (word.endsWith("ING")) return "TON";
|
||||
if (word.endsWith("IN")) return "GTON";
|
||||
if (word.endsWith("I")) return "NGTON";
|
||||
return "INGTON";
|
||||
};
|
||||
|
||||
const handleMessage = ((channelId, message) => {
|
||||
if (!settings.store.isEnabled) return;
|
||||
|
||||
const msg = message.content;
|
||||
if (!msg || !msg.trim()) return;
|
||||
|
||||
const wordMatch: WordMatch | null = chooseRandomWord(msg);
|
||||
if (wordMatch === null) return;
|
||||
|
||||
const { word } = wordMatch;
|
||||
const wordUpper = word.toUpperCase();
|
||||
const isUpper = word === wordUpper;
|
||||
|
||||
let insertion = ington(wordUpper);
|
||||
if (!isUpper) {
|
||||
insertion = insertion.toLowerCase();
|
||||
}
|
||||
|
||||
const idx: number = wordMatch.startIndex + word.length;
|
||||
message.content = msg.slice(0, idx) + insertion + msg.slice(idx);
|
||||
});
|
||||
|
||||
const IngtoninatorButton: ChatBarButtonFactory = ({ isMainChat }) => {
|
||||
const { isEnabled, showIcon } = settings.use(["isEnabled", "showIcon"]);
|
||||
const toggle = () => settings.store.isEnabled = !settings.store.isEnabled;
|
||||
|
||||
if (!isMainChat || !showIcon) return null;
|
||||
|
||||
return (
|
||||
<ChatBarButton
|
||||
tooltip={isEnabled ? "Ingtoninator Enabled" : "Ingtoninator Disabled"}
|
||||
onClick={toggle}
|
||||
>
|
||||
{isEnabled ? (
|
||||
enabledIcon()
|
||||
) : (
|
||||
disabledIcon()
|
||||
)}
|
||||
</ChatBarButton>
|
||||
);
|
||||
};
|
||||
|
||||
function enabledIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path transform="translate(351 -153)" fill="currentcolor" d="M-177.7,334.5c6.3-2.3,12.6-5.2,19.8-8.6c31.9-16.4,51.7-41.7,51.7-41.7s-32.5,0.6-64.4,17 c-4,1.7-7.5,4-10.9,5.7c5.7-7.5,12.1-16.4,18.7-25c25-37.1,31.3-77.3,31.3-77.3s-34.8,21-59.2,58.6c-5.2,7.5-9.8,14.9-13.8,22.7 c1.1-10.3,1.1-22.1,1.1-33.6c0-50-19.8-91.1-19.8-91.1s-19.8,40.5-19.8,91.1c0,12.1,0.6,23.3,1.1,33.6c-4-7.5-8.6-14.9-13.8-22.7 c-25-37.1-59.2-58.6-59.2-58.6s6.3,40,31.3,77.3c6.3,9.2,12.1,17.5,18.7,25c-3.4-2.3-7.5-4-10.9-5.7c-31.9-16.4-64.4-17-64.4-17 s19.8,25.6,51.7,41.7c6.9,3.4,13.2,6.3,19.8,8.6c-4,0.6-8,1.1-12.1,2.3c-30.5,6.4-53.2,23.9-53.2,23.9s27.3,7.5,58.6,1.1 c9.8-2.3,19.8-4.6,27.3-7.5c-1.1,1.1,15.8-8.6,21.6-14.4v60.4h8.6v-61.8c6.3,6.3,22.7,16.4,22.1,14.9c8,2.9,17.5,5.2,27.3,7.5 c30.8,6.3,58.6-1.1,58.6-1.1s-22.1-17.5-53.4-23.8C-169.6,335.7-173.7,335.1-177.7,334.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function disabledIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path transform="translate(351 -153)" fill="var(--status-danger)" d="M-177.7,334.5c6.3-2.3,12.6-5.2,19.8-8.6c31.9-16.4,51.7-41.7,51.7-41.7s-32.5,0.6-64.4,17 c-4,1.7-7.5,4-10.9,5.7c5.7-7.5,12.1-16.4,18.7-25c25-37.1,31.3-77.3,31.3-77.3s-34.8,21-59.2,58.6c-5.2,7.5-9.8,14.9-13.8,22.7 c1.1-10.3,1.1-22.1,1.1-33.6c0-50-19.8-91.1-19.8-91.1s-19.8,40.5-19.8,91.1c0,12.1,0.6,23.3,1.1,33.6c-4-7.5-8.6-14.9-13.8-22.7 c-25-37.1-59.2-58.6-59.2-58.6s6.3,40,31.3,77.3c6.3,9.2,12.1,17.5,18.7,25c-3.4-2.3-7.5-4-10.9-5.7c-31.9-16.4-64.4-17-64.4-17 s19.8,25.6,51.7,41.7c6.9,3.4,13.2,6.3,19.8,8.6c-4,0.6-8,1.1-12.1,2.3c-30.5,6.4-53.2,23.9-53.2,23.9s27.3,7.5,58.6,1.1 c9.8-2.3,19.8-4.6,27.3-7.5c-1.1,1.1,15.8-8.6,21.6-14.4v60.4h8.6v-61.8c6.3,6.3,22.7,16.4,22.1,14.9c8,2.9,17.5,5.2,27.3,7.5 c30.8,6.3,58.6-1.1,58.6-1.1s-22.1-17.5-53.4-23.8C-169.6,335.7-173.7,335.1-177.7,334.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "Ingtoninator",
|
||||
description: "Suffixes 'ington' to a random word in your message",
|
||||
authors: [EquicordDevs.zyqunix, EquicordDevs.BioTomateDE],
|
||||
settings,
|
||||
chatBarButton: {
|
||||
icon: disabledIcon,
|
||||
render: IngtoninatorButton
|
||||
},
|
||||
start() {
|
||||
addMessagePreSendListener(handleMessage);
|
||||
},
|
||||
stop() {
|
||||
removeMessagePreSendListener(handleMessage);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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 { Heading, HeadingTertiary } from "@components/Heading";
|
||||
import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalRoot,
|
||||
openModal,
|
||||
} from "@utils/modal";
|
||||
import { Button, React, TextInput } from "@webpack/common";
|
||||
|
||||
import { buildEmbed, decrypt } from "../index";
|
||||
|
||||
export function DecModal(props: any) {
|
||||
const encryptedMessage: string = props?.message?.content;
|
||||
const [password, setPassword] = React.useState("password");
|
||||
|
||||
return (
|
||||
<ModalRoot {...props}>
|
||||
<ModalHeader>
|
||||
<HeadingTertiary>Decrypt Message</HeadingTertiary>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalContent>
|
||||
<Heading style={{ marginTop: "10px" }}>Message with Encryption</Heading>
|
||||
<TextInput defaultValue={encryptedMessage} disabled={true}></TextInput>
|
||||
<Heading style={{ marginTop: "10px" }}>Password</Heading>
|
||||
<TextInput
|
||||
style={{ marginBottom: "20px" }}
|
||||
onChange={setPassword}
|
||||
/>
|
||||
</ModalContent>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
color={Button.Colors.GREEN}
|
||||
onClick={() => {
|
||||
const toSend = decrypt(encryptedMessage, password, true);
|
||||
if (!toSend || !props?.message) return;
|
||||
buildEmbed(props?.message, toSend);
|
||||
props.onClose();
|
||||
}}>
|
||||
Decrypt
|
||||
</Button>
|
||||
<Button
|
||||
color={Button.Colors.TRANSPARENT}
|
||||
look={Button.Looks.LINK}
|
||||
style={{ left: 15, position: "absolute" }}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDecModal(msg: any): any {
|
||||
openModal((props: any) => <DecModal {...props} {...msg} />);
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* 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 { FormSwitch } from "@components/FormSwitch";
|
||||
import { Heading, HeadingTertiary } from "@components/Heading";
|
||||
import { insertTextIntoChatInputBox } from "@utils/discord";
|
||||
import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalProps,
|
||||
ModalRoot,
|
||||
openModal,
|
||||
} from "@utils/modal";
|
||||
import { Button, React, TextInput } from "@webpack/common";
|
||||
|
||||
import { encrypt } from "../index";
|
||||
|
||||
function EncModal(props: ModalProps) {
|
||||
const [secret, setSecret] = React.useState("");
|
||||
const [cover, setCover] = React.useState("");
|
||||
const [password, setPassword] = React.useState("password");
|
||||
const [noCover, setNoCover] = React.useState(false);
|
||||
|
||||
const isValid = secret && (noCover || (cover && cover.trim().split(" ").length > 1));
|
||||
|
||||
return (
|
||||
<ModalRoot {...props}>
|
||||
<ModalHeader>
|
||||
<HeadingTertiary>Encrypt Message</HeadingTertiary>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalContent>
|
||||
<Heading style={{ marginTop: "10px" }}>Secret</Heading>
|
||||
<TextInput
|
||||
onChange={(e: string) => {
|
||||
setSecret(e);
|
||||
}}
|
||||
/>
|
||||
<Heading style={{ marginTop: "10px" }}>Cover (2 or more Words!!)</Heading>
|
||||
<TextInput
|
||||
disabled={noCover}
|
||||
onChange={(e: string) => {
|
||||
setCover(e);
|
||||
}}
|
||||
/>
|
||||
<Heading style={{ marginTop: "10px" }}>Password</Heading>
|
||||
<TextInput
|
||||
style={{ marginBottom: "20px" }}
|
||||
defaultValue={"password"}
|
||||
onChange={(e: string) => {
|
||||
setPassword(e);
|
||||
}}
|
||||
/>
|
||||
<FormSwitch
|
||||
title="Don't use a Cover"
|
||||
value={noCover}
|
||||
onChange={(e: boolean) => {
|
||||
setNoCover(e);
|
||||
}}
|
||||
/>
|
||||
</ModalContent>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
color={Button.Colors.GREEN}
|
||||
disabled={!isValid}
|
||||
onClick={() => {
|
||||
if (!isValid) return;
|
||||
const encrypted = encrypt(secret, password, noCover ? "d d" : cover);
|
||||
const toSend = noCover ? encrypted.replaceAll("d", "") : encrypted;
|
||||
if (!toSend) return;
|
||||
|
||||
insertTextIntoChatInputBox(toSend);
|
||||
|
||||
props.onClose();
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
color={Button.Colors.TRANSPARENT}
|
||||
look={Button.Looks.LINK}
|
||||
style={{ left: 15, position: "absolute" }}
|
||||
onClick={() => {
|
||||
props.onClose();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export function buildEncModal(): any {
|
||||
openModal(props => <EncModal {...props} />);
|
||||
}
|
||||
234
src/equicordplugins/invisibleChat.desktop/index.tsx
Normal file
234
src/equicordplugins/invisibleChat.desktop/index.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/*
|
||||
* 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 { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
|
||||
import { updateMessage } from "@api/MessageUpdater";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { getStegCloak } from "@utils/dependencies";
|
||||
import definePlugin, { OptionType, ReporterTestable } from "@utils/types";
|
||||
import { Message } from "@vencord/discord-types";
|
||||
import { ChannelStore, Constants, RestAPI, Tooltip } from "@webpack/common";
|
||||
|
||||
import { buildDecModal } from "./components/DecryptionModal";
|
||||
import { buildEncModal } from "./components/EncryptionModal";
|
||||
|
||||
let steggo: any;
|
||||
|
||||
function PopOverIcon() {
|
||||
return (
|
||||
|
||||
<svg
|
||||
fill="var(--text-default)"
|
||||
width={24} height={24}
|
||||
viewBox={"0 0 64 64"}
|
||||
>
|
||||
<path d="M 32 9 C 24.832 9 19 14.832 19 22 L 19 27.347656 C 16.670659 28.171862 15 30.388126 15 33 L 15 49 C 15 52.314 17.686 55 21 55 L 43 55 C 46.314 55 49 52.314 49 49 L 49 33 C 49 30.388126 47.329341 28.171862 45 27.347656 L 45 22 C 45 14.832 39.168 9 32 9 z M 32 13 C 36.963 13 41 17.038 41 22 L 41 27 L 23 27 L 23 22 C 23 17.038 27.037 13 32 13 z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Indicator() {
|
||||
return (
|
||||
<Tooltip text="This message has a hidden message! (InvisibleChat)">
|
||||
{({ onMouseEnter, onMouseLeave }) => (
|
||||
<img
|
||||
aria-label="Hidden Message Indicator (InvisibleChat)"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
src="https://github.com/SammCheese/invisible-chat/raw/NewReplugged/src/assets/lock.png"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ transform: "translateY(4p)", paddingInline: 4 }}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
const ChatBarRender: ChatBarButtonFactory = ({ isMainChat }) => {
|
||||
if (!isMainChat) return null;
|
||||
|
||||
return (
|
||||
<ChatBarButton
|
||||
tooltip="Encrypt Message"
|
||||
onClick={() => buildEncModal()}
|
||||
buttonProps={{
|
||||
"aria-haspopup": "dialog",
|
||||
}}
|
||||
>
|
||||
{ChatBarIcon()}
|
||||
</ChatBarButton>
|
||||
);
|
||||
};
|
||||
|
||||
function ChatBarIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
role="img"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox={"0 0 64 64"}
|
||||
style={{ scale: "1.39", translate: "0 -1px" }}
|
||||
>
|
||||
<path fill="currentColor" d="M 32 9 C 24.832 9 19 14.832 19 22 L 19 27.347656 C 16.670659 28.171862 15 30.388126 15 33 L 15 49 C 15 52.314 17.686 55 21 55 L 43 55 C 46.314 55 49 52.314 49 49 L 49 33 C 49 30.388126 47.329341 28.171862 45 27.347656 L 45 22 C 45 14.832 39.168 9 32 9 z M 32 13 C 36.963 13 41 17.038 41 22 L 41 27 L 23 27 L 23 22 C 23 17.038 27.037 13 32 13 z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
savedPasswords: {
|
||||
type: OptionType.STRING,
|
||||
default: "password, Password",
|
||||
description: "Saved Passwords (Seperated with a , )"
|
||||
}
|
||||
});
|
||||
|
||||
const EMBED_API_URL = "https://embed.sammcheese.net";
|
||||
const INV_REGEX = new RegExp(/( \u200c|\u200d |[\u2060-\u2064])[^\u200b]/);
|
||||
const URL_REGEX = new RegExp(/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/);
|
||||
function colorCodeFromNumber(color: number): string {
|
||||
return `#${[color >> 16, color >> 8, color]
|
||||
.map(x => (x & 0xFF).toString(16))
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
async function getEmbed(url: URL): Promise<Object | {}> {
|
||||
const { body } = await RestAPI.post({
|
||||
url: Constants.Endpoints.UNFURL_EMBED_URLS,
|
||||
body: {
|
||||
urls: [url]
|
||||
}
|
||||
});
|
||||
// The endpoint returns the color as a number, but Discord expects a string
|
||||
body.embeds[0].color = colorCodeFromNumber(body.embeds[0].color);
|
||||
return await body.embeds[0];
|
||||
}
|
||||
|
||||
export async function buildEmbed(message: any, revealed: string): Promise<void> {
|
||||
const urlCheck = revealed.match(URL_REGEX);
|
||||
|
||||
message.embeds.push({
|
||||
type: "rich",
|
||||
rawTitle: "Decrypted Message",
|
||||
color: "#45f5f5",
|
||||
rawDescription: revealed,
|
||||
footer: {
|
||||
text: "Made with ❤️ by c0dine and Sammy!",
|
||||
},
|
||||
});
|
||||
|
||||
if (urlCheck?.length) {
|
||||
const embed = await getEmbed(new URL(urlCheck[0]));
|
||||
if (embed)
|
||||
message.embeds.push(embed);
|
||||
}
|
||||
|
||||
updateMessage(message.channel_id, message.id, { embeds: message.embeds });
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "InvisibleChat",
|
||||
description: "Encrypt your Messages in a non-suspicious way!",
|
||||
authors: [Devs.SammCheese],
|
||||
dependencies: ["MessageUpdaterAPI"],
|
||||
reporterTestable: ReporterTestable.Patches,
|
||||
settings,
|
||||
|
||||
chatBarButton: {
|
||||
icon: ChatBarIcon,
|
||||
render: ChatBarRender
|
||||
},
|
||||
|
||||
patches: [
|
||||
{
|
||||
// Indicator
|
||||
find: ".SEND_FAILED,",
|
||||
replacement: {
|
||||
match: /let\{className:\i,message:\i[^}]*\}=(\i)/,
|
||||
replace: "try {$1 && $self.INV_REGEX.test($1.message.content) ? $1.content.push($self.indicator()) : null } catch {};$&"
|
||||
}
|
||||
},
|
||||
],
|
||||
INV_REGEX,
|
||||
|
||||
async start() {
|
||||
const { default: StegCloak } = await getStegCloak();
|
||||
steggo = new StegCloak(true, false);
|
||||
},
|
||||
messagePopoverButton: {
|
||||
icon: PopOverIcon,
|
||||
render(message) {
|
||||
return INV_REGEX.test(message?.content)
|
||||
? {
|
||||
label: "Decrypt Message",
|
||||
icon: PopOverIcon,
|
||||
message: message,
|
||||
channel: ChannelStore.getChannel(message.channel_id),
|
||||
onClick: async () => {
|
||||
const res = await iteratePasswords(message);
|
||||
|
||||
if (res)
|
||||
buildEmbed(message, res);
|
||||
else
|
||||
buildDecModal({ message });
|
||||
}
|
||||
}
|
||||
: null;
|
||||
}
|
||||
},
|
||||
|
||||
indicator: ErrorBoundary.wrap(Indicator, { noop: true })
|
||||
});
|
||||
|
||||
export function encrypt(secret: string, password: string, cover: string): string {
|
||||
return steggo.hide(secret + "\u200b", password, cover);
|
||||
}
|
||||
|
||||
export function decrypt(encrypted: string, password: string, removeIndicator: boolean): string {
|
||||
const decrypted = steggo.reveal(encrypted, password);
|
||||
return removeIndicator ? decrypted.replace("\u200b", "") : decrypted;
|
||||
}
|
||||
|
||||
export function isCorrectPassword(result: string): boolean {
|
||||
return result.endsWith("\u200b");
|
||||
}
|
||||
|
||||
export async function iteratePasswords(message: Message): Promise<string | false> {
|
||||
const passwords = settings.store.savedPasswords.split(",").map(s => s.trim());
|
||||
|
||||
if (!message?.content || !passwords?.length) return false;
|
||||
|
||||
let { content } = message;
|
||||
|
||||
// we use an extra variable so we dont have to edit the message content directly
|
||||
if (/^\W/.test(message.content)) content = `d ${message.content}d`;
|
||||
|
||||
for (let i = 0; i < passwords.length; i++) {
|
||||
const result = decrypt(content, passwords[i], false);
|
||||
if (isCorrectPassword(result)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
90
src/equicordplugins/jumpscare/index.tsx
Normal file
90
src/equicordplugins/jumpscare/index.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { createAudioPlayer } from "@api/AudioPlayer";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { createRoot, FluxDispatcher, useCallback, useEffect, useState } from "@webpack/common";
|
||||
import { Root } from "react-dom/client";
|
||||
|
||||
let jumpscareRoot: Root | undefined;
|
||||
|
||||
const settings = definePluginSettings({
|
||||
imageSource: {
|
||||
type: OptionType.STRING,
|
||||
description: "Sets the image url of the jumpscare",
|
||||
default: "https://github.com/Equicord/Equibored/blob/main/icons/jumpscare/troll.gif?raw=true"
|
||||
},
|
||||
audioSource: {
|
||||
type: OptionType.STRING,
|
||||
description: "Sets the audio url of the jumpscare",
|
||||
default: "https://github.com/Equicord/Equibored/raw/main/sounds/jumpscare/trollolol.mp3?raw=true"
|
||||
},
|
||||
chance: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "The chance of a jumpscare happening (1 in X so: 100 = 1/100 or 1%, 50 = 1/50 or 2%, etc.)",
|
||||
default: 1000
|
||||
}
|
||||
});
|
||||
|
||||
function getJumpscareRoot(): Root {
|
||||
if (!jumpscareRoot) {
|
||||
const element = document.createElement("div");
|
||||
element.id = "jumpscare-root";
|
||||
element.classList.add("jumpscare-root");
|
||||
document.body.append(element);
|
||||
jumpscareRoot = createRoot(element);
|
||||
}
|
||||
|
||||
return jumpscareRoot;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "Jumpscare",
|
||||
description: "Adds a configurable chance of jumpscaring you whenever you open a channel. Inspired by Geometry Dash Mega Hack",
|
||||
authors: [Devs.surgedevs],
|
||||
dependencies: ["AudioPlayerAPI"],
|
||||
settings,
|
||||
|
||||
start() {
|
||||
getJumpscareRoot().render(
|
||||
<this.JumpscareComponent />
|
||||
);
|
||||
},
|
||||
|
||||
stop() {
|
||||
jumpscareRoot?.unmount();
|
||||
jumpscareRoot = undefined;
|
||||
},
|
||||
|
||||
JumpscareComponent() {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const jumpscareAudio = createAudioPlayer(settings.store.audioSource, { volume: 100, onEnded: () => { setIsPlaying(false); } });
|
||||
|
||||
const jumpscare = useCallback(event => {
|
||||
if (isPlaying) return;
|
||||
|
||||
const chance = 1 / settings.store.chance;
|
||||
if (Math.random() > chance) return;
|
||||
|
||||
setIsPlaying(true);
|
||||
jumpscareAudio.play();
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
FluxDispatcher.subscribe("CHANNEL_SELECT", jumpscare);
|
||||
|
||||
return () => {
|
||||
FluxDispatcher.unsubscribe("CHANNEL_SELECT", jumpscare);
|
||||
};
|
||||
}, [jumpscare]);
|
||||
|
||||
return <img className={`jumpscare-img ${isPlaying ? "jumpscare-animate" : ""}`} src={settings.store.imageSource} />;
|
||||
}
|
||||
});
|
||||
33
src/equicordplugins/jumpscare/styles.css
Normal file
33
src/equicordplugins/jumpscare/styles.css
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
.jumpscare-root {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jumpscare-img {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
z-index: 99999;
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.jumpscare-animate {
|
||||
animation: jumpscare-animation 0.7s;
|
||||
}
|
||||
|
||||
@keyframes jumpscare-animation {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
144
src/equicordplugins/lastActive/index.tsx
Normal file
144
src/equicordplugins/lastActive/index.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Menu, NavigationRouter, RestAPI, Toasts, UserStore } from "@webpack/common";
|
||||
|
||||
async function findLastMessageFromUser(guildId: string, channelId: string, userId: string) {
|
||||
try {
|
||||
const res = await RestAPI.get({
|
||||
url: `/guilds/${guildId}/messages/search?author_id=${userId}&channel_id=${channelId}&sort_by=timestamp&sort_order=desc&offset=0`
|
||||
});
|
||||
|
||||
const allMessages = res.body.messages?.flat() || [];
|
||||
const newestMessage = allMessages.find(msg => msg && msg.id);
|
||||
|
||||
if (newestMessage) return newestMessage.id;
|
||||
|
||||
Toasts.show({
|
||||
type: Toasts.Type.FAILURE,
|
||||
message: "Couldn't find any recent messages from this user.",
|
||||
id: Toasts.genId()
|
||||
});
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error finding last message:", error);
|
||||
Toasts.show({
|
||||
type: Toasts.Type.FAILURE,
|
||||
message: "Failed to find messages. Check console for details.",
|
||||
id: Toasts.genId()
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function jumpToLastActive(channel: any, targetUserId?: string) {
|
||||
try {
|
||||
if (!channel) {
|
||||
Toasts.show({
|
||||
type: Toasts.Type.FAILURE,
|
||||
message: "Channel information not available.",
|
||||
id: Toasts.genId()
|
||||
});
|
||||
return;
|
||||
}
|
||||
const guildId = channel.guild_id !== null ? channel.guild_id : "@me";
|
||||
const channelId = channel.id;
|
||||
let userId: string;
|
||||
if (targetUserId) {
|
||||
|
||||
userId = targetUserId;
|
||||
} else {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
userId = currentUser.id;
|
||||
}
|
||||
const messageId = await findLastMessageFromUser(guildId, channelId, userId);
|
||||
if (messageId) {
|
||||
const url = `/channels/${guildId}/${channelId}/${messageId}`;
|
||||
NavigationRouter.transitionTo(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in jumpToLastActive:", error);
|
||||
Toasts.show({
|
||||
type: Toasts.Type.FAILURE,
|
||||
message: "Failed to jump to message. Check console for details.",
|
||||
id: Toasts.genId()
|
||||
});
|
||||
}
|
||||
}
|
||||
const ChannelContextMenuPatch: NavContextMenuPatchCallback = (children, { channel }) => {
|
||||
children.push(
|
||||
<Menu.MenuItem
|
||||
id="LastActive"
|
||||
label={<span style={{ color: "#aa6746" }}>Your Last Message</span>}
|
||||
icon={LastActiveIcon}
|
||||
action={() => {
|
||||
jumpToLastActive(channel);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user, channel }) => {
|
||||
if (!channel || !user?.id) return;
|
||||
|
||||
children.push(
|
||||
<Menu.MenuItem
|
||||
id="LastActive"
|
||||
label={<span style={{ color: "#aa6746" }}>User's Last Message</span>}
|
||||
icon={UserLastActiveIcon}
|
||||
action={() => {
|
||||
jumpToLastActive(channel, user.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function UserLastActiveIcon() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 52 52"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="#aa6746"
|
||||
>
|
||||
<g>
|
||||
<path d="M11.4,21.6L24.9,7.9c0.6-0.6,1.6-0.6,2.2,0l13.5,13.7c0.6,0.6,0.6,1.6,0,2.2L38.4,26
|
||||
c-0.6,0.6-1.6,0.6-2.2,0l-9.1-9.4c-0.6-0.6-1.6-0.6-2.2,0l-9.1,9.3c-0.6,0.6-1.6,0.6-2.2,0l-2.2-2.2C10.9,23.1,10.9,22.2,11.4,21.6
|
||||
z"/>
|
||||
<path d="M11.4,39.7L24.9,26c0.6-0.6,1.6-0.6,2.2,0l13.5,13.7c0.6,0.6,0.6,1.6,0,2.2l-2.2,2.2
|
||||
c-0.6,0.6-1.6,0.6-2.2,0l-9.1-9.4c-0.6-0.6-1.6-0.6-2.2,0L15.8,44c-0.6,0.6-1.6,0.6-2.2,0l-2.2-2.2C10.9,41.2,10.9,40.2,11.4,39.7z
|
||||
"/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LastActiveIcon() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="#aa6746"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fillRule="evenodd" d="M12,2 C17.5228475,2 22,6.4771525 22,12 C22,17.5228475 17.5228475,22 12,22 C6.4771525,22 2,17.5228475 2,12 C2,6.4771525 6.4771525,2 12,2 Z M12,4 C7.581722,4 4,7.581722 4,12 C4,16.418278 7.581722,20 12,20 C16.418278,20 20,16.418278 20,12 C20,7.581722 16.418278,4 12,4 Z M12,6 C12.5128358,6 12.9355072,6.38604019 12.9932723,6.88337887 L13,7 L13,11.5857864 L14.7071068,13.2928932 C15.0976311,13.6834175 15.0976311,14.3165825 14.7071068,14.7071068 C14.3466228,15.0675907 13.7793918,15.0953203 13.3871006,14.7902954 L13.2928932,14.7071068 L11.2928932,12.7071068 C11.1366129,12.5508265 11.0374017,12.3481451 11.0086724,12.131444 L11,12 L11,7 C11,6.44771525 11.4477153,6 12,6 Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "LastActive",
|
||||
description: "A plugin to jump to last active message from yourself or another user in a channel/server.",
|
||||
authors: [EquicordDevs.Crxa],
|
||||
contextMenus: {
|
||||
"channel-context": ChannelContextMenuPatch,
|
||||
"user-context": UserContextMenuPatch,
|
||||
"thread-context": ChannelContextMenuPatch
|
||||
}
|
||||
});
|
||||
96
src/equicordplugins/messageBurst/index.ts
Normal file
96
src/equicordplugins/messageBurst/index.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Channel, Message } from "@vencord/discord-types";
|
||||
import { ChannelStore, MessageActions, MessageStore, UserStore } from "@webpack/common";
|
||||
|
||||
function shouldEdit(channel: Channel, message: Message, timePeriod: number, shouldMergeWithAttachment: boolean) {
|
||||
let should = true;
|
||||
|
||||
if (channel.isGroupDM()) {
|
||||
if (channel.name === message.content) {
|
||||
should = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.author.id !== UserStore.getCurrentUser().id) {
|
||||
should = false;
|
||||
}
|
||||
|
||||
if (document.querySelector('[class*="replyBar"]')) {
|
||||
should = false;
|
||||
}
|
||||
|
||||
if (message.attachments.length > 0 && !shouldMergeWithAttachment) {
|
||||
should = false;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const timestamp = new Date(message.timestamp);
|
||||
const now = new Date();
|
||||
|
||||
if ((now.getTime() - timestamp.getTime()) > (timePeriod * 1000)) {
|
||||
should = false;
|
||||
}
|
||||
|
||||
return {
|
||||
should: should,
|
||||
content: message.content
|
||||
};
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
timePeriod: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "The duration of bursts (in seconds).",
|
||||
default: 3
|
||||
},
|
||||
shouldMergeWithAttachment: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Should the message be merged if the last message has an attachment?",
|
||||
default: false
|
||||
},
|
||||
useSpace: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Whether to add a space between messages when merging instead of new lines.",
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "MessageBurst",
|
||||
description: "Merges messages sent within a time period with your previous sent message if no one else sends a message before you.",
|
||||
authors: [EquicordDevs.port22exposed],
|
||||
settings,
|
||||
onBeforeMessageSend(channelId, message) {
|
||||
const messages = MessageStore.getMessages(channelId)._map;
|
||||
|
||||
if (!messages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = Object.entries(messages);
|
||||
const [lastMessageId, lastMessage] = entries[entries.length - 1];
|
||||
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
|
||||
const { should, content } = shouldEdit(channel, lastMessage as Message, this.settings.store.timePeriod, this.settings.store.shouldMergeWithAttachment);
|
||||
|
||||
if (should) {
|
||||
const separator = settings.store.useSpace ? " " : "\n";
|
||||
const newContent = content + separator + message.content;
|
||||
|
||||
MessageActions.editMessage(channelId, lastMessageId, {
|
||||
content: newContent,
|
||||
});
|
||||
|
||||
message.content = "";
|
||||
}
|
||||
},
|
||||
});
|
||||
178
src/equicordplugins/messageFetchTimer/index.tsx
Normal file
178
src/equicordplugins/messageFetchTimer/index.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { getCurrentChannel } from "@utils/discord";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { FluxDispatcher, React } from "@webpack/common";
|
||||
|
||||
interface FetchTiming {
|
||||
channelId: string;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
let currentFetch: FetchTiming | null = null;
|
||||
let currentChannelId: string | null = null;
|
||||
const channelTimings: Map<string, { time: number; timestamp: Date; }> = new Map();
|
||||
|
||||
const settings = definePluginSettings({
|
||||
showIcon: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show fetch time icon in message bar",
|
||||
default: true,
|
||||
},
|
||||
showMs: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show milliseconds in timing",
|
||||
default: true,
|
||||
},
|
||||
iconColor: {
|
||||
type: OptionType.STRING,
|
||||
description: "Icon color (CSS color value)",
|
||||
default: "#00d166",
|
||||
}
|
||||
});
|
||||
|
||||
const FetchTimeButton: ChatBarButtonFactory = ({ isMainChat }) => {
|
||||
const { showMs, iconColor } = settings.use(["showMs", "iconColor"]);
|
||||
|
||||
if (!isMainChat || !settings.store.showIcon || !currentChannelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channelData = channelTimings.get(currentChannelId);
|
||||
if (!channelData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { time, timestamp } = channelData;
|
||||
const displayTime = showMs ? `${Math.round(time)}ms` : `${Math.round(time / 1000)}s`;
|
||||
|
||||
if (!showMs && Math.round(time / 1000) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeAgo = formatTimeAgo(timestamp);
|
||||
|
||||
return (
|
||||
<ChatBarButton
|
||||
tooltip={`Messages loaded in ${Math.round(time)}ms (${timeAgo})`}
|
||||
onClick={() => { }}
|
||||
>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px"
|
||||
}}>
|
||||
{FetchTimeIcon()}
|
||||
<span style={{
|
||||
fontSize: "12px",
|
||||
color: iconColor,
|
||||
fontWeight: "500"
|
||||
}}>
|
||||
{displayTime}
|
||||
</span>
|
||||
</div>
|
||||
</ChatBarButton>
|
||||
);
|
||||
};
|
||||
|
||||
function FetchTimeIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - timestamp.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days} day${days > 1 ? "s" : ""} ago`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours} hour${hours > 1 ? "s" : ""} ago`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
|
||||
} else {
|
||||
return "just now";
|
||||
}
|
||||
}
|
||||
|
||||
function handleChannelSelect(data: any) {
|
||||
if (data.channelId && data.channelId !== currentChannelId) {
|
||||
currentChannelId = data.channelId;
|
||||
currentFetch = {
|
||||
channelId: data.channelId,
|
||||
startTime: performance.now()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageLoad(data: any) {
|
||||
if (!currentFetch || data.channelId !== currentFetch.channelId) return;
|
||||
|
||||
const existing = channelTimings.get(currentFetch.channelId);
|
||||
if (existing) return;
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - currentFetch.startTime;
|
||||
|
||||
channelTimings.set(currentFetch.channelId, {
|
||||
time: duration,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
currentFetch = null;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "MessageFetchTimer",
|
||||
description: "Shows how long it took to fetch messages for the current channel",
|
||||
authors: [EquicordDevs.GroupXyz],
|
||||
settings,
|
||||
|
||||
chatBarButton: {
|
||||
icon: FetchTimeIcon,
|
||||
render: FetchTimeButton
|
||||
},
|
||||
|
||||
start() {
|
||||
FluxDispatcher.subscribe("CHANNEL_SELECT", handleChannelSelect);
|
||||
FluxDispatcher.subscribe("LOAD_MESSAGES_SUCCESS", handleMessageLoad);
|
||||
FluxDispatcher.subscribe("MESSAGE_CREATE", handleMessageLoad);
|
||||
|
||||
const currentChannel = getCurrentChannel();
|
||||
if (currentChannel) {
|
||||
currentChannelId = currentChannel.id;
|
||||
}
|
||||
},
|
||||
|
||||
stop() {
|
||||
FluxDispatcher.unsubscribe("CHANNEL_SELECT", handleChannelSelect);
|
||||
FluxDispatcher.unsubscribe("LOAD_MESSAGES_SUCCESS", handleMessageLoad);
|
||||
FluxDispatcher.unsubscribe("MESSAGE_CREATE", handleMessageLoad);
|
||||
|
||||
currentFetch = null;
|
||||
channelTimings.clear();
|
||||
currentChannelId = null;
|
||||
}
|
||||
});
|
||||
63
src/equicordplugins/messageNotifier/index.ts
Normal file
63
src/equicordplugins/messageNotifier/index.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Notifications } from "@api/index";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { getCurrentChannel } from "@utils/discord";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { MessageJSON } from "@vencord/discord-types";
|
||||
import { MessageType } from "@vencord/discord-types/enums";
|
||||
import { ChannelStore, GuildStore, NavigationRouter, RelationshipStore } from "@webpack/common";
|
||||
|
||||
interface MessageCreatePayload {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
message: MessageJSON;
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
users: {
|
||||
type: OptionType.STRING,
|
||||
description: "Comma separated list of user ids to get message toasts for",
|
||||
default: "",
|
||||
isValid(value: string) {
|
||||
if (value === "") return true;
|
||||
const userIds = value.split(",").map(id => id.trim());
|
||||
for (const id of userIds)
|
||||
if (!/\d+/.test(id)) return `${id} isn't a valid user id`;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
authors: [EquicordDevs.cassie, EquicordDevs.mochienya],
|
||||
name: "MessageNotifier",
|
||||
description: "Get toasts for when chosen users send a message",
|
||||
settings,
|
||||
flux: {
|
||||
MESSAGE_CREATE({ message, channelId, guildId }: MessageCreatePayload) {
|
||||
if (message.type !== MessageType.DEFAULT || getCurrentChannel()?.id === channelId) return;
|
||||
|
||||
const userIds = settings.store.users.split(",").map(id => id.trim());
|
||||
if (!userIds.includes(message.author.id)) return;
|
||||
|
||||
const username = RelationshipStore.getNickname(message.author.id) ?? message.author.globalName ?? message.author.username;
|
||||
const guild = GuildStore.getGuild(guildId);
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
const locationName = guild ? `${guild.name}#${channel.name}` : channel?.name ?? "their dms";
|
||||
|
||||
Notifications.showNotification({
|
||||
title: `${username} sent a message`,
|
||||
body: `Click to jump to ${locationName}`,
|
||||
onClick() {
|
||||
NavigationRouter.transitionTo(`/channels/${guild?.id ?? "@me"}/${channel.id}/${message.id}`);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
174
src/equicordplugins/moyai/index.ts
Normal file
174
src/equicordplugins/moyai/index.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/*
|
||||
* 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 { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { sleep } from "@utils/misc";
|
||||
import definePlugin, { makeRange, OptionType } from "@utils/types";
|
||||
import { Message, ReactionEmoji } from "@vencord/discord-types";
|
||||
import { RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common";
|
||||
|
||||
interface IMessageCreate {
|
||||
type: "MESSAGE_CREATE";
|
||||
optimistic: boolean;
|
||||
isPushNotification: boolean;
|
||||
channelId: string;
|
||||
message: Message;
|
||||
}
|
||||
|
||||
interface IReactionAdd {
|
||||
type: "MESSAGE_REACTION_ADD";
|
||||
optimistic: boolean;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
messageAuthorId: string;
|
||||
userId: "195136840355807232";
|
||||
emoji: ReactionEmoji;
|
||||
}
|
||||
|
||||
interface IVoiceChannelEffectSendEvent {
|
||||
type: string;
|
||||
emoji?: ReactionEmoji; // Just in case...
|
||||
channelId: string;
|
||||
userId: string;
|
||||
animationType: number;
|
||||
animationId: number;
|
||||
}
|
||||
|
||||
const MOYAI = "🗿";
|
||||
const MOYAI_URL = "https://github.com/Equicord/Equibored/raw/main/sounds/moyai/moyai.mp3";
|
||||
const MOYAI_URL_HD = "https://github.com/Equicord/Equibored/raw/main/sounds/moyai/moyai.wav";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
volume: {
|
||||
description: "Volume of the 🗿🗿🗿",
|
||||
type: OptionType.SLIDER,
|
||||
markers: makeRange(0, 1, 0.1),
|
||||
default: 0.5,
|
||||
stickToMarkers: false
|
||||
},
|
||||
quality: {
|
||||
description: "Quality of the 🗿🗿🗿",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Normal", value: "Normal", default: true },
|
||||
{ label: "HD", value: "HD" }
|
||||
],
|
||||
},
|
||||
triggerWhenUnfocused: {
|
||||
description: "Trigger the 🗿 even when the window is unfocused",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true
|
||||
},
|
||||
ignoreBots: {
|
||||
description: "Ignore bots",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true
|
||||
},
|
||||
ignoreBlocked: {
|
||||
description: "Ignore blocked users",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "Moyai",
|
||||
authors: [Devs.Megu, Devs.Nuckyz],
|
||||
description: "Plays a 🗿 sound effect whenever a moyai emoji is sent, reacted, or used as a voice effect in your current channel.",
|
||||
settings,
|
||||
|
||||
flux: {
|
||||
async MESSAGE_CREATE({ optimistic, type, message, channelId }: IMessageCreate) {
|
||||
if (optimistic || type !== "MESSAGE_CREATE") return;
|
||||
if (message.state === "SENDING") return;
|
||||
if (settings.store.ignoreBots && message.author?.bot) return;
|
||||
if (settings.store.ignoreBlocked && RelationshipStore.isBlocked(message.author?.id)) return;
|
||||
if (!message.content) return;
|
||||
if (channelId !== SelectedChannelStore.getChannelId()) return;
|
||||
|
||||
const moyaiCount = getMoyaiCount(message.content);
|
||||
|
||||
for (let i = 0; i < moyaiCount; i++) {
|
||||
boom();
|
||||
await sleep(300);
|
||||
}
|
||||
},
|
||||
|
||||
MESSAGE_REACTION_ADD({ optimistic, type, channelId, userId, messageAuthorId, emoji }: IReactionAdd) {
|
||||
if (optimistic || type !== "MESSAGE_REACTION_ADD") return;
|
||||
if (settings.store.ignoreBots && UserStore.getUser(userId)?.bot) return;
|
||||
if (settings.store.ignoreBlocked && RelationshipStore.isBlocked(messageAuthorId)) return;
|
||||
if (channelId !== SelectedChannelStore.getChannelId()) return;
|
||||
|
||||
const name = emoji.name.toLowerCase();
|
||||
if (name !== MOYAI && !name.includes("moyai") && !name.includes("moai")) return;
|
||||
|
||||
boom();
|
||||
},
|
||||
|
||||
VOICE_CHANNEL_EFFECT_SEND({ emoji }: IVoiceChannelEffectSendEvent) {
|
||||
if (!emoji?.name) return;
|
||||
const name = emoji.name.toLowerCase();
|
||||
if (name !== MOYAI && !name.includes("moyai") && !name.includes("moai")) return;
|
||||
|
||||
boom();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function countOccurrences(sourceString: string, subString: string) {
|
||||
let i = 0;
|
||||
let lastIdx = 0;
|
||||
while ((lastIdx = sourceString.indexOf(subString, lastIdx) + 1) !== 0)
|
||||
i++;
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
function countMatches(sourceString: string, pattern: RegExp) {
|
||||
if (!pattern.global)
|
||||
throw new Error("pattern must be global");
|
||||
|
||||
let i = 0;
|
||||
while (pattern.test(sourceString))
|
||||
i++;
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
const customMoyaiRe = /<a?:\w*moy?ai\w*:\d{17,20}>/gi;
|
||||
|
||||
function getMoyaiCount(message: string) {
|
||||
const count = countOccurrences(message, MOYAI)
|
||||
+ countMatches(message, customMoyaiRe);
|
||||
|
||||
return Math.min(count, 10);
|
||||
}
|
||||
|
||||
function boom() {
|
||||
if (!settings.store.triggerWhenUnfocused && !document.hasFocus()) return;
|
||||
const audioElement = document.createElement("audio");
|
||||
|
||||
audioElement.src = settings.store.quality === "HD"
|
||||
? MOYAI_URL_HD
|
||||
: MOYAI_URL;
|
||||
|
||||
audioElement.volume = settings.store.volume;
|
||||
audioElement.play();
|
||||
}
|
||||
23
src/equicordplugins/noRoleHeaders/index.ts
Normal file
23
src/equicordplugins/noRoleHeaders/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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: "NoRoleHeaders",
|
||||
description: "We are all equal!! Removes the role headers in the member list.",
|
||||
authors: [Devs.Samwich],
|
||||
patches: [
|
||||
{
|
||||
find: "._areActivitiesExperimentallyHidden=(",
|
||||
replacement: {
|
||||
match: /return \i===\i\.\i\.UNKNOWN/,
|
||||
replace: "return null;$&"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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 { getIntlMessage } from "@utils/discord";
|
||||
import definePlugin from "@utils/types";
|
||||
import { findByCodeLazy, findByPropsLazy } from "@webpack";
|
||||
import { ChannelStore, GuildStore, RelationshipStore, UserStore } from "@webpack/common";
|
||||
|
||||
const { getName } = findByPropsLazy("getName", "useName", "getNickname");
|
||||
const computeChannelName = findByCodeLazy(".isThread())return`\"");
|
||||
|
||||
const ChannelTypes = findByPropsLazy("DM", "GUILD_TEXT", "PUBLIC_THREAD", "UNKNOWN");
|
||||
const ChannelTypesSets = findByPropsLazy("THREADS", "GUILD_TEXTUAL", "ALL_DMS");
|
||||
const MessageTypes = findByPropsLazy("REPLY", "STAGE_RAISE_HAND", "CHANNEL_NAME_CHANGE");
|
||||
|
||||
export default definePlugin({
|
||||
name: "NotificationTitle",
|
||||
description: "Makes desktop notifications more informative",
|
||||
authors: [Devs.Kyuuhachi],
|
||||
|
||||
patches: [
|
||||
{
|
||||
find: '"SystemMessageUtils.stringify(...) could not convert"',
|
||||
replacement: {
|
||||
match: /{icon:.{0,50}emoji:\i}/,
|
||||
replace: "($self.makeTitle($&,...arguments))",
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
makeTitle(result, channel, message, user) {
|
||||
const username = getName(channel.guild_id, channel.id, user);
|
||||
|
||||
let title = username;
|
||||
if (message.type === MessageTypes.REPLY && message.referenced_message?.author) {
|
||||
const replyUser = UserStore.getUser(message.referenced_message.author.id);
|
||||
const replyUsername = getName(channel.guild_id, channel.id, replyUser);
|
||||
title = getIntlMessage("CHANNEL_MESSAGE_REPLY_A11Y_LABEL", {
|
||||
author: username,
|
||||
repliedAuthor: replyUsername,
|
||||
});
|
||||
}
|
||||
|
||||
const guild = GuildStore.getGuild(channel.guild_id);
|
||||
const parent = ChannelStore.getChannel(channel.parent_id);
|
||||
|
||||
if (channel.type !== ChannelTypes.DM) {
|
||||
let where = ChannelTypesSets.THREADS.has(channel.type)
|
||||
? `${channelName(channel)} in ${channelName(parent, true)}`
|
||||
: `${channelName(channel, true)}`;
|
||||
if (guild != null)
|
||||
where += `, ${guild.name}`;
|
||||
title += `\n(${where})`;
|
||||
}
|
||||
result.title = title;
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
function channelName(channel, withPrefix = false) {
|
||||
return computeChannelName(channel, UserStore, RelationshipStore, withPrefix);
|
||||
}
|
||||
109
src/equicordplugins/partyMode/index.ts
Normal file
109
src/equicordplugins/partyMode/index.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* 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 { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType, ReporterTestable } from "@utils/types";
|
||||
import { FluxDispatcher } from "@webpack/common";
|
||||
|
||||
const enum Intensity {
|
||||
Normal,
|
||||
Better,
|
||||
ProjectX,
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
superIntensePartyMode: {
|
||||
description: "Party intensity",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Normal", value: Intensity.Normal, default: true },
|
||||
{ label: "Better", value: Intensity.Better },
|
||||
{ label: "Project X", value: Intensity.ProjectX },
|
||||
],
|
||||
restartNeeded: false,
|
||||
onChange: setSettings
|
||||
},
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "PartyMode",
|
||||
description: "Allows you to use party mode cause the party never ends ✨",
|
||||
authors: [Devs.UwUDev],
|
||||
reporterTestable: ReporterTestable.None,
|
||||
settings,
|
||||
|
||||
start() {
|
||||
setPoggerState(true);
|
||||
setSettings(settings.store.superIntensePartyMode);
|
||||
},
|
||||
|
||||
stop() {
|
||||
setPoggerState(false);
|
||||
},
|
||||
});
|
||||
|
||||
function setPoggerState(state: boolean) {
|
||||
FluxDispatcher.dispatch({
|
||||
type: "POGGERMODE_SETTINGS_UPDATE",
|
||||
settings: {
|
||||
enabled: state,
|
||||
settingsVisible: state
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setSettings(intensity: Intensity) {
|
||||
const state = {
|
||||
screenshakeEnabledLocations: { 0: true, 1: true, 2: true },
|
||||
shakeIntensity: 1,
|
||||
confettiSize: 16,
|
||||
confettiCount: 5,
|
||||
combosRequiredCount: 1
|
||||
};
|
||||
|
||||
switch (intensity) {
|
||||
case Intensity.Normal: {
|
||||
Object.assign(state, {
|
||||
screenshakeEnabledLocations: { 0: true, 1: false, 2: false },
|
||||
combosRequiredCount: 5
|
||||
});
|
||||
break;
|
||||
}
|
||||
case Intensity.Better: {
|
||||
Object.assign(state, {
|
||||
confettiSize: 12,
|
||||
confettiCount: 8,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case Intensity.ProjectX: {
|
||||
Object.assign(state, {
|
||||
shakeIntensity: 20,
|
||||
confettiSize: 25,
|
||||
confettiCount: 15,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
FluxDispatcher.dispatch({
|
||||
type: "POGGERMODE_SETTINGS_UPDATE",
|
||||
settings: state
|
||||
});
|
||||
}
|
||||
145
src/equicordplugins/pingNotifications/index.tsx
Normal file
145
src/equicordplugins/pingNotifications/index.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { showNotification } from "@api/Notifications";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { findStoreLazy } from "@webpack";
|
||||
import {
|
||||
ChannelStore,
|
||||
GuildStore,
|
||||
NavigationRouter,
|
||||
PresenceStore,
|
||||
RelationshipStore,
|
||||
SelectedChannelStore,
|
||||
UserStore
|
||||
} from "@webpack/common";
|
||||
|
||||
const UserGuildSettingsStore = findStoreLazy("UserGuildSettingsStore");
|
||||
|
||||
const settings = definePluginSettings({
|
||||
friends: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
description: "Notify when friends send messages in servers"
|
||||
},
|
||||
mentions: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Notify when someone @mentions you directly"
|
||||
},
|
||||
dms: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Notify for direct messages (DMs)"
|
||||
},
|
||||
showInActive: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
description: "Show notifications even for currently active channel"
|
||||
},
|
||||
ignoreMuted: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Skip notifications from muted servers, channels, or users"
|
||||
}
|
||||
});
|
||||
|
||||
function formatContent(message) {
|
||||
let content = message.content || "";
|
||||
message.mentions?.forEach(user => {
|
||||
content = content.replace(new RegExp(`<@!?${user.id}>`, "g"), `@${user.username}`);
|
||||
});
|
||||
return content.slice(0, 200) + (content.length > 200 ? "..." : "");
|
||||
}
|
||||
|
||||
function checkIfMuted(channel) {
|
||||
if (!settings.store.ignoreMuted) return false;
|
||||
if (!channel) return false;
|
||||
|
||||
if (channel.isMuted?.()) return true;
|
||||
|
||||
const isDM = [1, 3].includes(channel.type);
|
||||
if (isDM) {
|
||||
const recipientIds = channel.recipients || [];
|
||||
for (const userId of recipientIds) {
|
||||
if (RelationshipStore.isBlocked(userId)) return true;
|
||||
}
|
||||
|
||||
if (UserGuildSettingsStore?.getMutedChannels?.()?.includes?.(channel.id)) return true;
|
||||
}
|
||||
|
||||
if (channel.guild_id) {
|
||||
const guild = GuildStore.getGuild(channel.guild_id);
|
||||
if (UserGuildSettingsStore.isMuted(channel.guild_id)) return true;
|
||||
|
||||
if (UserGuildSettingsStore?.isMuted?.(channel.guild_id)) return true;
|
||||
if (UserGuildSettingsStore?.isChannelMuted?.(channel.guild_id, channel.id)) return true;
|
||||
if (UserGuildSettingsStore?.isCategoryMuted?.(channel.guild_id, channel.id)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isUserBlocked(userId) {
|
||||
return settings.store.ignoreMuted && RelationshipStore.isBlocked?.(userId);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "PingNotifications",
|
||||
description: "Customizable notifications with improved mention formatting",
|
||||
authors: [EquicordDevs.smuki],
|
||||
settings,
|
||||
|
||||
flux: {
|
||||
MESSAGE_CREATE({ message }) {
|
||||
try {
|
||||
if (!message?.channel_id || message.state === "SENDING") return;
|
||||
|
||||
const channel = ChannelStore.getChannel(message.channel_id);
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
|
||||
if (!channel || !currentUser) return;
|
||||
if (message.author?.id === currentUser.id) return;
|
||||
|
||||
const isDM = [1, 3].includes(channel.type);
|
||||
|
||||
if (checkIfMuted(channel)) return;
|
||||
if (isUserBlocked(message.author.id)) return;
|
||||
if (!settings.store.showInActive && channel.id === SelectedChannelStore.getChannelId()) return;
|
||||
if (PresenceStore.getStatus(currentUser.id) === "dnd") return;
|
||||
|
||||
const author = UserStore.getUser(message.author.id) || { username: "Unknown" };
|
||||
const channelName = channel.name || (isDM ? "DM" : "Group");
|
||||
const body = formatContent(message);
|
||||
|
||||
let shouldNotify = false;
|
||||
|
||||
if (settings.store.mentions && message.mentions?.some(u => u.id === currentUser.id)) {
|
||||
shouldNotify = true;
|
||||
} else if (settings.store.friends && RelationshipStore.isFriend(message.author.id) && !isDM) {
|
||||
shouldNotify = true;
|
||||
} else if (isDM && settings.store.dms) {
|
||||
shouldNotify = true;
|
||||
}
|
||||
|
||||
if (shouldNotify) {
|
||||
showNotification({
|
||||
title: `${author.username} in ${channelName}`,
|
||||
body,
|
||||
icon: author.getAvatarURL?.(undefined, 128),
|
||||
onClick: () => NavigationRouter.transitionTo(
|
||||
`/channels/${channel.guild_id || "@me"}/${channel.id}/${message.id}`
|
||||
)
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[PingNotifications] Error:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
81
src/equicordplugins/profileSets/components/confirmModal.tsx
Normal file
81
src/equicordplugins/profileSets/components/confirmModal.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Button } from "@components/Button";
|
||||
import { Heading } from "@components/Heading";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
|
||||
interface ConfirmModalProps extends ModalProps {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface ImportProfilesModalProps extends ModalProps {
|
||||
title: string;
|
||||
message: string;
|
||||
onOverride: () => void;
|
||||
onMerge: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmModal({ title, message, confirmText, cancelText, onConfirm, onCancel, onClose, transitionState }: ConfirmModalProps) {
|
||||
const closeAfter = (action: () => void) => () => {
|
||||
action();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot transitionState={transitionState}>
|
||||
<ModalHeader>
|
||||
<Heading tag="h2">{title}</Heading>
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<Paragraph>{message}</Paragraph>
|
||||
</ModalContent>
|
||||
<ModalFooter>
|
||||
<Button onClick={closeAfter(onConfirm)}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={closeAfter(onCancel)}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportProfilesModal({ title, message, onOverride, onMerge, onCancel, onClose, transitionState }: ImportProfilesModalProps) {
|
||||
const closeAfter = (action: () => void) => () => {
|
||||
action();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot transitionState={transitionState}>
|
||||
<ModalHeader>
|
||||
<Heading tag="h2">{title}</Heading>
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<Paragraph>{message}</Paragraph>
|
||||
</ModalContent>
|
||||
<ModalFooter>
|
||||
<Button onClick={closeAfter(onOverride)}>
|
||||
Override
|
||||
</Button>
|
||||
<Button onClick={closeAfter(onMerge)}>
|
||||
Merge
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={closeAfter(onCancel)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
);
|
||||
}
|
||||
220
src/equicordplugins/profileSets/components/presetList.tsx
Normal file
220
src/equicordplugins/profileSets/components/presetList.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { isNonNullish } from "@utils/guards";
|
||||
import { classes } from "@utils/misc";
|
||||
import { ProfilePreset } from "@vencord/discord-types";
|
||||
import { ContextMenuApi, Menu, React, TextInput } from "@webpack/common";
|
||||
|
||||
import { cl } from "..";
|
||||
import { deletePreset, movePreset, renamePreset, updatePresetField } from "../utils/actions";
|
||||
import { getCurrentProfile } from "../utils/profile";
|
||||
import { PresetSection, type ProfilePresetEx } from "../utils/storage";
|
||||
|
||||
interface PresetListProps {
|
||||
presets: ProfilePresetEx[];
|
||||
allPresets: ProfilePresetEx[];
|
||||
avatarSize: number;
|
||||
selectedPreset: number;
|
||||
onLoad: (index: number) => void;
|
||||
onUpdate: () => void;
|
||||
guildId?: string;
|
||||
section: PresetSection;
|
||||
currentPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function PresetList({
|
||||
presets,
|
||||
allPresets,
|
||||
avatarSize,
|
||||
selectedPreset,
|
||||
onLoad,
|
||||
onUpdate,
|
||||
guildId,
|
||||
section,
|
||||
currentPage,
|
||||
onPageChange
|
||||
}: PresetListProps) {
|
||||
type EditableProfile = Omit<ProfilePreset, "name" | "timestamp">;
|
||||
const [renaming, setRenaming] = React.useState<number>(-1);
|
||||
const [renameText, setRenameText] = React.useState("");
|
||||
const isGuildProfile = section === "server";
|
||||
|
||||
return (
|
||||
<div className={cl("list-container")}>
|
||||
{presets.map(preset => {
|
||||
const actualIndex = allPresets.indexOf(preset);
|
||||
const isRenaming = renaming === actualIndex;
|
||||
const isSelected = !isRenaming && selectedPreset === actualIndex;
|
||||
const date = new Date(preset.timestamp);
|
||||
const formattedDate = date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric"
|
||||
});
|
||||
const formattedTime = date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
|
||||
const commitRename = () => {
|
||||
const nextName = renameText.trim();
|
||||
if (!nextName) return;
|
||||
renamePreset(actualIndex, nextName, section, guildId);
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={actualIndex}
|
||||
tabIndex={isRenaming ? -1 : 0}
|
||||
role="button"
|
||||
onClick={() => {
|
||||
if (!isRenaming) {
|
||||
onLoad(actualIndex);
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (!isRenaming && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
onLoad(actualIndex);
|
||||
}
|
||||
}}
|
||||
className={classes(cl("row"), isSelected ? "selected" : "")}
|
||||
>
|
||||
<div className={cl("avatar-url")}>
|
||||
{preset.avatarDataUrl && (
|
||||
<img
|
||||
src={preset.avatarDataUrl}
|
||||
alt=""
|
||||
className={cl("avatar")}
|
||||
style={{ width: `${avatarSize}px`, height: `${avatarSize}px` }}
|
||||
/>
|
||||
)}
|
||||
<div className={cl("rename")}>
|
||||
{isRenaming ? (
|
||||
<TextInput
|
||||
value={renameText}
|
||||
onChange={setRenameText}
|
||||
onBlur={() => {
|
||||
commitRename();
|
||||
setRenaming(-1);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") {
|
||||
commitRename();
|
||||
setRenaming(-1);
|
||||
} else if (e.key === "Escape") {
|
||||
setRenaming(-1);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={cl("name")}>
|
||||
{preset.name}
|
||||
</div>
|
||||
<div className={cl("timestamp")}>
|
||||
{formattedDate} at {formattedTime}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cl("updated")}>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
className={cl("menu-icon")}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
const target = e.currentTarget;
|
||||
ContextMenuApi.openContextMenu(e, () => (
|
||||
<Menu.Menu navId="preset-options" onClose={ContextMenuApi.closeContextMenu}>
|
||||
<Menu.MenuItem
|
||||
id="rename"
|
||||
label="Rename"
|
||||
action={() => {
|
||||
setRenaming(actualIndex);
|
||||
setRenameText(preset.name);
|
||||
}}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
id="update"
|
||||
label="Update"
|
||||
action={async () => {
|
||||
const profile = await getCurrentProfile(guildId, { isGuildProfile });
|
||||
await Promise.all(
|
||||
(Object.entries(profile) as [keyof EditableProfile, EditableProfile[keyof EditableProfile]][])
|
||||
.filter(([, value]) => isNonNullish(value))
|
||||
.map(([key, value]) => updatePresetField(actualIndex, key, value, section, guildId))
|
||||
);
|
||||
onUpdate();
|
||||
}}
|
||||
/>
|
||||
<Menu.MenuSeparator />
|
||||
{actualIndex > 0 && (
|
||||
<Menu.MenuItem
|
||||
id="move-up"
|
||||
label="Move Up"
|
||||
action={() => {
|
||||
movePreset(actualIndex, actualIndex - 1, section, guildId);
|
||||
onUpdate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{actualIndex < allPresets.length - 1 && (
|
||||
<Menu.MenuItem
|
||||
id="move-down"
|
||||
label="Move Down"
|
||||
action={() => {
|
||||
movePreset(actualIndex, actualIndex + 1, section, guildId);
|
||||
onUpdate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{currentPage > 1 && (
|
||||
<Menu.MenuItem
|
||||
id="move-to-page-1"
|
||||
label="Move to Page 1"
|
||||
action={() => {
|
||||
movePreset(actualIndex, 0, section, guildId);
|
||||
onPageChange(1);
|
||||
onUpdate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Menu.MenuSeparator />
|
||||
<Menu.MenuItem
|
||||
id="delete"
|
||||
label="Delete"
|
||||
color="danger"
|
||||
action={async () => {
|
||||
await deletePreset(actualIndex, section, guildId);
|
||||
onUpdate();
|
||||
}}
|
||||
/>
|
||||
</Menu.Menu>
|
||||
));
|
||||
}}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M10 3a1.5 1.5 0 110 3 1.5 1.5 0 010-3zm0 5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zm0 5a1.5 1.5 0 110 3 1.5 1.5 0 010-3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
290
src/equicordplugins/profileSets/components/presetManager.tsx
Normal file
290
src/equicordplugins/profileSets/components/presetManager.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Button } from "@components/Button";
|
||||
import { Heading } from "@components/Heading";
|
||||
import { classes } from "@utils/misc";
|
||||
import { openModal } from "@utils/modal";
|
||||
import { React, SelectedGuildStore, TextInput, useStateFromStores } from "@webpack/common";
|
||||
|
||||
import { cl, settings } from "../index";
|
||||
import { exportPresets, ImportDecision, importPresets, savePreset } from "../utils/actions";
|
||||
import { loadPresetAsPending } from "../utils/profile";
|
||||
import { loadPresets, presets, PresetSection, setCurrentPresetIndex } from "../utils/storage";
|
||||
import { ImportProfilesModal } from "./confirmModal";
|
||||
import { PresetList } from "./presetList";
|
||||
|
||||
const PRESETS_PER_PAGE = 5;
|
||||
|
||||
type PresetManagerProps = {
|
||||
section?: PresetSection;
|
||||
guildId?: string;
|
||||
};
|
||||
|
||||
export function PresetManager({ section, guildId }: PresetManagerProps) {
|
||||
const [presetName, setPresetName] = React.useState("");
|
||||
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [currentPage, setCurrentPage] = React.useState(1);
|
||||
const [pageInput, setPageInput] = React.useState("1");
|
||||
const [selectedPreset, setSelectedPreset] = React.useState<number>(-1);
|
||||
const [searchMode, setSearchMode] = React.useState(false);
|
||||
const lastRandomIndexRef = React.useRef<number>(-1);
|
||||
const resolvedSection: PresetSection = section ?? "main";
|
||||
const isServerSection = resolvedSection === "server";
|
||||
const lastSelectedGuildId = useStateFromStores(
|
||||
[SelectedGuildStore],
|
||||
() => SelectedGuildStore.getLastSelectedGuildId() ?? SelectedGuildStore.getGuildId()
|
||||
);
|
||||
const resolvedGuildId = isServerSection ? (guildId ?? lastSelectedGuildId ?? undefined) : undefined;
|
||||
const canUseGuild = !isServerSection || Boolean(resolvedGuildId);
|
||||
|
||||
React.useEffect(() => {
|
||||
let isActive = true;
|
||||
(async () => {
|
||||
await loadPresets(resolvedSection);
|
||||
if (!isActive) return;
|
||||
setSelectedPreset(-1);
|
||||
setCurrentPage(1);
|
||||
setPageInput("1");
|
||||
forceUpdate();
|
||||
})();
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [resolvedGuildId, resolvedSection]);
|
||||
|
||||
const filteredPresets = !searchMode
|
||||
? presets
|
||||
: presets.filter(preset => preset.name.toLowerCase().includes(presetName.toLowerCase()));
|
||||
|
||||
const totalPages = Math.ceil(filteredPresets.length / PRESETS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * PRESETS_PER_PAGE;
|
||||
const currentPresets = filteredPresets.slice(startIndex, startIndex + PRESETS_PER_PAGE);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
setCurrentPage(newPage);
|
||||
setPageInput(String(newPage));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePreset = async () => {
|
||||
if (!canUseGuild) return;
|
||||
const trimmedName = presetName.trim();
|
||||
if (!trimmedName) return;
|
||||
setIsSaving(true);
|
||||
await savePreset(trimmedName, resolvedSection, resolvedGuildId);
|
||||
setPresetName("");
|
||||
setIsSaving(false);
|
||||
const newTotalPages = Math.ceil(presets.length / PRESETS_PER_PAGE);
|
||||
handlePageChange(newTotalPages);
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
const applyPreset = (index: number) => {
|
||||
setSelectedPreset(index);
|
||||
setCurrentPresetIndex(index);
|
||||
loadPresetAsPending(presets[index], resolvedGuildId, {
|
||||
isGuildProfile: resolvedSection === "server"
|
||||
});
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
const handleLoadPreset = (index: number) => {
|
||||
if (!canUseGuild) return;
|
||||
applyPreset(index);
|
||||
};
|
||||
|
||||
const selectRandomPreset = (sectionType: PresetSection) => {
|
||||
const availablePresets = presets;
|
||||
if (!availablePresets.length) return null;
|
||||
const randomIndex = Math.floor(Math.random() * availablePresets.length);
|
||||
return { preset: availablePresets[randomIndex], index: randomIndex, section: sectionType };
|
||||
};
|
||||
|
||||
const handleRandomPreset = () => {
|
||||
if (!canUseGuild) return;
|
||||
const selection = selectRandomPreset(resolvedSection);
|
||||
if (!selection) return;
|
||||
let nextIndex = selection.index;
|
||||
if (presets.length > 1 && nextIndex === lastRandomIndexRef.current) {
|
||||
let attempts = 0;
|
||||
while (attempts < 5 && nextIndex === lastRandomIndexRef.current) {
|
||||
nextIndex = Math.floor(Math.random() * presets.length);
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
lastRandomIndexRef.current = nextIndex;
|
||||
applyPreset(nextIndex);
|
||||
};
|
||||
|
||||
const showImportPrompt = (existingCount: number): Promise<ImportDecision> => {
|
||||
return new Promise(resolve => {
|
||||
openModal(props => (
|
||||
<ImportProfilesModal
|
||||
{...props}
|
||||
title="Import Profiles"
|
||||
message={`You have ${existingCount} existing profiles in this section. Do you want to override them or merge with imported profiles?`}
|
||||
onOverride={() => resolve("override")}
|
||||
onMerge={() => resolve("merge")}
|
||||
onCancel={() => resolve("cancel")}
|
||||
/>
|
||||
));
|
||||
});
|
||||
};
|
||||
|
||||
const { avatarSize } = settings.store;
|
||||
const hasPresets = presets.length > 0;
|
||||
const shouldShowPagination = filteredPresets.length > PRESETS_PER_PAGE;
|
||||
|
||||
return (
|
||||
<div className={classes(cl("section"), isServerSection ? cl("section-server") : "")} >
|
||||
<Heading tag="h3" className={cl("heading")}>
|
||||
Saved Profiles
|
||||
</Heading>
|
||||
|
||||
<div className={cl("text")}>
|
||||
<TextInput
|
||||
placeholder={searchMode ? "Search profiles..." : "Profile Name"}
|
||||
value={presetName}
|
||||
onChange={setPresetName}
|
||||
className={cl("text-input")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cl("search")}>
|
||||
{!searchMode && (
|
||||
<Button
|
||||
size="small"
|
||||
disabled={isSaving || !presetName.trim() || !canUseGuild}
|
||||
onClick={handleSavePreset}
|
||||
className={cl("search-button")}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Profile"}
|
||||
</Button>
|
||||
)}
|
||||
{hasPresets && (
|
||||
<Button
|
||||
size="small"
|
||||
variant={searchMode ? "primary" : "secondary"}
|
||||
onClick={() => {
|
||||
setSearchMode(!searchMode);
|
||||
handlePageChange(1);
|
||||
}}
|
||||
>
|
||||
{searchMode ? "Cancel Search" : "Search"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasPresets && (
|
||||
<>
|
||||
<PresetList
|
||||
presets={currentPresets}
|
||||
allPresets={presets}
|
||||
avatarSize={avatarSize}
|
||||
selectedPreset={selectedPreset}
|
||||
onLoad={handleLoadPreset}
|
||||
onUpdate={() => {
|
||||
const newTotal = Math.ceil(presets.length / PRESETS_PER_PAGE);
|
||||
if (newTotal === 0) {
|
||||
handlePageChange(1);
|
||||
} else if (currentPage > newTotal) {
|
||||
handlePageChange(newTotal);
|
||||
}
|
||||
forceUpdate();
|
||||
}}
|
||||
guildId={resolvedGuildId}
|
||||
section={resolvedSection}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
|
||||
{shouldShowPagination && (
|
||||
<div className={cl("pagination")}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
<div className={cl("page")}>
|
||||
<input
|
||||
type="text"
|
||||
value={pageInput}
|
||||
onChange={e => {
|
||||
const { value } = e.target;
|
||||
setPageInput(value);
|
||||
const num = parseInt(value);
|
||||
if (!isNaN(num) && num >= 1 && num <= totalPages) {
|
||||
setCurrentPage(num);
|
||||
}
|
||||
}}
|
||||
className={cl("page-input")}
|
||||
/>
|
||||
<span className={cl("page-of")}>
|
||||
/ {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className={cl("block")} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={cl("import")}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={handleRandomPreset}
|
||||
className={cl("random")}
|
||||
disabled={!presets.length || !canUseGuild}
|
||||
>
|
||||
<span className={cl("random-content")}>
|
||||
<svg className={cl("random-icon")} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20.5 4h-5a1 1 0 0 0 0 2h2.586l-4.293 4.293a1 1 0 0 0 1.414 1.414L19.5 7.414V10a1 1 0 1 0 2 0V5a1 1 0 0 0-1-1Zm-16 1a1 1 0 0 0 0 2h2.586l4.293 4.293a1 1 0 1 0 1.414-1.414L8.414 5H4.5ZM5 15a1 1 0 0 0-1 1v5a1 1 0 1 0 2 0v-2.586l4.293 4.293a1 1 0 0 0 1.414-1.414L7.414 17H10a1 1 0 1 0 0-2H5Zm14.5 0a1 1 0 0 0 0 2h-2.586l-4.293 4.293a1 1 0 1 0 1.414 1.414L18.586 17H21a1 1 0 1 0 0-2h-1.5Z"
|
||||
/>
|
||||
</svg>
|
||||
Random
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={() => importPresets(forceUpdate, showImportPrompt, resolvedSection, resolvedGuildId)}
|
||||
disabled={!canUseGuild}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={() => exportPresets(resolvedSection)}
|
||||
>
|
||||
Export All
|
||||
</Button>
|
||||
</div>
|
||||
<hr className={cl("block")} />
|
||||
{resolvedSection === "server" && (
|
||||
<hr className={cl("block")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/equicordplugins/profileSets/index.tsx
Normal file
56
src/equicordplugins/profileSets/index.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { React } from "@webpack/common";
|
||||
|
||||
import { PresetManager } from "./components/presetManager";
|
||||
import { loadPresets, PresetSection } from "./utils/storage";
|
||||
|
||||
export const cl = classNameFactory("vc-profile-presets-");
|
||||
export const settings = definePluginSettings({
|
||||
avatarSize: {
|
||||
type: OptionType.SLIDER,
|
||||
description: "Avatar size in preset list.",
|
||||
markers: [56, 64, 72, 80, 88, 96],
|
||||
default: 56,
|
||||
stickToMarkers: true
|
||||
},
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "ProfileSets",
|
||||
description: "Allows you to save and load different profile presets, via the Profile Section in Settings.",
|
||||
authors: [EquicordDevs.omaw, EquicordDevs.justjxke],
|
||||
settings,
|
||||
patches: [
|
||||
{
|
||||
find: "DefaultCustomizationSections: user cannot be undefined",
|
||||
replacement: {
|
||||
match: /return.{0,50}children:\[(?<=\.getLegacyUsername\(\).*?)/,
|
||||
replace: "$&$self.renderPresetSection(\"main\"),"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: "USER_SETTINGS_GUILD_PROFILE)",
|
||||
replacement: {
|
||||
match: /guildId:(\i\.id),onChange:(\i)\}\)(?=.{0,25}profilePreviewTitle:)/,
|
||||
replace: 'guildId:$1,onChange:$2}),$self.renderPresetSection("server",$1)'
|
||||
}
|
||||
}
|
||||
],
|
||||
start() {
|
||||
loadPresets("main");
|
||||
},
|
||||
renderPresetSection(section: PresetSection, guildId?: string) {
|
||||
return <PresetManager section={section} guildId={guildId} />;
|
||||
}
|
||||
});
|
||||
209
src/equicordplugins/profileSets/styles.css
Normal file
209
src/equicordplugins/profileSets/styles.css
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
.vc-profile-presets-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
max-width: 660px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.vc-profile-presets-section-server {
|
||||
max-width: 660px;
|
||||
}
|
||||
|
||||
.vc-profile-presets-heading {
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vc-profile-presets-text {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vc-profile-presets-text-input {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vc-profile-presets-search {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vc-profile-presets-search-button {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.vc-profile-presets-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.vc-profile-presets-section-server .vc-profile-presets-pagination {
|
||||
width: 300px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.vc-profile-presets-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.vc-profile-presets-page-of {
|
||||
font-size: 14px;
|
||||
color: var(--text-normal);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vc-profile-presets-import {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vc-profile-presets-random {
|
||||
background: linear-gradient(135deg, #ff5f6d 0%, #ffc371 50%, #8a7bff 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vc-profile-presets-random:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.vc-profile-presets-random-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vc-profile-presets-random-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.vc-profile-presets-block {
|
||||
margin: 16px 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.vc-profile-presets-avatar-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vc-profile-presets-rename {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vc-profile-presets-updated {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vc-profile-presets-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
background-color: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--background-modifier-accent);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.vc-profile-presets-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background-color: var(--background-primary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 2px solid transparent;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.vc-profile-presets-row:hover {
|
||||
background-color: var(--background-modifier-hover) !important;
|
||||
border-color: var(--background-modifier-accent) !important;
|
||||
}
|
||||
|
||||
.vc-profile-presets-row:focus-visible {
|
||||
border-color: var(--brand-experiment) !important;
|
||||
box-shadow: 0 0 0 2px var(--brand-experiment-30a);
|
||||
}
|
||||
|
||||
.vc-profile-presets-row.selected {
|
||||
background-color: var(--background-modifier-selected);
|
||||
}
|
||||
|
||||
.vc-profile-presets-avatar {
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--background-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vc-profile-presets-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--header-primary);
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vc-profile-presets-timestamp {
|
||||
font-size: 12px;
|
||||
color: var(--text-normal);
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.vc-profile-presets-menu-icon {
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.vc-profile-presets-menu-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vc-profile-presets-page-input {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
background-color: var(--background-secondary);
|
||||
border: 2px solid var(--background-modifier-accent);
|
||||
border-radius: 4px;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
152
src/equicordplugins/profileSets/utils/actions.ts
Normal file
152
src/equicordplugins/profileSets/utils/actions.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { isNonNullish } from "@utils/guards";
|
||||
import { ProfilePreset } from "@vencord/discord-types";
|
||||
import { findStoreLazy } from "@webpack";
|
||||
import { showToast, Toasts } from "@webpack/common";
|
||||
|
||||
import { getCurrentProfile } from "./profile";
|
||||
import { addPreset, movePresetInArray, presets, PresetSection, type ProfilePresetEx, removePreset, replaceAllPresets, savePresetsData, updatePreset } from "./storage";
|
||||
|
||||
const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore");
|
||||
|
||||
function isImageInput(value: unknown): value is string | { imageUri: string; } {
|
||||
if (typeof value === "string") return value.length > 0;
|
||||
return typeof value === "object" && isNonNullish(value) && "imageUri" in value && typeof (value as { imageUri: unknown }).imageUri === "string";
|
||||
}
|
||||
|
||||
function getFreshPendingAvatar(section: PresetSection, guildId?: string): string | null {
|
||||
const pending = (section === "server" && guildId
|
||||
? UserProfileSettingsStore.getPendingChanges?.(guildId)
|
||||
: UserProfileSettingsStore.getPendingChanges?.()) ?? {};
|
||||
const pendingObj = pending as Record<string, unknown>;
|
||||
|
||||
const candidates = [
|
||||
pendingObj.selectedAvatarRaw,
|
||||
pendingObj.presetAvatarRaw,
|
||||
pendingObj.pendingAvatar,
|
||||
pendingObj.avatar,
|
||||
pendingObj.selectedAvatarProcessed,
|
||||
pendingObj.presetAvatarProcessed
|
||||
];
|
||||
const selected = candidates.find(isImageInput);
|
||||
if (!selected) return null;
|
||||
return typeof selected === "string" ? selected : selected.imageUri;
|
||||
}
|
||||
|
||||
export async function savePreset(name: string, section: PresetSection, guildId?: string) {
|
||||
const profile = await getCurrentProfile(guildId, { isGuildProfile: section === "server" });
|
||||
const freshPendingAvatar = getFreshPendingAvatar(section, guildId);
|
||||
const effectiveAvatar = freshPendingAvatar ?? profile.avatarDataUrl ?? null;
|
||||
|
||||
const newPreset: ProfilePresetEx = {
|
||||
name,
|
||||
timestamp: Date.now(),
|
||||
...profile,
|
||||
avatarDataUrl: effectiveAvatar,
|
||||
};
|
||||
addPreset(newPreset);
|
||||
await savePresetsData(section);
|
||||
}
|
||||
|
||||
export async function updatePresetField<K extends keyof Omit<ProfilePreset, "name" | "timestamp">>(
|
||||
index: number,
|
||||
field: K,
|
||||
value: Omit<ProfilePreset, "name" | "timestamp">[K],
|
||||
section: PresetSection,
|
||||
guildId?: string
|
||||
) {
|
||||
if (index < 0 || index >= presets.length) return;
|
||||
void guildId;
|
||||
|
||||
const updatedPreset = {
|
||||
...presets[index],
|
||||
[field]: value,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
updatePreset(index, updatedPreset);
|
||||
await savePresetsData(section);
|
||||
}
|
||||
|
||||
export async function deletePreset(index: number, section: PresetSection, guildId?: string) {
|
||||
if (index < 0 || index >= presets.length) return;
|
||||
|
||||
removePreset(index);
|
||||
await savePresetsData(section);
|
||||
}
|
||||
|
||||
export async function movePreset(fromIndex: number, toIndex: number, section: PresetSection, guildId?: string) {
|
||||
if (fromIndex < 0 || fromIndex >= presets.length || toIndex < 0 || toIndex >= presets.length) return;
|
||||
|
||||
movePresetInArray(fromIndex, toIndex);
|
||||
await savePresetsData(section);
|
||||
}
|
||||
|
||||
export async function renamePreset(index: number, newName: string, section: PresetSection, guildId?: string) {
|
||||
if (index < 0 || index >= presets.length || !newName.trim()) return;
|
||||
|
||||
const updatedPreset = { ...presets[index], name: newName.trim() };
|
||||
updatePreset(index, updatedPreset);
|
||||
await savePresetsData(section);
|
||||
}
|
||||
|
||||
export function exportPresets(section: PresetSection) {
|
||||
const dataStr = JSON.stringify(presets, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: "application/json" });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `profile-presets-${section}-${Date.now()}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export type ImportDecision = "override" | "merge" | "cancel";
|
||||
|
||||
export async function importPresets(
|
||||
forceUpdate: () => void,
|
||||
onImportPrompt: (existingCount: number) => Promise<ImportDecision>,
|
||||
section: PresetSection,
|
||||
guildId?: string
|
||||
) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "application/json";
|
||||
input.onchange = async (event: Event) => {
|
||||
try {
|
||||
const target = event.currentTarget as HTMLInputElement | null;
|
||||
const file = target?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const text = await file.text();
|
||||
const importedPresets = JSON.parse(text);
|
||||
|
||||
if (!Array.isArray(importedPresets)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (presets.length > 0) {
|
||||
const decision = await onImportPrompt(presets.length);
|
||||
if (decision === "cancel") return;
|
||||
if (decision === "override") {
|
||||
replaceAllPresets(importedPresets);
|
||||
} else {
|
||||
const combined = [...presets, ...importedPresets];
|
||||
replaceAllPresets(combined);
|
||||
}
|
||||
} else {
|
||||
replaceAllPresets(importedPresets);
|
||||
}
|
||||
|
||||
await savePresetsData(section);
|
||||
forceUpdate();
|
||||
} catch {
|
||||
showToast("Failed to import presets. The file might be invalid.", Toasts.Type.FAILURE);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
415
src/equicordplugins/profileSets/utils/profile.ts
Normal file
415
src/equicordplugins/profileSets/utils/profile.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { getUserSettingLazy } from "@api/UserSettings";
|
||||
import { CustomStatus, DisplayNameStyles, Nameplate, ProfileEffect, ProfilePreset } from "@vencord/discord-types";
|
||||
import { findStoreLazy } from "@webpack";
|
||||
import { FluxDispatcher, GuildMemberStore, IconUtils, UserProfileStore, UserStore } from "@webpack/common";
|
||||
|
||||
const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore");
|
||||
const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!;
|
||||
|
||||
type PendingChanges = Record<string, unknown> & {
|
||||
selectedAvatarRaw?: ImageInput;
|
||||
presetAvatarRaw?: ImageInput;
|
||||
selectedAvatarProcessed?: ImageInput;
|
||||
presetAvatarProcessed?: ImageInput;
|
||||
pendingAvatar?: ImageInput;
|
||||
avatar?: ImageInput;
|
||||
pendingBanner?: ImageInput;
|
||||
banner?: ImageInput;
|
||||
pendingAvatarDecoration?: { asset: string; skuId: string; } | null;
|
||||
pendingProfileEffect?: ProfileEffect | null;
|
||||
pendingNameplate?: Nameplate | null;
|
||||
pendingDisplayNameStyles?: DisplayNameStyles | null;
|
||||
pendingAccentColor?: number | null;
|
||||
pendingThemeColors?: number[] | null;
|
||||
pendingBio?: string | null;
|
||||
pendingPronouns?: string | null;
|
||||
pendingNickname?: string | null;
|
||||
pendingGlobalName?: string | null;
|
||||
pendingPrimaryGuildId?: string | null;
|
||||
};
|
||||
|
||||
type ImageInput = string | { imageUri: string; } | null | undefined;
|
||||
type DisplayNameStylesLike = DisplayNameStyles & {
|
||||
fontId?: number;
|
||||
effectId?: number;
|
||||
};
|
||||
|
||||
type CurrentProfileOptions = {
|
||||
isGuildProfile?: boolean;
|
||||
};
|
||||
|
||||
type LoadPresetOptions = {
|
||||
skipGlobalName?: boolean;
|
||||
skipBio?: boolean;
|
||||
skipPronouns?: boolean;
|
||||
isGuildProfile?: boolean;
|
||||
};
|
||||
|
||||
function dispatch(type: string, payload: Record<string, unknown>) {
|
||||
FluxDispatcher.dispatch({ type, ...payload });
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function hasImageInput(value: ImageInput): boolean {
|
||||
if (!value) return false;
|
||||
if (typeof value === "string") return value.length > 0;
|
||||
return typeof value === "object" && isNonEmptyString(value?.imageUri);
|
||||
}
|
||||
|
||||
function normalizeDisplayNameStyles(value: DisplayNameStylesLike | null | undefined): DisplayNameStylesLike | null {
|
||||
if (!value) return null;
|
||||
const fontId = value.fontId ?? value.font_id;
|
||||
const effectId = value.effectId ?? value.effect_id;
|
||||
if (typeof fontId !== "number" || typeof effectId !== "number") return null;
|
||||
const colors = Array.isArray(value.colors) ? [...value.colors] : [];
|
||||
|
||||
return {
|
||||
fontId,
|
||||
effectId,
|
||||
font_id: fontId,
|
||||
effect_id: effectId,
|
||||
colors
|
||||
};
|
||||
}
|
||||
|
||||
export async function imageUrlToBase64(url: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function processImage(imageData: ImageInput, userId: string, type: "avatar" | "banner", guildId?: string, useGuildPath?: boolean): Promise<string | null> {
|
||||
if (!imageData) return null;
|
||||
|
||||
if (typeof imageData === "object" && isNonEmptyString(imageData?.imageUri)) {
|
||||
return imageData.imageUri;
|
||||
}
|
||||
|
||||
if (typeof imageData === "string") {
|
||||
if (imageData.startsWith("data:")) return imageData;
|
||||
if (/^https?:\/\//.test(imageData)) {
|
||||
return await imageUrlToBase64(imageData);
|
||||
}
|
||||
|
||||
const isAnimated = imageData.startsWith("a_");
|
||||
const size = type === "banner" ? 1024 : 512;
|
||||
const urlPath = type === "banner" ? "banners" : "avatars";
|
||||
const guildPath = guildId ? `guilds/${guildId}/users/${userId}/${type === "banner" ? "banners" : "avatars"}` : urlPath;
|
||||
const guildUrl = `https://cdn.discordapp.com/${guildPath}/${imageData}.${isAnimated ? "gif" : "png"}?size=${size}`;
|
||||
const globalUrl = `https://cdn.discordapp.com/${urlPath}/${userId}/${imageData}.${isAnimated ? "gif" : "png"}?size=${size}`;
|
||||
if (useGuildPath && guildId) {
|
||||
const guildResult = await imageUrlToBase64(guildUrl);
|
||||
if (guildResult) return guildResult;
|
||||
}
|
||||
return await imageUrlToBase64(globalUrl);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getCurrentProfile(guildId?: string, options: CurrentProfileOptions = {}): Promise<Omit<ProfilePreset, "name" | "timestamp">> {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const baseProfile = UserProfileStore.getUserProfile(currentUser.id);
|
||||
const isGuildProfile = options.isGuildProfile ?? Boolean(guildId);
|
||||
const effectiveGuildId = isGuildProfile ? guildId : undefined;
|
||||
const guildProfile = effectiveGuildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, effectiveGuildId) : null;
|
||||
const userProfile = guildProfile ?? baseProfile;
|
||||
const userAny = currentUser;
|
||||
const guildMember = effectiveGuildId ? GuildMemberStore.getMember(effectiveGuildId, currentUser.id) : null;
|
||||
|
||||
const pendingChangesDefault: PendingChanges = UserProfileSettingsStore.getPendingChanges() ?? {};
|
||||
const pendingChangesForGuild: PendingChanges = UserProfileSettingsStore.getPendingChanges(effectiveGuildId) ?? {};
|
||||
const pendingChanges: PendingChanges = isGuildProfile && Object.keys(pendingChangesForGuild).length > 0
|
||||
? pendingChangesForGuild
|
||||
: pendingChangesDefault;
|
||||
|
||||
const customStatusSetting = CustomStatusSettings.getSetting();
|
||||
const customStatus = isGuildProfile
|
||||
? null
|
||||
: {
|
||||
text: customStatusSetting?.text ?? "",
|
||||
emojiId: customStatusSetting?.emojiId ?? "0",
|
||||
emojiName: customStatusSetting?.emojiName ?? "",
|
||||
expiresAtMs: customStatusSetting?.expiresAtMs ?? "0"
|
||||
};
|
||||
|
||||
const avatarDecorationSource = pendingChanges.pendingAvatarDecoration ?? userAny.avatarDecorationData;
|
||||
const avatarDecoration = avatarDecorationSource ? {
|
||||
asset: avatarDecorationSource.asset,
|
||||
skuId: avatarDecorationSource.skuId
|
||||
} : null;
|
||||
|
||||
let profileEffect: ProfileEffect | null = null;
|
||||
const effectToUse = pendingChanges.pendingProfileEffect ?? userProfile?.profileEffect;
|
||||
|
||||
if (effectToUse) {
|
||||
if (effectToUse.skuId && effectToUse.effects) {
|
||||
profileEffect = {
|
||||
skuId: effectToUse.skuId,
|
||||
title: effectToUse.title,
|
||||
description: effectToUse.description,
|
||||
accessibilityLabel: effectToUse.accessibilityLabel,
|
||||
reducedMotionSrc: effectToUse.reducedMotionSrc,
|
||||
thumbnailPreviewSrc: effectToUse.thumbnailPreviewSrc,
|
||||
effects: effectToUse.effects,
|
||||
animationType: effectToUse.animationType,
|
||||
staticFrameSrc: effectToUse.staticFrameSrc,
|
||||
type: effectToUse.type || 1
|
||||
};
|
||||
} else if (effectToUse.skuId) {
|
||||
const collectibles = userProfile?.collectibles;
|
||||
const collectible = collectibles?.find(c => c?.skuId === effectToUse.skuId);
|
||||
if (collectible) {
|
||||
profileEffect = {
|
||||
skuId: collectible.skuId,
|
||||
title: collectible.title,
|
||||
description: collectible.description,
|
||||
accessibilityLabel: collectible.accessibilityLabel,
|
||||
reducedMotionSrc: collectible.reducedMotionSrc,
|
||||
thumbnailPreviewSrc: collectible.thumbnailPreviewSrc,
|
||||
effects: collectible.effects,
|
||||
animationType: collectible.animationType,
|
||||
staticFrameSrc: collectible.staticFrameSrc,
|
||||
type: collectible.type || 1
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nameplateToUse = pendingChanges.pendingNameplate ?? userAny.collectibles?.nameplate;
|
||||
const nameplate = nameplateToUse ? {
|
||||
skuId: nameplateToUse.skuId,
|
||||
asset: nameplateToUse.asset,
|
||||
label: nameplateToUse.label,
|
||||
palette: typeof nameplateToUse.palette === "string" ? nameplateToUse.palette : undefined,
|
||||
type: nameplateToUse.type || 2
|
||||
} : null;
|
||||
|
||||
const savedDisplayNameStyles = isGuildProfile
|
||||
? (guildMember?.displayNameStyles ?? userAny.displayNameStyles)
|
||||
: userAny.displayNameStyles;
|
||||
const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles ?? savedDisplayNameStyles;
|
||||
const displayNameStyles = normalizeDisplayNameStyles(displayNameStylesToUse);
|
||||
|
||||
const pendingAvatar = pendingChanges.pendingAvatar ?? pendingChanges.avatar;
|
||||
const avatarToUse: ImageInput = hasImageInput(pendingAvatar)
|
||||
? pendingAvatar
|
||||
: (isGuildProfile ? (guildMember?.avatar ?? currentUser.avatar ?? null) : (currentUser.avatar ?? null));
|
||||
|
||||
const useGuildAvatar = !!(effectiveGuildId && isGuildProfile && guildMember?.avatar && avatarToUse === guildMember.avatar);
|
||||
|
||||
const avatarInput: ImageInput = hasImageInput(avatarToUse)
|
||||
? avatarToUse
|
||||
: IconUtils.getUserAvatarURL(currentUser, true, 512);
|
||||
const avatarDataUrl = await processImage(avatarInput, currentUser.id, "avatar", effectiveGuildId, useGuildAvatar);
|
||||
const resolvedAvatarDataUrl = avatarDataUrl ?? IconUtils.getDefaultAvatarURL(currentUser.id);
|
||||
|
||||
const pendingBanner = pendingChanges.pendingBanner ?? pendingChanges.banner;
|
||||
const bannerToUse: ImageInput = hasImageInput(pendingBanner)
|
||||
? pendingBanner
|
||||
: (isGuildProfile ? (guildProfile?.banner ?? baseProfile?.banner) : baseProfile?.banner);
|
||||
const useGuildBanner = !!(effectiveGuildId && isGuildProfile && guildProfile?.banner && bannerToUse === guildProfile?.banner);
|
||||
|
||||
const bannerDataUrl = await processImage(bannerToUse, currentUser.id, "banner", effectiveGuildId, useGuildBanner);
|
||||
|
||||
return {
|
||||
avatarDataUrl: resolvedAvatarDataUrl,
|
||||
bannerDataUrl,
|
||||
bio: pendingChanges.pendingBio ?? userProfile?.bio ?? null,
|
||||
accentColor: pendingChanges.pendingAccentColor ?? userProfile?.accentColor ?? null,
|
||||
themeColors: pendingChanges.pendingThemeColors ?? userProfile?.themeColors ?? null,
|
||||
globalName: isGuildProfile
|
||||
? (pendingChanges.pendingNickname ?? guildMember?.nick ?? null)
|
||||
: (pendingChanges.pendingGlobalName ?? currentUser.globalName ?? null),
|
||||
pronouns: isGuildProfile ? null : (pendingChanges.pendingPronouns ?? userProfile?.pronouns ?? null),
|
||||
avatarDecoration,
|
||||
profileEffect,
|
||||
nameplate,
|
||||
primaryGuildId: isGuildProfile
|
||||
? null
|
||||
: (pendingChanges.pendingPrimaryGuildId ?? userAny.primaryGuild?.identityGuildId ?? null),
|
||||
customStatus,
|
||||
displayNameStyles
|
||||
};
|
||||
}
|
||||
|
||||
function jsonEq(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
function customStatusEq(a: CustomStatus | null | undefined, b: CustomStatus | null | undefined): boolean {
|
||||
if (a == null || b == null) return a == null && b == null;
|
||||
return a.text === b.text
|
||||
&& String(a.emojiId ?? "") === String(b.emojiId ?? "")
|
||||
&& a.emojiName === b.emojiName
|
||||
&& String(a.expiresAtMs ?? "0") === String(b.expiresAtMs ?? "0");
|
||||
}
|
||||
|
||||
function resolvePendingAvatar(pendingChanges: PendingChanges | null): ImageInput {
|
||||
if (!pendingChanges) return null;
|
||||
|
||||
const candidates: ImageInput[] = [
|
||||
pendingChanges.selectedAvatarRaw,
|
||||
pendingChanges.presetAvatarRaw,
|
||||
pendingChanges.pendingAvatar,
|
||||
pendingChanges.avatar,
|
||||
pendingChanges.selectedAvatarProcessed,
|
||||
pendingChanges.presetAvatarProcessed
|
||||
];
|
||||
return candidates.find(hasImageInput) ?? null;
|
||||
}
|
||||
|
||||
function normalizeImageValue(value: unknown): string | null {
|
||||
if (typeof value === "string") return value;
|
||||
if (value && typeof value === "object" && "imageUri" in value) {
|
||||
const { imageUri } = value as { imageUri: unknown; };
|
||||
return typeof imageUri === "string" ? imageUri : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectibleEqBySku(a: { skuId?: string | number | null; } | null | undefined, b: { skuId?: string | number | null; } | null | undefined): boolean {
|
||||
if (a == null || b == null) return a == null && b == null;
|
||||
return String(a.skuId ?? "") === String(b.skuId ?? "");
|
||||
}
|
||||
|
||||
function avatarDecorationEq(a: { skuId?: string | number | null; asset?: string | null; } | null | undefined, b: { skuId?: string | number | null; asset?: string | null; } | null | undefined): boolean {
|
||||
if (a == null || b == null) return a == null && b == null;
|
||||
return String(a.skuId ?? "") === String(b.skuId ?? "") && String(a.asset ?? "") === String(b.asset ?? "");
|
||||
}
|
||||
|
||||
function nameplateEq(a: { skuId?: string | number | null; asset?: string | null; } | null | undefined, b: { skuId?: string | number | null; asset?: string | null; } | null | undefined): boolean {
|
||||
if (a == null || b == null) return a == null && b == null;
|
||||
return String(a.skuId ?? "") === String(b.skuId ?? "") && String(a.asset ?? "") === String(b.asset ?? "");
|
||||
}
|
||||
|
||||
export async function loadPresetAsPending(preset: ProfilePreset, guildId?: string, options: LoadPresetOptions = {}) {
|
||||
try {
|
||||
const isGuild = options.isGuildProfile ?? Boolean(guildId);
|
||||
if (isGuild && !guildId) return;
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const baseProfile = UserProfileStore.getUserProfile(currentUser.id);
|
||||
const guildProfile = isGuild && guildId ? UserProfileStore.getGuildMemberProfile(currentUser.id, guildId) : null;
|
||||
const equippedProfile = guildProfile ?? baseProfile;
|
||||
const current = await getCurrentProfile(guildId, {
|
||||
isGuildProfile: isGuild
|
||||
});
|
||||
const pendingChanges = (isGuild && guildId
|
||||
? UserProfileSettingsStore.getPendingChanges(guildId)
|
||||
: UserProfileSettingsStore.getPendingChanges());
|
||||
const equippedAvatarDecoration = currentUser.avatarDecorationData ?? null;
|
||||
const equippedProfileEffect = equippedProfile?.profileEffect ?? null;
|
||||
const equippedNameplate = currentUser.collectibles?.nameplate ?? null;
|
||||
const pendingAvatarDecoration = pendingChanges?.pendingAvatarDecoration ?? null;
|
||||
const pendingProfileEffect = pendingChanges?.pendingProfileEffect ?? null;
|
||||
const pendingNameplate = pendingChanges?.pendingNameplate ?? null;
|
||||
const setPending = (field: string, payload: Record<string, unknown>) => {
|
||||
const cleanPayload = Object.fromEntries(Object.entries(payload).filter(([, v]) => v !== undefined));
|
||||
if (!Object.keys(cleanPayload).length) return;
|
||||
dispatch(`USER_PROFILE_SETTINGS_SET_PENDING_${field}`, isGuild ? { ...cleanPayload, guildId } : cleanPayload);
|
||||
};
|
||||
|
||||
if ("avatarDataUrl" in preset) {
|
||||
const avatarValue = preset.avatarDataUrl;
|
||||
const presetAvatar = normalizeImageValue(avatarValue);
|
||||
const currentAvatar = normalizeImageValue(current.avatarDataUrl);
|
||||
const pendingAvatar = normalizeImageValue(resolvePendingAvatar(pendingChanges));
|
||||
if (isGuild || (presetAvatar !== currentAvatar && presetAvatar !== pendingAvatar)) {
|
||||
const avatarPayload =
|
||||
typeof avatarValue === "string" && avatarValue.startsWith("data:")
|
||||
? {
|
||||
assetOrigin: "NEW_ASSET",
|
||||
imageUri: avatarValue,
|
||||
description: `profilesets-${preset.name ?? "preset"}`
|
||||
}
|
||||
: avatarValue;
|
||||
setPending("AVATAR", { avatar: avatarPayload });
|
||||
}
|
||||
}
|
||||
|
||||
if ("bannerDataUrl" in preset && preset.bannerDataUrl !== current.bannerDataUrl) {
|
||||
setPending("BANNER", { banner: preset.bannerDataUrl });
|
||||
}
|
||||
|
||||
if (!options.skipBio && preset?.bio !== current?.bio) {
|
||||
const bioValue = preset.bio === null ? "" : preset.bio;
|
||||
setPending("BIO", { bio: bioValue });
|
||||
}
|
||||
|
||||
if (!isGuild && !options.skipPronouns && preset?.pronouns !== current?.pronouns) {
|
||||
const pronounsValue = preset.pronouns === null ? "" : preset.pronouns;
|
||||
setPending("PRONOUNS", { pronouns: pronounsValue });
|
||||
}
|
||||
|
||||
if (!options.skipGlobalName && preset?.globalName !== current?.globalName) {
|
||||
if (isGuild) {
|
||||
setPending("NICKNAME", { nickname: preset.globalName });
|
||||
} else {
|
||||
setPending("GLOBAL_NAME", { globalName: preset.globalName });
|
||||
}
|
||||
}
|
||||
|
||||
if (preset.avatarDecoration && ![pendingAvatarDecoration, equippedAvatarDecoration, current.avatarDecoration].some(d => avatarDecorationEq(preset.avatarDecoration, d))) {
|
||||
setPending("COLLECTIBLES_ITEM", {
|
||||
item: { type: 0, value: preset.avatarDecoration }
|
||||
});
|
||||
}
|
||||
|
||||
if (preset.profileEffect && ![pendingProfileEffect, equippedProfileEffect, current.profileEffect].some(d => collectibleEqBySku(preset.profileEffect, d))) {
|
||||
setPending("COLLECTIBLES_ITEM", {
|
||||
item: { type: 1, value: preset.profileEffect }
|
||||
});
|
||||
}
|
||||
|
||||
if (preset.nameplate && ![pendingNameplate, equippedNameplate, current.nameplate].some(d => nameplateEq(preset.nameplate, d))) {
|
||||
setPending("COLLECTIBLES_ITEM", {
|
||||
item: { type: 2, value: preset.nameplate }
|
||||
});
|
||||
}
|
||||
|
||||
if (preset.displayNameStyles) {
|
||||
const presetDisplayNameStyles = normalizeDisplayNameStyles(preset.displayNameStyles);
|
||||
if (!jsonEq(presetDisplayNameStyles, current.displayNameStyles)) {
|
||||
setPending("DISPLAY_NAME_STYLES", { displayNameStyles: presetDisplayNameStyles });
|
||||
}
|
||||
}
|
||||
|
||||
if (preset.themeColors && !jsonEq(preset.themeColors, current.themeColors)) {
|
||||
setPending("THEME_COLORS", { themeColors: preset.themeColors });
|
||||
}
|
||||
|
||||
if (preset.primaryGuildId && !isGuild && preset.primaryGuildId !== current.primaryGuildId) {
|
||||
dispatch("USER_SETTINGS_SET_PENDING_PRIMARY_GUILD_ID", { primaryGuildId: preset.primaryGuildId });
|
||||
}
|
||||
|
||||
if (preset.customStatus && !isGuild && !customStatusEq(preset.customStatus, current.customStatus)) {
|
||||
CustomStatusSettings.updateSetting({
|
||||
text: preset.customStatus?.text ?? "",
|
||||
expiresAtMs: preset.customStatus?.expiresAtMs ?? "0",
|
||||
emojiId: preset.customStatus?.emojiId ?? "0",
|
||||
emojiName: preset.customStatus?.emojiName ?? ""
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
120
src/equicordplugins/profileSets/utils/storage.ts
Normal file
120
src/equicordplugins/profileSets/utils/storage.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { DataStore } from "@api/index";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { ProfilePreset } from "@vencord/discord-types";
|
||||
import { UserStore } from "@webpack/common";
|
||||
|
||||
const logger = new Logger("ProfilePresets");
|
||||
const LEGACY_PRESETS_KEY = "ProfileDataset";
|
||||
const MAIN_PRESETS_KEY = "ProfilePresets_v2_Main";
|
||||
const SERVER_PRESETS_KEY = "ProfilePresets_v2_Server";
|
||||
|
||||
export type PresetSection = "main" | "server";
|
||||
|
||||
export type ProfilePresetEx = ProfilePreset & {
|
||||
avatarRaw?: string | null;
|
||||
};
|
||||
|
||||
export let presets: ProfilePresetEx[] = [];
|
||||
export let currentPresetIndex = -1;
|
||||
let activeScopeKey: string | null = null;
|
||||
|
||||
function resetPresets(nextPresets: ProfilePresetEx[] = []) {
|
||||
presets = nextPresets;
|
||||
currentPresetIndex = -1;
|
||||
}
|
||||
|
||||
function getPresetsKey(section: PresetSection, userId: string) {
|
||||
const baseKey = section === "main" ? MAIN_PRESETS_KEY : SERVER_PRESETS_KEY;
|
||||
return `${baseKey}:${userId}`;
|
||||
}
|
||||
|
||||
function getLegacyKey(userId: string) {
|
||||
return `${LEGACY_PRESETS_KEY}:${userId}:main`;
|
||||
}
|
||||
|
||||
export async function loadPresets(section: PresetSection) {
|
||||
try {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const userId = currentUser!.id;
|
||||
const key = getPresetsKey(section, userId);
|
||||
activeScopeKey = key;
|
||||
const stored = await DataStore.get(key);
|
||||
if (stored && Array.isArray(stored)) {
|
||||
resetPresets(stored);
|
||||
return;
|
||||
}
|
||||
|
||||
if (section === "main") {
|
||||
const legacyKey = getLegacyKey(userId);
|
||||
const legacyStored = await DataStore.get(legacyKey);
|
||||
const legacyBaseStored = await DataStore.get(LEGACY_PRESETS_KEY);
|
||||
const legacyToUse = Array.isArray(legacyStored)
|
||||
? legacyStored
|
||||
: (Array.isArray(legacyBaseStored) ? legacyBaseStored : null);
|
||||
if (legacyToUse) {
|
||||
resetPresets(legacyToUse);
|
||||
await DataStore.set(key, legacyToUse);
|
||||
await DataStore.del(legacyKey);
|
||||
await DataStore.del(LEGACY_PRESETS_KEY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
resetPresets();
|
||||
} catch (err) {
|
||||
logger.error("Failed to load presets", err);
|
||||
resetPresets();
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePresetsData(section?: PresetSection) {
|
||||
try {
|
||||
if (!activeScopeKey && !section) return;
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const userId = currentUser!.id;
|
||||
const key = section ? getPresetsKey(section, userId) : activeScopeKey!;
|
||||
await DataStore.set(key, presets);
|
||||
} catch (err) {
|
||||
logger.error("Failed to save presets", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function setCurrentPresetIndex(index: number) {
|
||||
currentPresetIndex = index;
|
||||
}
|
||||
|
||||
export function addPreset(preset: ProfilePresetEx) {
|
||||
presets.push(preset);
|
||||
}
|
||||
|
||||
export function updatePreset(index: number, preset: ProfilePresetEx) {
|
||||
if (index >= 0 && index < presets.length) {
|
||||
presets[index] = preset;
|
||||
}
|
||||
}
|
||||
|
||||
export function removePreset(index: number) {
|
||||
if (index >= 0 && index < presets.length) {
|
||||
presets.splice(index, 1);
|
||||
if (currentPresetIndex === index) {
|
||||
currentPresetIndex = -1;
|
||||
} else if (currentPresetIndex > index) {
|
||||
currentPresetIndex--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function movePresetInArray(fromIndex: number, toIndex: number) {
|
||||
if (fromIndex < 0 || fromIndex >= presets.length || toIndex < 0 || toIndex >= presets.length) return;
|
||||
const [preset] = presets.splice(fromIndex, 1);
|
||||
presets.splice(toIndex, 0, preset);
|
||||
}
|
||||
|
||||
export function replaceAllPresets(newPresets: ProfilePresetEx[]) {
|
||||
presets = newPresets;
|
||||
}
|
||||
46
src/equicordplugins/questionMarkReplacement/index.tsx
Normal file
46
src/equicordplugins/questionMarkReplacement/index.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { addMessagePreSendListener, removeMessagePreSendListener } from "@api/MessageEvents";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
replace: {
|
||||
type: OptionType.STRING,
|
||||
description: "Replace with",
|
||||
default: ":face_with_monocle:"
|
||||
},
|
||||
});
|
||||
|
||||
function replaceQuestionMarks(content: string): string {
|
||||
const allQuestionMarks = content.split("").every(char => char === "?");
|
||||
|
||||
if (allQuestionMarks) {
|
||||
return content.replace(/\?/g, settings.store.replace);
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "QuestionMarkReplacement",
|
||||
description: "Replace all question marks with chosen string, if message only contains question marks.",
|
||||
authors: [Devs.nyx],
|
||||
|
||||
settings,
|
||||
|
||||
start() {
|
||||
this.preSend = addMessagePreSendListener((_, msg) => {
|
||||
msg.content = replaceQuestionMarks(msg.content);
|
||||
});
|
||||
},
|
||||
|
||||
stop() {
|
||||
removeMessagePreSendListener(this.preSend);
|
||||
}
|
||||
});
|
||||
66
src/equicordplugins/screenRecorder.equibop/index.tsx
Normal file
66
src/equicordplugins/screenRecorder.equibop/index.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu";
|
||||
import { ScreenshareIcon } from "@components/Icons";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Menu, UploadHandler } from "@webpack/common";
|
||||
|
||||
let recoder: MediaRecorder;
|
||||
|
||||
export default definePlugin({
|
||||
name: "ScreenRecorder",
|
||||
description: "epic screen recorder lol",
|
||||
authors: [Devs.AutumnVN],
|
||||
contextMenus: {
|
||||
"channel-attach": startRecording
|
||||
}
|
||||
});
|
||||
|
||||
function startRecording(children) {
|
||||
children.push(
|
||||
<Menu.MenuItem
|
||||
id="start-recording"
|
||||
label={
|
||||
<div>
|
||||
<ScreenshareIcon height={24} width={24} />
|
||||
<div>Start Recording</div>
|
||||
</div>
|
||||
}
|
||||
action={async () => {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: { frameRate: { ideal: 60 } } });
|
||||
recoder = new MediaRecorder(stream);
|
||||
recoder.start();
|
||||
removeContextMenuPatch("channel-attach", startRecording);
|
||||
addContextMenuPatch("channel-attach", stopRecording);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function stopRecording(children, props) {
|
||||
children.push(
|
||||
<Menu.MenuItem
|
||||
id="stop-recording"
|
||||
label={
|
||||
<div>
|
||||
<ScreenshareIcon height={24} width={24} />
|
||||
<div>Stop Recording</div>
|
||||
</div>
|
||||
}
|
||||
action={() => {
|
||||
recoder.addEventListener("dataavailable", e => {
|
||||
const file = new File([e.data], "watch if cute.webm", { type: "video/webm" });
|
||||
UploadHandler.promptToUpload([file], props.channel, 0);
|
||||
});
|
||||
recoder.stop();
|
||||
removeContextMenuPatch("channel-attach", stopRecording);
|
||||
addContextMenuPatch("channel-attach", startRecording);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
64
src/equicordplugins/serverSearch/index.tsx
Normal file
64
src/equicordplugins/serverSearch/index.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated, camila314, and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { FluxDispatcher } from "@webpack/common";
|
||||
import { Tooltip } from "webpack/common/components";
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" id="vc-searchbutton-icon">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M15.62 17.03a9 9 0 1 1 1.41-1.41l4.68 4.67a1 1 0 0 1-1.421.42l-4.67-4.68ZM17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"
|
||||
clipRule="evenodd"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "ServerSearch",
|
||||
authors: [EquicordDevs.camila314],
|
||||
description: "Navigate your servers better with a quick search button",
|
||||
|
||||
renderButton() {
|
||||
return <ErrorBoundary noop>
|
||||
<div id="vc-searchbutton-container">
|
||||
<Tooltip text="Search" position="right">
|
||||
{({ onMouseEnter, onMouseLeave }) => (
|
||||
<div
|
||||
id="vc-searchbutton"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={() =>
|
||||
FluxDispatcher.dispatch({
|
||||
type: "QUICKSWITCHER_SHOW",
|
||||
query: "",
|
||||
queryMode: null
|
||||
})
|
||||
}>
|
||||
<SearchIcon />
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ErrorBoundary>;
|
||||
},
|
||||
|
||||
start() {
|
||||
addServerListElement(ServerListRenderPosition.Above, this.renderButton);
|
||||
},
|
||||
|
||||
stop() {
|
||||
removeServerListElement(ServerListRenderPosition.Above, this.renderButton);
|
||||
}
|
||||
});
|
||||
33
src/equicordplugins/serverSearch/styles.css
Normal file
33
src/equicordplugins/serverSearch/styles.css
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#vc-searchbutton-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#vc-searchbutton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--background-primary);
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
border-radius: 100%;
|
||||
transition: border-radius 0.15s ease-out, background-color 0.15s ease-out;
|
||||
}
|
||||
|
||||
#vc-searchbutton-icon {
|
||||
color: var(--text-default);
|
||||
}
|
||||
|
||||
#vc-searchbutton:hover {
|
||||
border-radius: 34%;
|
||||
background-color: var(--green-360);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#vc-searchbutton:hover #vc-searchbutton-icon {
|
||||
color: var(--white-500);
|
||||
}
|
||||
155
src/equicordplugins/signature/index.tsx
Normal file
155
src/equicordplugins/signature/index.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
|
||||
import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, sendBotMessage } from "@api/Commands";
|
||||
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Menu, React } from "@webpack/common";
|
||||
|
||||
// Big thank you too slientTyping
|
||||
|
||||
const settings = definePluginSettings(
|
||||
{
|
||||
name: {
|
||||
type: OptionType.STRING,
|
||||
description: "The signature that will be added to the end of your messages",
|
||||
default: "a chronic discord user"
|
||||
},
|
||||
textHeader: {
|
||||
description: "What header to preface text with",
|
||||
type: OptionType.STRING,
|
||||
default: ">",
|
||||
},
|
||||
showIcon: {
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
description: "Show an icon for toggling the plugin in the chat bar",
|
||||
restartNeeded: true,
|
||||
},
|
||||
contextMenu: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Add option to toggle the functionality in the chat input context menu",
|
||||
default: true
|
||||
},
|
||||
isEnabled: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Toggle functionality",
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const SignatureToggle: ChatBarButtonFactory = ({ isMainChat }) => {
|
||||
const { isEnabled, showIcon } = settings.use(["isEnabled", "showIcon"]);
|
||||
const toggle = () => settings.store.isEnabled = !settings.store.isEnabled;
|
||||
|
||||
if (!isMainChat || !showIcon) return null;
|
||||
|
||||
return (
|
||||
<ChatBarButton
|
||||
tooltip={isEnabled ? "Disable Signature" : "Enable Signature"}
|
||||
onClick={toggle}
|
||||
>
|
||||
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 21.333">
|
||||
<path fill="currentColor" mask="url(#signature-msg-mask)" d="M2 4.621a.5.5 0 0 1 .854-.353l6.01 6.01c.126.126.17.31.15.487a2 2 0 1 0 1.751-1.751a.59.59 0 0 1-.487-.15l-6.01-6.01A.5.5 0 0 1 4.62 2H11a9 9 0 0 1 8.468 12.054l2.24 2.239a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.415 0l-2.239-2.239A9 9 0 0 1 2 11z" />
|
||||
{isEnabled && (
|
||||
<>
|
||||
<mask id="signature-msg-mask"> <path fill="#fff" d="M0 0h24v24H0Z"></path>
|
||||
<path stroke="#000" strokeWidth="5.99068" d="M0 24 24 0"></path> </mask>
|
||||
<path fill="var(--status-danger)" d="m21.178 1.70703 1.414 1.414L4.12103 21.593l-1.414-1.415L21.178 1.70703Z" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</ChatBarButton>
|
||||
);
|
||||
};
|
||||
|
||||
function SignatureIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 21.333">
|
||||
<path fill="currentColor" d="M2 4.621a.5.5 0 0 1 .854-.353l6.01 6.01c.126.126.17.31.15.487a2 2 0 1 0 1.751-1.751a.59.59 0 0 1-.487-.15l-6.01-6.01A.5.5 0 0 1 4.62 2H11a9 9 0 0 1 8.468 12.054l2.24 2.239a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.415 0l-2.239-2.239A9 9 0 0 1 2 11z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ChatBarContextCheckbox: NavContextMenuPatchCallback = children => {
|
||||
const { isEnabled, contextMenu } = settings.use(["isEnabled", "contextMenu"]);
|
||||
if (!contextMenu) return;
|
||||
|
||||
const group = findGroupChildrenByChildId("submit-button", children);
|
||||
|
||||
if (!group) return;
|
||||
|
||||
const idx = group.findIndex(c => c?.props?.id === "submit-button");
|
||||
|
||||
group.splice(idx + 1, 0,
|
||||
<Menu.MenuCheckboxItem
|
||||
id="vc-Signature"
|
||||
label="Enable Signature"
|
||||
checked={isEnabled}
|
||||
action={() => settings.store.isEnabled = !settings.store.isEnabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "Signature",
|
||||
description: "Automated fingerprint/end text",
|
||||
authors: [Devs.Ven, Devs.Rini, Devs.ImBanana, EquicordDevs.KrystalSkull],
|
||||
onBeforeMessageSend(channelId, msg) {
|
||||
if (!settings.store.isEnabled) {
|
||||
msg.content = msg.content;
|
||||
} else {
|
||||
msg.content = textProcessing(msg.content);
|
||||
}
|
||||
},
|
||||
start: () => {
|
||||
if (settings.store.isEnabled) true;
|
||||
},
|
||||
stop: () => {
|
||||
if (settings.store.isEnabled) false;
|
||||
},
|
||||
|
||||
chatBarButton: {
|
||||
icon: SignatureIcon,
|
||||
render: SignatureToggle
|
||||
},
|
||||
|
||||
settings,
|
||||
|
||||
contextMenus: {
|
||||
"textarea-context": ChatBarContextCheckbox
|
||||
},
|
||||
|
||||
commands: [
|
||||
{
|
||||
name: "signature",
|
||||
description: "Toggle your signature",
|
||||
inputType: ApplicationCommandInputType.BUILT_IN,
|
||||
options: [
|
||||
{
|
||||
name: "value",
|
||||
description: "Toggle your signature (default is toggle)",
|
||||
required: false,
|
||||
type: ApplicationCommandOptionType.BOOLEAN,
|
||||
},
|
||||
],
|
||||
execute: async (args, ctx) => {
|
||||
settings.store.isEnabled = !!findOption(args, "value", !settings.store.isEnabled);
|
||||
sendBotMessage(ctx.channel.id, {
|
||||
content: settings.store.isEnabled ? "Signature enabled!" : "Signature disabled!",
|
||||
});
|
||||
},
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
// text processing injection processor
|
||||
function textProcessing(input: string) {
|
||||
return `${input}\n${settings.store.textHeader ? settings.store.textHeader + " " : ""}${settings.store.name}`;
|
||||
}
|
||||
490
src/equicordplugins/snowfall/index.tsx
Normal file
490
src/equicordplugins/snowfall/index.tsx
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Heading } from "@components/Heading";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { createRoot, React } from "@webpack/common";
|
||||
import type { Root } from "react-dom/client";
|
||||
|
||||
const SnowfallCSS = `
|
||||
#snowfield {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
z-index: 100000;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
.snowflake {
|
||||
position: absolute;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.snowflake-solid {
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
}
|
||||
.snowflake-image {
|
||||
filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
`;
|
||||
|
||||
// SVG snowflake images as data URIs
|
||||
const SNOWFLAKE_SVGS = [
|
||||
// 6-pointed snowflake
|
||||
"data:image/svg+xml," + encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<path fill="white" d="M50,10 L55,45 L50,50 L45,45 Z M50,90 L55,55 L50,50 L45,55 Z M10,50 L45,55 L50,50 L45,45 Z M90,50 L55,55 L50,50 L55,45 Z M25,25 L45,45 L50,40 L40,30 Z M75,75 L55,55 L50,60 L60,70 Z M75,25 L55,45 L60,50 L70,40 Z M25,75 L45,55 L40,50 L30,60 Z"/>
|
||||
<circle cx="50" cy="50" r="8" fill="white"/>
|
||||
</svg>
|
||||
`),
|
||||
// 8-pointed star snowflake
|
||||
"data:image/svg+xml," + encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<g fill="white">
|
||||
<rect x="47" y="5" width="6" height="90" rx="2"/>
|
||||
<rect x="5" y="47" width="90" height="6" rx="2"/>
|
||||
<rect x="47" y="5" width="6" height="90" rx="2" transform="rotate(45 50 50)"/>
|
||||
<rect x="47" y="5" width="6" height="90" rx="2" transform="rotate(-45 50 50)"/>
|
||||
<circle cx="50" cy="50" r="10" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
`),
|
||||
// Discord snowflake
|
||||
"data:image/svg+xml," + encodeURIComponent(`
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<symbol id="discord" viewBox="0 0 24 24">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
||||
</symbol>
|
||||
</defs>
|
||||
<!-- snowflake arms -->
|
||||
<line x1="100" y1="100" x2="150" y2="100" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="100" y1="100" x2="125" y2="143.3" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="100" y1="100" x2="75" y2="143.3" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="100" y1="100" x2="50" y2="100" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="100" y1="100" x2="75" y2="56.7" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="100" y1="100" x2="125" y2="56.7" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<!-- circle mask -->
|
||||
<mask id="logoMask">
|
||||
<rect width="200" height="200" fill="white"/>
|
||||
<circle cx="150" cy="100" r="18" fill="black"/> <!-- right -->
|
||||
<circle cx="125" cy="143.3" r="18" fill="black"/> <!-- top-right -->
|
||||
<circle cx="75" cy="143.3" r="18" fill="black"/> <!-- top-left -->
|
||||
<circle cx="50" cy="100" r="18" fill="black"/> <!-- left -->
|
||||
<circle cx="75" cy="56.7" r="18" fill="black"/> <!-- bottom-left -->
|
||||
<circle cx="125" cy="56.7" r="18" fill="black"/> <!-- bottom-right -->
|
||||
</mask>
|
||||
<!-- circle behind all elements -->
|
||||
<circle cx="100" cy="100" r="50" stroke="#FFF" stroke-width="1.5" fill="none" stroke-linecap="round" mask="url(#logoMask)" />
|
||||
<use href="#discord" fill="#FFF" x="135" y="85" width="30" height="30" transform="rotate(90, 150, 100)"/>
|
||||
<use href="#discord" fill="#FFF" x="110" y="128.3" width="30" height="30" transform="rotate(150, 125, 143.3)"/>
|
||||
<use href="#discord" fill="#FFF" x="60" y="128.3" width="30" height="30" transform="rotate(210, 75, 143.3)"/>
|
||||
<use href="#discord" fill="#FFF" x="35" y="85" width="30" height="30" transform="rotate(270, 50, 100)"/>
|
||||
<use href="#discord" fill="#FFF" x="60" y="41.7" width="30" height="30" transform="rotate(330, 75, 56.7)"/>
|
||||
<use href="#discord" fill="#FFF" x="110" y="41.7" width="30" height="30" transform="rotate(30, 125, 56.7)"/>
|
||||
</svg>
|
||||
`),
|
||||
];
|
||||
|
||||
const settings = definePluginSettings({
|
||||
typeOfSnow: {
|
||||
description: "Change the type of snow displayed (Affects performance).",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ label: "Solid (Highest Performance)", value: "solid" },
|
||||
{ label: "Text (Medium Performance)", value: "text", default: true },
|
||||
{ label: "Image (Lowest Performance)", value: "image" }
|
||||
],
|
||||
},
|
||||
maxSize: {
|
||||
description: "Maximum snowflake size",
|
||||
type: OptionType.SLIDER,
|
||||
default: 30,
|
||||
markers: [10, 20, 30, 40, 50]
|
||||
},
|
||||
speed: {
|
||||
description: "Snowfall speed (higher = faster fall)",
|
||||
type: OptionType.SLIDER,
|
||||
default: 50,
|
||||
markers: [50, 100, 200, 300, 400, 500]
|
||||
},
|
||||
flakesPerSecond: {
|
||||
description: "Snowflakes per second (higher = denser snowfall)",
|
||||
type: OptionType.SLIDER,
|
||||
default: 5,
|
||||
markers: [1, 5, 10, 20, 40, 60],
|
||||
min: 1,
|
||||
max: 60,
|
||||
}
|
||||
});
|
||||
|
||||
class CopleSnow {
|
||||
private static winWidth = window.innerWidth;
|
||||
private static winHeight = window.innerHeight;
|
||||
|
||||
static readonly defaultOptions = {
|
||||
minSize: 10,
|
||||
maxSize: 30,
|
||||
type: "text" as "text" | "solid" | "image",
|
||||
content: "❄" as string | string[],
|
||||
fadeOut: true,
|
||||
autoplay: true,
|
||||
interval: 200
|
||||
};
|
||||
|
||||
private static cssPrefix(propertyName: string): string | null {
|
||||
const capitalize = propertyName.charAt(0).toUpperCase() + propertyName.slice(1);
|
||||
const tempDiv = document.createElement("div");
|
||||
const { style } = tempDiv;
|
||||
const prefixes = ["Webkit", "Moz", "ms", "O"];
|
||||
|
||||
if (propertyName in style) return propertyName;
|
||||
for (const prefix of prefixes) {
|
||||
const name = prefix + capitalize;
|
||||
if (name in style) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static readonly cssPrefixedNames = {
|
||||
transform: this.cssPrefix("transform"),
|
||||
transition: this.cssPrefix("transition")
|
||||
};
|
||||
|
||||
private static readonly transitionEndEvent =
|
||||
{ WebkitTransition: "webkitTransitionEnd", OTransition: "oTransitionEnd", Moztransition: "transitionend", transition: "transitionend" }[
|
||||
this.cssPrefixedNames.transition ?? "transition"
|
||||
] ?? "transitionend";
|
||||
|
||||
private static random(min: number, max: number, deviation?: number): number {
|
||||
if (deviation !== undefined) {
|
||||
deviation *= max;
|
||||
max += deviation;
|
||||
min = max - deviation;
|
||||
} else {
|
||||
min = min || 0;
|
||||
}
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
private static setStyle(element: HTMLElement, rules: Record<string, string | number>) {
|
||||
for (const [name, value] of Object.entries(rules)) {
|
||||
const cssName = CopleSnow.cssPrefixedNames[name as keyof typeof CopleSnow.cssPrefixedNames] || name;
|
||||
(element.style as any)[cssName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
private options = { ...CopleSnow.defaultOptions };
|
||||
private queue: HTMLElement[] = [];
|
||||
private $snowfield: HTMLDivElement;
|
||||
private timer: number | null = null;
|
||||
public playing = false;
|
||||
|
||||
constructor(newOptions: Partial<typeof CopleSnow.defaultOptions> = {}) {
|
||||
Object.assign(this.options, newOptions);
|
||||
|
||||
this.$snowfield = document.createElement("div");
|
||||
this.$snowfield.id = "snowfield";
|
||||
document.body.appendChild(this.$snowfield);
|
||||
|
||||
const updateSize = () => {
|
||||
CopleSnow.winHeight = window.innerHeight;
|
||||
CopleSnow.winWidth = window.innerWidth;
|
||||
};
|
||||
window.addEventListener("resize", updateSize);
|
||||
(this as any)._resizeHandler = updateSize;
|
||||
|
||||
this.$snowfield.addEventListener(CopleSnow.transitionEndEvent, e => {
|
||||
const snowflake = e.target as HTMLElement;
|
||||
if (snowflake.classList.contains("snowflake")) {
|
||||
this.$snowfield.removeChild(snowflake);
|
||||
this.queue.push(snowflake);
|
||||
}
|
||||
});
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) this.stop();
|
||||
else this.play();
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
(this as any)._visibilityHandler = handleVisibilityChange;
|
||||
|
||||
if (this.options.autoplay) this.play();
|
||||
}
|
||||
|
||||
private createSnowflake(): HTMLElement {
|
||||
const { type, content } = this.options as { type: "text" | "image" | "solid"; content: string | string[]; };
|
||||
const cntLength = Array.isArray(content) ? content.length : 1;
|
||||
|
||||
let snowflake: HTMLElement;
|
||||
|
||||
if (type === "image") {
|
||||
snowflake = document.createElement("img");
|
||||
const src = typeof content === "string"
|
||||
? content
|
||||
: content[Math.floor(Math.random() * cntLength)];
|
||||
(snowflake as HTMLImageElement).src = src;
|
||||
snowflake.setAttribute("draggable", "false");
|
||||
} else {
|
||||
snowflake = document.createElement("div");
|
||||
if (type === "text") {
|
||||
const textContent = typeof content === "string"
|
||||
? content
|
||||
: content[Math.floor(Math.random() * cntLength)];
|
||||
snowflake.textContent = textContent;
|
||||
}
|
||||
// if type is solid we don't need to set content
|
||||
}
|
||||
|
||||
snowflake.className = `snowflake snowflake-${type}`;
|
||||
(snowflake as any).dataset.type = type;
|
||||
|
||||
return snowflake;
|
||||
}
|
||||
|
||||
private animateSnowflake() {
|
||||
const { winWidth, winHeight } = CopleSnow;
|
||||
const size = CopleSnow.random(this.options.minSize, this.options.maxSize);
|
||||
const top = -2 * size;
|
||||
const left = CopleSnow.random(0, winWidth - size);
|
||||
const opacity = CopleSnow.random(5, 10) / 10;
|
||||
const angle = CopleSnow.random(0, winHeight * 0.8, 1);
|
||||
const translateX = CopleSnow.random(-100, 100);
|
||||
const translateY = winHeight + size * 2;
|
||||
const baseSpeed = settings.store.speed;
|
||||
const duration = (winHeight * 10) / (baseSpeed / 50);
|
||||
let snowflake: HTMLElement;
|
||||
if (this.queue.length > 0) {
|
||||
const reused = this.queue.shift()!;
|
||||
if ((reused.dataset.type as string) !== this.options.type) {
|
||||
snowflake = this.createSnowflake();
|
||||
} else {
|
||||
snowflake = reused;
|
||||
if (this.options.type === "text") {
|
||||
const { content } = this.options;
|
||||
if (Array.isArray(content)) {
|
||||
snowflake.textContent = content[Math.floor(Math.random() * content.length)];
|
||||
}
|
||||
} else if (this.options.type === "image") {
|
||||
const { content } = this.options;
|
||||
if (Array.isArray(content)) {
|
||||
(snowflake as HTMLImageElement).src = content[Math.floor(Math.random() * content.length)];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
snowflake = this.createSnowflake();
|
||||
}
|
||||
|
||||
const styleRules: Record<string, string | number> = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
opacity: opacity,
|
||||
transform: "none",
|
||||
transition: `${duration}ms linear`
|
||||
};
|
||||
|
||||
switch (this.options.type) {
|
||||
case "solid":
|
||||
styleRules.width = styleRules.height = `${size}px`;
|
||||
break;
|
||||
case "text":
|
||||
styleRules.fontSize = `${size}px`;
|
||||
break;
|
||||
case "image":
|
||||
styleRules.width = `${size}px`;
|
||||
styleRules.height = "auto";
|
||||
break;
|
||||
}
|
||||
|
||||
CopleSnow.setStyle(snowflake, styleRules);
|
||||
this.$snowfield.appendChild(snowflake);
|
||||
|
||||
setTimeout(() => {
|
||||
CopleSnow.setStyle(snowflake, {
|
||||
transform: `translate(${translateX}px, ${translateY}px) rotate(${angle}deg)`,
|
||||
opacity: this.options.fadeOut ? 0 : opacity
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.playing) return;
|
||||
this.timer = window.setInterval(() => this.animateSnowflake(), this.options.interval);
|
||||
this.playing = true;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.playing) return;
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.playing = false;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stop();
|
||||
if (this.$snowfield.parentNode) {
|
||||
this.$snowfield.remove();
|
||||
}
|
||||
window.removeEventListener("resize", (this as any)._resizeHandler);
|
||||
document.removeEventListener("visibilitychange", (this as any)._visibilityHandler);
|
||||
}
|
||||
|
||||
updateOptions(newOptions: Partial<typeof CopleSnow.defaultOptions>) {
|
||||
Object.assign(this.options, newOptions);
|
||||
}
|
||||
}
|
||||
|
||||
const SnowfallManager: React.FC = () => {
|
||||
const snowRef = React.useRef<CopleSnow | null>(null);
|
||||
const styleRef = React.useRef<HTMLStyleElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Inject CSS
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.id = "snowfall-styles";
|
||||
styleEl.textContent = SnowfallCSS;
|
||||
document.head.appendChild(styleEl);
|
||||
styleRef.current = styleEl;
|
||||
|
||||
// Get initial snow type from settings
|
||||
const snowType = settings.store.typeOfSnow as "text" | "solid" | "image";
|
||||
const snowOptions: Partial<typeof CopleSnow.defaultOptions> = {
|
||||
autoplay: false,
|
||||
type: snowType,
|
||||
maxSize: settings.store.maxSize,
|
||||
interval: 1000 / settings.store.flakesPerSecond
|
||||
};
|
||||
|
||||
snowOptions.interval = settings.store.speed;
|
||||
|
||||
// Set content based on type
|
||||
if (snowType === "image") {
|
||||
snowOptions.content = SNOWFLAKE_SVGS;
|
||||
}
|
||||
|
||||
// Create snow instance with settings
|
||||
const snow = new CopleSnow(snowOptions);
|
||||
snowRef.current = snow;
|
||||
|
||||
const blurHandler = () => snow.stop();
|
||||
const focusHandler = () => {
|
||||
if (document.hasFocus()) snow.play();
|
||||
};
|
||||
|
||||
window.addEventListener("blur", blurHandler);
|
||||
window.addEventListener("focus", focusHandler);
|
||||
|
||||
if (document.hasFocus()) {
|
||||
snow.play();
|
||||
}
|
||||
|
||||
// Custom listener for settings changes (we don't have a built-in one)
|
||||
let lastSettings = {
|
||||
type: settings.store.typeOfSnow as "text" | "solid" | "image",
|
||||
maxSize: settings.store.maxSize,
|
||||
speed: settings.store.speed,
|
||||
flakesPerSecond: settings.store.flakesPerSecond
|
||||
};
|
||||
|
||||
const settingsInterval = setInterval(() => {
|
||||
const newSettings = {
|
||||
type: settings.store.typeOfSnow as "text" | "solid" | "image",
|
||||
maxSize: settings.store.maxSize,
|
||||
speed: settings.store.speed,
|
||||
flakesPerSecond: settings.store.flakesPerSecond
|
||||
};
|
||||
|
||||
if (Object.keys(newSettings).some(k => newSettings[k as keyof typeof newSettings] !== lastSettings[k as keyof typeof lastSettings])) {
|
||||
lastSettings = newSettings;
|
||||
|
||||
const updateOptions: Partial<typeof CopleSnow.defaultOptions> = {
|
||||
type: newSettings.type,
|
||||
maxSize: newSettings.maxSize,
|
||||
interval: 1000 / newSettings.flakesPerSecond
|
||||
};
|
||||
|
||||
if (newSettings.type === "image") updateOptions.content = SNOWFLAKE_SVGS;
|
||||
else if (newSettings.type === "text") updateOptions.content = "❄";
|
||||
|
||||
snow.updateOptions(updateOptions);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
snow.destroy();
|
||||
snowRef.current = null;
|
||||
|
||||
window.removeEventListener("blur", blurHandler);
|
||||
window.removeEventListener("focus", focusHandler);
|
||||
|
||||
clearInterval(settingsInterval);
|
||||
|
||||
if (styleEl.parentNode) {
|
||||
styleEl.remove();
|
||||
}
|
||||
styleRef.current = null;
|
||||
|
||||
const snowfield = document.getElementById("snowfield");
|
||||
if (snowfield) snowfield.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
let snowRoot: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
export default definePlugin({
|
||||
name: "Snowfall",
|
||||
description: "Let it snow on Discord! Ported from the BetterDiscord plugin by square.",
|
||||
authors: [EquicordDevs.ZcraftElite, EquicordDevs.square],
|
||||
|
||||
settingsAboutComponent: () => (
|
||||
<>
|
||||
<Heading>Information</Heading>
|
||||
<Paragraph>
|
||||
This plugin adds a christmas-y snowfall effect on top of Discord's interface.
|
||||
You can change the type of snow in the settings below.
|
||||
<br /><br />
|
||||
NOTE: While on most computers this plugin will not impact performance any more than your average Equicord extension,
|
||||
it may cause some lag on lower end systems.
|
||||
</Paragraph>
|
||||
</>
|
||||
),
|
||||
|
||||
settings,
|
||||
|
||||
start() {
|
||||
container = document.createElement("div");
|
||||
container.id = "snowfall-plugin-container";
|
||||
document.body.appendChild(container);
|
||||
|
||||
snowRoot = createRoot(container);
|
||||
snowRoot.render(<SnowfallManager />);
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (snowRoot) {
|
||||
snowRoot.unmount();
|
||||
snowRoot = null;
|
||||
}
|
||||
if (container) {
|
||||
if (container.parentNode) container.remove();
|
||||
container = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
167
src/equicordplugins/soggy/index.tsx
Normal file
167
src/equicordplugins/soggy/index.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer";
|
||||
import { HeaderBarButton } from "@api/HeaderBar";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { ModalProps, ModalRoot, openModal } from "@utils/modal";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { React } from "@webpack/common";
|
||||
let boopSound: AudioPlayerInterface;
|
||||
let song: AudioPlayerInterface;
|
||||
|
||||
function assignSong(url: string, volume: number) {
|
||||
song?.delete();
|
||||
song = createAudioPlayer(url, { volume, preload: true, persistent: true });
|
||||
song.load();
|
||||
}
|
||||
|
||||
function assignBoop(url: string, volume: number) {
|
||||
boopSound?.delete();
|
||||
boopSound = createAudioPlayer(url, { volume, preload: true, persistent: true });
|
||||
boopSound.load();
|
||||
}
|
||||
|
||||
function SoggyModal(props: ModalProps) {
|
||||
if (settings.store.songVolume !== 0) {
|
||||
React.useEffect(() => {
|
||||
song?.loop();
|
||||
|
||||
return () => {
|
||||
song?.stop();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
const boop = (e: React.MouseEvent<HTMLImageElement>) => {
|
||||
const { offsetX, offsetY } = e.nativeEvent;
|
||||
|
||||
const region = { x: 155, y: 220, width: 70, height: 70 };
|
||||
|
||||
if (
|
||||
settings.store.boopVolume !== 0 &&
|
||||
offsetX >= region.x &&
|
||||
offsetX <= region.x + region.width &&
|
||||
offsetY >= region.y &&
|
||||
offsetY <= region.y + region.height
|
||||
) {
|
||||
boopSound?.play();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalRoot {...props}>
|
||||
<img
|
||||
src={settings.store.imageLink}
|
||||
onClick={boop}
|
||||
style={{ display: "block" }}
|
||||
|
||||
/>
|
||||
</ModalRoot >
|
||||
);
|
||||
}
|
||||
|
||||
function buildSoggyModal(): any {
|
||||
openModal(props => <SoggyModal {...props} />);
|
||||
}
|
||||
|
||||
function SoggyButton() {
|
||||
return (
|
||||
<HeaderBarButton
|
||||
className="soggy-button"
|
||||
tooltip={settings.store.tooltipText}
|
||||
icon={() => (
|
||||
<img
|
||||
alt=""
|
||||
src={settings.store.imageLink}
|
||||
width={24}
|
||||
height={24}
|
||||
draggable={false}
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
)}
|
||||
onClick={() => buildSoggyModal()}
|
||||
selected={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
songVolume: {
|
||||
description: "Volume of the song. 0 to disable",
|
||||
type: OptionType.SLIDER,
|
||||
default: 0.25,
|
||||
markers: [0, 0.25, 0.5, 0.75, 1],
|
||||
stickToMarkers: false,
|
||||
|
||||
},
|
||||
boopVolume: {
|
||||
description: "Volume of the boop sound",
|
||||
type: OptionType.SLIDER,
|
||||
default: 0.2,
|
||||
markers: [0, 0.25, 0.5, 0.75, 1],
|
||||
stickToMarkers: false,
|
||||
},
|
||||
tooltipText: {
|
||||
description: "The text shown when hovering over the button",
|
||||
type: OptionType.STRING,
|
||||
default: "the soggy",
|
||||
},
|
||||
imageLink: {
|
||||
description: "URL for the image (button and modal)",
|
||||
type: OptionType.STRING,
|
||||
default: "https://equicord.org/assets/plugins/soggy/cat.png",
|
||||
},
|
||||
songLink: {
|
||||
description: "URL for the song to play",
|
||||
type: OptionType.STRING,
|
||||
default: "https://github.com/Equicord/Equibored/raw/main/sounds/soggy/song.mp3?raw=true",
|
||||
onChange(newValue) {
|
||||
assignSong(newValue, settings.store.songVolume * 100);
|
||||
},
|
||||
},
|
||||
boopLink: {
|
||||
description: "URL for the boop sound",
|
||||
type: OptionType.STRING,
|
||||
default: "https://github.com/Equicord/Equibored/raw/main/sounds/soggy/honk.wav?raw=true",
|
||||
onChange(newValue) {
|
||||
assignBoop(newValue, settings.store.boopVolume * 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "Soggy",
|
||||
description: "Adds a soggy button to the toolbox",
|
||||
authors: [EquicordDevs.sliwka],
|
||||
settings,
|
||||
dependencies: ["AudioPlayerAPI"],
|
||||
|
||||
headerBarButton: {
|
||||
icon: () => (
|
||||
<img
|
||||
alt=""
|
||||
src={settings.store.imageLink}
|
||||
width={24}
|
||||
height={24}
|
||||
draggable={false}
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
),
|
||||
render: SoggyButton
|
||||
},
|
||||
|
||||
start() {
|
||||
assignBoop(settings.store.boopLink, settings.store.boopVolume * 100);
|
||||
assignSong(settings.store.songLink, settings.store.songVolume * 100);
|
||||
},
|
||||
|
||||
stop() {
|
||||
boopSound?.delete();
|
||||
song?.delete();
|
||||
},
|
||||
});
|
||||
74
src/equicordplugins/talkInReverse/index.tsx
Normal file
74
src/equicordplugins/talkInReverse/index.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* 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 { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
|
||||
import { addMessagePreSendListener, MessageSendListener, removeMessagePreSendListener } from "@api/MessageEvents";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { React, useEffect, useState } from "@webpack/common";
|
||||
|
||||
let lastState = false;
|
||||
|
||||
const ReverseMessageToggle: ChatBarButtonFactory = ({ isMainChat }) => {
|
||||
const [enabled, setEnabled] = useState(lastState);
|
||||
|
||||
function setEnabledValue(value: boolean) {
|
||||
lastState = value;
|
||||
|
||||
setEnabled(value);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const listener: MessageSendListener = async (_, message) => {
|
||||
if (enabled && message.content) message.content = message.content.split("").reverse().join("");
|
||||
};
|
||||
|
||||
addMessagePreSendListener(listener);
|
||||
return () => void removeMessagePreSendListener(listener);
|
||||
}, [enabled]);
|
||||
|
||||
if (!isMainChat) return null;
|
||||
|
||||
return (
|
||||
<ChatBarButton
|
||||
tooltip={enabled ? "Disable Reverse Message" : "Enable Reverse Message"}
|
||||
onClick={() => setEnabledValue(!enabled)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" width="20"><path fill={enabled ? "var(--green-360)" : "currentColor"} d="M482-160q-134 0-228-93t-94-227v-7l-36 36q-11 11-28 11t-28-11q-11-11-11-28t11-28l104-104q12-12 28-12t28 12l104 104q11 11 11 28t-11 28q-11 11-28 11t-28-11l-36-36v7q0 100 70.5 170T482-240q16 0 31.5-2t30.5-7q17-5 32 1t23 21q8 16 1.5 31.5T577-175q-23 8-47 11.5t-48 3.5Zm-4-560q-16 0-31.5 2t-30.5 7q-17 5-32.5-1T360-733q-8-15-1.5-30.5T381-784q24-8 48-12t49-4q134 0 228 93t94 227v7l36-36q11-11 28-11t28 11q11 11 11 28t-11 28L788-349q-12 12-28 12t-28-12L628-453q-11-11-11-28t11-28q11-11 28-11t28 11l36 36v-7q0-100-70.5-170T478-720Z" /></svg>
|
||||
</ChatBarButton>
|
||||
);
|
||||
};
|
||||
|
||||
function ReverseMessageIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" width="20">
|
||||
<path fill={"currentColor"} d="M482-160q-134 0-228-93t-94-227v-7l-36 36q-11 11-28 11t-28-11q-11-11-11-28t11-28l104-104q12-12 28-12t28 12l104 104q11 11 11 28t-11 28q-11 11-28 11t-28-11l-36-36v7q0 100 70.5 170T482-240q16 0 31.5-2t30.5-7q17-5 32 1t23 21q8 16 1.5 31.5T577-175q-23 8-47 11.5t-48 3.5Zm-4-560q-16 0-31.5 2t-30.5 7q-17 5-32.5-1T360-733q-8-15-1.5-30.5T381-784q24-8 48-12t49-4q134 0 228 93t94 227v7l36-36q11-11 28-11t28 11q11 11 11 28t-11 28L788-349q-12 12-28 12t-28-12L628-453q-11-11-11-28t11-28q11-11 28-11t28 11l36 36v-7q0-100-70.5-170T478-720Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "TalkInReverse",
|
||||
authors: [EquicordDevs.Tolgchu],
|
||||
description: "Reverses the message content before sending it.",
|
||||
dependencies: ["MessageEventsAPI"],
|
||||
chatBarButton: {
|
||||
icon: ReverseMessageIcon,
|
||||
render: ReverseMessageToggle
|
||||
}
|
||||
});
|
||||
70
src/equicordplugins/toggleVideoBind/index.ts
Normal file
70
src/equicordplugins/toggleVideoBind/index.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { FluxDispatcher } from "@webpack/common";
|
||||
|
||||
const validKeycodes = [
|
||||
"Backspace", "Tab", "Enter", "ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "Pause", "CapsLock",
|
||||
"Escape", "Space", "PageUp", "PageDown", "End", "Home", "ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown", "PrintScreen", "Insert",
|
||||
"Delete", "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "KeyA", "KeyB", "KeyC",
|
||||
"KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK", "KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT",
|
||||
"KeyU", "KeyV", "KeyW", "KeyX", "KeyY", "KeyZ", "MetaLeft", "MetaRight", "ContextMenu", "Numpad0", "Numpad1", "Numpad2", "Numpad3",
|
||||
"Numpad4", "Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9", "NumpadMultiply", "NumpadAdd", "NumpadSubtract", "NumpadDecimal",
|
||||
"NumpadDivide", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "NumLock", "ScrollLock",
|
||||
];
|
||||
|
||||
const settings = definePluginSettings({
|
||||
keyBind: {
|
||||
description: "The key to toggle webcam when pressed.",
|
||||
type: OptionType.STRING,
|
||||
default: "KeyX",
|
||||
isValid: (value: string) => validKeycodes.includes(value),
|
||||
},
|
||||
reqCtrl: {
|
||||
description: "Require control to be held.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
reqShift: {
|
||||
description: "Require shift to be held.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
reqAlt: {
|
||||
description: "Require alt to be held.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { isVideoEnabled } = findByPropsLazy("isVideoEnabled");
|
||||
|
||||
function handleKeydown({ code, ctrlKey, shiftKey, altKey }: KeyboardEvent) {
|
||||
const { keyBind, reqCtrl, reqShift, reqAlt } = settings.store;
|
||||
if (keyBind !== code || ctrlKey !== reqCtrl || shiftKey !== reqShift || altKey !== reqAlt) { return; } // please don't think about it
|
||||
|
||||
FluxDispatcher.dispatch({
|
||||
type: "MEDIA_ENGINE_SET_VIDEO_ENABLED",
|
||||
enabled: !isVideoEnabled(),
|
||||
});
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "ToggleVideoBind",
|
||||
description: "Adds a customizable bind to toggle webcam.",
|
||||
authors: [EquicordDevs.mochienya],
|
||||
settings,
|
||||
start() {
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
},
|
||||
stop() {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
},
|
||||
});
|
||||
36
src/equicordplugins/toneIndicators/ToneIndicator.tsx
Normal file
36
src/equicordplugins/toneIndicators/ToneIndicator.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Tooltip } from "@webpack/common";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export interface ToneIndicatorProps {
|
||||
prefix: string;
|
||||
indicator: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export default function ToneIndicator({
|
||||
prefix,
|
||||
indicator,
|
||||
desc,
|
||||
}: ToneIndicatorProps): ReactElement {
|
||||
return (
|
||||
<Tooltip text={desc}>
|
||||
{tooltipProps => (
|
||||
<span
|
||||
{...tooltipProps}
|
||||
style={{
|
||||
color: "var(--text-normal)",
|
||||
userSelect: "text",
|
||||
}}
|
||||
>
|
||||
{prefix}{indicator}
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
188
src/equicordplugins/toneIndicators/index.tsx
Normal file
188
src/equicordplugins/toneIndicators/index.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { React } from "@webpack/common";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import indicatorsDefault from "./indicators";
|
||||
import ToneIndicator from "./ToneIndicator";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
prefix: {
|
||||
type: OptionType.STRING,
|
||||
description: "Prefix character(s) for tone indicators.",
|
||||
default: "/",
|
||||
},
|
||||
customIndicators: {
|
||||
type: OptionType.STRING,
|
||||
description: "Custom tone indicators (format: jk=Joking; srs=Serious)",
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
function getCustomIndicators(): Record<string, string> {
|
||||
const raw = settings.store.customIndicators || "";
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
raw.split("; ").forEach(entry => {
|
||||
const [key, ...rest] = entry.split("=");
|
||||
if (key && rest.length > 0) {
|
||||
result[key.trim().toLowerCase()] = rest.join("=").trim();
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getIndicator(text: string): string | null {
|
||||
text = text.toLowerCase();
|
||||
const customIndicators = getCustomIndicators();
|
||||
|
||||
return (
|
||||
customIndicators[text] ||
|
||||
customIndicators[`_${text}`] ||
|
||||
indicatorsDefault.get(text) ||
|
||||
indicatorsDefault.get(`_${text}`) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function buildIndicatorRegex(): RegExp {
|
||||
const customIndicators = getCustomIndicators();
|
||||
const allIndicators = new Set<string>();
|
||||
|
||||
indicatorsDefault.forEach((_, key) => {
|
||||
allIndicators.add(key.replace(/^_/, "")); // remove underscore prefix for aliases
|
||||
});
|
||||
Object.keys(customIndicators).forEach(key => {
|
||||
allIndicators.add(key.replace(/^_/, "")); // remove underscore prefix for aliases
|
||||
});
|
||||
|
||||
// escape special regex characters and sort by length (longest first)
|
||||
const escaped = Array.from(allIndicators)
|
||||
.map(ind => ind.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.sort((a, b) => b.length - a.length); // longest first to avoid partial matches (should fix some edge cases)
|
||||
|
||||
const prefix = settings.store.prefix || "/";
|
||||
let escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
// if prefix is a markdown character, also match escaped version
|
||||
const isMarkdown = /[*_~`|]/.test(prefix);
|
||||
if (isMarkdown) {
|
||||
escapedPrefix = `(?:\\\\${escapedPrefix}|${escapedPrefix})`;
|
||||
}
|
||||
|
||||
// exclude forward slash from punctuation to prevent sed syntax conflicts (s/find/replace)
|
||||
const pattern = `(?:^|\\s)${escapedPrefix}(${escaped.join("|")})(?=\\s|$|[^\\s\\w/])`;
|
||||
return new RegExp(pattern, "giu"); // 'i' = case-insensitive, 'u' = unicode
|
||||
}
|
||||
|
||||
function splitTextWithIndicators(text: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
const regex = buildIndicatorRegex();
|
||||
const prefix = settings.store.prefix || "/";
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text))) {
|
||||
const indicator = match[1];
|
||||
const desc = getIndicator(indicator);
|
||||
|
||||
const fullMatch = match[0];
|
||||
const leadingWhitespace = fullMatch.match(/^(\s*)/)?.[1] ?? "";
|
||||
|
||||
const matchStart = match.index;
|
||||
const matchEnd = regex.lastIndex;
|
||||
|
||||
if (matchStart > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, matchStart));
|
||||
}
|
||||
|
||||
if (desc) {
|
||||
if (leadingWhitespace) nodes.push(leadingWhitespace);
|
||||
nodes.push(
|
||||
<ToneIndicator
|
||||
key={`ti-${matchStart}`}
|
||||
prefix={prefix}
|
||||
indicator={indicator}
|
||||
desc={desc}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = matchEnd;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function patchChildrenTree(children: any): any {
|
||||
const transform = (node: any): any => {
|
||||
if (node == null) return node;
|
||||
|
||||
if (typeof node === "string") {
|
||||
const prefix = settings.store.prefix || "/";
|
||||
let escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
// if prefix is markdown character, also check for escaped version
|
||||
const isMarkdown = /[*_~`|]/.test(prefix);
|
||||
if (isMarkdown) {
|
||||
escapedPrefix = `(?:\\\\${escapedPrefix}|${escapedPrefix})`;
|
||||
}
|
||||
|
||||
if (!new RegExp(`${escapedPrefix}[\\p{L}_]+`, "iu").test(node)) return node;
|
||||
const parts = splitTextWithIndicators(node);
|
||||
return parts.length === 1 ? parts[0] : parts;
|
||||
}
|
||||
|
||||
if (node?.props?.children != null) {
|
||||
const c = node.props.children;
|
||||
if (Array.isArray(c)) {
|
||||
node.props.children = c.map(transform).flat();
|
||||
} else {
|
||||
node.props.children = transform(c);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
if (Array.isArray(children)) return children.map(transform).flat();
|
||||
return transform(children);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "ToneIndicators",
|
||||
description: "Show tooltips for tone indicators like /srs, /gen, etc. in sent messages.",
|
||||
authors: [EquicordDevs.justjxke],
|
||||
settings,
|
||||
|
||||
patches: [
|
||||
{
|
||||
find: '["strong","em","u","text","inlineCode","s","spoiler"]',
|
||||
replacement: [
|
||||
{
|
||||
match: /(?=return\{hasSpoilerEmbeds:\i,.{0,15}content:(\i))/,
|
||||
replace: "$1=$self.patchToneIndicators($1);",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
patchToneIndicators(content: any): any {
|
||||
try {
|
||||
return patchChildrenTree(content);
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
},
|
||||
});
|
||||
62
src/equicordplugins/toneIndicators/indicators.ts
Normal file
62
src/equicordplugins/toneIndicators/indicators.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
const defaultIndicators = new Map(
|
||||
Object.entries({
|
||||
j: "Joking",
|
||||
hj: "Half Joking",
|
||||
s: "Sarcastic",
|
||||
_sarc: "Sarcastic", // Alias for s
|
||||
gen: "Genuine",
|
||||
_g: "Genuine", // Alias for gen,
|
||||
genq: "Genuine Question",
|
||||
_gq: "Genuine Question", // Alias for genq
|
||||
srs: "Serious",
|
||||
nsrs: "Non-Serious",
|
||||
pos: "Positive Connotation",
|
||||
_pc: "Positive Connotation", // Alias for pos
|
||||
neu: "Neutral Connotation",
|
||||
neg: "Negative Connotation",
|
||||
_nc: "Negative Connotation", // Alias for neg
|
||||
p: "Platonic",
|
||||
r: "Romantic",
|
||||
a: "Alterous",
|
||||
c: "Copypasta",
|
||||
l: "Lyrics",
|
||||
_ly: "Lyrics", // Alias for l
|
||||
lh: "Light-Hearted",
|
||||
nm: "Not Mad",
|
||||
lu: "A Little Upset",
|
||||
nbh: "Not Directed At Anybody Here",
|
||||
nay: "Not Directed At You",
|
||||
ay: "Directed At You",
|
||||
nsb: "Not Subtweeting",
|
||||
x: "Sexual Intent",
|
||||
_sx: "Sexual Intent", // Alias for x
|
||||
nx: "Non-Sexual Intent",
|
||||
_nsx: "Non-Sexual Intent", // Alias for nx
|
||||
rh: "Rhetorical Question",
|
||||
_rt: "Rhetorical Question", // Alias for rh
|
||||
t: "Teasing",
|
||||
ij: "Inside Joke",
|
||||
m: "Metaphorically",
|
||||
li: "Literally",
|
||||
hyp: "Hyperbole",
|
||||
ex: "Exaggeration",
|
||||
f: "Fake",
|
||||
q: "Quote",
|
||||
th: "Threat",
|
||||
cb: "Clickbait",
|
||||
ref: "Reference",
|
||||
nf: "Not Forced",
|
||||
nbr: "Not Being Rude",
|
||||
ot: "Off-Topic",
|
||||
nj: "Not Joking",
|
||||
naj: "Not A Joke",
|
||||
}),
|
||||
);
|
||||
|
||||
export default defaultIndicators;
|
||||
68
src/equicordplugins/translatePlus/index.tsx
Normal file
68
src/equicordplugins/translatePlus/index.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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 "./style.css";
|
||||
|
||||
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { ChannelStore, Menu } from "@webpack/common";
|
||||
|
||||
import { settings } from "./settings";
|
||||
import { Accessory, handleTranslate } from "./utils/accessory";
|
||||
import { Icon } from "./utils/icon";
|
||||
|
||||
const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }) => {
|
||||
if (!message.content) return;
|
||||
|
||||
const group = findGroupChildrenByChildId("copy-text", children);
|
||||
if (!group) return;
|
||||
|
||||
group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, (
|
||||
<Menu.MenuItem
|
||||
id="ec-trans"
|
||||
label="Translate"
|
||||
icon={Icon}
|
||||
action={() => handleTranslate(message)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "Translate+",
|
||||
description: "Vencord's translate plugin but with support for artistic languages!",
|
||||
authors: [Devs.Ven, EquicordDevs.Prince527],
|
||||
settings,
|
||||
contextMenus: {
|
||||
"message": messageCtxPatch
|
||||
},
|
||||
renderMessageAccessory: props => <Accessory message={props.message} />,
|
||||
messagePopoverButton: {
|
||||
icon: Icon,
|
||||
render(message) {
|
||||
if (!message.content) return null;
|
||||
return {
|
||||
label: "Translate",
|
||||
icon: Icon,
|
||||
message: message,
|
||||
channel: ChannelStore.getChannel(message.channel_id),
|
||||
onClick: () => handleTranslate(message),
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
173
src/equicordplugins/translatePlus/misc/languages.ts
Normal file
173
src/equicordplugins/translatePlus/misc/languages.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/*
|
||||
To generate:
|
||||
- Visit https://translate.google.com/?sl=auto&tl=en&op=translate
|
||||
- Open Language dropdown
|
||||
- Open Devtools and use the element picker to pick the root of the language picker
|
||||
- Right click on the element in devtools and click "Store as global variable"
|
||||
|
||||
copy(Object.fromEntries(
|
||||
Array.from(
|
||||
temp1.querySelectorAll("[data-language-code]"),
|
||||
e => [e.dataset.languageCode, e.children[1].textContent]
|
||||
).sort((a, b) => a[1] === "Detect language" ? -1 : b[1] === "Detect language" ? 1 : a[1].localeCompare(b[1]))
|
||||
))
|
||||
*/
|
||||
|
||||
export type languages = keyof typeof languages;
|
||||
|
||||
export const languages = {
|
||||
"auto": "Detect language",
|
||||
"af": "Afrikaans",
|
||||
"sq": "Albanian",
|
||||
"am": "Amharic",
|
||||
"ar": "Arabic",
|
||||
"hy": "Armenian",
|
||||
"as": "Assamese",
|
||||
"ay": "Aymara",
|
||||
"az": "Azerbaijani",
|
||||
"bm": "Bambara",
|
||||
"eu": "Basque",
|
||||
"be": "Belarusian",
|
||||
"bn": "Bengali",
|
||||
"bho": "Bhojpuri",
|
||||
"bs": "Bosnian",
|
||||
"bg": "Bulgarian",
|
||||
"ca": "Catalan",
|
||||
"ceb": "Cebuano",
|
||||
"ny": "Chichewa",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"zh-TW": "Chinese (Traditional)",
|
||||
"co": "Corsican",
|
||||
"hr": "Croatian",
|
||||
"cs": "Czech",
|
||||
"da": "Danish",
|
||||
"dv": "Dhivehi",
|
||||
"doi": "Dogri",
|
||||
"nl": "Dutch",
|
||||
"en": "English",
|
||||
"eo": "Esperanto",
|
||||
"et": "Estonian",
|
||||
"ee": "Ewe",
|
||||
"tl": "Filipino",
|
||||
"tp": "Toki Pona",
|
||||
"sh": "Shavian",
|
||||
"fi": "Finnish",
|
||||
"fr": "French",
|
||||
"fy": "Frisian",
|
||||
"gl": "Galician",
|
||||
"ka": "Georgian",
|
||||
"de": "German",
|
||||
"el": "Greek",
|
||||
"gn": "Guarani",
|
||||
"gu": "Gujarati",
|
||||
"ht": "Haitian Creole",
|
||||
"ha": "Hausa",
|
||||
"haw": "Hawaiian",
|
||||
"iw": "Hebrew",
|
||||
"hi": "Hindi",
|
||||
"hmn": "Hmong",
|
||||
"hu": "Hungarian",
|
||||
"is": "Icelandic",
|
||||
"ig": "Igbo",
|
||||
"ilo": "Ilocano",
|
||||
"id": "Indonesian",
|
||||
"ga": "Irish",
|
||||
"it": "Italian",
|
||||
"ja": "Japanese",
|
||||
"jw": "Javanese",
|
||||
"kn": "Kannada",
|
||||
"kk": "Kazakh",
|
||||
"km": "Khmer",
|
||||
"rw": "Kinyarwanda",
|
||||
"gom": "Konkani",
|
||||
"ko": "Korean",
|
||||
"kri": "Krio",
|
||||
"ku": "Kurdish (Kurmanji)",
|
||||
"ckb": "Kurdish (Sorani)",
|
||||
"ky": "Kyrgyz",
|
||||
"lo": "Lao",
|
||||
"la": "Latin",
|
||||
"lv": "Latvian",
|
||||
"ln": "Lingala",
|
||||
"lt": "Lithuanian",
|
||||
"lg": "Luganda",
|
||||
"lb": "Luxembourgish",
|
||||
"mk": "Macedonian",
|
||||
"mai": "Maithili",
|
||||
"mg": "Malagasy",
|
||||
"ms": "Malay",
|
||||
"ml": "Malayalam",
|
||||
"mt": "Maltese",
|
||||
"mi": "Maori",
|
||||
"mr": "Marathi",
|
||||
"mni-Mtei": "Meiteilon (Manipuri)",
|
||||
"lus": "Mizo",
|
||||
"mn": "Mongolian",
|
||||
"my": "Myanmar (Burmese)",
|
||||
"ne": "Nepali",
|
||||
"no": "Norwegian",
|
||||
"or": "Odia (Oriya)",
|
||||
"om": "Oromo",
|
||||
"ps": "Pashto",
|
||||
"fa": "Persian",
|
||||
"pl": "Polish",
|
||||
"pt": "Portuguese",
|
||||
"pa": "Punjabi",
|
||||
"qu": "Quechua",
|
||||
"ro": "Romanian",
|
||||
"ru": "Russian",
|
||||
"sm": "Samoan",
|
||||
"sa": "Sanskrit",
|
||||
"gd": "Scots Gaelic",
|
||||
"nso": "Sepedi",
|
||||
"sr": "Serbian",
|
||||
"st": "Sesotho",
|
||||
"sn": "Shona",
|
||||
"sd": "Sindhi",
|
||||
"si": "Sinhala",
|
||||
"sk": "Slovak",
|
||||
"sl": "Slovenian",
|
||||
"so": "Somali",
|
||||
"es": "Spanish",
|
||||
"su": "Sundanese",
|
||||
"sw": "Swahili",
|
||||
"sv": "Swedish",
|
||||
"tg": "Tajik",
|
||||
"ta": "Tamil",
|
||||
"tt": "Tatar",
|
||||
"te": "Telugu",
|
||||
"th": "Thai",
|
||||
"ti": "Tigrinya",
|
||||
"ts": "Tsonga",
|
||||
"tr": "Turkish",
|
||||
"tk": "Turkmen",
|
||||
"ak": "Twi",
|
||||
"uk": "Ukrainian",
|
||||
"ur": "Urdu",
|
||||
"ug": "Uyghur",
|
||||
"uz": "Uzbek",
|
||||
"vi": "Vietnamese",
|
||||
"cy": "Welsh",
|
||||
"xh": "Xhosa",
|
||||
"yi": "Yiddish",
|
||||
"yo": "Yoruba",
|
||||
"zu": "Zulu"
|
||||
} as const;
|
||||
19
src/equicordplugins/translatePlus/misc/types.ts
Normal file
19
src/equicordplugins/translatePlus/misc/types.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { classNameFactory } from "@utils/css";
|
||||
|
||||
export const cl = classNameFactory("eq-trans-");
|
||||
|
||||
export interface Translation {
|
||||
text: string;
|
||||
src: string;
|
||||
}
|
||||
|
||||
export type IconProps = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
35
src/equicordplugins/translatePlus/settings.ts
Normal file
35
src/equicordplugins/translatePlus/settings.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { OptionType } from "@utils/types";
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
target: {
|
||||
type: OptionType.STRING,
|
||||
description: "Target language",
|
||||
default: "en",
|
||||
restartNeeded: true
|
||||
},
|
||||
toki: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Enable Toki Pona",
|
||||
default: true,
|
||||
restartNeeded: true
|
||||
},
|
||||
sitelen: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Enable Sitelen Pona",
|
||||
default: true,
|
||||
restartNeeded: true
|
||||
},
|
||||
shavian: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Enable Shavian",
|
||||
default: true,
|
||||
restartNeeded: true
|
||||
}
|
||||
});
|
||||
22
src/equicordplugins/translatePlus/style.css
Normal file
22
src/equicordplugins/translatePlus/style.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.eq-trans-accessory {
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.5em;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.eq-trans-accessory svg {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.eq-trans-dismiss {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: var(--text-link);
|
||||
}
|
||||
|
||||
.eq-trans-dismiss:is(:hover, :focus) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
42
src/equicordplugins/translatePlus/utils/accessory.tsx
Normal file
42
src/equicordplugins/translatePlus/utils/accessory.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { languages } from "@equicordplugins/translatePlus/misc/languages";
|
||||
import { cl, Translation } from "@equicordplugins/translatePlus/misc/types";
|
||||
import { Message } from "@vencord/discord-types";
|
||||
import { Parser, useEffect, useState } from "@webpack/common";
|
||||
|
||||
import { SmallIcon } from "./icon";
|
||||
import { translate } from "./translator";
|
||||
|
||||
const setters = new Map();
|
||||
|
||||
export function Accessory({ message }: { message: Message; }) {
|
||||
const [translation, setTranslation] = useState<Translation | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if ((message as any).vencordEmbeddedBy) return;
|
||||
|
||||
setters.set(message.id, setTranslation);
|
||||
|
||||
return () => void setters.delete(message.id);
|
||||
}, []);
|
||||
|
||||
if (!translation) return null;
|
||||
|
||||
return (
|
||||
<div className={cl("accessory")}>
|
||||
<SmallIcon />
|
||||
{Parser.parse(translation.text)}
|
||||
{" "}
|
||||
(translated from {languages[translation.src] ?? translation.src} - <button onClick={() => setTranslation(undefined)} className={cl("dismiss")}>Dismiss</button>)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleTranslate(message: Message) {
|
||||
setters.get(message.id)!(await translate(message.content));
|
||||
}
|
||||
27
src/equicordplugins/translatePlus/utils/icon.tsx
Normal file
27
src/equicordplugins/translatePlus/utils/icon.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { cl } from "@equicordplugins/translatePlus/misc/types";
|
||||
|
||||
export function Icon() {
|
||||
return (
|
||||
<svg viewBox="0 96 960 960" height={24} width={24} className={cl("icon")}>
|
||||
<path fill="currentColor" d="m475 976 181-480h82l186 480h-87l-41-126H604l-47 126h-82Zm151-196h142l-70-194h-2l-70 194Zm-466 76-55-55 204-204q-38-44-67.5-88.5T190 416h87q17 33 37.5 62.5T361 539q45-47 75-97.5T487 336H40v-80h280v-80h80v80h280v80H567q-22 69-58.5 135.5T419 598l98 99-30 81-127-122-200 200Z" />
|
||||
<path fill="currentColor" d="m 830.17456,136.43701 c -11.54729,0 -20.84473,8.71252 -20.84473,19.53369 v 66.21826 h -66.21826 c -10.82122,0 -19.53369,9.29373 -19.53369,20.84107 0,11.54734 8.71247,20.84473 19.53369,20.84473 h 66.21826 v 66.21826 c 0,10.8212 9.29742,19.53369 20.84473,19.53369 11.54731,0 20.84106,-8.71249 20.84106,-19.53369 v -66.21826 h 66.21827 c 10.82124,0 19.53369,-9.29737 19.53369,-20.84473 0,-11.54736 -8.71245,-20.84107 -19.53369,-20.84107 H 851.01562 V 155.9707 c 0,-10.82117 -9.29377,-19.53369 -20.84106,-19.53369 z" />
|
||||
<rect fill="currentColor" width="0.42110577" height="2.1055288" x="848.52814" y="112.42313" ry="0.2105529" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SmallIcon() {
|
||||
return (
|
||||
<svg viewBox="0 96 960 960" height={16} width={16} className={cl("icon")}>
|
||||
<path fill="currentColor" d="m475 976 181-480h82l186 480h-87l-41-126H604l-47 126h-82Zm151-196h142l-70-194h-2l-70 194Zm-466 76-55-55 204-204q-38-44-67.5-88.5T190 416h87q17 33 37.5 62.5T361 539q45-47 75-97.5T487 336H40v-80h280v-80h80v80h280v80H567q-22 69-58.5 135.5T419 598l98 99-30 81-127-122-200 200Z" />
|
||||
<path fill="currentColor" d="m 830.17456,136.43701 c -11.54729,0 -20.84473,8.71252 -20.84473,19.53369 v 66.21826 h -66.21826 c -10.82122,0 -19.53369,9.29373 -19.53369,20.84107 0,11.54734 8.71247,20.84473 19.53369,20.84473 h 66.21826 v 66.21826 c 0,10.8212 9.29742,19.53369 20.84473,19.53369 11.54731,0 20.84106,-8.71249 20.84106,-19.53369 v -66.21826 h 66.21827 c 10.82124,0 19.53369,-9.29737 19.53369,-20.84473 0,-11.54736 -8.71245,-20.84107 -19.53369,-20.84107 H 851.01562 V 155.9707 c 0,-10.82117 -9.29377,-19.53369 -20.84106,-19.53369 z" />
|
||||
<rect fill="currentColor" width="0.42110577" height="2.1055288" x="848.52814" y="112.42313" ry="0.2105529" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
143
src/equicordplugins/translatePlus/utils/translator.ts
Normal file
143
src/equicordplugins/translatePlus/utils/translator.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { settings } from "@equicordplugins/translatePlus/settings";
|
||||
|
||||
function isTokiPona(text: string) {
|
||||
const dictionary = /\b(?:leko|weka|pan|lete|linja|lipu|suli|nimi|akesi|misikeke|selo|ike|sijelo|sona|lili|pimeja|ante|jo|loje|telo|walo|kijetesantakalu|kasi|waso|wile|utala|lukin|sina|lape|ma|pilin|jasima|la|olin|pipi|meso|lawa|pi|pakala|oko|tan|ken|jaki|unpa|esun|seme|sitelen|len|kule|soko|open|ala|tenpo|lon|sinpin|pini|kokosila|mama|musi|monsi|mewika|taso|ona|mun|kiwen|tomo|mute|mi|nena|palisa|meli|laso|wawa|ale|kipisi|kulupu|ilo|lupa|nanpa|en|mu|jelo|kili|tonsi|moku|ni|kama|pu|poki|monsuta|sin|lasina|poka|soweli|sewi|elena|epiku|moli|pona|lanpan|alasa|anu|kute|uta|luka|suno|sama|awen|namako|suwi|noka|seli|mije|sike|jan|pali|tawa|inli|nasa|mani|wan|insa|nijon|nasin|kalama|ijo|toki|anpa|kala|kepeken|ko|kon|pana|tu|supa|kin|usawi|yupekosi)\b/gm;
|
||||
|
||||
return (text.match(dictionary) || []).length >= text.split(/\s+/).length * 0.5;
|
||||
}
|
||||
|
||||
function isSitelen(text: string) {
|
||||
const dictionary = /(?:||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||)/gm;
|
||||
|
||||
return dictionary.test(text);
|
||||
}
|
||||
|
||||
function isShavian(text: string) {
|
||||
const shavianRegex = /[\u{10450}-\u{1047F}]+/u;
|
||||
|
||||
return shavianRegex.test(text);
|
||||
}
|
||||
|
||||
async function translateShavian(message: string) {
|
||||
const dictionary = await (await fetch("https://raw.githubusercontent.com/ForkPrince/TranslatePlus/322199d5fdb1a9506591c9f4a2826338b5d67e38/shavian.json")).json();
|
||||
|
||||
const punctuationMap = {
|
||||
'"': "\"",
|
||||
"«": "\"",
|
||||
"»": "\"",
|
||||
",": ",",
|
||||
"!": "!",
|
||||
"?": "?",
|
||||
".": ".",
|
||||
"(": "(",
|
||||
")": ")",
|
||||
"/": "/",
|
||||
";": ";",
|
||||
":": ":"
|
||||
};
|
||||
|
||||
let translated = "";
|
||||
const words = message.split(/\s+/);
|
||||
|
||||
for (let word of words) {
|
||||
let punctuationBefore = "", punctuationAfter = "";
|
||||
|
||||
if (word[0] in punctuationMap) {
|
||||
punctuationBefore = punctuationMap[word[0]];
|
||||
word = word.slice(1);
|
||||
}
|
||||
|
||||
if (word[word.length - 1] in punctuationMap) {
|
||||
punctuationAfter = punctuationMap[word[word.length - 1]];
|
||||
word = word.slice(0, -1);
|
||||
}
|
||||
|
||||
translated += punctuationBefore;
|
||||
|
||||
if (word in dictionary) translated += dictionary[word];
|
||||
else translated += word;
|
||||
|
||||
translated += punctuationAfter + " ";
|
||||
}
|
||||
|
||||
return translated.trim();
|
||||
}
|
||||
|
||||
async function translateSitelen(message: string) {
|
||||
message = Array.from(message).join(" ");
|
||||
|
||||
const dictionary = await (await fetch("https://raw.githubusercontent.com/ForkPrince/TranslatePlus/5ca152b134ea11433971f21b2ef8d556d4306717/sitelen-pona.json")).json();
|
||||
|
||||
const sorted = Object.keys(dictionary).sort((a, b) => b.length - a.length);
|
||||
|
||||
const pattern = new RegExp(`(${sorted.join("|")})`, "g");
|
||||
|
||||
const translate = message.replace(pattern, match => dictionary[match]);
|
||||
|
||||
return translate;
|
||||
}
|
||||
|
||||
async function google(target: string, text: string) {
|
||||
if (!text) return { src: "", text: "" };
|
||||
try {
|
||||
const res = await fetch(`https://translate.googleapis.com/translate_a/single?${new URLSearchParams({ client: "gtx", sl: "auto", tl: target, dt: "t", dj: "1", source: "input", q: text })}`);
|
||||
if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
|
||||
const translate = await res.json();
|
||||
|
||||
return {
|
||||
src: translate.src,
|
||||
text: translate.sentences?.map(s => s.trans).filter(Boolean).join("\n")
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[TranslatePlus] Google Translate request failed:", error);
|
||||
return { src: "en", text: "Translation failed due to an error." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function translate(text: string): Promise<any> {
|
||||
const { target, toki, sitelen, shavian } = settings.store;
|
||||
|
||||
const output = { src: "", text: "" };
|
||||
|
||||
if ((isTokiPona(text) || isSitelen(text)) && (toki || sitelen)) {
|
||||
if (isSitelen(text) && sitelen) text = await translateSitelen(text);
|
||||
|
||||
console.log(text);
|
||||
|
||||
const translate = await (await fetch("https://aiapi.serversmp.xyz/toki", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: text,
|
||||
src: "tl",
|
||||
target: "en"
|
||||
})
|
||||
})).json();
|
||||
|
||||
console.log(translate);
|
||||
|
||||
output.src = "tp";
|
||||
output.text = target === "en" ? translate.translation[0] : (await google(target, translate.translation[0])).text;
|
||||
} else if (isShavian(text) && shavian) {
|
||||
const translate = await translateShavian(text);
|
||||
|
||||
output.src = "sh";
|
||||
output.text = target === "en" ? translate : (await google(target, translate)).text;
|
||||
} else {
|
||||
const translate = await google(target, text);
|
||||
|
||||
output.src = translate.src;
|
||||
output.text = translate.text;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
66
src/equicordplugins/triviaAI/index.tsx
Normal file
66
src/equicordplugins/triviaAI/index.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { IconComponent } from "@utils/types";
|
||||
import { Message } from "@vencord/discord-types";
|
||||
import { findExportedComponentLazy } from "@webpack";
|
||||
import { ChannelStore, Menu } from "@webpack/common";
|
||||
|
||||
import { settings } from "./settings";
|
||||
import { getResponse, handleResponse, parseMessageContent } from "./utils";
|
||||
|
||||
const RobotIconLazy = findExportedComponentLazy("RobotIcon");
|
||||
const RobotIcon: IconComponent = props => <RobotIconLazy {...props} />;
|
||||
|
||||
const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }: { message: Message; }) => {
|
||||
const payload = parseMessageContent(message);
|
||||
if (!payload) return;
|
||||
|
||||
const group = findGroupChildrenByChildId("copy-text", children);
|
||||
if (!group) return;
|
||||
|
||||
group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, (
|
||||
<Menu.MenuItem
|
||||
id="vc-trivia-ai"
|
||||
label="Answer With AI"
|
||||
icon={RobotIcon}
|
||||
action={async () => {
|
||||
const ans = await getResponse(payload);
|
||||
handleResponse(message, ans);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "TriviaAI",
|
||||
description: "A plugin that helps you answer trivia questions using AI.",
|
||||
authors: [EquicordDevs.yash],
|
||||
settings,
|
||||
contextMenus: {
|
||||
"message": messageCtxPatch
|
||||
},
|
||||
messagePopoverButton: {
|
||||
icon: RobotIcon,
|
||||
render(message: Message) {
|
||||
const payload = parseMessageContent(message);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
label: "Answer With AI",
|
||||
icon: RobotIcon,
|
||||
message,
|
||||
channel: ChannelStore.getChannel(message.channel_id),
|
||||
onClick: async () => {
|
||||
const ans = await getResponse(payload);
|
||||
handleResponse(message, ans);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
53
src/equicordplugins/triviaAI/settings.ts
Normal file
53
src/equicordplugins/triviaAI/settings.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { OptionType } from "@utils/types";
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
apiKey: {
|
||||
type: OptionType.STRING,
|
||||
description: "API Key.",
|
||||
default: "",
|
||||
placeholder: "Enter API Key here for your AI endpoint.",
|
||||
componentProps: {
|
||||
type: "password"
|
||||
}
|
||||
},
|
||||
model: {
|
||||
type: OptionType.STRING,
|
||||
description: "AI Model to use.",
|
||||
default: "google/gemini-3-flash-preview",
|
||||
placeholder: "e.g. google/gemini-3-flash-preview, inception/mercury, openai/gpt-5.2-chat, etc."
|
||||
},
|
||||
systemPrompt: {
|
||||
type: OptionType.STRING,
|
||||
description: "System Prompt for the AI.",
|
||||
default: "You are a helpful assistant who answers questions for the user in a concise and short way while using the least amount of words and punctuation.",
|
||||
placeholder: "Enter system prompt."
|
||||
},
|
||||
maxTokens: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "Maximum number of tokens in the response.",
|
||||
default: 500
|
||||
},
|
||||
endpoint: {
|
||||
type: OptionType.STRING,
|
||||
description: "OpenAI Compatible AI Endpoint.",
|
||||
default: "https://openrouter.ai/api/v1/chat/completions",
|
||||
placeholder: "Enter your OpenAI compatible AI endpoint here."
|
||||
},
|
||||
autoRespond: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Automatically respond to messages on receiving a response.",
|
||||
default: false
|
||||
},
|
||||
supportImages: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Pass images to the AI for context (if any). This is not supported by all models.",
|
||||
default: true
|
||||
}
|
||||
});
|
||||
188
src/equicordplugins/triviaAI/utils.ts
Normal file
188
src/equicordplugins/triviaAI/utils.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { insertTextIntoChatInputBox, sendMessage } from "@utils/discord";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { Message } from "@vencord/discord-types";
|
||||
import { showToast, Toasts } from "@webpack/common";
|
||||
|
||||
import { settings } from "./settings";
|
||||
|
||||
const logger = new Logger("TriviaAI");
|
||||
|
||||
type TextPart = {
|
||||
type: "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ImagePart = {
|
||||
type: "image_url";
|
||||
image_url: {
|
||||
url: string;
|
||||
detail?: "auto" | "high" | "low";
|
||||
};
|
||||
};
|
||||
|
||||
export type ContentPayload = string | (TextPart | ImagePart)[];
|
||||
|
||||
export function parseMessageContent(message: Message): ContentPayload | null {
|
||||
const textParts: string[] = [];
|
||||
|
||||
if (message.content && message.content.trim().length > 0) {
|
||||
textParts.push(message.content);
|
||||
}
|
||||
|
||||
message.embeds.forEach(embed => {
|
||||
const embedBuffer: string[] = [];
|
||||
|
||||
const parts = [
|
||||
embed.provider?.name ? `> ${embed.provider.name}` : null,
|
||||
embed.author?.name ? `**${embed.author.name}**` : null,
|
||||
embed.rawTitle ? `## ${embed.rawTitle}` : null,
|
||||
embed.rawDescription ?? null,
|
||||
...(embed.fields?.map(f => (f.rawName && f.rawValue) ? `**${f.rawName}**: ${f.rawValue}` : null) ?? []),
|
||||
embed.footer?.text ? `_${embed.footer.text}_` : null,
|
||||
];
|
||||
|
||||
parts.forEach(p => {
|
||||
if (p) embedBuffer.push(p);
|
||||
});
|
||||
|
||||
if (embedBuffer.length > 0) textParts.push(embedBuffer.join("\n"));
|
||||
});
|
||||
|
||||
const combinedText = textParts.join("\n\n");
|
||||
|
||||
if (!settings.store.supportImages) {
|
||||
return combinedText || null;
|
||||
}
|
||||
|
||||
const imageUrls = new Set<string>();
|
||||
|
||||
message.attachments
|
||||
.filter(att => att.content_type?.startsWith("image/"))
|
||||
.forEach(att => imageUrls.add(att.url));
|
||||
|
||||
message.embeds.forEach(embed => {
|
||||
const potentialUrls = [
|
||||
embed.image?.url,
|
||||
embed.thumbnail?.url,
|
||||
...(embed.images?.map(img => img.url) ?? [])
|
||||
];
|
||||
|
||||
potentialUrls.forEach(url => {
|
||||
if (url) imageUrls.add(url);
|
||||
});
|
||||
});
|
||||
|
||||
if (imageUrls.size === 0) {
|
||||
return combinedText || null;
|
||||
}
|
||||
|
||||
const payload: (TextPart | ImagePart)[] = [];
|
||||
|
||||
if (combinedText.length > 0) {
|
||||
payload.push({
|
||||
type: "text",
|
||||
text: combinedText
|
||||
});
|
||||
}
|
||||
|
||||
imageUrls.forEach(url => {
|
||||
payload.push({
|
||||
type: "image_url",
|
||||
image_url: { url }
|
||||
});
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function handleResponse(message: Message, response: string): Promise<string> {
|
||||
if (settings.store.autoRespond) {
|
||||
sendMessage(
|
||||
message.channel_id,
|
||||
{ content: response },
|
||||
true,
|
||||
{ messageReference: { channel_id: message.channel_id, message_id: message.id } }
|
||||
);
|
||||
return response;
|
||||
} else {
|
||||
insertTextIntoChatInputBox(response);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResponse(payload: ContentPayload): Promise<string> {
|
||||
if (!settings.store.apiKey) {
|
||||
showToast("TriviaAI: API Key is not set.", Toasts.Type.FAILURE);
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!settings.store.endpoint) {
|
||||
showToast("TriviaAI: Endpoint is not set.", Toasts.Type.FAILURE);
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!settings.store.model) {
|
||||
showToast("TriviaAI: Model is not set.", Toasts.Type.FAILURE);
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const req = await fetch(settings.store.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${settings.store.apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: settings.store.model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: settings.store.systemPrompt
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: payload
|
||||
}
|
||||
],
|
||||
max_tokens: settings.store.maxTokens,
|
||||
})
|
||||
});
|
||||
|
||||
if (!req.ok) {
|
||||
const errorBody = await req.text();
|
||||
const errorMessage = `API request failed with status ${req.status}: ${errorBody}`;
|
||||
logger.error(errorMessage);
|
||||
showToast(errorMessage, Toasts.Type.FAILURE);
|
||||
return "";
|
||||
}
|
||||
|
||||
const data = await req.json();
|
||||
|
||||
if (data.error) {
|
||||
const errorMessage = data.error.message ?? "An unknown error occurred";
|
||||
logger.error(`API Error: ${errorMessage}`);
|
||||
showToast(errorMessage, Toasts.Type.FAILURE);
|
||||
return "";
|
||||
}
|
||||
|
||||
const response = data.choices[0].message.content;
|
||||
|
||||
if (!response?.trim()) {
|
||||
logger.warn("no response from AI model");
|
||||
return "";
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error("Error getting response from AI model", e);
|
||||
showToast("Error getting response from AI model", Toasts.Type.FAILURE);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
80
src/equicordplugins/unreadBadgeCount/index.tsx
Normal file
80
src/equicordplugins/unreadBadgeCount/index.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Channel } from "@vencord/discord-types";
|
||||
import { findComponentByCodeLazy, findStoreLazy } from "@webpack";
|
||||
import { ReadStateStore, useStateFromStores } from "@webpack/common";
|
||||
|
||||
const UserGuildSettingsStore = findStoreLazy("UserGuildSettingsStore");
|
||||
const JoinedThreadsStore = findStoreLazy("JoinedThreadsStore");
|
||||
const NumberBadge = findComponentByCodeLazy("BADGE_NOTIFICATION_BACKGROUND", "let{count:");
|
||||
|
||||
const settings = definePluginSettings({
|
||||
showOnMutedChannels: {
|
||||
description: "Show unread count on muted channels",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
notificationCountLimit: {
|
||||
description: "Show +99 instead of true amount",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "UnreadCountBadge",
|
||||
authors: [Devs.Joona],
|
||||
description: "Shows unread message count badges on channels in the channel list",
|
||||
settings,
|
||||
|
||||
patches: [
|
||||
// Kanged from typingindicators
|
||||
{
|
||||
find: "UNREAD_IMPORTANT:",
|
||||
replacement: [
|
||||
{
|
||||
match: /\.Children\.count.+?:null(?<=,channel:(\i).+?)/,
|
||||
replace: "$&,$self.CountBadge({channel: arguments[0].channel})",
|
||||
},
|
||||
]
|
||||
},
|
||||
// Threads
|
||||
{
|
||||
// This is the thread "spine" that shows in the left
|
||||
find: "M0 15H2c0 1.6569",
|
||||
replacement: [
|
||||
{
|
||||
match: /mentionsCount:\i.{0,50}?null/,
|
||||
replace: "$&,$self.CountBadge({channel: arguments[0].thread})",
|
||||
},
|
||||
]
|
||||
},
|
||||
],
|
||||
|
||||
CountBadge: ErrorBoundary.wrap(({ channel }: { channel: Channel; }) => {
|
||||
const unreadCount = useStateFromStores([ReadStateStore], () => ReadStateStore.getUnreadCount(channel.id));
|
||||
if (!unreadCount) return null;
|
||||
|
||||
if (!settings.store.showOnMutedChannels && (UserGuildSettingsStore.isChannelMuted(channel.guild_id, channel.id) || JoinedThreadsStore.isMuted(channel.id)))
|
||||
return null;
|
||||
|
||||
return (
|
||||
<NumberBadge
|
||||
color="var(--brand-500)"
|
||||
count={
|
||||
unreadCount > 99 && settings.store.notificationCountLimit
|
||||
? "+99"
|
||||
: unreadCount
|
||||
}
|
||||
/>
|
||||
);
|
||||
}, { noop: true }),
|
||||
});
|
||||
39
src/equicordplugins/unreadBadgeCount/styles.css
Normal file
39
src/equicordplugins/unreadBadgeCount/styles.css
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
.vc-unreadCountBadge {
|
||||
box-sizing: border-box;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
min-width: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
background-color: var(--brand-500);
|
||||
color: var(--white);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-display);
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
line-height: 1.33;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.vc-unreadCountBadge-dot {
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
margin-left: -3%;
|
||||
scale: 0.9;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vc-unreadCountBadge-dot-thread {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
margin-left: -18%;
|
||||
scale: 0.9;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
[class*="modeMuted"] .unreadCountBadge {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
.vc-voice-channel-log {
|
||||
list-style-type: none;
|
||||
height: 50px;
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem 24px 40px max-content;
|
||||
gap: 4px;
|
||||
width: 4px;
|
||||
background-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.vc-voice-channel-log-date-separator,
|
||||
.vc-voice-channel-log-timestamp {
|
||||
margin-left: 4px;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
font-size: .75rem;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
color: var(--text-default, white);
|
||||
}
|
||||
|
||||
.vc-voice-channel-log-avatar {
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-voice-channel-log-icon {
|
||||
margin: auto 3px;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./VoiceChannelLogEntryComponent.css";
|
||||
|
||||
import { VoiceChannelLogEntry } from "@equicordplugins/voiceChannelLog.dev/logs";
|
||||
import { classes } from "@utils/misc";
|
||||
import { Channel } from "@vencord/discord-types";
|
||||
import { React, Timestamp, Tooltip, UserStore } from "@webpack/common";
|
||||
import { Util } from "Vencord";
|
||||
|
||||
import { cl } from "..";
|
||||
import Icon from "./VoiceChannelLogEntryIcons";
|
||||
|
||||
export function VoiceChannelLogEntryComponent({ logEntry, channel }: { logEntry: VoiceChannelLogEntry; channel: Channel; }) {
|
||||
const user = UserStore.getUser(logEntry.userId);
|
||||
return <li className="vc-voice-channel-log">
|
||||
<Timestamp className={cl("timestamp")} timestamp={new Date(logEntry.timestamp)} compact isInline={false} cozyAlt></Timestamp>
|
||||
<Icon logEntry={logEntry} channel={channel} className={cl("icon")} />
|
||||
<Tooltip text={logEntry.username}>
|
||||
{(tooltipProps: any) => (
|
||||
<img
|
||||
{...tooltipProps}
|
||||
className={classes(cl("avatar"))}
|
||||
onClick={() => Util.openUserProfile(logEntry.userId)}
|
||||
src={user.getAvatarURL(channel.getGuildId())}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
<div className={cl("content")}>
|
||||
{ }
|
||||
</div>
|
||||
</li>;
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { VoiceChannelLogEntry } from "@equicordplugins/voiceChannelLog.dev/logs";
|
||||
import { classes } from "@utils/misc";
|
||||
import { Channel } from "@vencord/discord-types";
|
||||
import { React } from "@webpack/common";
|
||||
|
||||
import { cl } from "..";
|
||||
|
||||
export default function Icon({ logEntry, channel, className }: { logEntry: VoiceChannelLogEntry; channel: Channel; className: string; }) {
|
||||
// Taken from /assets/7378a83d74ce97d83380.svg
|
||||
const Join = <svg xmlns="http://www.w3.org/2000/svg" height="18" width="18"><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m0 8h14.2l-3.6-3.6 1.4-1.4 6 6-6 6-1.4-1.4 3.6-3.6h-14.2" fill="#3ba55c" /></g></svg>;
|
||||
// Taken from /assets/192510ade1abc3149b46.svg
|
||||
const Leave = <svg xmlns="http://www.w3.org/2000/svg" height="18" width="18" ><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m3.8 8 3.6-3.6-1.4-1.4-6 6 6 6 1.4-1.4-3.6-3.6h14.2v-2" fill="#ed4245" /></g></svg>;
|
||||
// For other contributors, please DO make specific designs for these instead of how I just copied the join/leave icons and making them orange
|
||||
const MovedTo = <svg xmlns="http://www.w3.org/2000/svg" height="18" width="18"><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m3.8 8 3.6-3.6-1.4-1.4-6 6 6 6 1.4-1.4-3.6-3.6h14.2v-2" fill="#faa61a" /></g></svg>;
|
||||
const MovedFrom = <svg xmlns="http://www.w3.org/2000/svg" height="18" width="18"><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m0 8h14.2l-3.6-3.6 1.4-1.4 6 6-6 6-1.4-1.4 3.6-3.6h-14.2" fill="#faa61a" /></g></svg>;
|
||||
|
||||
if (logEntry.newChannel && !logEntry.oldChannel) return React.cloneElement(Join, { className: classes(className, cl("join")) });
|
||||
if (!logEntry.newChannel && logEntry.oldChannel) return React.cloneElement(Leave, { className: classes(className, cl("leave")) });
|
||||
if (logEntry.newChannel === channel.id && logEntry.oldChannel) return React.cloneElement(MovedFrom, { className: classes(className, cl("moved-from")) });
|
||||
if (logEntry.newChannel && logEntry.oldChannel === channel.id) return React.cloneElement(MovedTo, { className: classes(className, cl("moved-to")) });
|
||||
// we should never get here, this is just here to shut up the type checker
|
||||
return <svg></svg>;
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { getVcLogs, vcLogSubscribe } from "@equicordplugins/voiceChannelLog.dev/logs";
|
||||
import { classes } from "@utils/misc";
|
||||
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import { Channel } from "@vencord/discord-types";
|
||||
import { findStoreLazy } from "@webpack";
|
||||
import { React, ScrollerThin } from "@webpack/common";
|
||||
|
||||
import { cl } from "..";
|
||||
import { VoiceChannelLogEntryComponent } from "./VoiceChannelLogEntryComponent";
|
||||
|
||||
const AccessibilityStore = findStoreLazy("AccessibilityStore");
|
||||
|
||||
export function openVoiceChannelLog(channel: Channel) {
|
||||
return openModal(props => (
|
||||
<VoiceChannelLogModal
|
||||
props={props}
|
||||
channel={channel}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
export function VoiceChannelLogModal({ channel, props }: { channel: Channel; props: ModalProps; }) {
|
||||
React.useSyncExternalStore(vcLogSubscribe, () => getVcLogs(channel.id));
|
||||
const vcLogs = getVcLogs(channel.id);
|
||||
const logElements: (React.ReactNode)[] = [];
|
||||
|
||||
if (vcLogs.length > 0) {
|
||||
for (let i = 0; i < vcLogs.length; i++) {
|
||||
const logEntry = vcLogs[i];
|
||||
if (i === 0 || logEntry.timestamp.toDateString() !== vcLogs[i - 1].timestamp.toDateString()) {
|
||||
logElements.push(<div className={classes(cl("date-separator"))} role="separator" aria-label={logEntry.timestamp.toDateString()}>
|
||||
<span>
|
||||
{logEntry.timestamp.toDateString()}
|
||||
</span>
|
||||
</div>);
|
||||
} else {
|
||||
logElements.push(<VoiceChannelLogEntryComponent logEntry={logEntry} channel={channel} />);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logElements.push(<div className={cl("empty")}>No logs to display.</div>);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalRoot
|
||||
{...props}
|
||||
size={ModalSize.LARGE}
|
||||
>
|
||||
<ModalHeader>
|
||||
<BaseText size="lg" weight="semibold" className={cl("header")} style={{ flexGrow: 1 }}>{channel.name} logs</BaseText>
|
||||
<ModalCloseButton onClick={props.onClose} />
|
||||
</ModalHeader>
|
||||
|
||||
<ModalContent>
|
||||
<ScrollerThin fade className={classes(cl("scroller"), `group-spacing-${AccessibilityStore.messageGroupSpacing}`)}>
|
||||
{logElements}
|
||||
</ScrollerThin>
|
||||
</ModalContent>
|
||||
</ModalRoot >
|
||||
);
|
||||
}
|
||||
164
src/equicordplugins/voiceChannelLog.dev/index.tsx
Normal file
164
src/equicordplugins/voiceChannelLog.dev/index.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Message, User } from "@vencord/discord-types";
|
||||
import { findByCodeLazy } from "@webpack";
|
||||
import { FluxDispatcher, Menu, MessageActions, MessageStore, RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common";
|
||||
|
||||
import { openVoiceChannelLog } from "./components/VoiceChannelLogModal";
|
||||
import { addLogEntry } from "./logs";
|
||||
|
||||
export const cl = classNameFactory("vc-voice-channel-log-");
|
||||
const createBotMessage = findByCodeLazy('username:"Clyde"');
|
||||
|
||||
const settings = definePluginSettings({
|
||||
mode: {
|
||||
type: OptionType.SELECT,
|
||||
description: "How to show the voice channel log",
|
||||
options: [
|
||||
{ label: "Log menu", value: 1, default: true },
|
||||
{ label: "Log to associated chat directly", value: 2 },
|
||||
{ label: "Log to chat and log menu", value: 3 },
|
||||
]
|
||||
},
|
||||
voiceChannelChatSelf: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Log your own voice channel events in the voice channels",
|
||||
default: true
|
||||
},
|
||||
voiceChannelChatSilent: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Join/leave/move messages in voice channel chats will be silent",
|
||||
default: true
|
||||
},
|
||||
voiceChannelChatSilentSelf: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Join/leave/move messages in voice channel chats will be silent if you are in the voice channel",
|
||||
default: false
|
||||
},
|
||||
ignoreBlockedUsers: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Do not log blocked users",
|
||||
default: false
|
||||
},
|
||||
});
|
||||
|
||||
interface VoiceState {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
oldChannelId?: string;
|
||||
user: User;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
function getMessageFlags(selfInChannel: boolean) {
|
||||
let flags = 1 << 6;
|
||||
if (selfInChannel ? settings.store.voiceChannelChatSilentSelf : settings.store.voiceChannelChatSilent) flags += 1 << 12;
|
||||
return flags;
|
||||
}
|
||||
|
||||
function sendVoiceStatusMessage(channelId: string, content: string, userId: string, selfInChannel: boolean): Message | null {
|
||||
if (!channelId) return null;
|
||||
|
||||
const message: Message = createBotMessage({ channelId, content, embeds: [] });
|
||||
message.flags = getMessageFlags(selfInChannel);
|
||||
message.author = UserStore.getUser(userId);
|
||||
// If we try to send a message into an unloaded channel, the client-sided messages get overwritten when the channel gets loaded
|
||||
// This might be messy but It Works:tm:
|
||||
const messagesLoaded: Promise<any> = MessageStore.hasPresent(channelId) ? new Promise<void>(r => r()) : MessageActions.fetchMessages({ channelId });
|
||||
messagesLoaded.then(() => {
|
||||
FluxDispatcher.dispatch({
|
||||
type: "MESSAGE_CREATE",
|
||||
channelId,
|
||||
message,
|
||||
optimistic: true,
|
||||
sendMessageOptions: {},
|
||||
isPushNotification: false
|
||||
});
|
||||
});
|
||||
return message;
|
||||
}
|
||||
|
||||
const patchChannelContextMenu: NavContextMenuPatchCallback = (children, { channel }) => {
|
||||
const group = findGroupChildrenByChildId("mark-channel-read", children) ?? children;
|
||||
group.push(
|
||||
<Menu.MenuItem
|
||||
id="vc-view-voice-channel-logs"
|
||||
label="View Channel Logs"
|
||||
action={() => { openVoiceChannelLog(channel); }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Blatantly stolen from VcNarrator plugin
|
||||
|
||||
// For every user, channelId and oldChannelId will differ when moving channel.
|
||||
// Only for the local user, channelId and oldChannelId will be the same when moving channel,
|
||||
// for some ungodly reason
|
||||
let clientOldChannelId: string | undefined;
|
||||
|
||||
export default definePlugin({
|
||||
name: "VoiceChannelLog",
|
||||
description: "Logs who joins and leaves voice channels",
|
||||
authors: [Devs.Sqaaakoi, Devs.thororen, EquicordDevs.nyx],
|
||||
contextMenus: {
|
||||
"channel-context": patchChannelContextMenu
|
||||
},
|
||||
settings,
|
||||
flux: {
|
||||
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
|
||||
const clientUserId = UserStore.getCurrentUser().id;
|
||||
voiceStates.forEach(state => {
|
||||
// mmmm hacky workaround
|
||||
const { userId, channelId } = state;
|
||||
const user = UserStore.getUser(userId) as User & { globalName: string; };
|
||||
const username = user.globalName || user.username;
|
||||
let { oldChannelId } = state;
|
||||
if (userId === clientUserId && channelId !== clientOldChannelId) {
|
||||
oldChannelId = clientOldChannelId;
|
||||
clientOldChannelId = channelId;
|
||||
}
|
||||
if (settings.store.ignoreBlockedUsers && RelationshipStore.isBlocked(userId)) return;
|
||||
// Ignore events from same channel
|
||||
if (oldChannelId === channelId) return;
|
||||
|
||||
const logEntry = {
|
||||
userId,
|
||||
username,
|
||||
oldChannel: oldChannelId || null,
|
||||
newChannel: channelId || null,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
if (settings.store.mode !== 2) {
|
||||
addLogEntry(logEntry, oldChannelId);
|
||||
addLogEntry(logEntry, channelId);
|
||||
}
|
||||
|
||||
if (!settings.store.voiceChannelChatSelf && userId === clientUserId) return;
|
||||
if (settings.store.mode === 1) return;
|
||||
// Join / Leave
|
||||
if ((!oldChannelId && channelId) || (oldChannelId && !channelId)) {
|
||||
// empty string is to make type checker shut up
|
||||
const targetChannelId = oldChannelId || channelId || "";
|
||||
const selfInChannel = SelectedChannelStore.getVoiceChannelId() === targetChannelId;
|
||||
sendVoiceStatusMessage(targetChannelId, `${(channelId ? "Joined" : "Left")} <#${targetChannelId}>`, userId, selfInChannel);
|
||||
}
|
||||
// Move between channels
|
||||
if (oldChannelId && channelId) {
|
||||
sendVoiceStatusMessage(oldChannelId, `Moved to <#${channelId}>`, userId, SelectedChannelStore.getVoiceChannelId() === oldChannelId);
|
||||
sendVoiceStatusMessage(channelId, `Moved from <#${oldChannelId}>`, userId, SelectedChannelStore.getVoiceChannelId() === channelId);
|
||||
}
|
||||
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
41
src/equicordplugins/voiceChannelLog.dev/logs.ts
Normal file
41
src/equicordplugins/voiceChannelLog.dev/logs.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export interface VoiceChannelLogEntry {
|
||||
userId: string;
|
||||
username: string;
|
||||
oldChannel: string | null;
|
||||
newChannel: string | null;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export const vcLogs = new Map<string, VoiceChannelLogEntry[]>();
|
||||
let vcLogSubscriptions: (() => void)[] = [];
|
||||
|
||||
export function getVcLogs(channel?: string): VoiceChannelLogEntry[] {
|
||||
if (!channel) return [];
|
||||
if (!vcLogs.has(channel)) vcLogs.set(channel, []);
|
||||
return vcLogs.get(channel) || [];
|
||||
}
|
||||
|
||||
export function addLogEntry(logEntry: VoiceChannelLogEntry, channel?: string) {
|
||||
if (!channel) return;
|
||||
vcLogs.set(channel, [...getVcLogs(channel), logEntry]);
|
||||
vcLogSubscriptions.forEach(u => u());
|
||||
}
|
||||
|
||||
export function clearLogs(channel?: string) {
|
||||
if (!channel) return;
|
||||
vcLogs.set(channel, []);
|
||||
vcLogSubscriptions.forEach(u => u());
|
||||
}
|
||||
|
||||
export function vcLogSubscribe(listener: () => void) {
|
||||
vcLogSubscriptions = [...vcLogSubscriptions, listener];
|
||||
return () => {
|
||||
vcLogSubscriptions = vcLogSubscriptions.filter(l => l !== listener);
|
||||
};
|
||||
}
|
||||
199
src/equicordplugins/wigglyText/index.tsx
Normal file
199
src/equicordplugins/wigglyText/index.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { makeRange, OptionType } from "@utils/types";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import ExampleWiggle from "./ui/components/ExampleWiggle";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
intensity: {
|
||||
type: OptionType.SLIDER,
|
||||
description: "Animation intensity in px",
|
||||
markers: makeRange(1, 10, 1),
|
||||
default: 4,
|
||||
stickToMarkers: true,
|
||||
onChange: () => updateStyles()
|
||||
}
|
||||
});
|
||||
|
||||
const dirMap = {
|
||||
x: "0.6s wiggle-wavy-x alternate ease-in-out infinite",
|
||||
y: "1.2s wiggle-wavy-y linear infinite"
|
||||
};
|
||||
|
||||
const classMap = [
|
||||
{
|
||||
chars: ["<", ">"],
|
||||
className: "wiggle-inner-x",
|
||||
},
|
||||
{
|
||||
chars: ["^", "^"],
|
||||
className: "wiggle-inner-y",
|
||||
},
|
||||
{
|
||||
chars: [")", "("],
|
||||
className: "wiggle-inner-xy"
|
||||
}
|
||||
];
|
||||
|
||||
let styles: HTMLStyleElement;
|
||||
const updateStyles = () => {
|
||||
const inten = settings.store.intensity + "px";
|
||||
styles.textContent = `
|
||||
.wiggle-example {
|
||||
list-style-type: disc;
|
||||
list-style-position: outside;
|
||||
margin: 4px 0 0 16px;
|
||||
}
|
||||
|
||||
.wiggle-example li {
|
||||
white-space: break-spaces;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wiggle-inner {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
&.wiggle-inner-x {
|
||||
animation: ${dirMap.x};
|
||||
}
|
||||
|
||||
&.wiggle-inner-y {
|
||||
animation: ${dirMap.y};
|
||||
}
|
||||
|
||||
&.wiggle-inner-xy {
|
||||
animation: ${dirMap.x}, ${dirMap.y};
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wiggle-wavy-x {
|
||||
from {
|
||||
left: -${inten};
|
||||
}
|
||||
|
||||
to {
|
||||
left: ${inten};
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wiggle-wavy-y {
|
||||
0% {
|
||||
top: 0;
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
25% {
|
||||
top: -${inten};
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
|
||||
50% {
|
||||
top: 0;
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
75% {
|
||||
top: ${inten};
|
||||
animation-timing-function: ease-in;
|
||||
}
|
||||
}`;
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "WigglyText",
|
||||
description: "Adds a new markdown formatting that makes text wiggly.",
|
||||
authors: [EquicordDevs.nexpid],
|
||||
settings,
|
||||
settingsAboutComponent: () => (
|
||||
<BaseText>
|
||||
You can make text wiggle with the following:<br />
|
||||
<ul className="wiggle-example">
|
||||
<li><ExampleWiggle wiggle="x">left and right</ExampleWiggle> by typing <code><~text~></code></li>
|
||||
<li><ExampleWiggle wiggle="y">up and down</ExampleWiggle> by typing <code>^~text~^</code></li>
|
||||
<li><ExampleWiggle wiggle="xy">in a circle</ExampleWiggle> by typing <code>)~text~(</code></li>
|
||||
</ul>
|
||||
</BaseText>
|
||||
),
|
||||
|
||||
patches: [
|
||||
{
|
||||
find: "parseToAST:",
|
||||
replacement: {
|
||||
match: /(parse[\w]*):(.*?)\((\i)\),/g,
|
||||
replace: "$1:$2({...$3,wiggly:$self.wigglyRule}),",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
wigglyRule: {
|
||||
order: 24,
|
||||
match: (source: string) => classMap.map(({ chars }) => source.match(new RegExp(`^(\\${chars[0]})~([\\s\\S]+?)~(\\${chars[1]})(?!_)`))).find(x => x !== null),
|
||||
parse: (
|
||||
capture: RegExpMatchArray,
|
||||
transform: (...args: any[]) => any,
|
||||
state: any
|
||||
) => {
|
||||
const className = classMap.find(({ chars }) => chars[0] === capture[1] && chars[1] === capture[3])?.className ?? "";
|
||||
|
||||
return {
|
||||
content: transform(capture[2], state),
|
||||
className
|
||||
};
|
||||
},
|
||||
react: (
|
||||
data: { content: any[]; className: string; },
|
||||
output: (...args: any[]) => ReactNode[]
|
||||
) => {
|
||||
let offset = 0;
|
||||
const traverse = (raw: any) => {
|
||||
const children = !Array.isArray(raw) ? [raw] : raw;
|
||||
let modified = false;
|
||||
|
||||
let j = -1;
|
||||
for (const child of children) {
|
||||
j++;
|
||||
if (typeof child === "string") {
|
||||
modified = true;
|
||||
children[j] = child.split("").map((x, i) => (
|
||||
<span key={i}>
|
||||
<span
|
||||
className={`wiggle-inner ${data.className}`}
|
||||
style={{
|
||||
animationDelay: `${((offset++) * 25) % 1200}ms`,
|
||||
}}
|
||||
>
|
||||
{x}
|
||||
</span>
|
||||
</span>
|
||||
));
|
||||
} else if (child?.props?.children)
|
||||
child.props.children = traverse(child.props.children);
|
||||
}
|
||||
|
||||
return modified ? children : raw;
|
||||
};
|
||||
|
||||
return traverse(output(data.content));
|
||||
},
|
||||
},
|
||||
|
||||
start: () => {
|
||||
styles = document.createElement("style");
|
||||
styles.id = "WigglyText";
|
||||
document.head.appendChild(styles);
|
||||
|
||||
updateStyles();
|
||||
},
|
||||
|
||||
stop: () => styles.remove()
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export default function ExampleWiggle({ wiggle, children }: { wiggle: "x" | "y" | "xy", children: string; }) {
|
||||
return children.split("").map((x, i) => (
|
||||
<span key={i}>
|
||||
<span
|
||||
className={`wiggle-inner wiggle-inner-${wiggle}`}
|
||||
style={{
|
||||
animationDelay: `${(i * 25) % 1200}ms`,
|
||||
}}
|
||||
>
|
||||
{x}
|
||||
</span>
|
||||
</span>
|
||||
));
|
||||
}
|
||||
49
src/equicordplugins/writeUpperCase/index.ts
Normal file
49
src/equicordplugins/writeUpperCase/index.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { addMessagePreSendListener, MessageSendListener, removeMessagePreSendListener } from "@api/MessageEvents";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
|
||||
const settings = definePluginSettings(
|
||||
{
|
||||
blockedWords: {
|
||||
type: OptionType.STRING,
|
||||
description: "Strings not to capitilise (seperate with a comma)",
|
||||
default: "http, https, ok"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const presendObject: MessageSendListener = (_, msg) => {
|
||||
const blockedWordsArray: string[] = settings.store.blockedWords.split(", ");
|
||||
const sentences = msg.content.split(/(?<=[.!?]+['")\]]*)(\s+)/);
|
||||
|
||||
msg.content = sentences.map((element, i) => {
|
||||
if (i % 2 === 1) return element;
|
||||
|
||||
if (!blockedWordsArray.some(word => element.toLowerCase().startsWith(word.toLocaleLowerCase()))) {
|
||||
return element.charAt(0).toUpperCase() + element.slice(1);
|
||||
} else {
|
||||
return element;
|
||||
}
|
||||
}).join("");
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "WriteUpperCase",
|
||||
description: "Changes the first Letter of each Sentence in Message Inputs to Uppercase",
|
||||
authors: [Devs.Samwich, EquicordDevs.KrystalSkull],
|
||||
settings,
|
||||
|
||||
start() {
|
||||
addMessagePreSendListener(presendObject);
|
||||
},
|
||||
stop() {
|
||||
removeMessagePreSendListener(presendObject);
|
||||
}
|
||||
});
|
||||
|
|
@ -156,8 +156,9 @@ async function generateDebugInfoMessage() {
|
|||
}
|
||||
|
||||
const potentiallyProblematicPlugins = ([
|
||||
"NoRPC", "NoProfileThemes", "NoMosaic", "NoSystemBadge",
|
||||
"AlwaysAnimate", "ClientTheme", "SoundTroll", "NeverPausePreviews",
|
||||
"NoRPC", "NoProfileThemes", "NoMosaic", "NoRoleHeaders", "NoSystemBadge",
|
||||
"AlwaysAnimate", "ClientTheme", "SoundTroll", "Ingtoninator", "NeverPausePreviews",
|
||||
"IdleAutoRestart",
|
||||
].filter(isPluginEnabled) ?? []).sort();
|
||||
|
||||
if (isPluginEnabled(customIdle.name) && customIdle.settings.store.idleTimeout === 0) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue