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,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`;
|
||||
}
|
||||
Reference in New Issue
Block a user