mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
feat: offer to save an event without invitations when the server refuses to send them
This commit is contained in:
@@ -80,6 +80,7 @@ import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction';
|
||||
import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../../lib/calendar-editor-discard';
|
||||
import { saveOfferingWithoutInvitations } from '../../lib/calendar-scheduling-denied';
|
||||
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
|
||||
@@ -435,14 +436,18 @@ export function EventModal({
|
||||
for (const key of Object.keys(data) as (keyof CalendarEvent)[]) {
|
||||
if (data[key] === undefined) delete data[key];
|
||||
}
|
||||
await onSave(data, calendarId, {
|
||||
// Only the organizer sends: on an invitation someone else sent, a
|
||||
// save would reach the organizer as a reply.
|
||||
sendSchedulingMessages: organizes && attendees.length > 0 && sendInvitations,
|
||||
// Only the organizer sends: on an invitation someone else sent, a
|
||||
// save would reach the organizer as a reply.
|
||||
const send = organizes && attendees.length > 0 && sendInvitations;
|
||||
// A server that refuses to send the invitations refuses the whole
|
||||
// save: offer to keep the event without them.
|
||||
const saved = await saveOfferingWithoutInvitations((sendSchedulingMessages) => onSave(data, calendarId, {
|
||||
sendSchedulingMessages,
|
||||
// The dates as shown: a series moves by the occurrence's move, on
|
||||
// its own clock (lib/recurrence-scope-edit).
|
||||
edit: { attendees, when: { allDay, start, end }, ruleChanged },
|
||||
});
|
||||
}), send, t);
|
||||
if (!saved) return;
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// The account changed under the save: the event is the previous
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('react-native', () => ({ Alert: { alert: vi.fn() } }));
|
||||
|
||||
import { JMAPSetError } from '../../api/jmap-result';
|
||||
import { saveOfferingWithoutInvitations, schedulingDeniedReason } from '../calendar-scheduling-denied';
|
||||
|
||||
const t = (_k: string, fallback?: string) => fallback ?? '';
|
||||
// As api/calendar throws Stalwart 0.16.21+'s refusal of the invitations.
|
||||
const denied = () => new JMAPSetError('forbidden', 'Failed to update event e1: forbidden – Scheduling not allowed');
|
||||
|
||||
describe('schedulingDeniedReason', () => {
|
||||
it('tells a refusal of the invitations from other failures', () => {
|
||||
expect(schedulingDeniedReason(denied(), true)).toBe('Scheduling not allowed');
|
||||
expect(schedulingDeniedReason(denied(), false)).toBeNull();
|
||||
expect(schedulingDeniedReason(new JMAPSetError('invalidProperties', 'x'), true)).toBeNull();
|
||||
expect(schedulingDeniedReason(new Error('offline'), true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveOfferingWithoutInvitations', () => {
|
||||
it('saves again without the invitations when the user agrees', async () => {
|
||||
const save = vi.fn().mockRejectedValueOnce(denied()).mockResolvedValueOnce(undefined);
|
||||
const ask = vi.fn().mockResolvedValue(true);
|
||||
expect(await saveOfferingWithoutInvitations(save, true, t, ask)).toBe(true);
|
||||
expect(save.mock.calls).toEqual([[true], [false]]);
|
||||
expect(ask).toHaveBeenCalledWith('Scheduling not allowed', t);
|
||||
});
|
||||
|
||||
it('keeps the editor open when the user declines, and passes other errors on', async () => {
|
||||
const save = vi.fn().mockRejectedValue(denied());
|
||||
expect(await saveOfferingWithoutInvitations(save, true, t, vi.fn().mockResolvedValue(false))).toBe(false);
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
const failing = vi.fn().mockRejectedValue(new Error('offline'));
|
||||
await expect(saveOfferingWithoutInvitations(failing, true, t, vi.fn())).rejects.toThrow('offline');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Alert } from 'react-native';
|
||||
import { JMAPMethodError } from '../api/jmap-result';
|
||||
|
||||
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/**
|
||||
* Why the server refused a save because of the invitations it would send, or
|
||||
* null for any other failure. Stalwart 0.16.21+ fails the whole
|
||||
* CalendarEvent/set with `forbidden` when the account may not send
|
||||
* scheduling messages; the same change without them can still be saved
|
||||
* (webmail lib/jmap/scheduling-error.ts).
|
||||
*/
|
||||
export function schedulingDeniedReason(err: unknown, sentScheduling: boolean): string | null {
|
||||
if (!sentScheduling || !(err instanceof JMAPMethodError) || err.type !== 'forbidden') return null;
|
||||
// The thrower's message carries the server's description after the type.
|
||||
const description = err.message.split(' – ').slice(1).join(' – ').trim();
|
||||
return description || err.message || 'forbidden';
|
||||
}
|
||||
|
||||
/** Ask whether to save the event without its invitations; resolves true for "Save without invitations". */
|
||||
export function askSaveWithoutInvitations(reason: string, t: Translate): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
Alert.alert(
|
||||
t('calendar.notifications.invitations_denied_title', "Invitations can't be sent"),
|
||||
t('calendar.notifications.invitations_denied', 'The server refused to send the invitations: {reason}. Save the event without sending them?', { reason }),
|
||||
[
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => resolve(false) },
|
||||
{ text: t('calendar.notifications.save_without_invitations', 'Save without invitations'), onPress: () => resolve(true) },
|
||||
],
|
||||
{ cancelable: true, onDismiss: () => resolve(false) },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save with the invitations `send` asks for; when the server refuses to send
|
||||
* them, ask and save again without. Resolves false when the user kept the
|
||||
* editor open instead.
|
||||
*/
|
||||
export async function saveOfferingWithoutInvitations(
|
||||
save: (send: boolean) => Promise<void> | void,
|
||||
send: boolean,
|
||||
t: Translate,
|
||||
ask: (reason: string, t: Translate) => Promise<boolean> = askSaveWithoutInvitations,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await save(send);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const reason = schedulingDeniedReason(err, send);
|
||||
if (reason === null) throw err;
|
||||
if (!(await ask(reason, t))) return false;
|
||||
await save(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user