Files
native/src/screens/UnifiedInboxScreen.tsx
T

648 lines
27 KiB
TypeScript

import React from 'react';
import {
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable, TextInput, Alert, AppState,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useDensity, useTypography } from '../theme/dynamic';
import { useFocusEffect } from '@react-navigation/native';
import {
ArrowLeft, Star, Paperclip, AlertTriangle, Search, X, Square, SquareCheck,
Mail as MailIcon, MailOpen, Archive, Trash2, ShieldAlert, ShieldCheck, Folder,
} from '../components/icons';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
import {
fetchUnifiedInbox, patchUnifiedKeywords, moveUnifiedEmails, deleteUnifiedEmails, isInUnifiedTrash,
type UnifiedEmail, type UnifiedRole, type CrossView,
} from '../api/unified-inbox';
import { useAccountStore } from '../stores/account-store';
import { useAuthStore } from '../stores/auth-store';
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
import { useLocaleStore } from '../stores/locale-store';
import { formatListDate } from '../lib/date-format';
import { singleLine } from '../lib/single-line';
import { prefetchMessage, rememberRows } from '../lib/email-detail-cache';
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
import { onStateChangeType } from '../lib/state-change-bus';
import { describeSenderVerification, senderWarningOf } from '../lib/email-headers';
import { createUnifiedReloadTracker } from '../lib/unified-reload';
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type Props = NativeStackScreenProps<RootStackParamList, 'UnifiedInbox'>;
const PAGE_SIZE = 25;
function senderName(email: UnifiedEmail, showRecipient: boolean, unknown: string): string {
if (showRecipient) {
const to = email.to?.[0];
if (to) return to.name || to.email;
}
return email.from?.[0]?.name || email.from?.[0]?.email || unknown;
}
function rowKey(e: UnifiedEmail): string {
return `${e.sourceAccountId}:${e.jmapAccountId}:${e.id}`;
}
export default function UnifiedInboxScreen({ navigation, route }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const dyn = useTypography();
const density = useDensity();
const showPreview = useSettingsStore((s) => s.showPreview);
const role: UnifiedRole = route.params?.role ?? 'inbox';
const view: CrossView | undefined = route.params?.view;
const accounts = useAccountStore((s) => s.accounts);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const dateFormat = useSettingsStore((s) => s.dateFormat);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const includeGroup = useSettingsStore((s) => s.includeGroupInUnified);
const crossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
const deleteAction = useSettingsStore((s) => s.deleteAction);
const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk);
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const showAvatarsInJunk = useSettingsStore((s) => s.showAvatarsInJunk);
const locale = useLocaleStore((s) => s.locale);
const [emails, setEmails] = React.useState<UnifiedEmail[]>([]);
const [errors, setErrors] = React.useState<Record<string, string>>({});
const [loading, setLoading] = React.useState(true);
const [loadingMore, setLoadingMore] = React.useState(false);
const [opening, setOpening] = React.useState(false);
const [searchInput, setSearchInput] = React.useState('');
const [query, setQuery] = React.useState('');
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const positionsRef = React.useRef<Record<string, number>>({});
const hasMoreRef = React.useRef(false);
const loadSeq = React.useRef(0);
const reloadTracker = React.useMemo(() => createUnifiedReloadTracker(), []);
const accountById = React.useMemo(
() => new Map(accounts.map((a) => [a.id, a])),
[accounts],
);
// Account-bounded by default (the active account + its shared/group
// folders); every signed-in account only with the cross-account opt-in.
const accountIds = React.useMemo(() => {
if (crossAccount || !activeAccountId) return accounts.map((a) => a.id);
return accounts.some((a) => a.id === activeAccountId) ? [activeAccountId] : accounts.map((a) => a.id);
}, [accounts, activeAccountId, crossAccount]);
const fetchOpts = React.useMemo(
() => ({ includeGroup, query, role, view }),
[includeGroup, query, role, view],
);
const load = React.useCallback(async () => {
const seq = ++loadSeq.current;
const active = useAuthStore.getState().activeAccountId;
reloadTracker.loaded([accountIds, fetchOpts], active, accountIds.some((id) => id !== active));
setLoading(true);
try {
const result = await fetchUnifiedInbox(accountIds, PAGE_SIZE, { ...fetchOpts, positions: {} });
if (seq !== loadSeq.current) return;
positionsRef.current = result.positions;
hasMoreRef.current = result.hasMore;
setEmails(result.emails);
setErrors(result.errors);
} finally {
if (seq === loadSeq.current) setLoading(false);
}
}, [accountIds, fetchOpts, reloadTracker]);
const loadMore = React.useCallback(async () => {
if (loading || loadingMore || !hasMoreRef.current) return;
const seq = loadSeq.current;
setLoadingMore(true);
try {
const result = await fetchUnifiedInbox(accountIds, PAGE_SIZE, {
...fetchOpts,
positions: positionsRef.current,
});
if (seq !== loadSeq.current) return;
positionsRef.current = result.positions;
hasMoreRef.current = result.hasMore;
setEmails((prev) => {
const seen = new Set(prev.map(rowKey));
const merged = [...prev];
for (const e of result.emails) if (!seen.has(rowKey(e))) merged.push(e);
return merged.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
});
} finally {
setLoadingMore(false);
}
}, [accountIds, fetchOpts, loading, loadingMore]);
// Reload whenever the inputs change, and on coming back into focus only
// when something may have changed meanwhile (a message read or deleted in
// the thread screen); see createUnifiedReloadTracker.
useFocusEffect(
React.useCallback(() => {
if (reloadTracker.focus([accountIds, fetchOpts], useAuthStore.getState().activeAccountId)) {
void load();
}
return () => reloadTracker.blur();
}, [load, accountIds, fetchOpts, reloadTracker]),
);
React.useEffect(() => {
const offEmail = onStateChangeType('Email', () => {
if (reloadTracker.changeArrived()) void load();
});
// The event stream is closed in the background: changes made meanwhile
// are never reported.
const appState = AppState.addEventListener('change', (next) => {
if (next === 'background') reloadTracker.markStale();
});
return () => {
offEmail();
appState.remove();
};
}, [load, reloadTracker]);
// Search on submit / after a pause (2+ chars), like the folder list.
React.useEffect(() => {
const trimmed = searchInput.trim();
if (trimmed === query) return;
if (trimmed.length > 0 && trimmed.length < 2) return;
const id = setTimeout(() => setQuery(trimmed), 600);
return () => clearTimeout(id);
}, [searchInput, query]);
const onOpen = React.useCallback(
(email: UnifiedEmail) => {
if (opening) return;
setOpening(true);
// The thread screen marks it read: show that when coming back.
if (!email.keywords?.$seen) reloadTracker.markStale();
void (async () => {
try {
const auth = useAuthStore.getState();
if (auth.activeAccountId !== email.sourceAccountId) {
await switchAccount(email.sourceAccountId);
if (useAuthStore.getState().activeAccountId !== email.sourceAccountId) return;
}
// Page over the rows of the same account the user is looking at
// (ids are only unique per JMAP account, so never mix accounts).
const pages = emails
.filter((e) => e.sourceAccountId === email.sourceAccountId && e.jmapAccountId === email.jmapAccountId);
// Group/shared messages live under another JMAP account in the
// same session; pass it so the thread opens against the right one.
const jmapAccountId = email.isShared ? email.jmapAccountId : undefined;
// The viewer paints these rows' headers, and starts on the body now.
rememberRows(pages, jmapAccountId);
prefetchMessage(email, jmapAccountId);
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject,
jmapAccountId,
emailIds: pages.map((e) => e.id),
});
} finally {
setOpening(false);
}
})();
},
[opening, switchAccount, navigation, emails, reloadTracker],
);
// ── Actions (routed to each message's own account) ────────────────────
const removeRows = (targets: UnifiedEmail[]) => {
const gone = new Set(targets.map(rowKey));
setEmails((prev) => prev.filter((e) => !gone.has(rowKey(e))));
};
const patchRows = (targets: UnifiedEmail[], patch: Record<string, boolean | null>) => {
const keys = new Set(targets.map(rowKey));
setEmails((prev) => prev.map((e) => {
if (!keys.has(rowKey(e))) return e;
const keywords = { ...e.keywords };
for (const [k, v] of Object.entries(patch)) {
if (v) keywords[k] = true;
else delete keywords[k];
}
return { ...e, keywords };
}));
};
const fail = (err: unknown) => {
Alert.alert(t('email_list.error', 'Error'), err instanceof Error ? err.message : String(err));
void load();
};
const setRead = (targets: UnifiedEmail[], read: boolean) => {
patchRows(targets, { $seen: read ? true : null });
patchUnifiedKeywords(targets, { $seen: read ? true : null }).catch(fail);
};
const setStarred = (targets: UnifiedEmail[], starred: boolean) => {
patchRows(targets, { $flagged: starred ? true : null });
patchUnifiedKeywords(targets, { $flagged: starred ? true : null }).catch(fail);
};
const archive = (targets: UnifiedEmail[]) => {
removeRows(targets);
moveUnifiedEmails(targets, 'archive').catch(fail);
};
const spam = (targets: UnifiedEmail[]) => {
removeRows(targets);
moveUnifiedEmails(targets, role === 'junk' ? 'inbox' : 'junk', {
markRead: role !== 'junk' && deleteAction === 'trash-and-read',
}).catch(fail);
};
const remove = async (targets: UnifiedEmail[]) => {
const permanent = targets.some((e) => isPermanentDelete({
inTrash: role === 'trash' || isInUnifiedTrash(e),
inJunk: role === 'junk',
deleteAction,
permanentlyDeleteJunk,
}));
if (permanent && !(await confirmPermanentDelete(targets.length, t))) return;
removeRows(targets);
deleteUnifiedEmails(targets, {
permanent: deleteAction === 'permanent' || (permanentlyDeleteJunk && role === 'junk'),
markRead: deleteAction === 'trash-and-read',
}).catch(fail);
};
const handleSwipe = (email: UnifiedEmail, action: SwipeAction) => {
switch (action) {
case 'archive': if (role !== 'archive') archive([email]); break;
case 'delete': void remove([email]); break;
case 'spam': if (role !== 'sent' && role !== 'drafts') spam([email]); break;
case 'read': setRead([email], !email.keywords?.$seen); break;
case 'star': setStarred([email], !email.keywords?.$flagged); break;
case 'pin':
patchRows([email], { $pinned: email.keywords?.$pinned ? null : true });
patchUnifiedKeywords([email], { $pinned: email.keywords?.$pinned ? null : true }).catch(fail);
break;
default: break;
}
};
const selectionMode = selected.size > 0;
const selectedEmails = React.useMemo(
() => emails.filter((e) => selected.has(rowKey(e))),
[emails, selected],
);
const toggleSelect = (email: UnifiedEmail) => {
setSelected((prev) => {
const next = new Set(prev);
const key = rowKey(email);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
};
const clearSelection = () => setSelected(new Set());
const allRead = selectedEmails.length > 0 && selectedEmails.every((e) => !!e.keywords?.$seen);
const allStarred = selectedEmails.length > 0 && selectedEmails.every((e) => !!e.keywords?.$flagged);
const errorCount = Object.keys(errors).length;
const showRecipient = role === 'sent' || role === 'drafts';
const title = view
? t(`sidebar.unified_all_${view === 'all' ? 'mail' : view}`, { all: 'All mail', unread: 'All unread', starred: 'All starred' }[view])
: role === 'inbox'
? t('sidebar.unified_inbox', 'All inboxes')
: t(`sidebar.unified_${role}`, `All ${role}`);
const renderItem = ({ item }: { item: UnifiedEmail }) => {
const acc = accountById.get(item.sourceAccountId);
const unread = !item.keywords?.$seen;
const starred = !!item.keywords?.$flagged;
const isSelected = selected.has(rowKey(item));
// Sent and Drafts rows show the recipient: the check says nothing about them.
const verification = showRecipient ? null : senderWarningOf(item);
return (
<SwipeableRow
leftAction={selectionMode ? 'none' : swipeLeftAction}
rightAction={selectionMode ? 'none' : swipeRightAction}
mode={swipeMode}
context={{ unread, starred, pinned: !!item.keywords?.$pinned, inJunk: role === 'junk' }}
onAction={(action) => handleSwipe(item, action)}
>
<Pressable
onPress={() => (selectionMode ? toggleSelect(item) : onOpen(item))}
onLongPress={() => toggleSelect(item)}
delayLongPress={300}
style={({ pressed }) => [
styles.row,
{ paddingVertical: density.rowPaddingVertical },
pressed && styles.rowPressed,
isSelected && styles.rowSelected,
]}
>
{unread && <View style={styles.unreadDot} />}
{selectionMode && (
<View style={styles.checkbox}>
{isSelected ? <SquareCheck size={16} color={c.primary} /> : <Square size={16} color={c.textMuted} />}
</View>
)}
{density.showAvatar && <View style={styles.avatarWrap}>
<SenderAvatar
name={senderName(item, showRecipient, t('email_viewer.unknown_sender', 'Unknown'))}
email={showRecipient ? item.to?.[0]?.email : item.from?.[0]?.email}
size={componentSizes.avatarMd}
disableImages={role === 'junk' && !showAvatarsInJunk}
senderWarning={verification ? describeSenderVerification(verification, t).label : undefined}
/>
{acc && accountIds.length > 1 && (
<View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />
)}
</View>}
<View style={styles.content}>
<View style={styles.line}>
<Text style={[styles.sender, dyn.bodyMedium, unread && styles.bold]} numberOfLines={1}>
{senderName(item, showRecipient, t('email_viewer.unknown_sender', 'Unknown'))}
</Text>
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={[styles.time, dyn.caption]}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale, t })}</Text>
</View>
<Text style={[styles.subject, dyn.body, unread && styles.bold]} numberOfLines={1}>
{singleLine(item.subject) || t('email_viewer.no_subject', '(No Subject)')}
</Text>
<View style={styles.line}>
{acc && accountIds.length > 1 && (
<Text style={styles.account} numberOfLines={1}>
{acc.email || acc.username}
</Text>
)}
{item.isShared && (
<View style={styles.sharedBadge}>
<Text style={styles.sharedBadgeText} numberOfLines={1}>
{item.sharedLabel || t('sidebar.shared', 'Shared')}
</Text>
</View>
)}
{item.sourceFolder && (
<View style={styles.sharedBadge}>
<Folder size={10} color={c.mutedForeground} />
<Text style={styles.sharedBadgeText} numberOfLines={1}>{item.sourceFolder}</Text>
</View>
)}
</View>
{/* Same settings as the folder list: preview on/off, density, text size. */}
{item.preview && showPreview && density.showPreview ? (
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>{singleLine(item.preview)}</Text>
) : null}
</View>
</Pressable>
</SwipeableRow>
);
};
return (
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
{selectionMode ? (
<View style={styles.header}>
<Pressable
onPress={clearSelection}
style={styles.headerBtn}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('email_list.batch_actions.clear_selection', 'Clear selection')}
>
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>
{t('email_list.batch_actions.selected_messages', '{count, plural, one {1 email} other {# emails}} selected', { count: selected.size })}
</Text>
<Pressable
onPress={() => { setStarred(selectedEmails, !allStarred); clearSelection(); }}
style={styles.headerBtn}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={allStarred ? t('context_menu.unstar', 'Unstar') : t('context_menu.star', 'Star')}
>
<Star size={20} color={allStarred ? c.starred : c.text} fill={allStarred ? c.starred : 'transparent'} />
</Pressable>
<Pressable
onPress={() => { setRead(selectedEmails, !allRead); clearSelection(); }}
style={styles.headerBtn}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={allRead
? t('email_list.batch_actions.mark_unread', 'Mark as unread')
: t('email_list.batch_actions.mark_read', 'Mark as read')}
>
{allRead ? <MailIcon size={20} color={c.text} /> : <MailOpen size={20} color={c.text} />}
</Pressable>
{role !== 'sent' && role !== 'drafts' && (
<Pressable
onPress={() => { spam(selectedEmails); clearSelection(); }}
style={styles.headerBtn}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={role === 'junk' ? t('context_menu.not_spam', 'Not spam') : t('context_menu.mark_as_spam', 'Report spam')}
>
{role === 'junk' ? <ShieldCheck size={20} color={c.text} /> : <ShieldAlert size={20} color={c.text} />}
</Pressable>
)}
{role !== 'archive' && (
<Pressable
onPress={() => { archive(selectedEmails); clearSelection(); }}
style={styles.headerBtn}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('context_menu.archive', 'Archive')}
>
<Archive size={20} color={c.text} />
</Pressable>
)}
<Pressable
onPress={() => { void remove(selectedEmails).then(clearSelection); }}
style={styles.headerBtn}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={t('email_list.batch_actions.delete', 'Delete')}
>
<Trash2 size={20} color={c.text} />
</Pressable>
</View>
) : (
<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>
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
<View style={styles.headerBtn}>
{opening ? <ActivityIndicator size="small" color={c.primary} /> : null}
</View>
</View>
)}
<View style={styles.searchBar}>
<Search size={16} color={c.textMuted} />
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
style={styles.searchInput}
placeholder={t('email_list.search_placeholder', 'Search mail...')}
placeholderTextColor={c.textMuted}
value={searchInput}
onChangeText={setSearchInput}
onSubmitEditing={() => setQuery(searchInput.trim())}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
/>
{searchInput.length > 0 && (
<Pressable
onPress={() => { setSearchInput(''); setQuery(''); }}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('contacts.clear_search', 'Clear search')}
>
<X size={14} color={c.textMuted} />
</Pressable>
)}
</View>
{errorCount > 0 && (
<View style={styles.errorBanner}>
<AlertTriangle size={14} color={c.error} />
<Text style={styles.errorBannerText} numberOfLines={2}>
{t('unified_mailbox.accounts_failed', '{count, plural, one {# account could not be loaded} other {# accounts could not be loaded}}', { count: errorCount })}
{': '}
{Object.values(errors)[0]}
</Text>
</View>
)}
{loading && emails.length === 0 ? (
<View style={styles.center}>
<ActivityIndicator color={c.primary} />
<Text style={styles.loadingText}>{t('email_list.loading', 'Loading…')}</Text>
</View>
) : emails.length === 0 ? (
<View style={styles.center}>
<Text style={styles.loadingText}>
{query ? t('email_list.no_search_results', 'No emails found') : t('email_list.no_emails', 'No emails')}
</Text>
</View>
) : (
<FlatList
data={emails}
keyExtractor={rowKey}
renderItem={renderItem}
ItemSeparatorComponent={() => <View style={styles.separator} />}
refreshing={loading}
onRefresh={() => { void load(); }}
onEndReached={() => { void loadMore(); }}
onEndReachedThreshold={0.3}
ListFooterComponent={loadingMore ? <ActivityIndicator style={{ padding: spacing.md }} color={c.primary} /> : null}
// A stack screen has no tab bar: the list ends clear of the gesture bar.
contentContainerStyle={{ paddingBottom: insets.bottom + 40 }}
/>
)}
</SafeAreaView>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: c.border,
gap: spacing.sm,
},
headerBtn: {
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
},
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
searchBar: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: spacing.lg,
marginVertical: spacing.sm,
paddingHorizontal: spacing.md,
height: componentSizes.inputHeight,
backgroundColor: c.surface,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
gap: spacing.sm,
},
searchInput: { flex: 1, ...typography.body, color: c.text },
errorBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: c.errorBg,
},
errorBannerText: { ...typography.caption, color: c.error, flex: 1 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm },
loadingText: { ...typography.body, color: c.textSecondary },
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border },
row: {
flexDirection: 'row',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: c.background,
},
rowPressed: { backgroundColor: c.surfaceHover },
rowSelected: { backgroundColor: c.selection },
checkbox: { width: 16, height: componentSizes.avatarMd, alignItems: 'center', justifyContent: 'center' },
// Same unread indicator as the mailbox list (and the webmail): an 8px
// filled circle in the start gutter (#27). Anchored on the row's *first*
// line — top padding + half an avatar — because centring it on the row
// drops it a full line below the avatar it reads as a column with.
unreadDot: {
position: 'absolute',
left: 4,
top: spacing.md + componentSizes.avatarMd / 2 - 4,
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: c.unread,
},
avatarWrap: { position: 'relative' },
accountDot: {
position: 'absolute',
right: -2, bottom: -2,
width: 12, height: 12,
borderRadius: 6,
borderWidth: 2,
borderColor: c.background,
},
content: { flex: 1, minWidth: 0, gap: 1 },
line: { flexDirection: 'row', alignItems: 'center', gap: 6 },
sender: { ...typography.bodyMedium, color: c.text, flex: 1 },
bold: { fontWeight: '700' },
time: { ...typography.caption, color: c.textMuted },
subject: { ...typography.body, color: c.text },
account: { ...typography.caption, color: c.textMuted },
sharedBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: radius.full,
backgroundColor: c.muted,
maxWidth: 140,
},
sharedBadgeText: { fontSize: 12, color: c.mutedForeground },
preview: { ...typography.caption, color: c.textMuted },
});
}