From 7b182ff950438d6955f30faacb8bad9d63158fb3 Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Mon, 10 Aug 2026 14:10:03 -0400 Subject: [PATCH] typing-avatars: add AvatarStack component and typing-indicator patch --- .../src/components/AvatarStack.tsx | 35 +++++++++++++ plugins/typing-avatars/src/index.ts | 5 +- .../src/patches/typingIndicator.tsx | 51 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 plugins/typing-avatars/src/components/AvatarStack.tsx create mode 100644 plugins/typing-avatars/src/patches/typingIndicator.tsx diff --git a/plugins/typing-avatars/src/components/AvatarStack.tsx b/plugins/typing-avatars/src/components/AvatarStack.tsx new file mode 100644 index 0000000..443fe82 --- /dev/null +++ b/plugins/typing-avatars/src/components/AvatarStack.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { View, Image, StyleSheet } from "react-native"; +import { getTypingAvatarURL } from "../lib/avatarUrl"; + +const SIZE = 20; +const OVERLAP = 8; + +// No text label and no tap handler by design - purely decorative avatars replacing the +// "X is typing..." text entirely. If a lot of people are typing, this wraps to a second +// overlapping row rather than clipping or capping with a "+N" badge. +export default function AvatarStack({ typingUserIds, guildId }: { typingUserIds: string[]; guildId: string | undefined }) { + if (!typingUserIds?.length) return null; + + return ( + + {typingUserIds.map((userId, i) => { + const uri = getTypingAvatarURL(guildId, userId, SIZE); + if (!uri) return null; + return ( + 0 ? st.overlap : null]} + /> + ); + })} + + ); +} + +const st = StyleSheet.create({ + row: { flexDirection: "row", flexWrap: "wrap", alignItems: "center" }, + avatar: { width: SIZE, height: SIZE, borderRadius: SIZE / 2 }, + overlap: { marginLeft: -OVERLAP }, +}); diff --git a/plugins/typing-avatars/src/index.ts b/plugins/typing-avatars/src/index.ts index f2d33c2..67ed0e0 100644 --- a/plugins/typing-avatars/src/index.ts +++ b/plugins/typing-avatars/src/index.ts @@ -1,11 +1,14 @@ import { logger } from "@vendetta"; import { applyPatches } from "@shared/lib/patcher"; +import patchTypingIndicator from "./patches/typingIndicator"; let unpatchAll: () => void = () => {}; export default { onLoad: () => { - unpatchAll = applyPatches("TypingAvatars", logger, {}); + unpatchAll = applyPatches("TypingAvatars", logger, { + "typing indicator avatars": patchTypingIndicator + }); }, onUnload: () => unpatchAll() }; diff --git a/plugins/typing-avatars/src/patches/typingIndicator.tsx b/plugins/typing-avatars/src/patches/typingIndicator.tsx new file mode 100644 index 0000000..799eb9f --- /dev/null +++ b/plugins/typing-avatars/src/patches/typingIndicator.tsx @@ -0,0 +1,51 @@ +import { React } from "@vendetta/metro/common"; +import { findInReactTree } from "@vendetta/utils"; +import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept"; +import AvatarStack from "../components/AvatarStack"; + +// Mirrors role-color-everywhere/src/patches/typingWrapper.ts for the detection/wrapping part +// (already proven live in this repo), but replaces the label's content with AvatarStack instead +// of recoloring the existing per-user text nodes. This only needs props.typingUserIds directly, +// so unlike the color-matching version it isn't affected by the "several people are typing..." +// collapsed-string case. +export default function patchTypingIndicator(): () => void { + const cleanups: (() => void)[] = []; + patchCreateElement(cleanups); + + registerTypeDetector( + "typing-avatars-indicator", + (type) => typeof type === "function" ? type.name === "TypingIndicatorInner" : type?.type?.name === "TypingIndicatorInner", + (TypingIndicatorInner: any) => { + const inner = typeof TypingIndicatorInner === "function" ? TypingIndicatorInner : TypingIndicatorInner.type; + + const PatchedTypingIndicatorInner = (props: any) => { + const ret = inner(props); + + try { + const label = findInReactTree( + ret, + (n: any) => + Array.isArray(n?.props?.children) && + n.props.children.some((c: any) => typeof c === "string" && c.includes("typing...")) + ); + if (!label?.props) return ret; + + const typingUserIds: string[] = props?.typingUserIds ?? []; + if (!typingUserIds.length) return ret; + + label.props.children = ( + + ); + } catch { + // Leave the default "X is typing..." text alone. + } + + return ret; + }; + + registerIntercept(TypingIndicatorInner, PatchedTypingIndicatorInner); + } + ); + + return () => cleanups.forEach((fn) => fn()); +}