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:
@@ -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