mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(app): gate a collapsing guild header behind an experiment (#2779)
This commit is contained in:
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;
|
||||
Reference in new issue
Block a user