From d59945eb4ceb56b7a6a3e37ab00b05d1327b3238 Mon Sep 17 00:00:00 2001 From: litruv Date: Mon, 24 Aug 2026 18:48:48 +1000 Subject: [PATCH] Add configurable sidebar nameplate background. Render the equipped nameplate behind the larger settings avatar with blur-aware sidebar controls, adjustable opacity, and immediate collectible cache invalidation. --- src/app/components/sidebar/Sidebar.css.ts | 42 +++++++++++++ src/app/features/settings/general/General.tsx | 62 +++++++++++++++++++ src/app/hooks/useUserCollectibles.ts | 23 ++++++- src/app/pages/client/sidebar/SearchTab.tsx | 9 ++- src/app/pages/client/sidebar/SettingsTab.tsx | 34 +++++++++- src/app/state/settings.ts | 2 + 6 files changed, 169 insertions(+), 3 deletions(-) diff --git a/src/app/components/sidebar/Sidebar.css.ts b/src/app/components/sidebar/Sidebar.css.ts index c368622..6ebe70d 100644 --- a/src/app/components/sidebar/Sidebar.css.ts +++ b/src/app/components/sidebar/Sidebar.css.ts @@ -9,6 +9,8 @@ export const Sidebar = style([ width: toRem(66), backgroundColor: color.Background.Container, borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, + position: 'relative', + isolation: 'isolate', display: 'flex', flexDirection: 'column', @@ -185,6 +187,46 @@ export const SidebarAvatar = recipe({ }); export type SidebarAvatarVariants = RecipeVariants; +/** A vertical rendition of the user's nameplate behind the bottom settings avatar. */ +export const SidebarAvatarNameplate = style({ + position: 'absolute', + // The 50px avatar is centered in the 66px sidebar. Pin the rotated plate's + // bottom-right corner 8px left of it so the 66px cross-axis + // spans the whole sidebar and grows upward from its bottom edge. + right: toRem(58), + bottom: toRem(-16), + width: toRem(220), + height: toRem(66), + transformOrigin: 'right bottom', + transform: 'rotate(90deg)', + objectFit: 'cover', + objectPosition: 'right center', + pointerEvents: 'none', + zIndex: -1, +}); + +export const SidebarAvatarLarge = style({ + width: toRem(50), + height: toRem(50), + borderRadius: '50%', + backgroundColor: `color-mix(in srgb, ${color.Background.Container} 68%, transparent)`, + backdropFilter: 'blur(10px)', + WebkitBackdropFilter: 'blur(10px)', +}); + +export const SidebarSearchAvatar = style({ + position: 'relative', + zIndex: 1, + backgroundColor: `color-mix(in srgb, ${color.Background.Container} 68%, transparent)`, + backdropFilter: 'blur(10px)', + WebkitBackdropFilter: 'blur(10px)', +}); + +export const SidebarAvatarForeground = style({ + position: 'relative', + zIndex: 1, +}); + export const SidebarFolder = recipe({ base: [ ContainerColor({ variant: 'Background' }), diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 0d1c2f0..5107792 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -9,6 +9,7 @@ import React, { import dayjs from 'dayjs'; import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds'; import { useSetAtom } from 'jotai'; +import { Range } from 'react-range'; import { Icon, Icons } from '../../../components/icons'; import { HexColorPicker } from 'react-colorful'; import { isKeyHotkey } from 'is-hotkey'; @@ -334,6 +335,66 @@ const emojiStyleNames: Record = { [EmojiStyle.Twemoji]: 'Twemoji', }; +function SidebarNameplate() { + const [sidebarNameplateOpacity, setSidebarNameplateOpacity] = useSetting( + settingsAtom, + 'sidebarNameplateOpacity' + ); + + return ( + + Collectibles + + + setSidebarNameplateOpacity(values[0])} + renderTrack={(params) => ( +
+ {params.children} +
+ )} + renderThumb={(params) => ( +
+ )} + /> + + {sidebarNameplateOpacity}% + + + } + /> + + + ); +} + type EmojiStyleSelectorProps = { selected: EmojiStyle; onSelect: (style: EmojiStyle) => void; @@ -1222,6 +1283,7 @@ export function General({ requestClose }: GeneralProps) { + diff --git a/src/app/hooks/useUserCollectibles.ts b/src/app/hooks/useUserCollectibles.ts index 90664ac..2ccd409 100644 --- a/src/app/hooks/useUserCollectibles.ts +++ b/src/app/hooks/useUserCollectibles.ts @@ -52,15 +52,18 @@ export function useUserCollectibles(): [ async (kind: CollectibleKind, collectible: StoredCollectible | undefined) => { if (kind === 'profile_effect') { await saveProfileEffect(mx, collectible); + clearUserCollectiblesCache(mx.getUserId()); setCollectibles((prev) => ({ ...prev, profile_effect: collectible })); return; } if (kind === 'nameplate') { await saveNameplate(mx, collectible); + clearUserCollectiblesCache(mx.getUserId()); setCollectibles((prev) => ({ ...prev, nameplate: collectible })); return; } await saveAvatarDecoration(mx, collectible); + clearUserCollectiblesCache(mx.getUserId()); setCollectibles((prev) => ({ ...prev, avatar_decoration: collectible })); }, [mx] @@ -70,8 +73,15 @@ export function useUserCollectibles(): [ } const collectibleCache = new Map(); +const collectibleCacheListeners = new Set<(userId: string) => void>(); const CACHE_TTL_MS = 5 * 60 * 1000; +export function clearUserCollectiblesCache(userId?: string): void { + if (!userId) return; + collectibleCache.delete(userId); + collectibleCacheListeners.forEach((listener) => listener(userId)); +} + async function fetchMxcBlobUrl( mx: ReturnType, mxc: string, @@ -100,6 +110,17 @@ export function useOtherUserCollectibles(userId: string): CollectibleProfileFiel const useAuthentication = useMediaAuthentication(); const [collectibles, setCollectibles] = useState({}); const [assetUrls, setAssetUrls] = useState>({}); + const [cacheVersion, setCacheVersion] = useState(0); + + useEffect(() => { + const handleCacheClear = (clearedUserId: string) => { + if (clearedUserId === userId) { + setCacheVersion((version) => version + 1); + } + }; + collectibleCacheListeners.add(handleCacheClear); + return () => collectibleCacheListeners.delete(handleCacheClear); + }, [userId]); useEffect(() => { if (!userId) { @@ -152,7 +173,7 @@ export function useOtherUserCollectibles(userId: string): CollectibleProfileFiel cancelled = true; blobUrls.forEach((url) => URL.revokeObjectURL(url)); }; - }, [mx, useAuthentication, userId]); + }, [cacheVersion, mx, useAuthentication, userId]); return { ...collectibles, assetUrls }; } diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx index 1a2b6e3..2197545 100644 --- a/src/app/pages/client/sidebar/SearchTab.tsx +++ b/src/app/pages/client/sidebar/SearchTab.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useAtom } from 'jotai'; import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; import { searchModalAtom } from '../../../state/searchModal'; +import * as sidebarCss from '../../../components/sidebar/Sidebar.css'; export function SearchTab() { const [opened, setOpen] = useAtom(searchModalAtom); @@ -14,7 +15,13 @@ export function SearchTab() { {(triggerRef) => ( - + )} diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index 9d33d19..c6b426f 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -12,8 +12,11 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { Settings } from '../../../features/settings'; import { useUserProfile } from '../../../hooks/useUserProfile'; import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles'; -import { pickAvatarDecorationUrl } from '../../../utils/collectibleAssets'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { pickAvatarDecorationUrl, pickNameplateUrl } from '../../../utils/collectibleAssets'; import * as avatarDecorationCss from '../../../styles/AvatarDecoration.css'; +import * as sidebarCss from '../../../components/sidebar/Sidebar.css'; import { Modal500 } from '../../../components/Modal500'; import { AccountSwitcher } from '../../../components/account-switcher'; import { stopPropagation } from '../../../utils/keyboard'; @@ -39,6 +42,11 @@ export function SettingsTab() { : undefined; const { assetUrls } = useOtherUserCollectibles(userId ?? ''); const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls); + const nameplateUrl = pickNameplateUrl(assetUrls); + const [sidebarNameplateOpacity] = useSetting(settingsAtom, 'sidebarNameplateOpacity'); + const nameplateIsVideo = Boolean( + assetUrls['nameplate:animated'] || assetUrls['nameplate:asset.webm'] + ); const openSettings = () => setSettings(true); const closeSettings = () => setSettings(false); @@ -53,12 +61,36 @@ export function SettingsTab() {
+ {sidebarNameplateOpacity > 0 && + nameplateUrl && + (nameplateIsVideo ? ( +