Merge pull request #82 from LINUXexpert-org/hide-identities
Hide identities from the compose picker
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAlwaysVisible, visibleIdentities } from "@/lib/identityVisibility";
|
||||
|
||||
/**
|
||||
* Issue #73: a unique address per service, on a server with an alias domain,
|
||||
* gives every local part twice and a compose picker nobody can use — while only
|
||||
* a handful are ever sent from.
|
||||
*
|
||||
* The interesting cases are not the hiding. They are the three refusals, all of
|
||||
* which exist because a sender picker with nothing usable in it is worse than a
|
||||
* cluttered one.
|
||||
*/
|
||||
|
||||
const ids = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `i${i + 1}`, email: `a${i + 1}@example.com` }));
|
||||
|
||||
describe("hiding identities from the picker", () => {
|
||||
it("removes the hidden ones", () => {
|
||||
expect(visibleIdentities(ids(4), ["i2", "i4"]).map((i) => i.id)).toEqual(["i1", "i3"]);
|
||||
});
|
||||
|
||||
it("changes nothing when none are hidden", () => {
|
||||
const all = ids(3);
|
||||
expect(visibleIdentities(all, [])).toBe(all);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses to hide", () => {
|
||||
it("keeps the identity the draft is already using", () => {
|
||||
// Otherwise the select has no matching option and the From line moves
|
||||
// under the writer.
|
||||
expect(visibleIdentities(ids(3), ["i2"], ["i2"]).map((i) => i.id)).toEqual(["i1", "i2", "i3"]);
|
||||
});
|
||||
|
||||
it("keeps the default, which a new draft starts on", () => {
|
||||
expect(visibleIdentities(ids(3), ["i1", "i3"], [null, "i1"]).map((i) => i.id)).toEqual(["i1", "i2"]);
|
||||
});
|
||||
|
||||
it("shows everything rather than nothing when all are hidden", () => {
|
||||
const all = ids(3);
|
||||
expect(visibleIdentities(all, ["i1", "i2", "i3"]).map((i) => i.id)).toEqual(["i1", "i2", "i3"]);
|
||||
});
|
||||
|
||||
it("ignores an id for an identity that no longer exists", () => {
|
||||
// A deleted identity leaves its id behind in the setting; it must not
|
||||
// silently hide anything else or empty the list.
|
||||
expect(visibleIdentities(ids(2), ["gone"]).map((i) => i.id)).toEqual(["i1", "i2"]);
|
||||
});
|
||||
|
||||
it("tolerates nulls among the ids to keep", () => {
|
||||
expect(visibleIdentities(ids(2), ["i1"], [null, undefined]).map((i) => i.id)).toEqual(["i2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the settings row may offer", () => {
|
||||
it("refuses to offer hiding for an always-visible identity", () => {
|
||||
expect(isAlwaysVisible("i1", ["i1"])).toBe(true);
|
||||
expect(isAlwaysVisible("i2", ["i1"])).toBe(false);
|
||||
expect(isAlwaysVisible("i2", [null, undefined])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Which identities the compose picker offers.
|
||||
*
|
||||
* Someone using a unique address per service, on a server with an alias domain,
|
||||
* ends up with every local part twice and a picker they cannot use — while only
|
||||
* ever sending from a handful (#73). Hiding is presentation only: the identity
|
||||
* still exists, still receives, and is still listed in Settings, the same way an
|
||||
* unsubscribed folder is still a folder.
|
||||
*
|
||||
* Three things it will not do, because a sender picker that cannot offer a
|
||||
* sender is worse than a cluttered one:
|
||||
*
|
||||
* - hide the identity a draft is already using, which would leave the select
|
||||
* with no matching option and reset the From line under the writer
|
||||
* - hide the default identity, which is what a new draft starts on
|
||||
* - hide everything; if every identity is hidden it shows them all instead
|
||||
*/
|
||||
import type { Identity } from "@/jmap/types";
|
||||
|
||||
export function visibleIdentities<T extends Pick<Identity, "id">>(
|
||||
identities: T[],
|
||||
hidden: readonly string[],
|
||||
keep: Array<string | null | undefined> = [],
|
||||
): T[] {
|
||||
if (!hidden.length) return identities;
|
||||
const hide = new Set(hidden);
|
||||
for (const k of keep) if (k) hide.delete(k);
|
||||
const shown = identities.filter((i) => !hide.has(i.id));
|
||||
// Everything hidden: show the lot rather than an empty picker.
|
||||
return shown.length ? shown : identities;
|
||||
}
|
||||
|
||||
/** Whether hiding this one would be refused, so the UI can say so. */
|
||||
export function isAlwaysVisible(id: string, keep: Array<string | null | undefined>): boolean {
|
||||
return keep.some((k) => k === id);
|
||||
}
|
||||
@@ -88,6 +88,19 @@ export interface Settings {
|
||||
eventCategories: Array<{ name: string; color: string }>;
|
||||
/** Default sending identity per account (JMAP has no such flag). */
|
||||
defaultIdentityByAccount: Record<string, string>;
|
||||
/**
|
||||
* Identities kept out of the compose picker, by id.
|
||||
*
|
||||
* An account with alias domains can have every address twice over while only
|
||||
* a handful are ever sent from, which makes the picker useless (#73). This
|
||||
* hides them from the picker only — the identity still exists on the server,
|
||||
* still receives, and is still listed and editable in Settings, exactly as an
|
||||
* unsubscribed folder still exists.
|
||||
*
|
||||
* A flat list rather than keyed by account: identity ids are unique, and an
|
||||
* id belonging to another account simply never matches.
|
||||
*/
|
||||
hiddenIdentities: string[];
|
||||
/**
|
||||
* The theme the top-bar toggle goes back to from light. Remembered rather
|
||||
* than assumed, so flipping to light and back returns you to the theme you
|
||||
@@ -160,6 +173,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
{ name: "Family", color: "#9333ea" },
|
||||
],
|
||||
defaultIdentityByAccount: {},
|
||||
hiddenIdentities: [],
|
||||
lastDarkTheme: "ihasmail",
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AlertTriangle, ChevronDown, FileText, Maximize2, Minimize2, Minus, More
|
||||
import { useCompose, type Draft } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { visibleIdentities } from "@/lib/identityVisibility";
|
||||
import { RecipientInput } from "./RecipientInput";
|
||||
import { RichEditor, type RichEditorHandle } from "./RichEditor";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
@@ -28,7 +29,10 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
const setIdentity = useCompose((s) => s.setIdentity);
|
||||
const insertTemplate = useCompose((s) => s.insertTemplate);
|
||||
const focus = useCompose((s) => s.focus);
|
||||
const identities = useMail((s) => s.identities);
|
||||
const allIdentities = useMail((s) => s.identities);
|
||||
const mailAccountId = useMail((s) => s.accountId);
|
||||
const hiddenIdentities = useSettings((s) => s.settings.hiddenIdentities);
|
||||
const defaultIdentityId = useSettings((s) => (mailAccountId ? s.settings.defaultIdentityByAccount[mailAccountId] : undefined));
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const updateSettings = useSettings((s) => s.update);
|
||||
const isMobile = useIsMobile();
|
||||
@@ -118,6 +122,16 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
if (files.length) addFiles(key, files);
|
||||
};
|
||||
|
||||
/*
|
||||
* The picker offers the visible identities, plus two that can never be
|
||||
* hidden from it: the one this draft is already using, and the default a new
|
||||
* draft starts on. Hiding either would leave the select with no matching
|
||||
* option and silently move the From line. See lib/identityVisibility.
|
||||
*/
|
||||
const identities = useMemo(
|
||||
() => visibleIdentities(allIdentities, hiddenIdentities, [d.identityId, defaultIdentityId]),
|
||||
[allIdentities, hiddenIdentities, d.identityId, defaultIdentityId],
|
||||
);
|
||||
const ident = identities.find((i) => i.id === d.identityId) ?? identities[0];
|
||||
const title = d.subject || (d.replyMode ? (d.replyMode === "forward" ? "Forward" : "Reply") : "New message");
|
||||
const status = d.sending ? "Sending…" : d.saving ? "Saving…" : d.error ? "Error" : d.savedAt ? `Saved ${formatRelative(new Date(d.savedAt).toISOString())}` : d.dirty ? "Unsaved" : "";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Plus, Trash2, Star } from "lucide-react";
|
||||
import { Plus, Trash2, Star, Eye, EyeOff } from "lucide-react";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import type { Identity } from "@/jmap/types";
|
||||
import { isAlwaysVisible } from "@/lib/identityVisibility";
|
||||
import { Dialog, confirmDialog } from "@/ui/dialog";
|
||||
import { RichEditor, type RichEditorHandle } from "../compose/RichEditor";
|
||||
import { toast } from "@/ui/toast";
|
||||
@@ -19,6 +20,10 @@ export function IdentitiesSettings() {
|
||||
const setDefault = useMail((s) => s.setDefaultIdentity);
|
||||
const defaultId = useSettings((s) => (accountId ? s.settings.defaultIdentityByAccount[accountId] : undefined)) ?? identities[0]?.id;
|
||||
const [editing, setEditing] = useState<Partial<Identity> | null>(null);
|
||||
const hidden = useSettings((s) => s.settings.hiddenIdentities);
|
||||
const updateSettings = useSettings((s) => s.update);
|
||||
const toggleHidden = (id: string) =>
|
||||
updateSettings({ hiddenIdentities: hidden.includes(id) ? hidden.filter((x) => x !== id) : [...hidden, id] });
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -34,16 +39,36 @@ export function IdentitiesSettings() {
|
||||
{i.id !== defaultId && (
|
||||
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); setDefault(i.id); toast.success(`${i.email} is now your default identity`); }}><Star size={14} /> Make default</button>
|
||||
)}
|
||||
{/*
|
||||
Hiding is presentation only -- the identity still exists and still
|
||||
receives, like an unsubscribed folder. The default cannot be
|
||||
hidden, because it is what a new draft starts on.
|
||||
*/}
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
disabled={isAlwaysVisible(i.id, [defaultId])}
|
||||
title={isAlwaysVisible(i.id, [defaultId]) ? "The default identity is always offered when composing" : hidden.includes(i.id) ? "Show this in the compose picker" : "Hide this from the compose picker"}
|
||||
onClick={(e) => { e.stopPropagation(); toggleHidden(i.id); }}
|
||||
>
|
||||
{hidden.includes(i.id) ? <><Eye size={14} /> Show when composing</> : <><EyeOff size={14} /> Hide when composing</>}
|
||||
</button>
|
||||
{i.mayDelete && (
|
||||
<button className="icon-btn sm danger" aria-label="Delete identity" onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
)}
|
||||
</div>
|
||||
{hidden.includes(i.id) && <div className="hint" style={{ marginTop: 4 }}>Not offered when composing. It still receives mail, and you can still send from it by showing it again.</div>}
|
||||
{(i.htmlSignature || i.textSignature) && <div className="hint" style={{ marginTop: 4 }}>{htmlToText(i.htmlSignature || i.textSignature).slice(0, 120)}</div>}
|
||||
{i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button>
|
||||
<p className="hint mt-8">New identities must use an address this account is allowed to send from (aliases configured on the server).</p>
|
||||
{hidden.length > 0 && (
|
||||
<p className="hint">
|
||||
{hidden.length} {hidden.length === 1 ? "identity is" : "identities are"} hidden from the compose picker. Hiding every one of them would leave nothing to
|
||||
choose from, so in that case they are all offered again.
|
||||
</p>
|
||||
)}
|
||||
{editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user