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 ( + + ); +} + +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 preview + )} +
+ + + {selected + ? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find( + (d) => d.slug === selected, + )?.name ?? selected) + : 'None'} + + {selected && ( + + )} + + {hasChanges && ( + + )} +
+ + {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({ - - - } - /> - - + + + + } + /> + + + } >