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>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
export const DECORATION_CDN = 'https://img.avatardecoration.com/decorations';
|
||||
|
||||
export type AvatarDecoration = {
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DecorationCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
decorations: AvatarDecoration[];
|
||||
};
|
||||
|
||||
export const DECORATION_CATEGORIES: DecorationCategory[] = [
|
||||
{
|
||||
id: 'gaming',
|
||||
label: 'Gaming',
|
||||
decorations: [
|
||||
{ slug: 'slither_n_snack', name: "Slither 'n Snack" },
|
||||
{ slug: 'joystick', name: 'Joystick' },
|
||||
{ slug: 'clyde_invaders', name: 'Space Invaders' },
|
||||
{ slug: 'mallow_jump', name: 'Mallow Jump' },
|
||||
{ slug: 'hot_shot', name: 'Hot Shot' },
|
||||
{ slug: 'pipedream', name: 'Pipedream' },
|
||||
{ slug: 'disxcore_headset', name: 'Gaming Headset' },
|
||||
{ slug: 'pink_headset', name: 'Pink Headset' },
|
||||
{ slug: 'green_headset', name: 'Green Headset' },
|
||||
{ slug: 'feelin_awe', name: "Feelin' Awe" },
|
||||
{ slug: 'feelin_panic', name: "Feelin' Panic" },
|
||||
{ slug: 'feelin_nervous', name: "Feelin' Nervous" },
|
||||
{ slug: 'feelin_scrumptious', name: "Feelin' Scrumptious" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cyber',
|
||||
label: 'Cyber',
|
||||
decorations: [
|
||||
{ slug: 'cybernetic', name: 'Cybernetic' },
|
||||
{ slug: 'glitch', name: 'Glitch' },
|
||||
{ slug: 'digital_sunrise', name: 'Digital Sunrise' },
|
||||
{ slug: 'implant', name: 'Implant' },
|
||||
{ slug: 'blue_futuristic_ui', name: 'Futuristic UI (Blue)' },
|
||||
{ slug: 'green_futuristic_ui', name: 'Futuristic UI (Green)' },
|
||||
{ slug: 'pink_futuristic_ui', name: 'Futuristic UI (Pink)' },
|
||||
{ slug: 'chromawave', name: 'Chromawave' },
|
||||
{ slug: 'hex_lights', name: 'Hex Lights' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'space',
|
||||
label: 'Space',
|
||||
decorations: [
|
||||
{ slug: 'stardust', name: 'Stardust' },
|
||||
{ slug: 'black_hole', name: 'Black Hole' },
|
||||
{ slug: 'constellations', name: 'Constellations' },
|
||||
{ slug: 'solar_orbit', name: 'Solar Orbit' },
|
||||
{ slug: 'astronaut_helmet', name: 'Astronaut Helmet' },
|
||||
{ slug: 'ufo', name: 'UFO' },
|
||||
{ slug: 'warp_helmet', name: 'Warp Helmet' },
|
||||
{ slug: 'aurora', name: 'Aurora' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fantasy',
|
||||
label: 'Fantasy',
|
||||
decorations: [
|
||||
{ slug: 'kitsune', name: 'Kitsune' },
|
||||
{ slug: 'phoenix', name: 'Phoenix' },
|
||||
{ slug: 'unicorn', name: 'Unicorn' },
|
||||
{ slug: 'flaming_sword', name: 'Flaming Sword' },
|
||||
{ slug: 'skull_medallion', name: 'Skull Medallion' },
|
||||
{ slug: 'glowing_runes', name: 'Glowing Runes' },
|
||||
{ slug: 'eldritch_ring', name: 'Eldritch Ring' },
|
||||
{ slug: 'arcane_sigil', name: 'Arcane Sigil' },
|
||||
{ slug: 'midnight_sorceress', name: 'Midnight Sorceress' },
|
||||
{ slug: 'deaths_edge', name: "Death's Edge" },
|
||||
{ slug: 'malefic_crown', name: 'Malefic Crown' },
|
||||
{ slug: 'spirit_embers', name: 'Spirit Embers' },
|
||||
{ slug: 'defensive_shield', name: 'Defensive Shield' },
|
||||
{ slug: 'magical_potion', name: 'Magical Potion' },
|
||||
{ slug: 'wizards_staff', name: "Wizard's Staff" },
|
||||
{ slug: 'crystal_ball_purple', name: 'Crystal Ball (Purple)' },
|
||||
{ slug: 'crystal_ball_blue', name: 'Crystal Ball (Blue)' },
|
||||
{ slug: 'owlbear_cub', name: 'Owlbear Cub' },
|
||||
{ slug: 'owlbear_cub_snowy', name: 'Snowy Owlbear Cub' },
|
||||
{ slug: 'baby_displacer_beast', name: 'Baby Displacer Beast' },
|
||||
{ slug: 'dice_violet', name: 'Violet Dice' },
|
||||
{ slug: 'dice_azure', name: 'Azure Dice' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'elements',
|
||||
label: 'Elements',
|
||||
decorations: [
|
||||
{ slug: 'fire', name: 'Fire' },
|
||||
{ slug: 'water', name: 'Water' },
|
||||
{ slug: 'air', name: 'Air' },
|
||||
{ slug: 'earth', name: 'Earth' },
|
||||
{ slug: 'lightning', name: 'Lightning' },
|
||||
{ slug: 'balance', name: 'Balance' },
|
||||
{ slug: 'ki_energy', name: 'Ki Energy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'japanese',
|
||||
label: 'Japanese',
|
||||
decorations: [
|
||||
{ slug: 'kabuto', name: 'Kabuto' },
|
||||
{ slug: 'oni_mask', name: 'Oni Mask' },
|
||||
{ slug: 'sakura_warrior', name: 'Sakura Warrior' },
|
||||
{ slug: 'sakura_ink', name: 'Sakura Ink' },
|
||||
{ slug: 'shurikens_mask', name: "Shuriken's Mask" },
|
||||
{ slug: 'straw_hat', name: 'Straw Hat' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nature',
|
||||
label: 'Nature',
|
||||
decorations: [
|
||||
{ slug: 'lotus_flower', name: 'Lotus Flower' },
|
||||
{ slug: 'koi_pond', name: 'Koi Pond' },
|
||||
{ slug: 'sakura', name: 'Sakura' },
|
||||
{ slug: 'sakura_pink', name: 'Pink Sakura' },
|
||||
{ slug: 'fall_leaves', name: 'Fall Leaves' },
|
||||
{ slug: 'fall_leaves_scarlet', name: 'Scarlet Leaves' },
|
||||
{ slug: 'butterflies', name: 'Butterflies' },
|
||||
{ slug: 'honeyblossom', name: 'Honeyblossom' },
|
||||
{ slug: 'dandelion_duo', name: 'Dandelion Duo' },
|
||||
{ slug: 'lunar_lanterns', name: 'Lunar Lanterns' },
|
||||
{ slug: 'firecrackers', name: 'Firecrackers' },
|
||||
{ slug: 'dragons_smile', name: "Dragon's Smile" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'spooky',
|
||||
label: 'Spooky',
|
||||
decorations: [
|
||||
{ slug: 'candlelight', name: 'Candlelight' },
|
||||
{ slug: 'candlelight_crimson', name: 'Crimson Candlelight' },
|
||||
{ slug: 'witch_hat_midnight', name: 'Midnight Witch Hat' },
|
||||
{ slug: 'witch_hat_plum', name: 'Plum Witch Hat' },
|
||||
{ slug: 'hood_dark', name: 'Dark Hood' },
|
||||
{ slug: 'hood_crimson', name: 'Crimson Hood' },
|
||||
{ slug: 'zombie_food', name: 'Zombie Food' },
|
||||
{ slug: 'bloodthirsty', name: 'Bloodthirsty' },
|
||||
{ slug: 'bloodthirsty_gold', name: 'Bloodthirsty (Gold)' },
|
||||
{ slug: 'jack_o_lantern', name: "Jack-o'-Lantern" },
|
||||
{ slug: 'pumpkin_spice', name: 'Pumpkin Spice' },
|
||||
{ slug: 'spooky_cat_ears', name: 'Spooky Cat Ears' },
|
||||
{ slug: 'ghosts', name: 'Ghosts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cozy',
|
||||
label: 'Cozy',
|
||||
decorations: [
|
||||
{ slug: 'cozy_cat', name: 'Cozy Cat' },
|
||||
{ slug: 'rainy_mood', name: 'Rainy Mood' },
|
||||
{ slug: 'oasis', name: 'Oasis' },
|
||||
{ slug: 'cozy_headphones', name: 'Cozy Headphones' },
|
||||
{ slug: 'doodling', name: 'Doodling' },
|
||||
{ slug: 'fox_hat', name: 'Fox Hat' },
|
||||
{ slug: 'fox_hat_chestnut', name: 'Chestnut Fox Hat' },
|
||||
{ slug: 'fox_hat_snow', name: 'Snow Fox Hat' },
|
||||
{ slug: 'cat_ears', name: 'Cat Ears' },
|
||||
{ slug: 'frog_hat', name: 'Frog Hat' },
|
||||
{ slug: 'polar_bear_hat', name: 'Polar Bear Hat' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap(
|
||||
(c) => c.decorations,
|
||||
);
|
||||
|
||||
export function decorationUrl(slug: string): string {
|
||||
return `${DECORATION_CDN}/${slug}.png`;
|
||||
}
|
||||
@@ -69,6 +69,7 @@ import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
||||
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
|
||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
|
||||
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
||||
|
||||
type MemberDrawerHeaderProps = {
|
||||
room: Room;
|
||||
@@ -150,16 +151,18 @@ function MemberItem({
|
||||
radii="400"
|
||||
onClick={onClick}
|
||||
before={
|
||||
<PresenceRingAvatar userId={member.userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={member.userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
<AvatarDecoration userId={member.userId}>
|
||||
<PresenceRingAvatar userId={member.userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={member.userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
</AvatarDecoration>
|
||||
}
|
||||
after={
|
||||
<>
|
||||
@@ -442,16 +445,18 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
gap="200"
|
||||
style={{ padding: `0 ${config.space.S200}` }}
|
||||
>
|
||||
<PresenceRingAvatar userId={knockMember.userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={knockMember.userId}
|
||||
src={knockAvatarUrl ?? undefined}
|
||||
alt={knockName}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
<AvatarDecoration userId={knockMember.userId}>
|
||||
<PresenceRingAvatar userId={knockMember.userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={knockMember.userId}
|
||||
src={knockAvatarUrl ?? undefined}
|
||||
alt={knockName}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
</AvatarDecoration>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T400" truncate>
|
||||
{knockName}
|
||||
|
||||
@@ -83,6 +83,7 @@ import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
||||
import { useBookmarks } from '../../../hooks/useBookmarks';
|
||||
import { PresenceRingAvatar } from '../../../components/presence';
|
||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||
|
||||
// Delivery status indicator for own messages
|
||||
function DeliveryStatus({
|
||||
@@ -874,27 +875,29 @@ export const Message = React.memo(
|
||||
<AvatarBase
|
||||
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
||||
>
|
||||
<PresenceRingAvatar userId={senderId}>
|
||||
<Avatar
|
||||
className={css.MessageAvatar}
|
||||
as="button"
|
||||
size="300"
|
||||
data-user-id={senderId}
|
||||
onClick={onUserClick}
|
||||
>
|
||||
<UserAvatar
|
||||
userId={senderId}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
|
||||
undefined)
|
||||
: undefined
|
||||
}
|
||||
alt={senderDisplayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
<AvatarDecoration userId={senderId}>
|
||||
<PresenceRingAvatar userId={senderId}>
|
||||
<Avatar
|
||||
className={css.MessageAvatar}
|
||||
as="button"
|
||||
size="300"
|
||||
data-user-id={senderId}
|
||||
onClick={onUserClick}
|
||||
>
|
||||
<UserAvatar
|
||||
userId={senderId}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
|
||||
undefined)
|
||||
: undefined
|
||||
}
|
||||
alt={senderDisplayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
</AvatarDecoration>
|
||||
</AvatarBase>
|
||||
);
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||
import { useUserPresence } from '../../../hooks/useUserPresence';
|
||||
import { ProfileDecoration } from './ProfileDecoration';
|
||||
import { EmojiBoard } from '../../../components/emoji-board';
|
||||
|
||||
type ProfileProps = {
|
||||
@@ -892,6 +893,7 @@ export function Profile() {
|
||||
<ProfileStatus />
|
||||
<ProfilePronouns />
|
||||
<ProfileTimezone />
|
||||
<ProfileDecoration />
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text, Spinner } from 'folds';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import {
|
||||
DECORATION_CATEGORIES,
|
||||
DECORATION_CDN,
|
||||
decorationUrl,
|
||||
} from '../../lotus/avatarDecorations';
|
||||
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||
|
||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||
const INSET = 8;
|
||||
|
||||
function DecorationPreviewCell({
|
||||
slug,
|
||||
name,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
slug: string;
|
||||
name: string;
|
||||
selected: boolean;
|
||||
onSelect: (slug: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={name}
|
||||
aria-label={name}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(slug)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 52,
|
||||
height: 52,
|
||||
flexShrink: 0,
|
||||
border: `2px solid ${selected ? 'var(--accent-cyan)' : 'transparent'}`,
|
||||
borderRadius: '0.75rem',
|
||||
background: 'var(--bg-surface-variant)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
boxShadow: selected ? '0 0 0 1px var(--accent-cyan)' : 'none',
|
||||
overflow: 'visible',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Avatar placeholder tint */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: '0.75rem',
|
||||
background: 'var(--bg-surface-variant)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src={`${DECORATION_CDN}/${slug}.png`}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -INSET,
|
||||
left: -INSET,
|
||||
width: `calc(100% + ${INSET * 2}px)`,
|
||||
height: `calc(100% + ${INSET * 2}px)`,
|
||||
objectFit: 'contain',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileDecoration() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
|
||||
const [current, setCurrent] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mx.http
|
||||
.authedRequest<Record<string, string>>(
|
||||
Method.Get,
|
||||
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
|
||||
)
|
||||
.then((res) => {
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
setCurrent(val);
|
||||
setSelected(val);
|
||||
})
|
||||
.catch(() => {
|
||||
setCurrent(null);
|
||||
setSelected(null);
|
||||
});
|
||||
}, [mx, userId]);
|
||||
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (slug: string | null) => {
|
||||
await mx.http.authedRequest(
|
||||
Method.Put,
|
||||
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
|
||||
undefined,
|
||||
{ [PROFILE_FIELD]: slug ?? '' },
|
||||
);
|
||||
setCurrent(slug);
|
||||
invalidateDecorationCache(userId);
|
||||
},
|
||||
[mx, userId],
|
||||
),
|
||||
);
|
||||
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
const hasChanges = selected !== current;
|
||||
|
||||
const handleSelect = (slug: string) => {
|
||||
setSelected((prev) => (prev === slug ? null : slug));
|
||||
};
|
||||
|
||||
const handleClear = () => setSelected(null);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!hasChanges || saving) return;
|
||||
save(selected);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Avatar Decoration
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<Text size="T200" priority="300">
|
||||
Shown on your avatar to all Lotus Chat users.
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box direction="Column" gap="300">
|
||||
{/* Current selection preview */}
|
||||
<Box alignItems="Center" gap="300">
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 52,
|
||||
height: 52,
|
||||
flexShrink: 0,
|
||||
borderRadius: '0.75rem',
|
||||
background: 'var(--bg-surface-variant)',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{selected && (
|
||||
<img
|
||||
src={decorationUrl(selected)}
|
||||
alt="Selected decoration preview"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -INSET,
|
||||
left: -INSET,
|
||||
width: `calc(100% + ${INSET * 2}px)`,
|
||||
height: `calc(100% + ${INSET * 2}px)`,
|
||||
objectFit: 'contain',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="T300">
|
||||
{selected
|
||||
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
|
||||
(d) => d.slug === selected,
|
||||
)?.name ?? selected)
|
||||
: 'None'}
|
||||
</Text>
|
||||
{selected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tc-surface-low-contrast)',
|
||||
fontSize: '0.8rem',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</Box>
|
||||
{hasChanges && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--accent-cyan)',
|
||||
background: 'transparent',
|
||||
color: 'var(--accent-cyan)',
|
||||
cursor: saving ? 'not-allowed' : 'pointer',
|
||||
fontSize: '0.85rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
opacity: saving ? 0.6 : 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{saving && <Spinner size="100" variant="Secondary" />}
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
|
||||
Failed to save. Try again.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Category grid */}
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="300"
|
||||
style={{
|
||||
maxHeight: 420,
|
||||
overflowY: 'auto',
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{DECORATION_CATEGORIES.map((category) => (
|
||||
<Box key={category.id} direction="Column" gap="200">
|
||||
<Text size="L400" style={{ opacity: 0.7 }}>
|
||||
{category.label}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 20,
|
||||
paddingBottom: 4,
|
||||
paddingLeft: INSET,
|
||||
paddingTop: INSET,
|
||||
}}
|
||||
>
|
||||
{category.decorations.map((d) => (
|
||||
<DecorationPreviewCell
|
||||
key={d.slug}
|
||||
slug={d.slug}
|
||||
name={d.name}
|
||||
selected={selected === d.slug}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
|
||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||
|
||||
// Module-level cache — survives re-renders, lives for the app session.
|
||||
// userId → slug | null (null = fetched, no decoration set)
|
||||
const cache = new Map<string, string | null>();
|
||||
// Callbacks waiting for a userId's result
|
||||
const pending = new Map<string, Array<(val: string | null) => void>>();
|
||||
|
||||
function fetchDecoration(
|
||||
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
||||
userId: string,
|
||||
): Promise<string | null> {
|
||||
if (cache.has(userId)) return Promise.resolve(cache.get(userId) ?? null);
|
||||
|
||||
// De-duplicate in-flight requests for the same userId
|
||||
if (pending.has(userId)) {
|
||||
return new Promise((resolve) => {
|
||||
pending.get(userId)!.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
const waiters: Array<(val: string | null) => void> = [];
|
||||
pending.set(userId, waiters);
|
||||
|
||||
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`)
|
||||
.then((res) => {
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
cache.set(userId, val);
|
||||
return val;
|
||||
})
|
||||
.catch(() => {
|
||||
cache.set(userId, null);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
const v = cache.get(userId) ?? null;
|
||||
pending.delete(userId);
|
||||
waiters.forEach((cb) => cb(v));
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateDecorationCache(userId: string): void {
|
||||
cache.delete(userId);
|
||||
}
|
||||
|
||||
export function useAvatarDecoration(userId: string): string | null {
|
||||
const mx = useMatrixClient();
|
||||
const [slug, setSlug] = useState<string | null>(() => cache.get(userId) ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchDecoration(
|
||||
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
|
||||
userId,
|
||||
).then((val) => {
|
||||
if (!cancelled) setSlug(val);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
return slug;
|
||||
}
|
||||
@@ -97,6 +97,7 @@ import {
|
||||
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { PresenceRingAvatar } from '../../../components/presence';
|
||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||
|
||||
type RoomNotificationsGroup = {
|
||||
roomId: string;
|
||||
@@ -479,27 +480,29 @@ function RoomNotificationsGroupComp({
|
||||
<ModernLayout
|
||||
before={
|
||||
<AvatarBase>
|
||||
<PresenceRingAvatar userId={event.sender}>
|
||||
<Avatar size="300">
|
||||
<UserAvatar
|
||||
userId={event.sender}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? (mxcUrlToHttp(
|
||||
mx,
|
||||
senderAvatarMxc,
|
||||
useAuthentication,
|
||||
48,
|
||||
48,
|
||||
'crop',
|
||||
) ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
<AvatarDecoration userId={event.sender}>
|
||||
<PresenceRingAvatar userId={event.sender}>
|
||||
<Avatar size="300">
|
||||
<UserAvatar
|
||||
userId={event.sender}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? (mxcUrlToHttp(
|
||||
mx,
|
||||
senderAvatarMxc,
|
||||
useAuthentication,
|
||||
48,
|
||||
48,
|
||||
'crop',
|
||||
) ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
</AvatarDecoration>
|
||||
</AvatarBase>
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user