mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 16:06:41 +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 MailTab from './src/screens/MailTab';
|
||||||
import FilesScreen from './src/screens/FilesScreen';
|
import FilesScreen from './src/screens/FilesScreen';
|
||||||
import CalendarScreen from './src/screens/CalendarScreen';
|
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 ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
||||||
@@ -325,7 +325,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
/>
|
/>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Contacts"
|
name="Contacts"
|
||||||
component={ContactsScreen}
|
component={ContactsTab}
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.contacts,
|
title: tabLabels.contacts,
|
||||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface ContactListRowProps {
|
|||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
onLongPress?: () => void;
|
onLongPress?: () => void;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
/** The contact open in the detail pane of a split view. */
|
||||||
|
active?: boolean;
|
||||||
selectionMode?: boolean;
|
selectionMode?: boolean;
|
||||||
secondaryText?: string;
|
secondaryText?: string;
|
||||||
}
|
}
|
||||||
@@ -28,6 +30,7 @@ export default function ContactListRow({
|
|||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
selected,
|
selected,
|
||||||
|
active = false,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
}: ContactListRowProps) {
|
}: ContactListRowProps) {
|
||||||
@@ -52,6 +55,7 @@ export default function ContactListRow({
|
|||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.row,
|
styles.row,
|
||||||
pressed && styles.rowPressed,
|
pressed && styles.rowPressed,
|
||||||
|
active && styles.rowActive,
|
||||||
selected && styles.rowSelected,
|
selected && styles.rowSelected,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -95,6 +99,13 @@ function makeStyles(c: ThemePalette) {
|
|||||||
},
|
},
|
||||||
rowPressed: { backgroundColor: c.surfaceHover },
|
rowPressed: { backgroundColor: c.surfaceHover },
|
||||||
rowSelected: { backgroundColor: c.primaryBg },
|
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: {
|
checkbox: {
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 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 FileSystem from 'expo-file-system/legacy';
|
||||||
import * as Sharing from 'expo-sharing';
|
import * as Sharing from 'expo-sharing';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useInPane } from '../navigation/pane';
|
||||||
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import {
|
import {
|
||||||
@@ -132,6 +133,7 @@ export default function ContactDetailScreen() {
|
|||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const route = useRoute<Route>();
|
const route = useRoute<Route>();
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const inPane = useInPane();
|
||||||
const { contactId } = route.params;
|
const { contactId } = route.params;
|
||||||
|
|
||||||
const contact = useContactsStore((s) => s.contacts.find((c) => c.id === contactId));
|
const contact = useContactsStore((s) => s.contacts.find((c) => c.id === contactId));
|
||||||
@@ -360,16 +362,19 @@ export default function ContactDetailScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={[styles.header, inPane && styles.headerInPane]}>
|
||||||
<Pressable
|
{/* In a split view's pane the list is right next to it: no back. */}
|
||||||
onPress={() => navigation.goBack()}
|
{!inPane && (
|
||||||
style={styles.headerBtn}
|
<Pressable
|
||||||
hitSlop={8}
|
onPress={() => navigation.goBack()}
|
||||||
accessibilityRole="button"
|
style={styles.headerBtn}
|
||||||
accessibilityLabel={t('common.back', 'Back')}
|
hitSlop={8}
|
||||||
>
|
accessibilityRole="button"
|
||||||
<ArrowLeft size={22} color={c.text} />
|
accessibilityLabel={t('common.back', 'Back')}
|
||||||
</Pressable>
|
>
|
||||||
|
<ArrowLeft size={22} color={c.text} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
{isGroup(contact) ? t('contacts.group', 'Group') : t('contacts.contact', 'Contact')}
|
{isGroup(contact) ? t('contacts.group', 'Group') : t('contacts.contact', 'Contact')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -911,7 +916,8 @@ function makeStyles(c: ThemePalette) {
|
|||||||
container: { flex: 1, backgroundColor: c.background },
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
scrollContent: { paddingBottom: spacing.xxxl * 2 },
|
scrollContent: { paddingBottom: spacing.xxxl * 2 },
|
||||||
|
|
||||||
header: {
|
headerInPane: { paddingLeft: spacing.lg },
|
||||||
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: spacing.sm,
|
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 c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
@@ -210,7 +217,9 @@ export default function ContactsScreen() {
|
|||||||
toggleSelect(c.id);
|
toggleSelect(c.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isGroup(c)) {
|
if (onOpenContact) {
|
||||||
|
onOpenContact(c);
|
||||||
|
} else if (isGroup(c)) {
|
||||||
navigation.navigate('GroupDetail', { groupId: c.id });
|
navigation.navigate('GroupDetail', { groupId: c.id });
|
||||||
} else {
|
} else {
|
||||||
navigation.navigate('ContactDetail', { contactId: c.id });
|
navigation.navigate('ContactDetail', { contactId: c.id });
|
||||||
@@ -541,6 +550,7 @@ export default function ContactsScreen() {
|
|||||||
onPress={() => handleRowPress(item)}
|
onPress={() => handleRowPress(item)}
|
||||||
onLongPress={() => handleLongPress(item)}
|
onLongPress={() => handleLongPress(item)}
|
||||||
selected={selection.has(item.id)}
|
selected={selection.has(item.id)}
|
||||||
|
active={item.id === activeContactId}
|
||||||
selectionMode={selectionMode}
|
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,
|
View, Text, StyleSheet, Pressable, FlatList, Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useInPane } from '../navigation/pane';
|
||||||
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,7 @@ export default function GroupDetailScreen() {
|
|||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const route = useRoute<Route>();
|
const route = useRoute<Route>();
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const inPane = useInPane();
|
||||||
const { groupId } = route.params;
|
const { groupId } = route.params;
|
||||||
|
|
||||||
const allContacts = useContactsStore((s) => s.contacts);
|
const allContacts = useContactsStore((s) => s.contacts);
|
||||||
@@ -132,16 +134,19 @@ export default function GroupDetailScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={[styles.header, inPane && styles.headerInPane]}>
|
||||||
<Pressable
|
{/* In a split view's pane the list is right next to it: no back. */}
|
||||||
onPress={() => navigation.goBack()}
|
{!inPane && (
|
||||||
style={styles.headerBtn}
|
<Pressable
|
||||||
hitSlop={8}
|
onPress={() => navigation.goBack()}
|
||||||
accessibilityRole="button"
|
style={styles.headerBtn}
|
||||||
accessibilityLabel={t('common.back', 'Back')}
|
hitSlop={8}
|
||||||
>
|
accessibilityRole="button"
|
||||||
<ArrowLeft size={22} color={c.text} />
|
accessibilityLabel={t('common.back', 'Back')}
|
||||||
</Pressable>
|
>
|
||||||
|
<ArrowLeft size={22} color={c.text} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
|
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -263,6 +268,7 @@ export default function GroupDetailScreen() {
|
|||||||
function makeStyles(c: ThemePalette) {
|
function makeStyles(c: ThemePalette) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: c.background },
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
|
headerInPane: { paddingLeft: spacing.lg },
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
Reference in New Issue
Block a user