diff --git a/src/plugins/userVoiceShow/README.md b/src/plugins/userVoiceShow/README.md new file mode 100644 index 000000000..a0eb7c43e --- /dev/null +++ b/src/plugins/userVoiceShow/README.md @@ -0,0 +1,7 @@ +# User Voice Show + +Shows an indicator when a user is in a Voice Channel + +![a preview of the indicator in the user profile](https://github.com/user-attachments/assets/48f825e4-fad5-40d7-bb4f-41d5e595aae0) + +![a preview of the indicator in the member list](https://github.com/user-attachments/assets/51be081d-7bbb-45c5-8533-d565228e50c1) diff --git a/src/plugins/userVoiceShow/components.tsx b/src/plugins/userVoiceShow/components.tsx new file mode 100644 index 000000000..fd7dbd29e --- /dev/null +++ b/src/plugins/userVoiceShow/components.tsx @@ -0,0 +1,170 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@api/Styles"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { classes } from "@utils/misc"; +import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack"; +import { ChannelStore, GuildStore, IconUtils, NavigationRouter, PermissionsBits, PermissionStore, showToast, Text, Toasts, Tooltip, useCallback, useMemo, UserStore, useStateFromStores } from "@webpack/common"; +import { Channel } from "discord-types/general"; + +const cl = classNameFactory("vc-uvs-"); + +const { selectVoiceChannel } = findByPropsLazy("selectChannel", "selectVoiceChannel"); +const VoiceStateStore = findStoreLazy("VoiceStateStore"); +const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers"); + +interface IconProps extends React.HTMLAttributes { + size?: number; +} + +function SpeakerIcon(props: IconProps) { + props.size ??= 16; + + return ( +
+ + + + +
+ ); +} + +function LockedSpeakerIcon(props: IconProps) { + props.size ??= 16; + + return ( +
+ + + + +
+ ); +} + +interface VoiceChannelTooltipProps { + channel: Channel; +} + +function VoiceChannelTooltip({ channel }: VoiceChannelTooltipProps) { + const voiceStates = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStatesForChannel(channel.id)); + const users = useMemo( + () => Object.values(voiceStates).map(voiceState => UserStore.getUser(voiceState.userId)).filter(user => user != null), + [voiceStates] + ); + + const guild = useMemo( + () => channel.getGuildId() == null ? undefined : GuildStore.getGuild(channel.getGuildId()), + [channel] + ); + + const guildIcon = useMemo(() => { + return guild?.icon == null ? undefined : IconUtils.getGuildIconURL({ + id: guild.id, + icon: guild.icon, + size: 30 + }); + }, [guild]); + + return ( + <> + {guild != null && ( +
+ {guildIcon != null && } + {guild.name} +
+ )} + {channel.name} +
+ + +
+ + ); +} + +interface VoiceChannelIndicatorProps { + userId: string; +} + +const clickTimers = {} as Record; + +export const VoiceChannelIndicator = ErrorBoundary.wrap(({ userId }: VoiceChannelIndicatorProps) => { + const channelId = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStateForUser(userId)?.channelId as string | undefined); + const channel = useMemo(() => channelId == null ? undefined : ChannelStore.getChannel(channelId), [channelId]); + + const onClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (channel == null || channelId == null) return; + + if (!PermissionStore.can(PermissionsBits.VIEW_CHANNEL, channel)) { + showToast("You cannot view the user's Voice Channel", Toasts.Type.FAILURE); + return; + } + + clearTimeout(clickTimers[channelId]); + delete clickTimers[channelId]; + + if (e.detail > 1) { + if (!PermissionStore.can(PermissionsBits.CONNECT, channel)) { + showToast("You cannot join the user's Voice Channel", Toasts.Type.FAILURE); + return; + } + + selectVoiceChannel(channelId); + } else { + clickTimers[channelId] = setTimeout(() => { + NavigationRouter.transitionTo(`/channels/${channel.getGuildId() ?? "@me"}/${channelId}`); + delete clickTimers[channelId]; + }, 250); + } + }, [channelId]); + + const isLocked = useMemo(() => { + return !PermissionStore.can(PermissionsBits.VIEW_CHANNEL, channel) || !PermissionStore.can(PermissionsBits.CONNECT, channel); + }, [channelId]); + + if (channel == null) return null; + + return ( + } + tooltipClassName={cl("tooltip-container")} + > + {props => + isLocked ? + + : + } + + ); +}, { noop: true }); diff --git a/src/plugins/userVoiceShow/components/VoiceChannelSection.css b/src/plugins/userVoiceShow/components/VoiceChannelSection.css deleted file mode 100644 index 00ecf5d41..000000000 --- a/src/plugins/userVoiceShow/components/VoiceChannelSection.css +++ /dev/null @@ -1,27 +0,0 @@ -.vc-uvs-button>div { - white-space: normal !important; -} - -.vc-uvs-button { - width: 100%; - margin: auto; - height: unset; -} - -.vc-uvs-header { - color: var(--header-primary); - margin-bottom: 6px; -} - -.vc-uvs-modal-margin { - margin: 0 12px; -} - -.vc-uvs-modal-margin div { - margin-bottom: 0 !important; -} - -.vc-uvs-popout-margin-self>[class^="section"] { - padding-top: 0; - padding-bottom: 12px; -} diff --git a/src/plugins/userVoiceShow/components/VoiceChannelSection.tsx b/src/plugins/userVoiceShow/components/VoiceChannelSection.tsx deleted file mode 100644 index 8ca335bb6..000000000 --- a/src/plugins/userVoiceShow/components/VoiceChannelSection.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Vencord, a modification for Discord's desktop app - * Copyright (c) 2023 Vendicated and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . -*/ - -import "./VoiceChannelSection.css"; - -import { findByPropsLazy } from "@webpack"; -import { Button, Forms, PermissionStore, Toasts } from "@webpack/common"; -import { Channel } from "discord-types/general"; - -const ChannelActions = findByPropsLazy("selectChannel", "selectVoiceChannel"); - -const CONNECT = 1n << 20n; - -interface VoiceChannelFieldProps { - channel: Channel; - label: string; - showHeader: boolean; -} - -export const VoiceChannelSection = ({ channel, label, showHeader }: VoiceChannelFieldProps) => ( - // @TODO The div is supposed to be a UserPopoutSection -
- {showHeader && In a voice channel} - -
-); diff --git a/src/plugins/userVoiceShow/index.tsx b/src/plugins/userVoiceShow/index.tsx index cad539b46..dee713b5e 100644 --- a/src/plugins/userVoiceShow/index.tsx +++ b/src/plugins/userVoiceShow/index.tsx @@ -16,85 +16,85 @@ * along with this program. If not, see . */ +import "./style.css"; + +import { addDecorator, removeDecorator } from "@api/MemberListDecorators"; import { definePluginSettings } from "@api/Settings"; -import ErrorBoundary from "@components/ErrorBoundary"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; -import { findStoreLazy } from "@webpack"; -import { ChannelStore, GuildStore, UserStore } from "@webpack/common"; -import { User } from "discord-types/general"; -import { VoiceChannelSection } from "./components/VoiceChannelSection"; - -const VoiceStateStore = findStoreLazy("VoiceStateStore"); +import { VoiceChannelIndicator } from "./components"; const settings = definePluginSettings({ showInUserProfileModal: { type: OptionType.BOOLEAN, - description: "Show a user's voice channel in their profile modal", + description: "Show a user's Voice Channel indicator in their profile next to the name", default: true, + restartNeeded: true }, - showVoiceChannelSectionHeader: { + showInVoiceMemberList: { type: OptionType.BOOLEAN, - description: 'Whether to show "IN A VOICE CHANNEL" above the join button', + description: "Show a user's Voice Channel indicator in the member and DMs list", default: true, + restartNeeded: true } }); -interface UserProps { - user: User; -} - -const VoiceChannelField = ErrorBoundary.wrap(({ user }: UserProps) => { - const { channelId } = VoiceStateStore.getVoiceStateForUser(user.id) ?? {}; - if (!channelId) return null; - - const channel = ChannelStore.getChannel(channelId); - if (!channel) return null; - - const guild = GuildStore.getGuild(channel.guild_id); - - if (!guild) return null; // When in DM call - - const result = `${guild.name} | ${channel.name}`; - - return ( - - ); -}); - export default definePlugin({ name: "UserVoiceShow", - description: "Shows whether a User is currently in a voice channel somewhere in their profile", - authors: [Devs.LordElias], + description: "Shows an indicator when a user is in a Voice Channel", + authors: [Devs.LordElias, Devs.Nuckyz], settings, - patchModal({ user }: UserProps) { - if (!settings.store.showInUserProfileModal) - return null; - - return ( -
- -
- ); - }, - - patchProfilePopout: ({ user }: UserProps) => { - const isSelfUser = user.id === UserStore.getCurrentUser().id; - return ( -
- -
- ); - }, - patches: [ - // @TODO Maybe patch UserVoiceShow in simplified profile popout - // @TODO Patch new profile modal + // User Popout, Full Size Profile, Direct Messages Side Profile + { + find: ".Messages.USER_PROFILE_LOAD_ERROR", + replacement: { + match: /(\.fetchError.+?\?)null/, + replace: (_, rest) => `${rest}$self.VoiceChannelIndicator({userId:arguments[0]?.userId})` + }, + predicate: () => settings.store.showInUserProfileModal + }, + // To use without the MemberList decorator API + /* // Guild Members List + { + find: ".lostPermission)", + replacement: { + match: /\.lostPermission\).+?(?=avatar:)/, + replace: "$&children:[$self.VoiceChannelIndicator({userId:arguments[0]?.user?.id})]," + }, + predicate: () => settings.store.showVoiceChannelIndicator + }, + // Direct Messages List + { + find: "PrivateChannel.renderAvatar", + replacement: { + match: /\.Messages\.CLOSE_DM.+?}\)(?=])/, + replace: "$&,$self.VoiceChannelIndicator({userId:arguments[0]?.user?.id})" + }, + predicate: () => settings.store.showVoiceChannelIndicator + }, */ + // Friends List + { + find: ".avatar,animate:", + replacement: { + match: /\.subtext,children:.+?}\)\]}\)(?=])/, + replace: "$&,$self.VoiceChannelIndicator({userId:arguments[0]?.user?.id})" + }, + predicate: () => settings.store.showInVoiceMemberList + } ], + + start() { + if (settings.store.showInVoiceMemberList) { + addDecorator("UserVoiceShow", ({ user }) => user == null ? null : ); + } + }, + + stop() { + removeDecorator("UserVoiceShow"); + }, + + VoiceChannelIndicator }); diff --git a/src/plugins/userVoiceShow/style.css b/src/plugins/userVoiceShow/style.css new file mode 100644 index 000000000..b4cc00631 --- /dev/null +++ b/src/plugins/userVoiceShow/style.css @@ -0,0 +1,37 @@ +.vc-uvs-speaker { + color: var(--interactive-normal); + padding: 0 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.vc-uvs-clickable { + cursor: pointer; +} + +.vc-uvs-clickable:hover { + color: var(--interactive-hover); +} + +.vc-uvs-tooltip-container { + max-width: 200px; +} + +.vc-uvs-guild-name { + display: flex; + align-items: center; + gap: 8px; +} + +.vc-uvs-guild-icon { + border-radius: 100%; + align-self: center; +} + +.vc-uvs-vc-members { + display: flex; + margin: 8px 0; + flex-direction: row; + gap: 6px; +}