mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
This commit is contained in:
@@ -497,6 +497,7 @@
|
||||
"drop_files": "Drop files to attach",
|
||||
"show_less": "Show less",
|
||||
"send_failed": "Failed to send email",
|
||||
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
|
||||
"continue_draft": "Continue draft",
|
||||
"close_draft_title": "Save or discard draft?",
|
||||
"close_draft_message": "You have unsaved changes. Would you like to save this as a draft or discard it?",
|
||||
|
||||
@@ -87,7 +87,10 @@ describe('calendar operations', () => {
|
||||
});
|
||||
|
||||
describe('getEvents', () => {
|
||||
it('should fetch events by id with expected properties', async () => {
|
||||
it('should fetch events by id with singular JSCalendar 2.0 recurrence properties', async () => {
|
||||
// Stalwart (calcard) names the property `recurrenceRule` (singular,
|
||||
// single object) — requesting the RFC 8984 plural form returns no
|
||||
// recurrence data and repeating events vanish from the grid (#13).
|
||||
const events = [{ id: 'ev1', title: 'Meeting', start: '2026-03-29T10:00:00' }];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
|
||||
@@ -99,7 +102,48 @@ describe('calendar operations', () => {
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].properties).toContain('title');
|
||||
expect(call[1].properties).toContain('start');
|
||||
expect(call[1].properties).toContain('recurrenceRules');
|
||||
expect(call[1].properties).toContain('recurrenceRule');
|
||||
expect(call[1].properties).toContain('excludedRecurrenceRule');
|
||||
expect(call[1].properties).not.toContain('recurrenceRules');
|
||||
expect(call[1].properties).not.toContain('excludedRecurrenceRules');
|
||||
});
|
||||
|
||||
it('should normalize a singular recurrenceRule object to the plural array form', async () => {
|
||||
const events = [{
|
||||
id: 'ev1',
|
||||
title: 'Every 5 weeks',
|
||||
start: '2026-03-02T10:00:00',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 },
|
||||
excludedRecurrenceRule: { frequency: 'weekly', interval: 10 },
|
||||
}];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
|
||||
});
|
||||
|
||||
const [event] = await getEvents(['ev1']);
|
||||
expect(event.recurrenceRules).toEqual([
|
||||
{ '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 },
|
||||
]);
|
||||
expect(event.excludedRecurrenceRules).toEqual([
|
||||
{ frequency: 'weekly', interval: 10 },
|
||||
]);
|
||||
expect(event).not.toHaveProperty('recurrenceRule');
|
||||
expect(event).not.toHaveProperty('excludedRecurrenceRule');
|
||||
});
|
||||
|
||||
it('should keep an array-valued recurrenceRule as-is (JMAP-created events)', async () => {
|
||||
const events = [{
|
||||
id: 'ev1',
|
||||
title: 'Weekly',
|
||||
start: '2026-03-02T10:00:00',
|
||||
recurrenceRule: [{ frequency: 'weekly', interval: 2 }],
|
||||
}];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
|
||||
});
|
||||
|
||||
const [event] = await getEvents(['ev1']);
|
||||
expect(event.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,10 +159,39 @@ describe('calendar operations', () => {
|
||||
'cal-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual(created);
|
||||
// The /set response echoes only server-set properties; the return value
|
||||
// merges them over the submitted payload.
|
||||
expect(result).toEqual({
|
||||
id: 'ev-new',
|
||||
title: 'Lunch',
|
||||
start: '2026-03-30T12:00:00',
|
||||
duration: 'PT1H',
|
||||
calendarIds: { 'cal-1': true },
|
||||
});
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].create['new-event'].calendarIds).toEqual({ 'cal-1': true });
|
||||
});
|
||||
|
||||
it('should send recurrence as a singular cleaned object', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/set', { created: { 'new-event': { id: 'ev-new' } } }, '0']],
|
||||
});
|
||||
|
||||
const result = await createEvent(
|
||||
{
|
||||
title: 'Series',
|
||||
start: '2026-03-30T12:00:00',
|
||||
recurrenceRules: [{ frequency: 'weekly', interval: 5, until: null as unknown as string }],
|
||||
},
|
||||
'cal-1',
|
||||
);
|
||||
|
||||
const sent = mockRequest.mock.calls[0][0][0][1].create['new-event'];
|
||||
expect(sent.recurrenceRule).toEqual({ frequency: 'weekly', interval: 5 });
|
||||
expect(sent).not.toHaveProperty('recurrenceRules');
|
||||
// The returned event exposes the internal plural form again.
|
||||
expect(result.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 5 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEvent', () => {
|
||||
@@ -132,6 +205,23 @@ describe('calendar operations', () => {
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].update).toEqual({ ev1: { title: 'Updated Meeting' } });
|
||||
});
|
||||
|
||||
it('should convert recurrenceRules updates to the singular property', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/set', { updated: {} }, '0']],
|
||||
});
|
||||
|
||||
await updateEvent('ev1', { recurrenceRules: [{ frequency: 'monthly' }] });
|
||||
expect(mockRequest.mock.calls[0][0][0][1].update).toEqual({
|
||||
ev1: { recurrenceRule: { frequency: 'monthly' } },
|
||||
});
|
||||
|
||||
// Empty array means "remove recurrence" — sent as an explicit null.
|
||||
await updateEvent('ev1', { recurrenceRules: [] });
|
||||
expect(mockRequest.mock.calls[1][0][0][1].update).toEqual({
|
||||
ev1: { recurrenceRule: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEvents', () => {
|
||||
|
||||
+93
-10
@@ -37,18 +37,94 @@ function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
// Stalwart's calcard crate implements JSCalendar 2.0 (jscalendarbis) property
|
||||
// names: singular `recurrenceRule` / `excludedRecurrenceRule` holding a single
|
||||
// object, not RFC 8984's plural array forms. Requesting the plural names
|
||||
// returns no recurrence data at all, so recurring events silently render as
|
||||
// one-off events (#13). Request the singular names and normalize below.
|
||||
const CALENDAR_EVENT_PROPERTIES = [
|
||||
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
|
||||
'start', 'duration', 'timeZone', 'showWithoutTime',
|
||||
'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
|
||||
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRules',
|
||||
'recurrenceOverrides', 'excludedRecurrenceRules',
|
||||
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRule',
|
||||
'recurrenceOverrides', 'excludedRecurrenceRule', 'recurrenceId',
|
||||
'replyTo', 'organizerCalendarAddress', 'sequence',
|
||||
'locations', 'virtualLocations',
|
||||
'progress', 'due', 'priority', 'percentComplete',
|
||||
'links', 'created', 'updated',
|
||||
];
|
||||
|
||||
/**
|
||||
* Normalize Stalwart's singular recurrence property names to the RFC 8984
|
||||
* plural array forms the client uses internally. JSCalendar 2.0 defines
|
||||
* recurrenceRule as a single object, but Stalwart may also return an array
|
||||
* (for events created via JMAP), so both forms are handled. Mirrors webmail's
|
||||
* normalizeStalwartPropertyNames.
|
||||
*/
|
||||
export function normalizeRecurrenceProperties<T extends Partial<CalendarEvent>>(event: T): T {
|
||||
const raw = event as Record<string, unknown>;
|
||||
let patched = false;
|
||||
const updates: Partial<CalendarEvent> = {};
|
||||
|
||||
if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) {
|
||||
const val = raw.recurrenceRule;
|
||||
if (val != null && !Array.isArray(val) && typeof val === 'object') {
|
||||
updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules'];
|
||||
} else {
|
||||
updates.recurrenceRules = val as CalendarEvent['recurrenceRules'];
|
||||
}
|
||||
patched = true;
|
||||
}
|
||||
if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) {
|
||||
const val = raw.excludedRecurrenceRule;
|
||||
if (val != null && !Array.isArray(val) && typeof val === 'object') {
|
||||
updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules'];
|
||||
} else {
|
||||
updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules'];
|
||||
}
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!patched) return event;
|
||||
|
||||
const result = { ...event, ...updates } as T;
|
||||
delete (result as Record<string, unknown>).recurrenceRule;
|
||||
delete (result as Record<string, unknown>).excludedRecurrenceRule;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the client's plural recurrence arrays to the singular JSCalendar 2.0
|
||||
* properties Stalwart expects on write, dropping null rule fields. An empty
|
||||
* array (or null) becomes an explicit null so "remove recurrence" round-trips.
|
||||
* Mirrors webmail's cleanRecurrenceRules.
|
||||
*/
|
||||
function cleanRecurrenceRules(event: Record<string, unknown>): void {
|
||||
const keyMap: Record<string, string> = {
|
||||
recurrenceRules: 'recurrenceRule',
|
||||
excludedRecurrenceRules: 'excludedRecurrenceRule',
|
||||
};
|
||||
for (const [pluralKey, singularKey] of Object.entries(keyMap)) {
|
||||
const rules = event[pluralKey];
|
||||
if (rules === undefined) continue;
|
||||
delete event[pluralKey];
|
||||
if (!Array.isArray(rules)) {
|
||||
event[singularKey] = rules;
|
||||
continue;
|
||||
}
|
||||
if (rules.length === 0) {
|
||||
event[singularKey] = null;
|
||||
continue;
|
||||
}
|
||||
const rule = rules[0] as Record<string, unknown>;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(rule)) {
|
||||
if (v !== null) cleaned[k] = v;
|
||||
}
|
||||
event[singularKey] = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
function methodResult<T = any>(res: any, index = 0): T {
|
||||
const entry = res?.methodResponses?.[index];
|
||||
if (!entry) throw new Error('JMAP: empty method response');
|
||||
@@ -149,7 +225,7 @@ export async function getEvents(ids: string[], accountId?: string): Promise<Cale
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: CalendarEvent[] }>(res).list ?? [];
|
||||
all.push(...list);
|
||||
all.push(...list.map(normalizeRecurrenceProperties));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
@@ -176,11 +252,11 @@ export async function createEvent(
|
||||
targetAccountId?: string,
|
||||
): Promise<CalendarEvent> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const payload: Record<string, unknown> = { ...event, calendarIds: { [calendarId]: true } };
|
||||
cleanRecurrenceRules(payload);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', setArgs(accountId, {
|
||||
create: {
|
||||
'new-event': { ...event, calendarIds: { [calendarId]: true } },
|
||||
},
|
||||
create: { 'new-event': payload },
|
||||
}, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
@@ -193,7 +269,9 @@ export async function createEvent(
|
||||
const err = result.notCreated?.['new-event'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create event');
|
||||
}
|
||||
return created;
|
||||
// The /set response echoes only server-set properties; merge them over the
|
||||
// submitted payload so the store's optimistic insert keeps recurrence data.
|
||||
return normalizeRecurrenceProperties({ ...payload, ...created } as CalendarEvent);
|
||||
}
|
||||
|
||||
// Batch-create many events in one CalendarEvent/set. Returns the number created.
|
||||
@@ -206,7 +284,9 @@ export async function batchCreateEvents(
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const create: Record<string, Partial<CalendarEvent>> = {};
|
||||
events.forEach((e, i) => {
|
||||
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
|
||||
const payload: Record<string, unknown> = { ...e, calendarIds: { [calendarId]: true } };
|
||||
cleanRecurrenceRules(payload);
|
||||
create[`evt-${i}`] = payload as Partial<CalendarEvent>;
|
||||
});
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, create }, '0']],
|
||||
@@ -223,8 +303,10 @@ export async function updateEvent(
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const patch: Record<string, unknown> = { ...changes };
|
||||
cleanRecurrenceRules(patch);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']],
|
||||
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: patch } }, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
@@ -285,7 +367,8 @@ export async function parseCalendarBlob(blobId: string): Promise<Partial<Calenda
|
||||
if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found');
|
||||
const parsed = result.parsed?.[blobId];
|
||||
if (!parsed) return [];
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
const list = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return list.map(normalizeRecurrenceProperties);
|
||||
}
|
||||
|
||||
export async function createCalendar(
|
||||
|
||||
@@ -51,6 +51,13 @@ export interface RichTextEditorHandle {
|
||||
unsetLink(): void;
|
||||
insertImage(src: string, cid?: string, alt?: string): void;
|
||||
setHtml(html: string): void;
|
||||
/**
|
||||
* Read the live editor DOM content. Resolves with the current innerHTML or
|
||||
* rejects after `timeoutMs` when the page script is dead or the bridge is
|
||||
* broken — callers must treat that as "content unknown", never fall back to
|
||||
* possibly-stale state (issue #9: doing so silently sent blank replies).
|
||||
*/
|
||||
getHtml(timeoutMs?: number): Promise<string>;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
@@ -94,6 +101,27 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||
}, []);
|
||||
|
||||
// Pending getHtml() readbacks, keyed by request id. Timers reject requests
|
||||
// the page never answers (dead script / broken bridge).
|
||||
const snapshotSeq = React.useRef(0);
|
||||
const pendingSnapshots = React.useRef(
|
||||
new Map<number, {
|
||||
resolve: (html: string) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}>(),
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const pending = pendingSnapshots.current;
|
||||
return () => {
|
||||
for (const { timer, reject } of pending.values()) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('Editor unmounted'));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
exec: (command) => {
|
||||
const [name, value] = command.split(':');
|
||||
@@ -112,6 +140,16 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
setHtml: (next) => {
|
||||
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
||||
},
|
||||
getHtml: (timeoutMs = 2000) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const id = ++snapshotSeq.current;
|
||||
const timer = setTimeout(() => {
|
||||
pendingSnapshots.current.delete(id);
|
||||
reject(new Error('Editor did not answer'));
|
||||
}, timeoutMs);
|
||||
pendingSnapshots.current.set(id, { resolve, reject, timer });
|
||||
call(`window.__rne && window.__rne.getHtml(${id})`);
|
||||
}),
|
||||
focus: () => {
|
||||
call(`window.__rne && window.__rne.focus()`);
|
||||
},
|
||||
@@ -133,6 +171,16 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_EDITOR_HEIGHT, h));
|
||||
break;
|
||||
}
|
||||
case 'htmlSnapshot': {
|
||||
const p = data.payload as { id?: number; html?: string } | null;
|
||||
const entry = typeof p?.id === 'number' ? pendingSnapshots.current.get(p.id) : undefined;
|
||||
if (entry) {
|
||||
pendingSnapshots.current.delete(p!.id!);
|
||||
clearTimeout(entry.timer);
|
||||
entry.resolve(typeof p?.html === 'string' ? p.html : '');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'selection':
|
||||
onSelectionRef.current?.((data.payload as RichTextSelectionState) ?? EMPTY_STATE);
|
||||
break;
|
||||
|
||||
@@ -47,4 +47,12 @@ describe('buildEditorHtml', () => {
|
||||
expect(script).toContain('window.__rne');
|
||||
expect(script).toContain('ReactNativeWebView.postMessage');
|
||||
});
|
||||
|
||||
// Regression for issue #9: sends must be able to read the live DOM back
|
||||
// instead of trusting async `change` messages that can lag or be lost.
|
||||
it('exposes the send-time getHtml readback on the bridge', () => {
|
||||
const script = extractScript(buildEditorHtml(base));
|
||||
expect(script).toContain('getHtml: function (id)');
|
||||
expect(script).toContain("post('htmlSnapshot', { id: id, html: editor.innerHTML })");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,54 @@ describe('expandRecurringEvents - weekly', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents - interval > 1 (#13)', () => {
|
||||
it('expands an every-5-weeks rule', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'weekly', interval: 5 };
|
||||
const result = expandRecurringEvents(
|
||||
[ev({ recurrenceRules: [rule] })], // starts Mon 2026-04-06
|
||||
'2026-04-01T00:00:00',
|
||||
'2026-08-01T00:00:00',
|
||||
);
|
||||
expect(result.map((e) => e.start)).toEqual([
|
||||
'2026-04-06T09:00:00',
|
||||
'2026-05-11T09:00:00',
|
||||
'2026-06-15T09:00:00',
|
||||
'2026-07-20T09:00:00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands an every-5-weeks series started long before the visible range', () => {
|
||||
const event = ev({
|
||||
start: '2025-01-06T09:00:00', // Monday
|
||||
recurrenceRules: [{ frequency: 'weekly', interval: 5 }],
|
||||
});
|
||||
// Occurrences fall every 35 days from 2025-01-06 (…2026-04-06, 05-11,
|
||||
// 06-15, 07-20…) — only those inside the window are returned.
|
||||
const result = expandRecurringEvents([event], '2026-05-01T00:00:00', '2026-08-01T00:00:00');
|
||||
expect(result.map((e) => e.start)).toEqual([
|
||||
'2026-05-11T09:00:00',
|
||||
'2026-06-15T09:00:00',
|
||||
'2026-07-20T09:00:00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands an every-2-months rule', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'monthly', interval: 2 };
|
||||
const result = expandRecurringEvents(
|
||||
[ev({ recurrenceRules: [rule] })],
|
||||
'2026-04-01T00:00:00',
|
||||
'2026-12-31T00:00:00',
|
||||
);
|
||||
expect(result.map((e) => e.start)).toEqual([
|
||||
'2026-04-06T09:00:00',
|
||||
'2026-06-06T09:00:00',
|
||||
'2026-08-06T09:00:00',
|
||||
'2026-10-06T09:00:00',
|
||||
'2026-12-06T09:00:00',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents - monthly', () => {
|
||||
it('expands monthly on the 6th', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'monthly', count: 3 };
|
||||
|
||||
@@ -263,6 +263,12 @@ export function buildEditorHtml(opts: {
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
// Send-time readback (issue #9): RN asks for the live DOM content instead
|
||||
// of trusting the last change message, which can lag typing or be lost
|
||||
// entirely if the bridge breaks. The id lets RN match reply to request.
|
||||
getHtml: function (id) {
|
||||
post('htmlSnapshot', { id: id, html: editor.innerHTML });
|
||||
},
|
||||
focus: function () { editor.focus(); },
|
||||
};
|
||||
|
||||
|
||||
@@ -758,13 +758,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
// Catch the classic "I forgot the attachment" footgun. Only file attachments
|
||||
// count - inline images don't satisfy the user's intent of attaching a file.
|
||||
// Returns true if it's safe to proceed, false if the user cancelled.
|
||||
const passesAttachmentReminder = async (): Promise<boolean> => {
|
||||
const passesAttachmentReminder = async (html: string): Promise<boolean> => {
|
||||
if (!attachmentReminderEnabled) return true;
|
||||
const hasFileAttachment = attachments.some(
|
||||
(a) => !a.inline && a.blobId && !a.error,
|
||||
);
|
||||
if (hasFileAttachment) return true;
|
||||
const haystack = `${subject}\n${htmlToPlainText(bodyHtml)}`.toLowerCase();
|
||||
const haystack = `${subject}\n${htmlToPlainText(html)}`.toLowerCase();
|
||||
const matchedKeyword = attachmentReminderKeywords.find((kw) => {
|
||||
const k = kw.trim().toLowerCase();
|
||||
if (!k) return false;
|
||||
@@ -878,12 +878,33 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
|
||||
const performSend = async (holdForSeconds?: number, scheduledAt?: Date) => {
|
||||
if (!canSend || !primaryIdentity || !sentMailbox) return;
|
||||
if (!(await passesAttachmentReminder())) return;
|
||||
|
||||
// Read the body straight from the editor DOM at send time. The `change`
|
||||
// messages that feed `bodyHtml` are async and best-effort — trusting them
|
||||
// here once shipped replies with the typed text silently missing when the
|
||||
// page script had died (issue #9). If the editor doesn't answer we abort
|
||||
// loudly rather than send possibly-stale content.
|
||||
let liveBodyHtml: string;
|
||||
try {
|
||||
liveBodyHtml = (await editorRef.current?.getHtml()) ?? bodyHtml;
|
||||
} catch {
|
||||
Alert.alert(
|
||||
t('email_composer.send_failed', 'Send failed'),
|
||||
t(
|
||||
'email_composer.editor_unavailable',
|
||||
'Could not read the message content. Copy your text, then close and reopen the composer.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (liveBodyHtml !== bodyHtml) setBodyHtml(liveBodyHtml);
|
||||
|
||||
if (!(await passesAttachmentReminder(liveBodyHtml))) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const from: EmailAddress[] = [{ name: primaryIdentity.name, email: primaryIdentity.email }];
|
||||
|
||||
const { html: rewrittenHtml, usedCids } = rewriteInlineImages(bodyHtml);
|
||||
const { html: rewrittenHtml, usedCids } = rewriteInlineImages(liveBodyHtml);
|
||||
// Belt-and-suspenders sanitization: the editor uses execCommand which
|
||||
// can preserve pasted <script>/<style>/etc. Strip them before sending.
|
||||
const safeHtml = stripDangerousTags(rewrittenHtml);
|
||||
|
||||
@@ -111,6 +111,7 @@ import { useEmailStore } from '../email-store';
|
||||
|
||||
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
|
||||
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>;
|
||||
const mockGetFullEmail = emailApi.getFullEmail as ReturnType<typeof vi.fn>;
|
||||
@@ -313,6 +314,149 @@ describe('email-store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #6: the "Unread" tri-state filter must reach Email/query as a
|
||||
// notKeyword condition and force the full re-query path (the incremental
|
||||
// path only serves the unfiltered base view).
|
||||
describe('filters (issue #6)', () => {
|
||||
it('applies the unread filter to the mailbox query', async () => {
|
||||
useEmailStore.setState({
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
searchQuery: '',
|
||||
filters: { isUnread: true },
|
||||
mailboxSnapshots: {
|
||||
'mb-1': { emails: [{ id: 'e9' } as any], total: 1, queryState: 'q-base' },
|
||||
},
|
||||
});
|
||||
mockQueryEmails.mockResolvedValue({ ids: ['e1'], total: 1, queryState: 'q-unread' });
|
||||
mockGetEmailsWithState.mockResolvedValue({ list: [{ id: 'e1' } as any], state: 'em-1' });
|
||||
|
||||
await useEmailStore.getState().refreshEmails();
|
||||
|
||||
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
|
||||
const [mailboxId, opts] = mockQueryEmails.mock.calls[0];
|
||||
expect(mailboxId).toBe('mb-1');
|
||||
expect(opts.filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }],
|
||||
});
|
||||
// Filter results must not leak into the base-view snapshot.
|
||||
expect(useEmailStore.getState().mailboxSnapshots['mb-1'].emails).toEqual([{ id: 'e9' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #10: after searching, the inbox stayed stuck on the search results.
|
||||
// Root cause: the incremental Email/queryChanges refresh diffed against the
|
||||
// on-screen list (search results) instead of the cached base view, then
|
||||
// wrote the result back into the persisted mailbox snapshot.
|
||||
describe('search / return to inbox (issue #10)', () => {
|
||||
const base = [
|
||||
{ id: 'e1', subject: 'One' } as any,
|
||||
{ id: 'e2', subject: 'Two' } as any,
|
||||
];
|
||||
|
||||
it('incremental refresh diffs against the snapshot, not on-screen search results', async () => {
|
||||
// Screen still shows a (stale) search-result list, but searchQuery is
|
||||
// already '' — the cold-start / just-cleared-search shape.
|
||||
useEmailStore.setState({
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: [base[1]],
|
||||
totalEmails: 1,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
mailboxSnapshots: { 'mb-1': { emails: base, total: 2, queryState: 'q-base' } },
|
||||
});
|
||||
mockGetEmailQueryChanges.mockResolvedValue({
|
||||
oldQueryState: 'q-base',
|
||||
newQueryState: 'q-2',
|
||||
total: 2,
|
||||
removed: [],
|
||||
added: [],
|
||||
});
|
||||
|
||||
await useEmailStore.getState().refreshEmails();
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
expect(state.emails).toEqual(base);
|
||||
expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base);
|
||||
expect(mockQueryEmails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not write search results into the mailbox snapshot', async () => {
|
||||
useEmailStore.setState({
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: base,
|
||||
totalEmails: 2,
|
||||
searchQuery: 'two',
|
||||
filters: {},
|
||||
queryState: 'q-base',
|
||||
mailboxSnapshots: { 'mb-1': { emails: base, total: 2, queryState: 'q-base' } },
|
||||
});
|
||||
mockQueryEmails.mockResolvedValue({ ids: ['e2'], total: 1, queryState: 'q-search' });
|
||||
mockGetEmailsWithState.mockResolvedValue({ list: [base[1]], state: 'em-1' });
|
||||
|
||||
await useEmailStore.getState().refreshEmails();
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
expect(state.emails).toEqual([base[1]]);
|
||||
// Base-view cache must survive the search untouched.
|
||||
expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base);
|
||||
expect(state.queryState).toBe('q-base');
|
||||
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clearSearchAndFilters restores the cached base view immediately', () => {
|
||||
useEmailStore.setState({
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: [base[1]],
|
||||
totalEmails: 1,
|
||||
searchQuery: 'two',
|
||||
filters: {},
|
||||
mailboxSnapshots: { 'mb-1': { emails: base, total: 2, queryState: 'q-base' } },
|
||||
});
|
||||
mockGetEmailQueryChanges.mockResolvedValue({
|
||||
oldQueryState: 'q-base',
|
||||
newQueryState: 'q-2',
|
||||
total: 2,
|
||||
removed: [],
|
||||
added: [],
|
||||
});
|
||||
|
||||
useEmailStore.getState().clearSearchAndFilters();
|
||||
|
||||
// Synchronously back on the base view — no waiting for the network.
|
||||
const state = useEmailStore.getState();
|
||||
expect(state.searchQuery).toBe('');
|
||||
expect(state.emails).toEqual(base);
|
||||
expect(state.totalEmails).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to a full re-query when the snapshot window is incomplete', async () => {
|
||||
// A pre-fix install could have a poisoned snapshot: a handful of search
|
||||
// results stored against the base view's total. It can't be patched
|
||||
// incrementally — the refresh must rebuild it from the server.
|
||||
useEmailStore.setState({
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: [base[1]],
|
||||
totalEmails: 1,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
mailboxSnapshots: { 'mb-1': { emails: [base[1]], total: 50, queryState: 'q-base' } },
|
||||
});
|
||||
mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 50, queryState: 'q-new' });
|
||||
mockGetEmailsWithState.mockResolvedValue({ list: base, state: 'em-2' });
|
||||
|
||||
await useEmailStore.getState().refreshEmails();
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
|
||||
expect(state.emails).toEqual(base);
|
||||
expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base);
|
||||
expect(state.mailboxSnapshots['mb-1'].queryState).toBe('q-new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailDetail', () => {
|
||||
it('should fetch full email', async () => {
|
||||
const fullEmail = { id: 'e1', subject: 'Test', bodyValues: { html: { value: '<p>Hi</p>' } } };
|
||||
|
||||
+96
-13
@@ -223,6 +223,17 @@ function isBaseView(searchQuery: string, filters: EmailFilters): boolean {
|
||||
return !searchQuery.trim() && Object.keys(filters).length === 0;
|
||||
}
|
||||
|
||||
// View fields to apply when returning from a search/filter to the base view:
|
||||
// the cached base-view snapshot, shown immediately so the list doesn't keep
|
||||
// displaying search results while the refresh is in flight (issue #10).
|
||||
function restoredBaseView(state: EmailState): Partial<EmailState> {
|
||||
const snap = state.currentMailboxId
|
||||
? state.mailboxSnapshots[state.currentMailboxId]
|
||||
: undefined;
|
||||
if (!snap) return {};
|
||||
return { emails: snap.emails, totalEmails: snap.total, queryState: snap.queryState };
|
||||
}
|
||||
|
||||
function snapshotFromActive(state: EmailState): AccountSnapshot {
|
||||
// Persist the currently-visible mailbox into its snapshot before tucking
|
||||
// the whole account away.
|
||||
@@ -642,7 +653,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
|
||||
refreshEmails: async () => {
|
||||
const state = get();
|
||||
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState, activeAccountId } = state;
|
||||
const { currentMailboxId, searchQuery, filters, emails: existing, emailState, activeAccountId } = state;
|
||||
if (!currentMailboxId) return;
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
set({ loading: true, error: null });
|
||||
@@ -651,13 +662,37 @@ export const useEmailStore = create<EmailState>()(
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
const baseView = isBaseView(searchQuery, filters);
|
||||
|
||||
// A response that lands after the user switched account/mailbox or
|
||||
// changed search/filters must not overwrite the newer view.
|
||||
const viewChanged = () =>
|
||||
get().activeAccountId !== activeAccountId ||
|
||||
get().currentMailboxId !== currentMailboxId ||
|
||||
get().searchQuery !== searchQuery ||
|
||||
get().filters !== filters;
|
||||
|
||||
// The incremental path diffs against the *base-view* list, which lives in
|
||||
// the per-mailbox snapshot — NOT `emails`, which may still hold search or
|
||||
// filter results (right after clearing a search, or after a cold start
|
||||
// that rehydrated a persisted search-result list). Diffing against a
|
||||
// non-base list lets Email/queryChanges "confirm" the search results as
|
||||
// the whole mailbox and bakes them into the snapshot (issue #10). The
|
||||
// snapshot is only trusted when its window is plausibly complete —
|
||||
// anything shorter can't be patched incrementally and needs the full
|
||||
// re-query below to rebuild it.
|
||||
const snap = state.mailboxSnapshots[currentMailboxId];
|
||||
|
||||
try {
|
||||
// Incremental sync path: requires the base unfiltered view AND a known
|
||||
// queryState (so Email/queryChanges has something to diff against).
|
||||
// Anything else — search, filter active, first-ever load — falls
|
||||
// through to a full re-query.
|
||||
if (baseView && queryState) {
|
||||
const queryChanges = await getEmailQueryChanges(currentMailboxId, queryState, {
|
||||
if (
|
||||
baseView &&
|
||||
snap?.queryState &&
|
||||
snap.emails.length >= Math.min(limit, snap.total)
|
||||
) {
|
||||
const baseEmails = snap.emails;
|
||||
const queryChanges = await getEmailQueryChanges(currentMailboxId, snap.queryState, {
|
||||
filter: undefined,
|
||||
});
|
||||
if (queryChanges) {
|
||||
@@ -687,9 +722,9 @@ export const useEmailStore = create<EmailState>()(
|
||||
}
|
||||
|
||||
// Fetch only what we don't already have. `addedIds` are new to the
|
||||
// window; `updatedIds` may already be in `existing` but their
|
||||
// window; `updatedIds` may already be in the base list but their
|
||||
// keywords/mailboxIds need refreshing.
|
||||
const existingById = new Map(existing.map((e) => [e.id, e]));
|
||||
const existingById = new Map(baseEmails.map((e) => [e.id, e]));
|
||||
const idsToFetch = [
|
||||
...addedIds.filter((id) => !existingById.has(id)),
|
||||
...updatedIds.filter((id) => existingById.has(id)),
|
||||
@@ -705,7 +740,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
// Rebuild the visible window order: start with existing emails,
|
||||
// drop removed/destroyed, then splice added at their indices.
|
||||
const allDestroyed = new Set([...destroyedExtra, ...removed]);
|
||||
const kept = existing.filter((e) => !allDestroyed.has(e.id));
|
||||
const kept = baseEmails.filter((e) => !allDestroyed.has(e.id));
|
||||
// Map updated entries onto kept array
|
||||
const fetchedById = new Map(fetched.map((e) => [e.id, e]));
|
||||
const updatedKept = kept.map((e) => fetchedById.get(e.id) ?? e);
|
||||
@@ -727,7 +762,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
const nextQueryState = queryChanges.newQueryState;
|
||||
const nextTotal = queryChanges.total;
|
||||
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
if (viewChanged()) return;
|
||||
|
||||
set({
|
||||
emails: trimmed,
|
||||
@@ -758,7 +793,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
? await getEmailsWithState(queryRes.ids)
|
||||
: { list: [], state: undefined as string | undefined };
|
||||
|
||||
if (get().currentMailboxId !== currentMailboxId) return;
|
||||
if (viewChanged()) return;
|
||||
|
||||
const updates: Partial<EmailState> = {
|
||||
emails: fetched.list,
|
||||
@@ -836,19 +871,31 @@ export const useEmailStore = create<EmailState>()(
|
||||
},
|
||||
|
||||
setSearchQuery: (query) => {
|
||||
set({ searchQuery: query });
|
||||
const state = get();
|
||||
const backToBase =
|
||||
!isBaseView(state.searchQuery, state.filters) && isBaseView(query, state.filters);
|
||||
set({
|
||||
searchQuery: query,
|
||||
...(backToBase ? restoredBaseView(state) : {}),
|
||||
});
|
||||
void get().refreshEmails();
|
||||
},
|
||||
|
||||
setFilters: (filters) => {
|
||||
set({ filters });
|
||||
const state = get();
|
||||
const backToBase =
|
||||
!isBaseView(state.searchQuery, state.filters) && isBaseView(state.searchQuery, filters);
|
||||
set({
|
||||
filters,
|
||||
...(backToBase ? restoredBaseView(state) : {}),
|
||||
});
|
||||
void get().refreshEmails();
|
||||
},
|
||||
|
||||
clearSearchAndFilters: () => {
|
||||
const { searchQuery, filters } = get();
|
||||
if (!searchQuery && Object.keys(filters).length === 0) return;
|
||||
set({ searchQuery: '', filters: {} });
|
||||
const state = get();
|
||||
if (!state.searchQuery && Object.keys(state.filters).length === 0) return;
|
||||
set({ searchQuery: '', filters: {}, ...restoredBaseView(state) });
|
||||
void get().refreshEmails();
|
||||
},
|
||||
|
||||
@@ -1330,6 +1377,42 @@ export const useEmailStore = create<EmailState>()(
|
||||
// refresh once the session is ready.
|
||||
name: 'email-cache',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
version: 1,
|
||||
// v0 → v1: drop every cached queryState. Pre-v1 builds could persist a
|
||||
// search-result list next to the base view's queryState, and the
|
||||
// incremental sync path would then "confirm" those search results as
|
||||
// the whole mailbox and bake them into the snapshot (issue #10).
|
||||
// Without a queryState the next refresh does a full re-query, which
|
||||
// rebuilds any poisoned window from the server.
|
||||
migrate: (persisted, version) => {
|
||||
if (version >= 1) return persisted as EmailState;
|
||||
const s = persisted as Pick<
|
||||
EmailState,
|
||||
'accountSnapshots' | 'mailboxSnapshots' | 'queryState'
|
||||
> & Record<string, unknown>;
|
||||
const stripQueryStates = (
|
||||
snaps: Record<string, MailboxSnapshot> | undefined,
|
||||
): Record<string, MailboxSnapshot> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(snaps ?? {}).map(([id, snap]) => [
|
||||
id,
|
||||
{ ...snap, queryState: undefined },
|
||||
]),
|
||||
);
|
||||
const accountSnapshots: Record<string, AccountSnapshot> = {};
|
||||
for (const [id, acc] of Object.entries(s.accountSnapshots ?? {})) {
|
||||
accountSnapshots[id] = {
|
||||
...acc,
|
||||
mailboxSnapshots: stripQueryStates(acc.mailboxSnapshots),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...s,
|
||||
accountSnapshots,
|
||||
mailboxSnapshots: stripQueryStates(s.mailboxSnapshots),
|
||||
queryState: undefined,
|
||||
} as unknown as EmailState;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
accountSnapshots: state.accountSnapshots,
|
||||
activeAccountId: state.activeAccountId,
|
||||
|
||||
Reference in New Issue
Block a user