mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
feat: show contacts as list and details side by side on tablets
This commit is contained in:
@@ -46,7 +46,7 @@ import LoginScreen from './src/screens/LoginScreen';
|
||||
import MailTab from './src/screens/MailTab';
|
||||
import FilesScreen from './src/screens/FilesScreen';
|
||||
import CalendarScreen from './src/screens/CalendarScreen';
|
||||
import ContactsScreen from './src/screens/ContactsScreen';
|
||||
import ContactsTab from './src/screens/ContactsTab';
|
||||
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
||||
@@ -325,7 +325,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Contacts"
|
||||
component={ContactsScreen}
|
||||
component={ContactsTab}
|
||||
options={{
|
||||
title: tabLabels.contacts,
|
||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||
|
||||
@@ -19,6 +19,8 @@ interface ContactListRowProps {
|
||||
onPress?: () => void;
|
||||
onLongPress?: () => void;
|
||||
selected?: boolean;
|
||||
/** The contact open in the detail pane of a split view. */
|
||||
active?: boolean;
|
||||
selectionMode?: boolean;
|
||||
secondaryText?: string;
|
||||
}
|
||||
@@ -28,6 +30,7 @@ export default function ContactListRow({
|
||||
onPress,
|
||||
onLongPress,
|
||||
selected,
|
||||
active = false,
|
||||
selectionMode,
|
||||
secondaryText,
|
||||
}: ContactListRowProps) {
|
||||
@@ -52,6 +55,7 @@ export default function ContactListRow({
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
pressed && styles.rowPressed,
|
||||
active && styles.rowActive,
|
||||
selected && styles.rowSelected,
|
||||
]}
|
||||
>
|
||||
@@ -95,6 +99,13 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
rowSelected: { backgroundColor: c.primaryBg },
|
||||
// Same as the open message in the mail list (webmail bg-selection + bar).
|
||||
rowActive: {
|
||||
backgroundColor: c.selection,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: c.primary,
|
||||
paddingLeft: spacing.lg - 2,
|
||||
},
|
||||
checkbox: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { NavigationProvider, type NavigationProp, type ParamListBase } from '@react-navigation/native';
|
||||
|
||||
/** True inside the detail pane of a split view. */
|
||||
const PaneContext = React.createContext(false);
|
||||
|
||||
/** A screen in a split view's detail pane has no back button. */
|
||||
export function useInPane(): boolean {
|
||||
return React.useContext(PaneContext);
|
||||
}
|
||||
|
||||
/** Routes a pane shows itself instead of pushing them on the stack. */
|
||||
export type PaneTarget = { name: string; params?: object };
|
||||
|
||||
/**
|
||||
* Runs a stack screen inside a split view's detail pane. The screen's own
|
||||
* `useNavigation()` / `useRoute()` get the pane: `goBack` closes it, the
|
||||
* routes `onOpen` accepts open in it, everything else goes to the stack.
|
||||
*/
|
||||
export function PaneScreen({
|
||||
navigation,
|
||||
name,
|
||||
params,
|
||||
onOpen,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
navigation: NavigationProp<ParamListBase>;
|
||||
name: string;
|
||||
params: object;
|
||||
/** Return true when the pane shows this route itself. */
|
||||
onOpen: (target: PaneTarget) => boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const route = React.useMemo(() => ({ key: `pane-${name}`, name, params }), [name, params]);
|
||||
const paneNavigation = React.useMemo(() => {
|
||||
const open = (method: 'navigate' | 'push' | 'replace') => (...args: unknown[]) => {
|
||||
const [target, targetParams] = args as [string, object | undefined];
|
||||
if (typeof target === 'string' && onOpen({ name: target, params: targetParams })) return;
|
||||
// `push` and `replace` only exist on a stack; from a tab they become a
|
||||
// navigate, which reaches the root stack all the same.
|
||||
const real = navigation as unknown as Record<string, ((...a: unknown[]) => void) | undefined>;
|
||||
(real[method] ?? real.navigate)!(...args);
|
||||
};
|
||||
return {
|
||||
...navigation,
|
||||
goBack: onClose,
|
||||
navigate: open('navigate'),
|
||||
push: open('push'),
|
||||
replace: open('replace'),
|
||||
canGoBack: () => true,
|
||||
} as unknown as NavigationProp<ParamListBase>;
|
||||
}, [navigation, onOpen, onClose]);
|
||||
return (
|
||||
<PaneContext.Provider value>
|
||||
<NavigationProvider route={route} navigation={paneNavigation}>
|
||||
{children}
|
||||
</NavigationProvider>
|
||||
</PaneContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useInPane } from '../navigation/pane';
|
||||
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
@@ -132,6 +133,7 @@ export default function ContactDetailScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const route = useRoute<Route>();
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const inPane = useInPane();
|
||||
const { contactId } = route.params;
|
||||
|
||||
const contact = useContactsStore((s) => s.contacts.find((c) => c.id === contactId));
|
||||
@@ -360,16 +362,19 @@ export default function ContactDetailScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.headerBtn}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.back', 'Back')}
|
||||
>
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<View style={[styles.header, inPane && styles.headerInPane]}>
|
||||
{/* In a split view's pane the list is right next to it: no back. */}
|
||||
{!inPane && (
|
||||
<Pressable
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.headerBtn}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.back', 'Back')}
|
||||
>
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{isGroup(contact) ? t('contacts.group', 'Group') : t('contacts.contact', 'Contact')}
|
||||
</Text>
|
||||
@@ -911,7 +916,8 @@ function makeStyles(c: ThemePalette) {
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
scrollContent: { paddingBottom: spacing.xxxl * 2 },
|
||||
|
||||
header: {
|
||||
headerInPane: { paddingLeft: spacing.lg },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
|
||||
@@ -101,7 +101,14 @@ function categoryTitle(
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContactsScreen() {
|
||||
interface ContactsScreenProps {
|
||||
/** Split view: open the contact in the detail pane instead of the stack. */
|
||||
onOpenContact?: (contact: ContactCard) => void;
|
||||
/** The contact open in the detail pane. */
|
||||
activeContactId?: string | null;
|
||||
}
|
||||
|
||||
export default function ContactsScreen({ onOpenContact, activeContactId = null }: ContactsScreenProps = {}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const navigation = useNavigation<Nav>();
|
||||
@@ -210,7 +217,9 @@ export default function ContactsScreen() {
|
||||
toggleSelect(c.id);
|
||||
return;
|
||||
}
|
||||
if (isGroup(c)) {
|
||||
if (onOpenContact) {
|
||||
onOpenContact(c);
|
||||
} else if (isGroup(c)) {
|
||||
navigation.navigate('GroupDetail', { groupId: c.id });
|
||||
} else {
|
||||
navigation.navigate('ContactDetail', { contactId: c.id });
|
||||
@@ -541,6 +550,7 @@ export default function ContactsScreen() {
|
||||
onPress={() => handleRowPress(item)}
|
||||
onLongPress={() => handleLongPress(item)}
|
||||
selected={selection.has(item.id)}
|
||||
active={item.id === activeContactId}
|
||||
selectionMode={selectionMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View, useWindowDimensions } from 'react-native';
|
||||
import { useNavigation, type NavigationProp, type ParamListBase } from '@react-navigation/native';
|
||||
import { BookUser } from 'lucide-react-native';
|
||||
|
||||
import ContactsScreen from './ContactsScreen';
|
||||
import ContactDetailScreen from './ContactDetailScreen';
|
||||
import GroupDetailScreen from './GroupDetailScreen';
|
||||
import type { ContactCard } from '../api/types';
|
||||
import { isGroup } from '../lib/contact-utils';
|
||||
import { listPaneWidth, useLayout } from '../lib/use-layout';
|
||||
import { PaneScreen, type PaneTarget } from '../navigation/pane';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||
|
||||
type Open =
|
||||
| { name: 'ContactDetail'; params: { contactId: string } }
|
||||
| { name: 'GroupDetail'; params: { groupId: string } };
|
||||
|
||||
function openFor(contact: ContactCard): Open {
|
||||
return isGroup(contact)
|
||||
? { name: 'GroupDetail', params: { groupId: contact.id } }
|
||||
: { name: 'ContactDetail', params: { contactId: contact.id } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Contacts tab: the list on a phone; list and contact side by side on a
|
||||
* wide window, where the contact (or group) opens in the right pane.
|
||||
*/
|
||||
export default function ContactsTab() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const navigation = useNavigation<NavigationProp<ParamListBase>>();
|
||||
const { isSplit } = useLayout();
|
||||
const { width } = useWindowDimensions();
|
||||
const [open, setOpen] = React.useState<Open | null>(null);
|
||||
|
||||
// Narrowed with a contact open: hand it to the stack.
|
||||
const openRef = React.useRef(open);
|
||||
openRef.current = open;
|
||||
React.useEffect(() => {
|
||||
if (isSplit || !openRef.current) return;
|
||||
navigation.navigate(openRef.current.name, openRef.current.params);
|
||||
setOpen(null);
|
||||
}, [isSplit, navigation]);
|
||||
|
||||
const onOpen = React.useCallback((target: PaneTarget) => {
|
||||
if (target.name === 'ContactDetail' || target.name === 'GroupDetail') {
|
||||
setOpen(target as Open);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
const onClose = React.useCallback(() => setOpen(null), []);
|
||||
|
||||
if (!isSplit) return <ContactsScreen />;
|
||||
|
||||
const activeId = open ? ('contactId' in open.params ? open.params.contactId : open.params.groupId) : null;
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.listPane, { width: listPaneWidth(width) }]}>
|
||||
<ContactsScreen onOpenContact={(contact) => setOpen(openFor(contact))} activeContactId={activeId} />
|
||||
</View>
|
||||
<View style={styles.detailPane}>
|
||||
{open ? (
|
||||
<PaneScreen
|
||||
key={activeId ?? undefined}
|
||||
navigation={navigation}
|
||||
name={open.name}
|
||||
params={open.params}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
{open.name === 'ContactDetail' ? <ContactDetailScreen /> : <GroupDetailScreen />}
|
||||
</PaneScreen>
|
||||
) : (
|
||||
<View style={styles.empty}>
|
||||
<BookUser size={40} color={c.textMuted} strokeWidth={1.5} />
|
||||
<Text style={styles.emptyText}>{t('contacts.detail.no_contact_selected', 'Select a contact to view details')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
row: { flex: 1, flexDirection: 'row', backgroundColor: c.background },
|
||||
listPane: { borderRightWidth: 1, borderRightColor: c.border },
|
||||
detailPane: { flex: 1 },
|
||||
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.md, padding: spacing.xl },
|
||||
emptyText: { ...typography.body, color: c.textMuted, textAlign: 'center' },
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
View, Text, StyleSheet, Pressable, FlatList, Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useInPane } from '../navigation/pane';
|
||||
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ export default function GroupDetailScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const route = useRoute<Route>();
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const inPane = useInPane();
|
||||
const { groupId } = route.params;
|
||||
|
||||
const allContacts = useContactsStore((s) => s.contacts);
|
||||
@@ -132,16 +134,19 @@ export default function GroupDetailScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.headerBtn}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.back', 'Back')}
|
||||
>
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<View style={[styles.header, inPane && styles.headerInPane]}>
|
||||
{/* In a split view's pane the list is right next to it: no back. */}
|
||||
{!inPane && (
|
||||
<Pressable
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.headerBtn}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.back', 'Back')}
|
||||
>
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
@@ -263,6 +268,7 @@ export default function GroupDetailScreen() {
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
headerInPane: { paddingLeft: spacing.lg },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
Reference in New Issue
Block a user