mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
feat: surface CalendarEventNotification invitations and updates
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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': {} });
|
||||
|
||||
@@ -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
@@ -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) ─────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -869,6 +869,7 @@ export interface StateChange {
|
||||
CalendarEvent?: string;
|
||||
SieveScript?: string;
|
||||
ShareNotification?: string;
|
||||
CalendarEventNotification?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: [] });
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}“",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}» را با شما به اشتراک گذاشت",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}」を共有しました",
|
||||
|
||||
@@ -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}\"을(를) 공유했습니다",
|
||||
|
||||
@@ -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}”",
|
||||
|
||||
@@ -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}\"-г хуваалцлаа",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}”",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}”",
|
||||
|
||||
@@ -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}»",
|
||||
|
||||
@@ -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}“",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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}»",
|
||||
|
||||
@@ -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}」",
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user