voiceChannelLog (#926)
This commit is contained in:
parent
1c06574881
commit
2b5ad8287d
25 changed files with 1028 additions and 1226 deletions
|
|
@ -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>;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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 >
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
271
src/equicordplugins/voiceChannelLog/index.tsx
Normal file
271
src/equicordplugins/voiceChannelLog/index.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
46
src/equicordplugins/voiceChannelLog/logs.ts
Normal file
46
src/equicordplugins/voiceChannelLog/logs.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { 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);
|
||||
};
|
||||
}
|
||||
63
src/equicordplugins/voiceChannelLog/settings.ts
Normal file
63
src/equicordplugins/voiceChannelLog/settings.ts
Normal 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;
|
||||
81
src/equicordplugins/voiceChannelLog/types.ts
Normal file
81
src/equicordplugins/voiceChannelLog/types.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { 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;
|
||||
}
|
||||
51
src/equicordplugins/voiceChannelLog/utils.ts
Normal file
51
src/equicordplugins/voiceChannelLog/utils.ts
Normal 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";
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue