voiceChannelLog (#926)

This commit is contained in:
creations 2026-03-21 11:31:16 -04:00 • committed by GitHub
parent 1c06574881
commit 2b5ad8287d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1028 additions and 1226 deletions

View file

@ -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 (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 20 20"><g fill="none" fillRule="evenodd" transform="translate(-2 -2)"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" /><path fill="currentColor" d="m9.998 8.428 5.492 3.138a.5.5 0 0 1 0 .868l-5.492 3.139a.5.5 0 0 1-.748-.435V8.862a.5.5 0 0 1 .748-.435z" /></g></svg>
);
}
// https://www.svgrepo.com/svg/528952/download
export function DownloadIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path stroke="currentColor" strokeLinecap="round" strokeWidth="1.5" d="M8 22h8c2.828 0 4.243 0 5.121-.879C22 20.243 22 18.828 22 16v-1c0-2.828 0-4.243-.879-5.121-.768-.769-1.946-.865-4.121-.877m-10 0c-2.175.012-3.353.108-4.121.877C2 10.757 2 12.172 2 15v1c0 2.828 0 4.243.879 5.121.3.3.662.498 1.121.628" /><path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M12 2v13m0 0-3-3.5m3 3.5 3-3.5" /></svg>
);
}
export function LogIcon({ height = 24, width = 24, className = "chatBarLogIcon" }: { height?: number; width?: number; className?: string; }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} className={classes(cl("icon"), className)} viewBox="1.134 10.59 87.732 68.821"><path fill="currentColor" d="M84.075 10.597 5.932 10.59a4.799 4.799 0 0 0-4.798 4.79v59.226a4.801 4.801 0 0 0 4.798 4.798l78.144.007a4.79 4.79 0 0 0 4.79-4.794V15.391a4.796 4.796 0 0 0-4.791-4.794zm-11.704 8.766c0-.283.229-.509.51-.509h7.105c.279 0 .501.226.501.509v7.102c0 .28-.222.509-.501.509h-7.105a.511.511 0 0 1-.51-.509v-7.102zm-63.968 0c0-.283.229-.509.509-.509h29.399c.28 0 .502.226.502.509v7.102c0 .28-.222.509-.502.509H8.912a.51.51 0 0 1-.509-.509v-7.102zm7.863 45.454a.727.727 0 0 1-.727.727H14.28v6.466c0 1-.822 1.821-1.829 1.821-.516 0-.97-.199-1.301-.53a1.829 1.829 0 0 1-.531-1.291v-6.466H9.51a.726.726 0 0 1-.727-.727v-6.854c0-.4.327-.727.727-.727h1.11V37.583c0-1.017.822-1.839 1.832-1.839.505 0 .96.211 1.291.542a1.8 1.8 0 0 1 .538 1.297v19.653h1.259c.4 0 .727.326.727.727v6.854zm11.02-13.204a.73.73 0 0 1-.727.728h-1.11v19.651a1.835 1.835 0 0 1-1.832 1.839c-.506 0-.96-.21-1.291-.541a1.8 1.8 0 0 1-.538-1.298V52.341H20.53a.73.73 0 0 1-.727-.728v-6.855c0-.403.327-.727.727-.727h1.259v-6.466a1.83 1.83 0 0 1 1.829-1.821c.516 0 .97.2 1.301.531.331.331.531.792.531 1.29v6.466h1.11c.4 0 .727.324.727.727v6.855zm11.268 13.204a.727.727 0 0 1-.727.727h-1.259v6.466c0 1-.821 1.821-1.829 1.821-.516 0-.97-.199-1.301-.53a1.828 1.828 0 0 1-.53-1.291v-6.466h-1.11a.726.726 0 0 1-.727-.727v-6.854c0-.4.327-.727.727-.727h1.11V37.583c0-1.017.821-1.839 1.832-1.839.505 0 .959.211 1.291.542a1.8 1.8 0 0 1 .538 1.297v19.653h1.259c.4 0 .727.326.727.727v6.854zm10.94-13.204a.73.73 0 0 1-.728.728h-1.11v19.651a1.835 1.835 0 0 1-1.832 1.839 1.82 1.82 0 0 1-1.29-.541 1.802 1.802 0 0 1-.539-1.298V52.341h-1.259a.73.73 0 0 1-.727-.728v-6.855c0-.403.327-.727.727-.727h1.259v-6.466c0-.999.822-1.821 1.829-1.821.516 0 .97.2 1.301.531.331.331.53.792.53 1.29v6.466h1.11c.4 0 .728.324.728.727v6.855zm8.703-32.25c0-.283.229-.509.508-.509h7.106c.279 0 .501.226.501.509v7.102c0 .28-.222.509-.501.509h-7.106a.51.51 0 0 1-.508-.509v-7.102zm4.176 53.927a4.083 4.083 0 0 1-4.082-4.082c0-2.25 1.828-4.078 4.082-4.078s4.078 1.828 4.078 4.078a4.08 4.08 0 0 1-4.078 4.082zm0-14.709a4.082 4.082 0 1 1 0-8.163 4.078 4.078 0 0 1 4.078 4.081 4.08 4.08 0 0 1-4.078 4.082zm0-14.713a4.082 4.082 0 0 1-4.082-4.081 4.08 4.08 0 0 1 8.16 0 4.079 4.079 0 0 1-4.078 4.081zM76.438 73.29a4.082 4.082 0 0 1-4.081-4.082 4.081 4.081 0 0 1 4.081-4.078 4.078 4.078 0 0 1 4.078 4.078 4.078 4.078 0 0 1-4.078 4.082zm0-14.709a4.081 4.081 0 0 1 0-8.163 4.078 4.078 0 0 1 4.078 4.081 4.08 4.08 0 0 1-4.078 4.082zm0-14.713a4.082 4.082 0 0 1-4.081-4.081 4.08 4.08 0 0 1 8.159 0 4.078 4.078 0 0 1-4.078 4.081z" /></svg>
);
}
export function IconWithTooltip({ text, icon, onClick }) {
return <Tooltip text={text}>
{({ onMouseEnter, onMouseLeave }) => (
<div style={{ display: "flex" }}>
<Button
aria-haspopup="dialog"
aria-label={text}
size=""
look={Button.Looks.FILLED}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={cl("discord-button")}
onClick={onClick}
style={{ padding: "0 4px" }}
>
<div className={cl("button-wrapper")}>
{icon}
</div>
</Button>
</div>
)}
</Tooltip>;
}
export const CharBarButton: ChatBarButtonFactory = ({ isMainChat }) => {
if (!isMainChat) return null;
return (
<ChatBarButton tooltip="Open SoundBoard Log"
onClick={openSoundBoardLog}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" className="chatBarLogIcon" viewBox="1.134 10.59 87.732 68.821"><path fill="currentColor" d="M84.075 10.597 5.932 10.59a4.799 4.799 0 0 0-4.798 4.79v59.226a4.801 4.801 0 0 0 4.798 4.798l78.144.007a4.79 4.79 0 0 0 4.79-4.794V15.391a4.796 4.796 0 0 0-4.791-4.794zm-11.704 8.766c0-.283.229-.509.51-.509h7.105c.279 0 .501.226.501.509v7.102c0 .28-.222.509-.501.509h-7.105a.511.511 0 0 1-.51-.509v-7.102zm-63.968 0c0-.283.229-.509.509-.509h29.399c.28 0 .502.226.502.509v7.102c0 .28-.222.509-.502.509H8.912a.51.51 0 0 1-.509-.509v-7.102zm7.863 45.454a.727.727 0 0 1-.727.727H14.28v6.466c0 1-.822 1.821-1.829 1.821-.516 0-.97-.199-1.301-.53a1.829 1.829 0 0 1-.531-1.291v-6.466H9.51a.726.726 0 0 1-.727-.727v-6.854c0-.4.327-.727.727-.727h1.11V37.583c0-1.017.822-1.839 1.832-1.839.505 0 .96.211 1.291.542a1.8 1.8 0 0 1 .538 1.297v19.653h1.259c.4 0 .727.326.727.727v6.854zm11.02-13.204a.73.73 0 0 1-.727.728h-1.11v19.651a1.835 1.835 0 0 1-1.832 1.839c-.506 0-.96-.21-1.291-.541a1.8 1.8 0 0 1-.538-1.298V52.341H20.53a.73.73 0 0 1-.727-.728v-6.855c0-.403.327-.727.727-.727h1.259v-6.466a1.83 1.83 0 0 1 1.829-1.821c.516 0 .97.2 1.301.531.331.331.531.792.531 1.29v6.466h1.11c.4 0 .727.324.727.727v6.855zm11.268 13.204a.727.727 0 0 1-.727.727h-1.259v6.466c0 1-.821 1.821-1.829 1.821-.516 0-.97-.199-1.301-.53a1.828 1.828 0 0 1-.53-1.291v-6.466h-1.11a.726.726 0 0 1-.727-.727v-6.854c0-.4.327-.727.727-.727h1.11V37.583c0-1.017.821-1.839 1.832-1.839.505 0 .959.211 1.291.542a1.8 1.8 0 0 1 .538 1.297v19.653h1.259c.4 0 .727.326.727.727v6.854zm10.94-13.204a.73.73 0 0 1-.728.728h-1.11v19.651a1.835 1.835 0 0 1-1.832 1.839 1.82 1.82 0 0 1-1.29-.541 1.802 1.802 0 0 1-.539-1.298V52.341h-1.259a.73.73 0 0 1-.727-.728v-6.855c0-.403.327-.727.727-.727h1.259v-6.466c0-.999.822-1.821 1.829-1.821.516 0 .97.2 1.301.531.331.331.53.792.53 1.29v6.466h1.11c.4 0 .728.324.728.727v6.855zm8.703-32.25c0-.283.229-.509.508-.509h7.106c.279 0 .501.226.501.509v7.102c0 .28-.222.509-.501.509h-7.106a.51.51 0 0 1-.508-.509v-7.102zm4.176 53.927a4.083 4.083 0 0 1-4.082-4.082c0-2.25 1.828-4.078 4.082-4.078s4.078 1.828 4.078 4.078a4.08 4.08 0 0 1-4.078 4.082zm0-14.709a4.082 4.082 0 1 1 0-8.163 4.078 4.078 0 0 1 4.078 4.081 4.08 4.08 0 0 1-4.078 4.082zm0-14.713a4.082 4.082 0 0 1-4.082-4.081 4.08 4.08 0 0 1 8.16 0 4.079 4.079 0 0 1-4.078 4.081zM76.438 73.29a4.082 4.082 0 0 1-4.081-4.082 4.081 4.081 0 0 1 4.081-4.078 4.078 4.078 0 0 1 4.078 4.078 4.078 4.078 0 0 1-4.078 4.082zm0-14.709a4.081 4.081 0 0 1 0-8.163 4.078 4.078 0 0 1 4.078 4.081 4.08 4.08 0 0 1-4.078 4.082zm0-14.713a4.082 4.082 0 0 1-4.081-4.081 4.08 4.08 0 0 1 8.159 0 4.078 4.078 0 0 1-4.078 4.081z" /></svg>
</ChatBarButton>
);
};
export const ChatBarIcon = () => {
return <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" className="chatBarLogIcon" viewBox="1.134 10.59 87.732 68.821">
<path fill="currentColor" d="M84.075 10.597 5.932 10.59a4.799 4.799 0 0 0-4.798 4.79v59.226a4.801 4.801 0 0 0 4.798 4.798l78.144.007a4.79 4.79 0 0 0 4.79-4.794V15.391a4.796 4.796 0 0 0-4.791-4.794zm-11.704 8.766c0-.283.229-.509.51-.509h7.105c.279 0 .501.226.501.509v7.102c0 .28-.222.509-.501.509h-7.105a.511.511 0 0 1-.51-.509v-7.102zm-63.968 0c0-.283.229-.509.509-.509h29.399c.28 0 .502.226.502.509v7.102c0 .28-.222.509-.502.509H8.912a.51.51 0 0 1-.509-.509v-7.102zm7.863 45.454a.727.727 0 0 1-.727.727H14.28v6.466c0 1-.822 1.821-1.829 1.821-.516 0-.97-.199-1.301-.53a1.829 1.829 0 0 1-.531-1.291v-6.466H9.51a.726.726 0 0 1-.727-.727v-6.854c0-.4.327-.727.727-.727h1.11V37.583c0-1.017.822-1.839 1.832-1.839.505 0 .96.211 1.291.542a1.8 1.8 0 0 1 .538 1.297v19.653h1.259c.4 0 .727.326.727.727v6.854zm11.02-13.204a.73.73 0 0 1-.727.728h-1.11v19.651a1.835 1.835 0 0 1-1.832 1.839c-.506 0-.96-.21-1.291-.541a1.8 1.8 0 0 1-.538-1.298V52.341H20.53a.73.73 0 0 1-.727-.728v-6.855c0-.403.327-.727.727-.727h1.259v-6.466a1.83 1.83 0 0 1 1.829-1.821c.516 0 .97.2 1.301.531.331.331.531.792.531 1.29v6.466h1.11c.4 0 .727.324.727.727v6.855zm11.268 13.204a.727.727 0 0 1-.727.727h-1.259v6.466c0 1-.821 1.821-1.829 1.821-.516 0-.97-.199-1.301-.53a1.828 1.828 0 0 1-.53-1.291v-6.466h-1.11a.726.726 0 0 1-.727-.727v-6.854c0-.4.327-.727.727-.727h1.11V37.583c0-1.017.821-1.839 1.832-1.839.505 0 .959.211 1.291.542a1.8 1.8 0 0 1 .538 1.297v19.653h1.259c.4 0 .727.326.727.727v6.854zm10.94-13.204a.73.73 0 0 1-.728.728h-1.11v19.651a1.835 1.835 0 0 1-1.832 1.839 1.82 1.82 0 0 1-1.29-.541 1.802 1.802 0 0 1-.539-1.298V52.341h-1.259a.73.73 0 0 1-.727-.728v-6.855c0-.403.327-.727.727-.727h1.259v-6.466c0-.999.822-1.821 1.829-1.821.516 0 .97.2 1.301.531.331.331.53.792.53 1.29v6.466h1.11c.4 0 .728.324.728.727v6.855zm8.703-32.25c0-.283.229-.509.508-.509h7.106c.279 0 .501.226.501.509v7.102c0 .28-.222.509-.501.509h-7.106a.51.51 0 0 1-.508-.509v-7.102zm4.176 53.927a4.083 4.083 0 0 1-4.082-4.082c0-2.25 1.828-4.078 4.082-4.078s4.078 1.828 4.078 4.078a4.08 4.08 0 0 1-4.078 4.082zm0-14.709a4.082 4.082 0 1 1 0-8.163 4.078 4.078 0 0 1 4.078 4.081 4.08 4.08 0 0 1-4.078 4.082zm0-14.713a4.082 4.082 0 0 1-4.082-4.081 4.08 4.08 0 0 1 8.16 0 4.079 4.079 0 0 1-4.078 4.081zM76.438 73.29a4.082 4.082 0 0 1-4.081-4.082 4.081 4.081 0 0 1 4.081-4.078 4.078 4.078 0 0 1 4.078 4.078 4.078 4.078 0 0 1-4.078 4.082zm0-14.709a4.081 4.081 0 0 1 0-8.163 4.078 4.078 0 0 1 4.078 4.081 4.08 4.08 0 0 1-4.078 4.082zm0-14.713a4.082 4.082 0 0 1-4.081-4.081 4.08 4.08 0 0 1 8.159 0 4.078 4.078 0 0 1-4.078 4.081z" />
</svg>;
};

View file

@ -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 => (
<ErrorBoundary>
<ModalRoot {...props}>
<MoreUsersModal item={item} users={users} onClickUser={onClickUser} closeModal={() => closeModal(key)} />
</ModalRoot>
</ErrorBoundary>
));
}
export default function MoreUsersModal({ item, users, onClickUser, closeModal }: { item: SoundLogEntry, users: User[], onClickUser: Function, closeModal: Function; }) {
return (
<ModalContent className={cl("more")}>
<div className={cl("more-header")}>
<img
className={cl("more-emoji")}
src={getEmojiUrl(item.emoji)}
alt=""
/>
<HeadingPrimary className={cl("more-soundId")}>{item.soundId}</HeadingPrimary>
</div>
<div className={cl("more-users")}>
{users.map(user => {
const currentUser = item.users.find(({ id }) => id === user.id) ?? { id: "", plays: [0] };
return (
<Clickable onClick={() => {
closeModal();
onClickUser(item, user);
}} key={user.id}>
<div className={cl("more-user")} style={{ cursor: "pointer" }}>
<Flex flexDirection="row" className={cl("more-user-profile")}>
<img
className={cl("user-avatar")}
src={user.getAvatarURL(void 0, 512, true)}
alt=""
style={{ cursor: "pointer" }}
/>
<BaseText size="xs" weight="medium" style={{ cursor: "pointer" }}>{user.username}</BaseText>
</Flex>
<BaseText size="xs" weight="medium" style={{ cursor: "pointer" }}>Played {currentUser.plays.length} {currentUser.plays.length === 1 ? "time" : "times"}</BaseText>
</div>
</Clickable>
);
})}
</div>
</ModalContent>
);
}

View file

@ -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<void> {
const data = await getLoggedSounds();
const key = openModal(props => <ErrorBoundary>
<ModalRoot {...props} size={ModalSize.LARGE}>
<SoundBoardLog data={data} closeModal={() => closeModal(key)} />
</ModalRoot>
</ErrorBoundary>);
}
export function OpenSBLogsButton() {
return (
<HeaderBarButton
className="chatBarLogIcon"
onClick={() => openSoundBoardLog()}
tooltip={"Open SoundBoard Log"}
icon={LogIcon}
/>
);
}
export default function SoundBoardLog({ data, closeModal }) {
const [sounds, setSounds] = useState(data);
const [users, setUsers] = useState<User[]>([]);
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 (
<Clickable
className={AvatarStyles.clickableAvatar}
onClick={() => {
onClickShowMoreUsers(item, itemUsers);
}}
>
<Tooltip text={`${itemUsers.length - avatarsMax} other people used this sound...`}>
{({ onMouseEnter, onMouseLeave }) => (
<div
className={AvatarStyles.moreUsers}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
+{itemUsers.length - avatarsMax}
</div>
)}
</Tooltip>
</Clickable>
);
}
/** 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 (
<>
<ModalHeader className={cl("modal-header")}>
<BaseText size="lg" weight="semibold" style={{ flexGrow: 1 }}>SoundBoard log</BaseText>
<ModalCloseButton onClick={closeModal} />
</ModalHeader>
<ModalContent className={classes(cl("modal-content"), Margins.top8)}>
{sounds.length ? sounds.map(item => {
const itemUsers = users.filter(user => item.users.map(u => u.id).includes(user.id));
return (
<div
key={cl("sound")}
className={cl("sound")}
>
<Flex flexDirection="row" className={cl("sound-info")}>
<img
src={getEmojiUrl(item.emoji)}
className={cl("sound-emoji")}
/>
<BaseText size="xs" weight="medium" className={cl("sound-id")}>{item.soundId}</BaseText>
</Flex>
<UserSummaryItem
users={itemUsers.slice(0, avatarsMax)} // Trimmed array to the size of max
count={item.users.length - 1} // True size (counting users that aren't rendered) - 1
guildId={undefined}
renderIcon={false}
max={avatarsMax}
showDefaultAvatarsForNullUsers
showUserPopout
renderMoreUsers={() => renderMoreUsers(item, itemUsers)}
className={cl("sound-users")}
renderUser={(user: User) => (
<Clickable
className={AvatarStyles.clickableAvatar}
onClick={() => {
onClickUser(item, user);
}}
>
<img
className={AvatarStyles.avatar}
src={user.getAvatarURL(void 0, 80, true)}
alt={user.username}
title={user.username}
/>
</Clickable>
)}
/>
<Flex flexDirection="row" className={cl("sound-buttons")}>
<Button color={Button.Colors.PRIMARY} size={Button.Sizes.SMALL} onClick={() => downloadAudio(item.soundId)}>Download</Button>
<Button color={Button.Colors.GREEN} size={Button.Sizes.SMALL} onClick={() => copyWithToast(item.soundId, "ID copied to clipboard!")}>Copy ID</Button>
<Tooltip text={`Soundboard volume: ${Math.floor(settings.store.soundVolume * 100)}%`}>
{({ onMouseEnter, onMouseLeave }) =>
<Button color={Button.Colors.BRAND} size={Button.Sizes.SMALL} onClick={() => playSound(item.soundId)} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>Play Sound</Button>
}
</Tooltip>
</Flex>
</div>
);
}) :
<div style={{ textAlign: "center" }} className={Margins.top16}>
<img
src="https://raw.githubusercontent.com/fres621/assets/main/shiggy.png"
height="200px"
/>
<BaseText size="sm" weight="medium" style={{ color: "var(--text-muted)" }} className={Margins.bottom16}>No sounds logged yet. Join a voice chat to start logging!</BaseText>
</div>
}
</ModalContent >
<ModalFooter className={cl("modal-footer")}>
<Button color={Button.Colors.RED} onClick={async () => { await clearLoggedSounds(); update(); }}>
Clear logs
</Button>
</ModalFooter>
</>
);
}

View file

@ -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 =>
<ModalRoot {...props}>
<UserModal item={item} user={user} sounds={sounds} closeModal={() => closeModal(key)} />
</ModalRoot>
);
}
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 (
<ModalContent className={cl("user")}>
<Clickable onClick={() => {
closeModal();
openUserProfile(user.id);
}}>
<div className={cl("user-header")}>
<img
className={cl("user-avatar")}
src={user.getAvatarURL(void 0, 512, true)}
alt=""
style={{ cursor: "pointer" }}
/>
<HeadingPrimary className={cl("user-name")} style={{ textTransform: "none", cursor: "pointer" }}>{user.username}</HeadingPrimary>
</div>
</Clickable>
<Flex flexDirection="row" style={{ gap: "10px" }}>
<img
className={cl("user-sound-emoji")}
src={getEmojiUrl(item.emoji)}
alt=""
/>
<Flex flexDirection="column" style={{ gap: "7px", height: "68px", justifyContent: "space-between" }}>
<BaseText size="md" weight="bold" style={{ height: "20px" }}>{item.soundId}</BaseText>
<BaseText size="md">Played {currentUser.plays.length} {currentUser.plays.length === 1 ? "time" : "times"}.</BaseText>
<BaseText size="md">Last played: <Timestamp timestamp={new Date(moment(currentUser.plays.at(-1)).toDate())} /></BaseText>
</Flex>
</Flex>
<BaseText size="lg" weight="semibold" tag="h2" className={classes(Margins.top16, Margins.bottom8)}>
{soundsDoneByCurrentUser.length ? "Also played:" : " "}
</BaseText>
<Flex style={{ justifyContent: "space-between" }}>
<UserSummaryItem
users={soundsDoneByCurrentUser}
count={soundsDoneByCurrentUser.length}
guildId={undefined}
renderIcon={false}
max={10}
showDefaultAvatarsForNullUsers
showUserPopout
renderMoreUsers={() =>
<div className={AvatarStyles.emptyUser}>
<div className={AvatarStyles.moreUsers}>
...
</div>
</div>
}
className={cl("user-sounds")}
renderUser={({ soundId, emoji }) => (
<Clickable
className={AvatarStyles.clickableAvatar}
onClick={() => {
closeModal();
openUserModal(sounds.find(sound => sound.soundId === soundId), user, sounds);
}}
>
<img
className={AvatarStyles.avatar}
src={getEmojiUrl(emoji)}
alt={soundId}
title={soundId}
/>
</Clickable>
)}
/>
<div className={cl("user-buttons")}>
<IconWithTooltip text="Download" icon={<DownloadIcon />} onClick={() => downloadAudio(item.soundId)} />
<IconWithTooltip text="Copy ID" icon={<CopyIcon />} onClick={() => copyWithToast(item.soundId, "ID copied to clipboard!")} />
<IconWithTooltip text="Play Sound" icon={<PlayIcon />} onClick={() => playSound(item.soundId)} />
</div>
</Flex>
</ModalContent>
);
}

View file

@ -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);
}
});

View file

@ -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<string | null>(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 (
<section>
<Heading>The amount of soundboard ids you want to save at a time (0 lets you save infinite)</Heading>
<TextInput
type="number"
pattern="-?[0-9]+"
value={state}
onChange={handleChange}
placeholder={"Enter a number"}
/>
{shouldShowWarning && <Paragraph style={{ color: "var(--text-feedback-critical)" }}>Warning! Setting the number to a lower value will reset the log!</Paragraph>}
{errorMessage && <Paragraph style={{ color: "var(--text-feedback-critical)" }}>{errorMessage}</Paragraph>}
</section>
);
}
},
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: () =>
<Button color={Button.Colors.LINK} size={Button.Sizes.SMALL} onClick={openSoundBoardLog}>Open Logs</Button>
},
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;

View file

@ -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<void> {
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<SoundLogEntry[]> {
const data = await DataStore.get("SoundBoardLogList");
if (!data) {
DataStore.set("SoundBoardLogList", []);
return [];
}
else {
return data;
}
}

View file

@ -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;
}

View file

@ -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<void> {
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");

View file

@ -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;
}

View file

@ -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 <li className="vc-voice-channel-log">
<Timestamp className={cl("timestamp")} timestamp={new Date(logEntry.timestamp)} compact isInline={false} cozyAlt></Timestamp>
<Icon logEntry={logEntry} channel={channel} className={cl("icon")} />
<Tooltip text={logEntry.username}>
{(tooltipProps: any) => (
<img
{...tooltipProps}
className={classes(cl("avatar"))}
onClick={() => Util.openUserProfile(logEntry.userId)}
src={user.getAvatarURL(channel.getGuildId())}
/>
)}
</Tooltip>
<div className={cl("content")}>
{ }
</div>
</li>;
}

View file

@ -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 = <svg xmlns="http://www.w3.org/2000/svg" height="18" width="18"><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m0 8h14.2l-3.6-3.6 1.4-1.4 6 6-6 6-1.4-1.4 3.6-3.6h-14.2" fill="#3ba55c" /></g></svg>;
// Taken from /assets/192510ade1abc3149b46.svg
const Leave = <svg xmlns="http://www.w3.org/2000/svg" height="18" width="18" ><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m3.8 8 3.6-3.6-1.4-1.4-6 6 6 6 1.4-1.4-3.6-3.6h14.2v-2" fill="#ed4245" /></g></svg>;
// For other contributors, please DO make specific designs for these instead of how I just copied the join/leave icons and making them orange
const MovedTo = <svg xmlns="http://www.w3.org/2000/svg" height="18" width="18"><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m3.8 8 3.6-3.6-1.4-1.4-6 6 6 6 1.4-1.4-3.6-3.6h14.2v-2" fill="#faa61a" /></g></svg>;
const MovedFrom = <svg xmlns="http://www.w3.org/2000/svg" height="18" width="18"><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m0 8h14.2l-3.6-3.6 1.4-1.4 6 6-6 6-1.4-1.4 3.6-3.6h-14.2" fill="#faa61a" /></g></svg>;
if (logEntry.newChannel && !logEntry.oldChannel) return React.cloneElement(Join, { className: classes(className, cl("join")) });
if (!logEntry.newChannel && logEntry.oldChannel) return React.cloneElement(Leave, { className: classes(className, cl("leave")) });
if (logEntry.newChannel === channel.id && logEntry.oldChannel) return React.cloneElement(MovedFrom, { className: classes(className, cl("moved-from")) });
if (logEntry.newChannel && logEntry.oldChannel === channel.id) return React.cloneElement(MovedTo, { className: classes(className, cl("moved-to")) });
// we should never get here, this is just here to shut up the type checker
return <svg></svg>;
}

View file

@ -1,68 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BaseText } from "@components/BaseText";
import { getVcLogs, vcLogSubscribe } from "@equicordplugins/voiceChannelLog.dev/logs";
import { classes } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Channel } from "@vencord/discord-types";
import { findStoreLazy } from "@webpack";
import { React, ScrollerThin } from "@webpack/common";
import { cl } from "..";
import { VoiceChannelLogEntryComponent } from "./VoiceChannelLogEntryComponent";
const AccessibilityStore = findStoreLazy("AccessibilityStore");
export function openVoiceChannelLog(channel: Channel) {
return openModal(props => (
<VoiceChannelLogModal
props={props}
channel={channel}
/>
));
}
export function VoiceChannelLogModal({ channel, props }: { channel: Channel; props: ModalProps; }) {
React.useSyncExternalStore(vcLogSubscribe, () => getVcLogs(channel.id));
const vcLogs = getVcLogs(channel.id);
const logElements: (React.ReactNode)[] = [];
if (vcLogs.length > 0) {
for (let i = 0; i < vcLogs.length; i++) {
const logEntry = vcLogs[i];
if (i === 0 || logEntry.timestamp.toDateString() !== vcLogs[i - 1].timestamp.toDateString()) {
logElements.push(<div className={classes(cl("date-separator"))} role="separator" aria-label={logEntry.timestamp.toDateString()}>
<span>
{logEntry.timestamp.toDateString()}
</span>
</div>);
} else {
logElements.push(<VoiceChannelLogEntryComponent logEntry={logEntry} channel={channel} />);
}
}
} else {
logElements.push(<div className={cl("empty")}>No logs to display.</div>);
}
return (
<ModalRoot
{...props}
size={ModalSize.LARGE}
>
<ModalHeader>
<BaseText size="lg" weight="semibold" className={cl("header")} style={{ flexGrow: 1 }}>{channel.name} logs</BaseText>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent>
<ScrollerThin fade className={classes(cl("scroller"), `group-spacing-${AccessibilityStore.messageGroupSpacing}`)}>
{logElements}
</ScrollerThin>
</ModalContent>
</ModalRoot >
);
}

View file

@ -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<any> = MessageStore.hasPresent(channelId) ? new Promise<void>(r => r()) : MessageActions.fetchMessages({ channelId });
messagesLoaded.then(() => {
FluxDispatcher.dispatch({
type: "MESSAGE_CREATE",
channelId,
message,
optimistic: true,
sendMessageOptions: {},
isPushNotification: false
});
});
return message;
}
const patchChannelContextMenu: NavContextMenuPatchCallback = (children, { channel }) => {
const group = findGroupChildrenByChildId("mark-channel-read", children) ?? children;
group.push(
<Menu.MenuItem
id="vc-view-voice-channel-logs"
label="View Channel Logs"
action={() => { openVoiceChannelLog(channel); }}
/>
);
};
// Blatantly stolen from VcNarrator plugin
// For every user, channelId and oldChannelId will differ when moving channel.
// Only for the local user, channelId and oldChannelId will be the same when moving channel,
// for some ungodly reason
let clientOldChannelId: string | undefined;
export default definePlugin({
name: "VoiceChannelLog",
description: "Logs who joins and leaves voice channels",
authors: [Devs.Sqaaakoi, Devs.thororen, EquicordDevs.nyx],
contextMenus: {
"channel-context": patchChannelContextMenu
},
settings,
flux: {
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
const clientUserId = UserStore.getCurrentUser().id;
voiceStates.forEach(state => {
// mmmm hacky workaround
const { userId, channelId } = state;
const user = UserStore.getUser(userId) as User & { globalName: string; };
const username = user.globalName || user.username;
let { oldChannelId } = state;
if (userId === clientUserId && channelId !== clientOldChannelId) {
oldChannelId = clientOldChannelId;
clientOldChannelId = channelId;
}
if (settings.store.ignoreBlockedUsers && RelationshipStore.isBlocked(userId)) return;
// Ignore events from same channel
if (oldChannelId === channelId) return;
const logEntry = {
userId,
username,
oldChannel: oldChannelId || null,
newChannel: channelId || null,
timestamp: new Date()
};
if (settings.store.mode !== 2) {
addLogEntry(logEntry, oldChannelId);
addLogEntry(logEntry, channelId);
}
if (!settings.store.voiceChannelChatSelf && userId === clientUserId) return;
if (settings.store.mode === 1) return;
// Join / Leave
if ((!oldChannelId && channelId) || (oldChannelId && !channelId)) {
// empty string is to make type checker shut up
const targetChannelId = oldChannelId || channelId || "";
const selfInChannel = SelectedChannelStore.getVoiceChannelId() === targetChannelId;
sendVoiceStatusMessage(targetChannelId, `${(channelId ? "Joined" : "Left")} <#${targetChannelId}>`, userId, selfInChannel);
}
// Move between channels
if (oldChannelId && channelId) {
sendVoiceStatusMessage(oldChannelId, `Moved to <#${channelId}>`, userId, SelectedChannelStore.getVoiceChannelId() === oldChannelId);
sendVoiceStatusMessage(channelId, `Moved from <#${oldChannelId}>`, userId, SelectedChannelStore.getVoiceChannelId() === channelId);
}
});
},
}
});

View file

@ -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<string, VoiceChannelLogEntry[]>();
let vcLogSubscriptions: (() => void)[] = [];
export function getVcLogs(channel?: string): VoiceChannelLogEntry[] {
if (!channel) return [];
if (!vcLogs.has(channel)) vcLogs.set(channel, []);
return vcLogs.get(channel) || [];
}
export function addLogEntry(logEntry: VoiceChannelLogEntry, channel?: string) {
if (!channel) return;
vcLogs.set(channel, [...getVcLogs(channel), logEntry]);
vcLogSubscriptions.forEach(u => u());
}
export function clearLogs(channel?: string) {
if (!channel) return;
vcLogs.set(channel, []);
vcLogSubscriptions.forEach(u => u());
}
export function vcLogSubscribe(listener: () => void) {
vcLogSubscriptions = [...vcLogSubscriptions, listener];
return () => {
vcLogSubscriptions = vcLogSubscriptions.filter(l => l !== listener);
};
}

View file

@ -0,0 +1,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 (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 24 24" fill="none">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14 2v6h6M8 13h8M8 17h8M8 9h2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function OpenLogsButton() {
const voiceChannelId = useStateFromStores([SelectedChannelStore], () => SelectedChannelStore.getVoiceChannelId());
if (!voiceChannelId) return null;
const channel = ChannelStore.getChannel(voiceChannelId);
if (!channel) return null;
return (
<HeaderBarButton
className={cl("toolbar-btn")}
onClick={() => openVoiceChannelLog(channel)}
tooltip="Voice Channel Logs"
icon={LogIcon}
/>
);
}
export { LogIcon };

View file

@ -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;
}

View file

@ -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 (
<li className={cl("entry")}>
<div className={cl("entry-timestamp")}>
<Timestamp className={cl("timestamp")} timestamp={new Date(logEntry.timestamp)} compact isInline={false} cozyAlt />
{elapsed && <span className={cl("elapsed")}>{elapsed}</span>}
</div>
{(logEntry.type === "activity" || logEntry.type === "activity_stop") && logEntry.applicationId ? (
<img
className={cl("app-icon")}
src={IconUtils.getApplicationIconURL({ id: logEntry.applicationId, icon: ApplicationStore.getApplication(logEntry.applicationId)?.icon })}
alt={logEntry.activityName ?? "Activity"}
/>
) : (
<EventIcon type={logEntry.type} />
)}
<Tooltip text={username}>
{(tooltipProps: any) => (
<img
{...tooltipProps}
className={cl("avatar")}
onClick={() => openUserProfile(logEntry.userId)}
src={user ? user.getAvatarURL(channel.getGuildId()) : IconUtils.getDefaultAvatarURL(logEntry.userId)}
alt={username}
/>
)}
</Tooltip>
<div className={cl("content")}>
<span className={cl("username")} onClick={() => openUserProfile(logEntry.userId)}>{username}</span>
{logEntry.type === "soundboard" ? (
<div className={cl("soundboard-info")}>
{logEntry.emoji && <img className={cl("soundboard-emoji")} src={getEmojiUrl(logEntry.emoji)} alt={logEntry.emoji.name} />}
<span className={cl("soundboard-id")}>Played a soundboard sound</span>
<div className={cl("soundboard-actions")}>
<Tooltip text="Play sound">
{tooltipProps => (
<Clickable {...tooltipProps} className={cl("soundboard-action")} onClick={() => playSound(logEntry.soundId!)}>
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z" /></svg>
</Clickable>
)}
</Tooltip>
<Tooltip text="Save sound">
{tooltipProps => (
<Clickable {...tooltipProps} className={cl("soundboard-action")} onClick={() => downloadSound(logEntry.soundId!)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg>
</Clickable>
)}
</Tooltip>
</div>
</div>
) : (logEntry.type === "activity" || logEntry.type === "activity_stop") && logEntry.applicationId ? (
<div className={cl("soundboard-info")}>
<span className={cl("soundboard-id")}>{description}</span>
<div className={cl("soundboard-actions")}>
<Tooltip text="View activity">
{tooltipProps => (
<Clickable
{...tooltipProps}
className={cl("soundboard-action")}
onClick={() => {
closeAllModals();
NavigationRouter.transitionTo(`/application-directory/${logEntry.applicationId}`);
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" /></svg>
</Clickable>
)}
</Tooltip>
</div>
</div>
) : (
<span className={cl("description")}>{description}</span>
)}
</div>
</li>
);
}

View file

@ -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 <svg {...iconProps}><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m0 8h14.2l-3.6-3.6 1.4-1.4 6 6-6 6-1.4-1.4 3.6-3.6h-14.2" fill="currentColor" /></g></svg>;
}
function LeaveIcon() {
return <svg {...iconProps}><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m3.8 8 3.6-3.6-1.4-1.4-6 6 6 6 1.4-1.4-3.6-3.6h14.2v-2" fill="currentColor" /></g></svg>;
}
function MoveIcon() {
return <svg {...iconProps}><g fill="none" fillRule="evenodd"><path d="m18 0h-18v18h18z" /><path d="m0 8h14.2l-3.6-3.6 1.4-1.4 6 6-6 6-1.4-1.4 3.6-3.6h-14.2" fill="currentColor" /></g></svg>;
}
function SoundboardIcon() {
return <svg {...iconProps} viewBox="0 0 24 24" fill="none">
<path d="M12 3v18M8 7v10M4 10v4M16 7v10M20 10v4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>;
}
function MuteIcon() {
return <svg {...iconProps} viewBox="0 0 24 24">
<path d="M12 1a4 4 0 0 0-4 4v7a4 4 0 0 0 8 0V5a4 4 0 0 0-4-4z" fill="currentColor" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" stroke="currentColor" strokeWidth="2" fill="none" />
<line x1="12" y1="19" x2="12" y2="23" stroke="currentColor" strokeWidth="2" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>;
}
function DeafenIcon() {
return <svg {...iconProps} viewBox="0 0 24 24">
<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3v-7zM21 14h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3v-7z" fill="currentColor" />
<path d="M3 14v-2a9 9 0 0 1 18 0v2" stroke="currentColor" strokeWidth="2" fill="none" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>;
}
function VideoIcon() {
return <svg {...iconProps} viewBox="0 0 24 24">
<rect x="2" y="6" width="14" height="12" rx="2" fill="currentColor" />
<path d="M17 9.5l5-3v11l-5-3v-5z" fill="currentColor" />
</svg>;
}
function StreamIcon() {
return <svg {...iconProps} viewBox="0 0 24 24">
<rect x="2" y="3" width="20" height="14" rx="2" fill="currentColor" />
<path d="M8 21h8M12 17v4" stroke="currentColor" strokeWidth="2" />
</svg>;
}
function ActivityIcon() {
return <svg {...iconProps} viewBox="0 0 24 24">
<rect x="2.5" y="2.5" width="7" height="7" rx="1" transform="rotate(15 6 6)" fill="currentColor" />
<path d="M17 3l2.5 4.5h-5z" fill="currentColor" />
<path d="M7 14.2l.9-1.9.7.5 1.2-.8 1 .8.7-.5.9 1.9-.9 1.3.3 1.5-1.2-.6-1.2.6.3-1.5z" fill="currentColor" />
<path d="M18.5 14c.2-.7.8-.7 1 0l.4 1.1 1.1.4c.7.2.7.8 0 1l-1.1.4-.4 1.1c-.2.7-.8.7-1 0l-.4-1.1-1.1-.4c-.7-.2-.7-.8 0-1l1.1-.4z" fill="currentColor" />
</svg>;
}
const iconMap: Record<LogEventType, () => 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<LogEventType, string> = {
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 <div className={classes(cl("icon"), cl(`icon-${colorMap[type]}`))}><IconComponent /></div>;
}

View file

@ -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 => (
<VoiceChannelLogModal props={props} channel={channel} />
));
}
export function VoiceChannelLogModal({ channel, props }: { channel: Channel; props: ModalProps; }) {
const logs = React.useSyncExternalStore(vcLogSubscribe, () => getVcLogs(channel.id));
return (
<ModalRoot {...props} size={ModalSize.LARGE}>
<ModalHeader>
<BaseText size="lg" weight="semibold" className={cl("header")} style={{ flexGrow: 1 }}>
{channel.name} logs
</BaseText>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent>
<ScrollerThin fade className={classes(cl("scroller"), `group-spacing-${AccessibilityStore.messageGroupSpacing}`)}>
{logs.length > 0 ? logs.map((entry, i) => {
const elements: React.ReactNode[] = [];
if (i === 0 || entry.timestamp.toDateString() !== logs[i - 1].timestamp.toDateString()) {
elements.push(
<div key={`sep-${i}`} className={cl("date-separator")} role="separator" aria-label={entry.timestamp.toDateString()}>
<span>{entry.timestamp.toDateString()}</span>
</div>
);
}
elements.push(
<VoiceChannelLogEntryComponent key={`entry-${i}`} logEntry={entry} channel={channel} />
);
return elements;
}) : (
<div className={cl("empty")}>No logs to display.</div>
)}
</ScrollerThin>
</ModalContent>
<ModalFooter>
<Button color={Button.Colors.RED} onClick={() => clearLogs(channel.id)}>
Clear logs
</Button>
</ModalFooter>
</ModalRoot>
);
}

View file

@ -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<string>();
const previousStates = new Map<string, PreviousVoiceState>();
const existingUsers = new Set<string>();
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<VoiceChannelLogEntry, "timestamp">) {
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(
<Menu.MenuItem
id="vc-view-voice-channel-logs"
label="View Voice Channel Logs"
action={() => 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);
}
});

View file

@ -0,0 +1,46 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { VoiceChannelLogEntry } from "./types";
const vcLogs = new Map<string, VoiceChannelLogEntry[]>();
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);
};
}

View file

@ -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;

View file

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

View file

@ -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<void> {
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";
}