From 9781b304787464762e25cd5f0737e1d58c3c9025 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:38:46 +0100 Subject: [PATCH] fix: show a scheduled out of office as scheduled, mark its placeholders and stop Save reading as busy --- locales/rn/de.json | 4 ++ locales/rn/en.json | 4 ++ src/components/settings/VacationSettings.tsx | 41 +++++++++++---- src/lib/__tests__/vacation-status.test.ts | 53 ++++++++++++++++++++ src/lib/vacation-status.ts | 18 +++++++ 5 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 src/lib/__tests__/vacation-status.test.ts create mode 100644 src/lib/vacation-status.ts diff --git a/locales/rn/de.json b/locales/rn/de.json index f810eca3..234ed8ae 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -1261,6 +1261,10 @@ "mobile_description": "Wählen Sie ein Farbdesign. Designs folgen der Hell/Dunkel-Einstellung unter Darstellung; eigene Designpakete werden im Webmail verwaltet." }, "vacation": { + "status": { + "ended": "Beendet", + "scheduled": "Geplant" + }, "warnings": { "date_format": "Datumsangaben müssen das Format \"YYYY-MM-DD\" oder \"YYYY-MM-DD HH:MM\" haben" } diff --git a/locales/rn/en.json b/locales/rn/en.json index 8b53ba9e..730e7571 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1626,6 +1626,10 @@ "description": "The message that will be sent as a reply", "title": "Auto-Reply Message" }, + "status": { + "ended": "Ended", + "scheduled": "Scheduled" + }, "warnings": { "date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\"" } diff --git a/src/components/settings/VacationSettings.tsx b/src/components/settings/VacationSettings.tsx index 09caed0c..1e96a348 100644 --- a/src/components/settings/VacationSettings.tsx +++ b/src/components/settings/VacationSettings.tsx @@ -29,6 +29,7 @@ import { utcIsoToLocalInput, } from '../../lib/vacation-dates'; import { STALWART_VACATION_LIMITS, vacationOversize } from '../../lib/vacation-limits'; +import { vacationStatus } from '../../lib/vacation-status'; import { jmapClient } from '../../api/jmap-client'; import { getSieveCapabilities } from '../../api/sieve'; @@ -139,6 +140,8 @@ export function VacationSettings() { const toParsed = parseLocalInput(toDate); const formatError = !isValidLocalInput(fromDate) || !isValidLocalInput(toDate); const endBeforeStart = !!(fromParsed && toParsed && toParsed <= fromParsed); + // "Scheduled" while the start date is ahead, not "Active". + const status = vacationStatus(enabled, fromParsed, toParsed); const htmlText = htmlEnabled ? htmlToPlainText(htmlBody).trim() : ''; const hasHtmlContent = htmlText.length > 0; const emptyBody = enabled && !body.trim() && !hasHtmlContent; @@ -235,6 +238,9 @@ export function VacationSettings() { forwardChanged, forwardSettings, audienceChanged, audienceSettings, ]); + const saveLabel = store.isSaving + ? t('settings.vacation.saving', 'Saving...') + : t('settings.vacation.save', 'Save Changes'); const title = t('settings.vacation.title', 'Vacation Responder'); const description = t('settings.vacation.description', "Automatically reply to incoming emails while you're away"); @@ -284,11 +290,15 @@ export function VacationSettings() { description={t('settings.vacation.status.description', 'Send an automatic reply to people who email you')} > - - - {enabled + + + {status === 'active' ? t('settings.vacation.status.active', 'Active') - : t('settings.vacation.status.inactive', 'Inactive')} + : status === 'scheduled' + ? t('settings.vacation.status.scheduled', 'Scheduled') + : status === 'ended' + ? t('settings.vacation.status.ended', 'Ended') + : t('settings.vacation.status.inactive', 'Inactive')} @@ -373,7 +383,7 @@ export function VacationSettings() { placeholder={t('settings.vacation.message.subject_placeholder', 'Out of Office')} placeholderTextColor={c.mutedForeground} accessibilityLabel={t('settings.vacation.message.subject_label', 'Subject')} - style={[styles.subjectInput, oversize.subject && styles.inputInvalid]} + style={[styles.subjectInput, !subject && styles.placeholderShown, oversize.subject && styles.inputInvalid]} /> {subjectTooLong && {subjectTooLong}} @@ -386,7 +396,7 @@ export function VacationSettings() { onChangeText={setBody} placeholder={t('settings.vacation.message.body_placeholder', 'Thank you for your email. I am currently out of the office and will respond when I return.')} placeholderTextColor={c.mutedForeground} - style={[styles.bodyInput, oversize.body && styles.inputInvalid]} + style={[styles.bodyInput, !body && styles.placeholderShown, oversize.body && styles.inputInvalid]} multiline numberOfLines={6} textAlignVertical="top" @@ -528,10 +538,16 @@ export function VacationSettings() { )} - @@ -597,6 +613,11 @@ function makeStyles(c: ThemePalette) { pillText: { fontSize: 12 }, pillTextActive: { color: c.success }, pillTextInactive: { color: c.mutedForeground }, + pillScheduled: { backgroundColor: c.primaryBg }, + pillTextScheduled: { color: c.primary }, + // An empty field's placeholder in italics: the sample reply read as a + // message already typed, next to the warning that the body is empty. + placeholderShown: { fontStyle: 'italic' }, dateInput: { minWidth: 180, paddingHorizontal: spacing.md, diff --git a/src/lib/__tests__/vacation-status.test.ts b/src/lib/__tests__/vacation-status.test.ts new file mode 100644 index 00000000..f736114c --- /dev/null +++ b/src/lib/__tests__/vacation-status.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { vacationStatus } from '../vacation-status'; + +const NOW = new Date(2026, 10, 1, 12, 0); +const day = (offset: number) => new Date(2026, 10, 1 + offset, 12, 0); + +describe('vacationStatus', () => { + it('is scheduled, not active, while the start date is ahead', () => { + expect(vacationStatus(true, day(3), day(10), NOW)).toBe('scheduled'); + expect(vacationStatus(true, day(3), null, NOW)).toBe('scheduled'); + }); + + it('is active inside the range or without dates', () => { + expect(vacationStatus(true, day(-1), day(2), NOW)).toBe('active'); + expect(vacationStatus(true, null, null, NOW)).toBe('active'); + expect(vacationStatus(true, null, day(1), NOW)).toBe('active'); + }); + + it('has ended after the end date', () => { + expect(vacationStatus(true, day(-5), day(-1), NOW)).toBe('ended'); + }); + + it('is inactive when switched off, whatever the dates', () => { + expect(vacationStatus(false, day(3), day(10), NOW)).toBe('inactive'); + }); +}); + +// Out of office in the phone walkthrough: the pill said "Active" for a range +// that starts next week; the sample reply in the empty body looked typed in, +// next to the warning that the body is empty; and after a save TalkBack read +// the disabled Save as "busy". +describe('the out-of-office pane', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../../components/settings/VacationSettings.tsx'), 'utf8') + .split('\r\n').join('\n'); + + it('shows the status from the dates', () => { + expect(source).toContain('vacationStatus(enabled, fromParsed, toParsed)'); + expect(source).toContain("t('settings.vacation.status.scheduled', 'Scheduled')"); + }); + + it('sets an empty subject or body placeholder apart from typed text', () => { + expect(source).toContain('!body && styles.placeholderShown'); + expect(source).toContain('!subject && styles.placeholderShown'); + expect(source).toMatch(/placeholderShown: \{ fontStyle: 'italic' \}/); + }); + + it('names the Save button, so its old "busy" description is replaced', () => { + const save = source.slice(source.indexOf(' void handleSave()}')); + expect(save.slice(0, save.indexOf(''))).toContain('accessibilityLabel={saveLabel}'); + }); +}); diff --git a/src/lib/vacation-status.ts b/src/lib/vacation-status.ts new file mode 100644 index 00000000..557720a5 --- /dev/null +++ b/src/lib/vacation-status.ts @@ -0,0 +1,18 @@ +/** + * What the out-of-office status pill says. Switched on with a start date + * still ahead, the server sends no replies yet: the pill said "Active" all + * the same. It says "Scheduled" until the start and "Ended" after the end. + */ +export type VacationStatus = 'inactive' | 'scheduled' | 'active' | 'ended'; + +export function vacationStatus( + enabled: boolean, + from: Date | null, + until: Date | null, + now: Date = new Date(), +): VacationStatus { + if (!enabled) return 'inactive'; + if (from && from.getTime() > now.getTime()) return 'scheduled'; + if (until && until.getTime() <= now.getTime()) return 'ended'; + return 'active'; +}