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; + }, +}));