"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 | 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 | null | undefined { const fromStore = useEmailStore((state) => state.mailboxes.find((mailbox) => mailbox.accountId === scope.accountId && mailbox.role === "inbox")?.myRights); const [fetched, setFetched] = useState | null | undefined>(undefined); useEffect(() => { if (!enabled || fromStore) return; let cancelled = false; const client = clientForLogin(scope.loginId) as unknown as { getMailboxes?: (accountId?: string) => Promise> } | 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 (
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-hidden" /> {duplicate &&

{t("exists")}

} {tooLong &&

{t("too_long")}

}

{t("color")}

{ setColor(next); setColorPicked(true); }} />
); } 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(null); const [saving, setSaving] = useState(false); const [focusId, setFocusId] = useState(null); const editButtons = useRef(new Map()); 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 = (
); if (status === "loading") { return (
{heading}

{t("loading")}

); } if (status === "unsupported" || status === "unavailable") { return (
{heading}

{t(status)}

); } 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 ( { 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 (
{found && {t("found_on_messages")}}
{confirming ? (
{t("delete_confirm")}
) : editable && (
{!found && ( )}
)}
); }; return (
{heading} {shared.length === 0 && discovered.length === 0 && (

{t("empty")}

)} {shared.map((tag) => row(tag, false))} {discovered.map((tag) => row(tag, true))} {(entry?.pending.length ?? 0) > 0 && (

{t("save_pending")}

)} {locked ? ( ) : !canWrite ? (

{rights === undefined ? t("loading") : t("no_rights")}

) : editing && !editing.id ? ( setEditing(null)} onSave={async (id, value) => { if (await save({ id, value })) { setEditing(null); setFocusId(id); } }} /> ) : ( )}
); } /** * 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 (
{accounts.map((account) => ( ))}
); }