mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: scroll the calendar month view continuously #759
The month view showed one month at a time and only the arrows moved it. With the new "Free scrolling" setting (on by default, the webmail's `calendarFreeScroll`) it is one continuous list of weeks, as in the webmail (15a5497a, ae403b06): - the month under the upper part of the grid is drawn at full strength, names the title and is where the arrows step from; the first day of each month carries the month's name - reaching the top or the bottom adds weeks there and loads only their events; weeks added above keep the rows on screen in place - rows have a fixed height and snap to whole weeks, so the list stays windowed and navigation lands exactly on the month Turning the setting off brings back the paged month and a fixed 30-day agenda. The week view still pages.
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
View,
|
||||
type ListRenderItemInfo,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from 'react-native';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
dayKey,
|
||||
type EventDayIndex,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
|
||||
import {
|
||||
dayIndexIn,
|
||||
monthFocusRow,
|
||||
monthKeyOf,
|
||||
monthMask,
|
||||
sampledRow,
|
||||
weekDays,
|
||||
windowWeekStarts,
|
||||
} from '../../lib/calendar-month-scroll';
|
||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import {
|
||||
MONTH_ROW_HEIGHT,
|
||||
MONTH_ROW_HEIGHT_CHIPS,
|
||||
MonthWeekRow,
|
||||
MonthWeekdayHeader,
|
||||
useMonthStyles,
|
||||
} from './MonthView';
|
||||
|
||||
type WeekStart = 0 | 1 | 6;
|
||||
|
||||
/** Rows the month viewport shows, as one month grid used to. */
|
||||
const VISIBLE_ROWS = 6;
|
||||
/** Navigation closer than this many rows scrolls smoothly; further jumps. */
|
||||
const SMOOTH_SCROLL_ROWS = 12;
|
||||
|
||||
interface MonthScrollViewProps {
|
||||
/** The day the user navigated to; its month's first week is scrolled to the top. */
|
||||
focus: CalendarFocus;
|
||||
/** The loaded window: whole weeks, one row each. */
|
||||
window: DayRange;
|
||||
selectedDate: Date;
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
eventsByDay?: EventDayIndex;
|
||||
weekStartsOn?: WeekStart;
|
||||
showWeekNumbers?: boolean;
|
||||
showTimeInMonthView?: boolean;
|
||||
timeFormat?: TimeFormat;
|
||||
/** Widen the window at the top; omitted once the limit is reached. */
|
||||
onExtendStart?: () => void;
|
||||
/** Widen the window at the bottom; omitted once the limit is reached. */
|
||||
onExtendEnd?: () => void;
|
||||
/** Reports a day of the month in view whenever that month changes. */
|
||||
onVisibleDateChange?: (date: Date) => void;
|
||||
onSelectDate: (date: Date) => void;
|
||||
onLongPressDate?: (date: Date) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freely scrolling month (#759, webmail 15a5497a): the window's weeks as one
|
||||
* continuous list of fixed-height rows. The month under the upper part of
|
||||
* the viewport is the one in focus: its days are drawn at full strength,
|
||||
* the title follows it, and the first of every month carries its name.
|
||||
* Reaching either end widens the window; rows added above keep what is on
|
||||
* screen in place. The screen remounts the list (keyed by the window) when
|
||||
* navigation starts a fresh window.
|
||||
*/
|
||||
function MonthScrollViewInner({
|
||||
focus,
|
||||
window,
|
||||
selectedDate,
|
||||
events,
|
||||
calendars,
|
||||
eventsByDay,
|
||||
weekStartsOn = 0,
|
||||
showWeekNumbers = false,
|
||||
showTimeInMonthView = false,
|
||||
timeFormat,
|
||||
onExtendStart,
|
||||
onExtendEnd,
|
||||
onVisibleDateChange,
|
||||
onSelectDate,
|
||||
onLongPressDate,
|
||||
}: MonthScrollViewProps) {
|
||||
const styles = useMonthStyles();
|
||||
const { locale } = useCalendarLocale();
|
||||
const index = React.useMemo(
|
||||
() => eventsByDay ?? buildEventDayIndex(events),
|
||||
[eventsByDay, events],
|
||||
);
|
||||
const opts = React.useMemo(() => ({ weekStartsOn }), [weekStartsOn]);
|
||||
|
||||
// One row per week of the window. A week keeps its array across window
|
||||
// changes, so growing the window doesn't re-render the rows already there.
|
||||
const weekCacheRef = React.useRef(new Map<string, Date[]>());
|
||||
const weeks = React.useMemo(() => {
|
||||
const previous = weekCacheRef.current;
|
||||
const next = new Map<string, Date[]>();
|
||||
const out = windowWeekStarts(window).map((start) => {
|
||||
const key = dayKey(start);
|
||||
const days = previous.get(key) ?? weekDays(start);
|
||||
next.set(key, days);
|
||||
return days;
|
||||
});
|
||||
weekCacheRef.current = next;
|
||||
return out;
|
||||
}, [window]);
|
||||
const weeksRef = React.useRef(weeks);
|
||||
weeksRef.current = weeks;
|
||||
const rowHeight = showTimeInMonthView ? MONTH_ROW_HEIGHT_CHIPS : MONTH_ROW_HEIGHT;
|
||||
const viewportHeight = rowHeight * VISIBLE_ROWS;
|
||||
|
||||
// The month in focus: set by navigation, then by what scrolls under the
|
||||
// sample line.
|
||||
const [activeMonth, setActiveMonth] = React.useState(() => monthKeyOf(focus.date));
|
||||
const activeMonthRef = React.useRef(activeMonth);
|
||||
const onVisibleRef = React.useRef(onVisibleDateChange);
|
||||
onVisibleRef.current = onVisibleDateChange;
|
||||
const offsetRef = React.useRef(0);
|
||||
// Row at the top of the viewport; a change of row height (the "show time"
|
||||
// setting) remounts the list there.
|
||||
const topRowRef = React.useRef<number | null>(null);
|
||||
|
||||
const handleScroll = React.useCallback(
|
||||
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offset = e.nativeEvent.contentOffset.y;
|
||||
offsetRef.current = offset;
|
||||
topRowRef.current = Math.round(offset / rowHeight);
|
||||
const list = weeksRef.current;
|
||||
const row = sampledRow(offset, viewportHeight, rowHeight, list.length);
|
||||
const days = list[row];
|
||||
if (!days) return;
|
||||
// Mid-week decides which month a row belongs to.
|
||||
const mid = days[3];
|
||||
const key = monthKeyOf(mid);
|
||||
if (key === activeMonthRef.current) return;
|
||||
activeMonthRef.current = key;
|
||||
setActiveMonth(key);
|
||||
onVisibleRef.current?.(mid);
|
||||
},
|
||||
[viewportHeight, rowHeight],
|
||||
);
|
||||
|
||||
// Navigation inside the window scrolls the focused month's first week to
|
||||
// the top. The list mounts there already, so its own nonce needs nothing.
|
||||
const listRef = React.useRef<FlatList<Date[]>>(null);
|
||||
const handledNonceRef = React.useRef(focus.nonce);
|
||||
const initialRef = React.useRef<{ rowHeight: number; row: number } | null>(null);
|
||||
if (initialRef.current === null || initialRef.current.rowHeight !== rowHeight) {
|
||||
initialRef.current = {
|
||||
rowHeight,
|
||||
row: topRowRef.current ?? monthFocusRow(window, focus.date, weeks.length, opts),
|
||||
};
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (handledNonceRef.current === focus.nonce) return;
|
||||
handledNonceRef.current = focus.nonce;
|
||||
const row = monthFocusRow(window, focus.date, weeks.length, opts);
|
||||
const currentRow = Math.round(offsetRef.current / rowHeight);
|
||||
const key = monthKeyOf(focus.date);
|
||||
activeMonthRef.current = key;
|
||||
setActiveMonth(key);
|
||||
listRef.current?.scrollToIndex({
|
||||
index: row,
|
||||
animated: Math.abs(row - currentRow) <= SMOOTH_SCROLL_ROWS,
|
||||
});
|
||||
}, [focus, window, weeks.length, opts, rowHeight]);
|
||||
|
||||
const selectedKey = dayKey(selectedDate);
|
||||
const todayKey = dayKey(new Date());
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({ item: days }: ListRenderItemInfo<Date[]>) => {
|
||||
return (
|
||||
<MonthWeekRow
|
||||
days={days}
|
||||
activeMask={monthMask(days, activeMonth)}
|
||||
selectedIndex={dayIndexIn(days, selectedDate)}
|
||||
todayIndex={dayIndexIn(days, new Date())}
|
||||
index={index}
|
||||
calendars={calendars}
|
||||
weekStartsOn={weekStartsOn}
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
showTimeInMonthView={showTimeInMonthView}
|
||||
timeFormat={timeFormat}
|
||||
labelMonths
|
||||
height={rowHeight}
|
||||
locale={locale}
|
||||
styles={styles}
|
||||
onSelectDate={onSelectDate}
|
||||
onLongPressDate={onLongPressDate}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
activeMonth,
|
||||
selectedDate,
|
||||
index,
|
||||
calendars,
|
||||
weekStartsOn,
|
||||
showWeekNumbers,
|
||||
showTimeInMonthView,
|
||||
timeFormat,
|
||||
rowHeight,
|
||||
locale,
|
||||
styles,
|
||||
onSelectDate,
|
||||
onLongPressDate,
|
||||
],
|
||||
);
|
||||
|
||||
const getItemLayout = React.useCallback(
|
||||
(_: ArrayLike<Date[]> | null | undefined, i: number) => ({
|
||||
length: rowHeight,
|
||||
offset: rowHeight * i,
|
||||
index: i,
|
||||
}),
|
||||
[rowHeight],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.grid}>
|
||||
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
|
||||
<FlatList
|
||||
key={rowHeight}
|
||||
ref={listRef}
|
||||
style={{ height: viewportHeight }}
|
||||
data={weeks}
|
||||
keyExtractor={weekKey}
|
||||
renderItem={renderItem}
|
||||
extraData={`${activeMonth}|${selectedKey}|${todayKey}`}
|
||||
getItemLayout={getItemLayout}
|
||||
initialScrollIndex={Math.min(initialRef.current.row, weeks.length - 1)}
|
||||
// Weeks added above keep the rows on screen where they are.
|
||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||
// Half the grid: the window opens with a month of room either side,
|
||||
// so this doesn't fire while the list settles on the focus.
|
||||
onStartReached={onExtendStart}
|
||||
onStartReachedThreshold={0.5}
|
||||
onEndReached={onExtendEnd}
|
||||
onEndReachedThreshold={0.5}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={32}
|
||||
snapToInterval={rowHeight}
|
||||
decelerationRate="fast"
|
||||
showsVerticalScrollIndicator={false}
|
||||
initialNumToRender={VISIBLE_ROWS + 2}
|
||||
maxToRenderPerBatch={VISIBLE_ROWS}
|
||||
windowSize={5}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const MonthScrollView = React.memo(MonthScrollViewInner);
|
||||
|
||||
function weekKey(days: Date[]): string {
|
||||
return dayKey(days[0]);
|
||||
}
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
format,
|
||||
getISOWeek,
|
||||
getWeek,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
type Locale,
|
||||
} from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -25,10 +23,16 @@ import {
|
||||
type EventDayIndex,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
|
||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
|
||||
type WeekStart = 0 | 1 | 6;
|
||||
|
||||
// Height of a week row with dots, and with event chips (#666). The freely
|
||||
// scrolling month uses them as fixed row heights.
|
||||
export const MONTH_ROW_HEIGHT = 54;
|
||||
export const MONTH_ROW_HEIGHT_CHIPS = 74;
|
||||
|
||||
interface MonthViewProps {
|
||||
currentDate: Date;
|
||||
selectedDate: Date;
|
||||
@@ -52,6 +56,175 @@ export function weekNumberFor(date: Date, weekStartsOn: WeekStart): number {
|
||||
return getWeek(date, { weekStartsOn });
|
||||
}
|
||||
|
||||
export type MonthStyles = ReturnType<typeof makeStyles>;
|
||||
|
||||
export function useMonthStyles(): MonthStyles {
|
||||
const c = useColors();
|
||||
return React.useMemo(() => makeStyles(c), [c]);
|
||||
}
|
||||
|
||||
export function MonthWeekdayHeader({
|
||||
weekStartsOn,
|
||||
showWeekNumbers,
|
||||
styles,
|
||||
}: {
|
||||
weekStartsOn: WeekStart;
|
||||
showWeekNumbers: boolean;
|
||||
styles: MonthStyles;
|
||||
}) {
|
||||
const { locale } = useCalendarLocale();
|
||||
const labels = React.useMemo(() => {
|
||||
const start = startOfWeek(new Date(), { weekStartsOn });
|
||||
return Array.from({ length: 7 }, (_, i) => format(addDays(start, i), 'EEEEE', { locale }));
|
||||
}, [weekStartsOn, locale]);
|
||||
return (
|
||||
<View style={styles.weekdayRow}>
|
||||
{showWeekNumbers && <Text style={styles.weekNumberLabel}>#</Text>}
|
||||
{labels.map((wd, i) => (
|
||||
<Text key={i} style={styles.weekdayLabel}>{wd}</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MonthWeekRowProps {
|
||||
days: Date[];
|
||||
/** Bit i set: day i belongs to the month in focus (others are muted). */
|
||||
activeMask: number;
|
||||
/** Index of the selected / today's day in this row, or -1. */
|
||||
selectedIndex: number;
|
||||
todayIndex: number;
|
||||
index: EventDayIndex;
|
||||
calendars: Calendar[];
|
||||
weekStartsOn: WeekStart;
|
||||
showWeekNumbers: boolean;
|
||||
showTimeInMonthView: boolean;
|
||||
timeFormat?: TimeFormat;
|
||||
/** Mark the first day of a month with the month's name (continuous scrolling). */
|
||||
labelMonths?: boolean;
|
||||
/** Fixed row height (continuous scrolling); natural height otherwise. */
|
||||
height?: number;
|
||||
locale: Locale;
|
||||
styles: MonthStyles;
|
||||
onSelectDate: (date: Date) => void;
|
||||
onLongPressDate?: (date: Date) => void;
|
||||
}
|
||||
|
||||
function MonthWeekRowInner({
|
||||
days,
|
||||
activeMask,
|
||||
selectedIndex,
|
||||
todayIndex,
|
||||
index,
|
||||
calendars,
|
||||
weekStartsOn,
|
||||
showWeekNumbers,
|
||||
showTimeInMonthView,
|
||||
timeFormat,
|
||||
labelMonths = false,
|
||||
height,
|
||||
locale,
|
||||
styles,
|
||||
onSelectDate,
|
||||
onLongPressDate,
|
||||
}: MonthWeekRowProps) {
|
||||
return (
|
||||
<View style={[styles.weekRow, height !== undefined && { height, overflow: 'hidden' }]}>
|
||||
{showWeekNumbers && (
|
||||
<Text style={styles.weekNumberCell}>{weekNumberFor(days[0], weekStartsOn)}</Text>
|
||||
)}
|
||||
{days.map((d, i) => {
|
||||
const sameMonth = (activeMask & (1 << i)) !== 0;
|
||||
const today = i === todayIndex;
|
||||
const selected = i === selectedIndex;
|
||||
const dayEvents = eventsOnDayFromIndex(index, d);
|
||||
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
|
||||
const MAX_CHIPS = dayEvents.length > 2 ? 1 : 2;
|
||||
const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS;
|
||||
const visible = dayEvents.slice(0, maxVisible);
|
||||
const overflow = Math.max(0, dayEvents.length - maxVisible);
|
||||
const monthLabel = labelMonths && d.getDate() === 1 ? format(d, 'MMM', { locale }) : null;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={i}
|
||||
style={[styles.dayCell, showTimeInMonthView && styles.dayCellTall]}
|
||||
onPress={() => onSelectDate(d)}
|
||||
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
||||
>
|
||||
<View style={[
|
||||
styles.dayNumber,
|
||||
today && styles.todayCircle,
|
||||
selected && !today && styles.selectedCircle,
|
||||
]}>
|
||||
{monthLabel && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.monthLabel,
|
||||
!sameMonth && styles.dayTextMuted,
|
||||
today && styles.todayText,
|
||||
selected && !today && styles.selectedText,
|
||||
]}
|
||||
>
|
||||
{monthLabel}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[
|
||||
styles.dayText,
|
||||
monthLabel !== null && styles.dayTextUnderLabel,
|
||||
!sameMonth && styles.dayTextMuted,
|
||||
today && styles.todayText,
|
||||
selected && !today && styles.selectedText,
|
||||
]}>
|
||||
{format(d, 'd')}
|
||||
</Text>
|
||||
</View>
|
||||
{dayEvents.length > 0 && !showTimeInMonthView && (
|
||||
<View style={styles.dotsRow}>
|
||||
{visible.map((event, idx) => (
|
||||
<View
|
||||
key={`${event.id}-${idx}`}
|
||||
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
|
||||
/>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<Text style={styles.overflowText}>+{overflow}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{dayEvents.length > 0 && showTimeInMonthView && (
|
||||
<View style={styles.chipsCol}>
|
||||
{visible.map((event, idx) => {
|
||||
const color = getEventColor(event, calendars);
|
||||
const cancelled = event.status === 'cancelled';
|
||||
return (
|
||||
<View key={`${event.id}-${idx}`} style={[styles.chip, { backgroundColor: color }]}>
|
||||
<Text
|
||||
style={[styles.chipText, cancelled && styles.chipTextCancelled]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{event.showWithoutTime
|
||||
? (event.title || '')
|
||||
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<Text style={styles.overflowText}>+{overflow}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const MonthWeekRow = React.memo(MonthWeekRowInner);
|
||||
|
||||
function MonthViewInner({
|
||||
currentDate,
|
||||
selectedDate,
|
||||
@@ -65,130 +238,50 @@ function MonthViewInner({
|
||||
onSelectDate,
|
||||
onLongPressDate,
|
||||
}: MonthViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const styles = useMonthStyles();
|
||||
const { locale } = useCalendarLocale();
|
||||
const weekdayLabels = React.useMemo(() => {
|
||||
const start = startOfWeek(new Date(), { weekStartsOn });
|
||||
return Array.from({ length: 7 }, (_, i) => format(addDays(start, i), 'EEEEE', { locale }));
|
||||
}, [weekStartsOn, locale]);
|
||||
const index = React.useMemo(
|
||||
() => eventsByDay ?? buildEventDayIndex(events),
|
||||
[eventsByDay, events],
|
||||
);
|
||||
const days = React.useMemo(() => {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const calStart = startOfWeek(monthStart, { weekStartsOn });
|
||||
const calEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||
const result: Date[] = [];
|
||||
|
||||
// The month grid as rows of 7 days.
|
||||
const rows = React.useMemo(() => {
|
||||
const calStart = startOfWeek(startOfMonth(currentDate), { weekStartsOn });
|
||||
const calEnd = endOfWeek(endOfMonth(currentDate), { weekStartsOn });
|
||||
const out: Date[][] = [];
|
||||
let day = calStart;
|
||||
while (day <= calEnd) {
|
||||
result.push(day);
|
||||
day = addDays(day, 1);
|
||||
out.push(Array.from({ length: 7 }, (_, i) => addDays(day, i)));
|
||||
day = addDays(day, 7);
|
||||
}
|
||||
return result;
|
||||
}, [currentDate, weekStartsOn]);
|
||||
|
||||
// Group days into rows of 7 so we can prepend a week-number column.
|
||||
const rows = React.useMemo(() => {
|
||||
const out: Date[][] = [];
|
||||
for (let i = 0; i < days.length; i += 7) out.push(days.slice(i, i + 7));
|
||||
return out;
|
||||
}, [days]);
|
||||
|
||||
const renderDay = (d: Date, key: React.Key, flexLayout = false) => {
|
||||
const sameMonth = isSameMonth(d, currentDate);
|
||||
const today = isToday(d);
|
||||
const selected = isSameDay(d, selectedDate);
|
||||
const dayEvents = eventsOnDayFromIndex(index, d);
|
||||
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
|
||||
const MAX_CHIPS = dayEvents.length > 2 ? 1 : 2;
|
||||
const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS;
|
||||
const visible = dayEvents.slice(0, maxVisible);
|
||||
const overflow = Math.max(0, dayEvents.length - maxVisible);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
style={[styles.dayCell, flexLayout && styles.dayCellFlex, showTimeInMonthView && styles.dayCellTall]}
|
||||
onPress={() => onSelectDate(d)}
|
||||
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
||||
>
|
||||
<View style={[
|
||||
styles.dayNumber,
|
||||
today && styles.todayCircle,
|
||||
selected && !today && styles.selectedCircle,
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.dayText,
|
||||
!sameMonth && styles.dayTextMuted,
|
||||
today && styles.todayText,
|
||||
selected && !today && styles.selectedText,
|
||||
]}>
|
||||
{format(d, 'd')}
|
||||
</Text>
|
||||
</View>
|
||||
{dayEvents.length > 0 && !showTimeInMonthView && (
|
||||
<View style={styles.dotsRow}>
|
||||
{visible.map((event, idx) => (
|
||||
<View
|
||||
key={`${event.id}-${idx}`}
|
||||
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
|
||||
/>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<Text style={styles.overflowText}>+{overflow}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{dayEvents.length > 0 && showTimeInMonthView && (
|
||||
<View style={styles.chipsCol}>
|
||||
{visible.map((event, idx) => {
|
||||
const color = getEventColor(event, calendars);
|
||||
const cancelled = event.status === 'cancelled';
|
||||
return (
|
||||
<View key={`${event.id}-${idx}`} style={[styles.chip, { backgroundColor: color }]}>
|
||||
<Text
|
||||
style={[styles.chipText, cancelled && styles.chipTextCancelled]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{event.showWithoutTime
|
||||
? (event.title || '')
|
||||
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<Text style={styles.overflowText}>+{overflow}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
}, [currentDate, weekStartsOn]);
|
||||
const activeMonth = monthKeyOf(currentDate);
|
||||
const today = new Date();
|
||||
|
||||
return (
|
||||
<View style={styles.grid}>
|
||||
<View style={styles.weekdayRow}>
|
||||
{showWeekNumbers && <Text style={styles.weekNumberLabel}>#</Text>}
|
||||
{weekdayLabels.map((wd, i) => (
|
||||
<Text key={i} style={styles.weekdayLabel}>{wd}</Text>
|
||||
))}
|
||||
</View>
|
||||
{showWeekNumbers ? (
|
||||
rows.map((row, rowIdx) => (
|
||||
<View key={rowIdx} style={styles.weekRow}>
|
||||
<Text style={styles.weekNumberCell}>{weekNumberFor(row[0], weekStartsOn)}</Text>
|
||||
{row.map((d, i) => renderDay(d, `${rowIdx}-${i}`, true))}
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.daysGrid}>
|
||||
{days.map((d, i) => renderDay(d, i))}
|
||||
</View>
|
||||
)}
|
||||
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
|
||||
{rows.map((days) => (
|
||||
<MonthWeekRow
|
||||
key={days[0].toISOString()}
|
||||
days={days}
|
||||
activeMask={monthMask(days, activeMonth)}
|
||||
selectedIndex={dayIndexIn(days, selectedDate)}
|
||||
todayIndex={dayIndexIn(days, today)}
|
||||
index={index}
|
||||
calendars={calendars}
|
||||
weekStartsOn={weekStartsOn}
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
showTimeInMonthView={showTimeInMonthView}
|
||||
timeFormat={timeFormat}
|
||||
locale={locale}
|
||||
styles={styles}
|
||||
onSelectDate={onSelectDate}
|
||||
onLongPressDate={onLongPressDate}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -205,7 +298,6 @@ function makeStyles(c: ThemePalette) {
|
||||
...typography.small,
|
||||
color: c.textMuted,
|
||||
},
|
||||
daysGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||
weekRow: { flexDirection: 'row', alignItems: 'flex-start' },
|
||||
weekNumberLabel: {
|
||||
width: 24,
|
||||
@@ -220,9 +312,8 @@ function makeStyles(c: ThemePalette) {
|
||||
color: c.textMuted,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 },
|
||||
dayCellFlex: { width: undefined, flex: 1 },
|
||||
dayCellTall: { minHeight: 74, paddingHorizontal: 1 },
|
||||
dayCell: { flex: 1, alignItems: 'center', paddingVertical: 4 },
|
||||
dayCellTall: { minHeight: MONTH_ROW_HEIGHT_CHIPS, paddingHorizontal: 1 },
|
||||
dayNumber: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -231,6 +322,9 @@ function makeStyles(c: ThemePalette) {
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dayText: { ...typography.body, color: c.text },
|
||||
// Day 1 under its month's name: both fit the 36px circle.
|
||||
dayTextUnderLabel: { lineHeight: 16 },
|
||||
monthLabel: { fontSize: 8, lineHeight: 10, fontWeight: '600', color: c.textSecondary, textTransform: 'uppercase' },
|
||||
dayTextMuted: { color: c.textMuted },
|
||||
todayCircle: { backgroundColor: c.primary },
|
||||
todayText: { color: c.textInverse, fontWeight: '700' },
|
||||
|
||||
@@ -41,6 +41,7 @@ export function CalendarSettings() {
|
||||
const timeZone = useSettingsStore((s) => s.calendarTimeZone);
|
||||
const showTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth);
|
||||
const showWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
||||
const freeScroll = useSettingsStore((s) => s.calendarFreeScroll);
|
||||
const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar);
|
||||
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
|
||||
@@ -140,6 +141,19 @@ export function CalendarSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
|
||||
description={t(
|
||||
'calendar.settings.calendar_free_scroll_desc',
|
||||
'Scroll continuously through months, weeks and days instead of one period at a time',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={freeScroll}
|
||||
onChange={(v) => update('calendarFreeScroll', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
|
||||
description={t(
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { dayKey } from '../calendar-utils';
|
||||
import {
|
||||
computeScrollWindow,
|
||||
freshScrollWindowState,
|
||||
growScrollWindow,
|
||||
} from '../calendar-scroll-window';
|
||||
import {
|
||||
dayIndexIn,
|
||||
monthFocusRow,
|
||||
monthKeyOf,
|
||||
monthMask,
|
||||
sampledRow,
|
||||
weekDays,
|
||||
windowWeekStarts,
|
||||
} from '../calendar-month-scroll';
|
||||
|
||||
const opts = { weekStartsOn: 1 as const };
|
||||
|
||||
describe('windowWeekStarts', () => {
|
||||
it('has one row per week of the window, each starting on the first weekday', () => {
|
||||
const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts);
|
||||
const weeks = windowWeekStarts(win);
|
||||
// 2026-07-27 .. 2026-11-08 is 15 weeks.
|
||||
expect(weeks).toHaveLength(15);
|
||||
expect(dayKey(weeks[0])).toBe('2026-07-27');
|
||||
expect(dayKey(weeks[14])).toBe('2026-11-02');
|
||||
expect(weeks.every((w) => w.getDay() === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps whole days across the DST change', () => {
|
||||
const win = { start: new Date(2026, 9, 19), end: new Date(2026, 10, 1) };
|
||||
const weeks = windowWeekStarts(win);
|
||||
expect(weeks.map(dayKey)).toEqual(['2026-10-19', '2026-10-26']);
|
||||
expect(weeks[1].getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it('adds rows above when the window grows into the past', () => {
|
||||
const state = freshScrollWindowState('month', new Date(2026, 8, 9));
|
||||
const before = windowWeekStarts(computeScrollWindow(state, opts));
|
||||
const after = windowWeekStarts(computeScrollWindow(growScrollWindow(state, 'before'), opts));
|
||||
const added = after.length - before.length;
|
||||
expect(added).toBeGreaterThanOrEqual(4);
|
||||
// The rows that were there keep their dates, shifted down by `added`.
|
||||
expect(after.slice(added).map(dayKey)).toEqual(before.map(dayKey));
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekDays', () => {
|
||||
it('lists the seven days of a week', () => {
|
||||
expect(weekDays(new Date(2026, 8, 28)).map(dayKey)).toEqual([
|
||||
'2026-09-28', '2026-09-29', '2026-09-30', '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthFocusRow', () => {
|
||||
const win = computeScrollWindow(
|
||||
growScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), 'before'),
|
||||
opts,
|
||||
);
|
||||
const count = windowWeekStarts(win).length;
|
||||
|
||||
it('is the row of the week that starts the month grid', () => {
|
||||
const row = monthFocusRow(win, new Date(2026, 9, 20), count, opts);
|
||||
// October 2026's grid starts on Monday 2026-09-28.
|
||||
expect(dayKey(windowWeekStarts(win)[row])).toBe('2026-09-28');
|
||||
});
|
||||
|
||||
it('stays inside the list', () => {
|
||||
expect(monthFocusRow(win, new Date(2020, 0, 1), count, opts)).toBe(0);
|
||||
expect(monthFocusRow(win, new Date(2030, 0, 1), count, opts)).toBe(count - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sampledRow', () => {
|
||||
it('picks the row under 40% of the viewport', () => {
|
||||
// 6 rows of 54px: the sample line is 129.6px below the top.
|
||||
expect(sampledRow(0, 324, 54, 20)).toBe(2);
|
||||
expect(sampledRow(54 * 5, 324, 54, 20)).toBe(7);
|
||||
});
|
||||
|
||||
it('clamps to the rows there are', () => {
|
||||
expect(sampledRow(-50, 324, 54, 20)).toBe(1);
|
||||
expect(sampledRow(100000, 324, 54, 20)).toBe(19);
|
||||
expect(sampledRow(0, 324, 54, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthMask / monthKeyOf', () => {
|
||||
it('marks the days of the month in focus', () => {
|
||||
const days = weekDays(new Date(2026, 8, 28)); // Sep 28 .. Oct 4
|
||||
expect(monthMask(days, monthKeyOf(new Date(2026, 8, 1)))).toBe(0b0000111);
|
||||
expect(monthMask(days, monthKeyOf(new Date(2026, 9, 1)))).toBe(0b1111000);
|
||||
expect(monthMask(days, monthKeyOf(new Date(2026, 10, 1)))).toBe(0);
|
||||
});
|
||||
|
||||
it('tells months of different years apart', () => {
|
||||
expect(monthKeyOf(new Date(2026, 0, 1))).not.toBe(monthKeyOf(new Date(2027, 0, 1)));
|
||||
expect(monthKeyOf(new Date(2026, 11, 31)) + 1).toBe(monthKeyOf(new Date(2027, 0, 1)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayIndexIn', () => {
|
||||
const days = weekDays(new Date(2026, 8, 28));
|
||||
|
||||
it('finds a day in the row regardless of the time', () => {
|
||||
expect(dayIndexIn(days, new Date(2026, 9, 1, 23, 30))).toBe(3);
|
||||
expect(dayIndexIn(days, new Date(2026, 8, 28))).toBe(0);
|
||||
});
|
||||
|
||||
it('is -1 outside the row', () => {
|
||||
expect(dayIndexIn(days, new Date(2026, 9, 5))).toBe(-1);
|
||||
expect(dayIndexIn(days, new Date(2026, 8, 27))).toBe(-1);
|
||||
expect(dayIndexIn([], new Date())).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -47,9 +47,10 @@ describe('baseRange', () => {
|
||||
});
|
||||
|
||||
describe('computeScrollWindow', () => {
|
||||
it('starts as the base range plus the initial days after it, snapped to whole weeks', () => {
|
||||
it('starts as the base range plus a step either side, snapped to whole weeks', () => {
|
||||
const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts);
|
||||
expect(local(win.start)).toBe('2026-08-31');
|
||||
// 2026-08-31 - 30 days = 2026-08-01 (Saturday) -> start of that week
|
||||
expect(local(win.start)).toBe('2026-07-27');
|
||||
// 2026-10-04 + 30 days = 2026-11-03 (Tuesday) -> end of that week
|
||||
expect(local(win.end)).toBe('2026-11-08');
|
||||
expect(win.canExtendStart).toBe(true);
|
||||
@@ -76,15 +77,15 @@ describe('computeScrollWindow', () => {
|
||||
|
||||
it('does not snap the day view to weeks', () => {
|
||||
const win = computeScrollWindow(freshScrollWindowState('day', new Date(2026, 8, 9)), opts);
|
||||
expect(local(win.start)).toBe('2026-09-09');
|
||||
expect(local(win.start)).toBe('2026-08-10');
|
||||
expect(local(win.end)).toBe('2026-10-09');
|
||||
});
|
||||
|
||||
it('snaps a grown week window to whole weeks', () => {
|
||||
const state = growScrollWindow(freshScrollWindowState('week', new Date(2026, 8, 9)), 'before');
|
||||
const win = computeScrollWindow(state, opts);
|
||||
// 2026-09-07 - 30 days = 2026-08-08 (Saturday) -> Monday 2026-08-03
|
||||
expect(local(win.start)).toBe('2026-08-03');
|
||||
// 2026-09-07 - 60 days = 2026-07-09 (Thursday) -> Monday 2026-07-06
|
||||
expect(local(win.start)).toBe('2026-07-06');
|
||||
expect(win.start.getDay()).toBe(1);
|
||||
expect(win.end.getDay()).toBe(0);
|
||||
});
|
||||
@@ -137,8 +138,8 @@ describe('normalizeScrollWindowState / scrollWindowContains', () => {
|
||||
const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts);
|
||||
expect(scrollWindowContains(win, 'month', new Date(2026, 9, 15), opts)).toBe(true); // October grid ends Nov 1
|
||||
expect(scrollWindowContains(win, 'month', new Date(2026, 10, 15), opts)).toBe(false); // November grid ends Dec 6
|
||||
expect(scrollWindowContains(win, 'week', new Date(2026, 7, 31), opts)).toBe(true);
|
||||
expect(scrollWindowContains(win, 'week', new Date(2026, 7, 30), opts)).toBe(false);
|
||||
expect(scrollWindowContains(win, 'week', new Date(2026, 6, 27), opts)).toBe(true); // window starts Jul 27
|
||||
expect(scrollWindowContains(win, 'week', new Date(2026, 6, 26), opts)).toBe(false);
|
||||
});
|
||||
|
||||
it('keys the anchor by calendar day', () => {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { addDays, differenceInCalendarDays } from 'date-fns';
|
||||
import { baseRange, type DayRange, type ScrollWindowOptions } from './calendar-scroll-window';
|
||||
|
||||
/**
|
||||
* Geometry of the freely scrolling month view (#759, webmail
|
||||
* calendar-month-view): one fixed-height row per week of the window.
|
||||
*/
|
||||
|
||||
/** Fraction of the viewport height at which the "current month" is sampled. */
|
||||
export const VISIBLE_MONTH_SAMPLE = 0.4;
|
||||
|
||||
/** The first day of every week in the window (which is made of whole weeks). */
|
||||
export function windowWeekStarts(window: DayRange): Date[] {
|
||||
const count = Math.floor(differenceInCalendarDays(window.end, window.start) / 7) + 1;
|
||||
const weeks: Date[] = [];
|
||||
for (let i = 0; i < count; i++) weeks.push(addDays(window.start, i * 7));
|
||||
return weeks;
|
||||
}
|
||||
|
||||
/** The seven days of the week starting at `weekStart`. */
|
||||
export function weekDays(weekStart: Date): Date[] {
|
||||
return Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||
}
|
||||
|
||||
/** Row of the week that starts `date`'s month grid: where navigation puts the top. */
|
||||
export function monthFocusRow(
|
||||
window: DayRange,
|
||||
date: Date,
|
||||
rowCount: number,
|
||||
opts: ScrollWindowOptions,
|
||||
): number {
|
||||
const gridStart = baseRange('month', date, opts).start;
|
||||
const row = Math.floor(differenceInCalendarDays(gridStart, window.start) / 7);
|
||||
return Math.max(0, Math.min(rowCount - 1, row));
|
||||
}
|
||||
|
||||
/** Row under the sample line for a scroll offset. */
|
||||
export function sampledRow(
|
||||
offset: number,
|
||||
viewportHeight: number,
|
||||
rowHeight: number,
|
||||
rowCount: number,
|
||||
fraction = VISIBLE_MONTH_SAMPLE,
|
||||
): number {
|
||||
if (rowCount <= 0 || rowHeight <= 0) return 0;
|
||||
const row = Math.floor((offset + viewportHeight * fraction) / rowHeight);
|
||||
return Math.max(0, Math.min(rowCount - 1, row));
|
||||
}
|
||||
|
||||
/** Year and month as one comparable number. */
|
||||
export function monthKeyOf(date: Date): number {
|
||||
return date.getFullYear() * 12 + date.getMonth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bit i is set when day i of the week belongs to the month `monthKey`. Rows
|
||||
* compare this instead of the month itself, so moving to another month only
|
||||
* re-renders the rows whose shading changes.
|
||||
*/
|
||||
export function monthMask(days: Date[], monthKey: number): number {
|
||||
let mask = 0;
|
||||
days.forEach((day, i) => {
|
||||
if (monthKeyOf(day) === monthKey) mask |= 1 << i;
|
||||
});
|
||||
return mask;
|
||||
}
|
||||
|
||||
/** Index of `date` in `days` (same calendar day), or -1. */
|
||||
export function dayIndexIn(days: Date[], date: Date): number {
|
||||
if (days.length === 0) return -1;
|
||||
const diff = differenceInCalendarDays(date, days[0]);
|
||||
return diff >= 0 && diff < days.length ? diff : -1;
|
||||
}
|
||||
@@ -71,13 +71,26 @@ const INITIAL_AFTER: Record<ScrollViewMode, number> = {
|
||||
day: SCROLL_WINDOW_STEP,
|
||||
};
|
||||
|
||||
/**
|
||||
* Days loaded before the base range at first. The grids open with a step
|
||||
* of room above / to the left, so the list isn't mounted right at its start
|
||||
* edge (where rows would be prepended while it is still settling on the
|
||||
* focus). The agenda starts at its anchor and loads the past on request.
|
||||
*/
|
||||
const INITIAL_BEFORE: Record<ScrollViewMode, number> = {
|
||||
month: SCROLL_WINDOW_STEP,
|
||||
agenda: 0,
|
||||
week: SCROLL_WINDOW_STEP,
|
||||
day: SCROLL_WINDOW_STEP,
|
||||
};
|
||||
|
||||
export function parseDayKey(key: string): Date {
|
||||
const [y, m, d] = key.split('-').map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
export function freshScrollWindowState(mode: ScrollViewMode, anchor: Date): ScrollWindowState {
|
||||
return { mode, anchorKey: dayKey(anchor), before: 0, after: INITIAL_AFTER[mode] };
|
||||
return { mode, anchorKey: dayKey(anchor), before: INITIAL_BEFORE[mode], after: INITIAL_AFTER[mode] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,7 @@ import { Button } from '../components';
|
||||
import { useCalendarStore } from '../stores/calendar-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { MonthView } from '../components/calendar/MonthView';
|
||||
import { MonthScrollView } from '../components/calendar/MonthScrollView';
|
||||
import { WeekView } from '../components/calendar/WeekView';
|
||||
import { AgendaView } from '../components/calendar/AgendaView';
|
||||
import { EventCard } from '../components/calendar/EventCard';
|
||||
@@ -154,6 +155,7 @@ export default function CalendarScreen() {
|
||||
const showTasksOnCalendar = useSettingsStore((s) => s.showTasksOnCalendar);
|
||||
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
|
||||
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
||||
const calendarFreeScroll = useSettingsStore((s) => s.calendarFreeScroll);
|
||||
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
||||
const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
|
||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
@@ -172,10 +174,12 @@ export default function CalendarScreen() {
|
||||
const [viewMode, setViewMode] = React.useState<ViewMode>(initialViewMode);
|
||||
|
||||
// Scroll window (#759, webmail calendar-app): every view shows a window of
|
||||
// days around the day the user navigated to (the "focus"). The agenda
|
||||
// scrolls freely: reaching an edge widens that side and only the new days
|
||||
// are fetched. Month and week show exactly one period. Navigation moves the
|
||||
// focus and, when that leaves the window, starts a fresh window there.
|
||||
// days around the day the user navigated to (the "focus"). With free
|
||||
// scrolling (the default) the agenda and the month scroll continuously:
|
||||
// reaching an edge widens that side and only the new days are fetched.
|
||||
// Otherwise, and in the week view, a view shows exactly one period.
|
||||
// Navigation moves the focus and, when that leaves the window, starts a
|
||||
// fresh window there.
|
||||
const windowOptions = React.useMemo<ScrollWindowOptions>(
|
||||
() => ({ weekStartsOn: calendarFirstDayOfWeek }),
|
||||
[calendarFirstDayOfWeek],
|
||||
@@ -186,7 +190,7 @@ export default function CalendarScreen() {
|
||||
const [windowState, setWindowState] = React.useState<ScrollWindowState>(
|
||||
() => freshScrollWindowState(initialViewMode, new Date()),
|
||||
);
|
||||
const freeScroll = viewMode === 'agenda';
|
||||
const freeScroll = calendarFreeScroll && (viewMode === 'agenda' || viewMode === 'month');
|
||||
const focusKey = dayKey(focus.date);
|
||||
const activeWindowState = React.useMemo(
|
||||
() =>
|
||||
@@ -403,20 +407,16 @@ export default function CalendarScreen() {
|
||||
}, [syncDueSubscriptions]);
|
||||
|
||||
// Load the window's events: one fetch at a time, and only the days not
|
||||
// loaded yet (calendar-store extendRange). `loadingEdge` is the side an
|
||||
// extension asked for, until the wider window is in.
|
||||
// loaded yet (calendar-store extendRange). `loadingEdge` is the side the
|
||||
// last extension asked for, until the wider window is in.
|
||||
const [rangeLoading, setRangeLoading] = React.useState(false);
|
||||
const [loadingEdge, setLoadingEdge] = React.useState<'start' | 'end' | null>(null);
|
||||
const loadingEdgeRef = React.useRef<'start' | 'end' | null>(null);
|
||||
const [rangeLoader] = React.useState(() =>
|
||||
createRangeLoader(
|
||||
(range) => useCalendarStore.getState().extendRange(range.after, range.before),
|
||||
(busy) => {
|
||||
setRangeLoading(busy);
|
||||
if (!busy) {
|
||||
loadingEdgeRef.current = null;
|
||||
setLoadingEdge(null);
|
||||
}
|
||||
if (!busy) setLoadingEdge(null);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -440,12 +440,11 @@ export default function CalendarScreen() {
|
||||
[viewMode, windowOptions],
|
||||
);
|
||||
|
||||
// The grids add their rows for the wider window right away (the events
|
||||
// follow); the agenda waits for one extension to load before the next.
|
||||
const extendWindow = React.useCallback(
|
||||
(side: 'before' | 'after') => {
|
||||
if (loadingEdgeRef.current) return;
|
||||
const edge = side === 'before' ? 'start' : 'end';
|
||||
loadingEdgeRef.current = edge;
|
||||
setLoadingEdge(edge);
|
||||
setLoadingEdge(side === 'before' ? 'start' : 'end');
|
||||
setWindowState((prev) =>
|
||||
growScrollWindow(normalizeScrollWindowState(prev, viewMode, focus.date), side),
|
||||
);
|
||||
@@ -957,7 +956,27 @@ export default function CalendarScreen() {
|
||||
)}
|
||||
|
||||
<View style={styles.content}>
|
||||
{viewMode === 'month' && (
|
||||
{viewMode === 'month' && freeScroll && (
|
||||
<MonthScrollView
|
||||
key={windowKey}
|
||||
focus={focus}
|
||||
window={scrollWindow}
|
||||
onExtendStart={scrollWindow.canExtendStart ? extendWindowStart : undefined}
|
||||
onExtendEnd={scrollWindow.canExtendEnd ? extendWindowEnd : undefined}
|
||||
onVisibleDateChange={setVisibleDate}
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
weekStartsOn={calendarFirstDayOfWeek}
|
||||
showWeekNumbers={calendarShowWeekNumbers}
|
||||
showTimeInMonthView={calendarShowTimeInMonth}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onSelectDate={handleSelectDate}
|
||||
onLongPressDate={openCreate}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'month' && !freeScroll && (
|
||||
<MonthView
|
||||
currentDate={focus.date}
|
||||
selectedDate={selectedDate}
|
||||
|
||||
@@ -232,6 +232,9 @@ interface PersistedSettings {
|
||||
calendarTimeFormat: TimeFormat;
|
||||
calendarShowTimeInMonth: boolean;
|
||||
calendarShowWeekNumbers: boolean;
|
||||
// Scroll continuously through months, weeks and days (#759) instead of
|
||||
// one period at a time. Same key as the webmail's setting.
|
||||
calendarFreeScroll: boolean;
|
||||
calendarHoverPreview: CalendarHoverPreview;
|
||||
// IANA zone the calendar works in, or 'auto' to follow the device (#755).
|
||||
// Same key semantics as the webmail's `timeZone` setting.
|
||||
@@ -392,6 +395,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
calendarTimeFormat: '24h',
|
||||
calendarShowTimeInMonth: true,
|
||||
calendarShowWeekNumbers: false,
|
||||
calendarFreeScroll: true,
|
||||
calendarHoverPreview: 'delay-500ms',
|
||||
calendarTimeZone: 'auto',
|
||||
showBirthdayCalendar: false,
|
||||
|
||||
Reference in New Issue
Block a user