Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 2 additions & 11 deletions components/brain/content/BrainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,22 +16,17 @@ interface BrainContentProps {
readonly children: React.ReactNode;
readonly activeDrop: ActiveDropState | null;
readonly onCancelReplyQuote: () => void;
readonly keyboardAdjustment?: number;
readonly showPinnedWaves?: boolean;
}

const BrainContent: React.FC<BrainContentProps> = ({
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();
Expand Down Expand Up @@ -64,11 +58,8 @@ const BrainContent: React.FC<BrainContentProps> = ({
// 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 (
<div className="tw-relative tw-flex tw-flex-col tw-h-full" style={containerStyle}>
<div className="tw-relative tw-flex tw-flex-col tw-h-full">
{showPinnedWaves && (
<div
ref={setPinnedRef}
Expand Down
7 changes: 1 addition & 6 deletions components/brain/my-stream/MyStreamWaveChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import PrivilegedDropCreator, {
import { ApiDrop } from "@/generated/models/ApiDrop";
import { ApiWave } from "@/generated/models/ApiWave";
import { getHomeFeedRoute } from "@/helpers/navigation.helpers";
import { useAndroidKeyboard } from "@/hooks/useAndroidKeyboard";
import useDeviceInfo from "@/hooks/useDeviceInfo";
import { useWave } from "@/hooks/useWave";
import { selectEditingDropId } from "@/store/editSlice";
Expand All @@ -36,7 +35,6 @@ const MyStreamWaveChat: React.FC<MyStreamWaveChatProps> = ({ wave }) => {
const { isMemesWave } = useWave(wave);
const editingDropId = useSelector(selectEditingDropId);
const { isApp } = useDeviceInfo();
const { getContainerStyle } = useAndroidKeyboard();
const [activeDrop, setActiveDrop] = useState<ActiveDropState | null>(null);

// Handle URL parameters
Expand Down Expand Up @@ -69,10 +67,7 @@ const MyStreamWaveChat: React.FC<MyStreamWaveChatProps> = ({ wave }) => {
return `${baseStyles} ${heightClass}`;
}, []);

// Android keyboard adjustment style using centralized hook
const containerStyle = useMemo<React.CSSProperties>(() => {
return getContainerStyle(waveViewStyle || {}, 128);
}, [waveViewStyle, getContainerStyle]);
const containerStyle = waveViewStyle || {};

useEffect(() => setActiveDrop(null), [wave]);

Expand Down
20 changes: 15 additions & 5 deletions components/brain/my-stream/layout/LayoutContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Record<LayoutRefType, HTMLDivElement | null>>({
Expand Down Expand Up @@ -377,16 +381,22 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({
const waveViewStyle = useMemo<React.CSSProperties>(() => {
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<React.CSSProperties>(() => {
if (!spaces.measurementsComplete) return {};
Expand Down
209 changes: 51 additions & 158 deletions hooks/useAndroidKeyboard.ts
Original file line number Diff line number Diff line change
@@ -1,200 +1,93 @@
"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<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
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 mounted = true;
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');
});

// If unmounted during async setup, remove listeners immediately
if (!mounted) {
showListener.remove();
hideListener.remove();
return;
}

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);
mounted = false;
showCleanup?.();
hideCleanup?.();
document.documentElement.style.setProperty('--android-keyboard-height', '0px');
};
Comment thread
ragnep marked this conversation as resolved.
}, [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 };
}
}