Merge pull request #82 from LINUXexpert-org/hide-identities

Hide identities from the compose picker
This commit is contained in:
LINUXexpert.org
2026-08-26 15:44:55 -07:00
committed by GitHub
6 changed files with 152 additions and 2 deletions
+15 -1
View File
@@ -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" : "";
+26 -1
View File
@@ -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>
);