feat: add network connectivity management and offline banner component

This commit is contained in:
Linus Rath
2026-04-27 11:20:23 +02:00
parent 571b28a9f2
commit ac84f13b22
8 changed files with 182 additions and 39 deletions
+2
View File
@@ -36,6 +36,7 @@ import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore } from './src/stores/email-store';
import { useSettingsStore } from './src/stores/settings-store';
import { useLocaleStore } from './src/stores/locale-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { colors, spacing, typography } from './src/theme/tokens';
@@ -177,6 +178,7 @@ export default function App() {
React.useEffect(() => {
void useSettingsStore.getState().hydrate();
void useLocaleStore.getState().hydrate();
return useNetworkStore.getState().init();
}, []);
React.useEffect(() => {
+10
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/netinfo": "11.4.1",
"@react-navigation/bottom-tabs": "^7.15.9",
"@react-navigation/native": "^7.2.2",
"@react-navigation/native-stack": "^7.14.10",
@@ -2793,6 +2794,15 @@
}
}
},
"node_modules/@react-native-community/netinfo": {
"version": "11.4.1",
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz",
"integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==",
"license": "MIT",
"peerDependencies": {
"react-native": ">=0.59"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
+1
View File
@@ -16,6 +16,7 @@
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/netinfo": "11.4.1",
"@react-navigation/bottom-tabs": "^7.15.9",
"@react-navigation/native": "^7.2.2",
"@react-navigation/native-stack": "^7.14.10",
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CloudOff } from 'lucide-react-native';
import { useNetworkStore } from '../stores/network-store';
import { colors, spacing, typography } from '../theme/tokens';
interface OfflineBannerProps {
/** Optional extra notice (e.g. "Showing cached mail"). */
hint?: string;
}
export function OfflineBanner({ hint }: OfflineBannerProps) {
const online = useNetworkStore((s) => s.online);
if (online) return null;
return (
<View style={styles.bar}>
<CloudOff size={14} color={colors.primaryForeground} />
<Text style={styles.text} numberOfLines={1}>
You are offline{hint ? ` — ${hint}` : ''}
</Text>
</View>
);
}
const styles = StyleSheet.create({
bar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
backgroundColor: '#a16207',
},
text: {
...typography.caption,
color: colors.primaryForeground,
flex: 1,
},
});
+66 -37
View File
@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import {
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
} from 'react-native';
@@ -11,18 +11,20 @@ import type { SwipeAction } from '../stores/settings-store';
interface SwipeableRowProps {
children: React.ReactNode;
/** Right-to-left swipe action (revealed under a leftward drag). */
/** Right-to-left swipe action (revealed under a leftward drag, sits at right edge). */
leftAction: SwipeAction;
/** Left-to-right swipe action (revealed under a rightward drag). */
/** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */
rightAction: SwipeAction;
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
context: { unread: boolean; starred: boolean };
onAction: (action: SwipeAction) => void;
}
const ACTIVATION_THRESHOLD = 0.4; // fraction of row width
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor to claim the gesture
const REVEAL_WIDTH = 88; // px shown when fully revealed
const ACTIVATION_THRESHOLD = 32; // px past which release snaps open instead of closing
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
const MIN_DX_TO_CLAIM = 6;
const MAX_DRAG_OVERSHOOT = REVEAL_WIDTH * 1.4;
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
@@ -45,6 +47,26 @@ export function SwipeableRow({
const dx = useRef(new Animated.Value(0)).current;
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);
const openSideRef = useRef<'left' | 'right' | null>(null);
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
const close = () => {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = null;
setOpenSide(null);
};
const openRight = () => {
Animated.spring(dx, { toValue: REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = 'right';
setOpenSide('right');
};
const openLeft = () => {
Animated.spring(dx, { toValue: -REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = 'left';
setOpenSide('left');
};
const responder = useRef(
PanResponder.create({
@@ -52,66 +74,65 @@ export function SwipeableRow({
onMoveShouldSetPanResponder: (_, g) => {
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
// If a side is already open, allow the gesture so the user can drag it closed.
if (openSideRef.current === null) {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
}
claimed.current = true;
return true;
},
onPanResponderMove: (_, g) => {
const max = widthRef.current;
const clamped = Math.max(-max, Math.min(max, g.dx));
const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0;
const next = base + g.dx;
const clamped = Math.max(-MAX_DRAG_OVERSHOOT, Math.min(MAX_DRAG_OVERSHOOT, next));
dx.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
const threshold = widthRef.current * ACTIVATION_THRESHOLD;
const action = g.dx > 0 ? rightAction : leftAction;
const triggered = Math.abs(g.dx) > threshold && action !== 'none';
if (triggered) {
Animated.timing(dx, {
toValue: g.dx > 0 ? widthRef.current : -widthRef.current,
duration: 160,
useNativeDriver: true,
}).start(() => {
onAction(action);
dx.setValue(0);
});
const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0;
const finalDx = base + g.dx;
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
openRight();
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
openLeft();
} else {
Animated.spring(dx, {
toValue: 0,
useNativeDriver: true,
speed: 24,
bounciness: 4,
}).start();
close();
}
claimed.current = false;
},
onPanResponderTerminate: () => {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
close();
claimed.current = false;
},
onPanResponderTerminationRequest: () => !claimed.current,
}),
).current;
const fireAction = (action: SwipeAction) => {
close();
if (action !== 'none') onAction(action);
};
const renderBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
return (
<View
<Pressable
onPress={() => fireAction(action)}
style={[
styles.band,
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
{ backgroundColor: meta.bg },
{ backgroundColor: meta.bg, width: REVEAL_WIDTH },
]}
pointerEvents="none"
>
<View style={styles.bandInner}>
<Icon size={20} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</View>
</View>
</Pressable>
);
};
@@ -123,23 +144,31 @@ export function SwipeableRow({
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
{renderBand(rightAction, 'left')}
{renderBand(leftAction, 'right')}
<Animated.View style={{ transform: [{ translateX: dx }] }}>
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
{children}
{/* When an action is revealed, an overlay absorbs taps on the row so a
tap closes the swipe instead of opening the email. */}
{openSide ? (
<Pressable
style={StyleSheet.absoluteFill}
onPress={close}
/>
) : null}
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
wrap: { position: 'relative' },
wrap: { position: 'relative', backgroundColor: colors.background },
content: { backgroundColor: colors.background },
band: {
position: 'absolute',
top: 0,
bottom: 0,
width: '100%',
justifyContent: 'center',
paddingHorizontal: 24,
paddingHorizontal: 16,
},
bandInner: { flexDirection: 'row', alignItems: 'center', gap: 8 },
bandInner: { alignItems: 'center', gap: 4 },
bandLabel: { color: '#fff', fontWeight: '600', ...typography.caption },
});
+20
View File
@@ -117,9 +117,29 @@ export function buildEventDayIndex(events: CalendarEvent[]): EventDayIndex {
safety++;
}
}
// Sort each day's bucket: all-day events first, then timed events by start.
// Without this, events from different calendars come out in arrival order
// (roughly grouped by calendar) rather than chronologically.
for (const arr of idx.values()) {
arr.sort(compareEventsForDay);
}
return idx;
}
function compareEventsForDay(a: CalendarEvent, b: CalendarEvent): number {
const aAllDay = !!a.showWithoutTime;
const bAllDay = !!b.showWithoutTime;
if (aAllDay !== bAllDay) return aAllDay ? -1 : 1;
const aStart = getEventStartDate(a).getTime();
const bStart = getEventStartDate(b).getTime();
if (aStart !== bStart) return aStart - bStart;
// Tie-breaker on end (shorter events first), then title for stability.
const aEnd = getEventEndDate(a).getTime();
const bEnd = getEventEndDate(b).getTime();
if (aEnd !== bEnd) return aEnd - bEnd;
return (a.title ?? '').localeCompare(b.title ?? '');
}
export function eventsOnDayFromIndex(
index: EventDayIndex,
day: Date,
+9 -2
View File
@@ -10,6 +10,8 @@ import { colors, spacing, radius, typography, componentSizes } from '../theme/to
import SidebarDrawer from '../components/SidebarDrawer';
import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
import { OfflineBanner } from '../components/OfflineBanner';
import { useNetworkStore } from '../stores/network-store';
import { useEmailStore, type EmailFilters } from '../stores/email-store';
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
import type { Email } from '../api/types';
@@ -158,6 +160,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const networkOnline = useNetworkStore((s) => s.online);
const archiveMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
@@ -552,15 +555,19 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
</View>
)}
<OfflineBanner hint={emails.length > 0 ? 'Showing cached mail' : undefined} />
{/* Email list */}
{loading && emails.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} />
<Text style={styles.loadingText}>Loading emails...</Text>
</View>
) : error ? (
) : error && emails.length === 0 ? (
<View style={styles.loadingContainer}>
<Text style={styles.errorText}>{error}</Text>
<Text style={styles.errorText}>
{networkOnline ? error : 'No connection. Showing nothing because no mail has been cached yet.'}
</Text>
<Pressable onPress={() => { void refreshEmails(); }}>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
+35
View File
@@ -0,0 +1,35 @@
import { create } from 'zustand';
import NetInfo, { type NetInfoState } from '@react-native-community/netinfo';
interface NetworkState {
/** Best-effort online flag. True when we have an internet-reachable connection
* (or when NetInfo hasn't yet reported anything — assume online by default
* so we don't flash the offline banner during cold start). */
online: boolean;
/** Lower-level connection flag. True if a network interface is up, regardless
* of whether the internet is actually reachable. */
connected: boolean;
init: () => () => void;
}
function fromNetInfo(state: NetInfoState): { online: boolean; connected: boolean } {
const connected = state.isConnected === true;
// isInternetReachable is `null` until tested; treat null as "we don't know"
// and fall back to connected.
const online = state.isInternetReachable === false ? false : connected;
return { online, connected };
}
export const useNetworkStore = create<NetworkState>((set) => ({
online: true,
connected: true,
init: () => {
const unsubscribe = NetInfo.addEventListener((state) => {
set(fromNetInfo(state));
});
void NetInfo.fetch().then((state) => set(fromNetInfo(state)));
return unsubscribe;
},
}));