Show names and honest free/busy when the directory is private #84
No files matched your search
@@ -1855,6 +1855,7 @@ export const catalog: Catalog = {
|
||||
"Move {n} items": { one: "{n} Element verschieben", other: "{n} Elemente verschieben" },
|
||||
"Move {n} items…": { one: "{n} Element verschieben…", other: "{n} Elemente verschieben…" },
|
||||
"The event runs {n} days longer than this shows.": { one: "Der Termin dauert {n} Tag länger, als hier angezeigt wird.", other: "Der Termin dauert {n} Tage länger, als hier angezeigt wird." },
|
||||
"No free/busy for {n} guests, because this server keeps its directory private.": { one: "Keine Frei/Gebucht-Informationen für {n} Gast, weil dieser Server sein Verzeichnis nicht freigibt.", other: "Keine Frei/Gebucht-Informationen für {n} Gäste, weil dieser Server sein Verzeichnis nicht freigibt." },
|
||||
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} Gast ist nicht auf diesem Server, daher gibt es dafür keine Frei/Gebucht-Informationen.", other: "{n} Gäste sind nicht auf diesem Server, daher gibt es dafür keine Frei/Gebucht-Informationen." },
|
||||
"{n} items selected": { one: "{n} Element ausgewählt", other: "{n} Elemente ausgewählt" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1828,6 +1828,7 @@ export const catalog: Catalog = {
|
||||
"Move {n} items": { one: "Mover {n} elemento", other: "Mover {n} elementos" },
|
||||
"Move {n} items…": { one: "Mover {n} elemento…", other: "Mover {n} elementos…" },
|
||||
"The event runs {n} days longer than this shows.": { one: "El evento dura {n} día más de lo que se muestra aquí.", other: "El evento dura {n} días más de lo que se muestra aquí." },
|
||||
"No free/busy for {n} guests, because this server keeps its directory private.": { one: "No hay información de libre/ocupado para {n} invitado porque este servidor mantiene privado su directorio.", other: "No hay información de libre/ocupado para {n} invitados porque este servidor mantiene privado su directorio." },
|
||||
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} invitado no está en este servidor, así que no hay información de libre/ocupado para él.", other: "{n} invitados no están en este servidor, así que no hay información de libre/ocupado para ellos." },
|
||||
"{n} items selected": { one: "{n} elemento seleccionado", other: "{n} elementos seleccionados" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1833,6 +1833,7 @@ export const catalog: Catalog = {
|
||||
"Move {n} items": { one: "Déplacer {n} élément", other: "Déplacer {n} éléments" },
|
||||
"Move {n} items…": { one: "Déplacer {n} élément…", other: "Déplacer {n} éléments…" },
|
||||
"The event runs {n} days longer than this shows.": { one: "L'événement dure {n} jour de plus que ce qui est affiché ici.", other: "L'événement dure {n} jours de plus que ce qui est affiché ici." },
|
||||
"No free/busy for {n} guests, because this server keeps its directory private.": { one: "Pas de disponibilité pour {n} invité, car ce serveur garde son annuaire privé.", other: "Pas de disponibilité pour {n} invités, car ce serveur garde son annuaire privé." },
|
||||
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} invité n'est pas sur ce serveur, il n'y a donc pas de disponibilité à lire pour lui.", other: "{n} invités ne sont pas sur ce serveur, il n'y a donc pas de disponibilité à lire pour eux." },
|
||||
"{n} items selected": { one: "{n} élément sélectionné", other: "{n} éléments sélectionnés" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1836,6 +1836,7 @@ export const catalog: Catalog = {
|
||||
"Move {n} items": { other: "{n} 件を移動" },
|
||||
"Move {n} items…": { other: "{n} 件を移動…" },
|
||||
"The event runs {n} days longer than this shows.": { other: "この予定は表示よりも {n} 日長く続きます。" },
|
||||
"No free/busy for {n} guests, because this server keeps its directory private.": { other: "このサーバーはディレクトリを非公開にしているため、{n} 名の参加者の空き情報を取得できません。" },
|
||||
"{n} guests are not on this server, so there is no free/busy to read for them.": { other: "{n} 名の参加者はこのサーバーにいないため、空き情報を取得できません。" },
|
||||
"{n} items selected": { other: "{n} 件を選択中" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1828,6 +1828,7 @@ export const catalog: Catalog = {
|
||||
"Move {n} items": { one: "{n} item verplaatsen", other: "{n} items verplaatsen" },
|
||||
"Move {n} items…": { one: "{n} item verplaatsen…", other: "{n} items verplaatsen…" },
|
||||
"The event runs {n} days longer than this shows.": { one: "De gebeurtenis duurt {n} dag langer dan hier wordt getoond.", other: "De gebeurtenis duurt {n} dagen langer dan hier wordt getoond." },
|
||||
"No free/busy for {n} guests, because this server keeps its directory private.": { one: "Geen vrij/bezet voor {n} gast, omdat deze server zijn adreslijst privé houdt.", other: "Geen vrij/bezet voor {n} gasten, omdat deze server zijn adreslijst privé houdt." },
|
||||
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} gast zit niet op deze server, dus er is geen vrij/bezet voor die persoon te lezen.", other: "{n} gasten zitten niet op deze server, dus er is geen vrij/bezet voor hen te lezen." },
|
||||
"{n} items selected": { one: "{n} item geselecteerd", other: "{n} items geselecteerd" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1831,6 +1831,7 @@ export const catalog: Catalog = {
|
||||
"Move {n} items": { one: "Mover {n} item", other: "Mover {n} itens" },
|
||||
"Move {n} items…": { one: "Mover {n} item…", other: "Mover {n} itens…" },
|
||||
"The event runs {n} days longer than this shows.": { one: "O evento dura {n} dia a mais do que é exibido aqui.", other: "O evento dura {n} dias a mais do que é exibido aqui." },
|
||||
"No free/busy for {n} guests, because this server keeps its directory private.": { one: "Sem livre/ocupado para {n} convidado, porque este servidor mantém seu diretório privado.", other: "Sem livre/ocupado para {n} convidados, porque este servidor mantém seu diretório privado." },
|
||||
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} convidado não está neste servidor, então não há livre/ocupado a consultar para ele.", other: "{n} convidados não estão neste servidor, então não há livre/ocupado a consultar para eles." },
|
||||
"{n} items selected": { one: "{n} item selecionado", other: "{n} itens selecionados" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1830,6 +1830,7 @@ export const catalog: Catalog = {
|
||||
"Move {n} items": { one: "Переместить {n} объект", few: "Переместить {n} объекта", many: "Переместить {n} объектов", other: "Переместить {n} объекта" },
|
||||
"Move {n} items…": { one: "Переместить {n} объект…", few: "Переместить {n} объекта…", many: "Переместить {n} объектов…", other: "Переместить {n} объекта…" },
|
||||
"The event runs {n} days longer than this shows.": { one: "Событие длится на {n} день дольше, чем показано здесь.", few: "Событие длится на {n} дня дольше, чем показано здесь.", many: "Событие длится на {n} дней дольше, чем показано здесь.", other: "Событие длится на {n} дня дольше, чем показано здесь." },
|
||||
"No free/busy for {n} guests, because this server keeps its directory private.": { one: "Нет сведений о занятости для {n} участника, потому что этот сервер не открывает свой каталог.", few: "Нет сведений о занятости для {n} участников, потому что этот сервер не открывает свой каталог.", many: "Нет сведений о занятости для {n} участников, потому что этот сервер не открывает свой каталог.", other: "Нет сведений о занятости для {n} участника, потому что этот сервер не открывает свой каталог." },
|
||||
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} участник не на этом сервере, поэтому сведений о занятости для него нет.", few: "{n} участника не на этом сервере, поэтому сведений о занятости для них нет.", many: "{n} участников не на этом сервере, поэтому сведений о занятости для них нет.", other: "{n} участника не на этом сервере, поэтому сведений о занятости для них нет." },
|
||||
"{n} items selected": { one: "Выбран {n} объект", few: "Выбрано {n} объекта", many: "Выбрано {n} объектов", other: "Выбрано {n} объекта" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1862,6 +1862,7 @@ export const catalog: Catalog = {
|
||||
one: "{n} grup",
|
||||
other: "{n} grup",
|
||||
},
|
||||
"No free/busy for {n} guests, because this server keeps its directory private.": { one: "Bu sunucu dizinini gizli tuttuğu için {n} konuğun uygunluk durumu okunamıyor.", other: "Bu sunucu dizinini gizli tuttuğu için {n} konuğun uygunluk durumu okunamıyor." },
|
||||
"{n} guests are not on this server, so there is no free/busy to read for them.": {
|
||||
one: "{n} konuk bu sunucuda değil, bu nedenle onun için uygunluk durumu okunamıyor.",
|
||||
other: "{n} konuk bu sunucuda değil, bu nedenle onlar için uygunluk durumu okunamıyor.",
|
||||
|
||||
@@ -1824,6 +1824,7 @@ export const catalog: Catalog = {
|
||||
"Move {n} items": { one: "Перемістити {n} об’єкт", few: "Перемістити {n} об’єкти", many: "Перемістити {n} об’єктів", other: "Перемістити {n} об’єкта" },
|
||||
"Move {n} items…": { one: "Перемістити {n} об’єкт…", few: "Перемістити {n} об’єкти…", many: "Перемістити {n} об’єктів…", other: "Перемістити {n} об’єкта…" },
|
||||
"The event runs {n} days longer than this shows.": { one: "Подія триває на {n} день довше, ніж показано тут.", few: "Подія триває на {n} дні довше, ніж показано тут.", many: "Подія триває на {n} днів довше, ніж показано тут.", other: "Подія триває на {n} дня довше, ніж показано тут." },
|
||||
"No free/busy for {n} guests, because this server keeps its directory private.": { one: "Немає відомостей про зайнятість для {n} гостя, бо цей сервер не відкриває свій каталог.", few: "Немає відомостей про зайнятість для {n} гостей, бо цей сервер не відкриває свій каталог.", many: "Немає відомостей про зайнятість для {n} гостей, бо цей сервер не відкриває свій каталог.", other: "Немає відомостей про зайнятість для {n} гостя, бо цей сервер не відкриває свій каталог." },
|
||||
"{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} гість не на цьому сервері, тому відомостей про зайнятість для нього немає.", few: "{n} гості не на цьому сервері, тому відомостей про зайнятість для них немає.", many: "{n} гостей не на цьому сервері, тому відомостей про зайнятість для них немає.", other: "{n} гостя не на цьому сервері, тому відомостей про зайнятість для них немає." },
|
||||
"{n} items selected": { one: "Вибрано {n} об’єкт", few: "Вибрано {n} об’єкти", many: "Вибрано {n} об’єктів", other: "Вибрано {n} об’єкта" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1835,6 +1835,7 @@ export const catalog: Catalog = {
|
||||
"Move {n} items": { other: "移动 {n} 个项目" },
|
||||
"Move {n} items…": { other: "移动 {n} 个项目…" },
|
||||
"The event runs {n} days longer than this shows.": { other: "此活动比这里显示的时间长 {n} 天。" },
|
||||
"No free/busy for {n} guests, because this server keeps its directory private.": { other: "此服务器未公开其目录,因此无法读取 {n} 位与会者的空闲/忙碌信息。" },
|
||||
"{n} guests are not on this server, so there is no free/busy to read for them.": { other: "有 {n} 位与会者不在此服务器上,因此无法读取他们的空闲/忙碌信息。" },
|
||||
"{n} items selected": { other: "已选择 {n} 个项目" },
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useSession } from "@/store/session";
|
||||
import { CAP, client, JmapMethodError } from "@/jmap/client";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* With directory queries off the server refuses Principal/query, but
|
||||
* Principal/get still answers for the accounts the reader already has to do
|
||||
* with. Those are what put a name on a share and find a sharer's free/busy.
|
||||
*/
|
||||
|
||||
const alice = { id: "c", type: "individual", name: "Alice Tester" };
|
||||
const carol = { id: "e", type: "individual", name: "Carol Sender" };
|
||||
const forbidden = () => new JmapMethodError("Principal/query", { type: "forbidden" });
|
||||
|
||||
const session = {
|
||||
capabilities: { [CAP.core]: {}, [CAP.principals]: {} },
|
||||
accounts: { c: { name: "[email protected]", isPersonal: true, accountCapabilities: { [CAP.principals]: {} } } },
|
||||
primaryAccounts: { [CAP.principals]: "c" },
|
||||
state: "s",
|
||||
} as unknown as JmapSession;
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = session;
|
||||
useSession.setState({ status: "authenticated", session, accountId: "c" });
|
||||
useContacts.setState({ accountId: "c", principals: [], principalsLoaded: false, directoryPrivate: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("loadPrincipals", () => {
|
||||
it("reads the whole directory when the server allows it", async () => {
|
||||
vi.spyOn(client, "chain").mockResolvedValue(new Map([["g", [{ list: [alice, carol] }]]]) as never);
|
||||
const call = vi.spyOn(client, "call");
|
||||
await useContacts.getState().loadPrincipals();
|
||||
const s = useContacts.getState();
|
||||
expect(s.principals).toEqual([alice, carol]);
|
||||
expect(s.directoryPrivate).toBe(false);
|
||||
expect(call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the accounts the reader knows when the query is refused", async () => {
|
||||
vi.spyOn(client, "chain").mockRejectedValue(forbidden());
|
||||
const call = vi.spyOn(client, "call").mockResolvedValue({ list: [alice, carol] } as never);
|
||||
await useContacts.getState().loadPrincipals();
|
||||
const s = useContacts.getState();
|
||||
expect(call).toHaveBeenCalledWith("Principal/get", expect.objectContaining({ ids: null }));
|
||||
expect(s.principals).toEqual([alice, carol]);
|
||||
expect(s.directoryPrivate).toBe(true);
|
||||
expect(s.principalsLoaded).toBe(true);
|
||||
});
|
||||
|
||||
it("stays empty when the fallback is refused too", async () => {
|
||||
vi.spyOn(client, "chain").mockRejectedValue(forbidden());
|
||||
vi.spyOn(client, "call").mockRejectedValue(new JmapMethodError("Principal/get", { type: "forbidden" }));
|
||||
await useContacts.getState().loadPrincipals();
|
||||
const s = useContacts.getState();
|
||||
expect(s.principals).toEqual([]);
|
||||
expect(s.directoryPrivate).toBe(true);
|
||||
expect(s.principalsLoaded).toBe(true);
|
||||
});
|
||||
|
||||
it("does not call a network failure a private directory", async () => {
|
||||
vi.spyOn(client, "chain").mockRejectedValue(new Error("offline"));
|
||||
const call = vi.spyOn(client, "call");
|
||||
await useContacts.getState().loadPrincipals();
|
||||
const s = useContacts.getState();
|
||||
expect(s.directoryPrivate).toBe(false);
|
||||
expect(call).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -182,6 +182,8 @@ interface ContactsState {
|
||||
error: string | null;
|
||||
principals: Principal[];
|
||||
principalsLoaded: boolean;
|
||||
/** The server keeps its directory private, so `principals` is only who the reader already has to do with. */
|
||||
directoryPrivate: boolean;
|
||||
recent: EmailAddress[];
|
||||
/** Address books shared with the reader, from every non-personal account. */
|
||||
sharedBooks: SharedBook[];
|
||||
@@ -264,6 +266,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
error: null,
|
||||
principals: [],
|
||||
principalsLoaded: false,
|
||||
directoryPrivate: false,
|
||||
recent: [],
|
||||
sharedBooks: [],
|
||||
sharedCards: {},
|
||||
@@ -793,15 +796,31 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
set({ principalsLoaded: true });
|
||||
return;
|
||||
}
|
||||
const properties = ["id", "type", "name", "description", "email", "timeZone"];
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["Principal/query", { accountId, limit: 1000 }, "q"],
|
||||
["Principal/get", { accountId, "#ids": { resultOf: "q", name: "Principal/query", path: "/ids" }, properties: ["id", "type", "name", "description", "email", "timeZone"] }, "g"],
|
||||
["Principal/get", { accountId, "#ids": { resultOf: "q", name: "Principal/query", path: "/ids" }, properties }, "g"],
|
||||
]);
|
||||
const g = res.get("g")?.[0] as unknown as GetResponse<Principal>;
|
||||
set({ principals: g.list, principalsLoaded: true });
|
||||
} catch {
|
||||
set({ principalsLoaded: true });
|
||||
} catch (err) {
|
||||
if (!(err instanceof JmapMethodError) || err.type !== "forbidden") {
|
||||
set({ principalsLoaded: true });
|
||||
return;
|
||||
}
|
||||
/*
|
||||
* The administrator has turned directory queries off. Principal/get still
|
||||
* answers for the accounts the reader already has to do with, which is
|
||||
* what puts a name on a share and finds a colleague's free/busy.
|
||||
*/
|
||||
set({ directoryPrivate: true });
|
||||
try {
|
||||
const g = await client.call<GetResponse<Principal>>("Principal/get", { accountId, ids: null, properties });
|
||||
set({ principals: g.list, principalsLoaded: true });
|
||||
} catch {
|
||||
set({ principalsLoaded: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -908,7 +927,7 @@ useSession.subscribe((s) => {
|
||||
}
|
||||
useContacts.setState({ recent });
|
||||
} else {
|
||||
useContacts.setState({ accountId: null, books: {}, cards: {}, loaded: false, principals: [], principalsLoaded: false });
|
||||
useContacts.setState({ accountId: null, books: {}, cards: {}, loaded: false, principals: [], principalsLoaded: false, directoryPrivate: false });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -536,10 +536,16 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
||||
<div className="hint">
|
||||
{known.length === 0
|
||||
? translate("Nobody here has free/busy on this server, so none of these rows can say whether anyone is free.")
|
||||
: plural(unknown.length, {
|
||||
one: "{n} guest is not on this server, so there is no free/busy to read for them.",
|
||||
other: "{n} guests are not on this server, so there is no free/busy to read for them.",
|
||||
})}
|
||||
: contacts.directoryPrivate
|
||||
/* We can't tell who is on this server, so don't say they aren't. */
|
||||
? plural(unknown.length, {
|
||||
one: "No free/busy for {n} guest, because this server keeps its directory private.",
|
||||
other: "No free/busy for {n} guests, because this server keeps its directory private.",
|
||||
})
|
||||
: plural(unknown.length, {
|
||||
one: "{n} guest is not on this server, so there is no free/busy to read for them.",
|
||||
other: "{n} guests are not on this server, so there is no free/busy to read for them.",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useEffect, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { useFiles } from "@/store/files";
|
||||
import { client, setErrorMessage } from "@/jmap/client";
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import { toast } from "@/ui/toast";
|
||||
import type { Id, Principal } from "@/jmap/types";
|
||||
import { t } from "@/lib/i18n";
|
||||
@@ -64,7 +65,9 @@ const PRESETS: Record<Kind, { reader: string[]; editor: string[] }> = {
|
||||
/** Share a mailbox / calendar / address book / file node with other principals (JMAP Sharing, RFC 9670). */
|
||||
export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind; id: Id; name: string; shareWith: Record<Id, object> | null; onClose: () => void }) {
|
||||
const principals = useContacts((s) => s.principals);
|
||||
const directoryPrivate = useContacts((s) => s.directoryPrivate);
|
||||
const loadPrincipals = useContacts((s) => s.loadPrincipals);
|
||||
const selfId = useSession((s) => s.accountFor(CAP.principals));
|
||||
const [rights, setRights] = useState<Record<Id, Record<string, boolean>>>(() => ({ ...((shareWith ?? {}) as Record<Id, Record<string, boolean>>) }));
|
||||
const [pick, setPick] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -72,7 +75,9 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
||||
void loadPrincipals();
|
||||
}, [loadPrincipals]);
|
||||
|
||||
const available = principals.filter((p) => !rights[p.id]);
|
||||
/* A private directory lists the reader too, and sharing with yourself is not "someone to add". */
|
||||
const available = principals.filter((p) => !rights[p.id] && !(directoryPrivate && p.id === selfId));
|
||||
const canPick = directoryPrivate ? available.length > 0 : principals.length > 0;
|
||||
const add = (p: Principal, preset: "reader" | "editor") => {
|
||||
const r: Record<string, boolean> = {};
|
||||
for (const k of PRESETS[kind][preset]) r[k] = true;
|
||||
@@ -110,13 +115,13 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
||||
with directory queries switched off -- which is the default, and which
|
||||
returns no principals -- showed nothing but the hint, and an existing
|
||||
share could not be seen, let alone removed. */}
|
||||
{!principals.length && (
|
||||
{!canPick && (
|
||||
<p className="hint" style={{ marginBottom: 12 }}>
|
||||
|
||||
{t("No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.")}
|
||||
</p>
|
||||
)}
|
||||
{principals.length > 0 && (
|
||||
{canPick && (
|
||||
<>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
|
||||
|
||||
Reference in new issue
Block a user