Plugin

MessageLatency

Displays an indicator for messages that took ≥n seconds to send

Chat Utility
index.tsx
Download

Source

src/plugins/messageLatency/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 { isNonNullish } from "@utils/guards";
11import definePlugin, { OptionType } from "@utils/types";
12import { Message } from "@vencord/discord-types";
13import { AuthenticationStore, SnowflakeUtils, Tooltip } from "@webpack/common";
14
15type FillValue = ("status-danger" | "status-warning" | "status-positive" | "text-muted");
16type Fill = [FillValue, FillValue, FillValue];
17type DiffKey = keyof Diff;
18
19interface Diff {
20 days: number,
21 hours: number,
22 minutes: number,
23 seconds: number;
24 milliseconds: number;
25}
26
27const DISCORD_KT_DELAY = 1471228928;
28
29export default definePlugin({
30 name: "MessageLatency",
31 description: "Displays an indicator for messages that took ≥n seconds to send",
32 tags: ["Chat", "Utility"],
33 authors: [Devs.FiveCord],
34
35 settings: definePluginSettings({
36 latency: {
37 type: OptionType.NUMBER,
38 description: "Threshold in seconds for latency indicator",
39 default: 2
40 },
41 detectDiscordKotlin: {
42 type: OptionType.BOOLEAN,
43 description: "Detect old Discord Android clients",
44 default: true
45 },
46 showMillis: {
47 type: OptionType.BOOLEAN,
48 description: "Show milliseconds",
49 default: false
50 },
51 ignoreSelf: {
52 type: OptionType.BOOLEAN,
53 description: "Don't add indicator to your own messages",
54 default: false
55 }
56 }),
57
58 patches: [
59 {
60 find: "showCommunicationDisabledStyles",
61 replacement: {
62 match: /(message:(\i),avatar:\i,username:\(0,\i.jsxs\)\(\i.Fragment,\{children:\[)(\i&&)/,
63 replace: "$1$self.Tooltip()({ message: $2 }),$3"
64 }
65 }
66 ],
67
68 stringDelta(delta: number, showMillis: boolean) {
69 const diff: Diff = {
70 days: Math.floor(delta / (60 * 60 * 24 * 1000)),
71 hours: Math.floor((delta / (60 * 60 * 1000)) % 24),
72 minutes: Math.floor((delta / (60 * 1000)) % 60),
73 seconds: Math.floor(delta / 1000 % 60),
74 milliseconds: Math.floor(delta % 1000)
75 };
76
77 const str = (k: DiffKey) => diff[k] > 0 ? `${diff[k]} ${diff[k] > 1 ? k : k.substring(0, k.length - 1)}` : null;
78 const keys = Object.keys(diff) as DiffKey[];
79
80 const ts = keys.reduce((prev, k) => {
81 const s = str(k);
82
83 return prev + (
84 isNonNullish(s)
85 ? (prev !== ""
86 ? (showMillis ? k === "milliseconds" : k === "seconds")
87 ? " and "
88 : " "
89 : "") + s
90 : ""
91 );
92 }, "");
93
94 return ts || "0 seconds";
95 },
96
97 latencyTooltipData(message: Message) {
98 const { latency, detectDiscordKotlin, showMillis, ignoreSelf } = this.settings.store;
99 const { id, nonce } = message;
100
101 // Message wasn't received through gateway
102 if (!isNonNullish(nonce)) return null;
103
104 // Bots basically never send a nonce, and if someone does do it then it's usually not a snowflake
105 if (message.author.bot) return null;
106
107 if (ignoreSelf && message.author.id === AuthenticationStore.getId()) return null;
108
109 let isDiscordKotlin = false;
110 let delta = SnowflakeUtils.extractTimestamp(id) - SnowflakeUtils.extractTimestamp(nonce); class="ts-cmt">// milliseconds
111 if (!showMillis) {
112 delta = Math.round(delta / 1000) * 1000;
113 }
114
115 // Old Discord Android clients have a delay of around 17 days
116 // This is a workaround for that
117 if (-delta >= DISCORD_KT_DELAY - 86400000) { class="ts-cmt">// One day of padding for good measure
118 isDiscordKotlin = detectDiscordKotlin;
119 delta += DISCORD_KT_DELAY;
120 }
121
122 // Thanks dziurwa (I hate you)
123 // This is when the user's clock is ahead
124 // Can't do anything if the clock is behind
125 const abs = Math.abs(delta);
126 const ahead = abs !== delta;
127 const latencyMillis = latency * 1000;
128
129 const stringDelta = abs >= latencyMillis ? this.stringDelta(abs, showMillis) : null;
130
131 // Also thanks dziurwa
132 // 2 minutes
133 const TROLL_LIMIT = 2 * 60 * 1000;
134
135 const fill: Fill = isDiscordKotlin
136 ? ["status-positive", "status-positive", "text-muted"]
137 : delta >= TROLL_LIMIT || ahead
138 ? ["text-muted", "text-muted", "text-muted"]
139 : delta >= (latencyMillis * 2)
140 ? ["status-danger", "text-muted", "text-muted"]
141 : ["status-warning", "status-warning", "text-muted"];
142
143 return (abs >= latencyMillis || isDiscordKotlin) ? { delta: stringDelta, ahead, fill, isDiscordKotlin } : null;
144 },
145
146 Tooltip() {
147 return ErrorBoundary.wrap(({ message }: { message: Message; }) => {
148 const d = this.latencyTooltipData(message);
149
150 if (!isNonNullish(d)) return null;
151
152 let text: string;
153 if (!d.delta) {
154 text = "User is suspected to be on an old Discord Android client";
155 } else {
156 text = (d.ahead ? `This user's clock is ${d.delta} ahead.` : `This message was sent with a delay of ${d.delta}.`) + (d.isDiscordKotlin ? " User is suspected to be on an old Discord Android client." : "");
157 }
158
159 return <Tooltip
160 text={text}
161 position="top"
162 >
163 {props => <this.Icon delta={d.delta} fill={d.fill} props={props} />}
164 </Tooltip>;
165 }, { noop: true });
166 },
167
168 Icon({ delta, fill, props }: {
169 delta: string | null;
170 fill: Fill,
171 props: {
172 onClick(): void;
173 onMouseEnter(): void;
174 onMouseLeave(): void;
175 onContextMenu(): void;
176 onFocus(): void;
177 onBlur(): void;
178 "aria-label"?: string;
179 };
180 }) {
181 return <svg
182 xmlns="http:class="ts-cmt">//www.w3.org/2000/svg"
183 viewBox="0 0 16 16"
184 width="12"
185 height="12"
186 role="img"
187 fill="none"
188 style={{ marginRight: "8px", verticalAlign: -1 }}
189 aria-label={delta ?? "Old Discord Android client"}
190 aria-hidden="false"
191 {...props}
192 >
193 <path
194 fill={`var(--${fill[0]})`}
195 d="M4.8001 12C4.8001 11.5576 4.51344 11.2 4.16023 11.2H2.23997C1.88676 11.2 1.6001 11.5576 1.6001 12V13.6C1.6001 14.0424 1.88676 14.4 2.23997 14.4H4.15959C4.5128 14.4 4.79946 14.0424 4.79946 13.6L4.8001 12Z"
196 />
197 <path
198 fill={`var(--${fill[1]})`}
199 d="M9.6001 7.12724C9.6001 6.72504 9.31337 6.39998 8.9601 6.39998H7.0401C6.68684 6.39998 6.40011 6.72504 6.40011 7.12724V13.6727C6.40011 14.0749 6.68684 14.4 7.0401 14.4H8.9601C9.31337 14.4 9.6001 14.0749 9.6001 13.6727V7.12724Z"
200 />
201 <path
202 fill={`var(--${fill[2]})`}
203 d="M14.4001 2.31109C14.4001 1.91784 14.1134 1.59998 13.7601 1.59998H11.8401C11.4868 1.59998 11.2001 1.91784 11.2001 2.31109V13.6888C11.2001 14.0821 11.4868 14.4 11.8401 14.4H13.7601C14.1134 14.4 14.4001 14.0821 14.4001 13.6888V2.31109Z"
204 />
205 </svg>;
206 }
207});
208