Merge branch 'fix/fab-scroll-visibility' into 'main'

Hide FAB while ComposeBox is visible, fade in on scroll

See merge request soapbox-pub/ditto!31
This commit is contained in:
Derek Ross
2026-03-02 21:38:31 -05:00
3 changed files with 56 additions and 8 deletions
+20 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useInView } from 'react-intersection-observer';
import { useQueryClient } from '@tanstack/react-query';
import { ComposeBox } from '@/components/ComposeBox';
@@ -15,6 +15,7 @@ import { useInfiniteSortedPosts } from '@/hooks/useTrending';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { useSetFabHidden } from '@/contexts/LayoutContext';
import { cn } from '@/lib/utils';
import type { FeedItem } from '@/lib/feedUtils';
@@ -38,6 +39,23 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage }: F
const { user } = useCurrentUser();
const { muteItems } = useMuteList();
const queryClient = useQueryClient();
const setFabHidden = useSetFabHidden();
// Hide FAB while the ComposeBox is visible in the viewport
const composeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = composeRef.current;
if (!el || hideCompose) return;
const observer = new IntersectionObserver(
([entry]) => setFabHidden(entry.isIntersecting),
{ threshold: 0 },
);
observer.observe(el);
return () => {
observer.disconnect();
setFabHidden(false); // restore FAB when Feed unmounts
};
}, [hideCompose, setFabHidden]);
// Tab settings from localStorage
const showGlobalFeed = (() => {
@@ -160,7 +178,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage }: F
return (
<main className="flex-1 min-w-0">
{!hideCompose && <ComposeBox compact />}
{!hideCompose && <div ref={composeRef}><ComposeBox compact /></div>}
{header}
+6 -3
View File
@@ -64,7 +64,7 @@ function PageSkeleton() {
/** Inner component that reads layout options from the context store. */
function MainLayoutInner() {
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, noBottomSpacer = false, wrapperClassName } = useLayoutSnapshot();
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, noBottomSpacer = false, wrapperClassName, fabHidden } = useLayoutSnapshot();
const [drawerOpen, setDrawerOpen] = useState(false);
const { config } = useAppContext();
@@ -92,8 +92,11 @@ function MainLayoutInner() {
<div className={cn("relative flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l border-r border-border bg-background/85")}>
<Outlet />
{showFAB && (
<div className="sticky bottom-fab sidebar:bottom-6 z-30 pointer-events-none flex justify-end pr-6">
<div className="pointer-events-auto">
<div className={cn(
'sticky bottom-fab sidebar:bottom-6 z-30 pointer-events-none flex justify-end pr-6 transition-all duration-300',
fabHidden ? 'opacity-0 translate-y-4' : 'opacity-100 translate-y-0',
)}>
<div className={cn('pointer-events-auto', fabHidden && 'pointer-events-none')}>
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} />
</div>
</div>
+30 -3
View File
@@ -26,11 +26,22 @@ const EMPTY: LayoutOptions = {};
* A mutable store that holds the current layout options.
* Pages call `setOptions` to update, and MainLayout subscribes via useSyncExternalStore.
*/
/** Snapshot returned by the layout store, combining page options with dynamic state. */
export type LayoutSnapshot = LayoutOptions & { fabHidden: boolean };
const EMPTY_SNAPSHOT: LayoutSnapshot = { fabHidden: false };
export class LayoutStore {
private options: LayoutOptions = EMPTY;
private _fabHidden = false;
private _snapshot: LayoutSnapshot = EMPTY_SNAPSHOT;
private listeners = new Set<Listener>();
getSnapshot = (): LayoutOptions => this.options;
private buildSnapshot(): void {
this._snapshot = { ...this.options, fabHidden: this._fabHidden };
}
getSnapshot = (): LayoutSnapshot => this._snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
@@ -40,12 +51,22 @@ export class LayoutStore {
setOptions = (next: LayoutOptions): void => {
if (this.options === next) return;
this.options = next;
this.buildSnapshot();
this.listeners.forEach((l) => l());
};
setFabHidden = (hidden: boolean): void => {
if (this._fabHidden === hidden) return;
this._fabHidden = hidden;
this.buildSnapshot();
this.listeners.forEach((l) => l());
};
reset = (): void => {
if (this.options === EMPTY) return;
if (this.options === EMPTY && !this._fabHidden) return;
this.options = EMPTY;
this._fabHidden = false;
this._snapshot = EMPTY_SNAPSHOT;
this.listeners.forEach((l) => l());
};
}
@@ -101,7 +122,13 @@ export function useLayoutOptions(options: LayoutOptions): void {
}
/** Hook for MainLayout to read the current layout options reactively. */
export function useLayoutSnapshot(): LayoutOptions {
export function useLayoutSnapshot(): LayoutSnapshot {
const store = useLayoutStore();
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
}
/** Hook for components to signal that the FAB should be hidden (e.g., ComposeBox is visible). */
export function useSetFabHidden(): (hidden: boolean) => void {
const store = useLayoutStore();
return store.setFabHidden;
}