From 16aec314c07d0e4aa342874e56bcc454bcfd6a06 Mon Sep 17 00:00:00 2001 From: ragnep Date: Tue, 9 Dec 2025 12:10:44 +0200 Subject: [PATCH 1/6] android keyboard bug fix Signed-off-by: ragnep --- components/brain/content/BrainContent.tsx | 13 +- .../brain/my-stream/MyStreamWaveChat.tsx | 7 +- .../brain/my-stream/layout/LayoutContext.tsx | 20 +- hooks/useAndroidKeyboard.ts | 202 ++++-------------- 4 files changed, 61 insertions(+), 181 deletions(-) diff --git a/components/brain/content/BrainContent.tsx b/components/brain/content/BrainContent.tsx index 60e113d7ce..e1ef7ec608 100644 --- a/components/brain/content/BrainContent.tsx +++ b/components/brain/content/BrainContent.tsx @@ -6,7 +6,6 @@ import BrainContentPinnedWaves from "./BrainContentPinnedWaves"; import BrainContentInput from "./input/BrainContentInput"; import { ActiveDropState } from "@/types/dropInteractionTypes"; import { useLayout } from "../my-stream/layout/LayoutContext"; -import { useAndroidKeyboard } from "@/hooks/useAndroidKeyboard"; import useDeviceInfo from "@/hooks/useDeviceInfo"; // Create breakpoint hook with the same values as tailwind classes @@ -17,7 +16,6 @@ interface BrainContentProps { readonly children: React.ReactNode; readonly activeDrop: ActiveDropState | null; readonly onCancelReplyQuote: () => void; - readonly keyboardAdjustment?: number; readonly showPinnedWaves?: boolean; } @@ -25,14 +23,10 @@ const BrainContent: React.FC = ({ children, activeDrop, onCancelReplyQuote, - keyboardAdjustment = 40, showPinnedWaves = true, }) => { - // Get layout context registration function for measuring + // Get layout context registration function const { registerRef } = useLayout(); - - // Android keyboard handling - only apply when input is visible - const { getContainerStyle } = useAndroidKeyboard(); // Get current breakpoint and device info const breakpoint = useBreakpoint(); @@ -64,11 +58,8 @@ const BrainContent: React.FC = ({ // Only show pinned waves in the app on small screens (not mobile web) const shouldShowPinnedWaves = showPinnedWaves && breakpoint === "S" && isApp; - // Only apply Android keyboard adjustments when input is visible - const containerStyle = activeDrop ? getContainerStyle({}, keyboardAdjustment) : {}; - return ( -
+
{showPinnedWaves && (
= ({ wave }) => { const { isMemesWave } = useWave(wave); const editingDropId = useSelector(selectEditingDropId); const { isApp } = useDeviceInfo(); - const { getContainerStyle } = useAndroidKeyboard(); const [activeDrop, setActiveDrop] = useState(null); // Handle URL parameters @@ -69,10 +67,7 @@ const MyStreamWaveChat: React.FC = ({ wave }) => { return `${baseStyles} ${heightClass}`; }, []); - // Android keyboard adjustment style using centralized hook - const containerStyle = useMemo(() => { - return getContainerStyle(waveViewStyle || {}, 128); - }, [waveViewStyle, getContainerStyle]); + const containerStyle = waveViewStyle || {}; useEffect(() => setActiveDrop(null), [wave]); diff --git a/components/brain/my-stream/layout/LayoutContext.tsx b/components/brain/my-stream/layout/LayoutContext.tsx index 23a2f56ded..aa5bf2cf1d 100644 --- a/components/brain/my-stream/layout/LayoutContext.tsx +++ b/components/brain/my-stream/layout/LayoutContext.tsx @@ -11,6 +11,7 @@ import React, { useMemo, } from "react"; import useCapacitor from "@/hooks/useCapacitor"; +import { useAndroidKeyboard } from "@/hooks/useAndroidKeyboard"; // Define the different spaces that need to be measured interface LayoutSpaces { @@ -64,10 +65,12 @@ const spacesAreEqual = (a: LayoutSpaces, b: LayoutSpaces) => const calculateHeightStyle = ( view: View, spaces: LayoutSpaces, - capacitorSpace: number // Accept specific space value + capacitorSpace: number, // Accept specific space value + keyboardHeight: number = 0 // Keyboard height when visible ): React.CSSProperties => { // Use dynamic viewport height to avoid extra space on mobile browsers - const heightCalc = `calc(100dvh - ${spaces.headerSpace}px - ${spaces.pinnedSpace}px - ${spaces.tabsSpace}px - ${spaces.spacerSpace}px - ${spaces.mobileTabsSpace}px - ${spaces.mobileNavSpace}px - ${capacitorSpace}px)`; + // 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)`; return { height: heightCalc, maxHeight: heightCalc, @@ -167,6 +170,7 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { const { isCapacitor, isAndroid, isIos } = useCapacitor(); + const { isVisible: isAndroidKeyboardVisible, keyboardHeight } = useAndroidKeyboard(); // Internal ref storage (source of truth) const refMap = useRef>({ @@ -377,16 +381,22 @@ 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) { - capSpace = 128; + // 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; } else if (isIos || isCapacitor) { capSpace = 20; } const adjustedSpaces = { ...spaces, mobileNavSpace: 0 }; - return calculateHeightStyle("wave", adjustedSpaces, capSpace); - }, [spaces, isAndroid, isIos, isCapacitor]); + return calculateHeightStyle("wave", adjustedSpaces, capSpace, kbHeight); + }, [spaces, isAndroid, isAndroidKeyboardVisible, keyboardHeight, isIos, isCapacitor]); const leaderboardViewStyle = useMemo(() => { if (!spaces.measurementsComplete) return {}; diff --git a/hooks/useAndroidKeyboard.ts b/hooks/useAndroidKeyboard.ts index f7eb3c7f3f..a6790334ae 100644 --- a/hooks/useAndroidKeyboard.ts +++ b/hooks/useAndroidKeyboard.ts @@ -1,200 +1,84 @@ "use client" -import { useEffect, useState, useCallback, useRef } from 'react'; +import { useEffect, useState, useCallback, type CSSProperties } from 'react'; import { Capacitor } from '@capacitor/core'; import { Keyboard } from '@capacitor/keyboard'; -// Debounce utility (simple implementation to avoid lodash dependency) -function debounce any>( - func: T, - wait: number -): (...args: Parameters) => void { - let timeout: NodeJS.Timeout; - return (...args: Parameters) => { - clearTimeout(timeout); - timeout = setTimeout(() => func(...args), wait); - }; -} - interface AndroidKeyboardHookReturn { keyboardHeight: number; isVisible: boolean; isAndroid: boolean; - getContainerStyle: (baseStyle?: React.CSSProperties, adjustment?: number) => React.CSSProperties; + getContainerStyle: (baseStyle?: CSSProperties, adjustment?: number) => CSSProperties; } -export function useAndroidKeyboard(debounceMs: number = 50): AndroidKeyboardHookReturn { - // All hooks must be called before any conditional logic +export function useAndroidKeyboard(): AndroidKeyboardHookReturn { const [keyboardHeight, setKeyboardHeight] = useState(0); const [isVisible, setIsVisible] = useState(false); - const initialHeightRef = useRef(typeof window !== 'undefined' ? window.innerHeight : 0); - // SSR safety check after hooks are declared const isSSR = typeof window === 'undefined'; const isAndroid = !isSSR && Capacitor.getPlatform() === 'android'; - // Recalculate initial height (for orientation changes) - const resetInitialHeight = useCallback(() => { - if (isSSR) return; - initialHeightRef.current = window.innerHeight; - }, [isSSR]); - - const detectKeyboard = useCallback(() => { - if (isSSR) return; - - const currentHeight = window.innerHeight; - const initialHeight = initialHeightRef.current; - let height = 0; - - // Method 1: VisualViewport API (most reliable) - if (window.visualViewport) { - height = initialHeight - window.visualViewport.height; - } - // Method 2: Window resize fallback - else { - height = initialHeight - currentHeight; - } - - // Only consider it a keyboard if height difference is significant - if (height > 50) { - setKeyboardHeight(height); - setIsVisible(true); - document.documentElement.style.setProperty('--android-keyboard-height', `${height}px`); - } else { - setKeyboardHeight(0); - setIsVisible(false); - document.documentElement.style.setProperty('--android-keyboard-height', '0px'); - } - }, [isSSR]); - - // Debounced version to prevent flooding React with state updates - const debouncedDetectKeyboard = useRef(debounce(detectKeyboard, debounceMs)).current; - - const handleOrientationChange = useCallback(() => { - if (isSSR) return; - resetInitialHeight(); - // Use immediate detection for orientation changes (not debounced) - detectKeyboard(); - }, [isSSR, resetInitialHeight, detectKeyboard]); - - // Simplified focus handlers - remove double state setting - const handleFocusIn = useCallback((e: FocusEvent) => { - if (isSSR) return; - - const target = e.target as HTMLElement; - if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true')) { - // Only use fallback if plugin isn't available - if (!Capacitor.isPluginAvailable('Keyboard')) { - const fallbackHeight = Math.floor(window.innerHeight * 0.35); - setKeyboardHeight(fallbackHeight); - setIsVisible(true); - document.documentElement.style.setProperty('--android-keyboard-height', `${fallbackHeight}px`); - } - } - }, [isSSR]); - - const handleFocusOut = useCallback(() => { - if (isSSR) return; - - // Immediately clear keyboard state when focus leaves input - setKeyboardHeight(0); - setIsVisible(false); - document.documentElement.style.setProperty('--android-keyboard-height', '0px'); - }, [isSSR]); - - const handleResize = useCallback(() => { - if (isSSR) return; - debouncedDetectKeyboard(); - }, [isSSR, debouncedDetectKeyboard]); - - const handleVisualViewportResize = useCallback(() => { - if (isSSR) return; - debouncedDetectKeyboard(); - }, [isSSR, debouncedDetectKeyboard]); - useEffect(() => { if (isSSR || !isAndroid) return; - let keyboardShowCleanup: (() => void) | undefined; - let keyboardHideCleanup: (() => void) | undefined; - - // Setup Capacitor keyboard listeners (if available) - const setupCapacitorKeyboard = async () => { - if (Capacitor.isPluginAvailable('Keyboard')) { - try { - const keyboardWillShowListener = await Keyboard.addListener('keyboardWillShow', (info) => { - const height = info.keyboardHeight || 300; - setKeyboardHeight(height); - setIsVisible(true); - document.documentElement.style.setProperty('--android-keyboard-height', `${height}px`); - }); - - const keyboardWillHideListener = await Keyboard.addListener('keyboardWillHide', () => { - setKeyboardHeight(0); - setIsVisible(false); - document.documentElement.style.setProperty('--android-keyboard-height', '0px'); - }); - - keyboardShowCleanup = () => keyboardWillShowListener.remove(); - keyboardHideCleanup = () => keyboardWillHideListener.remove(); - } catch (error) { - console.error('[Android Keyboard] Error setting up Capacitor Keyboard:', error); - } + let showCleanup: (() => void) | undefined; + let hideCleanup: (() => void) | undefined; + + const setupKeyboardListeners = async () => { + if (!Capacitor.isPluginAvailable('Keyboard')) return; + + try { + const showListener = await Keyboard.addListener('keyboardWillShow', (info) => { + const height = info.keyboardHeight || 300; + setKeyboardHeight(height); + setIsVisible(true); + document.documentElement.style.setProperty('--android-keyboard-height', `${height}px`); + }); + + const hideListener = await Keyboard.addListener('keyboardWillHide', () => { + setKeyboardHeight(0); + setIsVisible(false); + document.documentElement.style.setProperty('--android-keyboard-height', '0px'); + }); + + showCleanup = () => showListener.remove(); + hideCleanup = () => hideListener.remove(); + } catch (error) { + console.error('[Android Keyboard] Error setting up listeners:', error); } }; - // Add event listeners with proper cleanup references - if (window.visualViewport) { - window.visualViewport.addEventListener('resize', handleVisualViewportResize); - } - - window.addEventListener('resize', handleResize); - window.addEventListener('orientationchange', handleOrientationChange); - document.addEventListener('focusin', handleFocusIn); - document.addEventListener('focusout', handleFocusOut); - - // Setup Capacitor keyboard listeners - setupCapacitorKeyboard(); - - // Initial measurement - resetInitialHeight(); - detectKeyboard(); + setupKeyboardListeners(); return () => { - // Cleanup Capacitor listeners - keyboardShowCleanup?.(); - keyboardHideCleanup?.(); - - // Cleanup DOM listeners (using same function references) - if (window.visualViewport) { - window.visualViewport.removeEventListener('resize', handleVisualViewportResize); - } - window.removeEventListener('resize', handleResize); - window.removeEventListener('orientationchange', handleOrientationChange); - document.removeEventListener('focusin', handleFocusIn); - document.removeEventListener('focusout', handleFocusOut); + showCleanup?.(); + hideCleanup?.(); + document.documentElement.style.setProperty('--android-keyboard-height', '0px'); }; - }, [isSSR, isAndroid, handleVisualViewportResize, handleResize, handleOrientationChange, handleFocusIn, handleFocusOut, resetInitialHeight, detectKeyboard]); + }, [isSSR, isAndroid]); - // Centralized container style for keyboard adjustments const getContainerStyle = useCallback(( - baseStyle: React.CSSProperties = {}, + baseStyle: CSSProperties = {}, adjustment: number = 40 - ): React.CSSProperties => { + ): CSSProperties => { if (isSSR || !isAndroid || !isVisible || keyboardHeight <= 0) { return { ...baseStyle, - transition: 'transform 0.1s ease-out', + transition: baseStyle.transition ?? 'transform 0.1s ease-out', }; } - + const adjustedTransform = Math.max(0, keyboardHeight - adjustment); + const baseTransform = baseStyle.transform ?? ''; + const translateY = adjustedTransform > 0 ? `translateY(-${adjustedTransform}px)` : ''; + const combinedTransform = `${baseTransform} ${translateY}`.trim(); + return { ...baseStyle, - transform: `translateY(-${adjustedTransform}px)`, - transition: 'transform 0.1s ease-out', + transform: combinedTransform || undefined, + transition: baseStyle.transition ?? 'transform 0.1s ease-out', }; }, [isSSR, isAndroid, isVisible, keyboardHeight]); return { keyboardHeight, isVisible, isAndroid, getContainerStyle }; -} \ No newline at end of file +} From 3b0688df71c0ac132c2242c14e3ec7ceda47c8f0 Mon Sep 17 00:00:00 2001 From: ragnep Date: Tue, 9 Dec 2025 12:18:48 +0200 Subject: [PATCH 2/6] wip Signed-off-by: ragnep --- hooks/useAndroidKeyboard.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/hooks/useAndroidKeyboard.ts b/hooks/useAndroidKeyboard.ts index a6790334ae..6ca47e2f8f 100644 --- a/hooks/useAndroidKeyboard.ts +++ b/hooks/useAndroidKeyboard.ts @@ -21,6 +21,7 @@ export function useAndroidKeyboard(): AndroidKeyboardHookReturn { useEffect(() => { if (isSSR || !isAndroid) return; + let mounted = true; let showCleanup: (() => void) | undefined; let hideCleanup: (() => void) | undefined; @@ -41,6 +42,13 @@ export function useAndroidKeyboard(): AndroidKeyboardHookReturn { document.documentElement.style.setProperty('--android-keyboard-height', '0px'); }); + // If unmounted during async setup, remove listeners immediately + if (!mounted) { + showListener.remove(); + hideListener.remove(); + return; + } + showCleanup = () => showListener.remove(); hideCleanup = () => hideListener.remove(); } catch (error) { @@ -51,6 +59,7 @@ export function useAndroidKeyboard(): AndroidKeyboardHookReturn { setupKeyboardListeners(); return () => { + mounted = false; showCleanup?.(); hideCleanup?.(); document.documentElement.style.setProperty('--android-keyboard-height', '0px'); From 80e5940a2de8eaee26018b86fc5c0876437a1482 Mon Sep 17 00:00:00 2001 From: ragnep Date: Tue, 9 Dec 2025 12:27:18 +0200 Subject: [PATCH 3/6] wip Signed-off-by: ragnep --- hooks/useAndroidKeyboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/useAndroidKeyboard.ts b/hooks/useAndroidKeyboard.ts index 6ca47e2f8f..1fa4f2c07e 100644 --- a/hooks/useAndroidKeyboard.ts +++ b/hooks/useAndroidKeyboard.ts @@ -30,7 +30,7 @@ export function useAndroidKeyboard(): AndroidKeyboardHookReturn { try { const showListener = await Keyboard.addListener('keyboardWillShow', (info) => { - const height = info.keyboardHeight || 300; + const height = info.keyboardHeight ?? 300; setKeyboardHeight(height); setIsVisible(true); document.documentElement.style.setProperty('--android-keyboard-height', `${height}px`); From 52141e6f2a0c46020f7ed860de0f8526e3d8fd02 Mon Sep 17 00:00:00 2001 From: ragnep Date: Tue, 9 Dec 2025 12:55:00 +0200 Subject: [PATCH 4/6] wip Signed-off-by: ragnep --- .../my-stream/layout/LayoutContext.test.tsx | 83 ++++- .../waves/drop/SingleWaveDropChat.test.tsx | 58 ++- __tests__/hooks/useAndroidKeyboard.test.ts | 349 ++++++++++++++++++ hooks/useAndroidKeyboard.ts | 4 +- 4 files changed, 488 insertions(+), 6 deletions(-) create mode 100644 __tests__/hooks/useAndroidKeyboard.test.ts diff --git a/__tests__/components/brain/my-stream/layout/LayoutContext.test.tsx b/__tests__/components/brain/my-stream/layout/LayoutContext.test.tsx index 5873410011..678ef3a03b 100644 --- a/__tests__/components/brain/my-stream/layout/LayoutContext.test.tsx +++ b/__tests__/components/brain/my-stream/layout/LayoutContext.test.tsx @@ -1,8 +1,19 @@ import React, { useEffect, useRef } from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, act } from '@testing-library/react'; import { LayoutProvider, useLayout } from '@/components/brain/my-stream/layout/LayoutContext'; -jest.mock('@/hooks/useCapacitor', () => ({ __esModule: true, default: () => ({ isCapacitor: false, isAndroid: false, isIos: false }) })); +// Mock useCapacitor hook with configurable values +let mockCapacitorValues = { isCapacitor: false, isAndroid: false, isIos: false }; +jest.mock('@/hooks/useCapacitor', () => ({ + __esModule: true, + default: () => mockCapacitorValues +})); + +// Mock useAndroidKeyboard hook with configurable values +let mockKeyboardValues = { isVisible: false, keyboardHeight: 0, isAndroid: false, getContainerStyle: jest.fn() }; +jest.mock('@/hooks/useAndroidKeyboard', () => ({ + useAndroidKeyboard: () => mockKeyboardValues +})); beforeAll(() => { // run RAF callbacks immediately @@ -19,6 +30,12 @@ afterAll(() => { delete (global as any).requestAnimationFrame; }); +beforeEach(() => { + // Reset mocks before each test + mockCapacitorValues = { isCapacitor: false, isAndroid: false, isIos: false }; + mockKeyboardValues = { isVisible: false, keyboardHeight: 0, isAndroid: false, getContainerStyle: jest.fn() }; +}); + function TestComponent() { const { registerRef, spaces, waveViewStyle } = useLayout(); const ref = useRef(null); @@ -49,4 +66,66 @@ describe('LayoutProvider', () => { expect(content.textContent).toBe('900'); expect(content.style.height).toContain('calc(100dvh - 100px'); }); + + it('applies 128px capSpace on Android when keyboard is closed', () => { + mockCapacitorValues = { isCapacitor: true, isAndroid: true, isIos: false }; + mockKeyboardValues = { isVisible: false, keyboardHeight: 0, isAndroid: true, getContainerStyle: jest.fn() }; + + Object.defineProperty(window, 'innerHeight', { value: 1000, configurable: true }); + render( + + + + ); + const content = screen.getByTestId('content'); + // Should include 128px capSpace + expect(content.style.height).toContain('- 128px'); + }); + + it('removes capSpace and subtracts keyboard height on Android when keyboard is open', () => { + mockCapacitorValues = { isCapacitor: true, isAndroid: true, isIos: false }; + mockKeyboardValues = { isVisible: true, keyboardHeight: 350, isAndroid: true, getContainerStyle: jest.fn() }; + + Object.defineProperty(window, 'innerHeight', { value: 1000, configurable: true }); + render( + + + + ); + const content = screen.getByTestId('content'); + // Should subtract keyboard height (350px) but not capSpace + expect(content.style.height).toContain('- 350px'); + expect(content.style.height).not.toContain('- 128px'); + }); + + it('applies 20px capSpace on iOS', () => { + mockCapacitorValues = { isCapacitor: true, isAndroid: false, isIos: true }; + mockKeyboardValues = { isVisible: false, keyboardHeight: 0, isAndroid: false, getContainerStyle: jest.fn() }; + + Object.defineProperty(window, 'innerHeight', { value: 1000, configurable: true }); + render( + + + + ); + const content = screen.getByTestId('content'); + // Should include 20px capSpace for iOS + expect(content.style.height).toContain('- 20px'); + }); + + it('does not apply capSpace on desktop', () => { + mockCapacitorValues = { isCapacitor: false, isAndroid: false, isIos: false }; + mockKeyboardValues = { isVisible: false, keyboardHeight: 0, isAndroid: false, getContainerStyle: jest.fn() }; + + Object.defineProperty(window, 'innerHeight', { value: 1000, configurable: true }); + render( + + + + ); + const content = screen.getByTestId('content'); + // Should not include any capSpace + expect(content.style.height).not.toContain('- 128px'); + expect(content.style.height).not.toContain('- 20px'); + }); }); diff --git a/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx b/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx index 301c1077e1..ad32cde439 100644 --- a/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx +++ b/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx @@ -1,16 +1,30 @@ -import { render, fireEvent, act } from '@testing-library/react'; +import { render, fireEvent, act, screen } from '@testing-library/react'; import React from 'react'; import { SingleWaveDropChat } from '@/components/waves/drop/SingleWaveDropChat'; jest.mock('@/hooks/useCapacitor', () => () => false); jest.mock('@/components/brain/my-stream/layout/LayoutContext', () => ({ useLayout: () => ({ spaces: { measurementsComplete: true, headerSpace: 10 } }) })); +// Mock useAndroidKeyboard with configurable values +let mockKeyboardVisible = false; +let mockGetContainerStyle = jest.fn((baseStyle: any) => baseStyle); + +jest.mock('@/hooks/useAndroidKeyboard', () => ({ + useAndroidKeyboard: () => ({ + isVisible: mockKeyboardVisible, + keyboardHeight: mockKeyboardVisible ? 350 : 0, + isAndroid: true, + getContainerStyle: mockGetContainerStyle, + }), +})); + let capturedProps: any; +let capturedCreatorProps: any; jest.mock('@/components/waves/drops/wave-drops-all', () => ({ __esModule: true, default: (props: any) => { capturedProps = props; return
; } })); -jest.mock('@/components/waves/CreateDropWaveWrapper', () => ({ CreateDropWaveWrapper: ({ children }: any) =>
{children}
, CreateDropWaveWrapperContext: { SINGLE_DROP: 'SINGLE_DROP' } })); +jest.mock('@/components/waves/CreateDropWaveWrapper', () => ({ CreateDropWaveWrapper: ({ children }: any) =>
{children}
, CreateDropWaveWrapperContext: { SINGLE_DROP: 'SINGLE_DROP' } })); -jest.mock('@/components/waves/PrivilegedDropCreator', () => ({ __esModule: true, default: (props: any) =>
, DropMode: { BOTH: 'BOTH' } })); +jest.mock('@/components/waves/PrivilegedDropCreator', () => ({ __esModule: true, default: (props: any) => { capturedCreatorProps = props; return
; }, DropMode: { BOTH: 'BOTH' } })); // Mock window.matchMedia for useDeviceInfo hook Object.defineProperty(window, 'matchMedia', { @@ -28,6 +42,12 @@ Object.defineProperty(window, 'matchMedia', { }); describe('SingleWaveDropChat', () => { + beforeEach(() => { + mockKeyboardVisible = false; + mockGetContainerStyle.mockClear(); + mockGetContainerStyle.mockImplementation((baseStyle: any) => baseStyle); + }); + it('handles reply and reset actions', () => { const wave: any = { id: 'w1' }; const drop: any = { id: 'd1' }; @@ -42,4 +62,36 @@ describe('SingleWaveDropChat', () => { fireEvent.click(document.querySelector('[data-testid="creator"]')!); expect(document.querySelector('[data-part="1"]')).toBeInTheDocument(); }); + + it('applies 0px padding when keyboard is visible', () => { + mockKeyboardVisible = true; + + const wave: any = { id: 'w1' }; + const drop: any = { id: 'd1' }; + render(); + + // getContainerStyle should have been called with paddingBottom: "0px" + expect(mockGetContainerStyle).toHaveBeenCalledWith( + expect.objectContaining({ + paddingBottom: '0px', + }), + 0 + ); + }); + + it('applies safe-area-inset-bottom padding when keyboard is hidden', () => { + mockKeyboardVisible = false; + + const wave: any = { id: 'w1' }; + const drop: any = { id: 'd1' }; + render(); + + // getContainerStyle should have been called with paddingBottom: safe-area-inset-bottom + expect(mockGetContainerStyle).toHaveBeenCalledWith( + expect.objectContaining({ + paddingBottom: 'calc(env(safe-area-inset-bottom))', + }), + 0 + ); + }); }); diff --git a/__tests__/hooks/useAndroidKeyboard.test.ts b/__tests__/hooks/useAndroidKeyboard.test.ts new file mode 100644 index 0000000000..1ba72a41c5 --- /dev/null +++ b/__tests__/hooks/useAndroidKeyboard.test.ts @@ -0,0 +1,349 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useAndroidKeyboard } from '@/hooks/useAndroidKeyboard'; + +// Mock Capacitor +const mockAddListener = jest.fn(); +const mockIsPluginAvailable = jest.fn(); +const mockGetPlatform = jest.fn(); + +jest.mock('@capacitor/core', () => ({ + Capacitor: { + getPlatform: () => mockGetPlatform(), + isPluginAvailable: (name: string) => mockIsPluginAvailable(name), + }, +})); + +jest.mock('@capacitor/keyboard', () => ({ + Keyboard: { + addListener: (event: string, callback: Function) => mockAddListener(event, callback), + }, +})); + +describe('useAndroidKeyboard', () => { + let showCallback: Function; + let hideCallback: Function; + + beforeEach(() => { + jest.clearAllMocks(); + showCallback = jest.fn(); + hideCallback = jest.fn(); + + // Default: Android platform with Keyboard plugin available + mockGetPlatform.mockReturnValue('android'); + mockIsPluginAvailable.mockReturnValue(true); + + // Capture callbacks when addListener is called + mockAddListener.mockImplementation((event: string, callback: Function) => { + if (event === 'keyboardWillShow') { + showCallback = callback; + } else if (event === 'keyboardWillHide') { + hideCallback = callback; + } + return { remove: jest.fn() }; + }); + }); + + it('initializes with keyboard hidden on Android', () => { + const { result } = renderHook(() => useAndroidKeyboard()); + + expect(result.current.isVisible).toBe(false); + expect(result.current.keyboardHeight).toBe(0); + expect(result.current.isAndroid).toBe(true); + }); + + it('does not set up listeners on non-Android platforms', () => { + mockGetPlatform.mockReturnValue('ios'); + + const { result } = renderHook(() => useAndroidKeyboard()); + + expect(result.current.isAndroid).toBe(false); + expect(mockAddListener).not.toHaveBeenCalled(); + }); + + it('does not set up listeners when Keyboard plugin unavailable', () => { + mockIsPluginAvailable.mockReturnValue(false); + + renderHook(() => useAndroidKeyboard()); + + expect(mockAddListener).not.toHaveBeenCalled(); + }); + + it('updates state when keyboard shows', async () => { + const { result } = renderHook(() => useAndroidKeyboard()); + + act(() => { + showCallback({ keyboardHeight: 350 }); + }); + + await waitFor(() => { + expect(result.current.isVisible).toBe(true); + expect(result.current.keyboardHeight).toBe(350); + }); + }); + + it('updates state when keyboard hides', async () => { + const { result } = renderHook(() => useAndroidKeyboard()); + + // Show keyboard first + act(() => { + showCallback({ keyboardHeight: 350 }); + }); + + await waitFor(() => { + expect(result.current.isVisible).toBe(true); + }); + + // Hide keyboard + act(() => { + hideCallback(); + }); + + await waitFor(() => { + 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()); + + act(() => { + showCallback({ keyboardHeight: null }); + }); + + await waitFor(() => { + expect(result.current.keyboardHeight).toBe(300); + }); + }); + + it('uses fallback height when keyboardHeight is undefined', async () => { + const { result } = renderHook(() => useAndroidKeyboard()); + + act(() => { + showCallback({}); + }); + + 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 }); + }); + + await waitFor(() => { + expect(result.current.keyboardHeight).toBe(0); + }); + }); + + it('sets CSS variable when keyboard shows', () => { + const setPropertySpy = jest.spyOn(document.documentElement.style, 'setProperty'); + + renderHook(() => useAndroidKeyboard()); + + act(() => { + showCallback({ keyboardHeight: 400 }); + }); + + expect(setPropertySpy).toHaveBeenCalledWith('--android-keyboard-height', '400px'); + }); + + it('clears CSS variable when keyboard hides', () => { + const setPropertySpy = jest.spyOn(document.documentElement.style, 'setProperty'); + + renderHook(() => useAndroidKeyboard()); + + act(() => { + hideCallback(); + }); + + expect(setPropertySpy).toHaveBeenCalledWith('--android-keyboard-height', '0px'); + }); + + it('does not update state if unmounted before listener setup completes', async () => { + let resolveListener: any; + + // Make addListener async to simulate delay + mockAddListener.mockImplementation(() => { + return new Promise((resolve) => { + resolveListener = resolve; + }); + }); + + const { result, unmount } = renderHook(() => useAndroidKeyboard()); + + // Unmount before listener setup completes + unmount(); + + // Now resolve the listener setup + const mockRemove = jest.fn(); + act(() => { + resolveListener?.({ remove: mockRemove }); + }); + + // Listener should be immediately removed since component unmounted + await waitFor(() => { + expect(mockRemove).toHaveBeenCalled(); + }); + }); + + it('does not update state when keyboard events fire after unmount', async () => { + const { result, unmount } = renderHook(() => useAndroidKeyboard()); + + const initialState = { + isVisible: result.current.isVisible, + keyboardHeight: result.current.keyboardHeight, + }; + + unmount(); + + // Try to trigger callbacks after unmount + act(() => { + showCallback({ keyboardHeight: 500 }); + }); + + // State should not have changed (we can't access result.current after unmount, + // but this test ensures no errors are thrown) + expect(() => { + showCallback({ keyboardHeight: 500 }); + hideCallback(); + }).not.toThrow(); + }); + + describe('getContainerStyle', () => { + it('returns base style when keyboard is hidden', () => { + const { result } = renderHook(() => useAndroidKeyboard()); + + const style = result.current.getContainerStyle({ color: 'red' }); + + expect(style).toEqual({ + color: 'red', + transition: 'transform 0.1s ease-out', + }); + }); + + it('returns base style on non-Android platforms', () => { + mockGetPlatform.mockReturnValue('ios'); + const { result } = renderHook(() => useAndroidKeyboard()); + + const style = result.current.getContainerStyle({ color: 'blue' }); + + expect(style).toEqual({ + color: 'blue', + transition: 'transform 0.1s ease-out', + }); + }); + + it('applies transform when keyboard is visible', async () => { + const { result } = renderHook(() => useAndroidKeyboard()); + + act(() => { + showCallback({ keyboardHeight: 400 }); + }); + + await waitFor(() => { + 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()); + + act(() => { + showCallback({ keyboardHeight: 400 }); + }); + + await waitFor(() => { + const style = result.current.getContainerStyle({}, 100); + + expect(style.transform).toBe('translateY(-300px)'); + }); + }); + + it('does not apply negative transform', async () => { + const { result } = renderHook(() => useAndroidKeyboard()); + + act(() => { + showCallback({ keyboardHeight: 50 }); + }); + + await waitFor(() => { + const style = result.current.getContainerStyle({}, 100); + + expect(style.transform).toBe(''); + }); + }); + + it('preserves existing transition if provided', async () => { + const { result } = renderHook(() => useAndroidKeyboard()); + + act(() => { + showCallback({ keyboardHeight: 400 }); + }); + + await waitFor(() => { + 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()); + + act(() => { + showCallback({ keyboardHeight: 400 }); + }); + + await waitFor(() => { + 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', () => { + const mockRemoveShow = jest.fn(); + const mockRemoveHide = jest.fn(); + + mockAddListener.mockImplementation((event: string) => { + if (event === 'keyboardWillShow') { + return { remove: mockRemoveShow }; + } else if (event === 'keyboardWillHide') { + return { remove: mockRemoveHide }; + } + return { remove: jest.fn() }; + }); + + const { unmount } = renderHook(() => useAndroidKeyboard()); + + unmount(); + + expect(mockRemoveShow).toHaveBeenCalled(); + expect(mockRemoveHide).toHaveBeenCalled(); + }); + + it('clears CSS variable on unmount', () => { + const setPropertySpy = jest.spyOn(document.documentElement.style, 'setProperty'); + + const { unmount } = renderHook(() => useAndroidKeyboard()); + + unmount(); + + expect(setPropertySpy).toHaveBeenCalledWith('--android-keyboard-height', '0px'); + }); + }); +}); diff --git a/hooks/useAndroidKeyboard.ts b/hooks/useAndroidKeyboard.ts index 1fa4f2c07e..86cdf7e892 100644 --- a/hooks/useAndroidKeyboard.ts +++ b/hooks/useAndroidKeyboard.ts @@ -30,6 +30,7 @@ export function useAndroidKeyboard(): AndroidKeyboardHookReturn { try { const showListener = await Keyboard.addListener('keyboardWillShow', (info) => { + if (!mounted) return; const height = info.keyboardHeight ?? 300; setKeyboardHeight(height); setIsVisible(true); @@ -37,12 +38,13 @@ export function useAndroidKeyboard(): AndroidKeyboardHookReturn { }); const hideListener = await Keyboard.addListener('keyboardWillHide', () => { + if (!mounted) return; setKeyboardHeight(0); setIsVisible(false); document.documentElement.style.setProperty('--android-keyboard-height', '0px'); }); - // If unmounted during async setup, remove listeners immediately + if (!mounted) { showListener.remove(); hideListener.remove(); From 65966024fcdbd9d2f2e51857c529075d6ba4f0d8 Mon Sep 17 00:00:00 2001 From: ragnep Date: Tue, 9 Dec 2025 13:05:34 +0200 Subject: [PATCH 5/6] wip Signed-off-by: ragnep --- .../brain/my-stream/layout/LayoutContext.test.tsx | 12 ++++++------ .../waves/drop/SingleWaveDropChat.test.tsx | 4 ++-- __tests__/hooks/useAndroidKeyboard.test.ts | 9 ++------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/__tests__/components/brain/my-stream/layout/LayoutContext.test.tsx b/__tests__/components/brain/my-stream/layout/LayoutContext.test.tsx index 678ef3a03b..14de060876 100644 --- a/__tests__/components/brain/my-stream/layout/LayoutContext.test.tsx +++ b/__tests__/components/brain/my-stream/layout/LayoutContext.test.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { render, screen, act } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { LayoutProvider, useLayout } from '@/components/brain/my-stream/layout/LayoutContext'; // Mock useCapacitor hook with configurable values @@ -56,7 +56,7 @@ function TestComponent() { describe('LayoutProvider', () => { it('calculates spaces and styles', () => { - Object.defineProperty(window, 'innerHeight', { value: 1000, configurable: true }); + Object.defineProperty(globalThis, 'innerHeight', { value: 1000, configurable: true }); render( @@ -71,7 +71,7 @@ describe('LayoutProvider', () => { mockCapacitorValues = { isCapacitor: true, isAndroid: true, isIos: false }; mockKeyboardValues = { isVisible: false, keyboardHeight: 0, isAndroid: true, getContainerStyle: jest.fn() }; - Object.defineProperty(window, 'innerHeight', { value: 1000, configurable: true }); + Object.defineProperty(globalThis, 'innerHeight', { value: 1000, configurable: true }); render( @@ -86,7 +86,7 @@ describe('LayoutProvider', () => { mockCapacitorValues = { isCapacitor: true, isAndroid: true, isIos: false }; mockKeyboardValues = { isVisible: true, keyboardHeight: 350, isAndroid: true, getContainerStyle: jest.fn() }; - Object.defineProperty(window, 'innerHeight', { value: 1000, configurable: true }); + Object.defineProperty(globalThis, 'innerHeight', { value: 1000, configurable: true }); render( @@ -102,7 +102,7 @@ describe('LayoutProvider', () => { mockCapacitorValues = { isCapacitor: true, isAndroid: false, isIos: true }; mockKeyboardValues = { isVisible: false, keyboardHeight: 0, isAndroid: false, getContainerStyle: jest.fn() }; - Object.defineProperty(window, 'innerHeight', { value: 1000, configurable: true }); + Object.defineProperty(globalThis, 'innerHeight', { value: 1000, configurable: true }); render( @@ -117,7 +117,7 @@ describe('LayoutProvider', () => { mockCapacitorValues = { isCapacitor: false, isAndroid: false, isIos: false }; mockKeyboardValues = { isVisible: false, keyboardHeight: 0, isAndroid: false, getContainerStyle: jest.fn() }; - Object.defineProperty(window, 'innerHeight', { value: 1000, configurable: true }); + Object.defineProperty(globalThis, 'innerHeight', { value: 1000, configurable: true }); render( diff --git a/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx b/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx index ad32cde439..3ed995b36c 100644 --- a/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx +++ b/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx @@ -1,4 +1,4 @@ -import { render, fireEvent, act, screen } from '@testing-library/react'; +import { render, fireEvent, act } from '@testing-library/react'; import React from 'react'; import { SingleWaveDropChat } from '@/components/waves/drop/SingleWaveDropChat'; @@ -24,7 +24,7 @@ jest.mock('@/components/waves/drops/wave-drops-all', () => ({ __esModule: true, jest.mock('@/components/waves/CreateDropWaveWrapper', () => ({ CreateDropWaveWrapper: ({ children }: any) =>
{children}
, CreateDropWaveWrapperContext: { SINGLE_DROP: 'SINGLE_DROP' } })); -jest.mock('@/components/waves/PrivilegedDropCreator', () => ({ __esModule: true, default: (props: any) => { capturedCreatorProps = props; return
; }, DropMode: { BOTH: 'BOTH' } })); +jest.mock('@/components/waves/PrivilegedDropCreator', () => ({ __esModule: true, default: (props: any) => { capturedCreatorProps = props; return
{ if (e.key === 'Enter' || e.key === ' ') props.onCancelReplyQuote(); }} data-part={props.activeDrop?.partId} data-action={props.activeDrop?.action} />; }, DropMode: { BOTH: 'BOTH' } })); // Mock window.matchMedia for useDeviceInfo hook Object.defineProperty(window, 'matchMedia', { diff --git a/__tests__/hooks/useAndroidKeyboard.test.ts b/__tests__/hooks/useAndroidKeyboard.test.ts index 1ba72a41c5..5f5c859519 100644 --- a/__tests__/hooks/useAndroidKeyboard.test.ts +++ b/__tests__/hooks/useAndroidKeyboard.test.ts @@ -174,7 +174,7 @@ describe('useAndroidKeyboard', () => { }); }); - const { result, unmount } = renderHook(() => useAndroidKeyboard()); + const { unmount } = renderHook(() => useAndroidKeyboard()); // Unmount before listener setup completes unmount(); @@ -192,12 +192,7 @@ describe('useAndroidKeyboard', () => { }); it('does not update state when keyboard events fire after unmount', async () => { - const { result, unmount } = renderHook(() => useAndroidKeyboard()); - - const initialState = { - isVisible: result.current.isVisible, - keyboardHeight: result.current.keyboardHeight, - }; + const { unmount } = renderHook(() => useAndroidKeyboard()); unmount(); From 7e8199440191f60d586d504de31a65e5569ec45f Mon Sep 17 00:00:00 2001 From: ragnep Date: Tue, 9 Dec 2025 13:17:02 +0200 Subject: [PATCH 6/6] wip Signed-off-by: ragnep --- __tests__/components/waves/drop/SingleWaveDropChat.test.tsx | 2 +- __tests__/hooks/useAndroidKeyboard.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx b/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx index 3ed995b36c..c802de3914 100644 --- a/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx +++ b/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx @@ -24,7 +24,7 @@ jest.mock('@/components/waves/drops/wave-drops-all', () => ({ __esModule: true, jest.mock('@/components/waves/CreateDropWaveWrapper', () => ({ CreateDropWaveWrapper: ({ children }: any) =>
{children}
, CreateDropWaveWrapperContext: { SINGLE_DROP: 'SINGLE_DROP' } })); -jest.mock('@/components/waves/PrivilegedDropCreator', () => ({ __esModule: true, default: (props: any) => { capturedCreatorProps = props; return
{ if (e.key === 'Enter' || e.key === ' ') props.onCancelReplyQuote(); }} data-part={props.activeDrop?.partId} data-action={props.activeDrop?.action} />; }, DropMode: { BOTH: 'BOTH' } })); +jest.mock('@/components/waves/PrivilegedDropCreator', () => ({ __esModule: true, default: (props: any) => { capturedCreatorProps = props; return