Compare commits

1 Commits

Author SHA1 Message Date
d59945eb4c Add configurable sidebar nameplate background.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Render the equipped nameplate behind the larger settings avatar with blur-aware sidebar controls, adjustable opacity, and immediate collectible cache invalidation.
2026-08-24 18:48:48 +10:00
6 changed files with 169 additions and 3 deletions

View File

@@ -9,6 +9,8 @@ export const Sidebar = style([
width: toRem(66), width: toRem(66),
backgroundColor: color.Background.Container, backgroundColor: color.Background.Container,
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
position: 'relative',
isolation: 'isolate',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -185,6 +187,46 @@ export const SidebarAvatar = recipe({
}); });
export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>; export type SidebarAvatarVariants = RecipeVariants<typeof SidebarAvatar>;
/** 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({ export const SidebarFolder = recipe({
base: [ base: [
ContainerColor({ variant: 'Background' }), ContainerColor({ variant: 'Background' }),

View File

@@ -9,6 +9,7 @@ import React, {
import dayjs from 'dayjs'; 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 { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { Range } from 'react-range';
import { Icon, Icons } from '../../../components/icons'; import { Icon, Icons } from '../../../components/icons';
import { HexColorPicker } from 'react-colorful'; import { HexColorPicker } from 'react-colorful';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
@@ -334,6 +335,66 @@ const emojiStyleNames: Record<EmojiStyle, string> = {
[EmojiStyle.Twemoji]: 'Twemoji', [EmojiStyle.Twemoji]: 'Twemoji',
}; };
function SidebarNameplate() {
const [sidebarNameplateOpacity, setSidebarNameplateOpacity] = useSetting(
settingsAtom,
'sidebarNameplateOpacity'
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Collectibles</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Sidebar Nameplate Opacity"
description="Set to 0% to hide your equipped nameplate behind the bottom sidebar avatar."
after={
<Box gap="200" alignItems="Center" style={{ width: toRem(180) }}>
<Range
step={1}
min={0}
max={100}
values={[sidebarNameplateOpacity]}
onChange={(values) => setSidebarNameplateOpacity(values[0])}
renderTrack={(params) => (
<div
{...params.props}
style={{
...params.props.style,
width: '100%',
height: toRem(8),
borderRadius: toRem(4),
background: `linear-gradient(to right, ${color.Primary.Main} ${sidebarNameplateOpacity}%, ${color.Surface.ContainerLine} ${sidebarNameplateOpacity}%)`,
}}
>
{params.children}
</div>
)}
renderThumb={(params) => (
<div
{...params.props}
style={{
...params.props.style,
width: toRem(16),
height: toRem(16),
borderRadius: '50%',
backgroundColor: color.Primary.Main,
border: `${toRem(2)} solid ${color.Surface.Container}`,
}}
/>
)}
/>
<Text size="T300" style={{ minWidth: toRem(32), textAlign: 'right' }}>
{sidebarNameplateOpacity}%
</Text>
</Box>
}
/>
</SequenceCard>
</Box>
);
}
type EmojiStyleSelectorProps = { type EmojiStyleSelectorProps = {
selected: EmojiStyle; selected: EmojiStyle;
onSelect: (style: EmojiStyle) => void; onSelect: (style: EmojiStyle) => void;
@@ -1222,6 +1283,7 @@ export function General({ requestClose }: GeneralProps) {
<PageContent> <PageContent>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<Appearance /> <Appearance />
<SidebarNameplate />
<DateAndTime /> <DateAndTime />
<Editor /> <Editor />
<Spaces /> <Spaces />

View File

@@ -52,15 +52,18 @@ export function useUserCollectibles(): [
async (kind: CollectibleKind, collectible: StoredCollectible | undefined) => { async (kind: CollectibleKind, collectible: StoredCollectible | undefined) => {
if (kind === 'profile_effect') { if (kind === 'profile_effect') {
await saveProfileEffect(mx, collectible); await saveProfileEffect(mx, collectible);
clearUserCollectiblesCache(mx.getUserId());
setCollectibles((prev) => ({ ...prev, profile_effect: collectible })); setCollectibles((prev) => ({ ...prev, profile_effect: collectible }));
return; return;
} }
if (kind === 'nameplate') { if (kind === 'nameplate') {
await saveNameplate(mx, collectible); await saveNameplate(mx, collectible);
clearUserCollectiblesCache(mx.getUserId());
setCollectibles((prev) => ({ ...prev, nameplate: collectible })); setCollectibles((prev) => ({ ...prev, nameplate: collectible }));
return; return;
} }
await saveAvatarDecoration(mx, collectible); await saveAvatarDecoration(mx, collectible);
clearUserCollectiblesCache(mx.getUserId());
setCollectibles((prev) => ({ ...prev, avatar_decoration: collectible })); setCollectibles((prev) => ({ ...prev, avatar_decoration: collectible }));
}, },
[mx] [mx]
@@ -70,8 +73,15 @@ export function useUserCollectibles(): [
} }
const collectibleCache = new Map<string, { data: CollectibleProfileFields; timestamp: number }>(); const collectibleCache = new Map<string, { data: CollectibleProfileFields; timestamp: number }>();
const collectibleCacheListeners = new Set<(userId: string) => void>();
const CACHE_TTL_MS = 5 * 60 * 1000; 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( async function fetchMxcBlobUrl(
mx: ReturnType<typeof useMatrixClient>, mx: ReturnType<typeof useMatrixClient>,
mxc: string, mxc: string,
@@ -100,6 +110,17 @@ export function useOtherUserCollectibles(userId: string): CollectibleProfileFiel
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [collectibles, setCollectibles] = useState<CollectibleProfileFields>({}); const [collectibles, setCollectibles] = useState<CollectibleProfileFields>({});
const [assetUrls, setAssetUrls] = useState<Record<string, string | undefined>>({}); const [assetUrls, setAssetUrls] = useState<Record<string, string | undefined>>({});
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(() => { useEffect(() => {
if (!userId) { if (!userId) {
@@ -152,7 +173,7 @@ export function useOtherUserCollectibles(userId: string): CollectibleProfileFiel
cancelled = true; cancelled = true;
blobUrls.forEach((url) => URL.revokeObjectURL(url)); blobUrls.forEach((url) => URL.revokeObjectURL(url));
}; };
}, [mx, useAuthentication, userId]); }, [cacheVersion, mx, useAuthentication, userId]);
return { ...collectibles, assetUrls }; return { ...collectibles, assetUrls };
} }

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { searchModalAtom } from '../../../state/searchModal'; import { searchModalAtom } from '../../../state/searchModal';
import * as sidebarCss from '../../../components/sidebar/Sidebar.css';
export function SearchTab() { export function SearchTab() {
const [opened, setOpen] = useAtom(searchModalAtom); const [opened, setOpen] = useAtom(searchModalAtom);
@@ -14,7 +15,13 @@ export function SearchTab() {
<SidebarItem active={opened}> <SidebarItem active={opened}>
<SidebarItemTooltip tooltip="Search"> <SidebarItemTooltip tooltip="Search">
{(triggerRef) => ( {(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={open}> <SidebarAvatar
as="button"
ref={triggerRef}
className={sidebarCss.SidebarSearchAvatar}
outlined
onClick={open}
>
<Icon src={Icons.Search} filled={opened} /> <Icon src={Icons.Search} filled={opened} />
</SidebarAvatar> </SidebarAvatar>
)} )}

View File

@@ -12,8 +12,11 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Settings } from '../../../features/settings'; import { Settings } from '../../../features/settings';
import { useUserProfile } from '../../../hooks/useUserProfile'; import { useUserProfile } from '../../../hooks/useUserProfile';
import { useOtherUserCollectibles } from '../../../hooks/useUserCollectibles'; 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 avatarDecorationCss from '../../../styles/AvatarDecoration.css';
import * as sidebarCss from '../../../components/sidebar/Sidebar.css';
import { Modal500 } from '../../../components/Modal500'; import { Modal500 } from '../../../components/Modal500';
import { AccountSwitcher } from '../../../components/account-switcher'; import { AccountSwitcher } from '../../../components/account-switcher';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
@@ -39,6 +42,11 @@ export function SettingsTab() {
: undefined; : undefined;
const { assetUrls } = useOtherUserCollectibles(userId ?? ''); const { assetUrls } = useOtherUserCollectibles(userId ?? '');
const avatarDecorationUrl = pickAvatarDecorationUrl(assetUrls); 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 openSettings = () => setSettings(true);
const closeSettings = () => setSettings(false); const closeSettings = () => setSettings(false);
@@ -53,12 +61,36 @@ export function SettingsTab() {
<SidebarItem active={settings}> <SidebarItem active={settings}>
<SidebarAvatar <SidebarAvatar
as="button" as="button"
className={sidebarCss.SidebarAvatarLarge}
onClick={openSettings} onClick={openSettings}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ overflow: 'visible' }} style={{ overflow: 'visible' }}
> >
<div className={avatarDecorationCss.SidebarAvatarStack}> <div className={avatarDecorationCss.SidebarAvatarStack}>
{sidebarNameplateOpacity > 0 &&
nameplateUrl &&
(nameplateIsVideo ? (
<video
className={sidebarCss.SidebarAvatarNameplate}
src={nameplateUrl}
style={{ opacity: sidebarNameplateOpacity / 100 }}
autoPlay
loop
muted
playsInline
aria-hidden="true"
/>
) : (
<img
className={sidebarCss.SidebarAvatarNameplate}
src={nameplateUrl}
style={{ opacity: sidebarNameplateOpacity / 100 }}
alt=""
draggable={false}
/>
))}
<UserAvatar <UserAvatar
className={sidebarCss.SidebarAvatarForeground}
userId={userId ?? ''} userId={userId ?? ''}
src={avatarUrl} src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>} renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}

View File

@@ -50,6 +50,7 @@ export interface Settings {
encUrlPreview: boolean; encUrlPreview: boolean;
showHiddenEvents: boolean; showHiddenEvents: boolean;
legacyUsernameColor: boolean; legacyUsernameColor: boolean;
sidebarNameplateOpacity: number;
showNotifications: boolean; showNotifications: boolean;
isNotificationSounds: boolean; isNotificationSounds: boolean;
@@ -93,6 +94,7 @@ const defaultSettings: Settings = {
encUrlPreview: false, encUrlPreview: false,
showHiddenEvents: false, showHiddenEvents: false,
legacyUsernameColor: false, legacyUsernameColor: false,
sidebarNameplateOpacity: 60,
showNotifications: true, showNotifications: true,
isNotificationSounds: true, isNotificationSounds: true,