diff --git a/packages/discord-types/src/stores/StreamerModeStore.d.ts b/packages/discord-types/src/stores/StreamerModeStore.d.ts index 5585b4aba..efa6cfc9e 100644 --- a/packages/discord-types/src/stores/StreamerModeStore.d.ts +++ b/packages/discord-types/src/stores/StreamerModeStore.d.ts @@ -1,6 +1,11 @@ -import { FluxStore } from ".."; +import { FluxStore } from "@vencord/discord-types"; export class StreamerModeStore extends FluxStore { - enabled: boolean; - autoToggle: boolean; + get autoToggle(): boolean; + get disableNotifications(): boolean; + get disableSounds(): boolean; + get enableContentProtection(): boolean; + get enabled(): boolean; + get hideInstantInvites(): boolean; + get hidePersonalInformation(): boolean; } 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 000000000..84540d996 --- /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 bf7d7152b..45f66142b 100644 --- a/packages/discord-types/src/stores/index.d.ts +++ b/packages/discord-types/src/stores/index.d.ts @@ -15,10 +15,12 @@ export * from "./SpellCheckStore"; export * from "./SelectedChannelStore"; export * from "./SelectedGuildStore"; export * from "./StickersStore"; +export * from "./StreamerModeStore"; 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 0a3f5e620..020338861 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 000000000..e76f7151b --- /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 7fa6fbcde..bb48eedbf 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 f43bff830..2b0e4400f 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/messageLatency/index.tsx b/src/plugins/messageLatency/index.tsx index 6f51aedec..b23d4aadb 100644 --- a/src/plugins/messageLatency/index.tsx +++ b/src/plugins/messageLatency/index.tsx @@ -98,7 +98,7 @@ export default definePlugin({ if (!isNonNullish(nonce)) return null; // Bots basically never send a nonce, and if someone does do it then it's usually not a snowflake - if (message.bot) return null; + if (message.author.bot) return null; let isDiscordKotlin = false; let delta = SnowflakeUtils.extractTimestamp(id) - SnowflakeUtils.extractTimestamp(nonce); // milliseconds diff --git a/src/plugins/userVoiceShow/components.tsx b/src/plugins/userVoiceShow/components.tsx index a9f4311cc..ba5f36054 100644 --- a/src/plugins/userVoiceShow/components.tsx +++ b/src/plugins/userVoiceShow/components.tsx @@ -83,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] ); @@ -167,7 +167,7 @@ export type 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 4c76c2782..b2ca10d10 100644 --- a/src/plugins/vcNarrator/index.tsx +++ b/src/plugins/vcNarrator/index.tsx @@ -27,7 +27,7 @@ import { ReactElement } from "react"; import { getCurrentVoice, settings } from "./settings"; -interface VoiceState { +interface VoiceStateChangeEvent { userId: string; channelId?: string; oldChannelId?: string; @@ -78,7 +78,7 @@ function formatText(str: string, user: string, channel: string, displayName: str // for some ungodly reason let myLastChannelId: string | undefined; -function getTypeAndChannelId({ channelId, oldChannelId }: VoiceState, isMe: boolean) { +function getTypeAndChannelId({ channelId, oldChannelId }: VoiceStateChangeEvent, isMe: boolean) { if (isMe && channelId !== myLastChannelId) { oldChannelId = myLastChannelId; myLastChannelId = channelId; @@ -145,7 +145,7 @@ export default definePlugin({ settings, flux: { - VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { + VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceStateChangeEvent[]; }) { const myGuildId = SelectedGuildStore.getGuildId(); const myChanId = SelectedChannelStore.getVoiceChannelId(); const myId = UserStore.getCurrentUser().id; @@ -177,7 +177,7 @@ export default definePlugin({ AUDIO_TOGGLE_SELF_MUTE() { const chanId = SelectedChannelStore.getVoiceChannelId()!; - const s = VoiceStateStore.getVoiceStateForChannel(chanId) as VoiceState; + const s = VoiceStateStore.getVoiceStateForChannel(chanId); if (!s) return; const event = s.mute || s.selfMute ? "unmute" : "mute"; @@ -186,7 +186,7 @@ export default definePlugin({ AUDIO_TOGGLE_SELF_DEAF() { const chanId = SelectedChannelStore.getVoiceChannelId()!; - const s = VoiceStateStore.getVoiceStateForChannel(chanId) as VoiceState; + const s = VoiceStateStore.getVoiceStateForChannel(chanId); if (!s) return; const event = s.deaf || s.selfDeaf ? "undeafen" : "deafen"; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 17e95b4cb..9cf67baae 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -67,6 +67,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "V", id: 343383572805058560n }, + Apexo: { + name: "Apexo", + id: 228548952687902720n + }, Arjix: { name: "ArjixWasTaken", id: 674710789138939916n, diff --git a/src/webpack/common/stores.ts b/src/webpack/common/stores.ts index d89a1fa52..bc5b870ac 100644 --- a/src/webpack/common/stores.ts +++ b/src/webpack/common/stores.ts @@ -35,7 +35,6 @@ export let PermissionStore: GenericStore; export let GuildChannelStore: GenericStore; export let ReadStateStore: GenericStore; export let PresenceStore: GenericStore; -export let VoiceStateStore: GenericStore; export let GuildStore: t.GuildStore; export let GuildRoleStore: t.GuildRoleStore; @@ -49,14 +48,15 @@ export let ChannelStore: t.ChannelStore; export let TypingStore: t.TypingStore; export let RelationshipStore: t.RelationshipStore; export let MediaEngineStore: t.MediaEngineStore; -export let StreamerModeStore: t.StreamerModeStore; export let SpellCheckStore: t.SpellCheckStore; +export let VoiceStateStore: t.VoiceStateStore; export let EmojiStore: t.EmojiStore; export let StickersStore: t.StickersStore; export let ThemeStore: t.ThemeStore; export let WindowStore: t.WindowStore; export let DraftStore: t.DraftStore; +export let StreamerModeStore: t.StreamerModeStore; /** * @see jsdoc of {@link t.useStateFromStores} @@ -75,7 +75,6 @@ waitForStore("GuildRoleStore", m => GuildRoleStore = m); waitForStore("GuildMemberStore", m => GuildMemberStore = m); waitForStore("RelationshipStore", m => RelationshipStore = m); waitForStore("MediaEngineStore", m => MediaEngineStore = m); -waitForStore("StreamerModeStore", m => StreamerModeStore = m); waitForStore("SpellcheckStore", m => SpellCheckStore = m); waitForStore("PermissionStore", m => PermissionStore = m); waitForStore("PresenceStore", m => PresenceStore = m); @@ -87,9 +86,10 @@ waitForStore("WindowStore", m => WindowStore = m); waitForStore("EmojiStore", m => EmojiStore = m); waitForStore("StickersStore", m => StickersStore = m); waitForStore("TypingStore", m => TypingStore = m); +waitForStore("VoiceStateStore", m => VoiceStateStore = m); +waitForStore("StreamerModeStore", m => StreamerModeStore = m); waitForStore("ThemeStore", m => { ThemeStore = m; // Importing this directly can easily cause circular imports. For this reason, use a non import access here. Vencord.QuickCss.initQuickCssThemeStore(); }); -waitForStore("VoiceStateStore", m => VoiceStateStore = m);