(fix): weird keyboard behaviour in android app
Some checks failed
/ deploy (push) Has been cancelled

(qol): update todos
This commit is contained in:
Alois 2026-05-09 22:42:06 +02:00
commit c7ea9495bd
9 changed files with 157 additions and 24 deletions

View file

@ -1,11 +1,90 @@
package net.tensamin.client package net.tensamin.client
import android.graphics.Rect
import android.os.Bundle 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() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() WindowCompat.setDecorFitsSystemWindows(window, true)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
super.onCreate(savedInstanceState) 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<FrameLayout>(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
} }
} }

View file

@ -30,7 +30,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className={`${forMobile && "border-b"} w-full gap-2 h-13.5 flex items-center justify-between`} className={`${forMobile && "border-b"} w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
> >
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{forMobile ? ( {forMobile ? (
@ -137,7 +137,7 @@ export function MobileNavbar() {
const { setOpenMobile } = useSidebar(); const { setOpenMobile } = useSidebar();
return ( return (
<div className="z-51 w-full pb-[env(safe-area-inset-bottom)] bg-card border-t"> <div className="z-51 w-full shrink-0 pb-[env(safe-area-inset-bottom)] bg-card border-t">
<div className="z-51 w-full h-15 grid grid-cols-3 items-center place-items-center px-5"> <div className="z-51 w-full h-15 grid grid-cols-3 items-center place-items-center px-5">
<SidebarTrigger <SidebarTrigger
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1" className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1"

View file

@ -5,3 +5,15 @@
@source "./**/*.{ts,tsx}"; @source "./**/*.{ts,tsx}";
@source "../../../packages/**/src/**/*.{ts,tsx}"; @source "../../../packages/**/src/**/*.{ts,tsx}";
html,
body,
#root {
height: 100%;
margin: 0;
overflow: hidden;
}
#root {
min-height: 0;
}

View file

@ -110,7 +110,7 @@ function RootShell() {
return ( return (
<DeeplinkContext> <DeeplinkContext>
<ThemeProvider> <ThemeProvider>
<div className="w-screen h-screen overflow-hidden"> <div className="w-screen h-dvh overflow-hidden">
<Toaster <Toaster
position={isMobile ? "top-center" : "bottom-right"} position={isMobile ? "top-center" : "bottom-right"}
{...(isTauri() && isMobile {...(isTauri() && isMobile

View file

@ -18,13 +18,13 @@ export default function Layout({ children }: { children: ReactNode }) {
const showMobileNavbar = useShowMobileNavbar(); const showMobileNavbar = useShowMobileNavbar();
return ( return (
<div className="w-full h-full flex bg-sidebar"> <div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar">
<SidebarProvider> <SidebarProvider className="h-full min-h-0 overflow-hidden">
<Sidebar /> <Sidebar />
<div <div
// Background of ui that is overlapping with the system ui // Background of ui that is overlapping with the system ui
className={cn( className={cn(
"w-full h-full flex flex-col", "w-full h-full min-h-0 flex flex-col overflow-hidden",
isTauri() && isMobile && "pt-[env(safe-area-inset-top)]", isTauri() && isMobile && "pt-[env(safe-area-inset-top)]",
isMobile && showMobileNavbar && "bg-background", isMobile && showMobileNavbar && "bg-background",
)} )}
@ -34,7 +34,7 @@ export default function Layout({ children }: { children: ReactNode }) {
<div <div
className={cn( className={cn(
"bg-background h-full w-full", "bg-background min-h-0 flex-1 w-full overflow-hidden",
!isMobile && "rounded-tl-3xl border-t border-l", !isMobile && "rounded-tl-3xl border-t border-l",
)} )}
> >

1
apps/web/todo.md Normal file
View file

@ -0,0 +1 @@
- Make the sidebar use <Activity /> to make it feel less slow on mobile

View file

@ -93,7 +93,7 @@ export default function InputComponent({
className={cn( className={cn(
"rounded-none border-b-0 pt-0 pb-[env(safe-area-inset-bottom)] mx-2", "rounded-none border-b-0 pt-0 pb-[env(safe-area-inset-bottom)] mx-2",
isMobile 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", : "rounded-t-xl",
)} )}
> >

View file

@ -48,6 +48,8 @@ export default function Screen() {
>({}); >({});
const measurementRefs = React.useRef(new Map<number, HTMLDivElement>()); const measurementRefs = React.useRef(new Map<number, HTMLDivElement>());
const shouldRestoreBottomOnResizeRef = React.useRef(false); const shouldRestoreBottomOnResizeRef = React.useRef(false);
const isAtBottomRef = React.useRef(true);
const shouldRestoreBottomOnFocusResizeRef = React.useRef(false);
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0; const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
const hasSharedSecret = sharedSecret.length > 0; const hasSharedSecret = sharedSecret.length > 0;
@ -244,11 +246,9 @@ export default function Screen() {
return; return;
} }
const handleResize = () => { const restoreBottomAfterResize = () => {
if (scrollRef.current) { shouldRestoreBottomOnResizeRef.current =
shouldRestoreBottomOnResizeRef.current = isAtBottomRef.current || shouldRestoreBottomOnFocusResizeRef.current;
getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold;
}
requestAnimationFrame(() => { requestAnimationFrame(() => {
virtualizer.measure(); virtualizer.measure();
@ -259,16 +259,24 @@ export default function Screen() {
} }
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
isAtBottomRef.current = true;
shouldRestoreBottomOnFocusResizeRef.current = false;
shouldRestoreBottomOnResizeRef.current = false; shouldRestoreBottomOnResizeRef.current = false;
}); });
}); });
}; };
window.addEventListener("resize", handleResize); window.addEventListener("resize", restoreBottomAfterResize);
window.visualViewport?.addEventListener("resize", restoreBottomAfterResize);
return () => { return () => {
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", restoreBottomAfterResize);
window.visualViewport?.removeEventListener(
"resize",
restoreBottomAfterResize,
);
}; };
}, [stickyBottomThreshold, virtualizer]); }, [virtualizer]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const element = inputBoxRef.current; const element = inputBoxRef.current;
@ -290,6 +298,34 @@ export default function Screen() {
}; };
}, [inputBoxRef]); }, [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. * Loads the next page when the scroll container reaches the top.
* @returns Promise that resolves once pagination handling completes. * @returns Promise that resolves once pagination handling completes.
@ -325,6 +361,7 @@ export default function Screen() {
} }
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
isAtBottomRef.current = true;
setHasScrolledToBottomInitially(true); setHasScrolledToBottomInitially(true);
}, [hasScrolledToBottomInitially, virtualRowCount]); }, [hasScrolledToBottomInitially, virtualRowCount]);
@ -341,6 +378,7 @@ export default function Screen() {
if (shouldStickToBottom) { if (shouldStickToBottom) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
isAtBottomRef.current = true;
} }
} }
@ -370,8 +408,13 @@ export default function Screen() {
* @returns Void. * @returns Void.
*/ */
const handleContainerScroll = React.useCallback((): void => { const handleContainerScroll = React.useCallback((): void => {
if (scrollRef.current) {
isAtBottomRef.current =
getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold;
}
void onScroll(); void onScroll();
}, [onScroll]); }, [onScroll, stickyBottomThreshold]);
const [value, setValue] = React.useState(""); const [value, setValue] = React.useState("");
const totalSize = virtualizer.getTotalSize(); const totalSize = virtualizer.getTotalSize();
@ -388,13 +431,12 @@ export default function Screen() {
} }
return ( return (
<div className="relative w-full h-full overflow-hidden px-2"> <div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden px-2">
<div <div
ref={scrollRef} ref={scrollRef}
id="chat_container" id="chat_container"
className="absolute inset-x-0 top-0 overflow-y-auto px-2.5" className="min-h-0 flex-1 overflow-y-auto px-2.5"
style={{ style={{
bottom: `${inputHeight}px`,
overflowAnchor: "none", overflowAnchor: "none",
paddingBottom: "8px", paddingBottom: "8px",
}} }}
@ -459,7 +501,7 @@ export default function Screen() {
})} })}
</div> </div>
</div> </div>
<div className="absolute inset-x-0 bottom-0 z-10"> <div className="z-10 shrink-0">
<InputComponent setValue={setValue} value={value} /> <InputComponent setValue={setValue} value={value} />
</div> </div>
<div <div

View file

@ -1 +0,0 @@
- Mobile message box broken