diff --git a/src/equicordplugins/soundBoardLogger/components/Icons.tsx b/src/equicordplugins/soundBoardLogger/components/Icons.tsx deleted file mode 100644 index 155351a07..000000000 --- a/src/equicordplugins/soundBoardLogger/components/Icons.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons"; -import { cl } from "@equicordplugins/soundBoardLogger/utils"; -import { classes } from "@utils/misc"; -import { Button, Tooltip } from "@webpack/common"; - -import { openSoundBoardLog } from "./SoundBoardLog"; - -// Thanks svgrepo.com for the play and download icons. -// Licensed under CC Attribution License https://www.svgrepo.com/page/licensing/#CC%20Attribution - -// https://www.svgrepo.com/svg/438144/multimedia-play-icon-circle-button -export function PlayIcon() { - return ( - - ); -} - -// https://www.svgrepo.com/svg/528952/download -export function DownloadIcon() { - return ( - - ); -} - -export function LogIcon({ height = 24, width = 24, className = "chatBarLogIcon" }: { height?: number; width?: number; className?: string; }) { - return ( - - ); -} - -export function IconWithTooltip({ text, icon, onClick }) { - return - {({ onMouseEnter, onMouseLeave }) => ( -
- -
- )} -
; -} - -export const CharBarButton: ChatBarButtonFactory = ({ isMainChat }) => { - if (!isMainChat) return null; - return ( - - - - ); -}; - -export const ChatBarIcon = () => { - return - - ; -}; diff --git a/src/equicordplugins/soundBoardLogger/components/MoreUsersModal.tsx b/src/equicordplugins/soundBoardLogger/components/MoreUsersModal.tsx deleted file mode 100644 index 094bb47bd..000000000 --- a/src/equicordplugins/soundBoardLogger/components/MoreUsersModal.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { BaseText } from "@components/BaseText"; -import ErrorBoundary from "@components/ErrorBoundary"; -import { Flex } from "@components/Flex"; -import { HeadingPrimary } from "@components/Heading"; -import { cl, getEmojiUrl, SoundLogEntry, User } from "@equicordplugins/soundBoardLogger/utils"; -import { closeModal, ModalContent, ModalRoot, openModal } from "@utils/modal"; -import { Clickable } from "@webpack/common"; - -export function openMoreUsersModal(item: SoundLogEntry, users: User[], onClickUser: Function) { - const key = openModal(props => ( - - - closeModal(key)} /> - - - )); -} - -export default function MoreUsersModal({ item, users, onClickUser, closeModal }: { item: SoundLogEntry, users: User[], onClickUser: Function, closeModal: Function; }) { - return ( - -
- - {item.soundId} -
-
- {users.map(user => { - const currentUser = item.users.find(({ id }) => id === user.id) ?? { id: "", plays: [0] }; - return ( - { - closeModal(); - onClickUser(item, user); - }} key={user.id}> -
- - - {user.username} - - Played {currentUser.plays.length} {currentUser.plays.length === 1 ? "time" : "times"} -
-
- ); - })} -
-
- ); -} diff --git a/src/equicordplugins/soundBoardLogger/components/SoundBoardLog.tsx b/src/equicordplugins/soundBoardLogger/components/SoundBoardLog.tsx deleted file mode 100644 index b619eeebd..000000000 --- a/src/equicordplugins/soundBoardLogger/components/SoundBoardLog.tsx +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { HeaderBarButton } from "@api/HeaderBar"; -import { BaseText } from "@components/BaseText"; -import ErrorBoundary from "@components/ErrorBoundary"; -import { Flex } from "@components/Flex"; -import settings from "@equicordplugins/soundBoardLogger/settings"; -import { clearLoggedSounds, getLoggedSounds } from "@equicordplugins/soundBoardLogger/store"; -import { addListener, AvatarStyles, cl, downloadAudio, getEmojiUrl, playSound, removeListener, SoundLogEntry, UserSummaryItem } from "@equicordplugins/soundBoardLogger/utils"; -import { copyWithToast } from "@utils/discord"; -import { Margins } from "@utils/margins"; -import { classes } from "@utils/misc"; -import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; -import { User } from "@vencord/discord-types"; -import { Button, Clickable, Tooltip, useEffect, UserUtils, useState } from "@webpack/common"; - -import { LogIcon } from "./Icons"; -import { openMoreUsersModal } from "./MoreUsersModal"; -import { openUserModal } from "./UserModal"; - -export async function openSoundBoardLog(): Promise { - - const data = await getLoggedSounds(); - const key = openModal(props => - - closeModal(key)} /> - - ); - -} - -export function OpenSBLogsButton() { - return ( - openSoundBoardLog()} - tooltip={"Open SoundBoard Log"} - icon={LogIcon} - /> - ); -} - -export default function SoundBoardLog({ data, closeModal }) { - const [sounds, setSounds] = useState(data); - const [users, setUsers] = useState([]); - const update = async () => setSounds(await getLoggedSounds()); - - // Update the sounds state when a new sound is played - useEffect(() => { - const onSound = () => update(); - addListener(onSound); - return () => removeListener(onSound); - }, []); - - const avatarsMax = 2; - - // Update the users state when a new sound is played - useEffect(() => { - (async () => { - /** Array of user IDs without a resolved user object */ - const missing = sounds - .flatMap(sound => sound.users) // Get all users who have used any sound - .map(user => user.id) // Get their ID ( user is {id: string, plays: number[]} ) - .filter((id, index, self) => index === self.indexOf(id)) // Filter the array to remove non unique values - .filter(id => !users.map(user => user.id).includes(id)); // Filter the IDs to only get the ones not already in the users state - if (!missing.length) return; // return if every user ID is already in users - - for (const id of missing) { - const user = await UserUtils.getUser(id).catch(() => void 0); - if (user) setUsers(u => [...u, user]); - } - })(); - }, [sounds]); - - function renderMoreUsers(item, itemUsers) { - return ( - { - onClickShowMoreUsers(item, itemUsers); - }} - > - - {({ onMouseEnter, onMouseLeave }) => ( -
- +{itemUsers.length - avatarsMax} -
- )} -
-
- ); - } - - /** This function is called when you click the "Show more users" button. */ - function onClickShowMoreUsers(item: SoundLogEntry, users: User[]): void { - openMoreUsersModal(item, users, onClickUser); - } - - function onClickUser(item: SoundLogEntry, user: User) { - openUserModal(item, user, sounds); - } - - return ( - <> - - SoundBoard log - - - - {sounds.length ? sounds.map(item => { - const itemUsers = users.filter(user => item.users.map(u => u.id).includes(user.id)); - - return ( -
- - - {item.soundId} - - renderMoreUsers(item, itemUsers)} - className={cl("sound-users")} - renderUser={(user: User) => ( - { - onClickUser(item, user); - }} - > - {user.username} - - )} - /> - - - - - {({ onMouseEnter, onMouseLeave }) => - - } - - -
- ); - }) : -
- - No sounds logged yet. Join a voice chat to start logging! -
- } -
- - - - - ); -} diff --git a/src/equicordplugins/soundBoardLogger/components/UserModal.tsx b/src/equicordplugins/soundBoardLogger/components/UserModal.tsx deleted file mode 100644 index c9fab21b4..000000000 --- a/src/equicordplugins/soundBoardLogger/components/UserModal.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { BaseText } from "@components/BaseText"; -import { Flex } from "@components/Flex"; -import { HeadingPrimary } from "@components/Heading"; -import { CopyIcon } from "@components/Icons"; -import { AvatarStyles, cl, downloadAudio, getEmojiUrl, playSound, SoundLogEntry, User, UserSummaryItem } from "@equicordplugins/soundBoardLogger/utils"; -import { copyWithToast, openUserProfile } from "@utils/discord"; -import { Margins } from "@utils/margins"; -import { classes } from "@utils/misc"; -import { closeModal, ModalContent, ModalRoot, openModal } from "@utils/modal"; -import { Clickable, Timestamp } from "@webpack/common"; -import moment from "moment"; - -import { DownloadIcon, IconWithTooltip, PlayIcon } from "./Icons"; - -export function openUserModal(item, user, sounds) { - const key = openModal(props => - - closeModal(key)} /> - - ); -} - -export default function UserModal({ item, user, sounds, closeModal }: { item: SoundLogEntry, user: User, sounds: SoundLogEntry[], closeModal: Function; }) { - const currentUser = item.users.find(({ id }) => id === user.id) ?? { id: "", plays: [0] }; - const soundsDoneByCurrentUser = sounds.filter(sound => sound.users.some(itemUser => itemUser.id === user.id) && sound.soundId !== item.soundId); - - return ( - - { - closeModal(); - openUserProfile(user.id); - }}> -
- - {user.username} -
-
- - - - {item.soundId} - Played {currentUser.plays.length} {currentUser.plays.length === 1 ? "time" : "times"}. - Last played: - - - - {soundsDoneByCurrentUser.length ? "Also played:" : " "} - - - -
-
- ... -
-
- } - className={cl("user-sounds")} - renderUser={({ soundId, emoji }) => ( - { - closeModal(); - openUserModal(sounds.find(sound => sound.soundId === soundId), user, sounds); - }} - > - {soundId} - - )} - /> -
- } onClick={() => downloadAudio(item.soundId)} /> - } onClick={() => copyWithToast(item.soundId, "ID copied to clipboard!")} /> - } onClick={() => playSound(item.soundId)} /> -
-
-
- ); -} diff --git a/src/equicordplugins/soundBoardLogger/index.tsx b/src/equicordplugins/soundBoardLogger/index.tsx deleted file mode 100644 index e704db318..000000000 --- a/src/equicordplugins/soundBoardLogger/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { disableStyle, enableStyle } from "@api/Styles"; -import { Devs, EquicordDevs } from "@utils/constants"; -import definePlugin from "@utils/types"; -import { FluxDispatcher } from "@webpack/common"; - -import { LogIcon } from "./components/Icons"; -import { OpenSBLogsButton } from "./components/SoundBoardLog"; -import settings from "./settings"; -import { updateLoggedSounds } from "./store"; -import styles from "./styles.css?managed"; -import { getListeners } from "./utils"; - -export default definePlugin({ - name: "SoundBoardLogger", - authors: [Devs.Moxxie, EquicordDevs.Fres, Devs.amy, Devs.thororen], - description: "Logs all soundboards that are played in a voice chat and allows you to download them", - dependencies: ["AudioPlayerAPI"], - settings, - - headerBarButton: { - icon: LogIcon, - render: OpenSBLogsButton - }, - start() { - enableStyle(styles); - FluxDispatcher.subscribe("VOICE_CHANNEL_EFFECT_SEND", async sound => { - if (!sound?.soundId) return; - await updateLoggedSounds(sound); - getListeners().forEach(cb => cb()); - }); - }, - stop() { - disableStyle(styles); - } -}); diff --git a/src/equicordplugins/soundBoardLogger/settings.tsx b/src/equicordplugins/soundBoardLogger/settings.tsx deleted file mode 100644 index 47b6b037e..000000000 --- a/src/equicordplugins/soundBoardLogger/settings.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { definePluginSettings } from "@api/Settings"; -import { Heading } from "@components/Heading"; -import { Paragraph } from "@components/Paragraph"; -import { OptionType } from "@utils/types"; -import { Button, TextInput, useState } from "@webpack/common"; - -import { openSoundBoardLog } from "./components/SoundBoardLog"; - -const settings = definePluginSettings({ - SavedIds: { - description: "The amount of soundboard ids you want to save at a time (0 lets you save infinite)", - type: OptionType.COMPONENT, - component: ({ setValue }) => { - const value = settings.store.SavedIds ?? 50; - const [state, setState] = useState(`${value}`); - const [shouldShowWarning, setShouldShowWarning] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - - function handleChange(newValue) { - const changed = Number(newValue); - - if (Number.isNaN(changed) || changed % 1 !== 0 || changed < 0) { - let errorMsg = ""; - errorMsg += Number.isNaN(changed) ? "The value is not a number.\n" : ""; - errorMsg += (changed % 1 !== 0) ? "The value can't be a decimal number.\n" : ""; - errorMsg += (changed < 0) ? "The value can't be a negative number.\n" : ""; - setErrorMessage(errorMsg); - return; - } else { - setErrorMessage(null); - } - - if (changed < value) { - setShouldShowWarning(true); - } else { - setShouldShowWarning(false); - } - setState(newValue); - setValue(changed); - } - - return ( -
- The amount of soundboard ids you want to save at a time (0 lets you save infinite) - - {shouldShowWarning && Warning! Setting the number to a lower value will reset the log!} - {errorMessage && {errorMessage}} -
- ); - } - - }, - FileType: { - description: "the format that you want to save your file", - type: OptionType.SELECT, - options: [ - { label: ".ogg", value: ".ogg", default: true }, - { label: ".mp3", value: ".mp3" }, - { label: ".wav", value: ".wav" }, - ], - }, - OpenLogs: { - type: OptionType.COMPONENT, - description: "show the logs", - component: () => - - }, - soundVolume: { - description: "How loud the toggle sound is (0 to disable)", - type: OptionType.SLIDER, - default: 0.5, - markers: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1] - }, -}); - -export default settings; diff --git a/src/equicordplugins/soundBoardLogger/store.tsx b/src/equicordplugins/soundBoardLogger/store.tsx deleted file mode 100644 index 4532e1f40..000000000 --- a/src/equicordplugins/soundBoardLogger/store.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { DataStore } from "@api/index"; - -import settings from "./settings"; -import { SoundEvent, SoundLogEntry } from "./utils"; - -/** Attempts to add a sound event to the log */ -export async function updateLoggedSounds(sound: SoundEvent): Promise { - const data = await getLoggedSounds(); - - if (!data) { - await DataStore.set("SoundBoardLogList", [{ ...sound, users: [{ id: sound.userId, plays: [+Date.now()] }] }]); - } else { - if (data.some(item => item.soundId === sound.soundId)) { - const newSounds = data.map(item => { - if (item.soundId !== sound.soundId) return item; - return { - ...item, - users: item.users.some(user => user.id === sound.userId) ? - item.users.map(user => { - if (user.id !== sound.userId) return user; - return { id: sound.userId, plays: [...user.plays, +Date.now()] }; - }) : - [ - ...item.users, - { id: sound.userId, plays: [+Date.now()] } - ] - }; - }); - await DataStore.set("SoundBoardLogList", newSounds); - return; - } - - let limit = settings.store.SavedIds ?? 50; - if (limit === 0) limit = Infinity; - const modified = [{ ...sound, users: [{ id: sound.userId, plays: [+Date.now()] }] }, ...data].slice(0, limit); - - await DataStore.set("SoundBoardLogList", modified); - } -} - -/** Clears the logged sounds array */ -export async function clearLoggedSounds() { - await DataStore.set("SoundBoardLogList", []); -} - -/** Returns an array with the logged sounds */ -export async function getLoggedSounds(): Promise { - const data = await DataStore.get("SoundBoardLogList"); - if (!data) { - DataStore.set("SoundBoardLogList", []); - return []; - } - else { - return data; - } -} diff --git a/src/equicordplugins/soundBoardLogger/styles.css b/src/equicordplugins/soundBoardLogger/styles.css deleted file mode 100644 index 6bec08122..000000000 --- a/src/equicordplugins/soundBoardLogger/styles.css +++ /dev/null @@ -1,151 +0,0 @@ -.vc-soundlog-more-user, -.vc-soundlog-sound { - box-sizing: border-box; - position: relative; - background: var(--background-secondary-alt); - border-radius: var(--radius-sm); - height: 40px; - width: 100%; - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - font-weight: 600; - border: 1px solid transparent; - overflow: hidden; - text-align: center; - margin-top: 10px; - padding-left: 10px; - padding-right: 10px -} - -.vc-soundlog-more-user-profile, -.vc-soundlog-sound-info { - gap: 10px; - align-content: center; - align-items: center; - flex: 2; -} - -.vc-soundlog-sound-buttons { - gap: 10px; - align-content: center; - flex: 1; -} - -.vc-soundlog-sound-users { - flex: 2; -} - - -.vc-soundlog-sound-emoji { - width: 24px; - height: 24px; -} - -.vc-soundlog-more, -.vc-soundlog-user { - padding: 1em; -} - -.vc-soundlog-more-header, -.vc-soundlog-user-header { - display: flex; - align-items: center; - margin-bottom: 1em; - width: fit-content; -} - -.vc-soundlog-more-soundid, -.vc-soundlog-user-name { - text-transform: none; - flex-grow: 0; - background: var(--background-base-low); - border-radius: 0 9999px 9999px 0; - padding: 6px 0.8em 6px 0.5em; - font-size: 20px; - height: 20px; - position: relative; - text-wrap: nowrap; -} - -.vc-soundlog-more-soundid { - background: unset; -} - -.vc-soundlog-user-name::before { - content: ""; - display: block; - position: absolute; - height: 100%; - width: 16px; - background: var(--background-base-low); - z-index: -1; - left: -16px; - top: 0; -} - - -.vc-soundlog-more-emoji, -.vc-soundlog-user-avatar { - height: 32px; - width: 32px; - border-radius: 50%; -} - -.vc-soundlog-more-emoji { - border-radius: 0%; -} - -.vc-soundlog-user-buttons { - margin-left: auto; - display: flex; - gap: 0.2em; -} - -.vc-soundlog-user-sound-emoji { - height: 48px; - margin-top: 20px; -} - -/* input style from https://github.com/Vendicated/Vencord/blob/ea11f2244fde469ce308f8a4e7224430be62f8f1/src/plugins/sendTimestamps/styles.css#L1-L13 */ -.vc-soundlog-clone-input { - background-color: var(--input-background-default, var(--input-background)); - color: var(--text-default); - width: 100%; - padding: 8px 8px 8px 12px; - outline: none; - border: 1px solid var(--input-background-default, var(--input-background)); - border-radius: 4px; - font-weight: 500; - font-style: inherit; - font-size: 100%; - box-sizing: border-box; - height: 40px; -} - -/* FIXME: bad name -_7f7a2c5f23327d73 */ -.vc-soundlog-discord-button { - opacity: .7; - align-items: center; - align-self: center; - border-radius: 8px; - cursor: pointer; - display: flex; - justify-content: center; - padding: 10px; - - &:hover { - background: hsl(0deg 0% 100% / 10%); - opacity: 1 - } -} - -/* _3f4f204c3133fb93 */ -.vc-soundlog-button-wrapper { - align-self: center; - display: flex; - flex-direction: row; - gap: 8px; -} \ No newline at end of file diff --git a/src/equicordplugins/soundBoardLogger/utils.ts b/src/equicordplugins/soundBoardLogger/utils.ts deleted file mode 100644 index fcb811e2c..000000000 --- a/src/equicordplugins/soundBoardLogger/utils.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { playAudio } from "@api/AudioPlayer"; -import { classNameFactory } from "@utils/css"; -import { LazyComponent } from "@utils/react"; -import { saveFile } from "@utils/web"; -import type { User } from "@vencord/discord-types"; -import { findByCode, findByPropsLazy, findCssClassesLazy } from "@webpack"; - -import settings from "./settings"; - -export { User }; - -export interface SoundEvent { - type: "VOICE_CHANNEL_EFFECT_SEND", - emoji: { name: string, id?: string, animated: boolean; }, - channelId: string, - userId: string, - animationType: number, - animationId: number, - soundId: string, - soundVolume: number; -} - -export interface SoundLogEntry extends SoundEvent { - users: { id: string, plays: number[]; }[]; -} - -export const cl = classNameFactory("vc-soundlog-"); -const EmojiManager = findByPropsLazy("getEmojiColors", "getURL"); - -export function getEmojiUrl(emoji) { - if (!emoji) return EmojiManager.getURL("❓"); // If the sound doesn't have a related emoji - return emoji.id ? `https://cdn.discordapp.com/emojis/${emoji.id}.png?size=32` : EmojiManager.getURL(emoji.name); -} - -export const playSound = id => { - playAudio(`https://cdn.discordapp.com/soundboard-sounds/${id}`, { volume: settings.store.soundVolume * 100 }); -}; - -export async function downloadAudio(id: string): Promise { - const filename = id + settings.store.FileType; - const original = await fetch(`https://cdn.discordapp.com/soundboard-sounds/${id}`).then(res => res.arrayBuffer()); - - if (IS_DISCORD_DESKTOP) { - const data = new Uint8Array(original); - DiscordNative.fileManager.saveWithDialog(data, filename); - } else { - saveFile(new File([original], filename, { type: "audio/ogg" })); - } -} - -let listeners: Function[] = []; - -export function getListeners(): Function[] { - return listeners; -} - -export function addListener(fn): void { - listeners.push(fn); -} - -export function removeListener(fn): void { - listeners = listeners.filter(f => f !== fn); -} - -// Taken from https://github.com/Vendicated/Vencord/blob/86e94343cca10b950f2dc8d18d496d6db9f3b728/src/components/PluginSettings/PluginModal.tsx#L45 -export const UserSummaryItem = LazyComponent(() => findByCode("defaultRenderUser", "showDefaultAvatarsForNullUsers")); -export const AvatarStyles = findCssClassesLazy("moreUsers", "emptyUser", "avatarContainer", "clickableAvatar", "avatar"); diff --git a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.css b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.css deleted file mode 100644 index 4a08e56f9..000000000 --- a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.css +++ /dev/null @@ -1,42 +0,0 @@ -.vc-voice-channel-log { - list-style-type: none; - height: 50px; - display: grid; - grid-template-columns: 2.25rem 24px 40px max-content; - gap: 4px; - width: 4px; - background-color: var(--border-subtle); -} - -.vc-voice-channel-log-date-separator, -.vc-voice-channel-log-timestamp { - margin-left: 4px; - height: 48px; - line-height: 48px; - font-size: .75rem; - text-align: center; - display: inline-block; - vertical-align: middle; - color: var(--text-default, white); -} - -.vc-voice-channel-log-avatar { - height: 40px; - width: 100%; - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - font-weight: 600; - overflow: hidden; - text-align: center; - margin-top: 10px; - padding-left: 10px; - padding-right: 10px; - cursor: pointer; -} - -.vc-voice-channel-log-icon { - margin: auto 3px; - margin-left: 15px; -} \ No newline at end of file diff --git a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.tsx b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.tsx deleted file mode 100644 index 0ce2611ef..000000000 --- a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryComponent.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import "./VoiceChannelLogEntryComponent.css"; - -import { VoiceChannelLogEntry } from "@equicordplugins/voiceChannelLog.dev/logs"; -import { classes } from "@utils/misc"; -import { Channel } from "@vencord/discord-types"; -import { React, Timestamp, Tooltip, UserStore } from "@webpack/common"; -import { Util } from "Vencord"; - -import { cl } from ".."; -import Icon from "./VoiceChannelLogEntryIcons"; - -export function VoiceChannelLogEntryComponent({ logEntry, channel }: { logEntry: VoiceChannelLogEntry; channel: Channel; }) { - const user = UserStore.getUser(logEntry.userId); - return
  • - - - - {(tooltipProps: any) => ( - Util.openUserProfile(logEntry.userId)} - src={user.getAvatarURL(channel.getGuildId())} - /> - )} - -
    - { } -
    -
  • ; -} diff --git a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryIcons.tsx b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryIcons.tsx deleted file mode 100644 index 0054323f4..000000000 --- a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogEntryIcons.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { VoiceChannelLogEntry } from "@equicordplugins/voiceChannelLog.dev/logs"; -import { classes } from "@utils/misc"; -import { Channel } from "@vencord/discord-types"; -import { React } from "@webpack/common"; - -import { cl } from ".."; - -export default function Icon({ logEntry, channel, className }: { logEntry: VoiceChannelLogEntry; channel: Channel; className: string; }) { - // Taken from /assets/7378a83d74ce97d83380.svg - const Join = ; - // 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/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogModal.tsx b/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogModal.tsx deleted file mode 100644 index 6315e8b06..000000000 --- a/src/equicordplugins/voiceChannelLog.dev/components/VoiceChannelLogModal.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { BaseText } from "@components/BaseText"; -import { getVcLogs, vcLogSubscribe } from "@equicordplugins/voiceChannelLog.dev/logs"; -import { classes } from "@utils/misc"; -import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; -import { Channel } from "@vencord/discord-types"; -import { findStoreLazy } from "@webpack"; -import { React, ScrollerThin } from "@webpack/common"; - -import { cl } from ".."; -import { VoiceChannelLogEntryComponent } from "./VoiceChannelLogEntryComponent"; - -const AccessibilityStore = findStoreLazy("AccessibilityStore"); - -export function openVoiceChannelLog(channel: Channel) { - return openModal(props => ( - - )); -} - -export function VoiceChannelLogModal({ channel, props }: { channel: Channel; props: ModalProps; }) { - React.useSyncExternalStore(vcLogSubscribe, () => getVcLogs(channel.id)); - const vcLogs = getVcLogs(channel.id); - const logElements: (React.ReactNode)[] = []; - - if (vcLogs.length > 0) { - for (let i = 0; i < vcLogs.length; i++) { - const logEntry = vcLogs[i]; - if (i === 0 || logEntry.timestamp.toDateString() !== vcLogs[i - 1].timestamp.toDateString()) { - logElements.push(
    - - {logEntry.timestamp.toDateString()} - -
    ); - } else { - logElements.push(); - } - } - } else { - logElements.push(
    No logs to display.
    ); - } - - return ( - - - {channel.name} logs - - - - - - {logElements} - - - - ); -} diff --git a/src/equicordplugins/voiceChannelLog.dev/index.tsx b/src/equicordplugins/voiceChannelLog.dev/index.tsx deleted file mode 100644 index 94941f6e1..000000000 --- a/src/equicordplugins/voiceChannelLog.dev/index.tsx +++ /dev/null @@ -1,164 +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 { definePluginSettings } from "@api/Settings"; -import { Devs, EquicordDevs } from "@utils/constants"; -import { classNameFactory } from "@utils/css"; -import definePlugin, { OptionType } from "@utils/types"; -import { Message, User } from "@vencord/discord-types"; -import { findByCodeLazy } from "@webpack"; -import { FluxDispatcher, Menu, MessageActions, MessageStore, RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common"; - -import { openVoiceChannelLog } from "./components/VoiceChannelLogModal"; -import { addLogEntry } from "./logs"; - -export const cl = classNameFactory("vc-voice-channel-log-"); -const createBotMessage = findByCodeLazy('username:"Clyde"'); - -const settings = definePluginSettings({ - mode: { - type: OptionType.SELECT, - description: "How to show the voice channel log", - options: [ - { label: "Log menu", value: 1, default: true }, - { label: "Log to associated chat directly", value: 2 }, - { label: "Log to chat and log menu", value: 3 }, - ] - }, - voiceChannelChatSelf: { - type: OptionType.BOOLEAN, - description: "Log your own voice channel events in the voice channels", - default: true - }, - voiceChannelChatSilent: { - type: OptionType.BOOLEAN, - description: "Join/leave/move messages in voice channel chats will be silent", - default: true - }, - voiceChannelChatSilentSelf: { - type: OptionType.BOOLEAN, - description: "Join/leave/move messages in voice channel chats will be silent if you are in the voice channel", - default: false - }, - ignoreBlockedUsers: { - type: OptionType.BOOLEAN, - description: "Do not log blocked users", - default: false - }, -}); - -interface VoiceState { - guildId?: string; - channelId?: string; - oldChannelId?: string; - user: User; - userId: string; -} - -function getMessageFlags(selfInChannel: boolean) { - let flags = 1 << 6; - if (selfInChannel ? settings.store.voiceChannelChatSilentSelf : settings.store.voiceChannelChatSilent) flags += 1 << 12; - return flags; -} - -function sendVoiceStatusMessage(channelId: string, content: string, userId: string, selfInChannel: boolean): Message | null { - if (!channelId) return null; - - const message: Message = createBotMessage({ channelId, content, embeds: [] }); - message.flags = getMessageFlags(selfInChannel); - message.author = UserStore.getUser(userId); - // If we try to send a message into an unloaded channel, the client-sided messages get overwritten when the channel gets loaded - // This might be messy but It Works:tm: - const messagesLoaded: Promise = 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; -} - -const patchChannelContextMenu: NavContextMenuPatchCallback = (children, { channel }) => { - const group = findGroupChildrenByChildId("mark-channel-read", children) ?? children; - group.push( - { openVoiceChannelLog(channel); }} - /> - ); -}; - -// Blatantly stolen from VcNarrator plugin - -// For every user, channelId and oldChannelId will differ when moving channel. -// Only for the local user, channelId and oldChannelId will be the same when moving channel, -// for some ungodly reason -let clientOldChannelId: string | undefined; - -export default definePlugin({ - name: "VoiceChannelLog", - description: "Logs who joins and leaves voice channels", - authors: [Devs.Sqaaakoi, Devs.thororen, EquicordDevs.nyx], - contextMenus: { - "channel-context": patchChannelContextMenu - }, - settings, - flux: { - VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { - const clientUserId = UserStore.getCurrentUser().id; - voiceStates.forEach(state => { - // mmmm hacky workaround - const { userId, channelId } = state; - const user = UserStore.getUser(userId) as User & { globalName: string; }; - const username = user.globalName || user.username; - let { oldChannelId } = state; - if (userId === clientUserId && channelId !== clientOldChannelId) { - oldChannelId = clientOldChannelId; - clientOldChannelId = channelId; - } - if (settings.store.ignoreBlockedUsers && RelationshipStore.isBlocked(userId)) return; - // Ignore events from same channel - if (oldChannelId === channelId) return; - - const logEntry = { - userId, - username, - oldChannel: oldChannelId || null, - newChannel: channelId || null, - timestamp: new Date() - }; - - if (settings.store.mode !== 2) { - addLogEntry(logEntry, oldChannelId); - addLogEntry(logEntry, channelId); - } - - if (!settings.store.voiceChannelChatSelf && userId === clientUserId) return; - if (settings.store.mode === 1) return; - // Join / Leave - if ((!oldChannelId && channelId) || (oldChannelId && !channelId)) { - // empty string is to make type checker shut up - const targetChannelId = oldChannelId || channelId || ""; - const selfInChannel = SelectedChannelStore.getVoiceChannelId() === targetChannelId; - sendVoiceStatusMessage(targetChannelId, `${(channelId ? "Joined" : "Left")} <#${targetChannelId}>`, userId, selfInChannel); - } - // Move between channels - if (oldChannelId && channelId) { - sendVoiceStatusMessage(oldChannelId, `Moved to <#${channelId}>`, userId, SelectedChannelStore.getVoiceChannelId() === oldChannelId); - sendVoiceStatusMessage(channelId, `Moved from <#${oldChannelId}>`, userId, SelectedChannelStore.getVoiceChannelId() === channelId); - } - - }); - }, - } -}); diff --git a/src/equicordplugins/voiceChannelLog.dev/logs.ts b/src/equicordplugins/voiceChannelLog.dev/logs.ts deleted file mode 100644 index 6fefe4ba7..000000000 --- a/src/equicordplugins/voiceChannelLog.dev/logs.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -export interface VoiceChannelLogEntry { - userId: string; - username: string; - oldChannel: string | null; - newChannel: string | null; - timestamp: Date; -} - -export const vcLogs = new Map(); -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/equicordplugins/voiceChannelLog/components/LogsButton.tsx b/src/equicordplugins/voiceChannelLog/components/LogsButton.tsx new file mode 100644 index 000000000..7d6f29c8b --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/components/LogsButton.tsx @@ -0,0 +1,40 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { HeaderBarButton } from "@api/HeaderBar"; +import { ChannelStore, SelectedChannelStore, useStateFromStores } from "@webpack/common"; + +import { cl } from "../utils"; +import { openVoiceChannelLog } from "./VoiceChannelLogModal"; + +function LogIcon({ height = 24, width = 24 }: { height?: number; width?: number; }) { + return ( + + + + + ); +} + +export function OpenLogsButton() { + const voiceChannelId = useStateFromStores([SelectedChannelStore], () => SelectedChannelStore.getVoiceChannelId()); + + if (!voiceChannelId) return null; + + const channel = ChannelStore.getChannel(voiceChannelId); + if (!channel) return null; + + return ( + openVoiceChannelLog(channel)} + tooltip="Voice Channel Logs" + icon={LogIcon} + /> + ); +} + +export { LogIcon }; diff --git a/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryComponent.css b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryComponent.css new file mode 100644 index 000000000..100965cf8 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryComponent.css @@ -0,0 +1,178 @@ +.vc-voice-channel-log-entry { + list-style-type: none; + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + min-height: 44px; + border-radius: 4px; +} + +.vc-voice-channel-log-entry:hover { + background-color: var(--background-modifier-hover); +} + +.vc-voice-channel-log-entry-timestamp { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 72px; + flex-shrink: 0; +} + +.vc-voice-channel-log-timestamp { + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.3; +} + +.vc-voice-channel-log-elapsed { + font-size: 0.625rem; + color: var(--text-muted); + line-height: 1.3; +} + +.vc-voice-channel-log-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; +} + +.vc-voice-channel-log-icon-brand { + color: var(--brand-experiment); +} + +.vc-voice-channel-log-icon-danger { + color: var(--status-danger); +} + +.vc-voice-channel-log-icon-positive { + color: var(--status-positive); +} + +.vc-voice-channel-log-icon-warning { + color: var(--status-warning); +} + +.vc-voice-channel-log-app-icon { + width: 20px; + height: 20px; + border-radius: 4px; + flex-shrink: 0; +} + +.vc-voice-channel-log-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + flex-shrink: 0; + object-fit: cover; +} + +.vc-voice-channel-log-avatar:hover { + opacity: 0.8; +} + +.vc-voice-channel-log-content { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.vc-voice-channel-log-username { + font-weight: 600; + font-size: 0.875rem; + color: var(--text-strong); + cursor: pointer; + width: fit-content; +} + +.vc-voice-channel-log-username:hover { + text-decoration: underline; +} + +.vc-voice-channel-log-description { + font-size: 0.8125rem; + color: var(--text-subtle); +} + +.vc-voice-channel-log-soundboard-info { + display: flex; + align-items: center; + gap: 6px; +} + +.vc-voice-channel-log-soundboard-emoji { + width: 16px; + height: 16px; +} + +.vc-voice-channel-log-soundboard-id { + font-size: 0.8125rem; + color: var(--text-subtle); +} + +.vc-voice-channel-log-soundboard-actions { + display: flex; + align-items: center; + gap: 4px; + margin-left: 4px; +} + +.vc-voice-channel-log-soundboard-action { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + color: var(--interactive-icon-default); + cursor: pointer; +} + +.vc-voice-channel-log-soundboard-action:hover { + color: var(--interactive-icon-active); + background-color: var(--background-modifier-hover); +} + +.vc-voice-channel-log-date-separator { + display: flex; + align-items: center; + margin: 8px 16px; + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.vc-voice-channel-log-date-separator::before, +.vc-voice-channel-log-date-separator::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border-subtle); +} + +.vc-voice-channel-log-date-separator span { + padding: 0 8px; +} + +.vc-voice-channel-log-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 32px; + color: var(--text-muted); + font-size: 0.875rem; +} + +.vc-voice-channel-log-scroller { + padding: 8px 0; +} diff --git a/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryComponent.tsx b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryComponent.tsx new file mode 100644 index 000000000..e343de4ef --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryComponent.tsx @@ -0,0 +1,127 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./VoiceChannelLogEntryComponent.css"; + +import { openUserProfile } from "@utils/discord"; +import { closeAllModals } from "@utils/modal"; +import { Channel } from "@vencord/discord-types"; +import { ApplicationStore, Clickable, IconUtils, NavigationRouter, Timestamp, Tooltip, UserStore } from "@webpack/common"; + +import { getCallStartTime } from "../logs"; +import { VoiceChannelLogEntry } from "../types"; +import { cl, downloadSound, formatElapsedTime, getEmojiUrl, playSound } from "../utils"; +import EventIcon from "./VoiceChannelLogEntryIcons"; + +function getEventDescription(entry: VoiceChannelLogEntry): string { + switch (entry.type) { + case "join": + return "Joined the channel"; + case "leave": + return "Left the channel"; + case "move": + if (entry.newChannelId && entry.oldChannelId && entry.channelId === entry.oldChannelId) + return "Moved to another channel"; + return "Moved from another channel"; + case "soundboard": + return ""; + case "server_mute": + return entry.enabled ? "Server muted" : "Server unmuted"; + case "server_deafen": + return entry.enabled ? "Server deafened" : "Server undeafened"; + case "self_video": + return entry.enabled ? "Turned on camera" : "Turned off camera"; + case "self_stream": + return entry.enabled ? "Started screensharing" : "Stopped screensharing"; + case "activity": + return `Started ${entry.activityName ?? "an activity"}`; + case "activity_stop": + return `Stopped ${entry.activityName ?? "an activity"}`; + } +} + +export function VoiceChannelLogEntryComponent({ logEntry, channel }: { logEntry: VoiceChannelLogEntry; channel: Channel; }) { + const user = UserStore.getUser(logEntry.userId); + const username = user?.globalName ?? user?.username ?? "Unknown"; + const elapsed = formatElapsedTime(getCallStartTime(), logEntry.timestamp); + const description = getEventDescription(logEntry); + + return ( +
  • +
    + + {elapsed && {elapsed}} +
    + {(logEntry.type === "activity" || logEntry.type === "activity_stop") && logEntry.applicationId ? ( + {logEntry.activityName + ) : ( + + )} + + {(tooltipProps: any) => ( + openUserProfile(logEntry.userId)} + src={user ? user.getAvatarURL(channel.getGuildId()) : IconUtils.getDefaultAvatarURL(logEntry.userId)} + alt={username} + /> + )} + +
    + openUserProfile(logEntry.userId)}>{username} + {logEntry.type === "soundboard" ? ( +
    + {logEntry.emoji && {logEntry.emoji.name}} + Played a soundboard sound +
    + + {tooltipProps => ( + playSound(logEntry.soundId!)}> + + + )} + + + {tooltipProps => ( + downloadSound(logEntry.soundId!)}> + + + )} + +
    +
    + ) : (logEntry.type === "activity" || logEntry.type === "activity_stop") && logEntry.applicationId ? ( +
    + {description} +
    + + {tooltipProps => ( + { + closeAllModals(); + NavigationRouter.transitionTo(`/application-directory/${logEntry.applicationId}`); + }} + > + + + )} + +
    +
    + ) : ( + {description} + )} +
    +
  • + ); +} diff --git a/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryIcons.tsx b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryIcons.tsx new file mode 100644 index 000000000..5032c40da --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogEntryIcons.tsx @@ -0,0 +1,102 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classes } from "@utils/misc"; +import { React } from "@webpack/common"; + +import { LogEventType } from "../types"; +import { cl } from "../utils"; + +const iconProps = { xmlns: "http://www.w3.org/2000/svg", height: "18", width: "18" }; + +function JoinIcon() { + return ; +} + +function LeaveIcon() { + return ; +} + +function MoveIcon() { + return ; +} + +function SoundboardIcon() { + return + + ; +} + +function MuteIcon() { + return + + + + + ; +} + +function DeafenIcon() { + return + + + + ; +} + +function VideoIcon() { + return + + + ; +} + +function StreamIcon() { + return + + + ; +} + +function ActivityIcon() { + return + + + + + ; +} + +const iconMap: Record React.ReactNode> = { + join: JoinIcon, + leave: LeaveIcon, + move: MoveIcon, + soundboard: SoundboardIcon, + server_mute: MuteIcon, + server_deafen: DeafenIcon, + self_video: VideoIcon, + self_stream: StreamIcon, + activity: ActivityIcon, + activity_stop: ActivityIcon, +}; + +const colorMap: Record = { + join: "positive", + leave: "danger", + move: "warning", + soundboard: "brand", + server_mute: "danger", + server_deafen: "danger", + self_video: "positive", + self_stream: "brand", + activity: "brand", + activity_stop: "brand", +}; + +export default function EventIcon({ type }: { type: LogEventType; }) { + const IconComponent = iconMap[type]; + return
    ; +} diff --git a/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogModal.tsx b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogModal.tsx new file mode 100644 index 000000000..8437dc03d --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/components/VoiceChannelLogModal.tsx @@ -0,0 +1,69 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { classes } from "@utils/misc"; +import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { Channel } from "@vencord/discord-types"; +import { findStoreLazy } from "@webpack"; +import { Button, React, ScrollerThin } from "@webpack/common"; + +import { clearLogs, getVcLogs, vcLogSubscribe } from "../logs"; +import { cl } from "../utils"; +import { VoiceChannelLogEntryComponent } from "./VoiceChannelLogEntryComponent"; + +const AccessibilityStore = findStoreLazy("AccessibilityStore"); + +export function openVoiceChannelLog(channel: Channel) { + return openModal(props => ( + + )); +} + +export function VoiceChannelLogModal({ channel, props }: { channel: Channel; props: ModalProps; }) { + const logs = React.useSyncExternalStore(vcLogSubscribe, () => getVcLogs(channel.id)); + + return ( + + + + {channel.name} logs + + + + + + + {logs.length > 0 ? logs.map((entry, i) => { + const elements: React.ReactNode[] = []; + + if (i === 0 || entry.timestamp.toDateString() !== logs[i - 1].timestamp.toDateString()) { + elements.push( +
    + {entry.timestamp.toDateString()} +
    + ); + } + + elements.push( + + ); + + return elements; + }) : ( +
    No logs to display.
    + )} +
    +
    + + + + +
    + ); +} diff --git a/src/equicordplugins/voiceChannelLog/index.tsx b/src/equicordplugins/voiceChannelLog/index.tsx new file mode 100644 index 000000000..a0e4d1d9c --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/index.tsx @@ -0,0 +1,271 @@ +/* + * 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 { Devs, EquicordDevs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { ChannelType } from "@vencord/discord-types/enums"; +import { findByPropsLazy } from "@webpack"; +import { ApplicationStore, ChannelStore, Menu, RelationshipStore, SelectedChannelStore, UserStore, VoiceStateStore } from "@webpack/common"; + +import { LogIcon, OpenLogsButton } from "./components/LogsButton"; +import { openVoiceChannelLog } from "./components/VoiceChannelLogModal"; +import { addLogEntry, setCallStartTime } from "./logs"; +import settings from "./settings"; +import { EmbeddedActivityEvent, PreviousVoiceState, SoundEvent, VoiceChannelLogEntry, VoiceState } from "./types"; + +const { fetchApplication } = findByPropsLazy("fetchApplication"); + +const loggedActivities = new Set(); +const previousStates = new Map(); +const existingUsers = new Set(); + +let clientOldChannelId: string | undefined; +let clientJoinedAt = 0; + +function isMyChannel(channelId?: string): boolean { + return !!channelId && SelectedChannelStore.getVoiceChannelId() === channelId; +} + +function shouldLog(userId: string): boolean { + return !(settings.store.ignoreBlockedUsers && RelationshipStore.isBlocked(userId)); +} + +function log(entry: Omit) { + addLogEntry({ ...entry, timestamp: new Date() }); +} + +const VOICE_CHANNEL_TYPES = new Set([ChannelType.GUILD_VOICE, ChannelType.GUILD_STAGE_VOICE, ChannelType.DM, ChannelType.GROUP_DM]); + +const patchChannelContextMenu: NavContextMenuPatchCallback = (children, { channel }) => { + if (!VOICE_CHANNEL_TYPES.has(channel.type)) return; + children.push( + openVoiceChannelLog(channel)} + /> + ); +}; + +export default definePlugin({ + name: "VoiceChannelLog", + description: "Logs voice channel activity including joins, leaves, soundboard, mute, camera, screenshare, and more.", + authors: [Devs.Sqaaakoi, Devs.thororen, EquicordDevs.nyx, Devs.Moxxie, EquicordDevs.Fres, Devs.amy], + dependencies: ["AudioPlayerAPI"], + settings, + contextMenus: { + "channel-context": patchChannelContextMenu + }, + + toolboxActions: { + "Voice Channel Logs"() { + const channelId = SelectedChannelStore.getVoiceChannelId(); + if (!channelId) return; + const channel = ChannelStore.getChannel(channelId); + if (channel) openVoiceChannelLog(channel); + } + }, + + headerBarButton: { + icon: LogIcon, + render: OpenLogsButton + }, + + flux: { + VOICE_CHANNEL_SELECT({ channelId, currentVoiceChannelId }: { channelId: string | null; currentVoiceChannelId: string | null; }) { + const leaving = channelId == null && currentVoiceChannelId != null; + const joining = channelId != null && currentVoiceChannelId == null; + const oldChannel = currentVoiceChannelId ?? clientOldChannelId; + + clientOldChannelId = channelId ?? undefined; + + if (leaving && oldChannel) { + const userId = UserStore.getCurrentUser().id; + if (settings.store.logJoinLeave) { + log({ type: "leave", userId, channelId: oldChannel }); + } + setCallStartTime(null); + loggedActivities.clear(); + } else if (joining && channelId && channelId !== oldChannel) { + clientJoinedAt = Date.now(); + setCallStartTime(new Date()); + if (settings.store.logJoinLeave) { + log({ type: "join", userId: UserStore.getCurrentUser().id, channelId }); + } + } + }, + + VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { + const clientUserId = UserStore.getCurrentUser().id; + const suppressJoins = Date.now() - clientJoinedAt < 5000; + + for (const state of voiceStates) { + const { userId } = state; + const { channelId, oldChannelId } = state; + + if (userId === clientUserId) continue; + if (!shouldLog(userId)) continue; + + if (oldChannelId === channelId && !previousStates.has(userId)) { + previousStates.set(userId, { + mute: state.mute, + deaf: state.deaf, + selfVideo: state.selfVideo, + selfStream: state.selfStream ?? false, + channelId + }); + continue; + } + + const prev = previousStates.get(userId); + const inMyChannel = isMyChannel(channelId) || isMyChannel(oldChannelId); + + if (oldChannelId !== channelId) { + if (!oldChannelId && channelId) { + const skipJoin = suppressJoins || existingUsers.delete(userId); + if (!skipJoin && settings.store.logJoinLeave && isMyChannel(channelId)) { + log({ type: "join", userId, channelId }); + } + } else if (oldChannelId && !channelId) { + if (settings.store.logJoinLeave && isMyChannel(oldChannelId)) { + log({ type: "leave", userId, channelId: oldChannelId }); + } + } else if (oldChannelId && channelId) { + if (settings.store.logJoinLeave) { + if (isMyChannel(oldChannelId)) { + log({ type: "move", userId, channelId: oldChannelId, oldChannelId, newChannelId: channelId }); + } + if (isMyChannel(channelId)) { + log({ type: "move", userId, channelId, oldChannelId, newChannelId: channelId }); + } + } + } + } + + if (prev && channelId && inMyChannel) { + if (settings.store.logMuteDeafen) { + if (state.mute !== prev.mute) { + log({ type: "server_mute", userId, channelId, enabled: state.mute }); + } + if (state.deaf !== prev.deaf) { + log({ type: "server_deafen", userId, channelId, enabled: state.deaf }); + } + } + + if (settings.store.logVideo && state.selfVideo !== prev.selfVideo) { + log({ type: "self_video", userId, channelId, enabled: state.selfVideo }); + } + + if (settings.store.logStream && (state.selfStream ?? false) !== prev.selfStream) { + log({ type: "self_stream", userId, channelId, enabled: state.selfStream ?? false }); + } + } + + previousStates.set(userId, { + mute: state.mute, + deaf: state.deaf, + selfVideo: state.selfVideo, + selfStream: state.selfStream ?? false, + channelId + }); + + if (!channelId) { + previousStates.delete(userId); + } + } + }, + + EMBEDDED_ACTIVITY_UPDATE_V2(event: EmbeddedActivityEvent) { + if (!settings.store.logActivity) return; + + const channelId = event.location?.channel_id; + if (!channelId || !isMyChannel(channelId)) return; + + const appId = event.applicationId; + const currentUserIds = new Set((event.participants ?? []).map(p => p.user_id)); + + const joined: string[] = []; + for (const p of event.participants ?? []) { + if (!shouldLog(p.user_id)) continue; + const dedupKey = `${p.user_id}-${appId}`; + if (loggedActivities.has(dedupKey)) continue; + loggedActivities.add(dedupKey); + joined.push(p.user_id); + } + + const left: string[] = []; + for (const key of loggedActivities) { + if (!key.endsWith(`-${appId}`)) continue; + const userId = key.slice(0, -(appId.length + 1)); + if (!currentUserIds.has(userId)) { + loggedActivities.delete(key); + left.push(userId); + } + } + + if (!joined.length && !left.length) return; + + const app = ApplicationStore.getApplication(appId); + const logWithName = (activityName: string) => { + for (const userId of joined) + log({ type: "activity", userId, channelId, activityName, applicationId: appId }); + for (const userId of left) + log({ type: "activity_stop", userId, channelId, activityName, applicationId: appId }); + }; + + if (app) { + logWithName(app.name); + } else { + fetchApplication(appId).then(fetched => logWithName(fetched?.name ?? "Unknown activity")).catch(() => logWithName("Unknown activity")); + } + }, + + VOICE_CHANNEL_EFFECT_SEND(event: SoundEvent) { + if (!settings.store.logSoundboard) return; + if (!event.soundId) return; + if (!isMyChannel(event.channelId)) return; + if (!shouldLog(event.userId)) return; + + log({ + type: "soundboard", + userId: event.userId, + channelId: event.channelId, + soundId: event.soundId, + emoji: event.emoji + }); + } + }, + + start() { + clientOldChannelId = SelectedChannelStore.getVoiceChannelId() ?? undefined; + if (clientOldChannelId) { + clientJoinedAt = Date.now(); + setCallStartTime(new Date()); + if (settings.store.logJoinLeave) { + log({ type: "join", userId: UserStore.getCurrentUser().id, channelId: clientOldChannelId }); + } + const states = VoiceStateStore.getVoiceStatesForChannel(clientOldChannelId); + for (const [userId, s] of Object.entries(states)) { + existingUsers.add(userId); + previousStates.set(userId, { + mute: s.mute, + deaf: s.deaf, + selfVideo: s.selfVideo, + selfStream: s.selfStream ?? false, + channelId: clientOldChannelId + }); + } + } + }, + + stop() { + previousStates.clear(); + loggedActivities.clear(); + existingUsers.clear(); + setCallStartTime(null); + } +}); diff --git a/src/equicordplugins/voiceChannelLog/logs.ts b/src/equicordplugins/voiceChannelLog/logs.ts new file mode 100644 index 000000000..1e08fb429 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/logs.ts @@ -0,0 +1,46 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { VoiceChannelLogEntry } from "./types"; + +const vcLogs = new Map(); +let vcLogSubscriptions: (() => void)[] = []; + +let callStartTime: Date | null = null; + +export function getCallStartTime(): Date | null { + return callStartTime; +} + +export function setCallStartTime(time: Date | null) { + callStartTime = time; +} + +const EMPTY_LOGS: VoiceChannelLogEntry[] = []; + +export function getVcLogs(channelId?: string): VoiceChannelLogEntry[] { + if (!channelId) return EMPTY_LOGS; + return vcLogs.get(channelId) ?? EMPTY_LOGS; +} + +export function addLogEntry(entry: VoiceChannelLogEntry) { + const existing = vcLogs.get(entry.channelId) ?? []; + vcLogs.set(entry.channelId, [...existing, entry]); + vcLogSubscriptions.forEach(fn => fn()); +} + +export function clearLogs(channelId?: string) { + if (!channelId) return; + vcLogs.set(channelId, []); + vcLogSubscriptions.forEach(fn => fn()); +} + +export function vcLogSubscribe(listener: () => void) { + vcLogSubscriptions = [...vcLogSubscriptions, listener]; + return () => { + vcLogSubscriptions = vcLogSubscriptions.filter(l => l !== listener); + }; +} diff --git a/src/equicordplugins/voiceChannelLog/settings.ts b/src/equicordplugins/voiceChannelLog/settings.ts new file mode 100644 index 000000000..a1b924c24 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/settings.ts @@ -0,0 +1,63 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; + +const settings = definePluginSettings({ + logJoinLeave: { + type: OptionType.BOOLEAN, + description: "Log when users join, leave, or move between voice channels.", + default: true + }, + logSoundboard: { + type: OptionType.BOOLEAN, + description: "Log when users play soundboard sounds.", + default: true + }, + logMuteDeafen: { + type: OptionType.BOOLEAN, + description: "Log when users are server muted or deafened.", + default: true + }, + logVideo: { + type: OptionType.BOOLEAN, + description: "Log when users turn their camera on or off.", + default: true + }, + logStream: { + type: OptionType.BOOLEAN, + description: "Log when users start or stop screensharing.", + default: true + }, + logActivity: { + type: OptionType.BOOLEAN, + description: "Log when users start embedded activities.", + default: true + }, + ignoreBlockedUsers: { + type: OptionType.BOOLEAN, + description: "Do not log blocked users.", + default: false + }, + soundboardFileType: { + type: OptionType.SELECT, + description: "File format for downloading soundboard sounds.", + options: [ + { label: ".ogg", value: ".ogg", default: true }, + { label: ".mp3", value: ".mp3" }, + { label: ".wav", value: ".wav" }, + ], + }, + soundboardVolume: { + type: OptionType.SLIDER, + description: "Preview volume for soundboard sounds (0 to disable).", + default: 0.5, + markers: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1] + }, +}); + +export default settings; diff --git a/src/equicordplugins/voiceChannelLog/types.ts b/src/equicordplugins/voiceChannelLog/types.ts new file mode 100644 index 000000000..60bf8d8a5 --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/types.ts @@ -0,0 +1,81 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { User } from "@vencord/discord-types"; + +export type LogEventType = + | "join" + | "leave" + | "move" + | "soundboard" + | "server_mute" + | "server_deafen" + | "self_video" + | "self_stream" + | "activity" + | "activity_stop"; + +export interface Emoji { + name: string; + id?: string; + animated: boolean; +} + +export interface VoiceChannelLogEntry { + type: LogEventType; + userId: string; + channelId: string; + timestamp: Date; + oldChannelId?: string | null; + newChannelId?: string | null; + soundId?: string; + emoji?: Emoji; + enabled?: boolean; + activityName?: string; + applicationId?: string; +} + +export interface VoiceState { + guildId?: string; + channelId?: string; + oldChannelId?: string; + user: User; + userId: string; + mute: boolean; + deaf: boolean; + selfMute: boolean; + selfDeaf: boolean; + selfVideo: boolean; + selfStream?: boolean; +} + +export interface SoundEvent { + type: "VOICE_CHANNEL_EFFECT_SEND"; + emoji?: Emoji; + channelId: string; + userId: string; + soundId?: string; +} + +export interface EmbeddedActivityEvent { + applicationId: string; + location?: { + channel_id?: string; + guild_id?: string; + }; + participants?: Array<{ + user_id: string; + session_id: string; + }>; +} + +export interface PreviousVoiceState { + mute: boolean; + deaf: boolean; + selfVideo: boolean; + selfStream: boolean; + channelId?: string; +} diff --git a/src/equicordplugins/voiceChannelLog/utils.ts b/src/equicordplugins/voiceChannelLog/utils.ts new file mode 100644 index 000000000..4cddd3fca --- /dev/null +++ b/src/equicordplugins/voiceChannelLog/utils.ts @@ -0,0 +1,51 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { playAudio } from "@api/AudioPlayer"; +import { classNameFactory } from "@utils/css"; +import { saveFile } from "@utils/web"; +import { findByPropsLazy } from "@webpack"; + +import settings from "./settings"; +import { Emoji } from "./types"; + +export const cl = classNameFactory("vc-voice-channel-log-"); + +const EmojiManager = findByPropsLazy("getEmojiColors", "getURL"); + +export function getEmojiUrl(emoji?: Emoji): string { + if (!emoji) return EmojiManager.getURL("❓"); + return emoji.id ? `https://cdn.discordapp.com/emojis/${emoji.id}.png?size=32` : EmojiManager.getURL(emoji.name); +} + +export const playSound = (id: string) => { + playAudio(`https://cdn.discordapp.com/soundboard-sounds/${id}`, { volume: settings.store.soundboardVolume * 100 }); +}; + +export async function downloadSound(id: string): Promise { + const filename = id + settings.store.soundboardFileType; + const original = await fetch(`https://cdn.discordapp.com/soundboard-sounds/${id}`).then(res => res.arrayBuffer()); + + if (IS_DISCORD_DESKTOP) { + DiscordNative.fileManager.saveWithDialog(new Uint8Array(original), filename); + } else { + saveFile(new File([original], filename, { type: "audio/ogg" })); + } +} + +export function formatElapsedTime(startTime: Date | null, eventTime: Date): string | null { + if (!startTime) return null; + const elapsed = eventTime.getTime() - startTime.getTime(); + if (elapsed < 0) return null; + + const totalMinutes = Math.floor(elapsed / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + if (hours > 0) return `${hours}h ${minutes}m into the call`; + if (minutes > 0) return `${minutes}m into the call`; + return "start of call"; +}