diff --git a/__tests__/app/screen-options.test.tsx b/__tests__/app/screen-options.test.tsx
new file mode 100644
index 000000000..67e325205
--- /dev/null
+++ b/__tests__/app/screen-options.test.tsx
@@ -0,0 +1,84 @@
+import { Stack, Tabs } from 'expo-router';
+
+import Feed from '@/app/(app)/index';
+import Style from '@/app/(app)/style';
+import Onboarding from '@/app/onboarding';
+import Login from '@/app/sign-in';
+import { render, screen } from '@/lib/test-utils';
+
+jest.mock('expo-router', () => ({
+ Stack: { Screen: jest.fn(() => null) },
+ Tabs: { Screen: jest.fn(() => null) },
+ Link: ({ children }: { children: React.ReactNode }) => children,
+ useRouter: jest.fn(() => ({ replace: jest.fn() })),
+}));
+
+jest.mock('@/components/ui', () => ({
+ ...jest.requireActual('@/components/ui'),
+ FocusAwareStatusBar: () => null,
+}));
+
+jest.mock('@/components/ui/icons', () => ({
+ Feed: () => null,
+ Style: () => null,
+}));
+
+jest.mock('@/components/buttons', () => ({ Buttons: () => null }));
+jest.mock('@/components/colors', () => ({ Colors: () => null }));
+jest.mock('@/components/inputs', () => ({ Inputs: () => null }));
+jest.mock('@/components/typography', () => ({ Typography: () => null }));
+jest.mock('@/components/cover', () => ({ Cover: () => null }));
+jest.mock('@/components/login-form', () => ({ LoginForm: () => null }));
+
+jest.mock('@/lib/hooks', () => ({
+ useIsFirstTime: jest.fn(() => [true, jest.fn()]),
+}));
+
+jest.mock('@/api/auth/use-login', () => ({
+ useLogin: jest.fn(() => ({ mutate: jest.fn(), isPending: false })),
+}));
+
+const mockStackScreen = Stack.Screen as unknown as jest.Mock;
+const mockTabsScreen = Tabs.Screen as unknown as jest.Mock;
+
+const optionsOf = (mock: jest.Mock) => mock.mock.calls[0][0].options;
+
+describe('screen options declared by each screen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('titles the feed tab and adds a create link to its header', () => {
+ render();
+
+ const options = optionsOf(mockTabsScreen);
+ expect(options).toMatchObject({
+ title: 'Feed',
+ tabBarButtonTestID: 'feed-tab',
+ });
+
+ render(options.headerRight());
+ expect(screen.getByText('Create')).toBeOnTheScreen();
+ });
+
+ it('titles the style tab', () => {
+ render();
+
+ expect(optionsOf(mockTabsScreen)).toMatchObject({
+ title: 'Style',
+ tabBarButtonTestID: 'style-tab',
+ });
+ });
+
+ it('hides the header on onboarding', () => {
+ render();
+
+ expect(optionsOf(mockStackScreen)).toEqual({ headerShown: false });
+ });
+
+ it('hides the header on sign in', () => {
+ render();
+
+ expect(optionsOf(mockStackScreen)).toEqual({ headerShown: false });
+ });
+});
diff --git a/__tests__/app/www.test.tsx b/__tests__/app/www.test.tsx
new file mode 100644
index 000000000..8e0ea9931
--- /dev/null
+++ b/__tests__/app/www.test.tsx
@@ -0,0 +1,85 @@
+import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
+import { WebView } from 'react-native-webview';
+
+import WWW from '@/app/www';
+import { render, screen } from '@/lib/test-utils';
+
+jest.mock('expo-router', () => ({
+ Stack: { Screen: jest.fn(() => null) },
+ useLocalSearchParams: jest.fn(),
+ useRouter: jest.fn(),
+}));
+
+jest.mock('react-native-webview', () => ({
+ WebView: jest.fn(() => null),
+}));
+
+const mockStackScreen = Stack.Screen as unknown as jest.Mock;
+const mockWebView = WebView as unknown as jest.Mock;
+const mockUseLocalSearchParams = useLocalSearchParams as jest.Mock;
+const mockUseRouter = useRouter as jest.Mock;
+
+describe('WWW', () => {
+ const back = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseRouter.mockReturnValue({ back });
+ });
+
+ it('opens the url in a modal titled with the title param', () => {
+ mockUseLocalSearchParams.mockReturnValue({
+ url: 'https://example.com',
+ title: 'Example',
+ });
+
+ render();
+
+ expect(mockStackScreen).toHaveBeenCalledTimes(1);
+ expect(mockStackScreen.mock.calls[0][0].options).toEqual({
+ presentation: 'modal',
+ title: 'Example',
+ });
+ expect(mockWebView.mock.calls[0][0].source).toEqual({
+ uri: 'https://example.com',
+ });
+ });
+
+ it('leaves the title empty when the title param is missing', () => {
+ mockUseLocalSearchParams.mockReturnValue({ url: 'https://example.com' });
+
+ render();
+
+ expect(mockStackScreen.mock.calls[0][0].options.title).toBe('');
+ });
+
+ it('goes back when the page fails to load', () => {
+ mockUseLocalSearchParams.mockReturnValue({ url: 'https://example.com' });
+
+ render();
+ mockWebView.mock.calls[0][0].onError();
+
+ expect(back).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows an error instead of the page when the url is missing', () => {
+ mockUseLocalSearchParams.mockReturnValue({});
+
+ render();
+
+ expect(screen.getByText('Invalid Url')).toBeOnTheScreen();
+ expect(mockWebView).not.toHaveBeenCalled();
+ expect(mockStackScreen).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows an error when the url param is repeated', () => {
+ mockUseLocalSearchParams.mockReturnValue({
+ url: ['https://a.example', 'https://b.example'],
+ });
+
+ render();
+
+ expect(screen.getByText('Invalid Url')).toBeOnTheScreen();
+ expect(mockWebView).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx
index 66e3b8b7d..1c5e6a3d2 100644
--- a/src/app/(app)/_layout.tsx
+++ b/src/app/(app)/_layout.tsx
@@ -1,13 +1,7 @@
-import { Link, Redirect, SplashScreen, Tabs } from 'expo-router';
+import { Redirect, SplashScreen, Stack, Tabs } from 'expo-router';
import { useCallback, useEffect } from 'react';
import { useAuth } from '@/components/providers/auth';
-import { Pressable, Text } from '@/components/ui';
-import {
- Feed as FeedIcon,
- Settings as SettingsIcon,
- Style as StyleIcon,
-} from '@/components/ui/icons';
import { useIsFirstTime } from '@/lib';
export default function TabLayout() {
@@ -30,42 +24,9 @@ export default function TabLayout() {
return ;
}
return (
-
- ,
- headerRight: () => ,
- tabBarButtonTestID: 'feed-tab',
- }}
- />
- ,
- tabBarButtonTestID: 'style-tab',
- }}
- />
- ,
- tabBarButtonTestID: 'settings-tab',
- }}
- />
-
- );
-}
-
-function CreateNewPostLink() {
- return (
-
-
- Create
-
-
+ <>
+
+
+ >
);
}
diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx
index b5ca90b4c..9550ca1b3 100644
--- a/src/app/(app)/index.tsx
+++ b/src/app/(app)/index.tsx
@@ -1,9 +1,40 @@
-import { FocusAwareStatusBar, View } from '@/components/ui';
+import { Link, Tabs } from 'expo-router';
+import { useTranslation } from 'react-i18next';
+
+import {
+ FocusAwareStatusBar,
+ Pressable,
+ Text,
+ View,
+} from '@/components/ui';
+import { Feed as FeedIcon } from '@/components/ui/icons';
export default function Feed() {
+ const { t } = useTranslation();
+
return (
+ ,
+ headerRight: () => ,
+ tabBarButtonTestID: 'feed-tab',
+ }}
+ />
);
}
+
+function CreateNewPostLink() {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t('tabs.create')}
+
+
+ );
+}
diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx
index 2ad5ed049..3207fe1fb 100644
--- a/src/app/(app)/settings.tsx
+++ b/src/app/(app)/settings.tsx
@@ -1,5 +1,5 @@
/* eslint-disable max-lines-per-function */
-import { Link } from 'expo-router';
+import { Link, Tabs } from 'expo-router';
import { useColorScheme } from 'nativewind';
import * as React from 'react';
import { showMessage } from 'react-native-flash-message';
@@ -18,7 +18,7 @@ import {
Text,
View,
} from '@/components/ui';
-import { Website } from '@/components/ui/icons';
+import { Settings as SettingsIcon, Website } from '@/components/ui/icons';
import { translate } from '@/lib';
import { Env } from '@/lib/env';
@@ -44,6 +44,13 @@ export default function Settings() {
return (
<>
+ ,
+ tabBarButtonTestID: 'settings-tab',
+ }}
+ />
diff --git a/src/app/(app)/style.tsx b/src/app/(app)/style.tsx
index 2731161e6..128d1a3bb 100644
--- a/src/app/(app)/style.tsx
+++ b/src/app/(app)/style.tsx
@@ -1,14 +1,26 @@
+import { Tabs } from 'expo-router';
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { Buttons } from '@/components/buttons';
import { Colors } from '@/components/colors';
import { Inputs } from '@/components/inputs';
import { Typography } from '@/components/typography';
import { FocusAwareStatusBar, SafeAreaView, ScrollView } from '@/components/ui';
+import { Style as StyleIcon } from '@/components/ui/icons';
export default function Style() {
+ const { t } = useTranslation();
+
return (
<>
+ ,
+ tabBarButtonTestID: 'style-tab',
+ }}
+ />
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index a105f26b8..5cf425777 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -4,7 +4,6 @@ import { ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import * as React from 'react';
-import { useTranslation } from 'react-i18next';
import { StyleSheet } from 'react-native';
import FlashMessage from 'react-native-flash-message';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
@@ -37,38 +36,24 @@ SplashScreen.setOptions({
function GuardedStack() {
const { isAuthenticated } = useAuth();
- const { t } = useTranslation();
const [isFirstTime] = useIsFirstTime();
return (
-
+
-
-
+
+
-
+
-
-
);
}
diff --git a/src/app/onboarding.tsx b/src/app/onboarding.tsx
index 308b7088d..db22264e0 100644
--- a/src/app/onboarding.tsx
+++ b/src/app/onboarding.tsx
@@ -1,4 +1,4 @@
-import { useRouter } from 'expo-router';
+import { Stack, useRouter } from 'expo-router';
import { Cover } from '@/components/cover';
import {
@@ -17,6 +17,7 @@ export default function Onboarding() {
return (
+
diff --git a/src/app/sign-in.tsx b/src/app/sign-in.tsx
index a12412c8f..69f50a712 100644
--- a/src/app/sign-in.tsx
+++ b/src/app/sign-in.tsx
@@ -1,4 +1,5 @@
import type { LoginFormProps } from '@/components/login-form';
+import { Stack } from 'expo-router';
import * as React from 'react';
import { showMessage } from 'react-native-flash-message';
@@ -16,6 +17,7 @@ export default function Login() {
};
return (
<>
+
>
diff --git a/src/app/update-password.tsx b/src/app/update-password.tsx
index 923f0740b..b8f9121af 100644
--- a/src/app/update-password.tsx
+++ b/src/app/update-password.tsx
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod';
-import { useNavigation } from 'expo-router';
+import { Stack, useNavigation } from 'expo-router';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -64,6 +64,7 @@ export default function UpdatePassword() {
return (
<>
+
diff --git a/src/app/www.tsx b/src/app/www.tsx
index 6c24b3268..8e38d49e9 100644
--- a/src/app/www.tsx
+++ b/src/app/www.tsx
@@ -1,6 +1,5 @@
-import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
+import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import * as React from 'react';
-import { useEffect } from 'react';
import { View } from 'react-native';
import { WebView } from 'react-native-webview';
@@ -10,33 +9,32 @@ import { translate } from '@/lib';
export default function WWW() {
const router = useRouter();
const { url, title } = useLocalSearchParams();
- const navigation = useNavigation();
- useEffect(() => {
- if (title !== undefined) {
- navigation.setOptions({
- title,
- });
- }
- }, [navigation, title]);
-
- if (url === undefined || typeof url !== 'string') {
- return (
-
-
- {translate('www.invalidUrl')}
-
-
- );
- }
+ const screenOptions = {
+ presentation: 'modal',
+ title: typeof title === 'string' ? title : '',
+ } as const;
return (
-
- router.back()}
- />
-
+ <>
+
+ {typeof url === 'string'
+ ? (
+
+ router.back()}
+ />
+
+ )
+ : (
+
+
+ {translate('www.invalidUrl')}
+
+
+ )}
+ >
);
}
diff --git a/src/translations/en.json b/src/translations/en.json
index 58d57b6eb..d4df1f96f 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -103,6 +103,11 @@
"version": "Version",
"website": "Website"
},
+ "tabs": {
+ "create": "Create",
+ "feed": "Feed",
+ "style": "Style"
+ },
"updatePassword": {
"buttonLabel": "Update Password",
"confirmPasswordLabel": "Confirm Password",