feat: surface CalendarEventNotification invitations and updates

This commit is contained in:
Linus Rath
2026-08-29 18:11:50 +02:00
parent 14cdb2f6ff
commit e54d179f32
37 changed files with 425 additions and 61 deletions
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useCalendarEventNotificationStore } from "@/stores/calendar-event-notification-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
/**
* Shows pending CalendarEventNotifications (someone invited you, changed or
* cancelled an event you take part in) as toasts, refreshes the events in
* view so the change is visible, and acknowledges them on the server.
*/
export function CalendarEventNotificationToaster({ client }: { client: IJMAPClient | null }) {
const t = useTranslations("calendar_event_notifications");
const pending = useCalendarEventNotificationStore((s) => s.pending);
const acknowledge = useCalendarEventNotificationStore((s) => s.acknowledge);
const handled = useRef(new Set<string>());
// Initial pull; pushes call fetch() from the email store's state handler.
useEffect(() => {
if (!client) return;
void useCalendarEventNotificationStore.getState().fetch(client);
}, [client]);
useEffect(() => {
if (!client || pending.length === 0) return;
const fresh = pending.filter((n) => !handled.current.has(n.id));
if (fresh.length === 0) return;
for (const n of fresh) {
handled.current.add(n.id);
// Drafts are the user's own unsent scheduling changes - nothing to announce.
if (n.isDraft) continue;
const kind = n.type === "created" ? "invited" : n.type === "destroyed" ? "cancelled" : "updated";
const message = t(kind, {
name: n.changedBy?.name || n.changedBy?.email || t("someone"),
title: n.event?.title || t("untitled"),
});
if (kind === "cancelled") toast.warning(message, n.comment || undefined);
else toast.info(message, n.comment || undefined);
}
const calendarStore = useCalendarStore.getState();
if (calendarStore.supportsCalendar && calendarStore.dateRange) {
void calendarStore.fetchEvents(client, calendarStore.dateRange.start, calendarStore.dateRange.end);
}
void acknowledge(client, fresh.map((n) => n.id));
}, [client, pending, acknowledge, t]);
return null;
}
+2
View File
@@ -34,6 +34,7 @@ import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store"
import { toast } from "@/stores/toast-store";
import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog";
import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster";
import { CalendarEventNotificationToaster } from "@/components/layout/calendar-event-notification-toaster";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
@@ -4203,6 +4204,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
/>
)}
<ShareNotificationToaster client={client} />
<CalendarEventNotificationToaster client={client} />
<TotpReauthDialog />
</div>
</DragDropProvider>
@@ -188,6 +188,32 @@ describe('JMAP client extensions', () => {
});
});
describe('calendar event notifications', () => {
it('lists notifications with the event title and destroys acknowledged ones', async () => {
await setup({ 'urn:ietf:params:jmap:calendars': {} });
answer((body) => {
const [method] = body.methodCalls[0];
if (method === 'CalendarEventNotification/query') {
return { methodResponses: [
['CalendarEventNotification/query', { ids: ['n1'] }, '0'],
['CalendarEventNotification/get', { list: [
{ id: 'n1', created: '2026-08-29T10:00:00Z', type: 'created', changedBy: { name: 'Alice', email: 'alice@example.com' }, calendarEventId: 'e1', isDraft: false, event: { title: 'Sync' } },
] }, '1'],
] };
}
return { methodResponses: [['CalendarEventNotification/set', { destroyed: ['n1'] }, '0']] };
});
const list = await client.getCalendarEventNotifications();
expect(requests[0].methodCalls[1][1]).toMatchObject({ properties: expect.arrayContaining(['event', 'type', 'changedBy']) });
expect(list).toHaveLength(1);
expect(list[0].event?.title).toBe('Sync');
await client.destroyCalendarEventNotifications(['n1']);
expect(requests[1].methodCalls[0]).toEqual(['CalendarEventNotification/set', { accountId: 'acct-1', destroy: ['n1'] }, '0']);
});
});
describe('participant identities', () => {
it('maps ParticipantIdentity/get and sets the default via onSuccessSetIsDefault', async () => {
await setup({ 'urn:ietf:params:jmap:calendars': {} });
+5 -1
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, EmailPushConfig, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, EmailPushConfig, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { SortLevel } from "@/lib/message-list-order";
@@ -126,6 +126,10 @@ export interface IJMAPClient {
createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox>;
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountId?: string): Promise<void>;
deleteMailbox(mailboxId: string, accountId?: string): Promise<void>;
// CalendarEventNotification (draft-ietf-jmap-calendars §7): invitations,
// updates and cancellations made by other participants. Optional.
getCalendarEventNotifications?(): Promise<CalendarEventNotification[]>;
destroyCalendarEventNotifications?(ids: string[]): Promise<void>;
// ParticipantIdentity (draft-ietf-jmap-calendars §6): the addresses the
// user organises events as. Optional.
getParticipantIdentities?(): Promise<CalendarParticipantIdentity[]>;
+41 -1
View File
@@ -1,5 +1,5 @@
import { generateUUID } from '@/lib/utils';
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
@@ -5071,6 +5071,46 @@ export class JMAPClient implements IJMAPClient {
}
}
// ── CalendarEventNotification (draft-ietf-jmap-calendars §7) ───
/**
* Pending event notifications (invitations, updates, cancellations made by
* other participants), oldest first. `event` is requested for the title
* only; the default property set omits it.
*/
async getCalendarEventNotifications(): Promise<CalendarEventNotification[]> {
if (!this.supportsCalendars()) return [];
const accountId = this.getCalendarsAccountId();
const response = await this.request([
["CalendarEventNotification/query", {
accountId,
sort: [{ property: "created", isAscending: true }],
}, "0"],
["CalendarEventNotification/get", {
accountId,
"#ids": { resultOf: "0", name: "CalendarEventNotification/query", path: "/ids" },
properties: ["id", "created", "changedBy", "comment", "type", "calendarEventId", "isDraft", "event"],
}, "1"],
], this.calendarUsing());
const getResp = response.methodResponses?.find((r) => r[0] === "CalendarEventNotification/get");
if (!getResp) {
const error = response.methodResponses?.find((r) => r[0] === "error")?.[1] as { description?: string } | undefined;
throw new Error(error?.description || "Failed to load calendar event notifications");
}
return ((getResp[1] as { list?: CalendarEventNotification[] }).list ?? []);
}
/** Acknowledges (destroys) event notifications that have been shown. */
async destroyCalendarEventNotifications(ids: string[]): Promise<void> {
if (ids.length === 0 || !this.supportsCalendars()) return;
const accountId = this.getCalendarsAccountId();
for (const batch of batched(ids, this.getMaxObjectsInSet())) {
await this.request([
["CalendarEventNotification/set", { accountId, destroy: batch }, "0"],
], this.calendarUsing());
}
}
// ── ParticipantIdentity (draft-ietf-jmap-calendars §6) ─────────
/**
+1
View File
@@ -869,6 +869,7 @@ export interface StateChange {
CalendarEvent?: string;
SieveScript?: string;
ShareNotification?: string;
CalendarEventNotification?: string;
};
};
}
+77
View File
@@ -0,0 +1,77 @@
import { create, type StoreApi, type UseBoundStore } from "zustand";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
/**
* A server-side notification inbox (RFC 9670 ShareNotification,
* draft-ietf-jmap-calendars CalendarEventNotification): the server records
* an object per event, the client fetches them on login and on push, shows
* them, and destroys them once shown. This factory holds the shared
* fetch / acknowledge bookkeeping; the toaster components do the showing.
*/
export interface PendingNotificationStore<T extends { id: string }> {
/** Notifications fetched but not yet shown / acknowledged. */
pending: T[];
fetch: (client: IJMAPClient) => Promise<void>;
/** Acknowledge shown notifications: drop them locally and destroy them on the server. */
acknowledge: (client: IJMAPClient, ids: string[]) => Promise<void>;
reset: () => void;
}
interface PendingNotificationSource<T> {
/** Log label. */
name: string;
/** Lists the notifications, or null when the client cannot (demo, capability missing). */
list: (client: IJMAPClient) => Promise<T[]> | null;
destroy: (client: IJMAPClient, ids: string[]) => Promise<void> | undefined;
}
export function createPendingNotificationStore<T extends { id: string }>(
source: PendingNotificationSource<T>,
): UseBoundStore<StoreApi<PendingNotificationStore<T>>> {
// Ids already handed to the toaster; a fetch racing an acknowledge must not
// re-queue them.
const seen = new Set<string>();
let inFlight: Promise<void> | null = null;
return create<PendingNotificationStore<T>>((set, get) => ({
pending: [],
fetch: (client) => {
// Coalesce: a push arriving while the previous fetch runs joins it.
if (inFlight) return inFlight;
const listing = source.list(client);
if (!listing) return Promise.resolve();
inFlight = (async () => {
try {
const list = await listing;
const fresh = list.filter((n) => !seen.has(n.id));
if (fresh.length === 0) return;
for (const n of fresh) seen.add(n.id);
set((state) => ({ pending: [...state.pending, ...fresh] }));
} catch (error) {
console.error(`Failed to fetch ${source.name}:`, error);
} finally {
inFlight = null;
}
})();
return inFlight;
},
acknowledge: async (client, ids) => {
if (ids.length === 0) return;
const idSet = new Set(ids);
set({ pending: get().pending.filter((n) => !idSet.has(n.id)) });
try {
await source.destroy(client, ids);
} catch (error) {
// Worst case the notification is shown again after a reload.
console.error(`Failed to acknowledge ${source.name}:`, error);
}
},
reset: () => {
seen.clear();
set({ pending: [] });
},
}));
}
+7
View File
@@ -3161,6 +3161,13 @@
"overdue": "متأخرة"
}
},
"calendar_event_notifications": {
"someone": "شخص ما",
"invited": "دعاك {name} إلى \"{title}\"",
"updated": "حدّث {name} \"{title}\"",
"cancelled": "ألغى {name} \"{title}\"",
"untitled": "حدث بدون عنوان"
},
"share_notifications": {
"someone": "شخص ما",
"shared": "شارك {name} معك {kind} \"{object}\"",
+7
View File
@@ -3161,6 +3161,13 @@
"overdue": "Vençuda"
}
},
"calendar_event_notifications": {
"someone": "Algú",
"invited": "{name} t'ha convidat a \"{title}\"",
"updated": "{name} ha actualitzat \"{title}\"",
"cancelled": "{name} ha cancel·lat \"{title}\"",
"untitled": "Esdeveniment sense títol"
},
"share_notifications": {
"someone": "Algú",
"shared": "{name} ha compartit amb tu {kind} \"{object}\"",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "Vyhledávání není ve sjednoceném zobrazení k dispozici"
},
"calendar_event_notifications": {
"someone": "Někdo",
"invited": "{name} vás pozval(a) na „{title}“",
"updated": "{name} aktualizoval(a) „{title}“",
"cancelled": "{name} zrušil(a) „{title}“",
"untitled": "Událost bez názvu"
},
"share_notifications": {
"someone": "Někdo",
"shared": "{name} s vámi sdílí {kind} „{object}“",
+7
View File
@@ -3161,6 +3161,13 @@
},
"nav_open_menu": "Åbn menu"
},
"calendar_event_notifications": {
"someone": "Nogen",
"invited": "{name} har inviteret dig til \"{title}\"",
"updated": "{name} har opdateret \"{title}\"",
"cancelled": "{name} har aflyst \"{title}\"",
"untitled": "Unavngivet begivenhed"
},
"share_notifications": {
"someone": "Nogen",
"shared": "{name} har delt {kind} \"{object}\" med dig",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar"
},
"calendar_event_notifications": {
"someone": "Jemand",
"invited": "{name} hat dich zu „{title}“ eingeladen",
"updated": "{name} hat „{title}“ aktualisiert",
"cancelled": "{name} hat „{title}“ abgesagt",
"untitled": "Unbenannter Termin"
},
"share_notifications": {
"someone": "Jemand",
"shared": "{name} hat {kind} „{object}“ mit dir geteilt",
+7
View File
@@ -3161,6 +3161,13 @@
"overdue": "Overdue"
}
},
"calendar_event_notifications": {
"someone": "Someone",
"invited": "{name} invited you to \"{title}\"",
"updated": "{name} updated \"{title}\"",
"cancelled": "{name} cancelled \"{title}\"",
"untitled": "Untitled event"
},
"share_notifications": {
"someone": "Someone",
"shared": "{name} shared the {kind} \"{object}\" with you",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "La búsqueda no está disponible en la vista unificada"
},
"calendar_event_notifications": {
"someone": "Alguien",
"invited": "{name} te ha invitado a \"{title}\"",
"updated": "{name} ha actualizado \"{title}\"",
"cancelled": "{name} ha cancelado \"{title}\"",
"untitled": "Evento sin título"
},
"share_notifications": {
"someone": "Alguien",
"shared": "{name} ha compartido contigo {kind} \"{object}\"",
+7
View File
@@ -3161,6 +3161,13 @@
"overdue": "عقب‌افتاده"
}
},
"calendar_event_notifications": {
"someone": "کسی",
"invited": "{name} شما را به «{title}» دعوت کرد",
"updated": "{name} «{title}» را به‌روزرسانی کرد",
"cancelled": "{name} «{title}» را لغو کرد",
"untitled": "رویداد بدون عنوان"
},
"share_notifications": {
"someone": "کسی",
"shared": "{name} {kind} «{object}» را با شما به اشتراک گذاشت",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "La recherche n'est pas disponible dans la vue unifiée"
},
"calendar_event_notifications": {
"someone": "Quelqu'un",
"invited": "{name} vous a invité à « {title} »",
"updated": "{name} a mis à jour « {title} »",
"cancelled": "{name} a annulé « {title} »",
"untitled": "Événement sans titre"
},
"share_notifications": {
"someone": "Quelqu'un",
"shared": "{name} a partagé {kind} « {object} » avec vous",
+7
View File
@@ -3461,6 +3461,13 @@
"toast_error_delete_has_children": "לתיקייה יש תת־תיקיות. הסר אותן קודם.",
"toast_error_delete_has_email": "התיקייה אינה ריקה. תרוקנה קודם."
},
"calendar_event_notifications": {
"someone": "מישהו",
"invited": "{name} הזמין/ה אותך ל\"{title}\"",
"updated": "{name} עדכן/ה את \"{title}\"",
"cancelled": "{name} ביטל/ה את \"{title}\"",
"untitled": "אירוע ללא כותרת"
},
"share_notifications": {
"someone": "מישהו",
"shared": "{name} שיתף/ה איתך את {kind} \"{object}\"",
+7
View File
@@ -3161,6 +3161,13 @@
"overdue": "Lejárt"
}
},
"calendar_event_notifications": {
"someone": "Valaki",
"invited": "{name} meghívott a(z) „{title}” eseményre",
"updated": "{name} frissítette a(z) „{title}” eseményt",
"cancelled": "{name} lemondta a(z) „{title}” eseményt",
"untitled": "Névtelen esemény"
},
"share_notifications": {
"someone": "Valaki",
"shared": "{name} megosztotta veled a(z) {kind} „{object}” elemet",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "La ricerca non è disponibile nella vista unificata"
},
"calendar_event_notifications": {
"someone": "Qualcuno",
"invited": "{name} ti ha invitato a \"{title}\"",
"updated": "{name} ha aggiornato \"{title}\"",
"cancelled": "{name} ha annullato \"{title}\"",
"untitled": "Evento senza titolo"
},
"share_notifications": {
"someone": "Qualcuno",
"shared": "{name} ha condiviso con te {kind} \"{object}\"",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "統合ビューでは検索を利用できません"
},
"calendar_event_notifications": {
"someone": "誰か",
"invited": "{name} さんが「{title}」に招待しました",
"updated": "{name} さんが「{title}」を更新しました",
"cancelled": "{name} さんが「{title}」をキャンセルしました",
"untitled": "無題の予定"
},
"share_notifications": {
"someone": "誰か",
"shared": "{name} さんが{kind}「{object}」を共有しました",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다"
},
"calendar_event_notifications": {
"someone": "누군가",
"invited": "{name}님이 \"{title}\"에 초대했습니다",
"updated": "{name}님이 \"{title}\"을(를) 업데이트했습니다",
"cancelled": "{name}님이 \"{title}\"을(를) 취소했습니다",
"untitled": "제목 없는 일정"
},
"share_notifications": {
"someone": "누군가",
"shared": "{name}님이 {kind} \"{object}\"을(를) 공유했습니다",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "Meklēšana nav pieejama apvienotajā skatā"
},
"calendar_event_notifications": {
"someone": "Kāds",
"invited": "{name} uzaicināja jūs uz “{title}”",
"updated": "{name} atjaunināja “{title}”",
"cancelled": "{name} atcēla “{title}”",
"untitled": "Notikums bez nosaukuma"
},
"share_notifications": {
"someone": "Kāds",
"shared": "{name} kopīgoja ar jums {kind} “{object}”",
+7
View File
@@ -3161,6 +3161,13 @@
"overdue": "Хугацаа хэтэрсэн"
}
},
"calendar_event_notifications": {
"someone": "Хэн нэгэн",
"invited": "{name} таныг \"{title}\"-д урилаа",
"updated": "{name} \"{title}\"-г шинэчиллээ",
"cancelled": "{name} \"{title}\"-г цуцаллаа",
"untitled": "Нэргүй арга хэмжээ"
},
"share_notifications": {
"someone": "Хэн нэгэн",
"shared": "{name} танд {kind} \"{object}\"-г хуваалцлаа",
+7
View File
@@ -3159,6 +3159,13 @@
"overdue": "Forfalt"
}
},
"calendar_event_notifications": {
"someone": "Noen",
"invited": "{name} har invitert deg til «{title}»",
"updated": "{name} har oppdatert «{title}»",
"cancelled": "{name} har avlyst «{title}»",
"untitled": "Hendelse uten tittel"
},
"share_notifications": {
"someone": "Noen",
"shared": "{name} har delt {kind} «{object}» med deg",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave"
},
"calendar_event_notifications": {
"someone": "Iemand",
"invited": "{name} heeft je uitgenodigd voor \"{title}\"",
"updated": "{name} heeft \"{title}\" bijgewerkt",
"cancelled": "{name} heeft \"{title}\" geannuleerd",
"untitled": "Afspraak zonder titel"
},
"share_notifications": {
"someone": "Iemand",
"shared": "{name} heeft {kind} \"{object}\" met je gedeeld",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym"
},
"calendar_event_notifications": {
"someone": "Ktoś",
"invited": "{name} zaprosił(a) Cię na „{title}”",
"updated": "{name} zaktualizował(a) „{title}”",
"cancelled": "{name} odwołał(a) „{title}”",
"untitled": "Wydarzenie bez tytułu"
},
"share_notifications": {
"someone": "Ktoś",
"shared": "{name} udostępnił(a) Ci {kind} „{object}”",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "A pesquisa não está disponível na vista unificada"
},
"calendar_event_notifications": {
"someone": "Alguém",
"invited": "{name} convidou-o para \"{title}\"",
"updated": "{name} atualizou \"{title}\"",
"cancelled": "{name} cancelou \"{title}\"",
"untitled": "Evento sem título"
},
"share_notifications": {
"someone": "Alguém",
"shared": "{name} partilhou consigo {kind} \"{object}\"",
+7
View File
@@ -3161,6 +3161,13 @@
"overdue": "Restant"
}
},
"calendar_event_notifications": {
"someone": "Cineva",
"invited": "{name} te-a invitat la „{title}”",
"updated": "{name} a actualizat „{title}”",
"cancelled": "{name} a anulat „{title}”",
"untitled": "Eveniment fără titlu"
},
"share_notifications": {
"someone": "Cineva",
"shared": "{name} a partajat cu tine {kind} „{object}”",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "Поиск недоступен в объединённом представлении"
},
"calendar_event_notifications": {
"someone": "Кто-то",
"invited": "{name} пригласил(а) вас на «{title}»",
"updated": "{name} обновил(а) «{title}»",
"cancelled": "{name} отменил(а) «{title}»",
"untitled": "Событие без названия"
},
"share_notifications": {
"someone": "Кто-то",
"shared": "{name} поделился(ась) с вами {kind} «{object}»",
+7
View File
@@ -3161,6 +3161,13 @@
"overdue": "Po termíne"
}
},
"calendar_event_notifications": {
"someone": "Niekto",
"invited": "{name} vás pozval(a) na „{title}“",
"updated": "{name} aktualizoval(a) „{title}“",
"cancelled": "{name} zrušil(a) „{title}“",
"untitled": "Udalosť bez názvu"
},
"share_notifications": {
"someone": "Niekto",
"shared": "{name} s vami zdieľa {kind} „{object}“",
+7
View File
@@ -3161,6 +3161,13 @@
},
"nav_open_menu": "Menüyü aç"
},
"calendar_event_notifications": {
"someone": "Birisi",
"invited": "{name} sizi \"{title}\" etkinliğine davet etti",
"updated": "{name} \"{title}\" etkinliğini güncelledi",
"cancelled": "{name} \"{title}\" etkinliğini iptal etti",
"untitled": "Başlıksız etkinlik"
},
"share_notifications": {
"someone": "Birisi",
"shared": "{name}, \"{object}\" {kind} sizinle paylaştı",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "Пошук недоступний в об'єднаному перегляді"
},
"calendar_event_notifications": {
"someone": "Хтось",
"invited": "{name} запросив(ла) вас на «{title}»",
"updated": "{name} оновив(ла) «{title}»",
"cancelled": "{name} скасував(ла) «{title}»",
"untitled": "Подія без назви"
},
"share_notifications": {
"someone": "Хтось",
"shared": "{name} поділився(лася) з вами {kind} «{object}»",
+7
View File
@@ -3154,6 +3154,13 @@
"overdue": "逾期"
}
},
"calendar_event_notifications": {
"someone": "有人",
"invited": "{name} 邀請您參加「{title}」",
"updated": "{name} 更新了「{title}」",
"cancelled": "{name} 取消了「{title}」",
"untitled": "未命名活動"
},
"share_notifications": {
"someone": "有人",
"shared": "{name} 與您共用了{kind}「{object}」",
+7
View File
@@ -3463,6 +3463,13 @@
"unified_mailbox": {
"search_unavailable": "统一视图中无法使用搜索"
},
"calendar_event_notifications": {
"someone": "有人",
"invited": "{name} 邀请您参加“{title}”",
"updated": "{name} 更新了“{title}”",
"cancelled": "{name} 取消了“{title}”",
"untitled": "无标题活动"
},
"share_notifications": {
"someone": "有人",
"shared": "{name} 与您共享了{kind}“{object}”",
@@ -0,0 +1,16 @@
import type { CalendarEventNotification } from "@/lib/jmap/types";
import { createPendingNotificationStore } from "@/lib/pending-notification-store";
/**
* CalendarEventNotification (draft-ietf-jmap-calendars §7) inbox: the server
* records every invitation, update and cancellation another participant
* made to an event this user takes part in. Pulled on login and on every
* `CalendarEventNotification` push; the toaster shows them and acknowledges
* them, which destroys them server-side.
*/
export const useCalendarEventNotificationStore = createPendingNotificationStore<CalendarEventNotification>({
name: "calendar event notifications",
list: (client) =>
client.supportsCalendars() && client.getCalendarEventNotifications ? client.getCalendarEventNotifications() : null,
destroy: (client, ids) => client.destroyCalendarEventNotifications?.(ids),
});
+7
View File
@@ -3530,6 +3530,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
void useShareNotificationStore.getState().fetch(client);
}
// Another participant invited this user to, changed or cancelled an
// event (CalendarEventNotification): same pull-and-toast flow.
if (Object.values(change.changed).some((c) => c?.CalendarEventNotification)) {
const { useCalendarEventNotificationStore } = await import('./calendar-event-notification-store');
void useCalendarEventNotificationStore.getState().fetch(client);
}
// Handle SieveScript state changes - refresh filter rules
if (accountChanges?.SieveScript) {
const { useFilterStore } = await import('./filter-store');
+10 -59
View File
@@ -1,64 +1,15 @@
import { create } from "zustand";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { ShareNotification } from "@/lib/jmap/types";
import { createPendingNotificationStore } from "@/lib/pending-notification-store";
/**
* ShareNotification (RFC 9670 §3) inbox: the server records every change to
* this user's rights on someone else's collection. The store pulls those on
* login and on every `ShareNotification` push; the toaster component shows
* them (translated) and acknowledges them, which destroys them server-side.
* this user's rights on someone else's collection. Pulled on login and on
* every `ShareNotification` push; the toaster component shows them
* (translated) and acknowledges them, which destroys them server-side.
*/
interface ShareNotificationStore {
/** Notifications fetched but not yet shown / acknowledged. */
pending: ShareNotification[];
fetch: (client: IJMAPClient) => Promise<void>;
/** Acknowledge shown notifications: drop them locally and destroy them on the server. */
acknowledge: (client: IJMAPClient, ids: string[]) => Promise<void>;
reset: () => void;
}
// Ids already handed to the toaster; a fetch racing an acknowledge must not
// re-queue them.
const seen = new Set<string>();
let inFlight: Promise<void> | null = null;
export const useShareNotificationStore = create<ShareNotificationStore>((set, get) => ({
pending: [],
fetch: (client) => {
if (!client.supportsShareNotifications?.() || !client.getShareNotifications) return Promise.resolve();
// Coalesce: a push arriving while the previous fetch runs re-runs it once.
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const list = await client.getShareNotifications!();
const fresh = list.filter((n) => !seen.has(n.id));
if (fresh.length === 0) return;
for (const n of fresh) seen.add(n.id);
set((state) => ({ pending: [...state.pending, ...fresh] }));
} catch (error) {
console.error("Failed to fetch share notifications:", error);
} finally {
inFlight = null;
}
})();
return inFlight;
},
acknowledge: async (client, ids) => {
if (ids.length === 0) return;
const idSet = new Set(ids);
set({ pending: get().pending.filter((n) => !idSet.has(n.id)) });
try {
await client.destroyShareNotifications?.(ids);
} catch (error) {
// Worst case the notification is shown again after a reload.
console.error("Failed to acknowledge share notifications:", error);
}
},
reset: () => {
seen.clear();
set({ pending: [] });
},
}));
export const useShareNotificationStore = createPendingNotificationStore<ShareNotification>({
name: "share notifications",
list: (client) =>
client.supportsShareNotifications?.() && client.getShareNotifications ? client.getShareNotifications() : null,
destroy: (client, ids) => client.destroyShareNotifications?.(ids),
});