diff --git a/src/app/components/avatar-decoration/AvatarDecoration.tsx b/src/app/components/avatar-decoration/AvatarDecoration.tsx
new file mode 100644
index 000000000..9ffcd0c10
--- /dev/null
+++ b/src/app/components/avatar-decoration/AvatarDecoration.tsx
@@ -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 (
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx
index dcbc6537d..13e16d173 100644
--- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx
+++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx
@@ -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={
-
-
- }
- />
-
-
+
+
+
+ }
+ />
+
+
+
}
>
@@ -177,16 +180,18 @@ export function UserMentionAutocomplete({
}
before={
-
-
- }
- />
-
-
+
+
+
+ }
+ />
+
+
+
}
>
diff --git a/src/app/features/lotus/avatarDecorations.ts b/src/app/features/lotus/avatarDecorations.ts
new file mode 100644
index 000000000..8007a413b
--- /dev/null
+++ b/src/app/features/lotus/avatarDecorations.ts
@@ -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`;
+}
diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx
index 535398708..bd72722e8 100644
--- a/src/app/features/room/MembersDrawer.tsx
+++ b/src/app/features/room/MembersDrawer.tsx
@@ -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={
-
-
- }
- />
-
-
+
+
+
+ }
+ />
+
+
+
}
after={
<>
@@ -442,16 +445,18 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
gap="200"
style={{ padding: `0 ${config.space.S200}` }}
>
-
-
- }
- />
-
-
+
+
+
+ }
+ />
+
+
+
{knockName}
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index 9f9b4b68e..362edaa8b 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -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(
-
-
- }
- />
-
-
+
+
+
+ }
+ />
+
+
+
);
diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx
index bfb091544..d2201b738 100644
--- a/src/app/features/settings/account/Profile.tsx
+++ b/src/app/features/settings/account/Profile.tsx
@@ -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() {
+
);
diff --git a/src/app/features/settings/account/ProfileDecoration.tsx b/src/app/features/settings/account/ProfileDecoration.tsx
new file mode 100644
index 000000000..943ba1d19
--- /dev/null
+++ b/src/app/features/settings/account/ProfileDecoration.tsx
@@ -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 (
+ 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 */}
+
+
+
+ );
+}
+
+export function ProfileDecoration() {
+ const mx = useMatrixClient();
+ const userId = mx.getUserId()!;
+
+ const [current, setCurrent] = useState(null);
+ const [selected, setSelected] = useState(null);
+
+ useEffect(() => {
+ mx.http
+ .authedRequest>(
+ 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 (
+
+ Avatar Decoration
+
+ }
+ description={
+
+ Shown on your avatar to all Lotus Chat users.
+
+ }
+ >
+
+ {/* Current selection preview */}
+
+
+ {selected && (
+
+ )}
+
+
+
+ {selected
+ ? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
+ (d) => d.slug === selected,
+ )?.name ?? selected)
+ : 'None'}
+
+ {selected && (
+
+ Remove
+
+ )}
+
+ {hasChanges && (
+
+ {saving && }
+ {saving ? 'Saving…' : 'Save'}
+
+ )}
+
+
+ {saveState.status === AsyncStatus.Error && (
+
+ Failed to save. Try again.
+
+ )}
+
+ {/* Category grid */}
+
+ {DECORATION_CATEGORIES.map((category) => (
+
+
+ {category.label}
+
+
+ {category.decorations.map((d) => (
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/app/hooks/useAvatarDecoration.ts b/src/app/hooks/useAvatarDecoration.ts
new file mode 100644
index 000000000..f2875f549
--- /dev/null
+++ b/src/app/hooks/useAvatarDecoration.ts
@@ -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();
+// Callbacks waiting for a userId's result
+const pending = new Map void>>();
+
+function fetchDecoration(
+ authedRequest: (method: Method, path: string) => Promise>,
+ userId: string,
+): Promise {
+ 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(() => cache.get(userId) ?? null);
+
+ useEffect(() => {
+ let cancelled = false;
+ fetchDecoration(
+ (method, path) => mx.http.authedRequest>(method, path),
+ userId,
+ ).then((val) => {
+ if (!cancelled) setSlug(val);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [mx, userId]);
+
+ return slug;
+}
diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx
index bdf651de0..7b378e140 100644
--- a/src/app/pages/client/inbox/Notifications.tsx
+++ b/src/app/pages/client/inbox/Notifications.tsx
@@ -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({
-
-
- }
- />
-
-
+
+
+
+ }
+ />
+
+
+
}
>