diff --git a/.gitignore b/.gitignore index 12c91843..5440aa5f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,3 @@ lerna-debug.log* *.tsbuildinfo ExtensionCache/ -settings/ - -src/userplugins diff --git a/src/plusplugins/BetterPinDMs/components/CreateCategoryModal.tsx b/src/plusplugins/BetterPinDMs/components/CreateCategoryModal.tsx deleted file mode 100644 index 2a0e2cce..00000000 --- a/src/plusplugins/BetterPinDMs/components/CreateCategoryModal.tsx +++ /dev/null @@ -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(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)"); -const ColorPickerWithSwatches = findComponentByCodeLazy('"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(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 | React.MouseEvent) => { - e.preventDefault(); - if (!categoryId) - await createCategory(category); - else - await updateCategory(category); - - forceUpdate(); - modalProps.onClose(); - }; - - return ( - - - {categoryId ? "Edit" : "New"} Category - - - {/* form is here so when you press enter while in the text input it submits */} -
- - - Name - setCategory({ ...category, name: e })} - /> - - - - Color - setCategory({ ...category, color: c! })} - value={category.color} - renderDefaultButton={() => null} - renderCustomButton={() => ( - setCategory({ ...category, color: c! })} - key={category.name} - showEyeDropper={false} - /> - )} - /> - - - - - -
-
- ); -} - -export const openCategoryModal = (categoryId: string | null, channelId: string | null) => - openModal(modalProps => ); - diff --git a/src/plusplugins/BetterPinDMs/components/contextMenu.tsx b/src/plusplugins/BetterPinDMs/components/contextMenu.tsx deleted file mode 100644 index 42978858..00000000 --- a/src/plusplugins/BetterPinDMs/components/contextMenu.tsx +++ /dev/null @@ -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 ( - - - {( - <> - openCategoryModal(null, channelId)} - /> - - - { - categories.map(category => ( - moveChannelToCategory(channelId, category.id).then(() => forceUpdate())} - /> - )) - } - - )} - - {( - <> - removeChannelFromCategory(channelId).then(() => forceUpdate())} - /> - - { - !settings.store.sortDmsByNewestMessage && canMoveChannelInDirection(channelId, -1) && ( - moveChannel(channelId, -1).then(() => forceUpdate())} - /> - ) - } - - { - !settings.store.sortDmsByNewestMessage && canMoveChannelInDirection(channelId, 1) && ( - moveChannel(channelId, 1).then(() => forceUpdate())} - /> - ) - } - - )} - - - ); -} - - -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 -}; diff --git a/src/plusplugins/BetterPinDMs/constants.ts b/src/plusplugins/BetterPinDMs/constants.ts deleted file mode 100644 index dec23047..00000000 --- a/src/plusplugins/BetterPinDMs/constants.ts +++ /dev/null @@ -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 -]; diff --git a/src/plusplugins/BetterPinDMs/data.ts b/src/plusplugins/BetterPinDMs/data.ts deleted file mode 100644 index f6275b16..00000000 --- a/src/plusplugins/BetterPinDMs/data.ts +++ /dev/null @@ -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(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(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); -} diff --git a/src/plusplugins/BetterPinDMs/index.tsx b/src/plusplugins/BetterPinDMs/index.tsx deleted file mode 100644 index 7a87a277..00000000 --- a/src/plusplugins/BetterPinDMs/index.tsx +++ /dev/null @@ -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 ( -

{ - await collapseCategory(category.id, !category.collapsed); - forceUpdate(); - }} - onContextMenu={e => { - ContextMenuApi.openContextMenu(e, () => ( - FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} - color="danger" - aria-label="Pin DMs Category Menu" - > - openCategoryModal(category.id, null)} - /> - - removeCategory(category.id).then(() => forceUpdate())} - /> - - { - canMoveCategory(category.id) && ( - <> - - - { - canMoveCategoryInDirection(category.id, -1) && moveCategory(category.id, -1).then(() => forceUpdate())} - /> - } - { - canMoveCategoryInDirection(category.id, 1) && moveCategory(category.id, 1).then(() => forceUpdate())} - /> - } - - - - ) - } - - )); - }} - > - - {category?.name ?? "uh oh"} - - -

- ); - }, - - renderChannel(sectionIndex: number, index: number, ChannelComponent: React.ComponentType) { - 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 ( - - {channel.id} - - ); - }, - - getChannel(sectionIndex: number, index: number, channels: Record) { - 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 ?? []; - } -}); diff --git a/src/plusplugins/BetterPinDMs/styles.css b/src/plusplugins/BetterPinDMs/styles.css deleted file mode 100644 index 9b3a301e..00000000 --- a/src/plusplugins/BetterPinDMs/styles.css +++ /dev/null @@ -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; -} diff --git a/src/plusplugins/MessageLoggerEnhanced/components/settings/FolderSelectInput.tsx b/src/plusplugins/MessageLoggerEnhanced/components/settings/FolderSelectInput.tsx new file mode 100644 index 00000000..0617ddfc --- /dev/null +++ b/src/plusplugins/MessageLoggerEnhanced/components/settings/FolderSelectInput.tsx @@ -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 . +*/ + +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 ( + + {option.description} + + + ); + }; +} + +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 ( +
+
copyWithToast(path)} className={cl("-input")}> + {path == null || path === DEFAULT_IMAGE_CACHE_DIR ? "Choose Folder" : getDirName(path)} +
+ +
+ ); + +} diff --git a/src/plusplugins/RPCStats/index.tsx b/src/plusplugins/RPCStats/index.tsx deleted file mode 100644 index 7c1273f6..00000000 --- a/src/plusplugins/RPCStats/index.tsx +++ /dev/null @@ -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); diff --git a/src/plusplugins/autoMute/index.tsx b/src/plusplugins/autoMute/index.tsx new file mode 100644 index 00000000..fc133e4d --- /dev/null +++ b/src/plusplugins/autoMute/index.tsx @@ -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, + + { + settings.store.isEnabled = !isEnabled; + updateAutoMute(); + }} + /> + ( + { + 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(":"); + }} + /> + )} + /> + + ); + } +}; + + +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(); + } +}); + diff --git a/src/plusplugins/contextMenuSelectFix/index.tsx b/src/plusplugins/contextMenuSelectFix/index.tsx new file mode 100644 index 00000000..14dc9a5a --- /dev/null +++ b/src/plusplugins/contextMenuSelectFix/index.tsx @@ -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); + } +}); diff --git a/src/plusplugins/noDraftLengthLimit/index.ts b/src/plusplugins/noDraftLengthLimit/index.ts new file mode 100644 index 00000000..bf1aaa65 --- /dev/null +++ b/src/plusplugins/noDraftLengthLimit/index.ts @@ -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" + } + } + ] +}); diff --git a/src/plusplugins/voiceChannelLog/VoiceChannelLogEntryComponent.css b/src/plusplugins/voiceChannelLog/VoiceChannelLogEntryComponent.css new file mode 100644 index 00000000..a9190c62 --- /dev/null +++ b/src/plusplugins/voiceChannelLog/VoiceChannelLogEntryComponent.css @@ -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; +} diff --git a/src/plusplugins/voiceChannelLog/VoiceChannelLogEntryComponent.tsx b/src/plusplugins/voiceChannelLog/VoiceChannelLogEntryComponent.tsx new file mode 100644 index 00000000..6f0e9aee --- /dev/null +++ b/src/plusplugins/voiceChannelLog/VoiceChannelLogEntryComponent.tsx @@ -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
  • + + + Util.openUserProfile(logEntry.userId)} + src={user.getAvatarURL(channel.getGuildId())} + // style={{ left: "0px", zIndex: 0 }} + /> +
    + {/* */} +
    +
  • ; + }; +}); diff --git a/src/plusplugins/voiceChannelLog/VoiceChannelLogEntryIcons.tsx b/src/plusplugins/voiceChannelLog/VoiceChannelLogEntryIcons.tsx new file mode 100644 index 00000000..3e15bbdb --- /dev/null +++ b/src/plusplugins/voiceChannelLog/VoiceChannelLogEntryIcons.tsx @@ -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 = ; + // Taken from /assets/192510ade1abc3149b46.svg + const Leave = ; + // 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 = ; + const MovedFrom = ; + + 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 ; +} diff --git a/src/plusplugins/voiceChannelLog/VoiceChannelLogModal.tsx b/src/plusplugins/voiceChannelLog/VoiceChannelLogModal.tsx new file mode 100644 index 00000000..d0110c3d --- /dev/null +++ b/src/plusplugins/voiceChannelLog/VoiceChannelLogModal.tsx @@ -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 => ( + + )); +} + +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(
    + + {logEntry.timestamp.toDateString()} + +
    ); + } else { + logElements.push(); + } + } + } else { + logElements.push(
    No logs to display.
    ); + } + + return ( + + + {channel.name} logs + + + + + + {logElements} + + + + ); + }; +}); diff --git a/src/plusplugins/voiceChannelLog/index.tsx b/src/plusplugins/voiceChannelLog/index.tsx new file mode 100644 index 00000000..3ca588a9 --- /dev/null +++ b/src/plusplugins/voiceChannelLog/index.tsx @@ -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 = MessageStore.hasPresent(channelId) ? new Promise(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, ( + { 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); + }, +}); diff --git a/src/plusplugins/voiceChannelLog/logs.ts b/src/plusplugins/voiceChannelLog/logs.ts new file mode 100644 index 00000000..9e165180 --- /dev/null +++ b/src/plusplugins/voiceChannelLog/logs.ts @@ -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(); +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); + }; +} diff --git a/src/plusplugins/voiceJoinMessages/index.ts b/src/plusplugins/voiceJoinMessages/index.ts new file mode 100644 index 00000000..800672f9 --- /dev/null +++ b/src/plusplugins/voiceJoinMessages/index.ts @@ -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 = MessageStore.hasPresent(channelId) ? new Promise(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); + } + }); + }, + }, +});