diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index 4479c36f..e4fcaca9 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -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('loading'); const [event, setEvent] = React.useState | 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 diff --git a/src/lib/__tests__/calendar-invitation.test.ts b/src/lib/__tests__/calendar-invitation.test.ts index a8ba1c27..ab1b65c7 100644 --- a/src/lib/__tests__/calendar-invitation.test.ts +++ b/src/lib/__tests__/calendar-invitation.test.ts @@ -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(); + }); +}); diff --git a/src/lib/calendar-invitation.ts b/src/lib/calendar-invitation.ts index ea3657a7..b5bdee51 100644 --- a/src/lib/calendar-invitation.ts +++ b/src/lib/calendar-invitation.ts @@ -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, + attachment: Pick | null, + jmapAccountId?: string, +): string | null { + return attachment ? `${jmapAccountId ?? ''}|${email.id}|${attachment.blobId}` : null; +} + // ─── Authentication-Results / trust ────────────────────── export interface AuthenticationResults {