Plugin

TypingIndicator

Adds an indicator if someone is typing on a channel.

Notifications Appearance Servers
index.tsx
Download

Source

src/plugins/typingIndicator/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 "./style.css";
8
9import { isPluginEnabled } from "@api/PluginManager";
10import { definePluginSettings } from "@api/Settings";
11import ErrorBoundary from "@components/ErrorBoundary";
12import TypingTweaksPlugin, { buildSeveralUsers } from "@plugins/typingTweaks";
13import { Devs } from "@utils/constants";
14import { getIntlMessage } from "@utils/discord";
15import definePlugin, { OptionType } from "@utils/types";
16import { findComponentByCodeLazy } from "@webpack";
17import { GuildMemberStore, RelationshipStore, SelectedChannelStore, Tooltip, TypingStore, UserGuildSettingsStore, UserStore, UserSummaryItem, useStateFromStores } from "@webpack/common";
18
19const ThreeDots = findComponentByCodeLazy("Math.min(1,Math.max(", "dotRadius:");
20
21const enum IndicatorMode {
22 Dots = 1 << 0,
23 Avatars = 1 << 1
24}
25
26function 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
31function 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 <div
91 onClick={e => {
92 e.stopPropagation();
93 e.preventDefault();
94 }}
95 onKeyPress={e => e.stopPropagation()}
96 >
97 <UserSummaryItem
98 users={typingUsersArray.map(id => UserStore.getUser(id))}
99 guildId={guildId}
100 renderIcon={false}
101 max={3}
102 showDefaultAvatarsForNullUsers
103 showUserPopout
104 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
123const settings = definePluginSettings({
124 includeCurrentChannel: {
125 type: OptionType.BOOLEAN,
126 description: "Whether to show the typing indicator for the currently selected channel",
127 default: true
128 },
129 includeMutedChannels: {
130 type: OptionType.BOOLEAN,
131 description: "Whether to show the typing indicator for muted channels.",
132 default: false
133 },
134 includeBlockedUsers: {
135 type: OptionType.BOOLEAN,
136 description: "Whether to show the typing indicator for blocked users.",
137 default: false
138 },
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
150export 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 channel
159 {
160 find: "UNREAD_IMPORTANT:",
161 replacement: {
162 match: /\.Children\.count.+?:null(?<=,channel:(\i).+?)/,
163 replace: "$&,$self.TypingIndicator($1.id,$1.getGuildId())"
164 }
165 },
166 // Theads
167 {
168 // This is the thread "spine" that shows in the left
169 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