Plugin
TypingIndicator
Adds an indicator if someone is typing on a channel.
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import "./style.css";8
9
import { isPluginEnabled } from "@api/PluginManager";10
import { definePluginSettings } from "@api/Settings";11
import ErrorBoundary from "@components/ErrorBoundary";12
import TypingTweaksPlugin, { buildSeveralUsers } from "@plugins/typingTweaks";13
import { Devs } from "@utils/constants";14
import { getIntlMessage } from "@utils/discord";15
import definePlugin, { OptionType } from "@utils/types";16
import { findComponentByCodeLazy } from "@webpack";17
import { GuildMemberStore, RelationshipStore, SelectedChannelStore, Tooltip, TypingStore, UserGuildSettingsStore, UserStore, UserSummaryItem, useStateFromStores } from "@webpack/common";18
19
const ThreeDots = findComponentByCodeLazy("Math.min(1,Math.max(", "dotRadius:");20
21
const enum IndicatorMode {22
Dots = 1 << 0,23
Avatars = 1 << 124
}25
26
function getDisplayName(guildId: string, userId: string) {27
const user = UserStore.getUser(userId);28
return GuildMemberStore.getNick(guildId, userId) ?? (user as any).globalName ?? user.username;29
}30
31
function TypingIndicator({ channelId, guildId }: { channelId: string; guildId: string; }) {32
const typingUsers: Record<string, number> = useStateFromStores(33
[TypingStore],34
() => ({ ...TypingStore.getTypingUsers(channelId) }),35
null,36
(old, current) => {37
const oldKeys = Object.keys(old);38
const currentKeys = Object.keys(current);39
40
return oldKeys.length === currentKeys.length && currentKeys.every(key => old[key] != null);41
}42
);43
const currentChannelId = useStateFromStores([SelectedChannelStore], () => SelectedChannelStore.getChannelId());44
45
if (!settings.store.includeMutedChannels) {46
const isChannelMuted = UserGuildSettingsStore.isChannelMuted(guildId, channelId);47
if (isChannelMuted) return null;48
}49
50
if (!settings.store.includeCurrentChannel) {51
if (currentChannelId === channelId) return null;52
}53
54
const myId = UserStore.getCurrentUser()?.id;55
56
const typingUsersArray = Object.keys(typingUsers).filter(id =>57
id !== myId && !(RelationshipStore.isBlocked(id) && !settings.store.includeBlockedUsers)58
);59
const [a, b, c] = typingUsersArray;60
let tooltipText: string;61
62
switch (typingUsersArray.length) {63
case 0: break;64
case 1: {65
tooltipText = getIntlMessage("ONE_USER_TYPING", { a: getDisplayName(guildId, a) });66
break;67
}68
case 2: {69
tooltipText = getIntlMessage("TWO_USERS_TYPING", { a: getDisplayName(guildId, a), b: getDisplayName(guildId, b) });70
break;71
}72
case 3: {73
tooltipText = getIntlMessage("THREE_USERS_TYPING", { a: getDisplayName(guildId, a), b: getDisplayName(guildId, b), c: getDisplayName(guildId, c) });74
break;75
}76
default: {77
tooltipText = isPluginEnabled(TypingTweaksPlugin.name)78
? buildSeveralUsers({ users: [a, b].map(UserStore.getUser), count: typingUsersArray.length - 2, guildId })79
: getIntlMessage("SEVERAL_USERS_TYPING");80
break;81
}82
}83
84
if (typingUsersArray.length > 0) {85
return (86
<Tooltip text={tooltipText!}>87
{props => (88
<div className="vc-typing-indicator" {...props}>89
{((settings.store.indicatorMode & IndicatorMode.Avatars) === IndicatorMode.Avatars) && (90
<div91
onClick={e => {92
e.stopPropagation();93
e.preventDefault();94
}}95
onKeyPress={e => e.stopPropagation()}96
>97
<UserSummaryItem98
users={typingUsersArray.map(id => UserStore.getUser(id))}99
guildId={guildId}100
renderIcon={false}101
max={3}102
showDefaultAvatarsForNullUsers103
showUserPopout104
size={16}105
className="vc-typing-indicator-avatars"106
/>107
</div>108
)}109
{((settings.store.indicatorMode & IndicatorMode.Dots) === IndicatorMode.Dots) && (110
<div className="vc-typing-indicator-dots">111
<ThreeDots dotRadius={3} themed={true} />112
</div>113
)}114
</div>115
)}116
</Tooltip>117
);118
}119
120
return null;121
}122
123
const settings = definePluginSettings({124
includeCurrentChannel: {125
type: OptionType.BOOLEAN,126
description: "Whether to show the typing indicator for the currently selected channel",127
default: true128
},129
includeMutedChannels: {130
type: OptionType.BOOLEAN,131
description: "Whether to show the typing indicator for muted channels.",132
default: false133
},134
includeBlockedUsers: {135
type: OptionType.BOOLEAN,136
description: "Whether to show the typing indicator for blocked users.",137
default: false138
},139
indicatorMode: {140
type: OptionType.SELECT,141
description: "How should the indicator be displayed?",142
options: [143
{ label: "Avatars and animated dots", value: IndicatorMode.Dots | IndicatorMode.Avatars, default: true },144
{ label: "Animated dots", value: IndicatorMode.Dots },145
{ label: "Avatars", value: IndicatorMode.Avatars },146
],147
}148
});149
150
export default definePlugin({151
name: "TypingIndicator",152
description: "Adds an indicator if someone is typing on a channel.",153
tags: ["Notifications", "Appearance", "Servers"],154
authors: [Devs.Nuckyz, Devs.fawn, Devs.Sqaaakoi],155
settings,156
157
patches: [158
// Normal channel159
{160
find: "UNREAD_IMPORTANT:",161
replacement: {162
match: /\.Children\.count.+?:null(?<=,channel:(\i).+?)/,163
replace: "$&,$self.TypingIndicator($1.id,$1.getGuildId())"164
}165
},166
// Theads167
{168
// This is the thread "spine" that shows in the left169
find: "M0 15H2c0 1.6569",170
replacement: {171
match: /mentionsCount:\i.+?null(?<=channel:(\i).+?)/,172
replace: "$&,$self.TypingIndicator($1.id,$1.getGuildId())"173
}174
}175
],176
177
TypingIndicator: (channelId: string, guildId: string) => (178
<ErrorBoundary noop>179
<TypingIndicator channelId={channelId} guildId={guildId} />180
</ErrorBoundary>181
),182
});183