fix: stop reloading an invitation when the message is marked read

The invitation banner loaded the .ics whenever it was handed a new
message object, so marking an invitation read or starring it
downloaded and parsed the calendar part again (and fetched its raw
text when the method had to be read from it).

It now loads once per message, account and calendar part
(`calendarInvitationKey`), and reads the current message only when the
load runs.
This commit is contained in:
Linus Rath
2026-09-24 20:29:41 +02:00
parent 644443193c
commit 0cee422373
3 changed files with 50 additions and 6 deletions
@@ -14,6 +14,7 @@ import { useCalendarStore } from '../../stores/calendar-store';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import {
calendarInvitationKey,
findCalendarAttachment,
findParticipantByEmail,
getInvitationMethod,
@@ -72,6 +73,11 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
// Login address + identities + aliases, so invitations addressed to an
// alias still show the RSVP buttons. Only an invitation looks the aliases up.
const currentUserEmails = useUserCalendarAddresses(!!attachment && enabled);
// The invitation is loaded once per message, account and calendar part;
// a mark-read or star hands a new `email` for the same one.
const invitationKey = calendarInvitationKey(email, attachment, jmapAccountId);
const latest = React.useRef({ email, attachment });
latest.current = { email, attachment };
const [state, setState] = React.useState<BannerState>('loading');
const [event, setEvent] = React.useState<Partial<CalendarEvent> | null>(null);
@@ -87,12 +93,13 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
React.useEffect(() => {
let cancelled = false;
if (!attachment || !enabled) return;
const { email: current, attachment: part } = latest.current;
if (!invitationKey || !part || !enabled) return;
setState('loading');
setServerMatch(null);
(async () => {
try {
const events = await parseCalendarBlob(attachment.blobId, jmapAccountId);
const events = await parseCalendarBlob(part.blobId, jmapAccountId);
if (cancelled) return;
if (events.length === 0) {
setState('error');
@@ -110,11 +117,11 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
}
// Explicit method (Content-Type params) first; JMAP usually strips
// them, so fall back to the raw ICS METHOD line before guessing.
let detected = getInvitationMethod(parsed, { email, attachment });
let detected = getInvitationMethod(parsed, { email: current, attachment: part });
if (detected === 'unknown') {
const raw = await fetchCalendarBlobText(attachment.blobId, jmapAccountId);
const raw = await fetchCalendarBlobText(part.blobId, jmapAccountId);
if (cancelled) return;
detected = getInvitationMethod(parsed, { email, attachment, rawIcs: raw });
detected = getInvitationMethod(parsed, { email: current, attachment: part, rawIcs: raw });
}
setMethod(detected);
setState('parsed');
@@ -123,7 +130,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
}
})();
return () => { cancelled = true; };
}, [attachment, enabled, email, jmapAccountId]);
}, [invitationKey, enabled, jmapAccountId]);
// Import into the account's default calendar; never into a shared calendar,
// an iCal subscription (the next feed sync would delete the event) or a
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import type { Email } from '../../api/types';
import {
calendarInvitationKey,
extractMethodFromContentType,
extractMethodFromRawIcs,
findCalendarAttachment,
@@ -113,3 +114,25 @@ describe('getInvitationTrustAssessment', () => {
expect(getInvitationTrustAssessment(request, e, 'unknown').level).toBe('trusted');
});
});
describe('calendarInvitationKey', () => {
const invite = {
id: 'e1',
keywords: {},
attachments: [{ blobId: 'b1', type: 'text/calendar', name: 'invite.ics', size: 10 }],
} as unknown as Email;
it('stays the same when only the keywords change', () => {
const read = { ...invite, keywords: { $seen: true } } as Email;
const key = calendarInvitationKey(invite, findCalendarAttachment(invite), 'group');
expect(key).toBe('group|e1|b1');
expect(calendarInvitationKey(read, findCalendarAttachment(read), 'group')).toBe(key);
});
it('differs per account and calendar part, and is null without one', () => {
const other = { ...invite, attachments: [{ ...invite.attachments![0], blobId: 'b2' }] } as Email;
expect(calendarInvitationKey(invite, findCalendarAttachment(invite))).toBe('|e1|b1');
expect(calendarInvitationKey(other, findCalendarAttachment(other))).toBe('|e1|b2');
expect(calendarInvitationKey({ id: 'e2' }, null)).toBeNull();
});
});
+14
View File
@@ -250,6 +250,20 @@ export function findCalendarAttachment(
return findCalendarBodyPart(email.textBody) || findCalendarBodyPart(email.htmlBody);
}
/**
* What identifies a message's invitation for loading it: the message, its
* account and the calendar part's blob. A keyword change (read, star) hands
* a new message object with the same key, and must not download and parse
* the .ics again. Null when the message has no calendar part.
*/
export function calendarInvitationKey(
email: Pick<Email, 'id'>,
attachment: Pick<Attachment, 'blobId'> | null,
jmapAccountId?: string,
): string | null {
return attachment ? `${jmapAccountId ?? ''}|${email.id}|${attachment.blobId}` : null;
}
// ─── Authentication-Results / trust ──────────────────────
export interface AuthenticationResults {