MemberCount: also show members in voice (#2937)

Co-authored-by: Vendicated <vendicated@riseup.net>
This commit is contained in:
Gleb P 2025-09-05 04:37:13 +03:00 committed by GitHub
parent 1d00ba4161
commit b6e96a4d3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 140 additions and 27 deletions

View file

@ -0,0 +1,42 @@
import { DiscordRecord } from "../common";
import { FluxStore } from "./FluxStore";
export type UserVoiceStateRecords = Record<string, VoiceState>;
export type VoiceStates = Record<string, UserVoiceStateRecords>;
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;
}

View file

@ -16,6 +16,7 @@ export * from "./ThemeStore";
export * from "./TypingStore"; export * from "./TypingStore";
export * from "./UserProfileStore"; export * from "./UserProfileStore";
export * from "./UserStore"; export * from "./UserStore";
export * from "./VoiceStateStore";
export * from "./WindowStore"; export * from "./WindowStore";
/** /**

View file

@ -6,16 +6,37 @@
import { getCurrentChannel } from "@utils/discord"; import { getCurrentChannel } from "@utils/discord";
import { isObjectEmpty } from "@utils/misc"; 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 { OnlineMemberCountStore } from "./OnlineMemberCountStore";
import { VoiceIcon } from "./VoiceIcon";
export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; tooltipGuildId?: string; }) { 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 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( const totalCount = useStateFromStores(
[GuildMemberCountStore], [GuildMemberCountStore],
() => GuildMemberCountStore.getMemberCount(guildId) () => GuildMemberCountStore.getMemberCount(guildId)
@ -51,13 +72,14 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t
if (totalCount == null) if (totalCount == null)
return null; return null;
const formattedVoiceCount = numberFormat(voiceActivityCount ?? 0);
const formattedOnlineCount = onlineCount != null ? numberFormat(onlineCount) : "?"; const formattedOnlineCount = onlineCount != null ? numberFormat(onlineCount) : "?";
return ( return (
<div className={cl("widget", { tooltip: isTooltip, "member-list": !isTooltip })}> <div className={cl("widget", { tooltip: isTooltip, "member-list": !isTooltip })}>
<Tooltip text={`${formattedOnlineCount} online in this channel`} position="bottom"> <Tooltip text={`${formattedOnlineCount} online in this channel`} position="bottom">
{props => ( {props => (
<div {...props}> <div {...props} className={cl("container")}>
<span className={cl("online-dot")} /> <span className={cl("online-dot")} />
<span className={cl("online")}>{formattedOnlineCount}</span> <span className={cl("online")}>{formattedOnlineCount}</span>
</div> </div>
@ -65,12 +87,22 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t
</Tooltip> </Tooltip>
<Tooltip text={`${numberFormat(totalCount)} total server members`} position="bottom"> <Tooltip text={`${numberFormat(totalCount)} total server members`} position="bottom">
{props => ( {props => (
<div {...props}> <div {...props} className={cl("container")}>
<span className={cl("total-dot")} /> <span className={cl("total-dot")} />
<span className={cl("total")}>{numberFormat(totalCount)}</span> <span className={cl("total")}>{numberFormat(totalCount)}</span>
</div> </div>
)} )}
</Tooltip> </Tooltip>
{includeVoice && voiceActivityCount > 0 &&
<Tooltip text={`${formattedVoiceCount} members in voice`} position="bottom">
{props => (
<div {...props} className={cl("container")}>
<VoiceIcon className={cl("voice-icon")} />
<span className={cl("voice")}>{formattedVoiceCount}</span>
</div>
)}
</Tooltip>
}
</div> </div>
); );
} }

View file

@ -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 (
<svg viewBox="0 0 32 32" fill="currentColor" className={className}>
<path d="M15.6668 3C14.2523 3 12.8958 3.5619 11.8956 4.5621C10.8954 5.56229 10.3335 6.91884 10.3335 8.33333V13.6666C10.3335 15.0811 10.8954 16.4378 11.8956 17.438C12.8958 18.4381 14.2523 19 15.6668 19C17.0813 19 18.4378 18.4381 19.438 17.438C20.4382 16.4378 21.0001 15.0811 21.0001 13.6666V8.33333C21.0001 6.91884 20.4382 5.56229 19.438 4.5621C18.4378 3.5619 17.0813 3 15.6668 3Z" />
<path d="M7.66667 13.6666C7.66667 13.313 7.52619 12.9739 7.27614 12.7238C7.02609 12.4738 6.68695 12.3333 6.33333 12.3333C5.97971 12.3333 5.64057 12.4738 5.39052 12.7238C5.14047 12.9739 5 13.313 5 13.6666C4.99911 16.2653 5.94692 18.7749 7.66545 20.7243C9.38399 22.6736 11.7551 23.9285 14.3334 24.2533V27H11.6667C11.3131 27 10.9739 27.1404 10.7239 27.3905C10.4738 27.6405 10.3334 27.9797 10.3334 28.3333C10.3334 28.6869 10.4738 29.0261 10.7239 29.2761C10.9739 29.5262 11.3131 29.6666 11.6667 29.6666H19.6667C20.0203 29.6666 20.3595 29.5262 20.6095 29.2761C20.8596 29.0261 21 28.6869 21 28.3333C21 27.9797 20.8596 27.6405 20.6095 27.3905C20.3595 27.1404 20.0203 27 19.6667 27H17V24.2533C19.5783 23.9285 21.9494 22.6736 23.6679 20.7243C25.3864 18.7749 26.3343 16.2653 26.3334 13.6666C26.3334 13.313 26.1929 12.9739 25.9428 12.7238C25.6928 12.4738 25.3536 12.3333 25 12.3333C24.6464 12.3333 24.3073 12.4738 24.0572 12.7238C23.8072 12.9739 23.6667 13.313 23.6667 13.6666C23.6667 15.7884 22.8238 17.8232 21.3235 19.3235C19.8233 20.8238 17.7884 21.6666 15.6667 21.6666C13.545 21.6666 11.5101 20.8238 10.0098 19.3235C8.50952 17.8232 7.66667 15.7884 7.66667 13.6666Z" />
</svg>
);
}

View file

@ -37,18 +37,23 @@ export const ThreadMemberListStore = findStoreLazy("ThreadMemberListStore") as F
}; };
const settings = definePluginSettings({ export const settings = definePluginSettings({
toolTip: { toolTip: {
type: OptionType.BOOLEAN, 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, default: true,
restartNeeded: true restartNeeded: true
}, },
memberList: { memberList: {
type: OptionType.BOOLEAN, 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, default: true,
restartNeeded: 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({ export default definePlugin({
name: "MemberCount", name: "MemberCount",
description: "Shows the amount of online & total members in the server member list and tooltip", 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], authors: [Devs.Ven, Devs.Commandtechno, Devs.Apexo],
settings, settings,
patches: [ patches: [
@ -82,6 +87,6 @@ export default definePlugin({
predicate: () => settings.store.toolTip predicate: () => settings.store.toolTip
} }
], ],
render: ErrorBoundary.wrap(MemberCount, { noop: true }), render: ErrorBoundary.wrap(() => <MemberCount />, { noop: true }),
renderTooltip: ErrorBoundary.wrap(guild => <MemberCount isTooltip tooltipGuildId={guild.id} />, { noop: true }) renderTooltip: ErrorBoundary.wrap(guild => <MemberCount isTooltip tooltipGuildId={guild.id} />, { noop: true })
}); });

View file

@ -1,9 +1,11 @@
.vc-membercount-widget { .vc-membercount-widget {
gap: 0.85em;
display: flex; display: flex;
align-content: center; align-content: center;
--color-online: var(--green-360); --color-online: var(--green-360);
--color-total: var(--primary-400); --color-total: var(--primary-400);
--color-voice: var(--primary-400);
} }
.vc-membercount-tooltip { .vc-membercount-tooltip {
@ -13,10 +15,17 @@
.vc-membercount-member-list { .vc-membercount-member-list {
justify-content: center; justify-content: center;
flex-wrap: wrap;
margin-top: 1em; margin-top: 1em;
padding-inline: 1em; padding-inline: 1em;
} }
.vc-membercount-container {
display: flex;
align-items: center;
gap: 0.5em;
}
.vc-membercount-online { .vc-membercount-online {
color: var(--color-online); color: var(--color-online);
} }
@ -25,13 +34,16 @@
color: var(--color-total); color: var(--color-total);
} }
.vc-membercount-voice {
color: var(--color-voice);
}
.vc-membercount-online-dot { .vc-membercount-online-dot {
background-color: var(--color-online); background-color: var(--color-online);
display: inline-block; display: inline-block;
width: 12px; width: 12px;
height: 12px; height: 12px;
border-radius: 50%; border-radius: 50%;
margin-right: 0.5em;
} }
.vc-membercount-total-dot { .vc-membercount-total-dot {
@ -40,5 +52,10 @@
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
border: 3px solid var(--color-total); border: 3px solid var(--color-total);
margin: 0 0.5em 0 1em;
} }
.vc-membercount-voice-icon {
color: var(--color-voice);
width: 15px;
height: 15px;
}

View file

@ -8,8 +8,8 @@ import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { Channel } from "@vencord/discord-types"; import { Channel } from "@vencord/discord-types";
import { filters, findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findStoreLazy, mapMangledModuleLazy } from "@webpack"; 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 } from "@webpack/common"; 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-"); const cl = classNameFactory("vc-uvs-");
@ -18,7 +18,6 @@ const { useChannelName } = mapMangledModuleLazy("#{intl::GROUP_DM_ALONE}", {
useChannelName: filters.byCode("()=>null==") useChannelName: filters.byCode("()=>null==")
}); });
const getDMChannelIcon = findByCodeLazy(".getChannelIconURL({"); const getDMChannelIcon = findByCodeLazy(".getChannelIconURL({");
const VoiceStateStore = findStoreLazy("VoiceStateStore");
const Avatar = findComponentByCodeLazy(".status)/2):0"); const Avatar = findComponentByCodeLazy(".status)/2):0");
const GroupDMAvatars = findComponentByCodeLazy("frontSrc:", "getAvatarURL"); const GroupDMAvatars = findComponentByCodeLazy("frontSrc:", "getAvatarURL");
@ -84,7 +83,7 @@ function VoiceChannelTooltip({ channel, isLocked }: VoiceChannelTooltipProps) {
const voiceStates = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStatesForChannel(channel.id)); const voiceStates = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStatesForChannel(channel.id));
const users = useMemo( const users = useMemo(
() => Object.values<any>(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] [voiceStates]
); );
@ -139,7 +138,7 @@ export interface VoiceChannelIndicatorProps {
const clickTimers = {} as Record<string, any>; const clickTimers = {} as Record<string, any>;
export const VoiceChannelIndicator = ErrorBoundary.wrap(({ userId, isProfile, isActionButton, shouldHighlight }: VoiceChannelIndicatorProps) => { 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); const channel = channelId == null ? undefined : ChannelStore.getChannel(channelId);
if (channel == null) return null; if (channel == null) return null;

View file

@ -22,13 +22,12 @@ import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { wordsToTitle } from "@utils/text"; import { wordsToTitle } from "@utils/text";
import definePlugin, { ReporterTestable } from "@utils/types"; import definePlugin, { ReporterTestable } from "@utils/types";
import { findByPropsLazy } from "@webpack"; import { Button, ChannelStore, Forms, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, useMemo, UserStore, VoiceStateStore } from "@webpack/common";
import { Button, ChannelStore, Forms, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, useMemo, UserStore } from "@webpack/common";
import { ReactElement } from "react"; import { ReactElement } from "react";
import { getCurrentVoice, settings } from "./settings"; import { getCurrentVoice, settings } from "./settings";
interface VoiceState { interface VoiceStateChangeEvent {
userId: string; userId: string;
channelId?: string; channelId?: string;
oldChannelId?: string; oldChannelId?: string;
@ -38,8 +37,6 @@ interface VoiceState {
selfMute: boolean; 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 // 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 // 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 // not say the second mute, which would lead you to believe they're unmuted
@ -88,7 +85,7 @@ let StatusMap = {} as Record<string, {
// for some ungodly reason // for some ungodly reason
let myLastChannelId: string | undefined; let myLastChannelId: string | undefined;
function getTypeAndChannelId({ channelId, oldChannelId }: VoiceState, isMe: boolean) { function getTypeAndChannelId({ channelId, oldChannelId }: VoiceStateChangeEvent, isMe: boolean) {
if (isMe && channelId !== myLastChannelId) { if (isMe && channelId !== myLastChannelId) {
oldChannelId = myLastChannelId; oldChannelId = myLastChannelId;
myLastChannelId = channelId; myLastChannelId = channelId;
@ -163,7 +160,7 @@ export default definePlugin({
settings, settings,
flux: { flux: {
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceStateChangeEvent[]; }) {
const myGuildId = SelectedGuildStore.getGuildId(); const myGuildId = SelectedGuildStore.getGuildId();
const myChanId = SelectedChannelStore.getVoiceChannelId(); const myChanId = SelectedChannelStore.getVoiceChannelId();
const myId = UserStore.getCurrentUser().id; const myId = UserStore.getCurrentUser().id;
@ -195,7 +192,7 @@ export default definePlugin({
AUDIO_TOGGLE_SELF_MUTE() { AUDIO_TOGGLE_SELF_MUTE() {
const chanId = SelectedChannelStore.getVoiceChannelId()!; const chanId = SelectedChannelStore.getVoiceChannelId()!;
const s = VoiceStateStore.getVoiceStateForChannel(chanId) as VoiceState; const s = VoiceStateStore.getVoiceStateForChannel(chanId);
if (!s) return; if (!s) return;
const event = s.mute || s.selfMute ? "unmute" : "mute"; const event = s.mute || s.selfMute ? "unmute" : "mute";
@ -204,7 +201,7 @@ export default definePlugin({
AUDIO_TOGGLE_SELF_DEAF() { AUDIO_TOGGLE_SELF_DEAF() {
const chanId = SelectedChannelStore.getVoiceChannelId()!; const chanId = SelectedChannelStore.getVoiceChannelId()!;
const s = VoiceStateStore.getVoiceStateForChannel(chanId) as VoiceState; const s = VoiceStateStore.getVoiceStateForChannel(chanId);
if (!s) return; if (!s) return;
const event = s.deaf || s.selfDeaf ? "undeafen" : "deafen"; const event = s.deaf || s.selfDeaf ? "undeafen" : "deafen";

View file

@ -48,6 +48,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "V", name: "V",
id: 343383572805058560n id: 343383572805058560n
}, },
Apexo: {
name: "Apexo",
id: 228548952687902720n
},
Arjix: { Arjix: {
name: "ArjixWasTaken", name: "ArjixWasTaken",
id: 674710789138939916n, id: 674710789138939916n,

View file

@ -47,6 +47,7 @@ export let SelectedGuildStore: t.SelectedGuildStore;
export let ChannelStore: t.ChannelStore; export let ChannelStore: t.ChannelStore;
export let TypingStore: t.TypingStore; export let TypingStore: t.TypingStore;
export let RelationshipStore: t.RelationshipStore; export let RelationshipStore: t.RelationshipStore;
export let VoiceStateStore: t.VoiceStateStore;
export let EmojiStore: t.EmojiStore; export let EmojiStore: t.EmojiStore;
export let StickersStore: t.StickersStore; export let StickersStore: t.StickersStore;
@ -79,6 +80,7 @@ waitForStore("WindowStore", m => WindowStore = m);
waitForStore("EmojiStore", m => EmojiStore = m); waitForStore("EmojiStore", m => EmojiStore = m);
waitForStore("StickersStore", m => StickersStore = m); waitForStore("StickersStore", m => StickersStore = m);
waitForStore("TypingStore", m => TypingStore = m); waitForStore("TypingStore", m => TypingStore = m);
waitForStore("VoiceStateStore", m => VoiceStateStore = m);
waitForStore("ThemeStore", m => { waitForStore("ThemeStore", m => {
ThemeStore = m; ThemeStore = m;
// Importing this directly can easily cause circular imports. For this reason, use a non import access here. // Importing this directly can easily cause circular imports. For this reason, use a non import access here.