Plugin
TypingTweaks
Show avatars and role colours in the typing indicator
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 { classNameFactory } from "@utils/css";11
import { openUserProfile } from "@utils/discord";12
import { isNonNullish } from "@utils/guards";13
import { Logger } from "@utils/Logger";14
import definePlugin, { OptionType } from "@utils/types";15
import { Channel, User } from "@vencord/discord-types";16
import { AuthenticationStore, Avatar, GuildMemberStore, React, RelationshipStore, TypingStore, UserStore, useStateFromStores } from "@webpack/common";17
import { PropsWithChildren } from "react";18
19
import managedStyle from "./style.css?managed";20
21
const cl = classNameFactory("vc-typing-tweaks-");22
const 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
40
export 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
54
interface TypingUserProps {55
user: User;56
guildId: string;57
}58
59
const TypingUser = ErrorBoundary.wrap(function TypingUser({ user, guildId }: TypingUserProps) {60
return (61
<strong62
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
<Avatar73
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).globalName80
|| user.username81
}82
</strong>83
);84
}, { noop: true });85
86
export 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 rendering102
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 names111
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 ReferenceError113
replace: "$&typingUserObjects: $1 || typeof typingUserObjects === 039;undefined039; ? [] : typingUserObjects,"114
},115
{116
// Adds the alternative formatting for several users typing117
// 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.alternativeFormatting121
}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