Plugin

AllCallTimers

Add call timer to all users in a server voice channel.

index.tsx
Download

Source

src/plugins/allCallTimers/index.tsx
1/*
2 * FiveCord — a Discord client mod
3 * Copyright (c) 2025 FiveCord
4 * SPDX-License-Identifier: GPL-3.0-or-later
5 */
6
7import { definePluginSettings } from "@api/Settings";
8import ErrorBoundary from "@components/ErrorBoundary";
9import { Devs } from "@utils/constants";
10import definePlugin, { OptionType } from "@utils/types";
11import { FluxDispatcher, GuildStore, UserStore } from "@webpack/common";
12import { PassiveUpdateState, VoiceState } from "@webpack/types";
13
14import { Timer } from "./Timer";
15
16export const settings = definePluginSettings({
17 showWithoutHover: {
18 type: OptionType.BOOLEAN,
19 description: "Always show the timer without needing to hover",
20 restartNeeded: false,
21 default: true
22 },
23 showRoleColor: {
24 type: OptionType.BOOLEAN,
25 description: "Show the user's role color (if this plugin in enabled)",
26 restartNeeded: false,
27 default: true
28 },
29 trackSelf: {
30 type: OptionType.BOOLEAN,
31 description: "Also track yourself",
32 restartNeeded: false,
33 default: true
34 },
35 showSeconds: {
36 type: OptionType.BOOLEAN,
37 description: "Show seconds in the timer",
38 restartNeeded: false,
39 default: true
40 },
41 format: {
42 type: OptionType.SELECT,
43 description: "Compact or human readable format:",
44 options: [
45 {
46 label: "30:23:00:42",
47 value: "stopwatch",
48 default: true
49 },
50 {
51 label: "30d 23h 00m 42s",
52 value: "human"
53 }
54 ]
55 },
56 watchLargeGuilds: {
57 type: OptionType.BOOLEAN,
58 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.",
59 restartNeeded: true,
60 default: false
61 }
62});
63
64
65// Save the join time of all users in a Map
66type userJoinData = { channelId: string, time: number; guildId: string; };
67const userJoinTimes = new Map<string, userJoinData>();
68
69/**
70 * The function `addUserJoinTime` stores the join time of a user in a specific channel within a guild.
71 * @param {string} userId - The `userId` parameter is a string that represents the unique identifier of
72 * the user who is joining a channel in a guild.
73 * @param {string} channelId - The `channelId` parameter represents the unique identifier of the
74 * channel where the user joined.
75 * @param {string} guildId - The `guildId` parameter in the `addUserJoinTime` function represents the
76 * unique identifier of the guild (server) to which the user belongs. It is used to associate the
77 * user's join time with a specific guild within the application or platform.
78 */
79function addUserJoinTime(userId: string, channelId: string, guildId: string) {
80 // create a random number
81 userJoinTimes.set(userId, { channelId, time: Date.now(), guildId });
82}
83
84/**
85 * The function `removeUserJoinTime` removes the join time of a user identified by their user ID.
86 * @param {string} userId - The `userId` parameter is a string that represents the unique identifier of
87 * a user whose join time needs to be removed.
88 */
89function removeUserJoinTime(userId: string) {
90 userJoinTimes.delete(userId);
91}
92
93// For every user, channelId and oldChannelId will differ when moving channel.
94// Only for the local user, channelId and oldChannelId will be the same when moving channel,
95// for some ungodly reason
96let myLastChannelId: string | undefined;
97
98// Allow user updates on discord first load
99let runOneTime = true;
100
101export default definePlugin({
102 name: "AllCallTimers",
103 description: "Add call timer to all users in a server voice channel.",
104 authors: [Devs.FiveCord],
105
106 settings,
107
108 patches: [
109 {
110 find: "renderPrioritySpeaker",
111 replacement: [
112 {
113 match: /(render\(\)\{.+\}\),children:)\[(.+renderName\(\),)/,
114 replace: "$&,$self.showClockInjection(this),"
115 }
116 ]
117 },
118 {
119 find: "renderPrioritySpeaker",
120 replacement: [
121 {
122 match: /(renderName\(\)\{.+:"")/,
123 replace: "$&,$self.showTextInjection(this),"
124 }
125 ]
126 }
127 ],
128
129 flux: {
130 VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
131 const myId = UserStore.getCurrentUser().id;
132
133 for (const state of voiceStates) {
134 const { userId, channelId, guildId } = state;
135 const isMe = userId === myId;
136
137 if (!guildId) {
138 // guildId is never undefined here
139 continue;
140 }
141
142 // check if the state does not actually has a `oldChannelId` property
143 if (!("oldChannelId" in state) && !runOneTime && !settings.store.watchLargeGuilds) {
144 // batch update triggered. This is ignored because it
145 // is caused by opening a previously unopened guild
146 continue;
147 }
148
149 let { oldChannelId } = state;
150 if (isMe && channelId !== myLastChannelId) {
151 oldChannelId = myLastChannelId;
152 myLastChannelId = channelId;
153 }
154
155 if (channelId !== oldChannelId) {
156 if (channelId) {
157 // move or join
158 addUserJoinTime(userId, channelId, guildId);
159 } else if (oldChannelId) {
160 // leave
161 removeUserJoinTime(userId);
162 }
163 }
164 }
165 runOneTime = false;
166 },
167 PASSIVE_UPDATE_V1(passiveUpdate: PassiveUpdateState) {
168 if (settings.store.watchLargeGuilds) {
169 return;
170 }
171
172 const { voiceStates } = passiveUpdate;
173 if (!voiceStates) {
174 // if there are no users in a voice call
175 return;
176 }
177
178 // find all users that have the same guildId and if that user is not in the voiceStates, remove them from the map
179 const { guildId } = passiveUpdate;
180
181 // check the guildId in the userJoinTimes map
182 for (const [userId, data] of userJoinTimes) {
183 if (data.guildId === guildId) {
184 // check if the user is in the voiceStates
185 const userInVoiceStates = voiceStates.find(state => state.userId === userId);
186 if (!userInVoiceStates) {
187 // remove the user from the map
188 removeUserJoinTime(userId);
189 }
190 }
191 }
192
193 // since we were gifted this data let's use it to update our join times
194 for (const state of voiceStates) {
195 const { userId, channelId } = state;
196
197 if (!channelId) {
198 // channelId is never undefined here
199 continue;
200 }
201
202 // check if the user is in the map
203 if (userJoinTimes.has(userId)) {
204 // check if the user is in a channel
205 if (channelId !== userJoinTimes.get(userId)?.channelId) {
206 // update the user's join time
207 addUserJoinTime(userId, channelId, guildId);
208 }
209 } else {
210 // user wasn't previously tracked, add the user to the map
211 addUserJoinTime(userId, channelId, guildId);
212 }
213 }
214 },
215 },
216
217 subscribeToAllGuilds() {
218 // we need to subscribe to all guilds' events because otherwise we would miss updates on large guilds
219 const guilds = Object.values(GuildStore.getGuilds()).map(guild => guild.id);
220 const subscriptions = guilds.reduce((acc, id) => ({ ...acc, [id]: { typing: true } }), {});
221 FluxDispatcher.dispatch({ type: "GUILD_SUBSCRIPTIONS_FLUSH", subscriptions });
222 },
223
224 start() {
225 if (settings.store.watchLargeGuilds) {
226 this.subscribeToAllGuilds();
227 }
228 },
229
230 showClockInjection(property: { props: { user: { id: string; }; }; }) {
231 if (settings.store.showWithoutHover) {
232 return "";
233 }
234 return this.showInjection(property);
235 },
236
237 showTextInjection(property: { props: { user: { id: string; }; }; }) {
238 if (!settings.store.showWithoutHover) {
239 return "";
240 }
241 return this.showInjection(property);
242 },
243
244 showInjection(property: { props: { user: { id: string; }; }; }) {
245 const userId = property.props.user.id;
246 return this.renderTimer(userId);
247 },
248
249 renderTimer(userId: string) {
250 // get the user join time from the users object
251 const joinTime = userJoinTimes.get(userId);
252 if (!joinTime?.time) {
253 // join time is unknown
254 return;
255 }
256 if (userId === UserStore.getCurrentUser().id && !settings.store.trackSelf) {
257 // don't show for self
258 return;
259 }
260
261 return (
262 <ErrorBoundary>
263 <Timer time={joinTime.time} />
264 </ErrorBoundary>
265 );
266 },
267});
268