Skip to content

Commit

Permalink
fix!: avoid rerender on hover for animated avatars (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
JohnyTheCarrot authored Oct 11, 2023
1 parent fd29a3f commit 4d91852
Show file tree
Hide file tree
Showing 8 changed files with 111 additions and 47 deletions.
51 changes: 36 additions & 15 deletions src/Message/MessageAuthor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ interface MessageAuthorProps
function MessageAuthor({
onlyShowUsername,
author,
isAvatarAnimated,
crossPost,
referenceGuild,
guildId,
Expand All @@ -55,11 +54,8 @@ function MessageAuthor({
const member = guildId ? resolveMember(author, guildId) : null;
const isGuildMember = member !== null;

const avatarUrl =
avatarUrlOverride?.(author) ??
getAvatar(author, {
animated: isAvatarAnimated ?? false,
});
const { stillAvatarUrl, animatedAvatarUrl } =
avatarUrlOverride?.(author) ?? getAvatar(author);

const displayName = isGuildMember
? member.nick ?? getDisplayName(author)
Expand Down Expand Up @@ -120,15 +116,40 @@ function MessageAuthor({
{...props}
onClick={() => userOnClick?.(author)}
>
<Styles.Avatar data={avatarUrl} draggable={false} type="image/png">
<Styles.AvatarFallback
src={getAvatar(author, {
animated: false,
forceDefault: true,
})}
alt="avatar"
/>
</Styles.Avatar>
<Styles.AnimatedAvatarTrigger
data-is-animated={animatedAvatarUrl !== undefined}
>
<Styles.StillAvatar
data={stillAvatarUrl}
draggable={false}
type="image/png"
>
<Styles.AvatarFallback
src={
getAvatar(author, {
forceDefault: true,
}).stillAvatarUrl
}
alt="avatar"
/>
</Styles.StillAvatar>
{animatedAvatarUrl && (
<Styles.AnimatedAvatar
data={animatedAvatarUrl}
draggable={false}
type="image/gif"
>
<Styles.AvatarFallback
src={
getAvatar(author, {
forceDefault: true,
}).stillAvatarUrl
}
alt="avatar"
/>
</Styles.AnimatedAvatar>
)}
</Styles.AnimatedAvatarTrigger>
<Styles.Username style={{ color: dominantRoleColor }}>
{displayName}
</Styles.Username>
Expand Down
1 change: 0 additions & 1 deletion src/Message/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ function MessageTypeSwitch(props: Omit<MessageProps, "showButtons">) {
<NormalMessage
message={errorMessage}
isFirstMessage={props.isFirstMessage}
isHovered={props.isHovered}
/>
);
}
Expand Down
29 changes: 24 additions & 5 deletions src/Message/style/author.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,35 @@ export const Avatar = styled.withConfig({
displayName: "message-author-avatar",
componentId: commonComponentId,
})("object", {
position: "absolute",
left: `calc(${theme.sizes.messageLeftPadding} / 2)`,
transform: "translateX(-50%)",
marginTop: "calc(4px - .125rem)",
borderRadius: "100%",
width: 40,
height: 40,
zIndex: 1,
backgroundColor: theme.colors.backgroundSecondary, // when the avatar is loading
outline: "none",
position: "absolute",
left: `calc(${theme.sizes.messageLeftPadding} / 2)`,
transform: "translateX(-50%)",
marginTop: "calc(4px - .125rem)",
zIndex: 1,
});

export const StillAvatar = styled.withConfig({
displayName: "message-author-still-avatar",
componentId: commonComponentId,
})(Avatar, {});

export const AnimatedAvatar = styled.withConfig({
displayName: "message-author-animated-avatar",
componentId: commonComponentId,
})(Avatar, {});

export const AnimatedAvatarTrigger = styled.withConfig({
displayName: "message-author-animated-avatar-trigger",
componentId: commonComponentId,
})("span", {
[`& ${AnimatedAvatar}`]: {
display: "none",
},
});

export const AvatarFallback = styled.withConfig({
Expand Down
6 changes: 3 additions & 3 deletions src/Message/variants/NormalMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ interface ReplyInfoProps {
function getMiniAvatarUrl(user: APIUser) {
const getAvatarSettings: GetAvatarOptions = {
size: 16,
animated: false,
};

return getAvatar(user, getAvatarSettings);
Expand Down Expand Up @@ -125,7 +124,9 @@ const ReplyInfo = memo((props: ReplyInfoProps) => {
</>
) : (
<Styles.ReplyUser>
{miniAvatarUrl && <Styles.MiniUserAvatar src={miniAvatarUrl} />}
{miniAvatarUrl && (
<Styles.MiniUserAvatar src={miniAvatarUrl.stillAvatarUrl} />
)}
{props.referencedMessage && (
<ChatTag
author={props.referencedMessage.author}
Expand Down Expand Up @@ -216,7 +217,6 @@ function NormalMessage(props: MessageProps) {
<MessageAuthor
guildId={guildId}
author={props.message.author}
isAvatarAnimated={props.isHovered ?? false}
crossPost={Boolean((props.message.flags ?? 0) & FLAG_CROSSPOST)}
referenceGuild={props.message.message_reference?.guild_id}
/>
Expand Down
3 changes: 2 additions & 1 deletion src/core/ConfigContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
import type { SvgConfig } from "./svgs";
import type { Tag } from "../ChatTag/style";
import type { APIAttachment } from "discord-api-types/v10";
import type { UserAvatar } from "../utils/getAvatar";

export type PartialSvgConfig = Partial<SvgConfig>;

Expand Down Expand Up @@ -40,7 +41,7 @@ export type Config<SvgConfig extends PartialSvgConfig> = {
resolveGuild(id: Snowflake): APIGuild | null;
resolveUser(id: Snowflake): APIUser | null;
chatBadge?({ user, TagWrapper }: ChatBadgeProps): ReactElement | null;
avatarUrlOverride?(user: APIUser): string | null;
avatarUrlOverride?(user: APIUser): UserAvatar | null;
themeOverrideClassName?: string;

// Click handlers
Expand Down
30 changes: 20 additions & 10 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import "./i18n";
import type { CSSProperties } from "react";
import React, { useState } from "react";
import React from "react";
import Message from "./Message";
import type { APIMessage } from "discord-api-types/v10";
import { commonComponentId, styled } from "./Stitches/stitches.config";
import * as MessageAuthorStyles from "./Message/style/author";

export interface MessageProps {
messages: APIMessage[];
Expand All @@ -11,21 +13,29 @@ export interface MessageProps {
thread: boolean;
}

const MessageGroupStyle = styled.withConfig({
displayName: "message-group",
componentId: commonComponentId,
})("div", {
[`&:hover ${MessageAuthorStyles.AnimatedAvatarTrigger}[data-is-animated='true']`]:
{
[`& ${MessageAuthorStyles.Avatar}`]: {
display: "none",
},
[`& ${MessageAuthorStyles.AnimatedAvatar}`]: {
display: "unset",
},
},
});

export function MessageGroup(props: MessageProps) {
const [firstMessage, ...otherMessages] = props.messages;
const [isHovered, setIsHovered] = useState(false);

return (
<div
className="message-group"
style={props.style}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<MessageGroupStyle style={props.style}>
<Message
isFirstMessage={true}
message={firstMessage}
isHovered={isHovered}
showButtons={props.showButtons ?? true}
thread={props.thread}
/>
Expand All @@ -37,7 +47,7 @@ export function MessageGroup(props: MessageProps) {
thread={props.thread}
/>
))}
</div>
</MessageGroupStyle>
);
}

Expand Down
2 changes: 1 addition & 1 deletion src/stories/Wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ const Wrapper: Decorator = (Story) => {
}}
// avatarUrlOverride={(user) => {
// if (user.id === "132819036282159104")
// return "https://cdn.discordapp.com/emojis/698964060770926684.png";
// return { still: "https://cdn.discordapp.com/emojis/698964060770926684.png" };
//
// return null;
// }}
Expand Down
36 changes: 25 additions & 11 deletions src/utils/getAvatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,43 +33,57 @@ type AvatarSize =
| 3072
| 4096;

function gifCheck(url: string) {
return url?.includes("/a_") ? url.replace("webp", "gif") : url;
function checkIfAnimatedAvatar(url: string) {
return url?.includes("/a_") ?? false;
}

function getAvatarProperty(
user: APIUser,
avatarSize: AvatarSize = 80
avatarSize: AvatarSize = 80,
fileType: "webp" | "gif" = "webp"
): string | null {
if (!user.avatar) return null;

// todo: allow custom CDN
return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.webp?size=${avatarSize}`;
return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${fileType}?size=${avatarSize}`;
}

export interface GetAvatarOptions {
animated?: boolean;
size?: AvatarSize;
forceDefault?: boolean;
}

export interface UserAvatar {
stillAvatarUrl: string;
animatedAvatarUrl?: string;
}

function getAvatar(
user: APIUser,
{ animated = false, size = 80, forceDefault = false }: GetAvatarOptions = {}
): string {
{ size = 80, forceDefault = false }: GetAvatarOptions = {}
): UserAvatar {
const defaultAvatarIndex = isNaN(Number(user.id))
? 0
: Number(BigInt(user.id) >> 22n) % 6;

const defaultAvatar = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarIndex}.png`;

const avatarUrl = getAvatarProperty(user, size);
const stillAvatarUrl = getAvatarProperty(user, size);

if (forceDefault || stillAvatarUrl === null)
return { stillAvatarUrl: defaultAvatar };

const isAnimatedAvatar = checkIfAnimatedAvatar(stillAvatarUrl);

if (forceDefault || avatarUrl === null) return defaultAvatar;
if (!isAnimatedAvatar) return { stillAvatarUrl: stillAvatarUrl };

const potentialGif = animated ? gifCheck(avatarUrl) : avatarUrl;
const animatedAvatarUrl =
getAvatarProperty(user, size, "gif") ?? defaultAvatar;

return avatarUrl ? potentialGif.replace("webp", "png") : defaultAvatar;
return {
stillAvatarUrl: stillAvatarUrl,
animatedAvatarUrl: animatedAvatarUrl,
};
}

export default getAvatar;

0 comments on commit 4d91852

Please sign in to comment.