fix: settings dropdowns, background animations, ringing, avatar decorations
Settings dropdowns (Bug #3): - Add reusable SettingsSelect component using Menu+PopOut+FocusTrap — exact same pattern as Message Layout, so all dropdowns look consistent - Replace raw <select> for Seasonal Theme, UI Font, AFK Timeout, and Join & Leave Sounds with SettingsSelect Animated chat backgrounds bleeding onto content (Bug #6 / #7): - Remove filter:brightness() and opacity animations from chatBackground.ts (animRainGlowKeyframe, animGridBrightnessKeyframe, animFirefliesGlowKeyframe, animFirefliesBlinkKeyframe). These were applied to the Page element which caused ALL descendants (messages, composer) to flash in sync. Also created a CSS stacking context on Page that pushed SeasonalEffect (position:fixed; z-index:9997) behind the animated background layer. - Only backgroundPosition / backgroundSize animations remain — safe, do not affect descendants, and do not create stacking contexts. - Remove now-unused animation keyframe imports from chatBackground.ts. Voice ringing in persistent rooms (Bug #5): - Narrow the ringing condition from (Invite|Knock|Restricted) to only Invite, matching exactly the rooms where the call button is visible. - Add room.isCallRoom() early-exit so m.join_rule:call rooms never ring. Avatar decoration images not loading (Bug #8): - Change loading="lazy" → loading="eager" in DecorationPreviewCell. Lazy loading does not reliably trigger for images inside nested overflow scroll containers (the settings panel scroll area), so images never loaded. Docs: LOTUS_BUGS.md updated with root cause and resolution for all 5 new bugs. Docs: LOTUS_TODO.md adds P5-35/P5-36 (deferred desktop notification/jump list). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -325,21 +325,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
);
|
||||
if (!hasCallPermission) return;
|
||||
|
||||
// Only ring for DMs or private non-space group chats.
|
||||
// Space voice channels and public rooms fire room-level RTC notifications
|
||||
// whenever anyone joins — ringing every member is incorrect behaviour.
|
||||
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
||||
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
||||
// restricted rooms, and public rooms must never trigger ringing.
|
||||
if (room.isCallRoom()) return;
|
||||
const isDirect = directs.has(room.roomId);
|
||||
// m.space.parent uses the parent space ID as the state key, so getStateEvent
|
||||
// (which defaults to stateKey='') always returns undefined. Use getStateEvents
|
||||
// (no key filter) to detect any space parent relationship.
|
||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||
const joinRule = room.getJoinRule();
|
||||
const isPrivateGroup =
|
||||
!isSpaceChild &&
|
||||
(joinRule === JoinRule.Invite ||
|
||||
joinRule === JoinRule.Knock ||
|
||||
joinRule === JoinRule.Restricted);
|
||||
if (!isDirect && !isPrivateGroup) return;
|
||||
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
|
||||
if (!isDirect && !isPrivateInviteGroup) return;
|
||||
|
||||
const info: IncomingCallInfo = {
|
||||
room,
|
||||
|
||||
@@ -2,14 +2,10 @@ import { CSSProperties } from 'react';
|
||||
import { ChatBackground } from '../../state/settings';
|
||||
import {
|
||||
animRainKeyframe,
|
||||
animRainGlowKeyframe,
|
||||
animStarsDriftKeyframe,
|
||||
animGridPulseKeyframe,
|
||||
animGridBrightnessKeyframe,
|
||||
animAuroraKeyframe,
|
||||
animFirefliesKeyframe,
|
||||
animFirefliesGlowKeyframe,
|
||||
animFirefliesBlinkKeyframe,
|
||||
} from '../../styles/Animations.css';
|
||||
|
||||
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
|
||||
@@ -210,7 +206,7 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
||||
].join(','),
|
||||
backgroundSize: '40px 200px, 12px 200px',
|
||||
backgroundPosition: '0 0, 0 0',
|
||||
animation: `${animRainKeyframe} 8s linear infinite, ${animRainGlowKeyframe} 2.1s ease-in-out infinite`,
|
||||
animation: `${animRainKeyframe} 8s linear infinite`,
|
||||
},
|
||||
|
||||
// Animated: drifting star field — three seamlessly-tiling layers at different speeds
|
||||
@@ -236,7 +232,7 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
||||
'linear-gradient(90deg, rgba(0,212,255,0.06) 1px, transparent 1px)',
|
||||
].join(','),
|
||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
||||
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite, ${animGridBrightnessKeyframe} 3.3s ease-in-out infinite`,
|
||||
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
|
||||
},
|
||||
|
||||
// Animated: aurora borealis — four bands each travel an independent path
|
||||
@@ -263,7 +259,7 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
||||
].join(','),
|
||||
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
|
||||
backgroundPosition: '0 0, 120px 80px, 60px 140px',
|
||||
animation: `${animFirefliesKeyframe} 30s linear infinite, ${animFirefliesGlowKeyframe} 2.3s ease-in-out infinite, ${animFirefliesBlinkKeyframe} 1.7s ease-in-out infinite`,
|
||||
animation: `${animFirefliesKeyframe} 30s linear infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -481,7 +477,7 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
||||
].join(','),
|
||||
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
|
||||
backgroundPosition: '0 0, 120px 80px, 60px 140px',
|
||||
animation: `${animFirefliesKeyframe} 30s linear infinite, ${animFirefliesGlowKeyframe} 2.3s ease-in-out infinite, ${animFirefliesBlinkKeyframe} 1.7s ease-in-out infinite`,
|
||||
animation: `${animFirefliesKeyframe} 30s linear infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ function DecorationPreviewCell({
|
||||
<img
|
||||
src={`${DECORATION_CDN}/${slug}.png`}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -154,6 +154,82 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
type SettingsSelectOption<T extends string> = { value: T; label: string };
|
||||
|
||||
function SettingsSelect<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: T;
|
||||
options: SettingsSelectOption<T>[];
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
|
||||
|
||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleSelect = (v: T) => {
|
||||
onChange(v);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||
onClick={handleMenu}
|
||||
>
|
||||
<Text size="T300">{selectedLabel}</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{options.map((opt) => (
|
||||
<MenuItem
|
||||
key={opt.value}
|
||||
size="300"
|
||||
variant={opt.value === value ? 'Primary' : 'Surface'}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
>
|
||||
<Text size="T300">{opt.label}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemThemePreferences() {
|
||||
const themeKind = useSystemThemeKind();
|
||||
const themeNames = useThemeNames();
|
||||
@@ -417,42 +493,25 @@ function Appearance() {
|
||||
title="Seasonal Theme"
|
||||
description="Decorative overlays that activate automatically on holidays and events, or choose one manually."
|
||||
after={
|
||||
<select
|
||||
<SettingsSelect
|
||||
value={seasonalThemeOverride ?? 'auto'}
|
||||
onChange={(e) =>
|
||||
setSeasonalThemeOverride(
|
||||
e.target.value as typeof seasonalThemeOverride,
|
||||
)
|
||||
}
|
||||
style={{
|
||||
background: 'var(--bg-surface-variant)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-interactive)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="auto">🗓 Auto (date-based)</option>
|
||||
<option value="off">Off</option>
|
||||
<optgroup label="Holidays">
|
||||
<option value="newyear">🎆 New Year (Dec 31–Jan 2)</option>
|
||||
<option value="lunar">🏮 Lunar New Year (Jan 22–Feb 5)</option>
|
||||
<option value="valentines">💖 Valentine's Day (Feb 10–15)</option>
|
||||
<option value="stpatricks">🍀 St. Patrick's Day (Mar 15–18)</option>
|
||||
<option value="aprilfools">🃏 April Fool's Day (Apr 1)</option>
|
||||
<option value="earthday">🌱 Earth Day (Apr 22)</option>
|
||||
<option value="autumn">🍂 Autumn (Sep 21–Oct 31)</option>
|
||||
<option value="halloween">🎃 Halloween (Oct 15–Nov 1)</option>
|
||||
<option value="christmas">❄️ Christmas (Dec 10–Jan 2)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Events">
|
||||
<option value="arcade">👾 Retro Arcade Day (Sep 12)</option>
|
||||
<option value="deepspace">🚀 Deep Space Week (Oct 4–10)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
onChange={(v) => setSeasonalThemeOverride(v as typeof seasonalThemeOverride)}
|
||||
options={[
|
||||
{ value: 'auto', label: '🗓 Auto (date-based)' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'newyear', label: '🎆 New Year' },
|
||||
{ value: 'lunar', label: '🏮 Lunar New Year' },
|
||||
{ value: 'valentines', label: '💖 Valentine\'s Day' },
|
||||
{ value: 'stpatricks', label: '🍀 St. Patrick\'s Day' },
|
||||
{ value: 'aprilfools', label: '🃏 April Fool\'s Day' },
|
||||
{ value: 'earthday', label: '🌱 Earth Day' },
|
||||
{ value: 'autumn', label: '🍂 Autumn' },
|
||||
{ value: 'halloween', label: '🎃 Halloween' },
|
||||
{ value: 'christmas', label: '❄️ Christmas' },
|
||||
{ value: 'arcade', label: '👾 Retro Arcade Day' },
|
||||
{ value: 'deepspace', label: '🚀 Deep Space Week' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
@@ -556,26 +615,18 @@ function Appearance() {
|
||||
title="UI Font"
|
||||
description="Font used throughout the interface."
|
||||
after={
|
||||
<select
|
||||
<SettingsSelect
|
||||
value={fontFamily ?? 'inter'}
|
||||
onChange={(e) =>
|
||||
setFontFamily(e.target.value as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
|
||||
onChange={(v) =>
|
||||
setFontFamily(v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
|
||||
}
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="system">System Default</option>
|
||||
<option value="inter">Inter (default)</option>
|
||||
<option value="jetbrains-mono">JetBrains Mono</option>
|
||||
<option value="fira-code">Fira Code</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: 'system', label: 'System Default' },
|
||||
{ value: 'inter', label: 'Inter (default)' },
|
||||
{ value: 'jetbrains-mono', label: 'JetBrains Mono' },
|
||||
{ value: 'fira-code', label: 'Fira Code' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
@@ -1263,25 +1314,17 @@ function Calls() {
|
||||
title="Idle Timeout"
|
||||
description="How long to wait before auto-muting."
|
||||
after={
|
||||
<select
|
||||
value={afkTimeoutMinutes}
|
||||
onChange={(e) => setAfkTimeoutMinutes(Number(e.target.value))}
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value={1}>1 minute</option>
|
||||
<option value={5}>5 minutes</option>
|
||||
<option value={10}>10 minutes</option>
|
||||
<option value={20}>20 minutes</option>
|
||||
<option value={30}>30 minutes</option>
|
||||
</select>
|
||||
<SettingsSelect
|
||||
value={String(afkTimeoutMinutes ?? 10)}
|
||||
onChange={(v) => setAfkTimeoutMinutes(Number(v))}
|
||||
options={[
|
||||
{ value: '1', label: '1 minute' },
|
||||
{ value: '5', label: '5 minutes' },
|
||||
{ value: '10', label: '10 minutes' },
|
||||
{ value: '20', label: '20 minutes' },
|
||||
{ value: '30', label: '30 minutes' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -1291,26 +1334,16 @@ function Calls() {
|
||||
title="Join & Leave Sounds"
|
||||
description="Play a sound when someone joins or leaves a call you are in."
|
||||
after={
|
||||
<select
|
||||
<SettingsSelect
|
||||
value={callJoinLeaveSound}
|
||||
onChange={(e) =>
|
||||
handleJoinLeaveSoundChange(e.target.value as 'off' | 'chime' | 'soft' | 'retro')
|
||||
}
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="chime">Chime</option>
|
||||
<option value="soft">Soft</option>
|
||||
<option value="retro">Retro</option>
|
||||
</select>
|
||||
onChange={(v) => handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro')}
|
||||
options={[
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'chime', label: 'Chime' },
|
||||
{ value: 'soft', label: 'Soft' },
|
||||
{ value: 'retro', label: 'Retro' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
Reference in New Issue
Block a user