diff --git a/components/layout/calendar-event-notification-toaster.tsx b/components/layout/calendar-event-notification-toaster.tsx new file mode 100644 index 000000000..ab69d65a9 --- /dev/null +++ b/components/layout/calendar-event-notification-toaster.tsx @@ -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()); + + // 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; +} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 4f041682f..c1b1c9208 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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 = {}) { /> )} + diff --git a/lib/__tests__/jmap-client-extensions.test.ts b/lib/__tests__/jmap-client-extensions.test.ts index f392bfd94..f9932f935 100644 --- a/lib/__tests__/jmap-client-extensions.test.ts +++ b/lib/__tests__/jmap-client-extensions.test.ts @@ -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': {} }); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index c428ba965..3eac7a316 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -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; updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountId?: string): Promise; deleteMailbox(mailboxId: string, accountId?: string): Promise; + // CalendarEventNotification (draft-ietf-jmap-calendars §7): invitations, + // updates and cancellations made by other participants. Optional. + getCalendarEventNotifications?(): Promise; + destroyCalendarEventNotifications?(ids: string[]): Promise; // ParticipantIdentity (draft-ietf-jmap-calendars §6): the addresses the // user organises events as. Optional. getParticipantIdentities?(): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d7db4c568..c4ff09b59 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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 { + 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 { + 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) ───────── /** diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 0fffc6a22..5e93f475b 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -869,6 +869,7 @@ export interface StateChange { CalendarEvent?: string; SieveScript?: string; ShareNotification?: string; + CalendarEventNotification?: string; }; }; } diff --git a/lib/pending-notification-store.ts b/lib/pending-notification-store.ts new file mode 100644 index 000000000..a1d7296da --- /dev/null +++ b/lib/pending-notification-store.ts @@ -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 { + /** Notifications fetched but not yet shown / acknowledged. */ + pending: T[]; + fetch: (client: IJMAPClient) => Promise; + /** Acknowledge shown notifications: drop them locally and destroy them on the server. */ + acknowledge: (client: IJMAPClient, ids: string[]) => Promise; + reset: () => void; +} + +interface PendingNotificationSource { + /** Log label. */ + name: string; + /** Lists the notifications, or null when the client cannot (demo, capability missing). */ + list: (client: IJMAPClient) => Promise | null; + destroy: (client: IJMAPClient, ids: string[]) => Promise | undefined; +} + +export function createPendingNotificationStore( + source: PendingNotificationSource, +): UseBoundStore>> { + // Ids already handed to the toaster; a fetch racing an acknowledge must not + // re-queue them. + const seen = new Set(); + let inFlight: Promise | null = null; + + return create>((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: [] }); + }, + })); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 3967b232e..670b76b35 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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}\"", diff --git a/locales/ca/common.json b/locales/ca/common.json index 03fe45a58..a37430b11 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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}\"", diff --git a/locales/cs/common.json b/locales/cs/common.json index eb6b52a1c..61b9d54c0 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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}“", diff --git a/locales/da/common.json b/locales/da/common.json index de84dbadb..0214ac297 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index e92376919..6549b813c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 922361bb3..7c3d0e4ec 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index 0e2db5586..e85cd1e96 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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}\"", diff --git a/locales/fa/common.json b/locales/fa/common.json index f9d18474e..62cacabfa 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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}» را با شما به اشتراک گذاشت", diff --git a/locales/fr/common.json b/locales/fr/common.json index aa88abc4e..437bf839f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 8ea8a1521..e0149b46c 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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}\"", diff --git a/locales/hu/common.json b/locales/hu/common.json index 4597300e0..d5f6987a4 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index af76ccb2b..19020190c 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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}\"", diff --git a/locales/ja/common.json b/locales/ja/common.json index 36fc451fb..adc5a9bd9 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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}」を共有しました", diff --git a/locales/ko/common.json b/locales/ko/common.json index 7e0acda25..5319c0f3d 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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}\"을(를) 공유했습니다", diff --git a/locales/lv/common.json b/locales/lv/common.json index cd6670551..a1e67c811 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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}”", diff --git a/locales/mn/common.json b/locales/mn/common.json index da22095c1..83157b462 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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}\"-г хуваалцлаа", diff --git a/locales/nb/common.json b/locales/nb/common.json index 1a7f4e663..9de941b29 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index edb51f6d4..28d5572d1 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index 31fb66bd7..6078ecb1b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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}”", diff --git a/locales/pt/common.json b/locales/pt/common.json index 7a4e16e39..1a1d06d5d 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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}\"", diff --git a/locales/ro/common.json b/locales/ro/common.json index b35474a85..90575069d 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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}”", diff --git a/locales/ru/common.json b/locales/ru/common.json index f33d0a38f..990e9237f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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}»", diff --git a/locales/sk/common.json b/locales/sk/common.json index c09af2409..380fb33a0 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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}“", diff --git a/locales/tr/common.json b/locales/tr/common.json index 8399abf59..3e8deeb56 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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ı", diff --git a/locales/uk/common.json b/locales/uk/common.json index 6845ddd39..e648ae6fb 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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}»", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 7cad4d4f5..eea2b6eaf 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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}」", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0bbb50bcf..0713c0a7c 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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}”", diff --git a/stores/calendar-event-notification-store.ts b/stores/calendar-event-notification-store.ts new file mode 100644 index 000000000..547a3b700 --- /dev/null +++ b/stores/calendar-event-notification-store.ts @@ -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({ + name: "calendar event notifications", + list: (client) => + client.supportsCalendars() && client.getCalendarEventNotifications ? client.getCalendarEventNotifications() : null, + destroy: (client, ids) => client.destroyCalendarEventNotifications?.(ids), +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 94b5a5150..39398b0ff 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -3530,6 +3530,13 @@ export const useEmailStore = create((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'); diff --git a/stores/share-notification-store.ts b/stores/share-notification-store.ts index bec59de1a..e55d811b7 100644 --- a/stores/share-notification-store.ts +++ b/stores/share-notification-store.ts @@ -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; - /** Acknowledge shown notifications: drop them locally and destroy them on the server. */ - acknowledge: (client: IJMAPClient, ids: string[]) => Promise; - reset: () => void; -} - -// Ids already handed to the toaster; a fetch racing an acknowledge must not -// re-queue them. -const seen = new Set(); -let inFlight: Promise | null = null; - -export const useShareNotificationStore = create((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({ + name: "share notifications", + list: (client) => + client.supportsShareNotifications?.() && client.getShareNotifications ? client.getShareNotifications() : null, + destroy: (client, ids) => client.destroyShareNotifications?.(ids), +});