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:
@@ -96,6 +96,7 @@ seconds of downtime with nothing lost.
|
||||
- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:AccountSettings/get`, falling back to `x:Account/get`), and from the browser where the server will not say; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `<input type="date">` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse
|
||||
- **Self-service credentials** in Settings › Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Built on the `x:AccountPassword` / `x:AppPassword` registry objects
|
||||
- **Light and dark** follow the system by default, with a toggle in the top bar for flipping between them and a three-way choice in Settings › Appearance
|
||||
- **Hide identities from the compose picker** — an account with alias domains can have every address twice over while only a handful are ever sent from, which makes the From picker unusable. Hiding is presentation only: the identity still exists, still receives, and stays listed and editable in Settings, the way an unsubscribed folder is still a folder. The identity a draft is already using and the default can never be hidden, and hiding every one of them offers them all again — a sender picker with nothing in it is worse than a cluttered one
|
||||
- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings
|
||||
- **Settings follow the account, not the browser**: they are kept in a `settings.json` in the account's own JMAP Files, so the default identity, locale, date and time formats, theme, labels, templates, folder colours and the rest are the same wherever you sign in — including a private window. ihasmail still stores nothing itself; the file lives in the mail store and is backed up with it. Settings that describe *this* screen or browser stay local, because syncing them would be wrong rather than helpful: list-pane sizes, density, font size, sidebar state, and the notification toggles (which track a permission the browser grants per-device). localStorage is kept as a cache so the first frame is already right, and the file corrects it a moment later
|
||||
|
||||
|
||||
@@ -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