Hide identities from the compose picker

An account using a unique address per service, on a server with an alias
domain, ends up with every local part twice over and a From picker
nobody can use -- while only ever sending from a handful (#73).

Identities can now be hidden from that picker, from Identities &
signatures. Hiding is presentation only: the identity still exists,
still receives, and stays listed and editable, the way an unsubscribed
folder is still a folder. That framing is mbunkus's own, and it is the
right one -- this is a UI preference, not a change to the account.

Three things it refuses to do, because a sender picker with nothing
usable in it is worse than a cluttered one:

  - it will not hide the identity a draft is already using, which would
    leave the select with no matching option and move the From line
    under the writer
  - it will not hide the default, which is what a new draft starts on;
    the button is disabled there and says why
  - if every identity is somehow hidden -- reachable only through
    settings sync, since the UI will not do it -- they are all offered
    again

The setting syncs, so the picker looks the same on every device, which
follows from DEVICE_KEYS being a list of exceptions rather than a list
of what travels.

Verified against the mock with four identities and one hidden: the
picker offers the other three, the hidden address is gone from
composing, the default's hide button is disabled, and the row says the
identity still receives.
This commit is contained in:
2026-08-26 15:36:35 -07:00
parent 6e58c22807
commit 133036a6c5
6 changed files with 152 additions and 2 deletions
@@ -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);
});
});