Merge pull request #200 from Coffey-Labs/feat/privacy-safety-settings
Gather the privacy settings into a section of their own
This commit is contained in:
+16
-1
@@ -718,7 +718,8 @@ not reach another that already has ihasmail open until it signs in again.
|
|||||||
|
|
||||||
| Section | Holds |
|
| Section | Holds |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **General** | Reading pane, mark-as-read delay, auto-advance, remote images, conversation view, snippets, avatars, confirm-before-delete; compose format, undo-send window, quoting, signature placement, attachment reminder, read-receipt policy, spell check; time zone, week start, language & region, date format, time format; `mailto:` handler; export / import / reset |
|
| **General** | Reading pane, mark-as-read delay, auto-advance, conversation view, snippets, avatars; compose format, quoting, signature placement, spell check; time zone, week start, language & region, date format, time format; `mailto:` handler; export / import / reset |
|
||||||
|
| **Privacy & safety** | Remote images and the senders trusted with them, read receipts asked for and answered, undo-send window, attachment reminder, confirm-before-delete |
|
||||||
| **Appearance** | Theme, accent colour, density, font size, sidebar, swipe actions, interface language |
|
| **Appearance** | Theme, accent colour, density, font size, sidebar, swipe actions, interface language |
|
||||||
| **Identities & signatures** | Addresses, names, Reply-To, HTML signatures, the default, and which to hide from the picker |
|
| **Identities & signatures** | Addresses, names, Reply-To, HTML signatures, the default, and which to hide from the picker |
|
||||||
| **Filters & rules** | The visual builder and raw Sieve editor |
|
| **Filters & rules** | The visual builder and raw Sieve editor |
|
||||||
@@ -732,6 +733,20 @@ not reach another that already has ihasmail open until it signs in again.
|
|||||||
| **Keyboard shortcuts** | The full list, grouped |
|
| **Keyboard shortcuts** | The full list, grouped |
|
||||||
| **About** | Version, source URL, server, and the capabilities it advertises |
|
| **About** | Version, source URL, server, and the capabilities it advertises |
|
||||||
|
|
||||||
|
**Privacy & safety is separate from Security & sessions**, and the line
|
||||||
|
between them is worth stating because two similar words in one nav is how a
|
||||||
|
menu becomes something people hunt through. Security & sessions is credentials
|
||||||
|
and access: password, two-factor state, app passwords, live sessions. Privacy &
|
||||||
|
safety is how the app behaves towards the reader and towards senders: what
|
||||||
|
loads, what leaks, and what asks before it happens. These had been spread
|
||||||
|
through General, which had grown five unrelated headings — remote images filed
|
||||||
|
under "Reading", the read-receipt policy under "Composing", the undo-send window
|
||||||
|
beside the default message format.
|
||||||
|
|
||||||
|
The senders trusted with remote images are listed there and can be withdrawn
|
||||||
|
one at a time. Previously a sender was added from a message and could only be
|
||||||
|
removed by finding another message from the same sender.
|
||||||
|
|
||||||
Settings **export** to a JSON file and **import** back, and reset to defaults.
|
Settings **export** to a JSON file and **import** back, and reset to defaults.
|
||||||
|
|
||||||
## Dates, times and locale
|
## Dates, times and locale
|
||||||
|
|||||||
@@ -1395,3 +1395,6 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
|||||||
.placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; }
|
.placeholder-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; align-items: baseline; }
|
||||||
.placeholder-row { display: contents; }
|
.placeholder-row { display: contents; }
|
||||||
.placeholder-list code { font-family: var(--font-mono); font-size: 12.5px; background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 4px; padding: .1em .4em; white-space: nowrap; }
|
.placeholder-list code { font-family: var(--font-mono); font-size: 12.5px; background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 4px; padding: .1em .4em; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* The senders whose remote images load without asking, in Privacy & safety. */
|
||||||
|
.trusted-senders { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useSettings, type ReadReceiptPolicy } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { Switch } from "@/ui/misc";
|
import { Switch } from "@/ui/misc";
|
||||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
@@ -47,7 +47,7 @@ export function GeneralSettings() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{t("General")}</h1>
|
<h1>{t("General")}</h1>
|
||||||
<p className="lead">{t("Reading, sending and list behaviour. Settings are stored in this browser.")}</p>
|
<p className="lead">{t("Reading, sending, and how dates and times are shown. What reaches a sender lives in Privacy & safety.")}</p>
|
||||||
|
|
||||||
<h2>{t("Reading")}</h2>
|
<h2>{t("Reading")}</h2>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
@@ -76,19 +76,10 @@ export function GeneralSettings() {
|
|||||||
<option value="newer">{t("Open the previous (newer) conversation")}</option>
|
<option value="newer">{t("Open the previous (newer) conversation")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label>{t("Remote images")}</label>
|
|
||||||
<select className="select" value={s.imagePolicy} onChange={(e) => update({ imagePolicy: e.target.value as typeof s.imagePolicy })}>
|
|
||||||
<option value="ask">{t("Ask before showing (recommended)")}</option>
|
|
||||||
<option value="contacts">{t("Show automatically from my contacts")}</option>
|
|
||||||
<option value="always">{t("Always show")}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label={t("Conversation view")} hint={t("Group messages from the same thread together.")} />
|
<Switch checked={s.conversationMode} onChange={(v) => update({ conversationMode: v })} label={t("Conversation view")} hint={t("Group messages from the same thread together.")} />
|
||||||
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label={t("Show message snippets")} hint={t("Preview the first line of each message in the list.")} />
|
<Switch checked={s.showPreview} onChange={(v) => update({ showPreview: v })} label={t("Show message snippets")} hint={t("Preview the first line of each message in the list.")} />
|
||||||
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label={t("Show sender avatars")} />
|
<Switch checked={s.showAvatars} onChange={(v) => update({ showAvatars: v })} label={t("Show sender avatars")} />
|
||||||
<Switch checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label={t("Confirm before deleting")} />
|
|
||||||
|
|
||||||
<h2>{t("Composing")}</h2>
|
<h2>{t("Composing")}</h2>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
@@ -99,32 +90,9 @@ export function GeneralSettings() {
|
|||||||
<option value="text">{t("Plain text")}</option>
|
<option value="text">{t("Plain text")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label>{t("Undo send window")}</label>
|
|
||||||
<select className="select" value={String(s.undoSendSeconds)} onChange={(e) => update({ undoSendSeconds: Number(e.target.value) })}>
|
|
||||||
<option value="0">{t("Off")}</option>
|
|
||||||
<option value="5">{t("5 seconds")}</option>
|
|
||||||
<option value="8">{t("8 seconds")}</option>
|
|
||||||
<option value="15">{t("15 seconds")}</option>
|
|
||||||
<option value="30">{t("30 seconds")}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
|
<Switch checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
|
||||||
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
|
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
|
||||||
<Switch checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label={t("Attachment reminder")} hint={t("Warn when the message mentions an attachment but none is attached.")} />
|
|
||||||
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label={t("Always request read receipts")} />
|
|
||||||
<div className="field">
|
|
||||||
<label>{t("When someone requests a read receipt")}</label>
|
|
||||||
<select className="select" value={s.readReceiptPolicy} onChange={(e) => update({ readReceiptPolicy: e.target.value as ReadReceiptPolicy })}>
|
|
||||||
<option value="ask">{t("Ask me on each message")}</option>
|
|
||||||
<option value="never">{t("Never send one")}</option>
|
|
||||||
</select>
|
|
||||||
<p className="hint">
|
|
||||||
|
|
||||||
{t("A receipt tells whoever asked that this address is live and when the message was read, and the sender chooses where it goes — so there is no automatic option. Bulk mail, mailing lists and anything marked auto-submitted are never offered one at all.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
|
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
|
||||||
|
|
||||||
<h2>{t("Locale")}</h2>
|
<h2>{t("Locale")}</h2>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useSettings, type ReadReceiptPolicy } from "@/store/settings";
|
||||||
|
import { Switch } from "@/ui/misc";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything about what reaches a sender, and what asks before it happens.
|
||||||
|
*
|
||||||
|
* These settings were spread through General, which had grown into five
|
||||||
|
* unrelated headings -- remote images filed under "Reading", the read-receipt
|
||||||
|
* policy under "Composing", the undo-send window beside the default message
|
||||||
|
* format. They are the same kind of decision and they belong together, and
|
||||||
|
* gathering them leaves General smaller as well.
|
||||||
|
*
|
||||||
|
* The boundary against **Security & sessions** is worth keeping sharp, since
|
||||||
|
* two similar words next to each other in a nav is how a menu becomes
|
||||||
|
* something people hunt through: that section is credentials and access --
|
||||||
|
* password, two-factor, app passwords, live sessions. This one is how the app
|
||||||
|
* behaves towards the reader and towards senders.
|
||||||
|
*/
|
||||||
|
export function PrivacySettings() {
|
||||||
|
const s = useSettings((st) => st.settings);
|
||||||
|
const update = useSettings((st) => st.update);
|
||||||
|
const trusted = s.trustedImageSenders;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{t("Privacy & safety")}</h1>
|
||||||
|
<p className="lead">{t("What reaches a sender, and what asks before it happens.")}</p>
|
||||||
|
|
||||||
|
<h2>{t("Remote content")}</h2>
|
||||||
|
<div className="field">
|
||||||
|
<label>{t("Remote images")}</label>
|
||||||
|
<select className="select" value={s.imagePolicy} onChange={(e) => update({ imagePolicy: e.target.value as typeof s.imagePolicy })}>
|
||||||
|
<option value="ask">{t("Ask before showing (recommended)")}</option>
|
||||||
|
<option value="contacts">{t("Show automatically from my contacts")}</option>
|
||||||
|
<option value="always">{t("Always show")}</option>
|
||||||
|
</select>
|
||||||
|
<p className="hint">
|
||||||
|
{t("An image loaded from a sender's server tells them the message was opened, when, and from roughly where. Approved images are fetched by ihasmail's own server rather than the browser, so the sender learns none of those.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{trusted.length > 0 && (
|
||||||
|
<div className="field">
|
||||||
|
<label>{t("Always showing images from")}</label>
|
||||||
|
<div className="trusted-senders">
|
||||||
|
{trusted.map((addr) => (
|
||||||
|
<span key={addr} className="chip">
|
||||||
|
<span className="notranslate" translate="no">{addr}</span>
|
||||||
|
<button
|
||||||
|
className="chip-x"
|
||||||
|
aria-label={t("Stop trusting {address}", { address: addr })}
|
||||||
|
onClick={() => update({ trustedImageSenders: trusted.filter((x) => x !== addr) })}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="hint">{t("Added from a message, and removable here — previously the only way to undo one was to find another message from the same sender.")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2>{t("Read receipts")}</h2>
|
||||||
|
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label={t("Always request read receipts")} />
|
||||||
|
<div className="field">
|
||||||
|
<label>{t("When someone requests a read receipt")}</label>
|
||||||
|
<select className="select" value={s.readReceiptPolicy} onChange={(e) => update({ readReceiptPolicy: e.target.value as ReadReceiptPolicy })}>
|
||||||
|
<option value="ask">{t("Ask me on each message")}</option>
|
||||||
|
<option value="never">{t("Never send one")}</option>
|
||||||
|
</select>
|
||||||
|
<p className="hint">
|
||||||
|
{t("A receipt tells whoever asked that this address is live and when the message was read, and the sender chooses where it goes — so there is no automatic option. Bulk mail, mailing lists and anything marked auto-submitted are never offered one at all.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{t("Before it happens")}</h2>
|
||||||
|
<div className="field">
|
||||||
|
<label>{t("Undo send window")}</label>
|
||||||
|
<select className="select" value={String(s.undoSendSeconds)} onChange={(e) => update({ undoSendSeconds: Number(e.target.value) })}>
|
||||||
|
<option value="0">{t("Off")}</option>
|
||||||
|
<option value="5">{t("5 seconds")}</option>
|
||||||
|
<option value="8">{t("8 seconds")}</option>
|
||||||
|
<option value="15">{t("15 seconds")}</option>
|
||||||
|
<option value="30">{t("30 seconds")}</option>
|
||||||
|
</select>
|
||||||
|
<p className="hint">{t("The message is held in this browser and has not been submitted yet, so taking it back costs nothing.")}</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label={t("Attachment reminder")} hint={t("Warn when the message mentions an attachment but none is attached.")} />
|
||||||
|
<Switch checked={s.confirmDelete} onChange={(v) => update({ confirmDelete: v })} label={t("Confirm before deleting")} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { lazy, Suspense, type ReactNode } from "react";
|
import { lazy, Suspense, type ReactNode } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { ArrowLeft, Bell, Filter, Folder, Info, Keyboard, LayoutTemplate, Palette, PenLine, Plane, Settings as SettingsIcon, ShieldCheck, Tag, Users, Calendar } from "lucide-react";
|
import { ArrowLeft, Bell, EyeOff, Filter, Folder, Info, Keyboard, LayoutTemplate, Palette, PenLine, Plane, Settings as SettingsIcon, ShieldCheck, Tag, Users, Calendar } from "lucide-react";
|
||||||
import { Spinner } from "@/ui/misc";
|
import { Spinner } from "@/ui/misc";
|
||||||
import { GeneralSettings } from "./GeneralSettings";
|
import { GeneralSettings } from "./GeneralSettings";
|
||||||
import { AppearanceSettings } from "./AppearanceSettings";
|
import { AppearanceSettings } from "./AppearanceSettings";
|
||||||
@@ -9,6 +9,7 @@ import { FoldersSettings } from "./FoldersSettings";
|
|||||||
import { LabelsSettings } from "./LabelsSettings";
|
import { LabelsSettings } from "./LabelsSettings";
|
||||||
import { TemplatesSettings } from "./TemplatesSettings";
|
import { TemplatesSettings } from "./TemplatesSettings";
|
||||||
import { NotificationsSettings } from "./NotificationsSettings";
|
import { NotificationsSettings } from "./NotificationsSettings";
|
||||||
|
import { PrivacySettings } from "./PrivacySettings";
|
||||||
import { SecuritySettings } from "./SecuritySettings";
|
import { SecuritySettings } from "./SecuritySettings";
|
||||||
import { AboutSettings } from "./AboutSettings";
|
import { AboutSettings } from "./AboutSettings";
|
||||||
import { ShortcutsSettings } from "./ShortcutsSettings";
|
import { ShortcutsSettings } from "./ShortcutsSettings";
|
||||||
@@ -29,6 +30,7 @@ const SECTIONS: Array<{ id: string; label: string; icon: ReactNode; el: ReactNod
|
|||||||
{ id: "templates", label: "Templates", icon: <LayoutTemplate size={18} />, el: <TemplatesSettings /> },
|
{ id: "templates", label: "Templates", icon: <LayoutTemplate size={18} />, el: <TemplatesSettings /> },
|
||||||
{ id: "calendar", label: "Calendar & contacts", icon: <Calendar size={18} />, el: <CalendarSettings /> },
|
{ id: "calendar", label: "Calendar & contacts", icon: <Calendar size={18} />, el: <CalendarSettings /> },
|
||||||
{ id: "notifications", label: "Notifications", icon: <Bell size={18} />, el: <NotificationsSettings /> },
|
{ id: "notifications", label: "Notifications", icon: <Bell size={18} />, el: <NotificationsSettings /> },
|
||||||
|
{ id: "privacy", label: "Privacy & safety", icon: <EyeOff size={18} />, el: <PrivacySettings /> },
|
||||||
{ id: "security", label: "Security & sessions", icon: <ShieldCheck size={18} />, el: <SecuritySettings /> },
|
{ id: "security", label: "Security & sessions", icon: <ShieldCheck size={18} />, el: <SecuritySettings /> },
|
||||||
{ id: "shortcuts", label: "Keyboard shortcuts", icon: <Keyboard size={18} />, el: <ShortcutsSettings /> },
|
{ id: "shortcuts", label: "Keyboard shortcuts", icon: <Keyboard size={18} />, el: <ShortcutsSettings /> },
|
||||||
{ id: "about", label: "About", icon: <Info size={18} />, el: <AboutSettings /> },
|
{ id: "about", label: "About", icon: <Info size={18} />, el: <AboutSettings /> },
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { PrivacySettings } from "../PrivacySettings";
|
||||||
|
import { GeneralSettings } from "../GeneralSettings";
|
||||||
|
import { useSettings, DEFAULT_SETTINGS } from "@/store/settings";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The point of the section is that nothing was lost on the way out of General.
|
||||||
|
* A setting that stops being reachable is still stored, still applied, and
|
||||||
|
* impossible to change -- which is worse than leaving it where it was.
|
||||||
|
*/
|
||||||
|
describe("Privacy & safety", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
const render = async (el: React.ReactNode) => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(el);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers every control that left General", async () => {
|
||||||
|
await render(<PrivacySettings />);
|
||||||
|
const text = host.textContent ?? "";
|
||||||
|
expect(text).toContain("Remote images");
|
||||||
|
expect(text).toContain("Always request read receipts");
|
||||||
|
expect(text).toContain("When someone requests a read receipt");
|
||||||
|
expect(text).toContain("Undo send window");
|
||||||
|
expect(text).toContain("Attachment reminder");
|
||||||
|
expect(text).toContain("Confirm before deleting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves none of them behind in General", async () => {
|
||||||
|
await render(<GeneralSettings />);
|
||||||
|
const text = host.textContent ?? "";
|
||||||
|
for (const gone of [
|
||||||
|
"Remote images",
|
||||||
|
"Always request read receipts",
|
||||||
|
"When someone requests a read receipt",
|
||||||
|
"Undo send window",
|
||||||
|
"Attachment reminder",
|
||||||
|
"Confirm before deleting",
|
||||||
|
]) {
|
||||||
|
expect(text, `${gone} is in both sections`).not.toContain(gone);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps General's own settings where they were", async () => {
|
||||||
|
await render(<GeneralSettings />);
|
||||||
|
const text = host.textContent ?? "";
|
||||||
|
expect(text).toContain("Reading pane");
|
||||||
|
expect(text).toContain("Conversation view");
|
||||||
|
expect(text).toContain("Default format");
|
||||||
|
expect(text).toContain("Spell check while typing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes through to the same stored settings the old controls used", async () => {
|
||||||
|
await render(<PrivacySettings />);
|
||||||
|
const select = [...host.querySelectorAll("select")].find((el) =>
|
||||||
|
[...el.options].some((o) => o.value === "always"),
|
||||||
|
);
|
||||||
|
expect(select, "remote images select").toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
select!.value = "always";
|
||||||
|
select!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(useSettings.getState().settings.imagePolicy).toBe("always");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the trusted-sender list until there is one", async () => {
|
||||||
|
await render(<PrivacySettings />);
|
||||||
|
expect(host.textContent).not.toContain("Always showing images from");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, trustedImageSenders: ["[email protected]"] } });
|
||||||
|
});
|
||||||
|
await render(<PrivacySettings />);
|
||||||
|
expect(host.textContent).toContain("Always showing images from");
|
||||||
|
expect(host.textContent).toContain("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a trusted sender, which nothing outside a message could do before", async () => {
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, trustedImageSenders: ["[email protected]", "[email protected]"] } });
|
||||||
|
await render(<PrivacySettings />);
|
||||||
|
const remove = host.querySelector<HTMLButtonElement>('button[aria-label*="[email protected]"]');
|
||||||
|
expect(remove, "remove button").toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
remove!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(useSettings.getState().settings.trustedImageSenders).toEqual(["[email protected]"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user