(fix): weird keyboard behaviour in android app
Some checks failed
/ deploy (push) Has been cancelled
Some checks failed
/ deploy (push) Has been cancelled
(qol): update todos
This commit is contained in:
parent
8e5ed8217d
commit
c7ea9495bd
9 changed files with 157 additions and 24 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
1
apps/web/todo.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
- Make the sidebar use <Activity /> to make it feel less slow on mobile
|
||||||
|
|
@ -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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
- Mobile message box broken
|
|
||||||
Loading…
Reference in a new issue