feat: avatar decorations (P5-13)
256×256 APNG overlays from avatardecoration.com (open CORS CDN). Stored in the user's Matrix profile as io.lotus.avatar_decoration via MSC4133 so all Lotus Chat users see each other's decorations. - avatarDecorations.ts: curated catalog of 110 original-IP decorations across 9 categories (Gaming, Cyber, Space, Fantasy, Elements, Japanese, Nature, Spooky, Cozy) - useAvatarDecoration: per-user profile fetch with module-level cache and in-flight deduplication so concurrent renders for the same userId share one HTTP request - AvatarDecoration: position:relative wrapper that overlays the APNG 8px beyond the avatar on all sides; renders nothing when no decoration is set (zero cost for undecorated users) - ProfileDecoration: scrollable grid picker in Settings → Profile, grouped by category with live preview; Save button appears only when the selection differs from what's saved - Applied at all five avatar display sites: message timeline, members drawer, knock list, @mention autocomplete, notifications inbox Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
|
||||
import { decorationUrl } from '../../features/lotus/avatarDecorations';
|
||||
|
||||
// How far the decoration image extends beyond the avatar on each side (px).
|
||||
// The APNG files are 256×256 with a transparent center. At this extension
|
||||
// the center hole lines up naturally with the avatar beneath it.
|
||||
const INSET = 8;
|
||||
|
||||
type AvatarDecorationProps = {
|
||||
userId: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function AvatarDecoration({ userId, children }: AvatarDecorationProps) {
|
||||
const slug = useAvatarDecoration(userId);
|
||||
|
||||
if (!slug) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<img
|
||||
src={decorationUrl(slug)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -INSET,
|
||||
left: -INSET,
|
||||
right: -INSET,
|
||||
bottom: -INSET,
|
||||
width: `calc(100% + ${INSET * 2}px)`,
|
||||
height: `calc(100% + ${INSET * 2}px)`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { UserAvatar } from '../../user-avatar';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { Membership } from '../../../../types/matrix/room';
|
||||
import { PresenceRingAvatar } from '../../presence';
|
||||
import { AvatarDecoration } from '../../avatar-decoration/AvatarDecoration';
|
||||
|
||||
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
||||
|
||||
@@ -48,14 +49,16 @@ function UnknownMentionItem({
|
||||
}
|
||||
onClick={() => handleAutocomplete(userId, name)}
|
||||
before={
|
||||
<PresenceRingAvatar userId={userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
<AvatarDecoration userId={userId}>
|
||||
<PresenceRingAvatar userId={userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
</AvatarDecoration>
|
||||
}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} size="B400">
|
||||
@@ -177,16 +180,18 @@ export function UserMentionAutocomplete({
|
||||
</Text>
|
||||
}
|
||||
before={
|
||||
<PresenceRingAvatar userId={roomMember.userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={roomMember.userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={getName(roomMember)}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
<AvatarDecoration userId={roomMember.userId}>
|
||||
<PresenceRingAvatar userId={roomMember.userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={roomMember.userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={getName(roomMember)}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
</AvatarDecoration>
|
||||
}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||
|
||||
Reference in New Issue
Block a user