mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: show scheduled times rounded to the minute and offer this morning, this afternoon and Monday presets (bulwarkmail/webmail#1390)
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { formatScheduledSendAt, schedulePresetLabel, schedulePresets } from '../compose-schedule';
|
||||
|
||||
// Ported from the webmail's lib/__tests__/schedule-presets.test.ts and
|
||||
// scheduled-send-label.test.ts. Local dates, so the expectations hold in any
|
||||
// time zone.
|
||||
const local = (y: number, mo: number, d: number, h = 0, mi = 0, s = 0) => new Date(y, mo - 1, d, h, mi, s);
|
||||
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, rel), 'utf8');
|
||||
|
||||
describe('schedulePresets', () => {
|
||||
it('offers tomorrow morning and afternoon and next Monday morning', () => {
|
||||
// Wednesday 2026-10-14, 18:30
|
||||
const presets = schedulePresets(local(2026, 10, 14, 18, 30));
|
||||
expect(presets.map((p) => [p.key, p.date.getTime()])).toEqual([
|
||||
['tomorrow_morning', local(2026, 10, 15, 8).getTime()],
|
||||
['tomorrow_afternoon', local(2026, 10, 15, 13).getTime()],
|
||||
['monday_morning', local(2026, 10, 19, 8).getTime()],
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips Monday on a Sunday, when it is tomorrow anyway', () => {
|
||||
expect(schedulePresets(local(2026, 10, 11, 18)).map((p) => p.key)).toEqual(['tomorrow_morning', 'tomorrow_afternoon']);
|
||||
});
|
||||
|
||||
it('picks the Monday a week ahead on a Monday', () => {
|
||||
const presets = schedulePresets(local(2026, 10, 12, 9));
|
||||
expect(presets.find((p) => p.key === 'monday_morning')?.date.getTime()).toBe(local(2026, 10, 19, 8).getTime());
|
||||
});
|
||||
|
||||
it('offers this morning and afternoon after midnight, while they are ahead (#1390)', () => {
|
||||
// Thursday 2026-10-15, 01:30
|
||||
const presets = schedulePresets(local(2026, 10, 15, 1, 30));
|
||||
expect(presets.slice(0, 3).map((p) => [p.key, p.date.getTime()])).toEqual([
|
||||
['this_morning', local(2026, 10, 15, 8).getTime()],
|
||||
['this_afternoon', local(2026, 10, 15, 13).getTime()],
|
||||
['tomorrow_morning', local(2026, 10, 16, 8).getTime()],
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops a time of today that is past or about to be', () => {
|
||||
expect(schedulePresets(local(2026, 10, 15, 9)).map((p) => p.key)[0]).toBe('this_afternoon');
|
||||
expect(schedulePresets(local(2026, 10, 15, 12, 58)).map((p) => p.key)[0]).toBe('tomorrow_morning');
|
||||
});
|
||||
|
||||
it('labels every preset', () => {
|
||||
const t = (_key: string, fallback?: string) => fallback ?? '';
|
||||
expect(schedulePresets(local(2026, 10, 15, 1)).map((p) => schedulePresetLabel(p.key, t)))
|
||||
.toEqual(['This morning', 'This afternoon', 'Tomorrow morning', 'Tomorrow afternoon', 'Monday morning']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatScheduledSendAt', () => {
|
||||
const today = local(2026, 10, 11, 18, 0);
|
||||
|
||||
it('shows 20:00 for a release one second before eight', () => {
|
||||
const label = formatScheduledSendAt(local(2026, 10, 11, 19, 59, 59).toISOString(), '24h', 'en', today);
|
||||
expect(label).toContain('20:00');
|
||||
expect(label).not.toContain('19:59');
|
||||
});
|
||||
|
||||
it('shows only the time for a release later today', () => {
|
||||
expect(formatScheduledSendAt(local(2026, 10, 11, 21, 30).toISOString(), '24h', 'en', today)).toBe('21:30');
|
||||
});
|
||||
|
||||
it('includes the date for a release on another day', () => {
|
||||
const label = formatScheduledSendAt(local(2026, 10, 12, 7, 59, 59).toISOString(), '24h', 'en', today);
|
||||
expect(label).toContain('08:00');
|
||||
expect(label).toMatch(/12/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schedule wiring', () => {
|
||||
it('offers the presets in the composer and in Reschedule, and labels times rounded', () => {
|
||||
const compose = read('../../screens/ComposeScreen.tsx');
|
||||
expect(compose).toMatch(/\.\.\.webmailSchedulePresets\(now\)\.map\(/);
|
||||
expect(compose).toMatch(/time: formatScheduledSendAt\(when\.toISOString\(\), timeFormat, locale\)/);
|
||||
const scheduled = read('../../screens/ScheduledScreen.tsx');
|
||||
expect(scheduled).toMatch(/\.\.\.schedulePresets\(now\)\.map\(/);
|
||||
expect(scheduled).toMatch(/\(iso: string\) => formatScheduledSendAt\(iso, timeFormat, locale\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// "Schedule send" presets and the release time of a scheduled message.
|
||||
// Ports of the webmail's lib/schedule-presets.ts and
|
||||
// lib/scheduled-send-label.ts.
|
||||
|
||||
import { intlLocale } from './intl-locale';
|
||||
|
||||
export type SchedulePresetKey = 'this_morning' | 'this_afternoon' | 'tomorrow_morning' | 'tomorrow_afternoon' | 'monday_morning';
|
||||
|
||||
export interface SchedulePreset {
|
||||
key: SchedulePresetKey;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
function at(base: Date, daysAhead: number, hour: number): Date {
|
||||
const date = new Date(base);
|
||||
date.setDate(date.getDate() + daysAhead);
|
||||
date.setHours(hour, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
/** How far ahead a time for today must be to be offered, so it is not past by the tap. */
|
||||
const TODAY_LEAD_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* One-tap times for "Schedule send": tomorrow at 8:00 and 13:00, and next
|
||||
* Monday at 8:00. Monday is left out when it is tomorrow anyway (on a
|
||||
* Sunday). Today's 8:00 and 13:00 come first while they are still ahead, so
|
||||
* a message written after midnight can go out the same morning (#1390).
|
||||
*/
|
||||
export function schedulePresets(now: Date = new Date()): SchedulePreset[] {
|
||||
const presets: SchedulePreset[] = [
|
||||
{ key: 'this_morning' as const, date: at(now, 0, 8) },
|
||||
{ key: 'this_afternoon' as const, date: at(now, 0, 13) },
|
||||
].filter((preset) => preset.date.getTime() - now.getTime() >= TODAY_LEAD_MS);
|
||||
presets.push(
|
||||
{ key: 'tomorrow_morning', date: at(now, 1, 8) },
|
||||
{ key: 'tomorrow_afternoon', date: at(now, 1, 13) },
|
||||
);
|
||||
const daysToMonday = ((8 - now.getDay()) % 7) || 7;
|
||||
if (daysToMonday > 1) presets.push({ key: 'monday_morning', date: at(now, daysToMonday, 8) });
|
||||
return presets;
|
||||
}
|
||||
|
||||
/** The label of a "Schedule send" preset (webmail email_composer.schedule_preset). */
|
||||
export function schedulePresetLabel(
|
||||
key: SchedulePresetKey,
|
||||
t: (key: string, fallback?: string) => string,
|
||||
): string {
|
||||
switch (key) {
|
||||
case 'this_morning': return t('email_composer.schedule_preset.this_morning', 'This morning');
|
||||
case 'this_afternoon': return t('email_composer.schedule_preset.this_afternoon', 'This afternoon');
|
||||
case 'tomorrow_morning': return t('email_composer.schedule_preset.tomorrow_morning', 'Tomorrow morning');
|
||||
case 'tomorrow_afternoon': return t('email_composer.schedule_preset.tomorrow_afternoon', 'Tomorrow afternoon');
|
||||
case 'monday_morning': return t('email_composer.schedule_preset.monday_morning', 'Monday morning');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The release time of a scheduled message, for lists, banners and the
|
||||
* "Scheduled" confirmation.
|
||||
*
|
||||
* The server can land a second before the minute the user picked (08:00
|
||||
* showed as 07:59), so the time is rounded to the nearest minute. A message
|
||||
* that is not due today also shows its date; a bare "07:59" for tomorrow
|
||||
* morning read like a time that had already passed.
|
||||
*/
|
||||
export function formatScheduledSendAt(
|
||||
sendAt: string,
|
||||
timeFormat: '12h' | '24h',
|
||||
locale?: string,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const ms = Date.parse(sendAt);
|
||||
if (!Number.isFinite(ms)) return sendAt;
|
||||
const rounded = new Date(Math.round(ms / 60_000) * 60_000);
|
||||
const sameDay = rounded.toDateString() === now.toDateString();
|
||||
const time = { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' } as const;
|
||||
const options: Intl.DateTimeFormatOptions = sameDay
|
||||
? time
|
||||
: { ...time, weekday: 'short', month: 'short', day: 'numeric' };
|
||||
try {
|
||||
return rounded.toLocaleString(intlLocale(locale), options);
|
||||
} catch {
|
||||
return rounded.toLocaleString();
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
|
||||
import { placeholderWordsFor, unfilledPlaceholdersInMessage } from '../lib/placeholder-check';
|
||||
import { sendTargetFor } from '../lib/compose-send-account';
|
||||
import { findAttachmentKeyword } from '../lib/compose-send-checks';
|
||||
import { formatScheduledSendAt, schedulePresetLabel, schedulePresets as webmailSchedulePresets } from '../lib/compose-schedule';
|
||||
import { inlineImageType } from '../lib/compose-inline-type';
|
||||
import { attachmentFromFileNode } from '../lib/compose-files';
|
||||
import { supportsFiles } from '../api/files';
|
||||
@@ -2298,15 +2299,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const schedulePresets = React.useMemo(() => {
|
||||
const now = new Date();
|
||||
const inHours = (h: number) => new Date(now.getTime() + h * 3600 * 1000);
|
||||
const tomorrowMorning = new Date(now);
|
||||
tomorrowMorning.setDate(tomorrowMorning.getDate() + 1);
|
||||
tomorrowMorning.setHours(8, 0, 0, 0);
|
||||
// Only offer times within the server's hold limit.
|
||||
const maxMs = jmapClient.getMaxDelayedSend() * 1000;
|
||||
return [
|
||||
{ label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) },
|
||||
{ label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) },
|
||||
{ label: t('email_composer.schedule_tomorrow_morning', 'Tomorrow morning'), date: tomorrowMorning },
|
||||
// The webmail's: this morning and afternoon while still ahead (#1390),
|
||||
// tomorrow at 8:00 and 13:00, next Monday at 8:00.
|
||||
...webmailSchedulePresets(now).map((preset) => ({ label: schedulePresetLabel(preset.key, t), date: preset.date })),
|
||||
].filter((preset) => preset.date.getTime() - now.getTime() <= maxMs);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [t, scheduleSheetOpen]);
|
||||
@@ -2480,7 +2480,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const when = result.sendAt ? new Date(result.sendAt) : scheduledAt;
|
||||
Alert.alert(
|
||||
t('email_composer.scheduled_title', 'Scheduled'),
|
||||
t('email_composer.scheduled_body', 'Your message will be sent at {time}.', { time: formatWhen(when) }),
|
||||
t('email_composer.scheduled_body', 'Your message will be sent at {time}.', {
|
||||
// The server can land a second early: 08:00 read 07:59.
|
||||
time: formatScheduledSendAt(when.toISOString(), timeFormat, locale),
|
||||
}),
|
||||
);
|
||||
} else if (result.scheduled) {
|
||||
// Undo-send window: the undo bar offers Undo / Send now.
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useSendUndoStore } from '../stores/send-undo-store';
|
||||
import { toast } from '../stores/toast-store';
|
||||
import { draftContextFromEmail } from '../lib/draft-context';
|
||||
import { formatQuoteDate } from '../lib/quote-header';
|
||||
import { formatScheduledSendAt, schedulePresetLabel, schedulePresets } from '../lib/compose-schedule';
|
||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
@@ -93,8 +93,10 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
}, []);
|
||||
useBackWhileFocused(!!viewing, closeView);
|
||||
|
||||
// Rounded to the minute (the server can land a second early: 08:00 read
|
||||
// 07:59), with the date when it is not today (webmail scheduled-send-label).
|
||||
const formatWhen = React.useCallback(
|
||||
(iso: string) => formatQuoteDate(iso, timeFormat, locale),
|
||||
(iso: string) => formatScheduledSendAt(iso, timeFormat, locale),
|
||||
[timeFormat, locale],
|
||||
);
|
||||
|
||||
@@ -325,15 +327,13 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
const reschedulePresets = React.useMemo(() => {
|
||||
const now = new Date();
|
||||
const inHours = (h: number) => new Date(now.getTime() + h * 3600 * 1000);
|
||||
const tomorrowMorning = new Date(now);
|
||||
tomorrowMorning.setDate(tomorrowMorning.getDate() + 1);
|
||||
tomorrowMorning.setHours(8, 0, 0, 0);
|
||||
// Only offer times within the server's hold limit.
|
||||
const maxMs = jmapClient.getMaxDelayedSend(rescheduleFor?.accountId) * 1000;
|
||||
return [
|
||||
{ label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) },
|
||||
{ label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) },
|
||||
{ label: t('email_composer.schedule_tomorrow_morning', 'Tomorrow morning'), date: tomorrowMorning },
|
||||
// The composer's presets, the webmail's (lib/compose-schedule).
|
||||
...schedulePresets(now).map((preset) => ({ label: schedulePresetLabel(preset.key, t), date: preset.date })),
|
||||
].filter((preset) => preset.date.getTime() - now.getTime() <= maxMs);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [t, rescheduleFor]);
|
||||
|
||||
Reference in New Issue
Block a user