Files
webmail/components/settings/shared-tag-settings.tsx
T
Linus Rath 33ca8efb0f fix: keep shared tags safe when members edit at once or the server fails
The first version wrote the whole tag list on every change, guessed a
group when the server wouldn't say, and treated a failed read like a
server without metadata. Now:

- Definitions are a map by tag id under `tags`, and each change patches
  one tag (`metadata/org.bulwarkmail.tags/tags/<id>`), so two members
  adding tags at once both keep theirs. Fields and colors this version
  doesn't know survive. The `{ list }` and bare-array shapes are still
  read and converted by the first save.
- Shared tags appear only for confirmed group principals. A failed
  principal lookup keeps the user's own tags and asks again later.
- "No metadata advertised" (0.16) and "the read failed" are separate.
  A failed read keeps the last good tags; a failed save keeps the change,
  says so, and sends it with the next good read after the client's
  pause.
- Push reloads wait for 5 s of quiet and skip the echo of our own save;
  a returning tab reloads definitions only, and the scan for unnamed tags
  runs once per sign-in and then at most every 30 minutes.
- The editor names its buttons after the tag, shows the delete warning
  and 44 px buttons on phones, returns focus to the edit button, and
  asks for Inbox rights only for confirmed groups.
2026-10-28 08:58:11 +01:00

379 lines
16 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Check, Loader2, Pencil, Plus, Trash2, Users, X } from "@/components/icons";
import { TagBadge } from "@/components/email/tag-badge";
import { useAccountTags, TagScopeContext, clientForLogin, type TagScope } from "@/hooks/use-account-tags";
import { useRestriction } from "@/hooks/use-restriction";
import { MetadataTooLargeError } from "@/lib/jmap/bulwark-metadata";
import type { MailboxRights } from "@/lib/jmap/types";
import { composeKeywordId, MAX_KEYWORD_ID_LENGTH } from "@/lib/keyword-nesting";
import { MAX_SHARED_TAG_LABEL, groupAccountTags, type SharedTagChange } from "@/lib/shared-tags";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { KEYWORD_PALETTE, useSettingsStore, type KeywordDefinition } from "@/stores/settings-store";
import { SharedTagsSaveDeferredError, saveSharedTags, sharedTagsKey, useSharedTagsStore } from "@/stores/shared-tags-store";
import { toast } from "@/stores/toast-store";
import { toUnicodeEmail } from "@/lib/idn";
import { KeywordColorPicker } from "./keyword-settings";
import { OrgLockedNote } from "./org-locked-note";
import { SettingsSection } from "./settings-section";
/** Whether the rights on a group's Inbox let the user change its metadata. */
export function maySetSharedTags(rights: Partial<MailboxRights> | null | undefined): boolean {
if (!rights) return false;
// Stalwart 1.0 names the right; a server that doesn't counts renaming.
return typeof rights.maySetMetadata === "boolean" ? rights.maySetMetadata : rights.mayRename === true;
}
/**
* The user's rights on the Inbox of a confirmed group account, from the
* folder list or asked once. Nothing is asked for any other account.
*/
function useInboxRights(scope: TagScope, enabled: boolean): Partial<MailboxRights> | null | undefined {
const fromStore = useEmailStore((state) =>
state.mailboxes.find((mailbox) => mailbox.accountId === scope.accountId && mailbox.role === "inbox")?.myRights);
const [fetched, setFetched] = useState<Partial<MailboxRights> | null | undefined>(undefined);
useEffect(() => {
if (!enabled || fromStore) return;
let cancelled = false;
const client = clientForLogin(scope.loginId) as unknown as { getMailboxes?: (accountId?: string) => Promise<Array<{ role?: string; myRights?: MailboxRights }>> } | null;
if (!client?.getMailboxes) return;
client.getMailboxes(scope.accountId)
.then((mailboxes) => { if (!cancelled) setFetched(mailboxes.find((m) => m.role === "inbox")?.myRights ?? null); })
.catch(() => { if (!cancelled) setFetched(null); });
return () => { cancelled = true; };
}, [enabled, fromStore, scope.loginId, scope.accountId]);
return enabled ? fromStore ?? fetched : undefined;
}
function SharedTagForm({
initial,
fixedId,
takenIds,
saving,
onSave,
onCancel,
}: {
initial?: KeywordDefinition;
/** The id stays: an existing tag or one found on messages. */
fixedId?: string;
takenIds: string[];
saving: boolean;
/** `color` only when it was picked (or the tag is new), so a color this version doesn't know survives a rename. */
onSave: (id: string, value: { label: string; color?: string }) => void;
onCancel: () => void;
}) {
const t = useTranslations("shared_tags");
const [label, setLabel] = useState(initial?.label ?? "");
const [color, setColor] = useState(initial?.color ?? "blue");
const [colorPicked, setColorPicked] = useState(false);
const id = fixedId ?? composeKeywordId(null, label);
const duplicate = !fixedId && id.length > 0 && takenIds.includes(id.toLowerCase());
const tooLong = !fixedId && id.length > MAX_KEYWORD_ID_LENGTH;
const valid = label.trim().length > 0 && id.length > 0 && !duplicate && !tooLong;
const keepsColor = !!initial && !colorPicked;
const save = () => {
if (valid && !saving) onSave(id, keepsColor ? { label: label.trim() } : { label: label.trim(), color });
};
return (
<div className="space-y-3 rounded-md border border-primary/30 bg-accent/30 p-3" data-testid="shared-tag-form">
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground" htmlFor={`shared-tag-label-${fixedId ?? "new"}`}>
{t("name")}
</label>
<input
id={`shared-tag-label-${fixedId ?? "new"}`}
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={MAX_SHARED_TAG_LABEL}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") { e.stopPropagation(); onCancel(); }
}}
className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{duplicate && <p className="mt-1 text-xs text-destructive">{t("exists")}</p>}
{tooLong && <p className="mt-1 text-xs text-destructive">{t("too_long")}</p>}
</div>
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">{t("color")}</p>
<KeywordColorPicker value={color} onChange={(next) => { setColor(next); setColorPicked(true); }} />
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="flex min-h-[44px] items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted sm:min-h-0"
>
<X className="h-3.5 w-3.5" />
{t("cancel")}
</button>
<button
type="button"
onClick={save}
disabled={!valid || saving}
className="flex min-h-[44px] items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 sm:min-h-0"
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
{t("save")}
</button>
</div>
</div>
);
}
const ICON_BUTTON = "grid h-11 w-11 place-items-center rounded-md text-muted-foreground hover:bg-muted sm:h-8 sm:w-8";
/**
* The shared tags of one group account. Read-only with "Set by your
* organisation" when the admin locked `sharedTags`, read-only with a note
* when the user may not change the group's Inbox, and a one-line note
* instead of the editor when the server keeps no metadata or couldn't be
* read.
*/
function SharedAccountTagEditor({ scope, name }: { scope: TagScope; name: string }) {
const t = useTranslations("shared_tags");
const key = sharedTagsKey(scope.loginId, scope.accountId);
const { status } = useAccountTags(scope);
const entry = useSharedTagsStore((state) => state.entries[key]);
const nested = useSettingsStore((state) => state.nestedTags);
const { locked } = useRestriction("sharedTags");
const rights = useInboxRights(scope, status === "ready");
const canWrite = maySetSharedTags(rights);
const editable = !locked && canWrite;
const [editing, setEditing] = useState<{ id?: string } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [focusId, setFocusId] = useState<string | null>(null);
const editButtons = useRef(new Map<string, HTMLButtonElement>());
const { shared, discovered } = useMemo(() => {
if (status !== "ready" || !entry) return { shared: [] as KeywordDefinition[], discovered: [] as KeywordDefinition[] };
const { definitions, discoveredIds } = groupAccountTags(entry.tags, entry.discovered, nested);
const found = new Set(discoveredIds);
return { shared: entry.tags, discovered: definitions.filter((tag) => found.has(tag.id)) };
}, [status, entry, nested]);
const takenIds = useMemo(() => [...shared, ...discovered].map((tag) => tag.id.toLowerCase()), [shared, discovered]);
// Back to the row's edit button once a change is saved.
useEffect(() => {
if (!focusId) return;
editButtons.current.get(focusId)?.focus();
setFocusId(null);
}, [focusId, shared]);
if (status === "personal") return null;
const save = async (change: SharedTagChange) => {
const client = clientForLogin(scope.loginId);
if (!client) return false;
setSaving(true);
try {
await saveSharedTags(key, scope.accountId, client, [change]);
return true;
} catch (error) {
if (error instanceof SharedTagsSaveDeferredError) {
// Kept and shown; sent again when the server answers.
toast.warning(t("save_failed"));
return true;
}
toast.error(error instanceof MetadataTooLargeError ? t("too_many") : t("save_failed"));
return false;
} finally {
setSaving(false);
}
};
const heading = (
<div className="flex min-w-0 items-center gap-2">
<Users className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="truncate text-sm font-medium text-foreground">{toUnicodeEmail(name)}</span>
</div>
);
if (status === "loading") {
return (
<div className="space-y-2" data-testid="shared-tags-account" data-status="loading">
{heading}
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("loading")}
</p>
</div>
);
}
if (status === "unsupported" || status === "unavailable") {
return (
<div className="space-y-2" data-testid="shared-tags-account" data-status={status}>
{heading}
<p className="text-xs text-muted-foreground" data-testid={`shared-tags-${status}`}>{t(status)}</p>
</div>
);
}
const row = (tag: KeywordDefinition, found: boolean) => {
if (editing && editing.id === tag.id) {
const raw = entry?.stored?.entries[tag.id];
// The form shows the stored color when this version knows it.
const initial = { ...tag, color: typeof raw?.color === "string" && KEYWORD_PALETTE[raw.color] ? raw.color : tag.color };
return (
<SharedTagForm
key={tag.id}
initial={initial}
fixedId={tag.id}
takenIds={takenIds}
saving={saving}
onCancel={() => { setEditing(null); setFocusId(tag.id); }}
onSave={async (id, value) => {
const next = found ? { label: value.label, color: value.color ?? tag.color } : value;
if (await save({ id, value: next })) { setEditing(null); setFocusId(id); }
}}
/>
);
}
const confirming = editable && confirmDelete === tag.id;
return (
<div
key={tag.id}
className="flex min-h-[44px] flex-wrap items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 sm:min-h-0 sm:flex-nowrap"
data-testid="shared-tag-row"
data-tag-id={tag.id}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<TagBadge tagId={tag.id} variant="badge" className="text-xs" />
{found && <span className="truncate text-xs text-muted-foreground">{t("found_on_messages")}</span>}
</div>
{confirming ? (
<div className="flex basis-full flex-wrap items-center justify-end gap-1 sm:basis-auto sm:flex-nowrap" data-testid="shared-tag-delete-confirm">
<span className="text-xs text-muted-foreground">{t("delete_confirm")}</span>
<button
type="button"
onClick={async () => { if (await save({ id: tag.id, value: null })) setConfirmDelete(null); }}
className="min-h-[44px] rounded-md px-3 py-1 text-xs text-destructive hover:bg-destructive/10 sm:min-h-0"
>
{t("delete")}
</button>
<button
type="button"
onClick={() => { setConfirmDelete(null); setFocusId(tag.id); }}
className="min-h-[44px] rounded-md px-3 py-1 text-xs hover:bg-muted sm:min-h-0"
>
{t("cancel")}
</button>
</div>
) : editable && (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
ref={(el) => { if (el) editButtons.current.set(tag.id, el); else editButtons.current.delete(tag.id); }}
onClick={() => { setEditing({ id: tag.id }); setConfirmDelete(null); }}
className={cn(ICON_BUTTON, "hover:text-foreground")}
title={found ? t("name_it", { name: tag.id }) : t("edit_tag", { name: tag.label })}
aria-label={found ? t("name_it", { name: tag.id }) : t("edit_tag", { name: tag.label })}
>
{found ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
</button>
{!found && (
<button
type="button"
onClick={() => { setConfirmDelete(tag.id); setEditing(null); }}
className={cn(ICON_BUTTON, "hover:text-destructive")}
title={t("delete_tag", { name: tag.label })}
aria-label={t("delete_tag", { name: tag.label })}
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
);
};
return (
<TagScopeContext.Provider value={scope}>
<div className="space-y-2" data-testid="shared-tags-account" data-status="ready" data-editable={editable || undefined}>
{heading}
{shared.length === 0 && discovered.length === 0 && (
<p className="text-xs text-muted-foreground">{t("empty")}</p>
)}
{shared.map((tag) => row(tag, false))}
{discovered.map((tag) => row(tag, true))}
{(entry?.pending.length ?? 0) > 0 && (
<p className="text-xs text-muted-foreground" data-testid="shared-tags-pending">{t("save_pending")}</p>
)}
{locked ? (
<OrgLockedNote />
) : !canWrite ? (
<p className="text-xs text-muted-foreground" data-testid="shared-tags-no-rights">
{rights === undefined ? t("loading") : t("no_rights")}
</p>
) : editing && !editing.id ? (
<SharedTagForm
takenIds={takenIds}
saving={saving}
onCancel={() => setEditing(null)}
onSave={async (id, value) => { if (await save({ id, value })) { setEditing(null); setFocusId(id); } }}
/>
) : (
<button
type="button"
onClick={() => { setEditing({}); setConfirmDelete(null); }}
className={cn(
"flex min-h-[44px] items-center gap-1.5 rounded-md border border-dashed border-border px-3 py-1.5 text-xs text-muted-foreground transition-colors sm:min-h-0",
"hover:border-primary hover:bg-accent hover:text-foreground",
)}
>
<Plus className="h-3.5 w-3.5" />
{t("add")}
</button>
)}
</div>
</TagScopeContext.Provider>
);
}
/**
* Settings > Folders & tags > Shared tags: the tags of each group mailbox
* the user is a member of. Hidden without group accounts. The account list
* is worked out again whenever the folder list changes, which it does after
* every session change.
*/
export function SharedTagSettings() {
const t = useTranslations("shared_tags");
const client = useAuthStore((state) => state.client);
const loginId = useAuthStore((state) => state.activeAccountId);
const mailboxes = useEmailStore((state) => state.mailboxes);
const accounts = useMemo(
() => {
void mailboxes;
return client && typeof client.getSharedAccounts === "function"
? client.getSharedAccounts().filter((account) => !account.isPrimary && account.capabilities.mail && client.getAccountInfo?.(account.id)?.isPersonal === false)
: [];
},
[client, mailboxes],
);
const entries = useSharedTagsStore((state) => state.entries);
if (!loginId || accounts.length === 0) return null;
// Sharing by a person isn't a group: shown only once known not to be one.
const groups = accounts.filter((account) => entries[sharedTagsKey(loginId, account.id)]?.status !== "personal");
return (
<div className={groups.length === 0 ? "hidden" : undefined}>
<SettingsSection id="shared-tags" title={t("title")} description={t("description")}>
<div className="space-y-5" data-testid="shared-tag-settings">
{accounts.map((account) => (
<SharedAccountTagEditor key={account.id} scope={{ loginId, accountId: account.id }} name={account.name} />
))}
</div>
</SettingsSection>
</div>
);
}