diff --git a/App.tsx b/App.tsx
index 37ab3639..b85e0399 100644
--- a/App.tsx
+++ b/App.tsx
@@ -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(() => {
diff --git a/package-lock.json b/package-lock.json
index 2cd05610..908b4250 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 628626b8..7d2d831a 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/OfflineBanner.tsx b/src/components/OfflineBanner.tsx
new file mode 100644
index 00000000..e3559aa9
--- /dev/null
+++ b/src/components/OfflineBanner.tsx
@@ -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 (
+
+
+
+ You are offline{hint ? ` — ${hint}` : ''}
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx
index 7587e544..02d7e7bd 100644
--- a/src/components/SwipeableRow.tsx
+++ b/src/components/SwipeableRow.tsx
@@ -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, { 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 (
- 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"
>
{label}
-
+
);
};
@@ -123,23 +144,31 @@ export function SwipeableRow({
{renderBand(rightAction, 'left')}
{renderBand(leftAction, 'right')}
-
+
{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 ? (
+
+ ) : null}
);
}
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 },
});
diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts
index 074aac7e..7017b2d8 100644
--- a/src/lib/calendar-utils.ts
+++ b/src/lib/calendar-utils.ts
@@ -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,
diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx
index 06e9825f..638e4e01 100644
--- a/src/screens/EmailListScreen.tsx
+++ b/src/screens/EmailListScreen.tsx
@@ -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
)}
+ 0 ? 'Showing cached mail' : undefined} />
+
{/* Email list */}
{loading && emails.length === 0 ? (
Loading emails...
- ) : error ? (
+ ) : error && emails.length === 0 ? (
- {error}
+
+ {networkOnline ? error : 'No connection. Showing nothing because no mail has been cached yet.'}
+
{ void refreshEmails(); }}>
Retry
diff --git a/src/stores/network-store.ts b/src/stores/network-store.ts
new file mode 100644
index 00000000..125c97d4
--- /dev/null
+++ b/src/stores/network-store.ts
@@ -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((set) => ({
+ online: true,
+ connected: true,
+
+ init: () => {
+ const unsubscribe = NetInfo.addEventListener((state) => {
+ set(fromNetInfo(state));
+ });
+ void NetInfo.fetch().then((state) => set(fromNetInfo(state)));
+ return unsubscribe;
+ },
+}));