mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: surface ShareNotification changes as toasts
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { ShareNotification } from "@/lib/jmap/types";
|
||||
import { useShareNotificationStore } from "@/stores/share-notification-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
/** What happened to the user's rights, for the message wording. */
|
||||
export function shareNotificationKind(n: ShareNotification): "shared" | "changed" | "revoked" {
|
||||
if (!n.oldRights || Object.keys(n.oldRights).length === 0) return "shared";
|
||||
if (!n.newRights || Object.keys(n.newRights).length === 0) return "revoked";
|
||||
return "changed";
|
||||
}
|
||||
|
||||
const OBJECT_KEYS: Record<string, string> = {
|
||||
Mailbox: "folder",
|
||||
Calendar: "calendar",
|
||||
AddressBook: "address_book",
|
||||
FileNode: "files",
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows pending ShareNotifications as toasts, refreshes the collection list
|
||||
* the change belongs to (so a newly shared folder / calendar / address book
|
||||
* appears without a reload) and acknowledges them on the server.
|
||||
*/
|
||||
export function ShareNotificationToaster({ client }: { client: IJMAPClient | null }) {
|
||||
const t = useTranslations("share_notifications");
|
||||
const pending = useShareNotificationStore((s) => s.pending);
|
||||
const acknowledge = useShareNotificationStore((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 useShareNotificationStore.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;
|
||||
const touched = new Set<string>();
|
||||
for (const n of fresh) {
|
||||
handled.current.add(n.id);
|
||||
touched.add(n.objectType);
|
||||
const kind = shareNotificationKind(n);
|
||||
const objectKey = OBJECT_KEYS[n.objectType];
|
||||
const message = t(kind, {
|
||||
name: n.changedBy?.name || n.changedBy?.email || t("someone"),
|
||||
object: n.name || n.objectId,
|
||||
kind: objectKey ? t(`object.${objectKey}`) : n.objectType,
|
||||
});
|
||||
if (kind === "revoked") toast.warning(message);
|
||||
else toast.info(message);
|
||||
}
|
||||
if (touched.has("Mailbox")) void useEmailStore.getState().fetchMailboxes(client);
|
||||
if (touched.has("Calendar") && useCalendarStore.getState().supportsCalendar) {
|
||||
void useCalendarStore.getState().fetchCalendars(client);
|
||||
}
|
||||
if (touched.has("AddressBook")) void useContactStore.getState().fetchAddressBooks(client);
|
||||
if (touched.has("FileNode")) void useFileStore.getState().refresh();
|
||||
void acknowledge(client, fresh.map((n) => n.id));
|
||||
}, [client, pending, acknowledge, t]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
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 { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
@@ -4201,6 +4202,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
onClose={() => setSharingMailboxId(null)}
|
||||
/>
|
||||
)}
|
||||
<ShareNotificationToaster client={client} />
|
||||
<TotpReauthDialog />
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, 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, 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,11 @@ 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>;
|
||||
// ShareNotification (RFC 9670 §3): who shared what with this user. Optional
|
||||
// (demo client).
|
||||
supportsShareNotifications?(): boolean;
|
||||
getShareNotifications?(): Promise<ShareNotification[]>;
|
||||
destroyShareNotifications?(ids: string[]): Promise<void>;
|
||||
// Mailbox sharing (urn:ietf:params:jmap:mail:share). Optional: the demo
|
||||
// client has no principals to share with.
|
||||
supportsMailboxSharing?(accountId?: string): boolean;
|
||||
|
||||
+44
-1
@@ -1,5 +1,5 @@
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, 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, 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,49 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ── ShareNotification (RFC 9670 §3) ────────────────────────────
|
||||
|
||||
/** Whether the server can report who shared what with this user. */
|
||||
supportsShareNotifications(): boolean {
|
||||
return this.supportsPrincipals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every pending share notification of the user's own account, oldest
|
||||
* first. The list is small by construction: notifications are destroyed
|
||||
* once they have been shown (see destroyShareNotifications).
|
||||
*/
|
||||
async getShareNotifications(): Promise<ShareNotification[]> {
|
||||
if (!this.supportsShareNotifications()) return [];
|
||||
const accountId = this.accountId;
|
||||
const response = await this.request([
|
||||
["ShareNotification/query", {
|
||||
accountId,
|
||||
sort: [{ property: "created", isAscending: true }],
|
||||
}, "0"],
|
||||
["ShareNotification/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "ShareNotification/query", path: "/ids" },
|
||||
}, "1"],
|
||||
], this.principalsUsing());
|
||||
const getResp = response.methodResponses?.find((r) => r[0] === "ShareNotification/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 share notifications");
|
||||
}
|
||||
return ((getResp[1] as { list?: ShareNotification[] }).list ?? []);
|
||||
}
|
||||
|
||||
/** Acknowledges (destroys) share notifications that have been shown. */
|
||||
async destroyShareNotifications(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0 || !this.supportsShareNotifications()) return;
|
||||
for (const batch of batched(ids, this.getMaxObjectsInSet())) {
|
||||
await this.request([
|
||||
["ShareNotification/set", { accountId: this.accountId, destroy: batch }, "0"],
|
||||
], this.principalsUsing());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add, update, or remove a principal's rights on a calendar.
|
||||
* Pass `rights: null` to revoke access.
|
||||
|
||||
@@ -851,10 +851,28 @@ export interface StateChange {
|
||||
Calendar?: string;
|
||||
CalendarEvent?: string;
|
||||
SieveScript?: string;
|
||||
ShareNotification?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A ShareNotification (RFC 9670 §3): someone changed this user's rights on
|
||||
* one of their collections. `oldRights` null = newly shared, `newRights`
|
||||
* null = access revoked.
|
||||
*/
|
||||
export interface ShareNotification {
|
||||
id: string;
|
||||
created: string;
|
||||
changedBy: { name: string; email: string | null; principalId: string | null };
|
||||
objectType: 'Mailbox' | 'Calendar' | 'AddressBook' | 'FileNode' | string;
|
||||
objectAccountId: string;
|
||||
objectId: string;
|
||||
name: string;
|
||||
oldRights: Record<string, boolean> | null;
|
||||
newRights: Record<string, boolean> | null;
|
||||
}
|
||||
|
||||
// draft-ietf-jmap-emailpush EmailPushConfig: the server evaluates `filter`
|
||||
// against every newly delivered message and only pushes when it matches, so a
|
||||
// client can keep spam (Junk) out of its push channel server-side. Keyed by
|
||||
|
||||
@@ -3150,6 +3150,18 @@
|
||||
"overdue": "متأخرة"
|
||||
}
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "شخص ما",
|
||||
"shared": "شارك {name} معك {kind} \"{object}\"",
|
||||
"changed": "غيّر {name} صلاحيتك على {kind} \"{object}\"",
|
||||
"revoked": "أزال {name} صلاحيتك على {kind} \"{object}\"",
|
||||
"object": {
|
||||
"folder": "المجلد",
|
||||
"calendar": "التقويم",
|
||||
"address_book": "دفتر العناوين",
|
||||
"files": "مجلد الملفات"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "مشاركة \"{name}\"",
|
||||
"description": "امنح الوصول لمستخدمين أو مجموعات أخرى على هذا الخادم. تُطبَّق التغييرات فورًا.",
|
||||
|
||||
@@ -3150,6 +3150,18 @@
|
||||
"overdue": "Vençuda"
|
||||
}
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Algú",
|
||||
"shared": "{name} ha compartit amb tu {kind} \"{object}\"",
|
||||
"changed": "{name} ha canviat el teu accés a {kind} \"{object}\"",
|
||||
"revoked": "{name} ha retirat el teu accés a {kind} \"{object}\"",
|
||||
"object": {
|
||||
"folder": "la carpeta",
|
||||
"calendar": "el calendari",
|
||||
"address_book": "la llibreta d'adreces",
|
||||
"files": "la carpeta de fitxers"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Comparteix «{name}»",
|
||||
"description": "Concediu accés a altres usuaris o grups d'aquest servidor. Els canvis s'apliquen immediatament.",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Vyhledávání není ve sjednoceném zobrazení k dispozici"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Někdo",
|
||||
"shared": "{name} s vámi sdílí {kind} „{object}“",
|
||||
"changed": "{name} změnil(a) váš přístup k {kind} „{object}“",
|
||||
"revoked": "{name} odebral(a) váš přístup k {kind} „{object}“",
|
||||
"object": {
|
||||
"folder": "složku",
|
||||
"calendar": "kalendář",
|
||||
"address_book": "adresář",
|
||||
"files": "složku souborů"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Sdílet „{name}\"",
|
||||
"description": "Udělte přístup dalším uživatelům nebo skupinám na tomto serveru. Změny se projeví okamžitě.",
|
||||
|
||||
@@ -3150,6 +3150,18 @@
|
||||
},
|
||||
"nav_open_menu": "Åbn menu"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Nogen",
|
||||
"shared": "{name} har delt {kind} \"{object}\" med dig",
|
||||
"changed": "{name} har ændret din adgang til {kind} \"{object}\"",
|
||||
"revoked": "{name} har fjernet din adgang til {kind} \"{object}\"",
|
||||
"object": {
|
||||
"folder": "mappen",
|
||||
"calendar": "kalenderen",
|
||||
"address_book": "adressebogen",
|
||||
"files": "filmappen"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Del \"{name}\"",
|
||||
"description": "Giv adgang til andre brugere eller grupper på denne server. Ændringer træder i kraft øjeblikkeligt.",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Jemand",
|
||||
"shared": "{name} hat {kind} „{object}“ mit dir geteilt",
|
||||
"changed": "{name} hat deinen Zugriff auf {kind} „{object}“ geändert",
|
||||
"revoked": "{name} hat deinen Zugriff auf {kind} „{object}“ entfernt",
|
||||
"object": {
|
||||
"folder": "den Ordner",
|
||||
"calendar": "den Kalender",
|
||||
"address_book": "das Adressbuch",
|
||||
"files": "den Dateiordner"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "„{name}\" freigeben",
|
||||
"description": "Anderen Benutzern oder Gruppen auf diesem Server Zugriff gewähren. Änderungen werden sofort wirksam.",
|
||||
|
||||
@@ -3150,6 +3150,18 @@
|
||||
"overdue": "Overdue"
|
||||
}
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Someone",
|
||||
"shared": "{name} shared the {kind} \"{object}\" with you",
|
||||
"changed": "{name} changed your access to the {kind} \"{object}\"",
|
||||
"revoked": "{name} removed your access to the {kind} \"{object}\"",
|
||||
"object": {
|
||||
"folder": "folder",
|
||||
"calendar": "calendar",
|
||||
"address_book": "address book",
|
||||
"files": "file folder"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Share \"{name}\"",
|
||||
"description": "Grant access to other users or groups on this server. Changes take effect immediately.",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "La búsqueda no está disponible en la vista unificada"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Alguien",
|
||||
"shared": "{name} ha compartido contigo {kind} \"{object}\"",
|
||||
"changed": "{name} ha cambiado tu acceso a {kind} \"{object}\"",
|
||||
"revoked": "{name} ha retirado tu acceso a {kind} \"{object}\"",
|
||||
"object": {
|
||||
"folder": "la carpeta",
|
||||
"calendar": "el calendario",
|
||||
"address_book": "la libreta de direcciones",
|
||||
"files": "la carpeta de archivos"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Compartir «{name}»",
|
||||
"description": "Concede acceso a otros usuarios o grupos de este servidor. Los cambios surten efecto inmediatamente.",
|
||||
|
||||
@@ -3150,6 +3150,18 @@
|
||||
"overdue": "عقبافتاده"
|
||||
}
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "کسی",
|
||||
"shared": "{name} {kind} «{object}» را با شما به اشتراک گذاشت",
|
||||
"changed": "{name} دسترسی شما به {kind} «{object}» را تغییر داد",
|
||||
"revoked": "{name} دسترسی شما به {kind} «{object}» را حذف کرد",
|
||||
"object": {
|
||||
"folder": "پوشه",
|
||||
"calendar": "تقویم",
|
||||
"address_book": "دفترچه آدرس",
|
||||
"files": "پوشه فایل"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "اشتراکگذاری \"{name}\"",
|
||||
"description": "دسترسی به سایر کاربران یا گروههای این سرور را فراهم کنید.",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "La recherche n'est pas disponible dans la vue unifiée"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Quelqu'un",
|
||||
"shared": "{name} a partagé {kind} « {object} » avec vous",
|
||||
"changed": "{name} a modifié votre accès à {kind} « {object} »",
|
||||
"revoked": "{name} a retiré votre accès à {kind} « {object} »",
|
||||
"object": {
|
||||
"folder": "le dossier",
|
||||
"calendar": "l'agenda",
|
||||
"address_book": "le carnet d'adresses",
|
||||
"files": "le dossier de fichiers"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Partager « {name} »",
|
||||
"description": "Accordez l'accès à d'autres utilisateurs ou groupes de ce serveur. Les modifications sont immédiates.",
|
||||
|
||||
@@ -3450,6 +3450,18 @@
|
||||
"toast_error_delete_has_children": "לתיקייה יש תת־תיקיות. הסר אותן קודם.",
|
||||
"toast_error_delete_has_email": "התיקייה אינה ריקה. תרוקנה קודם."
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "מישהו",
|
||||
"shared": "{name} שיתף/ה איתך את {kind} \"{object}\"",
|
||||
"changed": "{name} שינה/תה את הגישה שלך ל{kind} \"{object}\"",
|
||||
"revoked": "{name} הסיר/ה את הגישה שלך ל{kind} \"{object}\"",
|
||||
"object": {
|
||||
"folder": "התיקייה",
|
||||
"calendar": "היומן",
|
||||
"address_book": "פנקס הכתובות",
|
||||
"files": "תיקיית הקבצים"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "שתף \"{name}\"",
|
||||
"description": "הענק גישה למשתמשים או קבוצות אחרות בשרת זה. השינויים יופעלו מיד.",
|
||||
|
||||
@@ -3150,6 +3150,18 @@
|
||||
"overdue": "Lejárt"
|
||||
}
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Valaki",
|
||||
"shared": "{name} megosztotta veled a(z) {kind} „{object}” elemet",
|
||||
"changed": "{name} módosította a hozzáférésedet a(z) {kind} „{object}” elemhez",
|
||||
"revoked": "{name} visszavonta a hozzáférésedet a(z) {kind} „{object}” elemhez",
|
||||
"object": {
|
||||
"folder": "mappa",
|
||||
"calendar": "naptár",
|
||||
"address_book": "címjegyzék",
|
||||
"files": "fájlmappa"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "\"{name}\" megosztása",
|
||||
"description": "Hozzáférés biztosítása más felhasználók vagy csoportok számára ezen a szerveren. A módosítások azonnal életbe lépnek.",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "La ricerca non è disponibile nella vista unificata"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Qualcuno",
|
||||
"shared": "{name} ha condiviso con te {kind} \"{object}\"",
|
||||
"changed": "{name} ha modificato il tuo accesso a {kind} \"{object}\"",
|
||||
"revoked": "{name} ha rimosso il tuo accesso a {kind} \"{object}\"",
|
||||
"object": {
|
||||
"folder": "la cartella",
|
||||
"calendar": "il calendario",
|
||||
"address_book": "la rubrica",
|
||||
"files": "la cartella dei file"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Condividi \"{name}\"",
|
||||
"description": "Concedi l'accesso ad altri utenti o gruppi su questo server. Le modifiche hanno effetto immediato.",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "統合ビューでは検索を利用できません"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "誰か",
|
||||
"shared": "{name} さんが{kind}「{object}」を共有しました",
|
||||
"changed": "{name} さんが{kind}「{object}」へのアクセス権を変更しました",
|
||||
"revoked": "{name} さんが{kind}「{object}」へのアクセス権を削除しました",
|
||||
"object": {
|
||||
"folder": "フォルダー",
|
||||
"calendar": "カレンダー",
|
||||
"address_book": "アドレス帳",
|
||||
"files": "ファイルフォルダー"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "「{name}」を共有",
|
||||
"description": "このサーバー上の他のユーザーまたはグループにアクセス権を付与します。変更はすぐに反映されます。",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "누군가",
|
||||
"shared": "{name}님이 {kind} \"{object}\"을(를) 공유했습니다",
|
||||
"changed": "{name}님이 {kind} \"{object}\"에 대한 접근 권한을 변경했습니다",
|
||||
"revoked": "{name}님이 {kind} \"{object}\"에 대한 접근 권한을 제거했습니다",
|
||||
"object": {
|
||||
"folder": "폴더",
|
||||
"calendar": "캘린더",
|
||||
"address_book": "주소록",
|
||||
"files": "파일 폴더"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "\"{name}\" 공유",
|
||||
"description": "이 서버의 다른 사용자나 그룹에 액세스 권한을 부여합니다. 변경 사항은 즉시 적용됩니다.",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Meklēšana nav pieejama apvienotajā skatā"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Kāds",
|
||||
"shared": "{name} kopīgoja ar jums {kind} “{object}”",
|
||||
"changed": "{name} mainīja jūsu piekļuvi {kind} “{object}”",
|
||||
"revoked": "{name} noņēma jūsu piekļuvi {kind} “{object}”",
|
||||
"object": {
|
||||
"folder": "mapi",
|
||||
"calendar": "kalendāru",
|
||||
"address_book": "adrešu grāmatu",
|
||||
"files": "failu mapi"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Kopīgot \"{name}\"",
|
||||
"description": "Piešķiriet piekļuvi citiem lietotājiem vai grupām šajā serverī. Izmaiņas stājas spēkā nekavējoties.",
|
||||
|
||||
@@ -3150,6 +3150,18 @@
|
||||
"overdue": "Хугацаа хэтэрсэн"
|
||||
}
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Хэн нэгэн",
|
||||
"shared": "{name} танд {kind} \"{object}\"-г хуваалцлаа",
|
||||
"changed": "{name} таны {kind} \"{object}\"-д хандах эрхийг өөрчиллөө",
|
||||
"revoked": "{name} таны {kind} \"{object}\"-д хандах эрхийг хаслаа",
|
||||
"object": {
|
||||
"folder": "хавтас",
|
||||
"calendar": "хуанли",
|
||||
"address_book": "хаягийн дэвтэр",
|
||||
"files": "файлын хавтас"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "\"{name}\"-г хуваалцаарай",
|
||||
"description": "Энэ сервер дээрх бусад хэрэглэгчид эсвэл бүлгүүдэд хандах эрх олгох. Өөрчлөлтүүд шууд хүчин төгөлдөр болно.",
|
||||
|
||||
@@ -3148,6 +3148,18 @@
|
||||
"overdue": "Forfalt"
|
||||
}
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Noen",
|
||||
"shared": "{name} har delt {kind} «{object}» med deg",
|
||||
"changed": "{name} har endret tilgangen din til {kind} «{object}»",
|
||||
"revoked": "{name} har fjernet tilgangen din til {kind} «{object}»",
|
||||
"object": {
|
||||
"folder": "mappen",
|
||||
"calendar": "kalenderen",
|
||||
"address_book": "adresseboken",
|
||||
"files": "filmappen"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Del «{name}»",
|
||||
"description": "Gi andre brukere eller grupper på denne serveren tilgang. Endringer trer i kraft umiddelbart.",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Iemand",
|
||||
"shared": "{name} heeft {kind} \"{object}\" met je gedeeld",
|
||||
"changed": "{name} heeft je toegang tot {kind} \"{object}\" gewijzigd",
|
||||
"revoked": "{name} heeft je toegang tot {kind} \"{object}\" ingetrokken",
|
||||
"object": {
|
||||
"folder": "de map",
|
||||
"calendar": "de agenda",
|
||||
"address_book": "het adresboek",
|
||||
"files": "de bestandsmap"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "\"{name}\" delen",
|
||||
"description": "Geef andere gebruikers of groepen op deze server toegang. Wijzigingen zijn direct van kracht.",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Ktoś",
|
||||
"shared": "{name} udostępnił(a) Ci {kind} „{object}”",
|
||||
"changed": "{name} zmienił(a) Twój dostęp do {kind} „{object}”",
|
||||
"revoked": "{name} odebrał(a) Ci dostęp do {kind} „{object}”",
|
||||
"object": {
|
||||
"folder": "folder",
|
||||
"calendar": "kalendarz",
|
||||
"address_book": "książkę adresową",
|
||||
"files": "folder plików"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Udostępnij „{name}\"",
|
||||
"description": "Udziel dostępu innym użytkownikom lub grupom na tym serwerze. Zmiany są natychmiastowe.",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "A pesquisa não está disponível na vista unificada"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Alguém",
|
||||
"shared": "{name} partilhou consigo {kind} \"{object}\"",
|
||||
"changed": "{name} alterou o seu acesso a {kind} \"{object}\"",
|
||||
"revoked": "{name} removeu o seu acesso a {kind} \"{object}\"",
|
||||
"object": {
|
||||
"folder": "a pasta",
|
||||
"calendar": "o calendário",
|
||||
"address_book": "o livro de endereços",
|
||||
"files": "a pasta de ficheiros"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Compartilhar \"{name}\"",
|
||||
"description": "Conceda acesso a outros usuários ou grupos neste servidor. As alterações têm efeito imediato.",
|
||||
|
||||
@@ -3150,6 +3150,18 @@
|
||||
"overdue": "Restant"
|
||||
}
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Cineva",
|
||||
"shared": "{name} a partajat cu tine {kind} „{object}”",
|
||||
"changed": "{name} ți-a modificat accesul la {kind} „{object}”",
|
||||
"revoked": "{name} ți-a retras accesul la {kind} „{object}”",
|
||||
"object": {
|
||||
"folder": "dosarul",
|
||||
"calendar": "calendarul",
|
||||
"address_book": "agenda de contacte",
|
||||
"files": "dosarul de fișiere"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Distribuie „{name}”",
|
||||
"description": "Acordați acces altor utilizatori sau grupuri de pe acest server. Modificările intră în vigoare imediat.",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Поиск недоступен в объединённом представлении"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Кто-то",
|
||||
"shared": "{name} поделился(ась) с вами {kind} «{object}»",
|
||||
"changed": "{name} изменил(а) ваш доступ к {kind} «{object}»",
|
||||
"revoked": "{name} отозвал(а) ваш доступ к {kind} «{object}»",
|
||||
"object": {
|
||||
"folder": "папкой",
|
||||
"calendar": "календарём",
|
||||
"address_book": "адресной книгой",
|
||||
"files": "папкой файлов"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Поделиться «{name}»",
|
||||
"description": "Предоставьте доступ другим пользователям или группам на этом сервере. Изменения вступают в силу немедленно.",
|
||||
|
||||
@@ -3150,6 +3150,18 @@
|
||||
"overdue": "Po termíne"
|
||||
}
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Niekto",
|
||||
"shared": "{name} s vami zdieľa {kind} „{object}“",
|
||||
"changed": "{name} zmenil(a) váš prístup k {kind} „{object}“",
|
||||
"revoked": "{name} odobral(a) váš prístup k {kind} „{object}“",
|
||||
"object": {
|
||||
"folder": "priečinok",
|
||||
"calendar": "kalendár",
|
||||
"address_book": "adresár",
|
||||
"files": "priečinok súborov"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Zdieľať \"{name}\"",
|
||||
"description": "Udeľte prístup ďalším používateľom alebo skupinám na tomto serveri. Zmeny sa prejavia okamžite.",
|
||||
|
||||
@@ -3150,6 +3150,18 @@
|
||||
},
|
||||
"nav_open_menu": "Menüyü aç"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Birisi",
|
||||
"shared": "{name}, \"{object}\" {kind} sizinle paylaştı",
|
||||
"changed": "{name}, \"{object}\" {kind} erişiminizi değiştirdi",
|
||||
"revoked": "{name}, \"{object}\" {kind} erişiminizi kaldırdı",
|
||||
"object": {
|
||||
"folder": "klasörünü",
|
||||
"calendar": "takvimini",
|
||||
"address_book": "adres defterini",
|
||||
"files": "dosya klasörünü"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "\"{name}\" paylaş",
|
||||
"description": "Bu sunucudaki diğer kullanıcılara veya gruplara erişim verin. Değişiklikler hemen geçerli olur.",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Пошук недоступний в об'єднаному перегляді"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Хтось",
|
||||
"shared": "{name} поділився(лася) з вами {kind} «{object}»",
|
||||
"changed": "{name} змінив(ла) ваш доступ до {kind} «{object}»",
|
||||
"revoked": "{name} скасував(ла) ваш доступ до {kind} «{object}»",
|
||||
"object": {
|
||||
"folder": "текою",
|
||||
"calendar": "календарем",
|
||||
"address_book": "адресною книгою",
|
||||
"files": "текою файлів"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Поділитися «{name}»",
|
||||
"description": "Надайте доступ іншим користувачам або групам на цьому сервері. Зміни набувають чинності негайно.",
|
||||
|
||||
@@ -3143,6 +3143,18 @@
|
||||
"overdue": "逾期"
|
||||
}
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "有人",
|
||||
"shared": "{name} 與您共用了{kind}「{object}」",
|
||||
"changed": "{name} 變更了您對{kind}「{object}」的存取權限",
|
||||
"revoked": "{name} 移除了您對{kind}「{object}」的存取權限",
|
||||
"object": {
|
||||
"folder": "資料夾",
|
||||
"calendar": "行事曆",
|
||||
"address_book": "通訊錄",
|
||||
"files": "檔案資料夾"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "分享「{name}」",
|
||||
"description": "授予此伺服器上的其他使用者或群組存取權。變更會立即生效。",
|
||||
|
||||
@@ -3452,6 +3452,18 @@
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "统一视图中无法使用搜索"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "有人",
|
||||
"shared": "{name} 与您共享了{kind}“{object}”",
|
||||
"changed": "{name} 更改了您对{kind}“{object}”的访问权限",
|
||||
"revoked": "{name} 移除了您对{kind}“{object}”的访问权限",
|
||||
"object": {
|
||||
"folder": "文件夹",
|
||||
"calendar": "日历",
|
||||
"address_book": "通讯录",
|
||||
"files": "文件目录"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "共享「{name}」",
|
||||
"description": "向此服务器上的其他用户或群组授予访问权限。更改会立即生效。",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useShareNotificationStore } from '../share-notification-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { ShareNotification } from '@/lib/jmap/types';
|
||||
|
||||
const notification = (id: string): ShareNotification => ({
|
||||
id,
|
||||
created: '2026-08-29T10:00:00Z',
|
||||
changedBy: { name: 'Alice', email: 'alice@example.com', principalId: 'p1' },
|
||||
objectType: 'Mailbox',
|
||||
objectAccountId: 'owner',
|
||||
objectId: 'f1',
|
||||
name: 'Projects',
|
||||
oldRights: null,
|
||||
newRights: { mayReadItems: true },
|
||||
});
|
||||
|
||||
function makeClient(list: ShareNotification[]) {
|
||||
return {
|
||||
supportsShareNotifications: () => true,
|
||||
getShareNotifications: vi.fn(async () => list),
|
||||
destroyShareNotifications: vi.fn(async () => undefined),
|
||||
} as unknown as IJMAPClient & { getShareNotifications: ReturnType<typeof vi.fn>; destroyShareNotifications: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
describe('share notification store', () => {
|
||||
beforeEach(() => {
|
||||
useShareNotificationStore.getState().reset();
|
||||
});
|
||||
|
||||
it('queues fetched notifications once and acknowledges them on the server', async () => {
|
||||
const client = makeClient([notification('n1'), notification('n2')]);
|
||||
const store = useShareNotificationStore.getState();
|
||||
|
||||
await store.fetch(client);
|
||||
expect(useShareNotificationStore.getState().pending.map((n) => n.id)).toEqual(['n1', 'n2']);
|
||||
|
||||
// A second fetch returning the same ids (not yet destroyed) adds nothing.
|
||||
await store.fetch(client);
|
||||
expect(useShareNotificationStore.getState().pending).toHaveLength(2);
|
||||
|
||||
await store.acknowledge(client, ['n1']);
|
||||
expect(useShareNotificationStore.getState().pending.map((n) => n.id)).toEqual(['n2']);
|
||||
expect(client.destroyShareNotifications).toHaveBeenCalledWith(['n1']);
|
||||
});
|
||||
|
||||
it('does nothing on a client without share notifications', async () => {
|
||||
const client = { supportsShareNotifications: () => false } as unknown as IJMAPClient;
|
||||
await useShareNotificationStore.getState().fetch(client);
|
||||
expect(useShareNotificationStore.getState().pending).toEqual([]);
|
||||
});
|
||||
|
||||
it('coalesces overlapping fetches', async () => {
|
||||
const client = makeClient([notification('n1')]);
|
||||
const store = useShareNotificationStore.getState();
|
||||
await Promise.all([store.fetch(client), store.fetch(client)]);
|
||||
expect(client.getShareNotifications).toHaveBeenCalledTimes(1);
|
||||
expect(useShareNotificationStore.getState().pending).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -3522,6 +3522,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
}
|
||||
|
||||
// Someone changed this user's access to one of their collections
|
||||
// (RFC 9670 ShareNotification): pull the notifications, the toaster
|
||||
// shows them and refreshes the affected list.
|
||||
if (Object.values(change.changed).some((c) => c?.ShareNotification)) {
|
||||
const { useShareNotificationStore } = await import('./share-notification-store');
|
||||
void useShareNotificationStore.getState().fetch(client);
|
||||
}
|
||||
|
||||
// Handle SieveScript state changes - refresh filter rules
|
||||
if (accountChanges?.SieveScript) {
|
||||
const { useFilterStore } = await import('./filter-store');
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { create } from "zustand";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { ShareNotification } from "@/lib/jmap/types";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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: [] });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user