fix(app): stop guild banner jumps and restore hover animation (#2792)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-15 09:31:52 +02:00
1 parent a08615e306
commit 0532dd0440
4 files changed
+304 -17

No files matched your search

@@ -186,7 +186,7 @@ export const ChannelListContent = observer(({guild, scrollY, banner}: ChannelLis
}); });
const [activeDragItem, setActiveDragItem] = useState<DragItem | null>(null); const [activeDragItem, setActiveDragItem] = useState<DragItem | null>(null);
const scrollerRef = useRef<ScrollerHandle>(null); const scrollerRef = useRef<ScrollerHandle>(null);
const showIntegratedBanner = banner.imageUrl != null && banner.collapseDistance > 0; const showIntegratedBanner = banner.collapsible;
const channelGroupsContainerRef = useRef<HTMLDivElement | null>(null); const channelGroupsContainerRef = useRef<HTMLDivElement | null>(null);
const stickToBottomRef = useRef(false); const stickToBottomRef = useRef(false);
const pendingScrollTopRef = useRef<number | null>(null); const pendingScrollTopRef = useRef<number | null>(null);
@@ -0,0 +1,288 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {GuildHeader} from '@app/features/app/components/layout/GuildHeader';
import styles from '@app/features/app/components/layout/GuildHeader.module.css';
import {useGuildBannerPresentation} from '@app/features/app/hooks/useGuildBannerPresentation';
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {Guild} from '@app/features/guild/models/Guild';
import {WINDOW_FOCUSED_CLASS} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import UserSettings from '@app/features/user/state/UserSettings';
import {
type ExperimentAssignmentsResponse,
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import type {GuildHeaderCollapseAssignmentResponse} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
import type {Guild as WireGuild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {setupI18n} from '@lingui/core';
import {useMotionValue} from 'framer-motion';
import {runInAction} from 'mobx';
import {observer} from 'mobx-react-lite';
import {act, useLayoutEffect} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/platform/utils/AppLogger', () => ({
Logger: class {
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({
http: {get: vi.fn(), post: vi.fn()},
}));
vi.mock('@app/features/app/state/RuntimeConfig', () => ({default: {localInstanceDomain: 'fluxer.test'}}));
vi.mock('@app/features/app/config/Config', () => ({
default: {
PUBLIC_BUILD_VERSION: 'test',
PUBLIC_RELEASE_CHANNEL: 'canary',
PUBLIC_BOOTSTRAP_API_ENDPOINT: 'https://example.invalid',
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: 'https://example.invalid',
},
}));
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown =>
typeof value === 'string'
? {id: value, message: value}
: {...(value as object), id: (value as {message: string}).message};
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
});
vi.mock('@lingui/react/macro', () => ({
useLingui: () => ({i18n}),
}));
vi.mock('@app/features/guild/components/popouts/GuildHeaderPopout', () => ({GuildHeaderPopout: () => null}));
vi.mock('@app/features/guild/components/bottomsheets/GuildHeaderBottomSheet', () => ({
GuildHeaderBottomSheet: () => null,
}));
vi.mock('@app/features/ui/action_menu/GuildContextMenu', () => ({GuildContextMenu: () => null}));
vi.mock('@app/features/app/hooks/useSkeletonLayoutMemoryCapture', () => ({
useSkeletonLayoutReport: () => {},
measureSkeletonTextWidthPx: () => 0,
}));
vi.mock('@app/features/messaging/utils/ImageCacheUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('@app/features/messaging/utils/ImageCacheUtils')>()),
hasImage: () => true,
}));
vi.mock('@app/features/user/utils/AvatarUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('@app/features/user/utils/AvatarUtils')>()),
getGuildBannerURL: ({id, banner}: {id: string; banner: string | null}, animated: boolean) =>
banner == null ? null : `https://media.test/banners/${id}/${banner}.${animated ? 'gif' : 'webp'}`,
}));
const i18n = setupI18n({locale: 'en-US', messages: {'en-US': {}}});
const DEFAULT_GIF_AUTOPLAY = UserSettings.gifAutoPlay;
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
const HEADER_ROW_HEIGHT = 56;
const SIXTEEN_BY_NINE_SIDEBAR_BANNER_HEIGHT = 135;
const TARGETED_ASSIGNMENT: GuildHeaderCollapseAssignmentResponse = {
enabled: true,
config_version: 1,
user_targeted: true,
source: 'canary',
};
interface Frame {
readonly headerHeight: number;
readonly bannerHeight: number;
readonly listTop: number;
}
const layout = {sidebarWidth: 240};
const frames: Array<Frame> = [];
const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth');
const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight');
let container: HTMLDivElement;
let root: Root;
function publish(assignment: GuildHeaderCollapseAssignmentResponse | undefined): void {
const response: ExperimentAssignmentsResponse = {
poll_interval_seconds: 300,
poll_jitter_percent: 15,
assignments: assignment === undefined ? {} : {guild_header_collapse: assignment},
};
runInAction(() => {
ExperimentAssignments.response = response;
});
}
function createBannerGuild(): Guild {
return new Guild({
id: '1400000000000000000',
name: 'Banner Community',
icon: null,
banner: 'b4nn3r',
banner_width: 1600,
banner_height: 900,
features: [],
roles: [],
owner_id: '1400000000000000001',
} as unknown as WireGuild);
}
function requireElement(selector: string): HTMLElement {
const element = container.querySelector<HTMLElement>(selector);
if (element == null) {
throw new Error(`missing ${selector}`);
}
return element;
}
const Harness = observer(({guild}: {guild: Guild}) => {
const scrollY = useMotionValue(0);
const banner = useGuildBannerPresentation({guild, scrollY});
useLayoutEffect(() => {
const header = requireElement(`.${styles.headerContainer}`);
const clip = container.querySelector<HTMLElement>('[data-flx="app.guild-header.banner-clip"]');
frames.push({
headerHeight: header.offsetHeight,
bannerHeight: clip?.offsetHeight ?? 0,
listTop: header.offsetHeight + banner.collapseDistance,
});
});
return <GuildHeader guild={guild} banner={banner} />;
});
function mount(guild: Guild): void {
act(() => {
root.render(<Harness guild={guild} />);
});
}
function lastFrame(): Frame {
const frame = frames.at(-1);
if (frame === undefined) {
throw new Error('nothing rendered');
}
return frame;
}
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
get() {
return layout.sidebarWidth;
},
});
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
get(this: HTMLElement) {
const inlineHeight = Number.parseFloat(this.style.height);
return Number.isFinite(inlineHeight) ? inlineHeight : HEADER_ROW_HEIGHT;
},
});
});
afterAll(() => {
if (originalOffsetWidth) Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth);
if (originalOffsetHeight) Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight);
});
beforeEach(() => {
layout.sidebarWidth = 240;
frames.length = 0;
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
document.documentElement.classList.remove(WINDOW_FOCUSED_CLASS);
vi.restoreAllMocks();
runInAction(() => {
UserSettings.gifAutoPlay = DEFAULT_GIF_AUTOPLAY;
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
});
});
describe('GuildHeader banner geometry', () => {
it('sizes the control header to the banner with the list below it', () => {
mount(createBannerGuild());
expect(lastFrame()).toEqual({
headerHeight: SIXTEEN_BY_NINE_SIDEBAR_BANNER_HEIGHT,
bannerHeight: SIXTEEN_BY_NINE_SIDEBAR_BANNER_HEIGHT,
listTop: SIXTEEN_BY_NINE_SIDEBAR_BANNER_HEIGHT,
});
});
it('keeps the list flush with the banner in every frame when the assignment lands after mount', () => {
mount(createBannerGuild());
const settled = frames.length;
act(() => {
publish(TARGETED_ASSIGNMENT);
});
const flipped = frames.slice(settled);
expect(flipped.length).toBeGreaterThan(0);
for (const frame of flipped) {
expect(frame.listTop).toBe(frame.bannerHeight);
}
expect(lastFrame()).toEqual({
headerHeight: HEADER_ROW_HEIGHT,
bannerHeight: SIXTEEN_BY_NINE_SIDEBAR_BANNER_HEIGHT,
listTop: SIXTEEN_BY_NINE_SIDEBAR_BANNER_HEIGHT,
});
});
it('keeps the list flush with the banner when the assignment is withdrawn mid-session', () => {
publish(TARGETED_ASSIGNMENT);
mount(createBannerGuild());
const settled = frames.length;
act(() => {
publish(undefined);
});
for (const frame of frames.slice(settled)) {
expect(frame.listTop).toBe(frame.bannerHeight);
}
expect(lastFrame()).toEqual({
headerHeight: SIXTEEN_BY_NINE_SIDEBAR_BANNER_HEIGHT,
bannerHeight: SIXTEEN_BY_NINE_SIDEBAR_BANNER_HEIGHT,
listTop: SIXTEEN_BY_NINE_SIDEBAR_BANNER_HEIGHT,
});
});
it('plays the animated control banner while the header is hovered', async () => {
runInAction(() => {
UserSettings.gifAutoPlay = false;
});
document.documentElement.classList.add(WINDOW_FOCUSED_CLASS);
mount(createBannerGuild());
const bannerImage = requireElement('[data-flx="app.guild-header.banner-image"]');
expect(bannerImage.style.backgroundImage).toContain('b4nn3r.webp');
const header = requireElement(`.${styles.headerContainer}`);
const matches = header.matches.bind(header);
vi.spyOn(header, 'matches').mockImplementation((selector: string) => selector === ':hover' || matches(selector));
await act(async () => {
header.dispatchEvent(new MouseEvent('mouseenter'));
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(bannerImage.style.backgroundImage).toContain('b4nn3r.gif');
});
it('shrinks the control banner again when the sidebar narrows', () => {
mount(createBannerGuild());
layout.sidebarWidth = 160;
act(() => {
window.dispatchEvent(new Event('resize'));
});
expect(lastFrame()).toEqual({headerHeight: 90, bannerHeight: 90, listTop: 90});
});
it('shrinks the collapsing banner again when the sidebar narrows', () => {
publish(TARGETED_ASSIGNMENT);
mount(createBannerGuild());
layout.sidebarWidth = 160;
act(() => {
window.dispatchEvent(new Event('resize'));
});
expect(lastFrame()).toEqual({headerHeight: HEADER_ROW_HEIGHT, bannerHeight: 90, listTop: 90});
});
});
@@ -59,7 +59,7 @@ export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: Gui
const isOpen = 'guild-header' in popouts; const isOpen = 'guild-header' in popouts;
const isMobile = MobileLayout.isMobileLayout(); const isMobile = MobileLayout.isMobileLayout();
const showIntegratedBanner = banner.imageUrl != null; const showIntegratedBanner = banner.imageUrl != null;
const collapsesOnScroll = showIntegratedBanner && banner.collapseEnabled; const collapsesOnScroll = showIntegratedBanner && banner.collapsible;
const onBanner = showIntegratedBanner && !banner.frosted; const onBanner = showIntegratedBanner && !banner.frosted;
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(event: React.MouseEvent) => { (event: React.MouseEvent) => {
@@ -73,7 +73,6 @@ export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: Gui
}, },
[guild], [guild],
); );
const headerButtonRef = useRef<HTMLDivElement | null>(null);
const guildNameRef = useRef<HTMLSpanElement | null>(null); const guildNameRef = useRef<HTMLSpanElement | null>(null);
const badgeVisible = resolveGuildBadgeVisible(guild.features); const badgeVisible = resolveGuildBadgeVisible(guild.features);
const bannerPlacement = resolveSkeletonBannerPlacement(banner.hasBanner, banner.isDetached); const bannerPlacement = resolveSkeletonBannerPlacement(banner.hasBanner, banner.isDetached);
@@ -106,7 +105,7 @@ export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: Gui
</motion.div> </motion.div>
)} )}
<NativeDragRegion <NativeDragRegion
ref={banner.headerRef} ref={collapsesOnScroll ? undefined : banner.hoverRef}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
className={clsx( className={clsx(
styles.headerContainer, styles.headerContainer,
@@ -146,7 +145,7 @@ export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: Gui
)} )}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
className={styles.headerContent} className={styles.headerContent}
triggerRef={headerButtonRef} triggerRef={banner.headerRowRef}
ariaLabel={i18n._(OPEN_COMMUNITY_MENU_FOR_DESCRIPTOR, {guildName: guild.name})} ariaLabel={i18n._(OPEN_COMMUNITY_MENU_FOR_DESCRIPTOR, {guildName: guild.name})}
data-flx="app.guild-header.header-content.context-menu" data-flx="app.guild-header.header-content.context-menu"
> >
@@ -29,7 +29,7 @@ interface GuildBannerMeasurements {
} }
export interface GuildBannerPresentation { export interface GuildBannerPresentation {
readonly headerRef: React.RefCallback<HTMLElement>; readonly headerRowRef: React.RefCallback<HTMLElement>;
readonly hoverRef: React.RefCallback<HTMLElement>; readonly hoverRef: React.RefCallback<HTMLElement>;
readonly imageUrl: string | null; readonly imageUrl: string | null;
readonly hasBanner: boolean; readonly hasBanner: boolean;
@@ -37,7 +37,7 @@ export interface GuildBannerPresentation {
readonly aspectRatio: number; readonly aspectRatio: number;
readonly bannerHeight: number; readonly bannerHeight: number;
readonly collapseDistance: number; readonly collapseDistance: number;
readonly collapseEnabled: boolean; readonly collapsible: boolean;
readonly centerCrop: boolean; readonly centerCrop: boolean;
readonly frosted: boolean; readonly frosted: boolean;
readonly collapsed: boolean; readonly collapsed: boolean;
@@ -57,9 +57,9 @@ export function useGuildBannerPresentation({
}): GuildBannerPresentation { }): GuildBannerPresentation {
const isMobile = MobileLayout.isMobileLayout(); const isMobile = MobileLayout.isMobileLayout();
const isDetached = guild.features.has(GuildFeatures.DETACHED_BANNER); const isDetached = guild.features.has(GuildFeatures.DETACHED_BANNER);
const [headerNode, setHeaderNode] = useState<HTMLElement | null>(null); const [headerRowNode, setHeaderRowNode] = useState<HTMLElement | null>(null);
const headerRef = useCallback((node: HTMLElement | null) => { const headerRowRef = useCallback((node: HTMLElement | null) => {
setHeaderNode(node); setHeaderRowNode(node);
}, []); }, []);
const [measurements, setMeasurements] = useState<GuildBannerMeasurements>(() => ({ const [measurements, setMeasurements] = useState<GuildBannerMeasurements>(() => ({
containerWidth: isMobile && typeof window !== 'undefined' ? window.innerWidth : SIDEBAR_WIDTH_DEFAULT, containerWidth: isMobile && typeof window !== 'undefined' ? window.innerWidth : SIDEBAR_WIDTH_DEFAULT,
@@ -67,12 +67,12 @@ export function useGuildBannerPresentation({
viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 0, viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 0,
})); }));
useLayoutEffect(() => { useLayoutEffect(() => {
if (headerNode == null) return; if (headerRowNode == null) return;
const measure = () => { const measure = () => {
setMeasurements((previous) => { setMeasurements((previous) => {
const next: GuildBannerMeasurements = { const next: GuildBannerMeasurements = {
containerWidth: headerNode.offsetWidth || previous.containerWidth, containerWidth: headerRowNode.offsetWidth || previous.containerWidth,
headerHeight: headerNode.offsetHeight || previous.headerHeight, headerHeight: headerRowNode.offsetHeight || previous.headerHeight,
viewportHeight: window.innerHeight, viewportHeight: window.innerHeight,
}; };
if ( if (
@@ -90,13 +90,13 @@ export function useGuildBannerPresentation({
const visualViewport = window.visualViewport; const visualViewport = window.visualViewport;
visualViewport?.addEventListener('resize', measure); visualViewport?.addEventListener('resize', measure);
const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null; const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
resizeObserver?.observe(headerNode); resizeObserver?.observe(headerRowNode);
return () => { return () => {
window.removeEventListener('resize', measure); window.removeEventListener('resize', measure);
visualViewport?.removeEventListener('resize', measure); visualViewport?.removeEventListener('resize', measure);
resizeObserver?.disconnect(); resizeObserver?.disconnect();
}; };
}, [headerNode]); }, [headerRowNode]);
const aspectRatio = useMemo(() => { const aspectRatio = useMemo(() => {
if (!guild.bannerWidth || !guild.bannerHeight) return GUILD_BANNER_DEFAULT_ASPECT_RATIO; if (!guild.bannerWidth || !guild.bannerHeight) return GUILD_BANNER_DEFAULT_ASPECT_RATIO;
return clampWideAssetAspectRatio(guild.bannerWidth / guild.bannerHeight) ?? GUILD_BANNER_DEFAULT_ASPECT_RATIO; return clampWideAssetAspectRatio(guild.bannerWidth / guild.bannerHeight) ?? GUILD_BANNER_DEFAULT_ASPECT_RATIO;
@@ -161,7 +161,7 @@ export function useGuildBannerPresentation({
kind: 'gif', kind: 'gif',
}); });
return { return {
headerRef, headerRowRef,
hoverRef, hoverRef,
imageUrl, imageUrl,
hasBanner, hasBanner,
@@ -169,7 +169,7 @@ export function useGuildBannerPresentation({
aspectRatio, aspectRatio,
bannerHeight: geometry.bannerHeight, bannerHeight: geometry.bannerHeight,
collapseDistance, collapseDistance,
collapseEnabled, collapsible: imageUrl != null && collapseDistance > 0,
centerCrop: isMobile && geometry.heightCapped, centerCrop: isMobile && geometry.heightCapped,
frosted, frosted,
collapsed, collapsed,