From c7ea9495bdda6df866845e87bdbd9115f1570f85 Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 9 May 2026 22:42:06 +0200 Subject: [PATCH] (fix): weird keyboard behaviour in android app (qol): update todos --- .../java/net/tensamin/client/MainActivity.kt | 83 ++++++++++++++++++- apps/web/src/components/navbar.tsx | 4 +- apps/web/src/index.css | 12 +++ apps/web/src/index.tsx | 2 +- apps/web/src/routes/app/layout.tsx | 8 +- apps/web/todo.md | 1 + packages/chat/src/components/input.tsx | 2 +- packages/chat/src/screen.tsx | 68 ++++++++++++--- packages/chat/todo.md | 1 - 9 files changed, 157 insertions(+), 24 deletions(-) create mode 100644 apps/web/todo.md delete mode 100644 packages/chat/todo.md diff --git a/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/MainActivity.kt b/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/MainActivity.kt index 8a936d6..ba57286 100644 --- a/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/MainActivity.kt +++ b/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/MainActivity.kt @@ -1,11 +1,90 @@ package net.tensamin.client +import android.graphics.Rect import android.os.Bundle -import androidx.activity.enableEdgeToEdge +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.view.WindowManager +import android.widget.FrameLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat class MainActivity : TauriActivity() { + private var contentRoot: FrameLayout? = null + private var contentChild: android.view.View? = null + private var previousUsableHeight = 0 + private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null + override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() + WindowCompat.setDecorFitsSystemWindows(window, true) + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) super.onCreate(savedInstanceState) + installKeyboardResizeWorkaround() + } + + override fun onDestroy() { + attachLayoutListener?.let { listener -> + contentRoot?.viewTreeObserver?.removeOnGlobalLayoutListener(listener) + } + attachLayoutListener = null + contentRoot = null + contentChild = null + super.onDestroy() + } + + private fun installKeyboardResizeWorkaround() { + val content = window.decorView.findViewById(android.R.id.content) + contentRoot = content + + val listener = ViewTreeObserver.OnGlobalLayoutListener { + attachKeyboardResizeWorkaroundIfReady(content) + } + + attachLayoutListener = listener + content.viewTreeObserver.addOnGlobalLayoutListener(listener) + content.post { attachKeyboardResizeWorkaroundIfReady(content) } + } + + private fun attachKeyboardResizeWorkaroundIfReady(content: FrameLayout) { + if (contentChild != null) { + resizeContentForKeyboard(contentChild ?: return) + return + } + + val child = content.getChildAt(0) ?: return + contentChild = child + + ViewCompat.setOnApplyWindowInsetsListener(content) { _, insets -> + resizeContentForKeyboard(child, insets) + insets + } + + ViewCompat.requestApplyInsets(content) + resizeContentForKeyboard(child) + } + + private fun resizeContentForKeyboard( + child: android.view.View, + insets: WindowInsetsCompat? = ViewCompat.getRootWindowInsets(child), + ) { + val visibleFrame = Rect() + child.getWindowVisibleDisplayFrame(visibleFrame) + + val rootHeight = child.rootView.height + if (rootHeight <= 0) return + + val imeHeight = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0 + val keyboardHeight = maxOf(imeHeight, rootHeight - visibleFrame.bottom) + val keyboardVisible = keyboardHeight > rootHeight * 0.15 + val usableHeight = if (keyboardVisible) rootHeight - keyboardHeight else ViewGroup.LayoutParams.MATCH_PARENT + + if (previousUsableHeight == usableHeight) return + + val params = child.layoutParams + params.height = usableHeight + child.layoutParams = params + child.requestLayout() + previousUsableHeight = usableHeight } } diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 2df62aa..162a057 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -30,7 +30,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) { return (
{forMobile ? ( @@ -137,7 +137,7 @@ export function MobileNavbar() { const { setOpenMobile } = useSidebar(); return ( -
+
-
+
- +
+
diff --git a/apps/web/todo.md b/apps/web/todo.md new file mode 100644 index 0000000..790b489 --- /dev/null +++ b/apps/web/todo.md @@ -0,0 +1 @@ +- Make the sidebar use to make it feel less slow on mobile diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index 66354c8..e832b4a 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -93,7 +93,7 @@ export default function InputComponent({ className={cn( "rounded-none border-b-0 pt-0 pb-[env(safe-area-inset-bottom)] mx-2", isMobile - ? "border-0 border-t fixed w-full bottom-0 left-0 px-2" + ? "border-0 border-t w-full mx-0 px-2" : "rounded-t-xl", )} > diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index 6719d7c..37119b8 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -48,6 +48,8 @@ export default function Screen() { >({}); const measurementRefs = React.useRef(new Map()); const shouldRestoreBottomOnResizeRef = React.useRef(false); + const isAtBottomRef = React.useRef(true); + const shouldRestoreBottomOnFocusResizeRef = React.useRef(false); const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0; const hasSharedSecret = sharedSecret.length > 0; @@ -244,11 +246,9 @@ export default function Screen() { return; } - const handleResize = () => { - if (scrollRef.current) { - shouldRestoreBottomOnResizeRef.current = - getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold; - } + const restoreBottomAfterResize = () => { + shouldRestoreBottomOnResizeRef.current = + isAtBottomRef.current || shouldRestoreBottomOnFocusResizeRef.current; requestAnimationFrame(() => { virtualizer.measure(); @@ -259,16 +259,24 @@ export default function Screen() { } scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + isAtBottomRef.current = true; + shouldRestoreBottomOnFocusResizeRef.current = false; shouldRestoreBottomOnResizeRef.current = false; }); }); }; - window.addEventListener("resize", handleResize); + window.addEventListener("resize", restoreBottomAfterResize); + window.visualViewport?.addEventListener("resize", restoreBottomAfterResize); + return () => { - window.removeEventListener("resize", handleResize); + window.removeEventListener("resize", restoreBottomAfterResize); + window.visualViewport?.removeEventListener( + "resize", + restoreBottomAfterResize, + ); }; - }, [stickyBottomThreshold, virtualizer]); + }, [virtualizer]); React.useLayoutEffect(() => { const element = inputBoxRef.current; @@ -290,6 +298,34 @@ export default function Screen() { }; }, [inputBoxRef]); + React.useEffect(() => { + const element = inputBoxRef.current; + if (!element) { + return; + } + + const handleFocusIn = () => { + if (!scrollRef.current) { + return; + } + + shouldRestoreBottomOnFocusResizeRef.current = + getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold; + }; + + const handleFocusOut = () => { + shouldRestoreBottomOnFocusResizeRef.current = false; + }; + + element.addEventListener("focusin", handleFocusIn); + element.addEventListener("focusout", handleFocusOut); + + return () => { + element.removeEventListener("focusin", handleFocusIn); + element.removeEventListener("focusout", handleFocusOut); + }; + }, [inputBoxRef, stickyBottomThreshold]); + /** * Loads the next page when the scroll container reaches the top. * @returns Promise that resolves once pagination handling completes. @@ -325,6 +361,7 @@ export default function Screen() { } scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + isAtBottomRef.current = true; setHasScrolledToBottomInitially(true); }, [hasScrolledToBottomInitially, virtualRowCount]); @@ -341,6 +378,7 @@ export default function Screen() { if (shouldStickToBottom) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + isAtBottomRef.current = true; } } @@ -370,8 +408,13 @@ export default function Screen() { * @returns Void. */ const handleContainerScroll = React.useCallback((): void => { + if (scrollRef.current) { + isAtBottomRef.current = + getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold; + } + void onScroll(); - }, [onScroll]); + }, [onScroll, stickyBottomThreshold]); const [value, setValue] = React.useState(""); const totalSize = virtualizer.getTotalSize(); @@ -388,13 +431,12 @@ export default function Screen() { } return ( -
+
-
+