Merge pull request #82 from LINUXexpert-org/hide-identities
Hide identities from the compose picker
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
|
- **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
|
- **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
|
- **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
|
- 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
|
- **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 }>;
|
eventCategories: Array<{ name: string; color: string }>;
|
||||||
/** Default sending identity per account (JMAP has no such flag). */
|
/** Default sending identity per account (JMAP has no such flag). */
|
||||||
defaultIdentityByAccount: Record<string, string>;
|
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
|
* 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
|
* 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" },
|
{ name: "Family", color: "#9333ea" },
|
||||||
],
|
],
|
||||||
defaultIdentityByAccount: {},
|
defaultIdentityByAccount: {},
|
||||||
|
hiddenIdentities: [],
|
||||||
lastDarkTheme: "ihasmail",
|
lastDarkTheme: "ihasmail",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AlertTriangle, ChevronDown, FileText, Maximize2, Minimize2, Minus, More
|
|||||||
import { useCompose, type Draft } from "@/store/compose";
|
import { useCompose, type Draft } from "@/store/compose";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
|
import { visibleIdentities } from "@/lib/identityVisibility";
|
||||||
import { RecipientInput } from "./RecipientInput";
|
import { RecipientInput } from "./RecipientInput";
|
||||||
import { RichEditor, type RichEditorHandle } from "./RichEditor";
|
import { RichEditor, type RichEditorHandle } from "./RichEditor";
|
||||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
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 setIdentity = useCompose((s) => s.setIdentity);
|
||||||
const insertTemplate = useCompose((s) => s.insertTemplate);
|
const insertTemplate = useCompose((s) => s.insertTemplate);
|
||||||
const focus = useCompose((s) => s.focus);
|
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 settings = useSettings((s) => s.settings);
|
||||||
const updateSettings = useSettings((s) => s.update);
|
const updateSettings = useSettings((s) => s.update);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -118,6 +122,16 @@ export function Composer({ draft }: { draft: Draft }) {
|
|||||||
if (files.length) addFiles(key, files);
|
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 ident = identities.find((i) => i.id === d.identityId) ?? identities[0];
|
||||||
const title = d.subject || (d.replyMode ? (d.replyMode === "forward" ? "Forward" : "Reply") : "New message");
|
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" : "";
|
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 { 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 { useSettings } from "@/store/settings";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import type { Identity } from "@/jmap/types";
|
import type { Identity } from "@/jmap/types";
|
||||||
|
import { isAlwaysVisible } from "@/lib/identityVisibility";
|
||||||
import { Dialog, confirmDialog } from "@/ui/dialog";
|
import { Dialog, confirmDialog } from "@/ui/dialog";
|
||||||
import { RichEditor, type RichEditorHandle } from "../compose/RichEditor";
|
import { RichEditor, type RichEditorHandle } from "../compose/RichEditor";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
@@ -19,6 +20,10 @@ export function IdentitiesSettings() {
|
|||||||
const setDefault = useMail((s) => s.setDefaultIdentity);
|
const setDefault = useMail((s) => s.setDefaultIdentity);
|
||||||
const defaultId = useSettings((s) => (accountId ? s.settings.defaultIdentityByAccount[accountId] : undefined)) ?? identities[0]?.id;
|
const defaultId = useSettings((s) => (accountId ? s.settings.defaultIdentityByAccount[accountId] : undefined)) ?? identities[0]?.id;
|
||||||
const [editing, setEditing] = useState<Partial<Identity> | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -34,16 +39,36 @@ export function IdentitiesSettings() {
|
|||||||
{i.id !== defaultId && (
|
{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>
|
<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 && (
|
{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>
|
<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>
|
</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.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}
|
{i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button>
|
<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>
|
<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)} />}
|
{editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user