From b6e96a4d3b631c5a2d236ae28179ee0879ae00fe Mon Sep 17 00:00:00 2001 From: Gleb P <63789651+ultard@users.noreply.github.com> Date: Fri, 5 Sep 2025 04:37:13 +0300 Subject: [PATCH] MemberCount: also show members in voice (#2937) Co-authored-by: Vendicated --- .../src/stores/VoiceStateStore.d.ts | 42 +++++++++++++++++++ packages/discord-types/src/stores/index.d.ts | 1 + src/plugins/memberCount/MemberCount.tsx | 42 ++++++++++++++++--- src/plugins/memberCount/VoiceIcon.tsx | 14 +++++++ src/plugins/memberCount/index.tsx | 17 +++++--- src/plugins/memberCount/style.css | 21 +++++++++- src/plugins/userVoiceShow/components.tsx | 9 ++-- src/plugins/vcNarrator/index.tsx | 15 +++---- src/utils/constants.ts | 4 ++ src/webpack/common/stores.ts | 2 + 10 files changed, 140 insertions(+), 27 deletions(-) create mode 100644 packages/discord-types/src/stores/VoiceStateStore.d.ts create mode 100644 src/plugins/memberCount/VoiceIcon.tsx diff --git a/packages/discord-types/src/stores/VoiceStateStore.d.ts b/packages/discord-types/src/stores/VoiceStateStore.d.ts new file mode 100644 index 00000000..84540d99 --- /dev/null +++ b/packages/discord-types/src/stores/VoiceStateStore.d.ts @@ -0,0 +1,42 @@ +import { DiscordRecord } from "../common"; +import { FluxStore } from "./FluxStore"; + +export type UserVoiceStateRecords = Record; +export type VoiceStates = Record; + +export interface VoiceState extends DiscordRecord { + userId: string; + channelId: string | null | undefined; + sessionId: string | null | undefined; + mute: boolean; + deaf: boolean; + selfMute: boolean; + selfDeaf: boolean; + selfVideo: boolean; + selfStream: boolean | undefined; + suppress: boolean; + requestToSpeakTimestamp: string | null | undefined; + discoverable: boolean; + + isVoiceMuted(): boolean; + isVoiceDeafened(): boolean; +} + +export class VoiceStateStore extends FluxStore { + getAllVoiceStates(): VoiceStates; + + getVoiceStates(guildId?: string | null): UserVoiceStateRecords; + getVoiceStatesForChannel(channelId: string): UserVoiceStateRecords; + getVideoVoiceStatesForChannel(channelId: string): UserVoiceStateRecords; + + getVoiceState(guildId: string | null, userId: string): VoiceState | undefined; + getUserVoiceChannelId(guildId: string | null, userId: string): string | undefined; + getVoiceStateForChannel(channelId: string, userId?: string): VoiceState | undefined; + getVoiceStateForUser(userId: string): VoiceState | undefined; + + getCurrentClientVoiceChannelId(guildId: string | null): string | undefined; + isCurrentClientInVoiceChannel(): boolean; + + isInChannel(channelId: string, userId?: string): boolean; + hasVideo(channelId: string): boolean; +} diff --git a/packages/discord-types/src/stores/index.d.ts b/packages/discord-types/src/stores/index.d.ts index 7a435ff5..5906262a 100644 --- a/packages/discord-types/src/stores/index.d.ts +++ b/packages/discord-types/src/stores/index.d.ts @@ -16,6 +16,7 @@ export * from "./ThemeStore"; export * from "./TypingStore"; export * from "./UserProfileStore"; export * from "./UserStore"; +export * from "./VoiceStateStore"; export * from "./WindowStore"; /** diff --git a/src/plugins/memberCount/MemberCount.tsx b/src/plugins/memberCount/MemberCount.tsx index 0a3f5e62..02033886 100644 --- a/src/plugins/memberCount/MemberCount.tsx +++ b/src/plugins/memberCount/MemberCount.tsx @@ -6,16 +6,37 @@ import { getCurrentChannel } from "@utils/discord"; import { isObjectEmpty } from "@utils/misc"; -import { SelectedChannelStore, Tooltip, useEffect, useStateFromStores } from "@webpack/common"; +import { ChannelStore, PermissionsBits, PermissionStore, SelectedChannelStore, Tooltip, useEffect, useStateFromStores, VoiceStateStore } from "@webpack/common"; -import { ChannelMemberStore, cl, GuildMemberCountStore, numberFormat, ThreadMemberListStore } from "."; +import { ChannelMemberStore, cl, GuildMemberCountStore, numberFormat, settings, ThreadMemberListStore } from "."; import { OnlineMemberCountStore } from "./OnlineMemberCountStore"; +import { VoiceIcon } from "./VoiceIcon"; export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; tooltipGuildId?: string; }) { - const currentChannel = useStateFromStores([SelectedChannelStore], () => getCurrentChannel()); + const { voiceActivity } = settings.use(["voiceActivity"]); + const includeVoice = voiceActivity && !isTooltip; + const currentChannel = useStateFromStores([SelectedChannelStore], () => getCurrentChannel()); const guildId = isTooltip ? tooltipGuildId! : currentChannel?.guild_id; + const voiceActivityCount = useStateFromStores( + [VoiceStateStore], + () => { + if (!includeVoice) return 0; + + const voiceStates = VoiceStateStore.getVoiceStates(guildId); + if (!voiceStates) return 0; + + return Object.values(voiceStates) + .filter(({ channelId }) => { + if (!channelId) return false; + const channel = ChannelStore.getChannel(channelId); + return channel && PermissionStore.can(PermissionsBits.VIEW_CHANNEL, channel); + }) + .length; + } + ); + const totalCount = useStateFromStores( [GuildMemberCountStore], () => GuildMemberCountStore.getMemberCount(guildId) @@ -51,13 +72,14 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t if (totalCount == null) return null; + const formattedVoiceCount = numberFormat(voiceActivityCount ?? 0); const formattedOnlineCount = onlineCount != null ? numberFormat(onlineCount) : "?"; return (
{props => ( -
+
{formattedOnlineCount}
@@ -65,12 +87,22 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t {props => ( -
+
{numberFormat(totalCount)}
)} + {includeVoice && voiceActivityCount > 0 && + + {props => ( +
+ + {formattedVoiceCount} +
+ )} +
+ }
); } diff --git a/src/plugins/memberCount/VoiceIcon.tsx b/src/plugins/memberCount/VoiceIcon.tsx new file mode 100644 index 00000000..e76f7151 --- /dev/null +++ b/src/plugins/memberCount/VoiceIcon.tsx @@ -0,0 +1,14 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export function VoiceIcon({ className }: { className?: string; }) { + return ( + + + + + ); +} diff --git a/src/plugins/memberCount/index.tsx b/src/plugins/memberCount/index.tsx index 7fa6fbcd..bb48eedb 100644 --- a/src/plugins/memberCount/index.tsx +++ b/src/plugins/memberCount/index.tsx @@ -37,18 +37,23 @@ export const ThreadMemberListStore = findStoreLazy("ThreadMemberListStore") as F }; -const settings = definePluginSettings({ +export const settings = definePluginSettings({ toolTip: { type: OptionType.BOOLEAN, - description: "If the member count should be displayed on the server tooltip", + description: "Show member count on the server tooltip", default: true, restartNeeded: true }, memberList: { type: OptionType.BOOLEAN, - description: "If the member count should be displayed on the member list", + description: "Show member count in the member list", default: true, restartNeeded: true + }, + voiceActivity: { + type: OptionType.BOOLEAN, + description: "Show voice activity with member count in the member list", + default: true } }); @@ -58,8 +63,8 @@ export const cl = classNameFactory("vc-membercount-"); export default definePlugin({ name: "MemberCount", - description: "Shows the amount of online & total members in the server member list and tooltip", - authors: [Devs.Ven, Devs.Commandtechno], + description: "Shows the number of online members, total members, and users in voice channels on the server — in the member list and tooltip.", + authors: [Devs.Ven, Devs.Commandtechno, Devs.Apexo], settings, patches: [ @@ -82,6 +87,6 @@ export default definePlugin({ predicate: () => settings.store.toolTip } ], - render: ErrorBoundary.wrap(MemberCount, { noop: true }), + render: ErrorBoundary.wrap(() => , { noop: true }), renderTooltip: ErrorBoundary.wrap(guild => , { noop: true }) }); diff --git a/src/plugins/memberCount/style.css b/src/plugins/memberCount/style.css index f43bff83..2b0e4400 100644 --- a/src/plugins/memberCount/style.css +++ b/src/plugins/memberCount/style.css @@ -1,9 +1,11 @@ .vc-membercount-widget { + gap: 0.85em; display: flex; align-content: center; --color-online: var(--green-360); --color-total: var(--primary-400); + --color-voice: var(--primary-400); } .vc-membercount-tooltip { @@ -13,10 +15,17 @@ .vc-membercount-member-list { justify-content: center; + flex-wrap: wrap; margin-top: 1em; padding-inline: 1em; } +.vc-membercount-container { + display: flex; + align-items: center; + gap: 0.5em; +} + .vc-membercount-online { color: var(--color-online); } @@ -25,13 +34,16 @@ color: var(--color-total); } +.vc-membercount-voice { + color: var(--color-voice); +} + .vc-membercount-online-dot { background-color: var(--color-online); display: inline-block; width: 12px; height: 12px; border-radius: 50%; - margin-right: 0.5em; } .vc-membercount-total-dot { @@ -40,5 +52,10 @@ height: 6px; border-radius: 50%; border: 3px solid var(--color-total); - margin: 0 0.5em 0 1em; } + +.vc-membercount-voice-icon { + color: var(--color-voice); + width: 15px; + height: 15px; +} \ No newline at end of file diff --git a/src/plugins/userVoiceShow/components.tsx b/src/plugins/userVoiceShow/components.tsx index 9d1c7211..b7907978 100644 --- a/src/plugins/userVoiceShow/components.tsx +++ b/src/plugins/userVoiceShow/components.tsx @@ -8,8 +8,8 @@ import { classNameFactory } from "@api/Styles"; import ErrorBoundary from "@components/ErrorBoundary"; import { classes } from "@utils/misc"; import { Channel } from "@vencord/discord-types"; -import { filters, findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findStoreLazy, mapMangledModuleLazy } from "@webpack"; -import { ChannelRouter, ChannelStore, GuildStore, IconUtils, match, P, PermissionsBits, PermissionStore, React, showToast, Text, Toasts, Tooltip, useMemo, UserStore, UserSummaryItem, useStateFromStores } from "@webpack/common"; +import { filters, findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, mapMangledModuleLazy } from "@webpack"; +import { ChannelRouter, ChannelStore, GuildStore, IconUtils, match, P, PermissionsBits, PermissionStore, React, showToast, Text, Toasts, Tooltip, useMemo, UserStore, UserSummaryItem, useStateFromStores, VoiceStateStore } from "@webpack/common"; const cl = classNameFactory("vc-uvs-"); @@ -18,7 +18,6 @@ const { useChannelName } = mapMangledModuleLazy("#{intl::GROUP_DM_ALONE}", { useChannelName: filters.byCode("()=>null==") }); const getDMChannelIcon = findByCodeLazy(".getChannelIconURL({"); -const VoiceStateStore = findStoreLazy("VoiceStateStore"); const Avatar = findComponentByCodeLazy(".status)/2):0"); const GroupDMAvatars = findComponentByCodeLazy("frontSrc:", "getAvatarURL"); @@ -84,7 +83,7 @@ function VoiceChannelTooltip({ channel, isLocked }: 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), + () => Object.values(voiceStates).map(voiceState => UserStore.getUser(voiceState.userId)).filter(user => user != null), [voiceStates] ); @@ -139,7 +138,7 @@ export interface VoiceChannelIndicatorProps { const clickTimers = {} as Record; export const VoiceChannelIndicator = ErrorBoundary.wrap(({ userId, isProfile, isActionButton, shouldHighlight }: VoiceChannelIndicatorProps) => { - const channelId = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStateForUser(userId)?.channelId as string | undefined); + const channelId = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStateForUser(userId)?.channelId); const channel = channelId == null ? undefined : ChannelStore.getChannel(channelId); if (channel == null) return null; diff --git a/src/plugins/vcNarrator/index.tsx b/src/plugins/vcNarrator/index.tsx index 8931efe3..5b7b3f82 100644 --- a/src/plugins/vcNarrator/index.tsx +++ b/src/plugins/vcNarrator/index.tsx @@ -22,13 +22,12 @@ import { Logger } from "@utils/Logger"; import { Margins } from "@utils/margins"; import { wordsToTitle } from "@utils/text"; import definePlugin, { ReporterTestable } from "@utils/types"; -import { findByPropsLazy } from "@webpack"; -import { Button, ChannelStore, Forms, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, useMemo, UserStore } from "@webpack/common"; +import { Button, ChannelStore, Forms, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, useMemo, UserStore, VoiceStateStore } from "@webpack/common"; import { ReactElement } from "react"; import { getCurrentVoice, settings } from "./settings"; -interface VoiceState { +interface VoiceStateChangeEvent { userId: string; channelId?: string; oldChannelId?: string; @@ -38,8 +37,6 @@ interface VoiceState { selfMute: boolean; } -const VoiceStateStore = findByPropsLazy("getVoiceStatesForChannel", "getCurrentClientVoiceChannelId"); - // Mute/Deaf for other people than you is commented out, because otherwise someone can spam it and it will be annoying // Filtering out events is not as simple as just dropping duplicates, as otherwise mute, unmute, mute would // not say the second mute, which would lead you to believe they're unmuted @@ -88,7 +85,7 @@ let StatusMap = {} as Record WindowStore = m); waitForStore("EmojiStore", m => EmojiStore = m); waitForStore("StickersStore", m => StickersStore = m); waitForStore("TypingStore", m => TypingStore = m); +waitForStore("VoiceStateStore", m => VoiceStateStore = m); waitForStore("ThemeStore", m => { ThemeStore = m; // Importing this directly can easily cause circular imports. For this reason, use a non import access here.