diff --git a/__tests__/components/brain/my-stream/layout/LayoutContext.test.tsx b/__tests__/components/brain/my-stream/layout/LayoutContext.test.tsx index 14de060876..bb2960604c 100644 --- a/__tests__/components/brain/my-stream/layout/LayoutContext.test.tsx +++ b/__tests__/components/brain/my-stream/layout/LayoutContext.test.tsx @@ -82,7 +82,7 @@ describe('LayoutProvider', () => { expect(content.style.height).toContain('- 128px'); }); - it('removes capSpace and subtracts keyboard height on Android when keyboard is open', () => { + it('removes capSpace on Android when keyboard is open', () => { mockCapacitorValues = { isCapacitor: true, isAndroid: true, isIos: false }; mockKeyboardValues = { isVisible: true, keyboardHeight: 350, isAndroid: true, getContainerStyle: jest.fn() }; @@ -93,9 +93,10 @@ describe('LayoutProvider', () => { ); const content = screen.getByTestId('content'); - // Should subtract keyboard height (350px) but not capSpace - expect(content.style.height).toContain('- 350px'); + // Should NOT include 128px capSpace when keyboard is open (capSpace = 0) expect(content.style.height).not.toContain('- 128px'); + // Should end with - 0px (the capSpace when keyboard is visible) + expect(content.style.height).toContain('- 0px)'); }); it('applies 20px capSpace on iOS', () => { diff --git a/__tests__/hooks/useAndroidKeyboard.test.ts b/__tests__/hooks/useAndroidKeyboard.test.ts index 86378e51f2..af517a5f65 100644 --- a/__tests__/hooks/useAndroidKeyboard.test.ts +++ b/__tests__/hooks/useAndroidKeyboard.test.ts @@ -1,6 +1,8 @@ -import { renderHook, act, waitFor } from '@testing-library/react'; +import { renderHook, act } from '@testing-library/react'; import { useAndroidKeyboard } from '@/hooks/useAndroidKeyboard'; +const DEBOUNCE_MS = 50; + // Mock Capacitor const mockAddListener = jest.fn(); const mockIsPluginAvailable = jest.fn(); @@ -24,6 +26,7 @@ describe('useAndroidKeyboard', () => { let hideCallback: Function; beforeEach(() => { + jest.useFakeTimers(); jest.clearAllMocks(); showCallback = jest.fn(); hideCallback = jest.fn(); @@ -39,10 +42,14 @@ describe('useAndroidKeyboard', () => { } else if (event === 'keyboardWillHide') { hideCallback = callback; } - return { remove: jest.fn() }; + return Promise.resolve({ remove: jest.fn() }); }); }); + afterEach(() => { + jest.useRealTimers(); + }); + it('initializes with keyboard hidden on Android', () => { const { result } = renderHook(() => useAndroidKeyboard()); @@ -68,145 +75,163 @@ describe('useAndroidKeyboard', () => { expect(mockAddListener).not.toHaveBeenCalled(); }); - it('updates state when keyboard shows', async () => { + it('updates state when keyboard shows (after debounce)', async () => { const { result } = renderHook(() => useAndroidKeyboard()); + // Wait for async listener setup + await act(async () => { + await Promise.resolve(); + }); + act(() => { showCallback({ keyboardHeight: 350 }); + jest.advanceTimersByTime(DEBOUNCE_MS); }); - await waitFor(() => { - expect(result.current.isVisible).toBe(true); - expect(result.current.keyboardHeight).toBe(350); - }); + expect(result.current.isVisible).toBe(true); + expect(result.current.keyboardHeight).toBe(350); }); - it('updates state when keyboard hides', async () => { + it('updates state when keyboard hides (after debounce)', async () => { const { result } = renderHook(() => useAndroidKeyboard()); + // Wait for async listener setup + await act(async () => { + await Promise.resolve(); + }); + // Show keyboard first act(() => { showCallback({ keyboardHeight: 350 }); + jest.advanceTimersByTime(DEBOUNCE_MS); }); - await waitFor(() => { - expect(result.current.isVisible).toBe(true); - }); + expect(result.current.isVisible).toBe(true); // Hide keyboard act(() => { hideCallback(); + jest.advanceTimersByTime(DEBOUNCE_MS); }); - await waitFor(() => { - expect(result.current.isVisible).toBe(false); - expect(result.current.keyboardHeight).toBe(0); - }); + expect(result.current.isVisible).toBe(false); + expect(result.current.keyboardHeight).toBe(0); }); it('uses fallback height when keyboardHeight is null', async () => { const { result } = renderHook(() => useAndroidKeyboard()); + await act(async () => { + await Promise.resolve(); + }); + act(() => { showCallback({ keyboardHeight: null }); + jest.advanceTimersByTime(DEBOUNCE_MS); }); - await waitFor(() => { - expect(result.current.keyboardHeight).toBe(300); - }); + expect(result.current.keyboardHeight).toBe(300); }); it('uses fallback height when keyboardHeight is undefined', async () => { const { result } = renderHook(() => useAndroidKeyboard()); - act(() => { - showCallback({}); + await act(async () => { + await Promise.resolve(); }); - await waitFor(() => { - expect(result.current.keyboardHeight).toBe(300); - }); - }); - - it('uses actual height of 0 if provided (not fallback)', async () => { - const { result } = renderHook(() => useAndroidKeyboard()); - act(() => { - showCallback({ keyboardHeight: 0 }); + showCallback({}); + jest.advanceTimersByTime(DEBOUNCE_MS); }); - await waitFor(() => { - expect(result.current.keyboardHeight).toBe(0); - }); + expect(result.current.keyboardHeight).toBe(300); }); - it('sets CSS variable when keyboard shows', () => { + it('sets CSS variable when keyboard shows (after debounce)', async () => { const setPropertySpy = jest.spyOn(document.documentElement.style, 'setProperty'); renderHook(() => useAndroidKeyboard()); + await act(async () => { + await Promise.resolve(); + }); + act(() => { showCallback({ keyboardHeight: 400 }); + jest.advanceTimersByTime(DEBOUNCE_MS); }); expect(setPropertySpy).toHaveBeenCalledWith('--android-keyboard-height', '400px'); + setPropertySpy.mockRestore(); }); - it('clears CSS variable when keyboard hides', () => { + it('clears CSS variable when keyboard hides (after debounce)', async () => { const setPropertySpy = jest.spyOn(document.documentElement.style, 'setProperty'); renderHook(() => useAndroidKeyboard()); + await act(async () => { + await Promise.resolve(); + }); + act(() => { hideCallback(); + jest.advanceTimersByTime(DEBOUNCE_MS); }); expect(setPropertySpy).toHaveBeenCalledWith('--android-keyboard-height', '0px'); setPropertySpy.mockRestore(); }); - it('does not update state if unmounted before listener setup completes', async () => { - let resolveListener: any; + it('cancels pending hide when show is called', async () => { + const { result } = renderHook(() => useAndroidKeyboard()); - // Make addListener async to simulate delay - mockAddListener.mockImplementation(() => { - return new Promise((resolve) => { - resolveListener = resolve; - }); + await act(async () => { + await Promise.resolve(); }); - const { unmount } = renderHook(() => useAndroidKeyboard()); + // Show keyboard + act(() => { + showCallback({ keyboardHeight: 350 }); + jest.advanceTimersByTime(DEBOUNCE_MS); + }); - // Unmount before listener setup completes - unmount(); + expect(result.current.isVisible).toBe(true); - // Now resolve the listener setup - const mockRemove = jest.fn(); + // Start hiding act(() => { - resolveListener?.({ remove: mockRemove }); + hideCallback(); + // Don't advance time fully + jest.advanceTimersByTime(DEBOUNCE_MS / 2); }); - // Listener should be immediately removed since component unmounted - await waitFor(() => { - expect(mockRemove).toHaveBeenCalled(); + // Show again before hide completes + act(() => { + showCallback({ keyboardHeight: 400 }); + jest.advanceTimersByTime(DEBOUNCE_MS); }); + + // Should still be visible with new height + expect(result.current.isVisible).toBe(true); + expect(result.current.keyboardHeight).toBe(400); }); it('does not update state when keyboard events fire after unmount', async () => { const { unmount } = renderHook(() => useAndroidKeyboard()); - unmount(); - - // Try to trigger callbacks after unmount - act(() => { - showCallback({ keyboardHeight: 500 }); + await act(async () => { + await Promise.resolve(); }); - // State should not have changed (we can't access result.current after unmount, - // but this test ensures no errors are thrown) + unmount(); + + // Try to trigger callbacks after unmount - should not throw expect(() => { showCallback({ keyboardHeight: 500 }); + jest.advanceTimersByTime(DEBOUNCE_MS); hideCallback(); + jest.advanceTimersByTime(DEBOUNCE_MS); }).not.toThrow(); }); @@ -237,95 +262,110 @@ describe('useAndroidKeyboard', () => { it('applies transform when keyboard is visible', async () => { const { result } = renderHook(() => useAndroidKeyboard()); + await act(async () => { + await Promise.resolve(); + }); + act(() => { showCallback({ keyboardHeight: 400 }); + jest.advanceTimersByTime(DEBOUNCE_MS); }); - await waitFor(() => { - const style = result.current.getContainerStyle({}); - - expect(style.transform).toBe('translateY(-360px)'); - expect(style.transition).toBe('transform 0.1s ease-out'); - }); + const style = result.current.getContainerStyle({}); + expect(style.transform).toBe('translateY(-360px)'); + expect(style.transition).toBe('transform 0.1s ease-out'); }); it('subtracts adjustment from keyboard height in transform', async () => { const { result } = renderHook(() => useAndroidKeyboard()); + await act(async () => { + await Promise.resolve(); + }); + act(() => { showCallback({ keyboardHeight: 400 }); + jest.advanceTimersByTime(DEBOUNCE_MS); }); - await waitFor(() => { - const style = result.current.getContainerStyle({}, 100); - - expect(style.transform).toBe('translateY(-300px)'); - }); + const style = result.current.getContainerStyle({}, 100); + expect(style.transform).toBe('translateY(-300px)'); }); it('does not apply negative transform', async () => { const { result } = renderHook(() => useAndroidKeyboard()); + await act(async () => { + await Promise.resolve(); + }); + act(() => { showCallback({ keyboardHeight: 50 }); + jest.advanceTimersByTime(DEBOUNCE_MS); }); - await waitFor(() => { - const style = result.current.getContainerStyle({}, 100); - - expect(style.transform).toBe(''); - }); + const style = result.current.getContainerStyle({}, 100); + expect(style.transform).toBeUndefined(); }); it('preserves existing transition if provided', async () => { const { result } = renderHook(() => useAndroidKeyboard()); + await act(async () => { + await Promise.resolve(); + }); + act(() => { showCallback({ keyboardHeight: 400 }); + jest.advanceTimersByTime(DEBOUNCE_MS); }); - await waitFor(() => { - const style = result.current.getContainerStyle({ - transition: 'all 0.3s ease', - }); - - expect(style.transition).toBe('all 0.3s ease'); + const style = result.current.getContainerStyle({ + transition: 'all 0.3s ease', }); + expect(style.transition).toBe('all 0.3s ease'); }); it('combines existing transform with keyboard transform', async () => { const { result } = renderHook(() => useAndroidKeyboard()); + await act(async () => { + await Promise.resolve(); + }); + act(() => { showCallback({ keyboardHeight: 400 }); + jest.advanceTimersByTime(DEBOUNCE_MS); }); - await waitFor(() => { - const style = result.current.getContainerStyle({ - transform: 'scale(1.1)', - }); - - expect(style.transform).toBe('scale(1.1) translateY(-360px)'); + const style = result.current.getContainerStyle({ + transform: 'scale(1.1)', }); + expect(style.transform).toBe('scale(1.1) translateY(-360px)'); }); }); describe('cleanup', () => { - it('removes listeners on unmount', () => { + it('removes listeners on unmount', async () => { const mockRemoveShow = jest.fn(); const mockRemoveHide = jest.fn(); mockAddListener.mockImplementation((event: string) => { if (event === 'keyboardWillShow') { - return { remove: mockRemoveShow }; + return Promise.resolve({ remove: mockRemoveShow }); } else if (event === 'keyboardWillHide') { - return { remove: mockRemoveHide }; + return Promise.resolve({ remove: mockRemoveHide }); } - return { remove: jest.fn() }; + return Promise.resolve({ remove: jest.fn() }); }); const { unmount } = renderHook(() => useAndroidKeyboard()); + // Wait for async listener setup + await act(async () => { + await Promise.resolve(); + }); + unmount(); expect(mockRemoveShow).toHaveBeenCalled(); @@ -341,5 +381,23 @@ describe('useAndroidKeyboard', () => { expect(setPropertySpy).toHaveBeenCalledWith('--android-keyboard-height', '0px'); }); + + it('safely clears pending timeouts on unmount without throwing', async () => { + const { unmount } = renderHook(() => useAndroidKeyboard()); + + await act(async () => { + await Promise.resolve(); + }); + + act(() => { + showCallback({ keyboardHeight: 350 }); + }); + + expect(() => unmount()).not.toThrow(); + + act(() => { + jest.advanceTimersByTime(DEBOUNCE_MS * 2); + }); + }); }); }); diff --git a/components/brain/my-stream/layout/LayoutContext.tsx b/components/brain/my-stream/layout/LayoutContext.tsx index aa5bf2cf1d..4e5953ca13 100644 --- a/components/brain/my-stream/layout/LayoutContext.tsx +++ b/components/brain/my-stream/layout/LayoutContext.tsx @@ -65,12 +65,10 @@ const spacesAreEqual = (a: LayoutSpaces, b: LayoutSpaces) => const calculateHeightStyle = ( view: View, spaces: LayoutSpaces, - capacitorSpace: number, // Accept specific space value - keyboardHeight: number = 0 // Keyboard height when visible + capacitorSpace: number // Accept specific space value ): React.CSSProperties => { // Use dynamic viewport height to avoid extra space on mobile browsers - // Subtract keyboard height when keyboard is open to shrink container to visible area - const heightCalc = `calc(100dvh - ${spaces.headerSpace}px - ${spaces.pinnedSpace}px - ${spaces.tabsSpace}px - ${spaces.spacerSpace}px - ${spaces.mobileTabsSpace}px - ${spaces.mobileNavSpace}px - ${capacitorSpace}px - ${keyboardHeight}px)`; + const heightCalc = `calc(100dvh - ${spaces.headerSpace}px - ${spaces.pinnedSpace}px - ${spaces.tabsSpace}px - ${spaces.spacerSpace}px - ${spaces.mobileTabsSpace}px - ${spaces.mobileNavSpace}px - ${capacitorSpace}px)`; return { height: heightCalc, maxHeight: heightCalc, @@ -170,7 +168,7 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { const { isCapacitor, isAndroid, isIos } = useCapacitor(); - const { isVisible: isAndroidKeyboardVisible, keyboardHeight } = useAndroidKeyboard(); + const { isVisible: isKeyboardVisible } = useAndroidKeyboard(); // Internal ref storage (source of truth) const refMap = useRef>({ @@ -381,22 +379,25 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ const waveViewStyle = useMemo(() => { if (!spaces.measurementsComplete) return {}; - // Reserve space for input area + bottom nav (only when keyboard closed) let capSpace = 0; - let kbHeight = 0; if (isAndroid) { - // When keyboard open: no capSpace needed, subtract keyboard height instead - // When keyboard closed: use 128px capSpace for input + bottom nav - capSpace = isAndroidKeyboardVisible ? 0 : 128; - kbHeight = isAndroidKeyboardVisible ? keyboardHeight : 0; + capSpace = isKeyboardVisible ? 0 : 128; } else if (isIos || isCapacitor) { capSpace = 20; } const adjustedSpaces = { ...spaces, mobileNavSpace: 0 }; - return calculateHeightStyle("wave", adjustedSpaces, capSpace, kbHeight); - }, [spaces, isAndroid, isAndroidKeyboardVisible, keyboardHeight, isIos, isCapacitor]); + const style = calculateHeightStyle("wave", adjustedSpaces, capSpace); + + if (isAndroid) { + return { + ...style, + transition: "height 75ms ease-out, max-height 75ms ease-out", + }; + } + return style; + }, [spaces, isAndroid, isKeyboardVisible, isIos, isCapacitor]); const leaderboardViewStyle = useMemo(() => { if (!spaces.measurementsComplete) return {}; diff --git a/components/layout/AppLayout.tsx b/components/layout/AppLayout.tsx index 1d254d28c3..35732d39b3 100644 --- a/components/layout/AppLayout.tsx +++ b/components/layout/AppLayout.tsx @@ -14,6 +14,7 @@ import BrainMobileMessages from "../brain/mobile/BrainMobileMessages"; import { useSelector } from "react-redux"; import { selectEditingDropId } from "@/store/editSlice"; import useDeviceInfo from "@/hooks/useDeviceInfo"; +import { useAndroidKeyboard } from "@/hooks/useAndroidKeyboard"; const TouchDeviceHeader = dynamic(() => import("../header/AppHeader"), { ssr: false, @@ -45,7 +46,9 @@ export default function AppLayout({ children }: Props) { const isHomeFeedView = pathname === "/" && homeActiveTab === "feed"; const editingDropId = useSelector(selectEditingDropId); const { isApp } = useDeviceInfo(); + const { isVisible: isKeyboardVisible, isAndroid } = useAndroidKeyboard(); const isEditingOnMobile = isApp && editingDropId !== null; + const shouldHideBottomNav = isAndroid && isKeyboardVisible; const headerWrapperRef = useCallback( (node: HTMLDivElement | null) => { @@ -55,9 +58,13 @@ export default function AppLayout({ children }: Props) { [registerRef, setHeaderRef] ); + const safeAreaClass = shouldHideBottomNav + ? "" + : "tw-pb-[env(safe-area-inset-bottom,0px)]"; + return (
@@ -73,7 +80,9 @@ export default function AppLayout({ children }: Props) { {!isSingleDropOpen && !isStreamRoute && !isHomeFeedView && (
)} - {!isSingleDropOpen && !isEditingOnMobile && } + {!isSingleDropOpen && !isEditingOnMobile && ( +
); } diff --git a/components/navigation/BottomNavigation.tsx b/components/navigation/BottomNavigation.tsx index 3b05cb8bff..eeafc12a6a 100644 --- a/components/navigation/BottomNavigation.tsx +++ b/components/navigation/BottomNavigation.tsx @@ -1,19 +1,19 @@ "use client"; +import { getNotificationsRoute } from "@/helpers/navigation.helpers"; +import useCapacitor from "@/hooks/useCapacitor"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; import React, { useCallback, useMemo, useRef } from "react"; -import NavItem from "./NavItem"; -import type { NavItem as NavItemData } from "./navTypes"; -import HomeIcon from "../common/icons/HomeIcon"; -import WavesIcon from "../common/icons/WavesIcon"; +import { useLayout } from "../brain/my-stream/layout/LayoutContext"; +import BellIcon from "../common/icons/BellIcon"; import ChatBubbleIcon from "../common/icons/ChatBubbleIcon"; +import HomeIcon from "../common/icons/HomeIcon"; +import LogoIcon from "../common/icons/LogoIcon"; import Squares2X2Icon from "../common/icons/Squares2X2Icon"; -import BellIcon from "../common/icons/BellIcon"; import UsersIcon from "../common/icons/UsersIcon"; -import LogoIcon from "../common/icons/LogoIcon"; -import { useLayout } from "../brain/my-stream/layout/LayoutContext"; -import useCapacitor from "@/hooks/useCapacitor"; -import useDeviceInfo from "@/hooks/useDeviceInfo"; -import { getNotificationsRoute } from "@/helpers/navigation.helpers"; +import WavesIcon from "../common/icons/WavesIcon"; +import NavItem from "./NavItem"; +import type { NavItem as NavItemData } from "./navTypes"; const items: NavItemData[] = [ { @@ -69,7 +69,11 @@ const items: NavItemData[] = [ }, ]; -const BottomNavigation: React.FC = () => { +interface BottomNavigationProps { + readonly hidden?: boolean; +} + +const BottomNavigation: React.FC = ({ hidden = false }) => { const { registerRef } = useLayout(); const { isAndroid } = useCapacitor(); const { isApp } = useDeviceInfo(); @@ -96,14 +100,17 @@ const BottomNavigation: React.FC = () => { ), [isApp] ); - - // Only add safe area padding on Android + const paddingClass = isAndroid ? "tw-pb-[env(safe-area-inset-bottom,0px)]" : ""; - + + const hiddenStyle = hidden + ? "tw-opacity-0 tw-translate-y-full tw-pointer-events-none" + : "tw-opacity-100 tw-translate-y-0"; + return (