diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx
index ea3289ff..7ca442ef 100644
--- a/src/app/(app)/_layout.tsx
+++ b/src/app/(app)/_layout.tsx
@@ -22,6 +22,7 @@ import { Text } from '@/components/ui/text';
import { useAnalytics } from '@/hooks/use-analytics';
import { useAppLifecycle } from '@/hooks/use-app-lifecycle';
import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle';
+import { getAppTabBarHeight } from '@/lib/app-shell-layout';
import { useAuthStore } from '@/lib/auth';
import { cacheManager } from '@/lib/cache/cache-manager';
import { logger } from '@/lib/logging';
@@ -393,7 +394,7 @@ export default function TabLayout() {
tabBarStyle: {
paddingBottom: Math.max(insets.bottom, 5),
paddingTop: 5,
- height: isLandscape ? 65 : Math.max(60 + insets.bottom, 60),
+ height: getAppTabBarHeight(insets.bottom, isLandscape),
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: -1 },
diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx
index 51b21837..5f1947c0 100644
--- a/src/app/(app)/chatbot.tsx
+++ b/src/app/(app)/chatbot.tsx
@@ -2,7 +2,8 @@ import { Redirect, useFocusEffect } from 'expo-router';
import { RefreshCw, Send, Sparkles } from 'lucide-react-native';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Platform } from 'react-native';
+import { useWindowDimensions } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { copyToClipboard } from '@/components/chat/chat-utils';
import { MessageActionsSheet } from '@/components/chat/message-actions-sheet';
@@ -14,11 +15,12 @@ import { FlatList } from '@/components/ui/flat-list';
import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
import { HStack } from '@/components/ui/hstack';
import { Input, InputField } from '@/components/ui/input';
-import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view';
+import { BottomAnchoredKeyboardView } from '@/components/ui/keyboard-avoiding-view';
import { Pressable } from '@/components/ui/pressable';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
+import { getAppTabBarHeight } from '@/lib/app-shell-layout';
import { type ChatMessageResultData } from '@/models/v4/chat';
import useAuthStore from '@/stores/auth/store';
import { useChatStore } from '@/stores/chat/store';
@@ -28,6 +30,11 @@ import { useToastStore } from '@/stores/toast/store';
export default function ChatbotScreen() {
const { t } = useTranslation();
+ // The assistant is a hidden tab, so the tab bar still sits below it and the keyboard
+ // already covers that strip — pad for the remainder only.
+ const insets = useSafeAreaInsets();
+ const { width, height } = useWindowDimensions();
+ const tabBarHeight = getAppTabBarHeight(insets.bottom, width > height);
const currentUserId = useAuthStore((s) => s.userId);
const chatStatus = useChatSystemStatus();
const isChatEnabled = chatStatus === 'enabled';
@@ -115,7 +122,7 @@ export default function ChatbotScreen() {
-
+
{ordered.length === 0 ? (
@@ -152,7 +159,7 @@ export default function ChatbotScreen() {
-
+
{/* Restricted actions for assistant messages: copy, pin (moderator), flag. */}
ref?.isReady?.() ?? false);
+
// Clear the badge count on app startup (native only — notifee has no web implementation)
if (Platform.OS !== 'web') {
notifee
diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx
index 24cbd0ad..d2146651 100644
--- a/src/app/chat/[channelId].tsx
+++ b/src/app/chat/[channelId].tsx
@@ -3,7 +3,6 @@ import { type Href, Redirect, Stack, useFocusEffect, useLocalSearchParams, useRo
import { Circle } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Platform } from 'react-native';
import { getPresence, uploadAttachment } from '@/api/chat/chat';
import { AckBanner } from '@/components/chat/ack-banner';
@@ -19,7 +18,7 @@ import { Button, ButtonText } from '@/components/ui/button';
import { Center } from '@/components/ui/center';
import { FlatList } from '@/components/ui/flat-list';
import { HStack } from '@/components/ui/hstack';
-import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view';
+import { BottomAnchoredKeyboardView } from '@/components/ui/keyboard-avoiding-view';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { Textarea, TextareaInput } from '@/components/ui/textarea';
@@ -304,7 +303,7 @@ export default function ChannelConversationScreen() {
useChatStore.getState().acknowledgeMessage(messageId)} />
-
+
{loading && ordered.length === 0 ? (
@@ -342,7 +341,7 @@ export default function ChannelConversationScreen() {
onTyping={(isTyping) => channelId && useChatStore.getState().sendTyping(channelId, isTyping)}
disabled={channel?.IsLocked && !isModerator}
/>
-
+
setGifOpen(false)} onSelect={handleSendGif} />
diff --git a/src/app/chat/thread/[messageId].tsx b/src/app/chat/thread/[messageId].tsx
index 99cb6116..8c1c35e4 100644
--- a/src/app/chat/thread/[messageId].tsx
+++ b/src/app/chat/thread/[messageId].tsx
@@ -1,7 +1,6 @@
import { Redirect, Stack, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Platform } from 'react-native';
import { getThread } from '@/api/chat/chat';
import { buildLocationMetadata } from '@/components/chat/chat-utils';
@@ -10,7 +9,7 @@ import { MessageComposer } from '@/components/chat/message-composer';
import { Box } from '@/components/ui/box';
import { Divider } from '@/components/ui/divider';
import { FlatList } from '@/components/ui/flat-list';
-import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view';
+import { BottomAnchoredKeyboardView } from '@/components/ui/keyboard-avoiding-view';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
@@ -114,7 +113,7 @@ export default function ThreadScreen() {
-
+
{root ? (
{t('chat.original_message')}
@@ -137,7 +136,7 @@ export default function ThreadScreen() {
{/* Threads carry text and location only; omitting the image/GIF callbacks keeps
those actions out of the composer instead of showing dead buttons. */}
undefined} placeholder={t('chat.reply_placeholder')} allowUrgent={false} />
-
+
);
}
diff --git a/src/components/ui/keyboard-avoiding-view/__tests__/bottom-anchored.test.tsx b/src/components/ui/keyboard-avoiding-view/__tests__/bottom-anchored.test.tsx
new file mode 100644
index 00000000..f9c1bad6
--- /dev/null
+++ b/src/components/ui/keyboard-avoiding-view/__tests__/bottom-anchored.test.tsx
@@ -0,0 +1,43 @@
+import { render, screen } from '@testing-library/react-native';
+import React from 'react';
+import { Text } from 'react-native';
+
+import { BottomAnchoredKeyboardView, keyboardPaddingBottom } from '../bottom-anchored';
+
+jest.mock('react-native-keyboard-controller', () => ({
+ useReanimatedKeyboardAnimation: () => ({ height: { value: 0 }, progress: { value: 0 } }),
+}));
+
+describe('keyboardPaddingBottom', () => {
+ it('leaves no gap while the keyboard is closed', () => {
+ expect(keyboardPaddingBottom(0, 0)).toBe(0);
+ });
+
+ it('pads by the full keyboard height, which the library reports negative', () => {
+ expect(keyboardPaddingBottom(-320, 0)).toBe(320);
+ });
+
+ it('subtracts chrome the keyboard already covers, such as a bottom tab bar', () => {
+ expect(keyboardPaddingBottom(-320, 60)).toBe(260);
+ });
+
+ it('never pads when the offset alone exceeds the keyboard', () => {
+ expect(keyboardPaddingBottom(-40, 60)).toBe(0);
+ });
+
+ it('clamps at zero so an unexpected positive height cannot pull the content down', () => {
+ expect(keyboardPaddingBottom(40, 0)).toBe(0);
+ });
+});
+
+describe('BottomAnchoredKeyboardView', () => {
+ it('renders its children', () => {
+ render(
+
+ composer
+
+ );
+
+ expect(screen.getByText('composer')).toBeTruthy();
+ });
+});
diff --git a/src/components/ui/keyboard-avoiding-view/bottom-anchored.tsx b/src/components/ui/keyboard-avoiding-view/bottom-anchored.tsx
new file mode 100644
index 00000000..0abc6069
--- /dev/null
+++ b/src/components/ui/keyboard-avoiding-view/bottom-anchored.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import React from 'react';
+import { StyleSheet, type ViewStyle } from 'react-native';
+import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller';
+import Reanimated, { useAnimatedStyle } from 'react-native-reanimated';
+
+interface BottomAnchoredKeyboardViewProps {
+ children: React.ReactNode;
+ /**
+ * Height in dp of any chrome sitting between this container and the bottom of the
+ * display — a bottom tab bar, most often. The keyboard already covers that chrome, so
+ * it is space this view must not pad for a second time.
+ */
+ offset?: number;
+ style?: ViewStyle;
+}
+
+/**
+ * Padding a bottom-anchored container owes the keyboard.
+ *
+ * `animatedHeight` is the library's keyboard value: 0 when closed, and negative while the
+ * keyboard is up, since it is published for `translateY`. Marked as a worklet so the
+ * animated style can call it on the UI thread.
+ */
+export const keyboardPaddingBottom = (animatedHeight: number, offset: number): number => {
+ 'worklet';
+
+ return Math.max(-animatedHeight - offset, 0);
+};
+
+/**
+ * Keyboard avoidance for a container whose bottom edge *is* the bottom of the screen.
+ *
+ * `KeyboardAvoidingView` derives its padding from where it believes it sits on screen:
+ * `frame.y + frame.height` versus `screenHeight - keyboardHeight`. Its `automaticOffset`
+ * prop is what makes that frame absolute — it asks the native side for
+ * `getLocationOnScreen` once per layout, over a promise. Under a native-stack header on
+ * Android that measurement is the entire fix, and when it is stale, rejected, or resolved
+ * mid-transition the padding comes up short by the header plus the status bar, which is
+ * exactly enough for the keyboard to sit over the composer.
+ *
+ * This view measures nothing. The container already ends at the bottom of the screen, so
+ * the gap it owes is precisely the keyboard height, on both platforms.
+ *
+ * Only for full-bleed screen content. Anything that does not reach the bottom of the
+ * screen (bottom sheets, inset cards) still needs a measuring variant — see
+ * `useKeyboardHeight` for the sheet case, which lives in its own native window.
+ */
+export const BottomAnchoredKeyboardView: React.FC = ({ children, offset = 0, style }) => {
+ // Also arms Android's resize mode, the way `KeyboardAvoidingView` did.
+ const { height } = useReanimatedKeyboardAnimation();
+ const animatedStyle = useAnimatedStyle(() => ({ paddingBottom: keyboardPaddingBottom(height.value, offset) }), [offset]);
+
+ return {children};
+};
+
+const styles = StyleSheet.create({
+ fill: { flex: 1 },
+});
diff --git a/src/components/ui/keyboard-avoiding-view/index.tsx b/src/components/ui/keyboard-avoiding-view/index.tsx
index 7fc72834..e0295304 100644
--- a/src/components/ui/keyboard-avoiding-view/index.tsx
+++ b/src/components/ui/keyboard-avoiding-view/index.tsx
@@ -1,2 +1,5 @@
'use client';
export { KeyboardAvoidingView } from 'react-native';
+// Prefer this on screens that run edge-to-edge to the bottom of the display: it skips
+// the screen-position measurement entirely. See the file for why that matters on Android.
+export { BottomAnchoredKeyboardView } from './bottom-anchored';
diff --git a/src/lib/__tests__/app-shell-layout.test.ts b/src/lib/__tests__/app-shell-layout.test.ts
new file mode 100644
index 00000000..1e7e0de4
--- /dev/null
+++ b/src/lib/__tests__/app-shell-layout.test.ts
@@ -0,0 +1,15 @@
+import { getAppTabBarHeight } from '@/lib/app-shell-layout';
+
+describe('getAppTabBarHeight', () => {
+ it('adds the bottom safe-area inset in portrait', () => {
+ expect(getAppTabBarHeight(34, false)).toBe(94);
+ });
+
+ it('uses the fixed landscape height, ignoring the inset', () => {
+ expect(getAppTabBarHeight(34, true)).toBe(65);
+ });
+
+ it('treats a negative inset as zero', () => {
+ expect(getAppTabBarHeight(-10, false)).toBe(60);
+ });
+});
diff --git a/src/lib/__tests__/navigation-ready.test.ts b/src/lib/__tests__/navigation-ready.test.ts
new file mode 100644
index 00000000..df0470b9
--- /dev/null
+++ b/src/lib/__tests__/navigation-ready.test.ts
@@ -0,0 +1,127 @@
+import { router } from 'expo-router';
+
+import { isNavigationReady, registerNavigationReadyCheck, routerPushWithRetry } from '../navigation';
+
+jest.mock('expo-router', () => ({
+ router: { push: jest.fn() },
+}));
+
+jest.mock('../logging', () => ({
+ logger: {
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+ },
+}));
+
+/**
+ * expo-router's `router.push` does not throw when the root layout has not mounted — it
+ * warns and discards the navigation. The original retry loop only retried inside a
+ * `catch`, so it never retried at all and cold-start deep links landed on the home
+ * screen. These tests pin the readiness gate that replaced it.
+ */
+describe('navigation readiness gate', () => {
+ const push = router.push as jest.Mock;
+ const href = { pathname: '/chat/[channelId]', params: { channelId: 'abc' } } as never;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ push.mockReset();
+ push.mockImplementation(() => undefined);
+ registerNavigationReadyCheck(null);
+ });
+
+ afterEach(() => {
+ registerNavigationReadyCheck(null);
+ jest.useRealTimers();
+ });
+
+ it('reports ready when nothing has registered a check', () => {
+ expect(isNavigationReady()).toBe(true);
+ });
+
+ it('reflects the registered check', () => {
+ let ready = false;
+ registerNavigationReadyCheck(() => ready);
+
+ expect(isNavigationReady()).toBe(false);
+
+ ready = true;
+ expect(isNavigationReady()).toBe(true);
+ });
+
+ it('does not push while the navigation container is not ready', async () => {
+ registerNavigationReadyCheck(() => false);
+
+ const pending = routerPushWithRetry(href, { maxAttempts: 3, retryDelayMs: 250 }).catch(() => 'rejected');
+
+ await jest.advanceTimersByTimeAsync(250 * 3);
+
+ await expect(pending).resolves.toBe('rejected');
+ expect(push).not.toHaveBeenCalled();
+ });
+
+ it('pushes as soon as the container becomes ready', async () => {
+ let ready = false;
+ registerNavigationReadyCheck(() => ready);
+
+ const pending = routerPushWithRetry(href, { maxAttempts: 20, retryDelayMs: 250 });
+
+ // Silent no-op window: the old implementation gave up here having pushed nothing.
+ await jest.advanceTimersByTimeAsync(500);
+ expect(push).not.toHaveBeenCalled();
+
+ ready = true;
+ await jest.advanceTimersByTimeAsync(250);
+
+ await expect(pending).resolves.toBeUndefined();
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(push).toHaveBeenCalledWith(href);
+ });
+
+ it('holds the push until the waitUntil gate opens', async () => {
+ let signedIn = false;
+ const pending = routerPushWithRetry(href, { maxAttempts: 20, retryDelayMs: 250, waitUntil: () => signedIn });
+
+ await jest.advanceTimersByTimeAsync(1000);
+ expect(push).not.toHaveBeenCalled();
+
+ signedIn = true;
+ await jest.advanceTimersByTimeAsync(250);
+
+ await expect(pending).resolves.toBeUndefined();
+ expect(push).toHaveBeenCalledTimes(1);
+ });
+
+ it('treats a throwing gate as not-ready and surfaces one error', async () => {
+ const pending = routerPushWithRetry(href, {
+ maxAttempts: 2,
+ retryDelayMs: 250,
+ waitUntil: () => {
+ throw new Error('auth store unavailable');
+ },
+ });
+
+ const settled = pending.catch((error: Error) => error.message);
+ await jest.advanceTimersByTimeAsync(250 * 2);
+
+ await expect(settled).resolves.toBe('auth store unavailable');
+ expect(push).not.toHaveBeenCalled();
+ });
+
+ it('still retries a router that throws once ready', async () => {
+ push
+ .mockImplementationOnce(() => {
+ throw new Error('router not ready');
+ })
+ .mockImplementationOnce(() => undefined);
+
+ const pending = routerPushWithRetry(href, { maxAttempts: 5, retryDelayMs: 250 });
+
+ await jest.advanceTimersByTimeAsync(250);
+
+ await expect(pending).resolves.toBeUndefined();
+ expect(push).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/lib/app-shell-layout.ts b/src/lib/app-shell-layout.ts
new file mode 100644
index 00000000..6657f206
--- /dev/null
+++ b/src/lib/app-shell-layout.ts
@@ -0,0 +1,14 @@
+const PORTRAIT_TAB_BAR_CONTENT_HEIGHT = 60;
+const LANDSCAPE_TAB_BAR_HEIGHT = 65;
+
+/**
+ * Height of the bottom tab bar, matching the `tabBarStyle` the app shell sets.
+ *
+ * Screens that lift their content over the soft keyboard need this: the tab bar sits
+ * between them and the bottom of the display, and the keyboard covers it, so that much of
+ * the keyboard is already accounted for.
+ */
+export const getAppTabBarHeight = (bottomInset: number, isLandscape: boolean): number => {
+ const safeBottomInset = Math.max(0, bottomInset);
+ return isLandscape ? LANDSCAPE_TAB_BAR_HEIGHT : PORTRAIT_TAB_BAR_CONTENT_HEIGHT + safeBottomInset;
+};
diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts
index 59f50f62..fab0045c 100644
--- a/src/lib/navigation.ts
+++ b/src/lib/navigation.ts
@@ -6,26 +6,72 @@ import { logger } from './logging';
export interface RouterPushRetryOptions {
maxAttempts?: number;
retryDelayMs?: number;
+ /**
+ * Extra gate the push waits on, beyond the router itself being mounted. Deep links
+ * use it to hold until the session has hydrated — pushing a protected route before
+ * then just gets the app redirected straight back out by the auth guard.
+ */
+ waitUntil?: () => boolean;
}
+let navigationReadyCheck: (() => boolean) | null = null;
+
+/**
+ * Publishes the navigation container's real readiness, registered by the root layout.
+ *
+ * `router.push` does NOT throw when the root layout has not mounted yet: expo-router
+ * logs a warning and drops the navigation on the floor. Retrying inside a `catch` was
+ * therefore waiting on an error that never arrived, which is why a push-notification
+ * tap that cold-started the app silently landed on the home screen instead of the
+ * target route.
+ *
+ * Defaults to ready when nothing has registered, so callers outside the app tree (and
+ * tests) behave exactly as before.
+ */
+export const registerNavigationReadyCheck = (check: (() => boolean) | null): void => {
+ navigationReadyCheck = check;
+};
+
+export const isNavigationReady = (): boolean => navigationReadyCheck?.() ?? true;
+
/**
- * Pushes an expo-router href, retrying when the router has not mounted yet
- * (cold-start deep links). Throws the last error once every attempt fails.
+ * Pushes an expo-router href, waiting for the router (and any caller-supplied gate) to
+ * be ready. Throws once the retry budget is exhausted.
*/
export const routerPushWithRetry = async (href: Href, options?: RouterPushRetryOptions): Promise => {
const maxAttempts = options?.maxAttempts ?? 1;
const retryDelayMs = options?.retryDelayMs ?? 250;
+ const waitUntil = options?.waitUntil;
+
+ let lastError: unknown;
for (let attempt = 1; ; attempt++) {
+ // Only push once the router can actually receive it — an early push is discarded
+ // without an error, so attempting regardless would burn the whole budget silently.
+ // A gate that throws counts as not-ready rather than aborting: the retry budget then
+ // surfaces one clear error instead of the navigation vanishing without explanation.
+ let ready: boolean;
try {
- router.push(href);
- return;
+ ready = isNavigationReady() && (waitUntil?.() ?? true);
} catch (error) {
- if (attempt >= maxAttempts) {
- throw error;
+ lastError = error;
+ ready = false;
+ }
+
+ if (ready) {
+ try {
+ router.push(href);
+ return;
+ } catch (error) {
+ lastError = error;
}
- await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
+
+ if (attempt >= maxAttempts) {
+ throw lastError ?? new Error('Navigation never became ready; the push was dropped.');
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
};
diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts
index 5259f1f0..3975ef49 100644
--- a/src/services/push-notification.ts
+++ b/src/services/push-notification.ts
@@ -44,7 +44,17 @@ export function handleChatDeepLink(eventCode: string): boolean {
if (!match) return false;
const channelId = match[2];
if (/[/\\?#]/.test(channelId)) return false;
- void routerPushWithRetry({ pathname: '/chat/[channelId]', params: { channelId } }, { maxAttempts: 20, retryDelayMs: 250 }).catch((error) => {
+ void routerPushWithRetry(
+ { pathname: '/chat/[channelId]', params: { channelId } },
+ {
+ maxAttempts: 40,
+ retryDelayMs: 250,
+ // On a cold start the session is still hydrating. Pushing a protected route before
+ // it settles gets the route replaced by the auth guard, which is indistinguishable
+ // from the tap doing nothing at all.
+ waitUntil: () => useAuthStore.getState().status === 'signedIn',
+ }
+ ).catch((error) => {
logger.error({ message: 'Failed to deep-link to chat channel', context: { error, eventCode } });
});
return true;