fix: show a scheduled out of office as scheduled, mark its placeholders and stop Save reading as busy

This commit is contained in:
Linus Rath
2026-10-31 13:38:46 +01:00
parent e9816809f2
commit 9781b30478
5 changed files with 110 additions and 10 deletions
+4
View File
@@ -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." "mobile_description": "Wählen Sie ein Farbdesign. Designs folgen der Hell/Dunkel-Einstellung unter Darstellung; eigene Designpakete werden im Webmail verwaltet."
}, },
"vacation": { "vacation": {
"status": {
"ended": "Beendet",
"scheduled": "Geplant"
},
"warnings": { "warnings": {
"date_format": "Datumsangaben müssen das Format \"YYYY-MM-DD\" oder \"YYYY-MM-DD HH:MM\" haben" "date_format": "Datumsangaben müssen das Format \"YYYY-MM-DD\" oder \"YYYY-MM-DD HH:MM\" haben"
} }
+4
View File
@@ -1626,6 +1626,10 @@
"description": "The message that will be sent as a reply", "description": "The message that will be sent as a reply",
"title": "Auto-Reply Message" "title": "Auto-Reply Message"
}, },
"status": {
"ended": "Ended",
"scheduled": "Scheduled"
},
"warnings": { "warnings": {
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\"" "date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
} }
+31 -10
View File
@@ -29,6 +29,7 @@ import {
utcIsoToLocalInput, utcIsoToLocalInput,
} from '../../lib/vacation-dates'; } from '../../lib/vacation-dates';
import { STALWART_VACATION_LIMITS, vacationOversize } from '../../lib/vacation-limits'; import { STALWART_VACATION_LIMITS, vacationOversize } from '../../lib/vacation-limits';
import { vacationStatus } from '../../lib/vacation-status';
import { jmapClient } from '../../api/jmap-client'; import { jmapClient } from '../../api/jmap-client';
import { getSieveCapabilities } from '../../api/sieve'; import { getSieveCapabilities } from '../../api/sieve';
@@ -139,6 +140,8 @@ export function VacationSettings() {
const toParsed = parseLocalInput(toDate); const toParsed = parseLocalInput(toDate);
const formatError = !isValidLocalInput(fromDate) || !isValidLocalInput(toDate); const formatError = !isValidLocalInput(fromDate) || !isValidLocalInput(toDate);
const endBeforeStart = !!(fromParsed && toParsed && toParsed <= fromParsed); 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 htmlText = htmlEnabled ? htmlToPlainText(htmlBody).trim() : '';
const hasHtmlContent = htmlText.length > 0; const hasHtmlContent = htmlText.length > 0;
const emptyBody = enabled && !body.trim() && !hasHtmlContent; const emptyBody = enabled && !body.trim() && !hasHtmlContent;
@@ -235,6 +238,9 @@ export function VacationSettings() {
forwardChanged, forwardSettings, audienceChanged, audienceSettings, 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 title = t('settings.vacation.title', 'Vacation Responder');
const description = t('settings.vacation.description', "Automatically reply to incoming emails while you're away"); 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')} description={t('settings.vacation.status.description', 'Send an automatic reply to people who email you')}
> >
<View style={styles.statusRow}> <View style={styles.statusRow}>
<View style={[styles.pill, enabled ? styles.pillActive : styles.pillInactive]}> <View style={[styles.pill, status === 'active' ? styles.pillActive : status === 'scheduled' ? styles.pillScheduled : styles.pillInactive]}>
<Text style={[styles.pillText, enabled ? styles.pillTextActive : styles.pillTextInactive]}> <Text style={[styles.pillText, status === 'active' ? styles.pillTextActive : status === 'scheduled' ? styles.pillTextScheduled : styles.pillTextInactive]}>
{enabled {status === 'active'
? t('settings.vacation.status.active', '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')}
</Text> </Text>
</View> </View>
<ToggleSwitch checked={enabled} onChange={setEnabled} /> <ToggleSwitch checked={enabled} onChange={setEnabled} />
@@ -373,7 +383,7 @@ export function VacationSettings() {
placeholder={t('settings.vacation.message.subject_placeholder', 'Out of Office')} placeholder={t('settings.vacation.message.subject_placeholder', 'Out of Office')}
placeholderTextColor={c.mutedForeground} placeholderTextColor={c.mutedForeground}
accessibilityLabel={t('settings.vacation.message.subject_label', 'Subject')} 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 && <Text style={styles.fieldError}>{subjectTooLong}</Text>} {subjectTooLong && <Text style={styles.fieldError}>{subjectTooLong}</Text>}
</View> </View>
@@ -386,7 +396,7 @@ export function VacationSettings() {
onChangeText={setBody} 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.')} 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} placeholderTextColor={c.mutedForeground}
style={[styles.bodyInput, oversize.body && styles.inputInvalid]} style={[styles.bodyInput, !body && styles.placeholderShown, oversize.body && styles.inputInvalid]}
multiline multiline
numberOfLines={6} numberOfLines={6}
textAlignVertical="top" textAlignVertical="top"
@@ -528,10 +538,16 @@ export function VacationSettings() {
)} )}
<View style={styles.saveRow}> <View style={styles.saveRow}>
<Button onPress={() => void handleSave()} disabled={!canSave} loading={store.isSaving}> {/* Named, so Android doesn't keep announcing the spinner's "busy"
{store.isSaving once the save is done: React Native leaves a view's old content
? t('settings.vacation.saving', 'Saving...') description when the new one would be empty. */}
: t('settings.vacation.save', 'Save Changes')} <Button
onPress={() => void handleSave()}
disabled={!canSave}
loading={store.isSaving}
accessibilityLabel={saveLabel}
>
{saveLabel}
</Button> </Button>
</View> </View>
</View> </View>
@@ -597,6 +613,11 @@ function makeStyles(c: ThemePalette) {
pillText: { fontSize: 12 }, pillText: { fontSize: 12 },
pillTextActive: { color: c.success }, pillTextActive: { color: c.success },
pillTextInactive: { color: c.mutedForeground }, 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: { dateInput: {
minWidth: 180, minWidth: 180,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
+53
View File
@@ -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('<Button\n onPress={() => void handleSave()}'));
expect(save.slice(0, save.indexOf('</Button>'))).toContain('accessibilityLabel={saveLabel}');
});
});
+18
View File
@@ -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';
}