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;