Update Vencord+

This commit is contained in:
RobinRMC 2024-05-23 20:45:02 +02:00
parent 496d62f40a
commit 63f4fc7169
No known key found for this signature in database
GPG key ID: AC6D1EDC9ADED07B
19 changed files with 909 additions and 1010 deletions

3
.gitignore vendored
View file

@ -19,6 +19,3 @@ lerna-debug.log*
*.tsbuildinfo
ExtensionCache/
settings/
src/userplugins

View file

@ -1,131 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
import { extractAndLoadChunksLazy, findComponentByCodeLazy } from "@webpack";
import { Button, Forms, Text, TextInput, Toasts, useEffect, useState } from "@webpack/common";
import { DEFAULT_COLOR, SWATCHES } from "../constants";
import { categories, Category, createCategory, getCategory, updateCategory } from "../data";
import { forceUpdate } from "../index";
interface ColorPickerProps {
color: number | null;
showEyeDropper?: boolean;
suggestedColors?: string[];
onChange(value: number | null): void;
}
interface ColorPickerWithSwatchesProps {
defaultColor: number;
colors: number[];
value: number;
disabled?: boolean;
onChange(value: number | null): void;
renderDefaultButton?: () => React.ReactNode;
renderCustomButton?: () => React.ReactNode;
}
const ColorPicker = findComponentByCodeLazy<ColorPickerProps>(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)");
const ColorPickerWithSwatches = findComponentByCodeLazy<ColorPickerWithSwatchesProps>('"color-picker"', ".customContainer");
export const requireSettingsMenu = extractAndLoadChunksLazy(['name:"UserSettings"'], /createPromise:.{0,20}el\("(.+?)"\).{0,50}"UserSettings"/);
const cl = classNameFactory("vc-pindms-modal-");
interface Props {
categoryId: string | null;
initalChannelId: string | null;
modalProps: ModalProps;
}
const useCategory = (categoryId: string | null, initalChannelId: string | null) => {
const [category, setCategory] = useState<Category | null>(null);
useEffect(() => {
if (categoryId)
setCategory(getCategory(categoryId)!);
else if (initalChannelId)
setCategory({
id: Toasts.genId(),
name: `Pin Category ${categories.length + 1}`,
color: 10070709,
collapsed: false,
channels: [initalChannelId]
});
}, []);
return {
category,
setCategory
};
};
export function NewCategoryModal({ categoryId, modalProps, initalChannelId }: Props) {
const { category, setCategory } = useCategory(categoryId, initalChannelId);
if (!category) return null;
const onSave = async (e: React.FormEvent<HTMLFormElement> | React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
if (!categoryId)
await createCategory(category);
else
await updateCategory(category);
forceUpdate();
modalProps.onClose();
};
return (
<ModalRoot {...modalProps}>
<ModalHeader>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>{categoryId ? "Edit" : "New"} Category</Text>
</ModalHeader>
{/* form is here so when you press enter while in the text input it submits */}
<form onSubmit={onSave}>
<ModalContent className={cl("content")}>
<Forms.FormSection>
<Forms.FormTitle>Name</Forms.FormTitle>
<TextInput
value={category.name}
onChange={e => setCategory({ ...category, name: e })}
/>
</Forms.FormSection>
<Forms.FormDivider />
<Forms.FormSection>
<Forms.FormTitle>Color</Forms.FormTitle>
<ColorPickerWithSwatches
key={category.name}
defaultColor={DEFAULT_COLOR}
colors={SWATCHES}
onChange={c => setCategory({ ...category, color: c! })}
value={category.color}
renderDefaultButton={() => null}
renderCustomButton={() => (
<ColorPicker
color={category.color}
onChange={c => setCategory({ ...category, color: c! })}
key={category.name}
showEyeDropper={false}
/>
)}
/>
</Forms.FormSection>
</ModalContent>
<ModalFooter>
<Button type="submit" onClick={onSave} disabled={!category.name}>{categoryId ? "Save" : "Create"}</Button>
</ModalFooter>
</form>
</ModalRoot>
);
}
export const openCategoryModal = (categoryId: string | null, channelId: string | null) =>
openModal(modalProps => <NewCategoryModal categoryId={categoryId} modalProps={modalProps} initalChannelId={channelId} />);

View file

@ -1,100 +0,0 @@
/*
* 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 { Menu } from "@webpack/common";
import { moveChannelToCategory, canMoveChannelInDirection, categories, isPinned, moveChannel, removeChannelFromCategory } from "../data";
import { forceUpdate, settings } from "../index";
import { openCategoryModal } from "./CreateCategoryModal";
function PinMenuItem(channelId: string) {
const pinned = isPinned(channelId);
return (
<Menu.MenuItem
id="better-pin-dm"
label="Move DM to category"
>
{(
<>
<Menu.MenuItem
id="add-category"
label="New category"
color="brand"
action={() => openCategoryModal(null, channelId)}
/>
<Menu.MenuSeparator />
{
categories.map(category => (
<Menu.MenuItem
id={`pin-category-${category.name}`}
label={category.name}
disabled={category.channels.includes(channelId)}
action={() => moveChannelToCategory(channelId, category.id).then(() => forceUpdate())}
/>
))
}
</>
)}
{(
<>
<Menu.MenuItem
id="unpin-dm"
label="Unpin DM"
color="danger"
disabled={!pinned}
action={() => removeChannelFromCategory(channelId).then(() => forceUpdate())}
/>
{
!settings.store.sortDmsByNewestMessage && canMoveChannelInDirection(channelId, -1) && (
<Menu.MenuItem
id="move-up"
label="Move Up"
action={() => moveChannel(channelId, -1).then(() => forceUpdate())}
/>
)
}
{
!settings.store.sortDmsByNewestMessage && canMoveChannelInDirection(channelId, 1) && (
<Menu.MenuItem
id="move-down"
label="Move Down"
action={() => moveChannel(channelId, 1).then(() => forceUpdate())}
/>
)
}
</>
)}
</Menu.MenuItem>
);
}
const GroupDMContext: NavContextMenuPatchCallback = (children, props) => {
const container = findGroupChildrenByChildId("leave-channel", children);
if (container)
container.unshift(PinMenuItem(props.channel.id));
};
const UserContext: NavContextMenuPatchCallback = (children, props) => {
const container = findGroupChildrenByChildId("close-dm", children);
if (container) {
const idx = container.findIndex(c => c?.props?.id === "close-dm");
container.splice(idx, 0, PinMenuItem(props.channel.id));
}
};
export const contextMenus = {
"gdm-context": GroupDMContext,
"user-context": UserContext
};

View file

@ -1,31 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export const DEFAULT_COLOR = 10070709;
export const SWATCHES = [
1752220,
3066993,
3447003,
10181046,
15277667,
15844367,
15105570,
15158332,
9807270,
6323595,
1146986,
2067276,
2123412,
7419530,
11342935,
12745742,
11027200,
10038562,
9936031,
5533306
];

View file

@ -1,209 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as DataStore from "@api/DataStore";
import { Settings } from "@api/Settings";
import { UserStore } from "@webpack/common";
import { DEFAULT_COLOR } from "./constants";
export interface Category {
id: string;
name: string;
color: number;
channels: string[];
collapsed?: boolean;
}
export const KEYS = {
CATEGORY_BASE_KEY: "BetterPinDMsCategories-",
CATEGORY_MIGRATED_PINDMS_KEY: "BetterPinDMsMigratedPinDMs",
CATEGORY_MIGRATED_KEY: "BetterPinDMsMigratedOldCategories",
OLD_CATEGORY_KEY: "betterPinDmsCategories"
};
export let categories: Category[] = [];
export async function saveCats(cats: Category[]) {
const { id } = UserStore.getCurrentUser();
await DataStore.set(KEYS.CATEGORY_BASE_KEY + id, cats);
}
export async function initCategories(userId: string) {
categories = await DataStore.get<Category[]>(KEYS.CATEGORY_BASE_KEY + userId) ?? [];
}
export function getCategory(id: string) {
return categories.find(c => c.id === id);
}
export async function createCategory(category: Category) {
categories.push(category);
saveCats(categories);
}
export async function updateCategory(category: Category) {
const index = categories.findIndex(c => c.id === category.id);
if (index === -1) return;
categories[index] = category;
saveCats(categories);
}
export async function moveChannelToCategory(channelId: string, categoryId: string) {
const targetcategory = categories.find(c => c.id === categoryId);
if (!targetcategory) return;
const oldcategory = categories.find(c => c.channels.includes(channelId));
if (oldcategory) oldcategory.channels = oldcategory.channels.filter(c => c !== channelId);
// remove channel from old category if it was in one
if (categoryId !== '0') targetcategory.channels.push(channelId);
// if categoryId is not 0, add channel to category
saveCats(categories);
}
/* substituted with an implementation of "moveChannelToCategory";
export async function addChannelToCategory(channelId: string, categoryId: string) {
const category = categories.find(c => c.id === categoryId);
if (!category) return;
if (category.channels.includes(channelId)) return;
category.channels.push(channelId);
saveCats(categories);
}
*/
export async function removeChannelFromCategory(channelId: string) {
const category = categories.find(c => c.channels.includes(channelId));
if (!category) return;
category.channels = category.channels.filter(c => c !== channelId);
saveCats(categories);
}
export async function removeCategory(categoryId: string) {
const catagory = categories.find(c => c.id === categoryId);
if (!catagory) return;
catagory?.channels.forEach(c => removeChannelFromCategory(c));
categories = categories.filter(c => c.id !== categoryId);
saveCats(categories);
}
export function isPinned(id: string) {
return categories.some(c => c.channels.includes(id));
}
export const canMoveArrayInDirection = (array: any[], index: number, direction: -1 | 1) => {
const a = array[index];
const b = array[index + direction];
return a && b;
};
export const canMoveCategoryInDirection = (id: string, direction: -1 | 1) => {
const index = categories.findIndex(m => m.id === id);
return canMoveArrayInDirection(categories, index, direction);
};
export const canMoveCategory = (id: string) => canMoveCategoryInDirection(id, -1) || canMoveCategoryInDirection(id, 1);
export const canMoveChannelInDirection = (channelId: string, direction: -1 | 1) => {
const category = categories.find(c => c.channels.includes(channelId));
if (!category) return false;
const index = category.channels.indexOf(channelId);
return canMoveArrayInDirection(category.channels, index, direction);
};
function swapElementsInArray(array: any[], index1: number, index2: number) {
if (!array[index1] || !array[index2]) return;
[array[index1], array[index2]] = [array[index2], array[index1]];
}
export async function moveCategory(id: string, direction: -1 | 1) {
const a = categories.findIndex(m => m.id === id);
const b = a + direction;
swapElementsInArray(categories, a, b);
saveCats(categories);
}
export async function moveChannel(channelId: string, direction: -1 | 1) {
const category = categories.find(c => c.channels.includes(channelId));
if (!category) return;
const a = category.channels.indexOf(channelId);
const b = a + direction;
swapElementsInArray(category.channels, a, b);
saveCats(categories);
}
export async function collapseCategory(id: string, value = true) {
const category = categories.find(c => c.id === id);
if (!category) return;
category.collapsed = value;
saveCats(categories);
}
const getPinDMsPins = () => (Settings.plugins.PinDMs.pinnedDMs || void 0)?.split(",") as string[] | undefined;
async function migratePinDMs() {
if (categories.some(m => m.id === "oldPins")) {
return await DataStore.set(KEYS.CATEGORY_MIGRATED_PINDMS_KEY, true);
}
const pindmspins = getPinDMsPins();
// we dont want duplicate pins
const difference = [...new Set(pindmspins)]?.filter(m => !categories.some(c => c.channels.includes(m)));
if (difference?.length) {
categories.push({
id: "oldPins",
name: "Pins",
color: DEFAULT_COLOR,
channels: difference
});
}
await DataStore.set(KEYS.CATEGORY_MIGRATED_PINDMS_KEY, true);
}
async function migrateOldCategories() {
const oldCats = await DataStore.get<Category[]>(KEYS.OLD_CATEGORY_KEY);
// dont want to migrate if the user has already has categories.
if (categories.length === 0 && oldCats?.length) {
categories.push(...(oldCats.filter(m => m.id !== "oldPins")));
}
await DataStore.set(KEYS.CATEGORY_MIGRATED_KEY, true);
}
export async function migrateData() {
const m1 = await DataStore.get(KEYS.CATEGORY_MIGRATED_KEY), m2 = await DataStore.get(KEYS.CATEGORY_MIGRATED_PINDMS_KEY);
if (m1 && m2) return;
// want to migrate the old categories first and then slove any conflicts with the PinDMs pins
if (!m1) await migrateOldCategories();
if (!m2) await migratePinDMs();
await saveCats(categories);
}

View file

@ -1,379 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { definePluginSettings, Settings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { classes } from "@utils/misc";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy, findStoreLazy, waitFor } from "@webpack";
import { Alerts, Button, ContextMenuApi, FluxDispatcher, Menu, React, UserStore } from "@webpack/common";
import { Channel } from "discord-types/general";
import { contextMenus } from "./components/contextMenu";
import { openCategoryModal, requireSettingsMenu } from "./components/CreateCategoryModal";
import { canMoveCategory, canMoveCategoryInDirection, categories, Category, collapseCategory, initCategories, isPinned, migrateData, moveCategory, removeCategory } from "./data";
interface ChannelComponentProps {
children: React.ReactNode,
channel: Channel,
selected: boolean;
}
const headerClasses = findByPropsLazy("privateChannelsHeaderContainer");
const PrivateChannelSortStore = findStoreLazy("PrivateChannelSortStore") as { getPrivateChannelIds: () => string[]; };
export let instance: any;
export const forceUpdate = () => instance?.props?._forceUpdate?.();
// the flux property in definePlugin doenst fire, probably because startAt isnt Init
waitFor(["dispatch", "subscribe"], m => {
m.subscribe("CONNECTION_OPEN", async () => {
if (!Settings.plugins.BetterPinDMs?.enabled) return;
const id = UserStore.getCurrentUser()?.id;
await initCategories(id);
await migrateData();
forceUpdate();
// dont want to unsubscribe because if they switch accounts we want to reinit
});
});
export const settings = definePluginSettings({
sortDmsByNewestMessage: {
type: OptionType.BOOLEAN,
description: "Sort DMs by newest message",
default: false,
onChange: () => forceUpdate()
},
dmSectioncollapsed: {
type: OptionType.BOOLEAN,
description: "Collapse DM sections",
default: false,
}
});
export default definePlugin({
name: "BetterPinDMs",
description: "Pin DMs but with categories",
authors: [Devs.Aria, Devs.Ven, Devs.Strencher],
settings,
contextMenus,
patches: [
{
find: ".privateChannelsHeaderContainer,",
predicate: () => !Settings.plugins.PinDMs?.enabled,
replacement: [
{
match: /(?<=\i,{channels:\i,)privateChannelIds:(\i)/,
replace: "privateChannelIds:$1.filter(c=>!$self.isPinned(c))"
},
{
match: /(?<=renderRow:this\.renderRow,)sections:\[.+?1\)]/,
replace: "...$self.makeProps(this,{$&})"
},
{
match: /this\.renderDM=\(.+?(\i\.default),{channel.+?this.renderRow=(\i)=>{/,
replace: "$&if($self.isChannelIndex($2.section, $2.row))return $self.renderChannel($2.section,$2.row,$1);"
},
{
match: /this\.renderSection=(\i)=>{/,
replace: "$&if($self.isCategoryIndex($1.section))return $self.renderCategory($1);"
},
{
match: /(?<=span",{)className:\i\.headerText,/,
replace: "onClick: (e) => $self.collapseDMList(e),$&"
},
{
match: /(this\.getRowHeight=.{1,100}return 1===)(\i)/,
replace: "$1($2-$self.categoryLen())"
},
{
match: /componentDidMount\(\){/,
replace: "$&$self._instance = this;"
},
{
match: /this.getRowHeight=\((\i),(\i)\)=>{/,
replace: "$&if($self.isChannelHidden($1,$2))return 0;"
},
{
// Copied from PinDMs
// Override scrollToChannel to properly account for pinned channels
match: /(?<=scrollTo\(\{to:\i\}\):\(\i\+=)(\d+)\*\(.+?(?=,)/,
replace: "$self.getScrollOffset(arguments[0],$1,this.props.padding,this.state.preRenderedChildren,$&)"
},
{
match: /(?<=scrollToChannel\(\i\){.{1,300})this\.props\.privateChannelIds/,
replace: "[...$&,...$self.getAllUncollapsedChannels()]"
}
]
},
// forceUpdate moment
// https://regex101.com/r/kDN9fO/1
{
find: ".FRIENDS},\"friends\"",
predicate: () => !Settings.plugins.PinDMs?.enabled,
replacement: {
match: /(\i=\i=>{)(.{1,850})showDMHeader:/,
replace: "$1let forceUpdate = Vencord.Util.useForceUpdater();$2_forceUpdate:forceUpdate,showDMHeader:"
}
},
// copied from PinDMs
// Fix Alt Up/Down navigation
{
find: ".Routes.APPLICATION_STORE&&",
predicate: () => !Settings.plugins.PinDMs?.enabled,
replacement: {
// channelIds = __OVERLAY__ ? stuff : [...getStaticPaths(),...channelIds)]
match: /(?<=\i=__OVERLAY__\?\i:\[\.\.\.\i\(\),\.\.\.)\i/,
// ....concat(pins).concat(toArray(channelIds).filter(c => !isPinned(c)))
replace: "$self.getAllUncollapsedChannels().concat($&.filter(c=>!$self.isPinned(c)))"
}
},
// copied from PinDMs
// fix alt+shift+up/down
{
find: ".getFlattenedGuildIds()],",
predicate: () => !Settings.plugins.PinDMs?.enabled,
replacement: {
match: /(?<=\i===\i\.ME\?)\i\.\i\.getPrivateChannelIds\(\)/,
replace: "$self.getAllUncollapsedChannels().concat($&.filter(c=>!$self.isPinned(c)))"
}
},
],
sections: null as number[] | null,
set _instance(i: any) {
this.instance = i;
instance = i;
},
isPinned,
start() {
if (Settings.plugins.PinDMs?.enabled) {
console.log("disable PinDMs to use this plugin");
setTimeout(() => {
Alerts.show({
title: "PinDMs Enabled",
body: "BetterPinDMs requires PinDMs to be disabled. Please disable it to use this plugin.",
confirmText: "Disable",
confirmColor: Button.Colors.RED,
cancelText: "Cancel",
onConfirm: () => {
Settings.plugins.PinDMs.enabled = false;
location.reload();
},
});
}, 5_000);
return;
}
requireSettingsMenu();
},
makeProps(instance, { sections }: { sections: number[]; }) {
this.sections = sections;
this.sections.splice(1, 0, ...this.usePinCount(instance.props.privateChannelIds || []));
if (this.instance?.props?.privateChannelIds?.length === 0) {
this.sections[this.sections.length - 1] = 0;
}
return {
sections: this.sections,
chunkSize: this.getChunkSize(),
};
},
categoryLen() {
return categories.length;
},
getChunkSize() {
return 256 + this.getSections().reduce((acc, v) => acc += v, 0) * 20;
},
getAllChannels() {
return categories.map(c => c.channels).flat();
},
getAllUncollapsedChannels() {
return categories.filter(c => !c.collapsed).map(c => c.channels).flat();
},
usePinCount(channelIds: string[]) {
return channelIds.length ? this.getSections() : [];
},
collapseDMList() {
// console.log("HI");
settings.store.dmSectioncollapsed = !settings.store.dmSectioncollapsed;
forceUpdate();
},
getSections() {
return categories.reduce((acc, category) => {
acc.push(category.channels.length === 0 ? 1 : category.channels.length);
return acc;
}, [] as number[]);
},
isCategoryIndex(sectionIndex: number) {
return this.sections && sectionIndex > 0 && sectionIndex < this.sections.length - 1;
},
isChannelIndex(sectionIndex: number, channelIndex: number) {
if (settings.store.dmSectioncollapsed && sectionIndex !== 0)
return true;
const cat = categories[sectionIndex - 1];
return this.isCategoryIndex(sectionIndex) && (cat.channels.length === 0 || cat?.channels[channelIndex]);
},
isChannelHidden(categoryIndex: number, channelIndex: number) {
if (categoryIndex === 0) return false;
if (settings.store.dmSectioncollapsed && this.getSections().length + 1 === categoryIndex)
return true;
if (!this.instance || !this.isChannelIndex(categoryIndex, channelIndex)) return false;
const category = categories[categoryIndex - 1];
if (!category) return false;
return category.collapsed && this.instance.props.selectedChannelId !== category.channels[channelIndex];
},
getScrollOffset(channelId: string, rowHeight: number, padding: number, preRenderedChildren: number, originalOffset: number) {
if (!isPinned(channelId))
return (
(rowHeight + padding) * 2 // header
+ rowHeight * this.getAllUncollapsedChannels().length // pins
+ originalOffset // original pin offset minus pins
);
return rowHeight * (this.getAllUncollapsedChannels().indexOf(channelId) + preRenderedChildren) + padding;
},
renderCategory({ section }: { section: number; }) {
const category = categories[section - 1];
if (!category) return null;
return (
<h2
className={classes(headerClasses.privateChannelsHeaderContainer, "vc-pindms-section-container", category.collapsed ? "vc-pindms-collapsed" : "")}
style={{ color: `#${category.color.toString(16).padStart(6, "0")}` }}
onClick={async () => {
await collapseCategory(category.id, !category.collapsed);
forceUpdate();
}}
onContextMenu={e => {
ContextMenuApi.openContextMenu(e, () => (
<Menu.Menu
navId="vc-pindms-header-menu"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
color="danger"
aria-label="Pin DMs Category Menu"
>
<Menu.MenuItem
id="vc-pindms-edit-category"
label="Edit Category"
action={() => openCategoryModal(category.id, null)}
/>
<Menu.MenuItem
id="vc-pindms-delete-category"
color="danger"
label="Delete Category"
action={() => removeCategory(category.id).then(() => forceUpdate())}
/>
{
canMoveCategory(category.id) && (
<>
<Menu.MenuSeparator />
<Menu.MenuItem
id="vc-pindms-move-category"
label="Move Category"
>
{
canMoveCategoryInDirection(category.id, -1) && <Menu.MenuItem
id="vc-pindms-move-category-up"
label="Move Up"
action={() => moveCategory(category.id, -1).then(() => forceUpdate())}
/>
}
{
canMoveCategoryInDirection(category.id, 1) && <Menu.MenuItem
id="vc-pindms-move-category-down"
label="Move Down"
action={() => moveCategory(category.id, 1).then(() => forceUpdate())}
/>
}
</Menu.MenuItem>
</>
)
}
</Menu.Menu>
));
}}
>
<span className={headerClasses.headerText}>
{category?.name ?? "uh oh"}
</span>
<svg className="vc-pindms-collapse-icon" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M9.3 5.3a1 1 0 0 0 0 1.4l5.29 5.3-5.3 5.3a1 1 0 1 0 1.42 1.4l6-6a1 1 0 0 0 0-1.4l-6-6a1 1 0 0 0-1.42 0Z"></path>
</svg>
</h2>
);
},
renderChannel(sectionIndex: number, index: number, ChannelComponent: React.ComponentType<ChannelComponentProps>) {
const { channel, category } = this.getChannel(sectionIndex, index, this.instance.props.channels);
if (!channel || !category) return null;
const selected = this.instance.props.selectedChannelId === channel.id;
if (!selected && category.collapsed) return null;
return (
<ChannelComponent
channel={channel}
selected={selected}
>
{channel.id}
</ChannelComponent>
);
},
getChannel(sectionIndex: number, index: number, channels: Record<string, Channel>) {
const category = categories[sectionIndex - 1];
if (!category) return { channel: null, category: null };
const channelId = this.getCategoryChannels(category)[index];
return { channel: channels[channelId], category };
},
getCategoryChannels(category: Category) {
if (settings.store.sortDmsByNewestMessage) {
return PrivateChannelSortStore.getPrivateChannelIds().filter(c => category?.channels?.includes(c));
}
return category?.channels ?? [];
}
});

View file

@ -1,41 +0,0 @@
.vc-pindms-section-container {
box-sizing: border-box;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
text-transform: uppercase;
font-size: 12px;
line-height: 16px;
letter-spacing: .02em;
font-family: var(--font-display);
font-weight: 600;
flex: 1 1 auto;
color: var(--channels-default);
cursor: pointer;
}
.vc-pindms-modal-content {
display: grid;
justify-content: center;
padding: 1rem;
gap: 1.5rem;
}
.vc-pindms-modal-content [class^="defaultContainer"] {
display: none;
}
.vc-pindms-collapse-icon {
width: 16px;
height: 16px;
color: var(--interactive-normal);
transform: rotate(90deg)
}
.vc-pindms-collapsed .vc-pindms-collapse-icon {
transform: rotate(0deg);
}
[class^="privateChannelsHeaderContainer"] > [class^="headerText"] {
cursor: pointer;
}

View file

@ -0,0 +1,95 @@
/*
* 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 { classNameFactory } from "@api/Styles";
import { copyWithToast } from "@utils/misc";
import { Button, Forms, Toasts } from "@webpack/common";
import { Native, settings } from "../..";
import { DEFAULT_IMAGE_CACHE_DIR } from "../../utils/constants";
const cl = classNameFactory("folder-upload");
function createDirSelector(settingKey: "logsDir" | "imageCacheDir", successMessage: string) {
return function DirSelector({ option }) {
if (IS_WEB) return null;
return (
<Forms.FormSection>
<Forms.FormTitle>{option.description}</Forms.FormTitle>
<SelectFolderInput
settingsKey={settingKey}
successMessage={successMessage}
/>
</Forms.FormSection>
);
};
}
export const ImageCacheDir = createDirSelector("imageCacheDir", "Successfully updated Image Cache Dir");
export const LogsDir = createDirSelector("logsDir", "Successfully updated Logs Dir");
interface Props {
settingsKey: "imageCacheDir" | "logsDir",
successMessage: string,
}
export function SelectFolderInput({ settingsKey, successMessage }: Props) {
const path = settings.store[settingsKey];
function getDirName(path: string) {
const parts = path.split("\\").length > 1 ? path.split("\\") : path.split("/");
return parts.slice(parts.length - 2, parts.length).join("\\");
}
async function onFolderSelect() {
try {
const res = await Native.chooseDir(settingsKey);
settings.store[settingsKey] = res;
return Toasts.show({
id: Toasts.genId(),
type: Toasts.Type.SUCCESS,
message: successMessage
});
} catch (err) {
Toasts.show({
id: Toasts.genId(),
type: Toasts.Type.FAILURE,
message: "Failed to update directory"
});
}
}
return (
<div className={cl("-container")}>
<div onClick={() => copyWithToast(path)} className={cl("-input")}>
{path == null || path === DEFAULT_IMAGE_CACHE_DIR ? "Choose Folder" : getDirName(path)}
</div>
<Button
className={cl("-button")}
size={Button.Sizes.SMALL}
onClick={onFolderSelect}
>
Browse
</Button>
</div>
);
}

View file

@ -1,116 +0,0 @@
/*
* 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 { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { FluxDispatcher } from "@webpack/common";
import { getApplicationAsset } from "plugins/customRPC";
import { Message } from "discord-types/general";
import { UserStore } from "@webpack/common";
async function setRpc(disable?: boolean, details?: string) {
const activity = {
"application_id": "0",
"name": "Today's Stats",
"details": details ? details : "No info right now :(",
"type": 0,
"flags": 1,
"assets": {
"large_image": await getApplicationAsset(UserStore.getCurrentUser().getAvatarURL())
}
}
FluxDispatcher.dispatch({
type: "LOCAL_ACTIVITY_UPDATE",
activity: !disable ? activity : null,
socketId: "CustomRPC",
});
}
function getCurrentDate(): string {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function getCurrentTime(): string {
const today = new Date();
let hour = today.getHours();
const minute = today.getMinutes();
const ampm = hour >= 12 ? 'PM' : 'AM';
hour = hour % 12 || 12
const formattedHour = String(hour).padStart(2, '0');
const formattedMinute = String(minute).padStart(2, '0');
return `${formattedHour}:${formattedMinute} ${ampm}`;
}
interface IMessageCreate {
type: "MESSAGE_CREATE";
optimistic: boolean;
isPushNotification: boolean;
channelId: string;
message: Message;
}
async function updateData()
{
let messagesSent;
if(await DataStore.get("RPCStatsDate") == getCurrentDate())
{
messagesSent = await DataStore.get("RPCStatsMessages");
}
else
{
await DataStore.set("RPCStatsDate", getCurrentDate());
await DataStore.set("RPCStatsMessages", 0);
messagesSent = 0;
}
setRpc(false, `Messages sent: ${messagesSent}`);
}
export default definePlugin({
name: "RPCStats",
description: "Displays stats about your current session in your rpc",
authors: [Devs.Samwich],
async start()
{
updateData();
},
stop()
{
setRpc(true);
},
flux:
{
async MESSAGE_CREATE({ optimistic, type, message }: IMessageCreate) {
if (optimistic || type !== "MESSAGE_CREATE") return;
if (message.state === "SENDING") return;
if (message.author.id != UserStore.getCurrentUser().id) return;
await DataStore.set("RPCStatsMessages", await DataStore.get("RPCStatsMessages") + 1);
updateData();
},
}
});
let lastCheckedDate: string = getCurrentDate();
function checkForNewDay(): void {
const currentDate = getCurrentDate();
if (currentDate !== lastCheckedDate) {
updateData();
lastCheckedDate = currentDate;
}
}
setInterval(checkForNewDay, 1000 * 60);

View file

@ -0,0 +1,206 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { Notices } from "@api/index";
import { popNotice } from "@api/Notices";
import { definePluginSettings } from "@api/Settings";
import { makeRange } from "@components/PluginSettings/components";
import { clearableDebounce, debounce } from "@shared/debounce";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy, findStoreLazy } from "@webpack";
import { Menu, SelectedChannelStore, UserStore } from "@webpack/common";
import { User } from "discord-types/general";
const { toggleSelfMute } = findByPropsLazy("toggleSelfMute");
// We cannot destructure isSelfMute as it depends on isEnabled
const MediaEngineStore = findStoreLazy("MediaEngineStore");
interface SpeakingState {
speakingFlags: number,
type: string,
userId: string;
}
const enum SpeakingFlagsMask {
VOICE = 1 << 0,
SOUNDSHARE = 1 << 1,
PRIORITY = 1 << 2
}
interface VoiceState {
guildId?: string;
channelId?: string;
oldChannelId?: string;
user: User;
userId: string;
}
let [setAutoMute, cancelAutoMute] = [() => { }, () => { }];
function updateTimeout(seconds: number) {
cancelAutoMute();
[setAutoMute, cancelAutoMute] = clearableDebounce(autoMute, seconds * 1000);
updateAutoMute();
}
const settings = definePluginSettings({
isEnabled: {
type: OptionType.BOOLEAN,
description: "Whether the plugin will automatically mute you or not",
default: true,
onChange() {
updateAutoMute();
}
},
timeout: {
description: "Inactivity timeout (seconds)",
type: OptionType.SLIDER,
markers: [15, ...makeRange(60, 900, 60)],
default: 300,
stickToMarkers: false,
onChange(value) {
updateTimeout(value);
},
}
});
const AudioDeviceContextMenuPatch: NavContextMenuPatchCallback = (children, props: { renderInputVolume?: boolean; }) => {
const { isEnabled, timeout } = settings.use(["isEnabled", "timeout"]);
if ("renderInputVolume" in props) {
children.splice(children.length - 1, 0,
<Menu.MenuGroup
label="Auto Mute"
>
<Menu.MenuCheckboxItem
checked={isEnabled}
id="vc-auto-mute-toggle"
label="Enable Auto Mute"
action={() => {
settings.store.isEnabled = !isEnabled;
updateAutoMute();
}}
/>
<Menu.MenuControlItem
id="vc-auto-mute-timeout"
label="Inactivity Timeout"
control={(props, ref) => (
<Menu.MenuSliderControl
{...props}
ref={ref}
minValue={15}
maxValue={900}
value={timeout}
onChange={debounce((rawValue: number) => {
const value = Math.round(rawValue);
settings.store.timeout = value;
updateTimeout(value);
}, 10)}
renderValue={(value: number) => {
const minutes = Math.floor(value / 60);
const seconds = Math.round(value % 60);
return [
minutes,
`${seconds < 10 ? "0" + seconds : seconds}${minutes ? "" : "s"}`
].filter(Boolean).join(":");
}}
/>
)}
/>
</Menu.MenuGroup>
);
}
};
let isSpeaking = false;
function updateAutoMute() {
if (!settings.store.isEnabled) return cancelAutoMute();
if (!SelectedChannelStore.getVoiceChannelId()) return cancelAutoMute();
isSpeaking ? cancelAutoMute() : setAutoMute();
}
function autoMute() {
if (!MediaEngineStore.isSelfMute()) {
toggleSelfMute();
Notices.showNotice("You have been silent for a while, so your mic has been automatically muted.", "Unmute", () => {
popNotice();
if (MediaEngineStore.isSelfMute()) toggleSelfMute();
});
}
}
// 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: "AutoMute",
description: "Automatically mute yourself in voice channels if you're not speaking for too long.",
authors: [Devs.Sqaaakoi],
settings,
flux: {
SPEAKING(s: SpeakingState) {
if (s.userId !== UserStore.getCurrentUser().id) return;
isSpeaking = (s.speakingFlags & SpeakingFlagsMask.VOICE) === 1;
isSpeaking ? cancelAutoMute() : setAutoMute();
},
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
// Blatantly stolen from my other unfinished plugin
if (!voiceStates) return;
voiceStates.forEach(state => {
if (state.userId !== UserStore.getCurrentUser().id) return;
const { channelId } = state;
let { oldChannelId } = state;
if (channelId !== clientOldChannelId) {
oldChannelId = clientOldChannelId;
clientOldChannelId = channelId;
}
if (!oldChannelId && channelId) {
console.log("join");
updateAutoMute();
}
if (oldChannelId && !channelId) {
console.log("dc");
cancelAutoMute();
isSpeaking = false;
}
});
},
AUDIO_TOGGLE_SELF_MUTE() {
updateAutoMute();
},
AUDIO_TOGGLE_SELF_DEAF() {
updateAutoMute();
},
AUDIO_TOGGLE_SET_MUTE() {
updateAutoMute();
},
AUDIO_TOGGLE_SET_DEAF() {
updateAutoMute();
},
},
contextMenus: {
"audio-device-context": AudioDeviceContextMenuPatch
},
start() {
updateTimeout(settings.store.timeout);
},
stop() {
cancelAutoMute();
}
});

View file

@ -0,0 +1,39 @@
/*
* 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 { findByPropsLazy } from "@webpack";
const classes = findByPropsLazy("menu", "submenu");
export default definePlugin({
name: "ContextMenuSelectFix",
description: "Releasing right click when hovering over a context menu entry selects it, bringing the behaviour in line with other apps",
authors: [Devs.Sqaaakoi],
pointerUpEventHandler(e: PointerEvent) {
let target = e.target as HTMLElement | null;
if (!target || e.button !== 2) return;
let parent = target.parentElement;
try {
while (parent && !parent?.classList.contains(classes.menu)) {
parent = parent.parentElement;
}
} catch (err) { return console.error(err); }
if (parent) {
while (target && !target?.click) {
target = target?.parentElement;
}
target?.click();
}
},
start() {
document.body.addEventListener("pointerup", this.pointerUpEventHandler);
},
stop() {
document.body.addEventListener("pointerup", this.pointerUpEventHandler);
}
});

View 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: "NoDraftLengthLimit",
description: "Removes the 4500 character saved draft message truncation",
authors: [Devs.Sqaaakoi],
patches: [
{
find: "MAX_MESSAGE_LENGTH_PREMIUM+500",
replacement: {
match: /=[^=]{0,20}MAX_MESSAGE_LENGTH_PREMIUM\+500/,
replace: "=Infinity"
}
}
]
});

View file

@ -0,0 +1,29 @@
.vc-voice-channel-log-entry {
list-style-type: none;
height: 48px;
/* margin: 4px; */
display: grid;
grid-template-columns: 2.25rem 24px 40px max-content;
gap: 4px;
}
.vc-voice-channel-log-entry-timestamp {
height: 48px;
line-height: 48px;
font-size: .75rem;
text-align: center;
display: inline-block;
vertical-align: middle;
color: var(--text-muted-on-default);
}
.vc-voice-channel-log-entry-avatar {
left: calc(2.25rem + 32px);
padding: 4px 0;
margin-top: 0;
}
.vc-voice-channel-log-entry-icon {
margin: auto 3px;
}

View file

@ -0,0 +1,51 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./VoiceChannelLogEntryComponent.css";
import { classNameFactory } from "@api/Styles";
import { classes } from "@utils/misc";
import { LazyComponent } from "@utils/react";
import { filters, find, findByPropsLazy, findExportedComponentLazy } from "@webpack";
import { GuildStore, React, Timestamp, UserStore } from "@webpack/common";
import { Channel } from "discord-types/general";
import { Util } from "Vencord";
import { VoiceChannelLogEntry } from "./logs";
import Icon from "./VoiceChannelLogEntryIcons";
// this is terrible, blame mantika and vee for this, as I stole the code from them and adapted it (see ../reviewDB/components/ReviewComponent.tsx line 40 and 46 )
export const VoiceChannelLogEntryComponent = LazyComponent(() => {
const IconClasses = findByPropsLazy("icon", "acronym", "childWrapper");
const FriendRow = findExportedComponentLazy("FriendRow");
// const NameWithRole = findByCode("name", "color", "roleName", "dotAlignment");
const { avatar, clickable } = find(filters.byProps("avatar", "zalgo", "clickable"));
const cl = classNameFactory("vc-voice-channel-log-entry-");
return function VoiceChannelLogEntryComponent({ logEntry, channel }: { logEntry: VoiceChannelLogEntry; channel: Channel; }) {
const guild = channel.getGuildId() ? GuildStore.getGuild(channel.getGuildId()) : null;
const user = UserStore.getUser(logEntry.userId);
return <li className="vc-voice-channel-log-entry">
<Timestamp className={cl("timestamp")} timestamp={logEntry.timestamp} compact isVisibleOnlyOnHover isInline={false} cozyAlt></Timestamp>
<Icon logEntry={logEntry} channel={channel} className={cl("icon")} />
<img
className={classes(avatar, clickable, cl("avatar"))}
onClick={() => Util.openUserProfile(logEntry.userId)}
src={user.getAvatarURL(channel.getGuildId())}
// style={{ left: "0px", zIndex: 0 }}
/>
<div className={cl("content")}>
{/* <NameWithRole ></NameWithRole> */}
</div>
</li>;
};
});

View file

@ -0,0 +1,31 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { classes } from "@utils/misc";
import { React } from "@webpack/common";
import { Channel } from "discord-types/general";
import { VoiceChannelLogEntry } from "./logs";
const cl = classNameFactory("vc-voice-channel-log-entry-icon-");
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" fill-rule="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" fill-rule="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" fill-rule="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" fill-rule="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>;
}

View file

@ -0,0 +1,75 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { classes } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { LazyComponent } from "@utils/react";
import { filters, find, findByProps, findStoreLazy } from "@webpack";
import { React, ScrollerThin, Text } from "@webpack/common";
import { Channel } from "discord-types/general";
import { getVcLogs, vcLogSubscribe } from "./logs";
import { VoiceChannelLogEntryComponent } from "./VoiceChannelLogEntryComponent";
const AccessibilityStore = findStoreLazy("AccessibilityStore");
const cl = classNameFactory("vc-voice-channel-log-");
export function openVoiceChannelLog(channel: Channel) {
return openModal(props => (
<VoiceChannelLogModal
props={props}
channel={channel}
/>
));
}
export const VoiceChannelLogModal = LazyComponent(() => {
const { avatar, clickable } = find(filters.byProps("avatar", "zalgo", "clickable"));
const { divider, hasContent } = findByProps("divider", "hasContent", "ephemeral");
const { divider: divider_, hasContent: hasContent_, content } = findByProps("divider", "hasContent", "isUnread", "content");
return 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(divider, hasContent, divider_, hasContent_, cl("date-separator"))} role="separator" aria-label={logEntry.timestamp.toDateString()}>
<span className={content}>
{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>
<Text className={cl("header")} variant="heading-lg/semibold" style={{ flexGrow: 1 }}>{channel.name} logs</Text>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent>
<ScrollerThin fade className={classes(cl("scroller"), `group-spacing-${AccessibilityStore.messageGroupSpacing}`)}>
{logElements}
</ScrollerThin>
</ModalContent>
</ModalRoot >
);
};
});

View file

@ -0,0 +1,170 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { addContextMenuPatch, findGroupChildrenByChildId, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { FluxDispatcher, Menu, MessageActions, MessageStore, RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common";
import { Channel, Message, User } from "discord-types/general";
import { addLogEntry } from "./logs";
import { openVoiceChannelLog } from "./VoiceChannelLogModal";
const MessageCreator = findByPropsLazy("createBotMessage");
const SortedVoiceStateStore = findByPropsLazy("getVoiceStatesForChannel");
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 = MessageCreator.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;
}
interface ChannelContextProps {
channel: Channel;
}
const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { channel }: ChannelContextProps) => () => {
if (!channel) return;
const group = findGroupChildrenByChildId("hide-voice-names", children);
const injectIndex = group?.findIndex(i => i?.props?.id === "hide-voice-names");
if (!injectIndex || !group) return;
group.splice(injectIndex, 0, (
<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],
settings,
flux: {
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
if (!voiceStates) return;
const clientUserId = UserStore.getCurrentUser().id;
voiceStates.forEach(state => {
// mmmm hacky workaround
const { userId, channelId } = state;
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,
oldChannel: oldChannelId || null,
newChannel: channelId || null,
timestamp: new Date()
};
addLogEntry(logEntry, oldChannelId);
addLogEntry(logEntry, channelId);
if (!settings.store.voiceChannelChatSelf && userId === clientUserId) 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);
}
});
},
},
start() {
addContextMenuPatch("channel-context", UserContextMenuPatch);
},
stop() {
removeContextMenuPatch("channel-context", UserContextMenuPatch);
},
});

View file

@ -0,0 +1,40 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface VoiceChannelLogEntry {
userId: 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);
};
}

View file

@ -0,0 +1,150 @@
/*
* 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 { Devs } from "@utils/constants";
import { humanFriendlyJoin } from "@utils/text";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { ChannelStore, FluxDispatcher, MessageActions, MessageStore, RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common";
import { Message, User } from "discord-types/general";
const MessageCreator = findByPropsLazy("createBotMessage");
const SortedVoiceStateStore = findByPropsLazy("getVoiceStatesForChannel");
const settings = definePluginSettings({
friendDirectMessages: {
type: OptionType.BOOLEAN,
description: "Recieve notifications in your friends' DMs when they join a voice channel",
default: true
},
friendDirectMessagesShowMembers: {
type: OptionType.BOOLEAN,
description: "Show a list of other members in the voice channel when recieving a DM notification of your friend joining a voice channel",
default: true
},
friendDirectMessagesShowMemberCount: {
type: OptionType.BOOLEAN,
description: "Show the count of other members in the voice channel when recieving a DM notification of your friend joining a voice channel",
default: false
},
friendDirectMessagesSelf: {
type: OptionType.BOOLEAN,
description: "Recieve notifications in your friends' DMs even if you are in the same voice channel as them",
default: false
},
friendDirectMessagesSilent: {
type: OptionType.BOOLEAN,
description: "Join messages in your friends DMs will be silent",
default: false
},
allowedFriends: {
type: OptionType.STRING,
description: "Comma or space separated list of friends' user IDs you want to recieve join messages from",
default: ""
},
ignoreBlockedUsers: {
type: OptionType.BOOLEAN,
description: "Do not send messages about blocked users joining/leaving/moving voice channels",
default: true
},
});
interface VoiceState {
guildId?: string;
channelId?: string;
oldChannelId?: string;
user: User;
userId: string;
}
function getMessageFlags() {
let flags = 1 << 6;
if (settings.store.friendDirectMessagesSilent) flags += 1 << 12;
return flags;
}
function sendVoiceStatusMessage(channelId: string, content: string, userId: string): Message | null {
if (!channelId) return null;
const message: Message = MessageCreator.createBotMessage({ channelId, content, embeds: [] });
message.flags = getMessageFlags();
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;
}
function isFriendAllowlisted(friendId: string) {
if (!RelationshipStore.isFriend(friendId)) return false;
const list = settings.store.allowedFriends.split(",").join(" ").split(" ").filter(i => i.length > 0);
if (list.join(" ").length < 1) return true;
return list.includes(friendId);
}
// 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: "VoiceJoinMessages",
description: "Recieve client-side ephemeral messages when your friends join voice channels",
authors: [Devs.Sqaaakoi],
settings,
flux: {
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
if (!voiceStates) return;
const clientUserId = UserStore.getCurrentUser().id;
voiceStates.forEach(state => {
// mmmm hacky workaround
const { userId, channelId } = state;
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;
// Friend joined a voice channel
if (settings.store.friendDirectMessages && (!oldChannelId && channelId) && userId !== clientUserId && isFriendAllowlisted(userId)) {
const selfInChannel = SelectedChannelStore.getVoiceChannelId() === channelId;
let memberListContent = "";
if (settings.store.friendDirectMessagesShowMembers || settings.store.friendDirectMessagesShowMemberCount) {
const sortedVoiceStates: [{ user: { id: string; }; }] = SortedVoiceStateStore.getVoiceStatesForChannel(ChannelStore.getChannel(channelId));
const otherMembers = sortedVoiceStates.filter(s => s.user.id !== userId);
const otherMembersCount = otherMembers.length;
if (otherMembersCount <= 0) {
memberListContent += ", nobody else is in the voice channel";
} else if (settings.store.friendDirectMessagesShowMemberCount) {
memberListContent += ` with ${otherMembersCount} other member${otherMembersCount === 1 ? "s" : ""}`;
}
if (settings.store.friendDirectMessagesShowMembers && otherMembersCount > 0) {
memberListContent += settings.store.friendDirectMessagesShowMemberCount ? ", " : " with ";
memberListContent += humanFriendlyJoin(otherMembers.map(s => `<@${s.user.id}>`));
}
}
const dmChannelId = ChannelStore.getDMFromUserId(userId);
if (dmChannelId && (selfInChannel ? settings.store.friendDirectMessagesSelf : true)) sendVoiceStatusMessage(dmChannelId, `Joined voice channel <#${channelId}>${memberListContent}`, userId);
}
});
},
},
});