From 6abe9fade0d3061d59d4bd0c3b1f313ab53fefd7 Mon Sep 17 00:00:00 2001 From: Derek Ross Date: Mon, 2 Mar 2026 21:35:36 -0500 Subject: [PATCH] Hide FAB while ComposeBox is visible, fade in on scroll Use IntersectionObserver on the ComposeBox wrapper in Feed.tsx to track its visibility. When visible, hide the FAB to avoid redundancy. When the user scrolls past the compose area, the FAB smoothly fades in with an upward slide animation. Extends LayoutStore with fabHidden state that is part of the snapshot so useSyncExternalStore triggers re-renders. --- src/components/Feed.tsx | 22 ++++++++++++++++++++-- src/components/MainLayout.tsx | 9 ++++++--- src/contexts/LayoutContext.ts | 33 ++++++++++++++++++++++++++++++--- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 3c3bad50..5cc31e5e 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -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(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 (
- {!hideCompose && } + {!hideCompose &&
} {header} diff --git a/src/components/MainLayout.tsx b/src/components/MainLayout.tsx index 9828497c..3bba1595 100644 --- a/src/components/MainLayout.tsx +++ b/src/components/MainLayout.tsx @@ -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() {
{showFAB && ( -
-
+
+
diff --git a/src/contexts/LayoutContext.ts b/src/contexts/LayoutContext.ts index 444d919d..f82bece5 100644 --- a/src/contexts/LayoutContext.ts +++ b/src/contexts/LayoutContext.ts @@ -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(); - 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; +}