Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
import { NavigationContainer } from '@react-navigation/native';
import { AppNavigator } from './src/navigation';
import { useTheme } from './src/theme';
import { hardwareService, modelManager, authService, backgroundDownloadService } from './src/services';
import { hardwareService, modelManager, authService } from './src/services';
import logger from './src/utils/logger';
import { useAppStore, useAuthStore } from './src/stores';
import { LockScreen } from './src/screens';
Expand Down Expand Up @@ -75,10 +75,6 @@ function App() {
// Ensure persisted download metadata is loaded before restore logic reads it.
await ensureAppStoreHydrated();

// Request POST_NOTIFICATIONS permission on Android 13+ so system
// DownloadManager shows progress notifications.
backgroundDownloadService.requestNotificationPermission().catch((err) => logger.warn('Failed to request notification permission', err));

// Phase 1: Quick initialization - get app ready to show UI
// Initialize hardware detection
const deviceInfo = await hardwareService.getDeviceInfo();
Expand Down
90 changes: 90 additions & 0 deletions __tests__/rntl/components/ChatInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1830,4 +1830,94 @@ describe('ChatInput', () => {
jest.useRealTimers();
});
});

// ============================================================================
// Icon collapse animation (triggered by text content)
// ============================================================================
describe('icon collapse animation', () => {
it('starts Animated.timing to collapse when text is entered', () => {
const timingSpy = jest.spyOn(require('react-native').Animated, 'timing');
const { getByTestId } = render(<ChatInput {...defaultProps} />);

fireEvent.changeText(getByTestId('chat-input'), 'a');

expect(timingSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ toValue: 1 }),
);
timingSpy.mockRestore();
});

it('starts Animated.timing to expand when text is cleared', () => {
const timingSpy = jest.spyOn(require('react-native').Animated, 'timing');
const { getByTestId } = render(<ChatInput {...defaultProps} />);

fireEvent.changeText(getByTestId('chat-input'), 'a');
timingSpy.mockClear();
fireEvent.changeText(getByTestId('chat-input'), '');

expect(timingSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ toValue: 0 }),
);
timingSpy.mockRestore();
});

it('disables pointer events on pill icons when text is present', () => {
const { getByTestId, UNSAFE_queryAllByProps } = render(
<ChatInput {...defaultProps} />
);

// Before typing, icons should be interactive
expect(getByTestId('document-picker-button')).toBeTruthy();

fireEvent.changeText(getByTestId('chat-input'), 'hello');

// After typing, the Animated.View wrapping icons should have pointerEvents='none'
const pointerNoneViews = UNSAFE_queryAllByProps({ pointerEvents: 'none' });
expect(pointerNoneViews.length).toBeGreaterThan(0);
});

it('re-enables pointer events on pill icons when text is cleared', () => {
const { getByTestId, UNSAFE_queryAllByProps } = render(
<ChatInput {...defaultProps} />
);

fireEvent.changeText(getByTestId('chat-input'), 'hello');
fireEvent.changeText(getByTestId('chat-input'), '');

const pointerNoneViews = UNSAFE_queryAllByProps({ pointerEvents: 'none' });
expect(pointerNoneViews.length).toBe(0);
});

it('icons remain accessible when input is empty', () => {
const { getByTestId } = render(
<ChatInput {...defaultProps} supportsVision={true} imageModelLoaded={true} />
);

// All three icons should be pressable when no text
expect(getByTestId('document-picker-button')).toBeTruthy();
expect(getByTestId('camera-button')).toBeTruthy();
expect(getByTestId('image-mode-toggle')).toBeTruthy();
});

it('send button remains visible when text is entered', () => {
const { getByTestId } = render(
<ChatInput {...defaultProps} />
);

fireEvent.changeText(getByTestId('chat-input'), 'Hello');

// Send button should be accessible while typing
expect(getByTestId('send-button')).toBeTruthy();
});

it('stop button remains visible when generating with no text', () => {
const { getByTestId } = render(
<ChatInput {...defaultProps} isGenerating={true} onStop={jest.fn()} />
);

expect(getByTestId('stop-button')).toBeTruthy();
});
});
});
116 changes: 116 additions & 0 deletions __tests__/unit/hooks/useNotifRationale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { renderHook, act } from '@testing-library/react-native';
import { Platform, PermissionsAndroid } from 'react-native';
import { useNotifRationale } from '../../../src/screens/ModelsScreen/useNotifRationale';

const mockRequestNotificationPermission = jest.fn().mockResolvedValue(undefined);
jest.mock('../../../src/services', () => ({
backgroundDownloadService: {
get requestNotificationPermission() { return mockRequestNotificationPermission; },
},
}));

jest.mock('../../../src/utils/logger', () => ({
__esModule: true,
default: { warn: jest.fn() },
}));

function setupAndroid33(permissionGranted: boolean) {
Object.defineProperty(Platform, 'OS', { get: () => 'android' });
Object.defineProperty(Platform, 'Version', { get: () => 33 });
jest.spyOn(PermissionsAndroid, 'check').mockResolvedValue(permissionGranted);
}

async function renderAndTrigger(isFirstDownload: boolean) {
const proceed = jest.fn();
const hook = renderHook(() => useNotifRationale(isFirstDownload));
await act(async () => {
await hook.result.current.maybeShowNotifRationale(proceed);
});
return { ...hook, proceed };
}

describe('useNotifRationale', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('proceeds immediately when not first download', async () => {
const { result, proceed } = await renderAndTrigger(false);

expect(proceed).toHaveBeenCalled();
expect(result.current.showNotifRationale).toBe(false);
});

it('proceeds immediately on iOS', async () => {
Object.defineProperty(Platform, 'OS', { get: () => 'ios' });
const { result, proceed } = await renderAndTrigger(true);

expect(proceed).toHaveBeenCalled();
expect(result.current.showNotifRationale).toBe(false);
Object.defineProperty(Platform, 'OS', { get: () => 'android' });
});

it('proceeds immediately on Android < 33', async () => {
Object.defineProperty(Platform, 'OS', { get: () => 'android' });
Object.defineProperty(Platform, 'Version', { get: () => 32 });
const { result, proceed } = await renderAndTrigger(true);

expect(proceed).toHaveBeenCalled();
expect(result.current.showNotifRationale).toBe(false);
});

it('proceeds immediately when permission already granted', async () => {
setupAndroid33(true);
const { result, proceed } = await renderAndTrigger(true);

expect(proceed).toHaveBeenCalled();
expect(result.current.showNotifRationale).toBe(false);
});

it('shows rationale on Android 33+ first download without permission', async () => {
setupAndroid33(false);
const { result, proceed } = await renderAndTrigger(true);

expect(proceed).not.toHaveBeenCalled();
expect(result.current.showNotifRationale).toBe(true);
});

it('handleNotifRationaleAllow requests permission then proceeds', async () => {
setupAndroid33(false);
const { result, proceed } = await renderAndTrigger(true);
expect(result.current.showNotifRationale).toBe(true);

await act(async () => { result.current.handleNotifRationaleAllow(); });
await act(async () => { await Promise.resolve(); });

expect(mockRequestNotificationPermission).toHaveBeenCalled();
expect(proceed).toHaveBeenCalled();
expect(result.current.showNotifRationale).toBe(false);
});

it('only shows rationale once per session', async () => {
setupAndroid33(false);
const { result, proceed } = await renderAndTrigger(true);
expect(proceed).not.toHaveBeenCalled();

await act(async () => { result.current.handleNotifRationaleDismiss(); });

const proceed2 = jest.fn();
await act(async () => {
await result.current.maybeShowNotifRationale(proceed2);
});
expect(proceed2).toHaveBeenCalled();
expect(result.current.showNotifRationale).toBe(false);
});

it('handleNotifRationaleDismiss proceeds without requesting permission', async () => {
setupAndroid33(false);
const { result, proceed } = await renderAndTrigger(true);

await act(async () => { result.current.handleNotifRationaleDismiss(); });

expect(mockRequestNotificationPermission).not.toHaveBeenCalled();
expect(proceed).toHaveBeenCalled();
expect(result.current.showNotifRationale).toBe(false);
});
});
1 change: 0 additions & 1 deletion __tests__/unit/services/backgroundDownloadService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,7 +846,6 @@ describe('BackgroundDownloadService', () => {

expect(PermissionsAndroid.request).toHaveBeenCalledWith(
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
expect.any(Object),
);
});

Expand Down
30 changes: 24 additions & 6 deletions src/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React, { useState, useRef } from 'react';
import { View, TextInput, TouchableOpacity, Text } from 'react-native';
import React, { useState, useRef, useEffect } from 'react';
import { View, TextInput, TouchableOpacity, Text, Animated } from 'react-native';
import Icon from 'react-native-vector-icons/Feather';
import { useTheme, useThemedStyles } from '../../theme';
import { ImageModeState, MediaAttachment } from '../../types';
import { VoiceRecordButton } from '../VoiceRecordButton';
import { triggerHaptic } from '../../utils/haptics';
import { CustomAlert, showAlert, hideAlert, AlertState, initialAlertState } from '../CustomAlert';
import { createStyles } from './styles';
import { createStyles, PILL_ICONS_WIDTH, ANIM_DURATION_IN, ANIM_DURATION_OUT } from './styles';
import { QueueRow } from './Toolbar';
import { AttachmentPreview, useAttachments } from './Attachments';
import { useVoiceInput } from './Voice';
Expand Down Expand Up @@ -50,6 +50,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const [imageMode, setImageMode] = useState<ImageModeState>('auto');
const [alertState, setAlertState] = useState<AlertState>(initialAlertState);
const inputRef = useRef<TextInput>(null);
const hasText = message.length > 0;
const iconsAnim = useRef(new Animated.Value(0)).current;

useEffect(() => {
Animated.timing(iconsAnim, {
toValue: hasText ? 1 : 0,
duration: hasText ? ANIM_DURATION_IN : ANIM_DURATION_OUT,
useNativeDriver: false,
}).start();
}, [hasText, iconsAnim]);

const { attachments, removeAttachment, clearAttachments, handlePickImage, handlePickDocument } = useAttachments(setAlertState);

Expand Down Expand Up @@ -150,7 +160,15 @@ export const ChatInput: React.FC<ChatInputProps> = ({
blurOnSubmit={false}
returnKeyType="default"
/>
<View style={styles.pillIcons}>
{/* Icons collapse when user starts typing, reappear when input is empty */}
<Animated.View
pointerEvents={hasText ? 'none' : 'auto'}
style={[styles.pillIcons, {
width: iconsAnim.interpolate({ inputRange: [0, 1], outputRange: [PILL_ICONS_WIDTH, 0] }),
opacity: iconsAnim.interpolate({ inputRange: [0, 0.4], outputRange: [1, 0], extrapolate: 'clamp' }),
overflow: 'hidden' as const,
}]}
>
Comment on lines +164 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While overflow: 'hidden' is applied to the parent pill container, it is safer to also apply it directly to this Animated.View. This ensures that as the width collapses to 0, the icon buttons (which have a fixed width of 36) are clipped immediately and don't potentially overlap with the TextInput or other elements if the parent's clipping doesn't align perfectly with the shrinking container.

          <Animated.View
            pointerEvents={isFocused ? 'none' : 'auto'}
            style={[
              styles.pillIcons,
              {
                width: focusAnim.interpolate({ inputRange: [0, 1], outputRange: [PILL_ICONS_WIDTH, 0] }),
                opacity: focusAnim.interpolate({ inputRange: [0, 0.4], outputRange: [1, 0], extrapolate: 'clamp' }),
                overflow: 'hidden'
              }
            ]}
          >

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added overflow: 'hidden' directly on the icons Animated.View for safer clipping. Fixed in 299e6e0.

{/* Attachment button */}
<TouchableOpacity
testID="document-picker-button"
Expand Down Expand Up @@ -205,10 +223,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({
<Text style={styles.iconBadgeText}>{imgState.badge}</Text>
</View>
</TouchableOpacity>
</View>
</Animated.View>
</View>

{/* Circular action button */}
{/* Circular action button — always visible */}
{canSend ? (
<TouchableOpacity
testID="send-button"
Expand Down
13 changes: 10 additions & 3 deletions src/components/ChatInput/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import type { ThemeColors, ThemeShadows } from '../../theme';
import { FONTS } from '../../constants';
import { Platform } from 'react-native';

export const PILL_ICON_SIZE = 36;
const NUM_PILL_ICONS = 3;
export const PILL_ICONS_WIDTH = PILL_ICON_SIZE * NUM_PILL_ICONS;
export const ANIM_DURATION_IN = 180;
export const ANIM_DURATION_OUT = 200;

export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
container: {
paddingHorizontal: 12,
Expand Down Expand Up @@ -109,6 +115,7 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
borderRadius: 24,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden' as const,
paddingLeft: 14,
paddingRight: 4,
paddingVertical: 4,
Expand All @@ -134,11 +141,11 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
gap: 0,
},
pillIconButton: {
width: 36,
height: 36,
width: PILL_ICON_SIZE,
height: PILL_ICON_SIZE,
alignItems: 'center' as const,
justifyContent: 'center' as const,
borderRadius: 18,
borderRadius: PILL_ICON_SIZE / 2,
position: 'relative' as const,
},
pillIconButtonActive: {
Expand Down
10 changes: 10 additions & 0 deletions src/screens/ModelsScreen/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,16 @@ export const ModelsScreen: React.FC = () => {
)}

<CustomAlert {...vm.alertState} onClose={() => vm.setAlertState(hideAlert())} />
<CustomAlert
visible={vm.showNotifRationale}
title="Download notifications"
message={"Off Grid can show download progress in your notification tray while you use other apps.\n\nThis is optional — downloads work fine without it."}
onClose={vm.handleNotifRationaleDismiss}
buttons={[
{ text: 'No thanks', style: 'cancel', onPress: vm.handleNotifRationaleDismiss },
{ text: 'Allow', style: 'default', onPress: vm.handleNotifRationaleAllow },
]}
/>
</SafeAreaView>
);
};
Loading
Loading