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-datasource": "15.0.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"fast-average-color": "^9.5.0",
"fasttext.wasm.js": "^1.0.0", "fasttext.wasm.js": "^1.0.0",
"flexsearch": "^0.7.43", "flexsearch": "^0.7.43",
"fuzzysort": "^3.1.0", "fuzzysort": "^3.1.0",

View File

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

View File

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

View File

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

View File

@ -657,6 +657,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
alt={account.avatar_description} alt={account.avatar_description}
size={96} 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' 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> </a>
{account.verified && ( {account.verified && (

View File

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

View File

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

View File

@ -186,7 +186,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
return ( return (
<Stack alignItems='center' justifyContent='center' className='h-full grow'> <Stack alignItems='center' justifyContent='center' className='h-full grow'>
<Stack alignItems='center' space={2}> <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 align='center'>
<> <>
<Text tag='span'>{intl.formatMessage(messages.blockedBy)}</Text> <Text tag='span'>{intl.formatMessage(messages.blockedBy)}</Text>

View File

@ -137,7 +137,7 @@ const ChatPageMain = () => {
/> />
<Link to={`/@${chat.account.acct}`}> <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> </Link>
</HStack> </HStack>
@ -157,7 +157,7 @@ const ChatPageMain = () => {
src={require('@tabler/icons/outline/info-circle.svg')} src={require('@tabler/icons/outline/info-circle.svg')}
component={() => ( component={() => (
<HStack className='px-4 py-2' alignItems='center' space={3}> <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> <Stack>
<Text weight='semibold'>{chat.account.display_name}</Text> <Text weight='semibold'>{chat.account.display_name}</Text>
<Text size='sm' theme='primary'>@{chat.account.acct}</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' data-testid='account'
> >
<HStack alignItems='center' space={2}> <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'> <Stack alignItems='start'>
<div className='flex grow items-center space-x-1'> <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'> <Stack space={4} className='mx-auto w-5/6'>
<HStack alignItems='center' space={3}> <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> <Stack>
<Text weight='semibold'>{chat.account.display_name}</Text> <Text weight='semibold'>{chat.account.display_name}</Text>
<Text size='sm' theme='primary'>@{chat.account.acct}</Text> <Text size='sm' theme='primary'>@{chat.account.acct}</Text>

View File

@ -70,7 +70,7 @@ const ChatWindow = () => {
<HStack alignItems='center' space={3}> <HStack alignItems='center' space={3}>
{isOpen && ( {isOpen && (
<Link to={`/@${chat.account.acct}`}> <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> </Link>
)} )}

View File

@ -58,6 +58,7 @@ const AccountCard: React.FC<IAccountCard> = ({ id }) => {
alt={account.avatar_description} 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' 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} size={64}
isCat={account.is_cat}
/> />
</Link> </Link>
</HoverAccountWrapper> </HoverAccountWrapper>

View File

@ -77,7 +77,7 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
})} })}
> >
<Link to={`/@${account.acct}`}> <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> </Link>
<ComposeForm <ComposeForm

View File

@ -75,7 +75,7 @@ const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
<Stack space={10}> <Stack space={10}>
<div className='relative mx-auto rounded-lg bg-gray-200'> <div className='relative mx-auto rounded-lg bg-gray-200'>
{account && ( {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 && ( {isSubmitting && (

View File

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

View File

@ -70,7 +70,7 @@ const HomeLayout: React.FC<IHomeLayout> = ({ children }) => {
<CardBody> <CardBody>
<HStack alignItems='start' space={2}> <HStack alignItems='start' space={2}>
<Link to={`/@${acct}`}> <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> </Link>
<div className='w-full translate-y-0.5'> <div className='w-full translate-y-0.5'>

View File

@ -42,4 +42,61 @@
.underline-line-through { .underline-line-through {
text-decoration: 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" resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-6.0.0.tgz#3173d5ad141436dace95f8de6e9ecdc3d9787d5d"
integrity sha512-YEboHE5VfopUclOck7LncgIqskAqnv4q0EWbYCaxKKjAvO93c+TJIaBuGy8CBFdbg9nKdpN3AuPRwVBJ4k7NrQ== 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: fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3" version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"