Plugin

TypingTweaks

Show avatars and role colours in the typing indicator

Appearance Customisation
index.tsx
Download

Source

src/plugins/typingTweaks/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 { classNameFactory } from "@utils/css";
11import { openUserProfile } from "@utils/discord";
12import { isNonNullish } from "@utils/guards";
13import { Logger } from "@utils/Logger";
14import definePlugin, { OptionType } from "@utils/types";
15import { Channel, User } from "@vencord/discord-types";
16import { AuthenticationStore, Avatar, GuildMemberStore, React, RelationshipStore, TypingStore, UserStore, useStateFromStores } from "@webpack/common";
17import { PropsWithChildren } from "react";
18
19import managedStyle from "./style.css?managed";
20
21const cl = classNameFactory("vc-typing-tweaks-");
22const settings = definePluginSettings({
23 showAvatars: {
24 type: OptionType.BOOLEAN,
25 default: true,
26 description: "Show avatars in the typing indicator"
27 },
28 showRoleColors: {
29 type: OptionType.BOOLEAN,
30 default: true,
31 description: "Show role colors in the typing indicator"
32 },
33 alternativeFormatting: {
34 type: OptionType.BOOLEAN,
35 default: true,
36 description: "Show a more useful message when several users are typing"
37 }
38});
39
40export const buildSeveralUsers = ErrorBoundary.wrap(function buildSeveralUsers({ users, count, guildId }: { users: User[], count: number; guildId: string; }) {
41 return (
42 <>
43 {users.slice(0, count).map(user => (
44 <React.Fragment key={user.id}>
45 <TypingUser user={user} guildId={guildId} />
46 {", "}
47 </React.Fragment>
48 ))}
49 and {count} others are typing...
50 </>
51 );
52}, { noop: true });
53
54interface TypingUserProps {
55 user: User;
56 guildId: string;
57}
58
59const TypingUser = ErrorBoundary.wrap(function TypingUser({ user, guildId }: TypingUserProps) {
60 return (
61 <strong
62 className={cl("user")}
63 role="button"
64 onClick={() => {
65 openUserProfile(user.id);
66 }}
67 style={{
68 color: settings.store.showRoleColors ? GuildMemberStore.getMember(guildId, user.id)?.colorString : undefined,
69 }}
70 >
71 {settings.store.showAvatars && (
72 <Avatar
73 className={cl("avatar")}
74 size="SIZE_16"
75 src={user.getAvatarURL(guildId, 128)} />
76 )}
77 {GuildMemberStore.getNick(guildId!, user.id)
78 || (!guildId && RelationshipStore.getNickname(user.id))
79 || (user as any).globalName
80 || user.username
81 }
82 </strong>
83 );
84}, { noop: true });
85
86export default definePlugin({
87 name: "TypingTweaks",
88 description: "Show avatars and role colours in the typing indicator",
89 tags: ["Appearance", "Customisation"],
90 authors: [Devs.zt, Devs.sadan],
91 settings,
92
93 managedStyle,
94
95 patches: [
96 {
97 find: "#{intl::SEVERAL_USERS_TYPING_STRONG}",
98 group: true,
99 replacement: [
100 {
101 // Style the indicator and add function call to modify the children before rendering
102 match: /(?<="aria-hidden":!0,children:)\i/,
103 replace: "$self.renderTypingUsers({ users: arguments[0]?.typingUserObjects, guildId: arguments[0]?.channel?.guild_id, children: $& })"
104 },
105 {
106 match: /(?<=function \i\(\i\)\{)(?=[^}]+?\{channel:\i,isThreadCreation:\i=!1,\.\.\.\i\})/,
107 replace: "let typingUserObjects = $self.useTypingUsers(arguments[0]?.channel);"
108 },
109 {
110 // Get the typing users as user objects instead of names
111 match: /typingUsers:(\i)\?\[\]:\i,/,
112 // check by typeof so if the variable is not defined due to other patch failing, it won't throw a ReferenceError
113 replace: "$&typingUserObjects: $1 || typeof typingUserObjects === &#039;undefined&#039; ? [] : typingUserObjects,"
114 },
115 {
116 // Adds the alternative formatting for several users typing
117 // users.length > 3 && (component = intl(key))
118 match: /(&&\(\i=)\i\.\i\.format\(\i\.\i#{intl::SEVERAL_USERS_TYPING_STRONG},\{\}\)/,
119 replace: "$1$self.buildSeveralUsers({ users: arguments[0]?.typingUserObjects, count: arguments[0]?.typingUserObjects?.length - 2, guildId: arguments[0]?.channel?.guild_id })",
120 predicate: () => settings.store.alternativeFormatting
121 }
122 ]
123 }
124 ],
125
126 useTypingUsers(channel: Channel | undefined): User[] {
127 try {
128 if (!channel) {
129 throw new Error("No channel");
130 }
131
132 const typingUsers = useStateFromStores([TypingStore], () => TypingStore.getTypingUsers(channel.id));
133 const myId = useStateFromStores([AuthenticationStore], () => AuthenticationStore.getId());
134
135 return Object.keys(typingUsers)
136 .filter(id => id && id !== myId && !RelationshipStore.isBlockedOrIgnored(id))
137 .map(id => UserStore.getUser(id))
138 .filter(isNonNullish);
139 } catch (e) {
140 new Logger("TypingTweaks").error("Failed to get typing users:", e);
141 return [];
142 }
143 },
144
145 buildSeveralUsers,
146
147 renderTypingUsers: ErrorBoundary.wrap(({ guildId, users, children }: PropsWithChildren<{ guildId: string, users: User[]; }>) => {
148 try {
149 if (!Array.isArray(children)) {
150 return children;
151 }
152
153 let element = 0;
154
155 return children.map(c => {
156 if (c.type !== "strong" && !(typeof c !== "string" && !React.isValidElement(c))) return c;
157
158 const user = users[element++];
159 return <TypingUser key={user.id} guildId={guildId} user={user} />;
160 });
161 } catch (e) {
162 new Logger("TypingTweaks").error("Failed to render typing users:", e);
163 }
164
165 return children;
166 }, { noop: true })
167});
168