Plugin
MessageLatency
Displays an indicator for messages that took ≥n seconds to send
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import { definePluginSettings } from "@api/Settings";8
import ErrorBoundary from "@components/ErrorBoundary";9
import { Devs } from "@utils/constants";10
import { isNonNullish } from "@utils/guards";11
import definePlugin, { OptionType } from "@utils/types";12
import { Message } from "@vencord/discord-types";13
import { AuthenticationStore, SnowflakeUtils, Tooltip } from "@webpack/common";14
15
type FillValue = ("status-danger" | "status-warning" | "status-positive" | "text-muted");16
type Fill = [FillValue, FillValue, FillValue];17
type DiffKey = keyof Diff;18
19
interface Diff {20
days: number,21
hours: number,22
minutes: number,23
seconds: number;24
milliseconds: number;25
}26
27
const DISCORD_KT_DELAY = 1471228928;28
29
export 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: 240
},41
detectDiscordKotlin: {42
type: OptionType.BOOLEAN,43
description: "Detect old Discord Android clients",44
default: true45
},46
showMillis: {47
type: OptionType.BOOLEAN,48
description: "Show milliseconds",49
default: false50
},51
ignoreSelf: {52
type: OptionType.BOOLEAN,53
description: "Don039;t add indicator to your own messages",54
default: false55
}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
: "") + s90
: ""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 gateway102
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 snowflake105
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">// milliseconds111
if (!showMillis) {112
delta = Math.round(delta / 1000) * 1000;113
}114
115
// Old Discord Android clients have a delay of around 17 days116
// This is a workaround for that117
if (-delta >= DISCORD_KT_DELAY - 86400000) { class="ts-cmt">// One day of padding for good measure118
isDiscordKotlin = detectDiscordKotlin;119
delta += DISCORD_KT_DELAY;120
}121
122
// Thanks dziurwa (I hate you)123
// This is when the user's clock is ahead124
// Can't do anything if the clock is behind125
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 dziurwa132
// 2 minutes133
const TROLL_LIMIT = 2 * 60 * 1000;134
135
const fill: Fill = isDiscordKotlin136
? ["status-positive", "status-positive", "text-muted"]137
: delta >= TROLL_LIMIT || ahead138
? ["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 user039;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 <Tooltip160
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 <svg182
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
<path194
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
<path198
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
<path202
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