feat(app): gate a collapsing guild header behind an experiment (#2779)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 21:15:31 +02:00
1 parent 550e6b05a1
commit d0c6146429
29 files changed
+1449 -115

No files matched your search

@@ -5,6 +5,15 @@
overflow-anchor: none;
}
.channelListScrollerOverBanner {
background-color: transparent;
}
.bannerSpacer {
flex: 0 0 auto;
width: 100%;
}
.channelListScrollerWrapper {
--floating-unread-indicator-bottom-inset: calc(
var(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +
@@ -33,6 +33,7 @@ import {
type RememberedSkeletonGuildChannelRow,
reportSkeletonGuildChannelList,
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
import type {GuildBannerPresentation} from '@app/features/app/hooks/useGuildBannerPresentation';
import {useRovingFocusList} from '@app/features/app/hooks/useRovingFocusList';
import {
measureSkeletonTextWidthPx,
@@ -72,7 +73,7 @@ import {UsersIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type {MotionValue} from 'motion';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
import {useDragLayer} from 'react-dnd';
const CATEGORY_FULL_DESCRIPTOR = msg({
@@ -164,7 +165,12 @@ function createRememberedChannelGroups(
};
});
}
export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scrollY: MotionValue<number>}) => {
interface ChannelListContentProps {
readonly guild: Guild;
readonly scrollY: MotionValue<number>;
readonly banner: GuildBannerPresentation;
}
export const ChannelListContent = observer(({guild, scrollY, banner}: ChannelListContentProps) => {
const {i18n} = useLingui();
const channels = Channels.getGuildChannels(guild.id);
const location = useLocation();
@@ -180,6 +186,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
});
const [activeDragItem, setActiveDragItem] = useState<DragItem | null>(null);
const scrollerRef = useRef<ScrollerHandle>(null);
const showIntegratedBanner = banner.imageUrl != null && banner.collapseDistance > 0;
const channelGroupsContainerRef = useRef<HTMLDivElement | null>(null);
const stickToBottomRef = useRef(false);
const pendingScrollTopRef = useRef<number | null>(null);
@@ -313,7 +320,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
scrollerRef.current.jumpToEndEdge({animate: false});
}
}, []);
useEffect(() => {
useLayoutEffect(() => {
const guildDimensions = Dimension.guildDimensionsFor(guild.id);
if (guildDimensions.scrollTo) {
const element = document.querySelector(`[data-channel-id="${guildDimensions.scrollTo}"]`);
@@ -324,7 +331,8 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
} else if (guildDimensions.scrollTop && guildDimensions.scrollTop > 0 && scrollerRef.current) {
scrollerRef.current.scrollTo({to: guildDimensions.scrollTop, animate: false});
}
}, [guild.id]);
scrollY.set(scrollerRef.current?.getViewportElement()?.scrollTop ?? 0);
}, [guild.id, scrollY]);
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
@@ -517,7 +525,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
>
<Scroller
ref={scrollerRef}
className={styles.channelListScroller}
className={clsx(styles.channelListScroller, showIntegratedBanner && styles.channelListScrollerOverBanner)}
onScroll={handleScroll}
onResize={handleResize}
key={guild.id}
@@ -531,6 +539,14 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
ref={channelListNavigationRef}
data-flx="app.channel-list-content.navigation-container.context-menu"
>
{showIntegratedBanner && (
<div
ref={banner.hoverRef}
className={styles.bannerSpacer}
style={{height: banner.collapseDistance}}
data-flx="app.channel-list-content.banner-spacer"
/>
)}
<GuildDetachedBanner guild={guild} data-flx="app.channel-list-content.guild-detached-banner" />
<div className={styles.topDropZone} data-flx="app.channel-list-content.top-drop-zone">
<NullSpaceDropIndicator
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
GUILD_BANNER_HEADER_GLASS_DISTANCE,
GUILD_BANNER_HEADER_GLASS_MAX_OPACITY,
isGuildBannerHeaderFrosted,
resolveGuildBannerCollapseProgress,
resolveGuildBannerGeometry,
resolveGuildBannerHeaderGlassOpacity,
} from '@app/features/app/components/layout/GuildBannerCollapse';
import {describe, expect, it} from 'vitest';
describe('resolveGuildBannerGeometry', () => {
it('sizes the banner from the sidebar width and the banner aspect ratio', () => {
const geometry = resolveGuildBannerGeometry({
containerWidth: 320,
viewportHeight: 1000,
headerHeight: 56,
aspectRatio: 16 / 9,
});
expect(geometry.bannerHeight).toBeCloseTo(180);
expect(geometry.collapseDistance).toBeCloseTo(124);
expect(geometry.heightCapped).toBe(false);
});
it('caps the banner at a fraction of the viewport height', () => {
const geometry = resolveGuildBannerGeometry({
containerWidth: 480,
viewportHeight: 500,
headerHeight: 56,
aspectRatio: 1,
});
expect(geometry.bannerHeight).toBeCloseTo(150);
expect(geometry.collapseDistance).toBeCloseTo(94);
expect(geometry.heightCapped).toBe(true);
});
it('never collapses below the header height', () => {
const geometry = resolveGuildBannerGeometry({
containerWidth: 200,
viewportHeight: 120,
headerHeight: 56,
aspectRatio: 21 / 9,
});
expect(geometry.bannerHeight).toBe(56);
expect(geometry.collapseDistance).toBe(0);
});
it('reports no banner height before the header is measured', () => {
const geometry = resolveGuildBannerGeometry({
containerWidth: 0,
viewportHeight: 0,
headerHeight: 56,
aspectRatio: 16 / 9,
});
expect(geometry.bannerHeight).toBe(56);
expect(geometry.collapseDistance).toBe(0);
});
});
describe('resolveGuildBannerCollapseProgress', () => {
it('tracks scroll position across the collapse distance', () => {
expect(resolveGuildBannerCollapseProgress(0, 80)).toBe(0);
expect(resolveGuildBannerCollapseProgress(40, 80)).toBe(0.5);
expect(resolveGuildBannerCollapseProgress(80, 80)).toBe(1);
});
it('clamps overscroll and bounce past both ends', () => {
expect(resolveGuildBannerCollapseProgress(-30, 80)).toBe(0);
expect(resolveGuildBannerCollapseProgress(4000, 80)).toBe(1);
});
it('stays expanded when there is nothing to collapse', () => {
expect(resolveGuildBannerCollapseProgress(200, 0)).toBe(0);
});
});
describe('resolveGuildBannerHeaderGlassOpacity', () => {
it('ramps in over a fixed scroll distance regardless of banner height', () => {
expect(resolveGuildBannerHeaderGlassOpacity(0)).toBe(0);
expect(resolveGuildBannerHeaderGlassOpacity(GUILD_BANNER_HEADER_GLASS_DISTANCE / 2)).toBeCloseTo(
GUILD_BANNER_HEADER_GLASS_MAX_OPACITY / 2,
);
expect(resolveGuildBannerHeaderGlassOpacity(GUILD_BANNER_HEADER_GLASS_DISTANCE)).toBeCloseTo(
GUILD_BANNER_HEADER_GLASS_MAX_OPACITY,
);
});
it('never exceeds the opaque header background', () => {
expect(resolveGuildBannerHeaderGlassOpacity(5000)).toBe(GUILD_BANNER_HEADER_GLASS_MAX_OPACITY);
});
it('frosts the header once the glass is fully ramped in', () => {
expect(isGuildBannerHeaderFrosted(0)).toBe(false);
expect(isGuildBannerHeaderFrosted(GUILD_BANNER_HEADER_GLASS_DISTANCE - 1)).toBe(false);
expect(isGuildBannerHeaderFrosted(GUILD_BANNER_HEADER_GLASS_DISTANCE)).toBe(true);
});
});
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const GUILD_BANNER_DEFAULT_ASPECT_RATIO = 16 / 9;
export const GUILD_BANNER_MAX_VIEWPORT_HEIGHT_FRACTION = 0.3;
export const GUILD_BANNER_HEADER_GLASS_MAX_OPACITY = 0.72;
export const GUILD_BANNER_HEADER_GLASS_DISTANCE = 16;
export interface GuildBannerMetrics {
readonly containerWidth: number;
readonly viewportHeight: number;
readonly headerHeight: number;
readonly aspectRatio: number;
}
export interface GuildBannerGeometry {
readonly bannerHeight: number;
readonly collapseDistance: number;
readonly heightCapped: boolean;
}
export function resolveGuildBannerGeometry({
containerWidth,
viewportHeight,
headerHeight,
aspectRatio,
}: GuildBannerMetrics): GuildBannerGeometry {
if (containerWidth <= 0 || aspectRatio <= 0) {
return {bannerHeight: headerHeight, collapseDistance: 0, heightCapped: false};
}
const idealHeight = containerWidth / aspectRatio;
const viewportCap =
viewportHeight > 0 ? viewportHeight * GUILD_BANNER_MAX_VIEWPORT_HEIGHT_FRACTION : Number.POSITIVE_INFINITY;
const bannerHeight = Math.max(headerHeight, Math.min(idealHeight, viewportCap));
return {
bannerHeight,
collapseDistance: Math.max(bannerHeight - headerHeight, 0),
heightCapped: idealHeight > viewportCap,
};
}
export function resolveGuildBannerCollapseProgress(scrollTop: number, collapseDistance: number): number {
if (collapseDistance <= 0) {
return 0;
}
return Math.min(1, Math.max(0, scrollTop / collapseDistance));
}
export function resolveGuildBannerHeaderGlassOpacity(scrollTop: number): number {
if (scrollTop <= 0) {
return 0;
}
const ramp = (scrollTop / GUILD_BANNER_HEADER_GLASS_DISTANCE) * GUILD_BANNER_HEADER_GLASS_MAX_OPACITY;
return Math.min(GUILD_BANNER_HEADER_GLASS_MAX_OPACITY, ramp);
}
export function isGuildBannerHeaderFrosted(scrollTop: number): boolean {
return resolveGuildBannerHeaderGlassOpacity(scrollTop) >= GUILD_BANNER_HEADER_GLASS_MAX_OPACITY;
}
@@ -1,14 +1,17 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.headerWrapper {
position: relative;
min-width: 0;
}
.headerContainer {
position: relative;
z-index: 2;
display: flex;
align-items: flex-start;
overflow: hidden;
height: var(--layout-header-height);
min-height: var(--layout-header-height);
min-width: 0;
border-bottom: 0.0625rem solid var(--user-area-divider-color);
@@ -24,25 +27,54 @@
background-color: var(--background-modifier-hover);
}
.headerContainerBanner {
background-color: transparent;
}
.headerContainerCollapsing {
border-bottom-color: transparent;
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.headerContainerNoBanner:hover {
background-color: var(--background-modifier-hover);
}
.bannerBackground {
.bannerClip {
position: absolute;
inset: 0;
top: 0;
left: 0;
right: 0;
overflow: hidden;
pointer-events: none;
}
.bannerImage {
position: absolute;
top: 0;
left: 0;
right: 0;
background-size: cover;
background-position: top center;
background-repeat: no-repeat;
background-color: var(--background-secondary);
pointer-events: none;
}
.bannerBackgroundCentered {
.bannerImageCentered {
background-position: center;
}
.headerGlass {
position: absolute;
inset: 0;
box-sizing: border-box;
background-color: var(--background-secondary);
border-bottom: 0.0625rem solid var(--user-area-divider-color);
backdrop-filter: blur(1.25rem);
-webkit-backdrop-filter: blur(1.25rem);
pointer-events: none;
}
.bannerGradient {
position: absolute;
left: 0;
@@ -78,6 +110,9 @@
height: 1rem;
width: 1rem;
flex-shrink: 0;
transition:
color var(--transition-normal),
filter var(--transition-normal);
}
.verifiedIconDefault {
@@ -92,6 +127,9 @@
}
.guildName {
transition:
color var(--transition-normal),
text-shadow var(--transition-normal);
flex: 1;
min-width: 0;
font-weight: 600;
@@ -118,7 +156,10 @@
height: 1rem;
width: 1rem;
flex-shrink: 0;
transition: transform var(--transition-fast);
transition:
transform var(--transition-fast),
color var(--transition-normal),
filter var(--transition-normal);
}
.caretIconDefault {
@@ -141,6 +182,9 @@
height: 1.5rem;
width: 1.5rem;
flex-shrink: 0;
transition:
color var(--transition-normal),
filter var(--transition-normal);
}
.dotsIconDefault {
@@ -7,24 +7,19 @@ import {
reportSkeletonGuildPresentation,
SkeletonGuildBannerPlacement,
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
import {useAnimatedImageUrl} from '@app/features/app/hooks/useAnimatedImageUrl';
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
import type {GuildBannerPresentation} from '@app/features/app/hooks/useGuildBannerPresentation';
import {
measureSkeletonTextWidthPx,
useSkeletonLayoutReport,
} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
import {clampWideAssetAspectRatio} from '@app/features/expressions/utils/AssetImageGeometry';
import {GuildHeaderBottomSheet} from '@app/features/guild/components/bottomsheets/GuildHeaderBottomSheet';
import {GuildBadge} from '@app/features/guild/components/GuildBadge';
import {GuildHeaderPopout} from '@app/features/guild/components/popouts/GuildHeaderPopout';
import type {Guild} from '@app/features/guild/models/Guild';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {GuildContextMenu} from '@app/features/ui/action_menu/GuildContextMenu';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import Popout from '@app/features/ui/state/Popout';
import {SIDEBAR_WIDTH_DEFAULT} from '@app/features/ui/state/SidebarWidth';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -33,15 +28,12 @@ import {clsx} from 'clsx';
import {motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react';
import {useCallback, useRef} from 'react';
const OPEN_COMMUNITY_MENU_FOR_DESCRIPTOR = msg({
message: 'Open community menu for {guildName}',
comment: 'Short label in the app layout guild header. Preserve {guildName}; it is inserted by code.',
});
const HEADER_MIN_HEIGHT = 56;
const DEFAULT_BANNER_ASPECT_RATIO = 16 / 9;
const MAX_VIEWPORT_HEIGHT_FRACTION = 0.3;
const GUILD_BADGE_FEATURES: ReadonlyArray<string> = [
GuildFeatures.VERIFIED,
GuildFeatures.PARTNERED,
@@ -61,71 +53,14 @@ function resolveSkeletonBannerPlacement(hasBanner: boolean, isDetachedBanner: bo
}
return SkeletonGuildBannerPlacement.INTEGRATED;
}
export const GuildHeader = observer(({guild}: {guild: Guild}) => {
export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: GuildBannerPresentation}) => {
const {i18n} = useLingui();
const {popouts} = Popout;
const isOpen = 'guild-header' in popouts;
const isMobile = MobileLayout.isMobileLayout();
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
const staticBannerURL = useMemo(
() => AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, false) || null,
[guild.banner, guild.id],
);
const animatedBannerURL = useMemo(
() => (isDetachedBanner ? null : AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true) || null),
[guild.banner, guild.id, isDetachedBanner],
);
const {hoverRef: bannerHoverRef, imageUrl: bannerURL} = useAnimatedImageUrl({
staticUrl: staticBannerURL,
animatedUrl: animatedBannerURL,
kind: 'gif',
});
const showIntegratedBanner = Boolean(bannerURL && !isDetachedBanner);
const bannerAspectRatio =
guild.bannerWidth && guild.bannerHeight
? (clampWideAssetAspectRatio(guild.bannerWidth / guild.bannerHeight) ?? DEFAULT_BANNER_ASPECT_RATIO)
: DEFAULT_BANNER_ASPECT_RATIO;
const headerContainerRef = useRef<HTMLElement | null>(null);
const mergedHeaderContainerRef = useMergeRefs([headerContainerRef, bannerHoverRef]);
const [containerWidth, setContainerWidth] = useState<number>(() =>
isMobile && typeof window !== 'undefined' ? window.innerWidth : SIDEBAR_WIDTH_DEFAULT,
);
const [viewportHeight, setViewportHeight] = useState<number>(() =>
typeof window !== 'undefined' ? window.innerHeight : 0,
);
useLayoutEffect(() => {
const measure = () => {
const width = headerContainerRef.current?.clientWidth;
if (width) setContainerWidth((prev) => (prev === width ? prev : width));
setViewportHeight((prev) => (prev === window.innerHeight ? prev : window.innerHeight));
};
measure();
window.addEventListener('resize', measure);
const visualViewport = typeof window !== 'undefined' ? window.visualViewport : null;
visualViewport?.addEventListener('resize', measure);
const container = headerContainerRef.current;
const resizeObserver = container && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
if (container && resizeObserver) {
resizeObserver.observe(container);
}
return () => {
window.removeEventListener('resize', measure);
visualViewport?.removeEventListener('resize', measure);
resizeObserver?.disconnect();
};
}, []);
const {bannerMaxHeight, centerCrop} = (() => {
if (!showIntegratedBanner || !bannerURL || !containerWidth) {
return {bannerMaxHeight: HEADER_MIN_HEIGHT, centerCrop: false};
}
const idealHeight = containerWidth / bannerAspectRatio;
const viewportCap = viewportHeight * MAX_VIEWPORT_HEIGHT_FRACTION;
const isCapped = idealHeight > viewportCap;
return {
bannerMaxHeight: Math.max(HEADER_MIN_HEIGHT, Math.min(idealHeight, viewportCap)),
centerCrop: isMobile && isCapped,
};
})();
const showIntegratedBanner = banner.imageUrl != null;
const collapsesOnScroll = showIntegratedBanner && banner.collapseEnabled;
const onBanner = showIntegratedBanner && !banner.frosted;
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
@@ -141,37 +76,61 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
const headerButtonRef = useRef<HTMLDivElement | null>(null);
const guildNameRef = useRef<HTMLSpanElement | null>(null);
const badgeVisible = resolveGuildBadgeVisible(guild.features);
const bannerPlacement = resolveSkeletonBannerPlacement(Boolean(bannerURL), isDetachedBanner);
const bannerPlacement = resolveSkeletonBannerPlacement(banner.hasBanner, banner.isDetached);
useSkeletonLayoutReport(() => {
reportSkeletonGuildPresentation(guild.id, {
headerNameWidthPx: measureSkeletonTextWidthPx(guildNameRef.current),
badgeVisible,
bannerPlacement,
bannerAspectRatio,
bannerAspectRatio: banner.aspectRatio,
});
}, `${guild.id}:${guild.name}:${badgeVisible}:${bannerPlacement}:${bannerAspectRatio}:${isMobile}`);
}, `${guild.id}:${guild.name}:${badgeVisible}:${bannerPlacement}:${banner.aspectRatio}:${isMobile}`);
return (
<div className={styles.headerWrapper} data-flx="app.guild-header.header-wrapper">
{showIntegratedBanner && (
<motion.div
className={styles.bannerClip}
style={{height: banner.bannerHeight, y: banner.clipShift, opacity: banner.imageOpacity}}
aria-hidden
data-flx="app.guild-header.banner-clip"
>
<motion.div
className={clsx(styles.bannerImage, banner.centerCrop && styles.bannerImageCentered)}
style={{
height: banner.bannerHeight,
y: banner.imageShift,
backgroundImage: `url(${banner.imageUrl})`,
}}
data-flx="app.guild-header.banner-image"
/>
</motion.div>
)}
<NativeDragRegion
as={motion.div}
ref={mergedHeaderContainerRef}
ref={banner.headerRef}
onContextMenu={handleContextMenu}
className={clsx(
styles.headerContainer,
!showIntegratedBanner && styles.headerContainerNoBanner,
showIntegratedBanner ? styles.headerContainerBanner : styles.headerContainerNoBanner,
collapsesOnScroll && styles.headerContainerCollapsing,
!showIntegratedBanner && isOpen && styles.headerContainerActive,
)}
style={{height: showIntegratedBanner ? bannerMaxHeight : remFromPx(HEADER_MIN_HEIGHT)}}
style={showIntegratedBanner && !collapsesOnScroll ? {height: banner.bannerHeight} : undefined}
data-flx="app.guild-header.header-container.context-menu"
>
{showIntegratedBanner && (
<>
<div
className={clsx(styles.bannerBackground, centerCrop && styles.bannerBackgroundCentered)}
style={{backgroundImage: `url(${bannerURL})`}}
data-flx="app.guild-header.banner-background"
{collapsesOnScroll && (
<motion.div
className={styles.headerGlass}
style={{opacity: banner.glassOpacity}}
data-flx="app.guild-header.header-glass"
/>
)}
<motion.div
className={styles.bannerGradient}
style={{opacity: banner.scrimOpacity}}
data-flx="app.guild-header.banner-gradient"
/>
<div className={styles.bannerGradient} data-flx="app.guild-header.banner-gradient" />
</>
)}
<GuildHeaderShell
@@ -195,13 +154,13 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
<>
<GuildBadge
features={guild.features}
variant={showIntegratedBanner ? 'banner' : 'default'}
variant={onBanner ? 'banner' : 'default'}
tooltipPosition="bottom"
data-flx="app.guild-header.guild-badge"
/>
<span
ref={guildNameRef}
className={showIntegratedBanner ? styles.guildNameWithBanner : styles.guildNameDefault}
className={onBanner ? styles.guildNameWithBanner : styles.guildNameDefault}
data-flx="app.guild-header.guild-name"
>
{guild.name}
@@ -209,14 +168,14 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
{isMobile ? (
<DotsThreeIcon
weight="bold"
className={showIntegratedBanner ? styles.dotsIconWithBanner : styles.dotsIconDefault}
className={onBanner ? styles.dotsIconWithBanner : styles.dotsIconDefault}
data-flx="app.guild-header.dots-icon"
/>
) : (
<CaretDownIcon
weight="bold"
className={clsx(
showIntegratedBanner ? styles.caretIconWithBanner : styles.caretIconDefault,
onBanner ? styles.caretIconWithBanner : styles.caretIconDefault,
isOpen && styles.caretIconOpen,
)}
data-flx="app.guild-header.caret-icon"
@@ -4,6 +4,7 @@ import {TopNagbarContext} from '@app/features/app/components/layout/app_layout/T
import {ChannelListContent} from '@app/features/app/components/layout/ChannelListContent';
import {GuildHeader} from '@app/features/app/components/layout/GuildHeader';
import {GuildSidebar} from '@app/features/app/components/layout/GuildSidebar';
import {useGuildBannerPresentation} from '@app/features/app/hooks/useGuildBannerPresentation';
import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform';
import KeybindManager from '@app/features/app/keybindings/KeybindManager';
import Channels from '@app/features/channel/state/Channels';
@@ -12,18 +13,16 @@ import * as UserGuildSettingsCommands from '@app/features/user/commands/UserGuil
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {useMotionValue} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import {useContext, useEffect, useMemo} from 'react';
import {useContext, useMemo} from 'react';
import {useHotkeys} from 'react-hotkeys-hook';
export const GuildNavbar = observer(({guild}: {guild: Guild}) => {
const scrollY = useMotionValue(0);
const banner = useGuildBannerPresentation({guild, scrollY});
const {isNative, isWindows, isLinux} = useNativePlatform();
const topNagbarCount = useContext(TopNagbarContext);
const hasTopNagbar = topNagbarCount > 0;
const shouldRoundTopLeft = isNative && (isWindows || isLinux) && !hasTopNagbar;
useEffect(() => {
scrollY.set(0);
}, [guild.id, scrollY]);
const channels = Channels.getGuildChannels(guild.id);
const categoryIds = useMemo(() => {
return channels.filter((ch) => ch.type === ChannelTypes.GUILD_CATEGORY).map((ch) => ch.id);
@@ -46,8 +45,15 @@ export const GuildNavbar = observer(({guild}: {guild: Guild}) => {
return (
<GuildSidebar
roundTopLeft={shouldRoundTopLeft}
header={<GuildHeader guild={guild} data-flx="app.guild-navbar.guild-header" />}
content={<ChannelListContent guild={guild} scrollY={scrollY} data-flx="app.guild-navbar.channel-list-content" />}
header={<GuildHeader guild={guild} banner={banner} data-flx="app.guild-navbar.guild-header" />}
content={
<ChannelListContent
guild={guild}
scrollY={scrollY}
banner={banner}
data-flx="app.guild-navbar.channel-list-content"
/>
}
data-flx="app.guild-navbar.guild-sidebar"
/>
);
@@ -0,0 +1,182 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
GUILD_BANNER_DEFAULT_ASPECT_RATIO,
GUILD_BANNER_HEADER_GLASS_MAX_OPACITY,
isGuildBannerHeaderFrosted,
resolveGuildBannerCollapseProgress,
resolveGuildBannerGeometry,
resolveGuildBannerHeaderGlassOpacity,
} from '@app/features/app/components/layout/GuildBannerCollapse';
import {useAnimatedImageUrl} from '@app/features/app/hooks/useAnimatedImageUrl';
import GuildHeaderCollapseRollout from '@app/features/app/state/GuildHeaderCollapseRollout';
import {clampWideAssetAspectRatio} from '@app/features/expressions/utils/AssetImageGeometry';
import type {Guild} from '@app/features/guild/models/Guild';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import {SIDEBAR_WIDTH_DEFAULT} from '@app/features/ui/state/SidebarWidth';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {type MotionValue, useMotionValue, useMotionValueEvent, useTransform} from 'framer-motion';
import type React from 'react';
import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react';
const HEADER_FALLBACK_HEIGHT = 56;
interface GuildBannerMeasurements {
readonly containerWidth: number;
readonly headerHeight: number;
readonly viewportHeight: number;
}
export interface GuildBannerPresentation {
readonly headerRef: React.RefCallback<HTMLElement>;
readonly hoverRef: React.RefCallback<HTMLElement>;
readonly imageUrl: string | null;
readonly hasBanner: boolean;
readonly isDetached: boolean;
readonly aspectRatio: number;
readonly bannerHeight: number;
readonly collapseDistance: number;
readonly collapseEnabled: boolean;
readonly centerCrop: boolean;
readonly frosted: boolean;
readonly collapsed: boolean;
readonly clipShift: MotionValue<number>;
readonly imageShift: MotionValue<number>;
readonly imageOpacity: MotionValue<number>;
readonly glassOpacity: MotionValue<number>;
readonly scrimOpacity: MotionValue<number>;
}
export function useGuildBannerPresentation({
guild,
scrollY,
}: {
guild: Guild;
scrollY: MotionValue<number>;
}): GuildBannerPresentation {
const isMobile = MobileLayout.isMobileLayout();
const isDetached = guild.features.has(GuildFeatures.DETACHED_BANNER);
const [headerNode, setHeaderNode] = useState<HTMLElement | null>(null);
const headerRef = useCallback((node: HTMLElement | null) => {
setHeaderNode(node);
}, []);
const [measurements, setMeasurements] = useState<GuildBannerMeasurements>(() => ({
containerWidth: isMobile && typeof window !== 'undefined' ? window.innerWidth : SIDEBAR_WIDTH_DEFAULT,
headerHeight: HEADER_FALLBACK_HEIGHT,
viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 0,
}));
useLayoutEffect(() => {
if (headerNode == null) return;
const measure = () => {
setMeasurements((previous) => {
const next: GuildBannerMeasurements = {
containerWidth: headerNode.offsetWidth || previous.containerWidth,
headerHeight: headerNode.offsetHeight || previous.headerHeight,
viewportHeight: window.innerHeight,
};
if (
next.containerWidth === previous.containerWidth &&
next.headerHeight === previous.headerHeight &&
next.viewportHeight === previous.viewportHeight
) {
return previous;
}
return next;
});
};
measure();
window.addEventListener('resize', measure);
const visualViewport = window.visualViewport;
visualViewport?.addEventListener('resize', measure);
const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
resizeObserver?.observe(headerNode);
return () => {
window.removeEventListener('resize', measure);
visualViewport?.removeEventListener('resize', measure);
resizeObserver?.disconnect();
};
}, [headerNode]);
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;
}, [guild.bannerHeight, guild.bannerWidth]);
const staticBannerUrl = useMemo(
() => AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, false) || null,
[guild.banner, guild.id],
);
const hasBanner = staticBannerUrl != null;
const showsIntegratedBanner = hasBanner && !isDetached;
const geometry = useMemo(() => {
if (!showsIntegratedBanner) {
return {bannerHeight: measurements.headerHeight, collapseDistance: 0, heightCapped: false};
}
return resolveGuildBannerGeometry({
containerWidth: measurements.containerWidth,
viewportHeight: measurements.viewportHeight,
headerHeight: measurements.headerHeight,
aspectRatio,
});
}, [aspectRatio, measurements, showsIntegratedBanner]);
const collapseEnabled = showsIntegratedBanner && GuildHeaderCollapseRollout.collapsesOnScroll;
const collapseDistance = collapseEnabled ? geometry.collapseDistance : 0;
const progress = useMotionValue(0);
const clipShift = useMotionValue(0);
const glassOpacity = useMotionValue(0);
const imageShift = useTransform(clipShift, (value) => -value);
const imageOpacity = useTransform(progress, [0, 1], [1, 0]);
const scrimOpacity = useTransform(glassOpacity, (value) => 1 - value / GUILD_BANNER_HEADER_GLASS_MAX_OPACITY);
const [frosted, setFrosted] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const frostedRef = useRef(false);
const collapsedRef = useRef(false);
const syncToScroll = useCallback(() => {
const scrollTop = scrollY.get();
const nextProgress = resolveGuildBannerCollapseProgress(scrollTop, collapseDistance);
progress.set(nextProgress);
clipShift.set(-nextProgress * collapseDistance);
glassOpacity.set(collapseDistance > 0 ? resolveGuildBannerHeaderGlassOpacity(scrollTop) : 0);
const nextFrosted = collapseDistance > 0 && isGuildBannerHeaderFrosted(scrollTop);
if (frostedRef.current !== nextFrosted) {
frostedRef.current = nextFrosted;
setFrosted(nextFrosted);
}
const nextCollapsed = collapseDistance > 0 && nextProgress >= 1;
if (collapsedRef.current !== nextCollapsed) {
collapsedRef.current = nextCollapsed;
setCollapsed(nextCollapsed);
}
}, [clipShift, collapseDistance, glassOpacity, progress, scrollY]);
useMotionValueEvent(scrollY, 'change', syncToScroll);
useLayoutEffect(() => {
syncToScroll();
}, [syncToScroll]);
const animatedBannerUrl = useMemo(() => {
if (!showsIntegratedBanner || collapsed) return null;
return AvatarUtils.getGuildBannerURL({id: guild.id, banner: guild.banner}, true) || null;
}, [collapsed, guild.banner, guild.id, showsIntegratedBanner]);
const {hoverRef, imageUrl} = useAnimatedImageUrl({
staticUrl: showsIntegratedBanner ? staticBannerUrl : null,
animatedUrl: animatedBannerUrl,
kind: 'gif',
});
return {
headerRef,
hoverRef,
imageUrl,
hasBanner,
isDetached,
aspectRatio,
bannerHeight: geometry.bannerHeight,
collapseDistance,
collapseEnabled,
centerCrop: isMobile && geometry.heightCapped,
frosted,
collapsed,
clipShift,
imageShift,
imageOpacity,
glassOpacity,
scrimOpacity,
};
}
@@ -0,0 +1,95 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type ExperimentAssignmentsResponse,
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
type GuildHeaderCollapseAssignmentResponse,
INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT,
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
import {runInAction} from 'mobx';
import {afterEach, 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()},
}));
const {ExperimentAssignments} = await import('@app/features/experiment/state/ExperimentAssignments');
const {GuildHeaderCollapseRollout} = await import('@app/features/app/state/GuildHeaderCollapseRollout');
const TARGETED_ASSIGNMENT: GuildHeaderCollapseAssignmentResponse = {
enabled: true,
config_version: 3,
user_targeted: true,
source: 'canary',
};
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;
});
}
afterEach(() => {
runInAction(() => {
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
});
});
describe('GuildHeaderCollapseRollout', () => {
it('reads the inert assignment before any response has landed', () => {
expect(GuildHeaderCollapseRollout.assignment).toEqual(INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT);
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(false);
});
it('treats a missing experiment key as off', () => {
publish(undefined);
expect(GuildHeaderCollapseRollout.assignment).toEqual(INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT);
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(false);
});
it('collapses on scroll for a targeted account', () => {
publish(TARGETED_ASSIGNMENT);
expect(GuildHeaderCollapseRollout.assignment).toEqual(TARGETED_ASSIGNMENT);
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(true);
});
it('keeps the old header for an account outside the rollout', () => {
publish({...TARGETED_ASSIGNMENT, user_targeted: false, source: null});
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(false);
});
it('keeps the old header while the rollout is disabled instance-wide', () => {
publish({...TARGETED_ASSIGNMENT, enabled: false});
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(false);
});
it('follows a later poll that turns the rollout off', () => {
publish(TARGETED_ASSIGNMENT);
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(true);
publish({...TARGETED_ASSIGNMENT, enabled: false, user_targeted: false, source: null});
expect(GuildHeaderCollapseRollout.collapsesOnScroll).toBe(false);
});
});
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {readGuildHeaderCollapseAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
type GuildHeaderCollapseAssignmentResponse,
isGuildHeaderCollapseTargeted,
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
class GuildHeaderCollapseRolloutSelector {
get assignment(): GuildHeaderCollapseAssignmentResponse {
return readGuildHeaderCollapseAssignment(ExperimentAssignments.response);
}
get collapsesOnScroll(): boolean {
return isGuildHeaderCollapseTargeted(this.assignment);
}
}
export const GuildHeaderCollapseRollout = new GuildHeaderCollapseRolloutSelector();
export default GuildHeaderCollapseRollout;