mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: show a scheduled out of office as scheduled, mark its placeholders and stop Save reading as busy
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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\""
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
>
|
||||
<View style={styles.statusRow}>
|
||||
<View style={[styles.pill, enabled ? styles.pillActive : styles.pillInactive]}>
|
||||
<Text style={[styles.pillText, enabled ? styles.pillTextActive : styles.pillTextInactive]}>
|
||||
{enabled
|
||||
<View style={[styles.pill, status === 'active' ? styles.pillActive : status === 'scheduled' ? styles.pillScheduled : styles.pillInactive]}>
|
||||
<Text style={[styles.pillText, status === 'active' ? styles.pillTextActive : status === 'scheduled' ? styles.pillTextScheduled : styles.pillTextInactive]}>
|
||||
{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')}
|
||||
</Text>
|
||||
</View>
|
||||
<ToggleSwitch checked={enabled} onChange={setEnabled} />
|
||||
@@ -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 && <Text style={styles.fieldError}>{subjectTooLong}</Text>}
|
||||
</View>
|
||||
@@ -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() {
|
||||
)}
|
||||
|
||||
<View style={styles.saveRow}>
|
||||
<Button onPress={() => void handleSave()} disabled={!canSave} loading={store.isSaving}>
|
||||
{store.isSaving
|
||||
? t('settings.vacation.saving', 'Saving...')
|
||||
: t('settings.vacation.save', 'Save Changes')}
|
||||
{/* Named, so Android doesn't keep announcing the spinner's "busy"
|
||||
once the save is done: React Native leaves a view's old content
|
||||
description when the new one would be empty. */}
|
||||
<Button
|
||||
onPress={() => void handleSave()}
|
||||
disabled={!canSave}
|
||||
loading={store.isSaving}
|
||||
accessibilityLabel={saveLabel}
|
||||
>
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -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,
|
||||
|
||||
@@ -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}');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user