feat: avatar decorations (P5-13)
CI / Build & Quality Checks (push) Successful in 10m30s
Trigger Desktop Build / trigger (push) Successful in 19s

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:
2026-06-14 11:24:04 -04:00
parent ca09e8e6ca
commit bf1308dd55
9 changed files with 671 additions and 80 deletions
+178
View File
@@ -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`;
}