/* * Vencord, a Discord client mod * Copyright (c) 2024 Vendicated and contributors * SPDX-License-Identifier: GPL-3.0-or-later */ import { definePluginSettings, migratePluginToSettings, migrateSettingsFromPlugin } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { Devs, EquicordDevs } from "@utils/constants"; import { useTimer } from "@utils/react"; import { formatDurationMs } from "@utils/text"; import definePlugin, { OptionType } from "@utils/types"; import { PassiveUpdateState, VoiceState } from "@vencord/discord-types"; import { FluxDispatcher, GuildStore, React, UserStore } from "@webpack/common"; import alignedChatInputFix from "./alignedChatInputFix.css?managed"; import { Timer } from "./Timer"; export const settings = definePluginSettings({ format: { type: OptionType.SELECT, description: "Compact or human readable format:", options: [ { label: "30:23:00:42", value: "stopwatch", default: true }, { label: "30d 23h 00m 42s", value: "human" } ] }, allCallTimers: { type: OptionType.BOOLEAN, description: "Add call timer to all users in a server voice channel", restartNeeded: true, default: false }, showWithoutHover: { type: OptionType.BOOLEAN, description: "Always show the timer without needing to hover", restartNeeded: true, default: false }, showRoleColor: { type: OptionType.BOOLEAN, description: "Show the user's role color (if this plugin in enabled)", restartNeeded: false, default: false }, trackSelf: { type: OptionType.BOOLEAN, description: "Also track yourself", restartNeeded: false, default: false }, showSeconds: { type: OptionType.BOOLEAN, description: "Show seconds in the timer", restartNeeded: false, default: false }, watchLargeGuilds: { type: OptionType.BOOLEAN, description: "Track users in large guilds. This may cause lag if you're in a lot of large guilds with active voice users. Tested with up to 2000 active voice users with no issues.", restartNeeded: true, default: false } }); // Save the join time of all users in a Map type userJoinData = { channelId: string, time: number; guildId: string; }; const userJoinTimes = new Map(); /** * The function `addUserJoinTime` stores the join time of a user in a specific channel within a guild. * @param {string} userId - The `userId` parameter is a string that represents the unique identifier of * the user who is joining a channel in a guild. * @param {string} channelId - The `channelId` parameter represents the unique identifier of the * channel where the user joined. * @param {string} guildId - The `guildId` parameter in the `addUserJoinTime` function represents the * unique identifier of the guild (server) to which the user belongs. It is used to associate the * user's join time with a specific guild within the application or platform. */ function addUserJoinTime(userId: string, channelId: string, guildId: string) { // create a random number userJoinTimes.set(userId, { channelId, time: Date.now(), guildId }); } /** * The function `removeUserJoinTime` removes the join time of a user identified by their user ID. * @param {string} userId - The `userId` parameter is a string that represents the unique identifier of * a user whose join time needs to be removed. */ function removeUserJoinTime(userId: string) { userJoinTimes.delete(userId); } // 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 myLastChannelId: string | undefined; // Allow user updates on discord first load let runOneTime = true; migratePluginToSettings(false, "CallTimer", "AllCallTimers", "allCallTimers"); migrateSettingsFromPlugin("CallTimer", "AllCallTimers", "showWithoutHover", "showRoleColor", "trackSelf", "showSeconds", "watchLargeGuilds"); export default definePlugin({ name: "CallTimer", description: "Add call timers for all users in voice channels and in the connection status.", authors: [Devs.Ven, EquicordDevs.MaxHerbold, Devs.D3SOX], managedStyle: alignedChatInputFix, settings, isModified: true, startTime: 0, interval: void 0 as NodeJS.Timeout | undefined, patches: [ { find: "VOICE_PANEL}};", predicate: () => settings.store.allCallTimers, replacement: [ { match: /user:(\i).*?\.EMBEDDED.{0,25};(?=return 0!==(\i)\.length)/, replace: "$&$2.push($self.renderTimer($1.id));", predicate: () => !settings.store.showWithoutHover, }, { match: /#{intl::GUEST_NAME_SUFFIX}\)\]\}\):""(?=.*?userId:(\i\.\i))/, replace: "$&,$self.renderTimer($1)", predicate: () => settings.store.showWithoutHover, } ] }, { find: "renderConnectionStatus(){", replacement: { match: /(lineClamp:1,children:)(\i)(?=,|}\))/, replace: "$1[$2,$self.renderConnectionTimer(this.props.channel.id)]" } } ], flux: { VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) { const myId = UserStore.getCurrentUser().id; for (const state of voiceStates) { const { userId, channelId, guildId } = state; const isMe = userId === myId; if (!guildId) { // guildId is never undefined here continue; } // check if the state does not actually has a `oldChannelId` property if (!("oldChannelId" in state) && !runOneTime && !settings.store.watchLargeGuilds) { // batch update triggered. This is ignored because it // is caused by opening a previously unopened guild continue; } let { oldChannelId } = state; if (isMe && channelId !== myLastChannelId) { oldChannelId = myLastChannelId; myLastChannelId = channelId ?? undefined; } if (channelId !== oldChannelId) { if (channelId) { // move or join addUserJoinTime(userId, channelId, guildId); } else if (oldChannelId) { // leave removeUserJoinTime(userId); } } } runOneTime = false; }, PASSIVE_UPDATE_V1(passiveUpdate: PassiveUpdateState) { if (settings.store.watchLargeGuilds) { return; } const { voiceStates } = passiveUpdate; if (!voiceStates) { // if there are no users in a voice call return; } // find all users that have the same guildId and if that user is not in the voiceStates, remove them from the map const { guildId } = passiveUpdate; // check the guildId in the userJoinTimes map for (const [userId, data] of userJoinTimes) { if (data.guildId === guildId) { // check if the user is in the voiceStates const userInVoiceStates = voiceStates.find(state => state.userId === userId); if (!userInVoiceStates) { // remove the user from the map removeUserJoinTime(userId); } } } // since we were gifted this data let's use it to update our join times for (const state of voiceStates) { const { userId, channelId } = state; if (!channelId) { // channelId is never undefined here continue; } // check if the user is in the map if (userJoinTimes.has(userId)) { // check if the user is in a channel if (channelId !== userJoinTimes.get(userId)?.channelId) { // update the user's join time addUserJoinTime(userId, channelId, guildId); } } else { // user wasn't previously tracked, add the user to the map addUserJoinTime(userId, channelId, guildId); } } }, }, subscribeToAllGuilds() { // we need to subscribe to all guilds' events because otherwise we would miss updates on large guilds const guilds = Object.values(GuildStore.getGuilds()).map(guild => guild.id); const subscriptions = guilds.reduce((acc, id) => ({ ...acc, [id]: { typing: true } }), {}); FluxDispatcher.dispatch({ type: "GUILD_SUBSCRIPTIONS_FLUSH", subscriptions }); }, start() { if (settings.store.watchLargeGuilds) { this.subscribeToAllGuilds(); } }, renderTimer(userId: string) { // get the user join time from the users object const joinTime = userJoinTimes.get(userId); if (!joinTime?.time) { // join time is unknown return; } if (userId === UserStore.getCurrentUser().id && !settings.store.trackSelf) { // don't show for self return; } return ( ); }, renderConnectionTimer(channelId: string) { return ; }, ConnectionTimer({ channelId }: { channelId: string; }) { const time = useTimer({ deps: [channelId] }); return

{formatDurationMs(time, settings.store.format === "human")}

; } });