diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx
index edb1b57..2fc0632 100644
--- a/src/app/(app)/_layout.tsx
+++ b/src/app/(app)/_layout.tsx
@@ -23,7 +23,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 { getAppHeaderHeight } from '@/lib/app-shell-layout';
+import { getAppHeaderHeight, getAppTabBarHeight } from '@/lib/app-shell-layout';
import { useAuthStore } from '@/lib/auth';
import { enforceCommandAppAccess } from '@/lib/auth/command-app-access';
import { logger } from '@/lib/logging';
@@ -382,7 +382,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 c9d5254..836b973 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 { FlatList, Platform } from 'react-native';
+import { FlatList, 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';
@@ -13,10 +14,11 @@ import { Center } from '@/components/ui/center';
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 { 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';
@@ -26,6 +28,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 chatbotChannelId = useChatStore((s) => s.chatbotChannelId);
const chatbotTyping = useChatStore((s) => s.chatbotTyping);
@@ -112,7 +119,7 @@ export default function ChatbotScreen() {
-
+
{inverted.length === 0 ? (
@@ -141,7 +148,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 a715819..0239082 100644
--- a/src/app/chat/[channelId].tsx
+++ b/src/app/chat/[channelId].tsx
@@ -3,7 +3,7 @@ import { type Href, Redirect, Stack, useFocusEffect, useLocalSearchParams, useRo
import { Archive, ArrowLeft, Circle, ShieldCheck } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { FlatList, Platform } from 'react-native';
+import { FlatList } from 'react-native';
import { getPresence, uploadAttachment } from '@/api/chat/chat';
import { AckBanner } from '@/components/chat/ack-banner';
@@ -19,7 +19,7 @@ import { Button, ButtonText } from '@/components/ui/button';
import { Center } from '@/components/ui/center';
import { HStack } from '@/components/ui/hstack';
import { Icon } from '@/components/ui/icon';
-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';
@@ -392,7 +392,7 @@ export default function ChannelConversationScreen() {
useChatStore.getState().acknowledgeMessage(messageId)} />
-
+
{loading && inverted.length === 0 ? (
@@ -420,7 +420,7 @@ export default function ChannelConversationScreen() {
onTyping={(isTyping) => channelId && useChatStore.getState().sendTyping(channelId, isTyping)}
disabled={isFrozen || (channel?.IsLocked && !isModerator)}
/>
-
+
setGifOpen(false)} onSelect={handleSendGif} />
diff --git a/src/app/chat/thread/[messageId].tsx b/src/app/chat/thread/[messageId].tsx
index 788a924..ba2683c 100644
--- a/src/app/chat/thread/[messageId].tsx
+++ b/src/app/chat/thread/[messageId].tsx
@@ -1,7 +1,7 @@
import { Redirect, Stack, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { FlatList, Platform } from 'react-native';
+import { FlatList } from 'react-native';
import { getChannel, getThread } from '@/api/chat/chat';
import { buildLocationMetadata } from '@/components/chat/chat-utils';
@@ -9,7 +9,7 @@ import { MessageBubble } from '@/components/chat/message-bubble';
import { MessageComposer } from '@/components/chat/message-composer';
import { Box } from '@/components/ui/box';
import { Divider } from '@/components/ui/divider';
-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';
@@ -164,7 +164,7 @@ export default function ThreadScreen() {
-
+
{root ? (
{t('chat.original_message')}
@@ -179,7 +179,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} disabled={!channel || isFrozen} />
-
+
);
}
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 0000000..f9c1bad
--- /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 0000000..0abc606
--- /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 7fc7283..e029530 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
index eb0d923..756950a 100644
--- a/src/lib/__tests__/app-shell-layout.test.ts
+++ b/src/lib/__tests__/app-shell-layout.test.ts
@@ -1,4 +1,4 @@
-import { getAppHeaderHeight } from '@/lib/app-shell-layout';
+import { getAppHeaderHeight, getAppTabBarHeight } from '@/lib/app-shell-layout';
describe('getAppHeaderHeight', () => {
it('keeps the safe-area inset while using a compact portrait header', () => {
@@ -10,3 +10,17 @@ describe('getAppHeaderHeight', () => {
expect(getAppHeaderHeight(-10, false)).toBe(44);
});
});
+
+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 0000000..df0470b
--- /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
index 324a349..249e179 100644
--- a/src/lib/app-shell-layout.ts
+++ b/src/lib/app-shell-layout.ts
@@ -5,3 +5,18 @@ export const getAppHeaderHeight = (topInset: number, isLandscape: boolean): numb
const safeTopInset = Math.max(0, topInset);
return safeTopInset + (isLandscape ? LANDSCAPE_HEADER_CONTENT_HEIGHT : PORTRAIT_HEADER_CONTENT_HEIGHT);
};
+
+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 59f50f6..fab0045 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/__tests__/push-notification.test.ts b/src/services/__tests__/push-notification.test.ts
index ab7fcba..730c402 100644
--- a/src/services/__tests__/push-notification.test.ts
+++ b/src/services/__tests__/push-notification.test.ts
@@ -47,12 +47,14 @@ jest.mock('@/api/devices/push', () => ({
registerDevice: (...args: unknown[]) => mockRegisterDevice(...args),
}));
-jest.mock('@/lib/auth', () => ({
- useAuthStore: jest.fn((selector) => {
- const state = { userId: 'test-user' };
- return selector ? selector(state) : state;
- }),
-}));
+jest.mock('@/lib/auth', () => {
+ // handleChatDeepLink gates the cold-start push on a hydrated session, so the mock has to
+ // answer getState() as well as being callable as a selector hook.
+ const state = { userId: 'test-user', status: 'signedIn' };
+ const store: any = jest.fn((selector: any) => (selector ? selector(state) : state));
+ store.getState = () => state;
+ return { useAuthStore: store };
+});
jest.mock('@/stores/app/core-store', () => ({
useCoreStore: {
diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts
index 61fbd61..c2d5d70 100644
--- a/src/services/push-notification.ts
+++ b/src/services/push-notification.ts
@@ -43,7 +43,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;