From 0532dd0440402078cf3bde25bda484f269bda589 Mon Sep 17 00:00:00 2001 From: Hampus Date: Tue, 15 Sep 2026 09:31:52 +0200 Subject: [PATCH] fix(app): stop guild banner jumps and restore hover animation (#2792) --- .../components/layout/ChannelListContent.tsx | 2 +- .../components/layout/GuildHeader.test.tsx | 288 ++++++++++++++++++ .../app/components/layout/GuildHeader.tsx | 7 +- .../app/hooks/useGuildBannerPresentation.ts | 24 +- 4 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 fluxer_app/src/features/app/components/layout/GuildHeader.test.tsx diff --git a/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx b/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx index 77f01abdc..1bdc50008 100644 --- a/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx +++ b/fluxer_app/src/features/app/components/layout/ChannelListContent.tsx @@ -186,7 +186,7 @@ export const ChannelListContent = observer(({guild, scrollY, banner}: ChannelLis }); const [activeDragItem, setActiveDragItem] = useState(null); const scrollerRef = useRef(null); - const showIntegratedBanner = banner.imageUrl != null && banner.collapseDistance > 0; + const showIntegratedBanner = banner.collapsible; const channelGroupsContainerRef = useRef(null); const stickToBottomRef = useRef(false); const pendingScrollTopRef = useRef(null); diff --git a/fluxer_app/src/features/app/components/layout/GuildHeader.test.tsx b/fluxer_app/src/features/app/components/layout/GuildHeader.test.tsx new file mode 100644 index 000000000..2eab317a1 --- /dev/null +++ b/fluxer_app/src/features/app/components/layout/GuildHeader.test.tsx @@ -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()), + hasImage: () => true, +})); +vi.mock('@app/features/user/utils/AvatarUtils', async (importOriginal) => ({ + ...(await importOriginal()), + 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 = []; +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(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('[data-flx="app.guild-header.banner-clip"]'); + frames.push({ + headerHeight: header.offsetHeight, + bannerHeight: clip?.offsetHeight ?? 0, + listTop: header.offsetHeight + banner.collapseDistance, + }); + }); + return ; +}); + +function mount(guild: Guild): void { + act(() => { + root.render(); + }); +} + +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}); + }); +}); diff --git a/fluxer_app/src/features/app/components/layout/GuildHeader.tsx b/fluxer_app/src/features/app/components/layout/GuildHeader.tsx index 2526501d6..5a761c556 100644 --- a/fluxer_app/src/features/app/components/layout/GuildHeader.tsx +++ b/fluxer_app/src/features/app/components/layout/GuildHeader.tsx @@ -59,7 +59,7 @@ export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: Gui const isOpen = 'guild-header' in popouts; const isMobile = MobileLayout.isMobileLayout(); const showIntegratedBanner = banner.imageUrl != null; - const collapsesOnScroll = showIntegratedBanner && banner.collapseEnabled; + const collapsesOnScroll = showIntegratedBanner && banner.collapsible; const onBanner = showIntegratedBanner && !banner.frosted; const handleContextMenu = useCallback( (event: React.MouseEvent) => { @@ -73,7 +73,6 @@ export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: Gui }, [guild], ); - const headerButtonRef = useRef(null); const guildNameRef = useRef(null); const badgeVisible = resolveGuildBadgeVisible(guild.features); const bannerPlacement = resolveSkeletonBannerPlacement(banner.hasBanner, banner.isDetached); @@ -106,7 +105,7 @@ export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: Gui )} diff --git a/fluxer_app/src/features/app/hooks/useGuildBannerPresentation.ts b/fluxer_app/src/features/app/hooks/useGuildBannerPresentation.ts index 16810d581..ebab665a3 100644 --- a/fluxer_app/src/features/app/hooks/useGuildBannerPresentation.ts +++ b/fluxer_app/src/features/app/hooks/useGuildBannerPresentation.ts @@ -29,7 +29,7 @@ interface GuildBannerMeasurements { } export interface GuildBannerPresentation { - readonly headerRef: React.RefCallback; + readonly headerRowRef: React.RefCallback; readonly hoverRef: React.RefCallback; readonly imageUrl: string | null; readonly hasBanner: boolean; @@ -37,7 +37,7 @@ export interface GuildBannerPresentation { readonly aspectRatio: number; readonly bannerHeight: number; readonly collapseDistance: number; - readonly collapseEnabled: boolean; + readonly collapsible: boolean; readonly centerCrop: boolean; readonly frosted: boolean; readonly collapsed: boolean; @@ -57,9 +57,9 @@ export function useGuildBannerPresentation({ }): GuildBannerPresentation { const isMobile = MobileLayout.isMobileLayout(); const isDetached = guild.features.has(GuildFeatures.DETACHED_BANNER); - const [headerNode, setHeaderNode] = useState(null); - const headerRef = useCallback((node: HTMLElement | null) => { - setHeaderNode(node); + const [headerRowNode, setHeaderRowNode] = useState(null); + const headerRowRef = useCallback((node: HTMLElement | null) => { + setHeaderRowNode(node); }, []); const [measurements, setMeasurements] = useState(() => ({ containerWidth: isMobile && typeof window !== 'undefined' ? window.innerWidth : SIDEBAR_WIDTH_DEFAULT, @@ -67,12 +67,12 @@ export function useGuildBannerPresentation({ viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 0, })); useLayoutEffect(() => { - if (headerNode == null) return; + if (headerRowNode == null) return; const measure = () => { setMeasurements((previous) => { const next: GuildBannerMeasurements = { - containerWidth: headerNode.offsetWidth || previous.containerWidth, - headerHeight: headerNode.offsetHeight || previous.headerHeight, + containerWidth: headerRowNode.offsetWidth || previous.containerWidth, + headerHeight: headerRowNode.offsetHeight || previous.headerHeight, viewportHeight: window.innerHeight, }; if ( @@ -90,13 +90,13 @@ export function useGuildBannerPresentation({ const visualViewport = window.visualViewport; visualViewport?.addEventListener('resize', measure); const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null; - resizeObserver?.observe(headerNode); + resizeObserver?.observe(headerRowNode); return () => { window.removeEventListener('resize', measure); visualViewport?.removeEventListener('resize', measure); resizeObserver?.disconnect(); }; - }, [headerNode]); + }, [headerRowNode]); const aspectRatio = useMemo(() => { if (!guild.bannerWidth || !guild.bannerHeight) return 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', }); return { - headerRef, + headerRowRef, hoverRef, imageUrl, hasBanner, @@ -169,7 +169,7 @@ export function useGuildBannerPresentation({ aspectRatio, bannerHeight: geometry.bannerHeight, collapseDistance, - collapseEnabled, + collapsible: imageUrl != null && collapseDistance > 0, centerCrop: isMobile && geometry.heightCapped, frosted, collapsed,