mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: add network connectivity management and offline banner component
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user