Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-04-02 11:55:09 +02:00
parent d5d3a7ba95
commit b68fc4e7d8
21 changed files with 119 additions and 22 deletions

View File

@@ -84,6 +84,7 @@
"emoji-datasource": "15.0.1",
"emoji-mart": "^5.6.0",
"exifr": "^7.1.3",
"fast-average-color": "^9.5.0",
"fasttext.wasm.js": "^1.0.0",
"flexsearch": "^0.7.43",
"fuzzysort": "^3.1.0",

View File

@@ -193,7 +193,7 @@ const Account = ({
<HStack alignItems={actionAlignment} space={3} justifyContent='between'>
<HStack alignItems='center' space={3} className='overflow-hidden'>
<div className='rounded-lg'>
<Avatar src={account.avatar} size={avatarSize} alt={account.avatar_description} />
<Avatar src={account.avatar} size={avatarSize} alt={account.avatar_description} isCat={account.is_cat} />
{emoji && (
<Emoji
className='!absolute -right-1.5 bottom-0 size-5'
@@ -242,14 +242,14 @@ const Account = ({
return (
<div data-testid='account' className='group block w-full shrink-0' ref={overflowRef}>
<HStack alignItems={actionAlignment} space={3} justifyContent='between'>
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3} className='overflow-hidden'>
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3}>
{withAvatar && (
<ProfilePopper
condition={showAccountHoverCard}
wrapper={(children) => <HoverAccountWrapper className='relative' accountId={account.id} element='span'>{children}</HoverAccountWrapper>}
>
<LinkEl className='rounded-lg' {...linkProps}>
<Avatar src={account.avatar} size={avatarSize} alt={account.avatar_description} />
<Avatar src={account.avatar} size={avatarSize} alt={account.avatar_description} isCat={account.is_cat} />
{emoji && (
<Emoji
className='!absolute -right-1.5 bottom-0 size-5'

View File

@@ -27,6 +27,7 @@ const AvatarStack: React.FC<IAvatarStack> = ({ accountIds, limit = 3 }) => {
src={account.avatar}
alt={account.avatar_description}
size={20}
isCat={account.is_cat}
/>
</div>
))}

View File

@@ -8,6 +8,8 @@ interface IStillImage {
alt?: string;
/** Extra class names for the outer <div> container. */
className?: string;
/** Extra class names for the inner <img> element. */
innerClassName?: string;
/** URL to the image */
src: string;
/** Extra CSS styles on the outer <div> element. */
@@ -18,6 +20,8 @@ interface IStillImage {
showExt?: boolean;
/** Callback function if the image fails to load */
onError?(): void;
/** Callback function if the image loads successfully */
onLoad?: React.ReactEventHandler<HTMLImageElement>;
/** Treat as animated, no matter the extension */
isGif?: boolean;
/** Specify that the group is defined by the parent */
@@ -26,7 +30,7 @@ interface IStillImage {
/** Renders images on a canvas, only playing GIFs if autoPlayGif is enabled. */
const StillImage: React.FC<IStillImage> = ({
alt, className, src, style, letterboxed = false, showExt = false, onError, isGif, noGroup,
alt, className, innerClassName, src, style, letterboxed = false, showExt = false, onError, onLoad, isGif, noGroup,
}) => {
const { autoPlayGif } = useSettings();
@@ -37,16 +41,20 @@ const StillImage: React.FC<IStillImage> = ({
src && !autoPlayGif && ((isGif) || src.endsWith('.gif') || src.startsWith('blob:'))
);
const handleImageLoad = () => {
const handleImageLoad: React.ReactEventHandler<HTMLImageElement> = (e) => {
if (hoverToPlay && canvas.current && img.current) {
canvas.current.width = img.current.naturalWidth;
canvas.current.height = img.current.naturalHeight;
canvas.current.getContext('2d')?.drawImage(img.current, 0, 0);
}
if (onLoad) {
onLoad(e);
}
};
/** ClassNames shared between the `<img>` and `<canvas>` elements. */
const baseClassName = clsx('block size-full', {
const baseClassName = clsx('block size-full', innerClassName, {
'object-contain': letterboxed,
'object-cover': !letterboxed,
});
@@ -54,7 +62,7 @@ const StillImage: React.FC<IStillImage> = ({
return (
<div
data-testid='still-image-container'
className={clsx(className, 'relative isolate overflow-hidden', { 'group': !noGroup })}
className={clsx(className, 'relative isolate', { 'group': !noGroup })}
style={style}
>
<img
@@ -66,6 +74,7 @@ const StillImage: React.FC<IStillImage> = ({
className={clsx(baseClassName, {
'invisible group-hover:visible': hoverToPlay,
})}
crossOrigin='anonymous'
/>
{hoverToPlay && (

View File

@@ -1,4 +1,5 @@
import clsx from 'clsx';
import { FastAverageColor } from 'fast-average-color';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@@ -15,22 +16,36 @@ const messages = defineMessages({
interface IAvatar extends Pick<IStillImage, 'alt' | 'src' | 'onError' | 'className'> {
/** Width and height of the avatar in pixels. */
size?: number;
/** Whether the user is a cat. */
isCat?: boolean;
}
const fac = new FastAverageColor();
/** Round profile avatar for accounts. */
const Avatar = (props: IAvatar) => {
const intl = useIntl();
const { alt, src, size = AVATAR_SIZE, className } = props;
const { alt, src, size = AVATAR_SIZE, className, isCat } = props;
const [color, setColor] = useState<string | undefined>(undefined);
const [isAvatarMissing, setIsAvatarMissing] = useState<boolean>(false);
const handleLoadFailure = () => setIsAvatarMissing(true);
const handleLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const color = fac.getColor(e.currentTarget);
if (!color.error) {
setColor(color.hex);
}
};
const style: React.CSSProperties = React.useMemo(() => ({
width: size,
height: size,
}), [size]);
fontSize: size,
color,
}), [size, color]);
if (isAvatarMissing) {
return (
@@ -38,8 +53,9 @@ const Avatar = (props: IAvatar) => {
style={{
width: size,
height: size,
color,
}}
className={clsx('flex items-center justify-center rounded-lg bg-gray-200 dark:bg-gray-900', className)}
className={clsx('flex items-center justify-center rounded-lg bg-gray-200 leading-[0] dark:bg-gray-900', isCat && 'avatar__cat', className)}
>
<Icon
src={require('@tabler/icons/outline/photo-off.svg')}
@@ -51,11 +67,13 @@ const Avatar = (props: IAvatar) => {
return (
<StillImage
className={clsx('rounded-lg', className)}
className={clsx('rounded-lg leading-[0]', isCat && 'avatar__cat', className)}
innerClassName='rounded-lg'
style={style}
src={src}
alt={alt || intl.formatMessage(messages.avatar)}
onError={handleLoadFailure}
onLoad={handleLoad}
/>
);
};

View File

@@ -657,6 +657,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
alt={account.avatar_description}
size={96}
className='relative size-24 rounded-lg bg-white ring-4 ring-white black:ring-black dark:bg-primary-900 dark:ring-primary-900'
isCat={account.is_cat}
/>
</a>
{account.verified && (

View File

@@ -91,6 +91,7 @@ const Report: React.FC<IReport> = ({ id }) => {
alt={targetAccount.avatar_description}
size={32}
className='overflow-hidden'
isCat={targetAccount.is_cat}
/>
</Link>
</HoverAccountWrapper>

View File

@@ -94,6 +94,7 @@ const ChatListItem: React.FC<IChatListItemInterface> = ({ chat, onClick }) => {
alt={chat.account.avatar_description}
size={40}
className='flex-none'
isCat={chat.account.is_cat}
/>
<Stack alignItems='start' className='overflow-hidden'>

View File

@@ -186,7 +186,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
return (
<Stack alignItems='center' justifyContent='center' className='h-full grow'>
<Stack alignItems='center' space={2}>
<Avatar src={chat.account.avatar} alt={chat.account.avatar_description} size={75} />
<Avatar src={chat.account.avatar} alt={chat.account.avatar_description} size={75} isCat={chat.account.is_cat} />
<Text align='center'>
<>
<Text tag='span'>{intl.formatMessage(messages.blockedBy)}</Text>

View File

@@ -137,7 +137,7 @@ const ChatPageMain = () => {
/>
<Link to={`/@${chat.account.acct}`}>
<Avatar src={chat.account.avatar} alt={chat.account.avatar_description} size={40} className='flex-none' />
<Avatar src={chat.account.avatar} alt={chat.account.avatar_description} size={40} className='flex-none' isCat={chat.account.is_cat} />
</Link>
</HStack>
@@ -157,7 +157,7 @@ const ChatPageMain = () => {
src={require('@tabler/icons/outline/info-circle.svg')}
component={() => (
<HStack className='px-4 py-2' alignItems='center' space={3}>
<Avatar src={chat.account.avatar_static} alt={chat.account.avatar_description} size={50} />
<Avatar src={chat.account.avatar_static} alt={chat.account.avatar_description} size={50} isCat={chat.account.is_cat} />
<Stack>
<Text weight='semibold'>{chat.account.display_name}</Text>
<Text size='sm' theme='primary'>@{chat.account.acct}</Text>

View File

@@ -41,7 +41,7 @@ const Results = ({ accountSearchResult, onSelect, parentRef }: IResults) => {
data-testid='account'
>
<HStack alignItems='center' space={2}>
<Avatar src={account.avatar} alt={account.avatar_description} size={40} />
<Avatar src={account.avatar} alt={account.avatar_description} size={40} isCat={account.is_cat} />
<Stack alignItems='start'>
<div className='flex grow items-center space-x-1'>

View File

@@ -113,7 +113,7 @@ const ChatSettings = () => {
<Stack space={4} className='mx-auto w-5/6'>
<HStack alignItems='center' space={3}>
<Avatar src={chat.account.avatar_static} alt={chat.account.avatar_description} size={50} />
<Avatar src={chat.account.avatar_static} alt={chat.account.avatar_description} size={50} isCat={chat.account.is_cat} />
<Stack>
<Text weight='semibold'>{chat.account.display_name}</Text>
<Text size='sm' theme='primary'>@{chat.account.acct}</Text>

View File

@@ -70,7 +70,7 @@ const ChatWindow = () => {
<HStack alignItems='center' space={3}>
{isOpen && (
<Link to={`/@${chat.account.acct}`}>
<Avatar src={chat.account.avatar} alt={chat.account.avatar_description} size={40} />
<Avatar src={chat.account.avatar} alt={chat.account.avatar_description} size={40} isCat={chat.account.is_cat} />
</Link>
)}

View File

@@ -58,6 +58,7 @@ const AccountCard: React.FC<IAccountCard> = ({ id }) => {
alt={account.avatar_description}
className='!absolute bottom-0 left-3 translate-y-1/2 bg-white ring-2 ring-white dark:bg-primary-900 dark:ring-primary-900'
size={64}
isCat={account.is_cat}
/>
</Link>
</HoverAccountWrapper>

View File

@@ -77,7 +77,7 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
})}
>
<Link to={`/@${account.acct}`}>
<Avatar src={account.avatar} alt={account.avatar_description} size={42} />
<Avatar src={account.avatar} alt={account.avatar_description} size={42} isCat={account.is_cat} />
</Link>
<ComposeForm

View File

@@ -75,7 +75,7 @@ const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
<Stack space={10}>
<div className='relative mx-auto rounded-lg bg-gray-200'>
{account && (
<Avatar src={selectedFile || account.avatar} alt={account.avatar_description} size={175} />
<Avatar src={selectedFile || account.avatar} alt={account.avatar_description} size={175} isCat={account.is_cat} />
)}
{isSubmitting && (

View File

@@ -118,6 +118,7 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
<Avatar
src={account.avatar}
alt={account.avatar_description}
isCat={account.is_cat}
size={64}
className='-mt-8 mb-2 ring-2 ring-white dark:ring-primary-800'
/>

View File

@@ -53,8 +53,9 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
<Avatar
src={account.avatar}
alt={account.avatar_description}
isCat={account.is_cat}
size={80}
className='size-20 overflow-hidden bg-gray-50 ring-2 ring-white'
className='size-20 bg-gray-50 ring-2 ring-white'
/>
</Link>

View File

@@ -70,7 +70,7 @@ const HomeLayout: React.FC<IHomeLayout> = ({ children }) => {
<CardBody>
<HStack alignItems='start' space={2}>
<Link to={`/@${acct}`}>
<Avatar src={avatar} alt={account?.avatar_description} size={42} />
<Avatar src={avatar} alt={account?.avatar_description} isCat={account?.is_cat} size={42} />
</Link>
<div className='w-full translate-y-0.5'>

View File

@@ -42,4 +42,61 @@
.underline-line-through {
text-decoration: underline line-through;
}
}
// Adapted from Iceshrimp which I guess took it from Misskey
// https://iceshrimp.dev/iceshrimp/iceshrimp/src/branch/dev/packages/client/src/components/global/MkAvatar.vue
.avatar__cat {
img {
position: absolute;
inset: 0;
z-index: 1;
}
&::before,
&::after {
background: #ebbcba;
border: solid 0.05em currentcolor;
box-sizing: border-box;
content: "";
display: inline-block;
height: 50%;
width: 50%;
}
&::before {
border-radius: 0 75% 75%;
transform: rotate(37.5deg) skew(30deg);
}
&::after {
border-radius: 75% 0 75% 75%;
transform: rotate(-37.5deg) skew(-30deg);
}
&:hover {
&::before {
animation: earwiggleleft 1s infinite;
}
&::after {
animation: earwiggleright 1s infinite;
}
}
}
@keyframes earwiggleleft {
0% { transform: rotate(37.6deg) skew(30deg); }
25% { transform: rotate(10deg) skew(30deg); }
50% { transform: rotate(20deg) skew(30deg); }
75% { transform: rotate(0deg) skew(30deg); }
100% { transform: rotate(37.6deg) skew(30deg); }
}
@keyframes earwiggleright {
0% { transform: rotate(-37.6deg) skew(-30deg); }
30% { transform: rotate(-10deg) skew(-30deg); }
55% { transform: rotate(-20deg) skew(-30deg); }
75% { transform: rotate(0deg) skew(-30deg); }
100% { transform: rotate(-37.6deg) skew(-30deg); }
}

View File

@@ -4787,6 +4787,11 @@ fake-indexeddb@^6.0.0:
resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-6.0.0.tgz#3173d5ad141436dace95f8de6e9ecdc3d9787d5d"
integrity sha512-YEboHE5VfopUclOck7LncgIqskAqnv4q0EWbYCaxKKjAvO93c+TJIaBuGy8CBFdbg9nKdpN3AuPRwVBJ4k7NrQ==
fast-average-color@^9.5.0:
version "9.5.0"
resolved "https://registry.yarnpkg.com/fast-average-color/-/fast-average-color-9.5.0.tgz#deade6bd998c0ea399a519ed1e2cd67ef673f112"
integrity sha512-nC6x2YIlJ9xxgkMFMd1BNoM1ctMjNoRKfRliPmiEWW3S6rLTHiQcy9g3pt/xiKv/D0NAAkhb9VyV+WJFvTqMGg==
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"