feat(webmail): trusted senders on the server, Not spam, and a trust pill in the reader
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d7f9659b5e
commit
d2611d39fa
9 files changed
+200
-34
No files matched your search
@@ -321,9 +321,15 @@
|
|||||||
"ccLabel": "Cc",
|
"ccLabel": "Cc",
|
||||||
"bccLabel": "Bcc",
|
"bccLabel": "Bcc",
|
||||||
"hideImages": "Hide images",
|
"hideImages": "Hide images",
|
||||||
"alwaysFromSender": "Always from this sender",
|
"alwaysFromSender": "Trust this sender",
|
||||||
"forgetSender": "Stop trusting sender",
|
"forgetSender": "Stop trusting sender",
|
||||||
"moveTo": "Move to"
|
"moveTo": "Move to",
|
||||||
|
"notSpam": "Not spam",
|
||||||
|
"junkNotice": "This message is in Spam. Marking it as not spam moves it to the inbox and trusts this sender, so their next messages arrive there too.",
|
||||||
|
"trustSender": "Trust sender",
|
||||||
|
"trustedSender": "Trusted sender",
|
||||||
|
"trustSenderHint": "Never file this sender's mail as spam, and always show their images",
|
||||||
|
"trustedSenderHint": "Not filed as spam, images always shown. Click to stop trusting."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"actionDiscard": "Discard",
|
"actionDiscard": "Discard",
|
||||||
|
|||||||
@@ -321,9 +321,15 @@
|
|||||||
"ccLabel": "Cc",
|
"ccLabel": "Cc",
|
||||||
"bccLabel": "Cco",
|
"bccLabel": "Cco",
|
||||||
"hideImages": "Ocultar imágenes",
|
"hideImages": "Ocultar imágenes",
|
||||||
"alwaysFromSender": "Siempre de este remitente",
|
"alwaysFromSender": "Confiar en este remitente",
|
||||||
"forgetSender": "Dejar de confiar",
|
"forgetSender": "Dejar de confiar",
|
||||||
"moveTo": "Mover a"
|
"moveTo": "Mover a",
|
||||||
|
"notSpam": "No es spam",
|
||||||
|
"junkNotice": "Este mensaje está en Spam. Al marcarlo como no es spam, pasa a la bandeja de entrada y este remitente se vuelve de confianza, así que sus próximos mensajes llegarán allí también.",
|
||||||
|
"trustSender": "Confiar en el remitente",
|
||||||
|
"trustedSender": "Remitente de confianza",
|
||||||
|
"trustSenderHint": "No enviar nunca el correo de este remitente a spam y mostrar siempre sus imágenes",
|
||||||
|
"trustedSenderHint": "No va a spam y sus imágenes siempre se muestran. Haz clic para dejar de confiar."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"actionDiscard": "Descartar",
|
"actionDiscard": "Descartar",
|
||||||
|
|||||||
@@ -321,9 +321,15 @@
|
|||||||
"ccLabel": "Cc",
|
"ccLabel": "Cc",
|
||||||
"bccLabel": "Cco",
|
"bccLabel": "Cco",
|
||||||
"hideImages": "Ocultar imagens",
|
"hideImages": "Ocultar imagens",
|
||||||
"alwaysFromSender": "Sempre deste remetente",
|
"alwaysFromSender": "Confiar neste remetente",
|
||||||
"forgetSender": "Deixar de confiar",
|
"forgetSender": "Deixar de confiar",
|
||||||
"moveTo": "Mover para"
|
"moveTo": "Mover para",
|
||||||
|
"notSpam": "Não é spam",
|
||||||
|
"junkNotice": "Esta mensagem está no Spam. Ao marcar como não é spam, ela vai para a caixa de entrada e este remetente passa a ser confiável, então as próximas mensagens dele também chegam lá.",
|
||||||
|
"trustSender": "Confiar no remetente",
|
||||||
|
"trustedSender": "Remetente confiável",
|
||||||
|
"trustSenderHint": "Nunca mandar o e-mail deste remetente para o spam e sempre mostrar as imagens dele",
|
||||||
|
"trustedSenderHint": "Não vai para o spam e as imagens sempre aparecem. Clique para deixar de confiar."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"actionDiscard": "Descartar",
|
"actionDiscard": "Descartar",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from "../../../../../lib/mailCounts";
|
} from "../../../../../lib/mailCounts";
|
||||||
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
|
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
|
||||||
import { plainSnippet } from "../../../../../lib/snippet";
|
import { plainSnippet } from "../../../../../lib/snippet";
|
||||||
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
|
import { useTrustedSenders } from "../../../../../lib/trustedSenders";
|
||||||
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
|
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
|
||||||
import { Button } from "../../../../../components/ui/Button";
|
import { Button } from "../../../../../components/ui/Button";
|
||||||
import {
|
import {
|
||||||
@@ -122,9 +122,9 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
const [messages, setMessages] = useState<MessageSummary[]>([]);
|
const [messages, setMessages] = useState<MessageSummary[]>([]);
|
||||||
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
||||||
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
||||||
// Whether this sender has a standing decision, so the banner can offer to
|
// The senders this account trusts, kept on the server so the decision
|
||||||
// take it back rather than only to grant it.
|
// follows the account to every browser and also reaches the spam filter.
|
||||||
const [rememberSender, setRememberSender] = useState(false);
|
const trustedSenders = useTrustedSenders();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeFilter, setActiveFilter] = useState<ListFilter>(null);
|
const [activeFilter, setActiveFilter] = useState<ListFilter>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -333,7 +333,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadRemoteImages(false);
|
setLoadRemoteImages(false);
|
||||||
setRememberSender(false);
|
|
||||||
setReaderHeight(420);
|
setReaderHeight(420);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, {
|
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, {
|
||||||
@@ -342,10 +341,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
if (!res.ok) throw new Error(t("errors.serverError"));
|
if (!res.ok) throw new Error(t("errors.serverError"));
|
||||||
const message = await res.json();
|
const message = await res.json();
|
||||||
setSelected(message);
|
setSelected(message);
|
||||||
// A sender the reader has already allowed does not get asked again.
|
|
||||||
const trusted = isSenderTrusted(message.from ?? "");
|
|
||||||
setLoadRemoteImages(trusted);
|
|
||||||
setRememberSender(trusted);
|
|
||||||
// Fetching the message is what marks it read on the server, so this only
|
// Fetching the message is what marks it read on the server, so this only
|
||||||
// brings the list and the badges into line with what already happened.
|
// brings the list and the badges into line with what already happened.
|
||||||
void setSeen(uid, true, { persist: false });
|
void setSeen(uid, true, { persist: false });
|
||||||
@@ -362,6 +357,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
searching: searchQuery.trim().length > 0,
|
searching: searchQuery.trim().length > 0,
|
||||||
});
|
});
|
||||||
const selectedRow = messages.find((m) => m.uid === selected?.uid);
|
const selectedRow = messages.find((m) => m.uid === selected?.uid);
|
||||||
|
const senderTrusted = selected ? trustedSenders.isTrusted(selected.from ?? "") : false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The document the reader frame renders.
|
* The document the reader frame renders.
|
||||||
@@ -376,9 +372,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
const sanitized = sanitizeEmailHtml(selected.html);
|
const sanitized = sanitizeEmailHtml(selected.html);
|
||||||
if (!sanitized) return null;
|
if (!sanitized) return null;
|
||||||
const withInline = resolveInlineImages(sanitized, selected.inline_images ?? {});
|
const withInline = resolveInlineImages(sanitized, selected.inline_images ?? {});
|
||||||
const body = loadRemoteImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline);
|
// A trusted sender's images load without asking; anyone else's wait for
|
||||||
|
// the reader's choice, one message at a time.
|
||||||
|
const showImages = loadRemoteImages || trustedSenders.isTrusted(selected.from ?? "");
|
||||||
|
const body = showImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline);
|
||||||
return buildReaderDocument(body);
|
return buildReaderDocument(body);
|
||||||
}, [selected, loadRemoteImages]);
|
}, [selected, loadRemoteImages, trustedSenders]);
|
||||||
|
|
||||||
// Whether anything was actually held back, so the privacy banner is not shown
|
// Whether anything was actually held back, so the privacy banner is not shown
|
||||||
// over a message that has no remote content at all.
|
// over a message that has no remote content at all.
|
||||||
@@ -651,25 +650,29 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
document={readerDocument}
|
document={readerDocument}
|
||||||
frameHeight={readerHeight}
|
frameHeight={readerHeight}
|
||||||
hasBlockedContent={hasBlockedContent}
|
hasBlockedContent={hasBlockedContent}
|
||||||
imagesLoaded={loadRemoteImages}
|
imagesLoaded={loadRemoteImages || senderTrusted}
|
||||||
senderRemembered={rememberSender}
|
senderTrusted={senderTrusted}
|
||||||
|
isJunk={folder.toLowerCase() === "junk"}
|
||||||
onClose={() => setSelected(null)}
|
onClose={() => setSelected(null)}
|
||||||
onToggleFlagged={() => void setFlagged(selected.uid, !(selectedRow?.flagged ?? false))}
|
onToggleFlagged={() => void setFlagged(selected.uid, !(selectedRow?.flagged ?? false))}
|
||||||
onToggleSeen={() => void setSeen(selected.uid, !(selectedRow?.seen ?? true), { persist: true })}
|
onToggleSeen={() => void setSeen(selected.uid, !(selectedRow?.seen ?? true), { persist: true })}
|
||||||
onDelete={() => void deleteMessage(selected.uid)}
|
onDelete={() => void deleteMessage(selected.uid)}
|
||||||
onMove={(target) => void moveMessage(selected.uid, target)}
|
onMove={(target) => void moveMessage(selected.uid, target)}
|
||||||
onToggleImages={() => setLoadRemoteImages(!loadRemoteImages)}
|
onToggleImages={() => setLoadRemoteImages(!loadRemoteImages)}
|
||||||
onToggleRemember={() => {
|
onToggleTrusted={() => {
|
||||||
if (rememberSender) {
|
if (senderTrusted) {
|
||||||
revokeSender(selected.from);
|
void trustedSenders.revoke(selected.from);
|
||||||
setRememberSender(false);
|
|
||||||
setLoadRemoteImages(false);
|
setLoadRemoteImages(false);
|
||||||
} else {
|
} else {
|
||||||
trustSender(selected.from);
|
void trustedSenders.trust(selected.from);
|
||||||
setRememberSender(true);
|
|
||||||
setLoadRemoteImages(true);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onNotSpam={() => {
|
||||||
|
// Two steps, one decision: trusting is what makes the next
|
||||||
|
// reply reach the inbox, moving this one is what is seen now.
|
||||||
|
void trustedSenders.trust(selected.from);
|
||||||
|
void moveMessage(selected.uid, "INBOX");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
FolderInput,
|
FolderInput,
|
||||||
Forward,
|
Forward,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Pencil,
|
Pencil,
|
||||||
Reply,
|
Reply,
|
||||||
@@ -72,14 +74,18 @@ interface Props {
|
|||||||
/** Whether anything was held back, so the privacy bar is not always shown. */
|
/** Whether anything was held back, so the privacy bar is not always shown. */
|
||||||
hasBlockedContent: boolean;
|
hasBlockedContent: boolean;
|
||||||
imagesLoaded: boolean;
|
imagesLoaded: boolean;
|
||||||
senderRemembered: boolean;
|
/** Whether the account trusts this sender: not spam, and images load. */
|
||||||
|
senderTrusted: boolean;
|
||||||
|
/** The message is being read in the spam folder. */
|
||||||
|
isJunk: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onToggleSeen: () => void;
|
onToggleSeen: () => void;
|
||||||
onToggleFlagged: () => void;
|
onToggleFlagged: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onMove: (target: string) => void;
|
onMove: (target: string) => void;
|
||||||
onToggleImages: () => void;
|
onToggleImages: () => void;
|
||||||
onToggleRemember: () => void;
|
onToggleTrusted: () => void;
|
||||||
|
onNotSpam: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The sender's name and address, from a header like `Jenny <jenny@x.test>`. */
|
/** The sender's name and address, from a header like `Jenny <jenny@x.test>`. */
|
||||||
@@ -324,6 +330,22 @@ export function MessageReader(props: Props) {
|
|||||||
<span className="text-[13px] text-slate-400 [overflow-wrap:anywhere]"><{address}></span>
|
<span className="text-[13px] text-slate-400 [overflow-wrap:anywhere]"><{address}></span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onToggleTrusted}
|
||||||
|
aria-pressed={props.senderTrusted}
|
||||||
|
title={props.senderTrusted ? t("mail.trustedSenderHint") : t("mail.trustSenderHint")}
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
||||||
|
props.senderTrusted
|
||||||
|
? "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30"
|
||||||
|
: "text-slate-400 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] hover:bg-white/[0.07] hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3 w-3" aria-hidden />
|
||||||
|
{props.senderTrusted ? t("mail.trustedSender") : t("mail.trustSender")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="text-[12.5px] leading-relaxed text-slate-400 [overflow-wrap:anywhere]">
|
<div className="text-[12.5px] leading-relaxed text-slate-400 [overflow-wrap:anywhere]">
|
||||||
{visibleTo.length > 0 && (
|
{visibleTo.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -361,18 +383,35 @@ export function MessageReader(props: Props) {
|
|||||||
error. Two separate decisions - look now, or stop asking about
|
error. Two separate decisions - look now, or stop asking about
|
||||||
this sender - so a reader can see one message without granting
|
this sender - so a reader can see one message without granting
|
||||||
anything permanently. */}
|
anything permanently. */}
|
||||||
{(props.hasBlockedContent || props.senderRemembered) && (
|
{props.isJunk && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg bg-amber-500/10 px-3 py-2.5 shadow-[inset_0_0_0_1px_rgba(245,158,11,0.35)]">
|
||||||
|
<ShieldAlert className="h-4 w-4 flex-none text-amber-400" aria-hidden />
|
||||||
|
<span className="min-w-[180px] flex-1 text-[12.5px] leading-relaxed text-amber-100">
|
||||||
|
{t("mail.junkNotice")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onNotSpam}
|
||||||
|
className="rounded-md bg-amber-400 px-2.5 py-1 text-[12.5px] font-semibold text-slate-900 transition-colors hover:bg-amber-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200"
|
||||||
|
>
|
||||||
|
{t("mail.notSpam")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.hasBlockedContent && (
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg bg-dark-card px-3 py-2 shadow-edge">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg bg-dark-card px-3 py-2 shadow-edge">
|
||||||
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" aria-hidden />
|
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" aria-hidden />
|
||||||
<span className="min-w-[180px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
|
<span className="min-w-[180px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
|
||||||
{props.imagesLoaded ? t("mail.imagesEnabled") : t("mail.remoteContentBlocked")}
|
{props.imagesLoaded ? t("mail.imagesEnabled") : t("mail.remoteContentBlocked")}
|
||||||
</span>
|
</span>
|
||||||
|
{/* A trusted sender's images are shown for as long as they are
|
||||||
|
trusted, so hiding them here would do nothing. */}
|
||||||
|
{!props.senderTrusted && (
|
||||||
<button type="button" onClick={props.onToggleImages} className={infoLinkClass}>
|
<button type="button" onClick={props.onToggleImages} className={infoLinkClass}>
|
||||||
{props.imagesLoaded ? t("mail.hideImages") : t("mail.loadImages")}
|
{props.imagesLoaded ? t("mail.hideImages") : t("mail.loadImages")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={props.onToggleRemember} className={infoLinkClass}>
|
)}
|
||||||
{props.senderRemembered ? t("mail.forgetSender") : t("mail.alwaysFromSender")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function senderKey(from: string): string {
|
|||||||
* reader to fail to render. Failing closed means falling back to blocked,
|
* reader to fail to render. Failing closed means falling back to blocked,
|
||||||
* which is the safe direction.
|
* which is the safe direction.
|
||||||
*/
|
*/
|
||||||
function readTrusted(): string[] {
|
export function readTrusted(): string[] {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(TRUSTED_SENDERS_KEY);
|
const raw = window.localStorage.getItem(TRUSTED_SENDERS_KEY);
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
@@ -86,3 +86,12 @@ export function revokeSender(from: string): void {
|
|||||||
if (!key) return;
|
if (!key) return;
|
||||||
writeTrusted(readTrusted().filter((entry) => entry !== key));
|
writeTrusted(readTrusted().filter((entry) => entry !== key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forgets the browser's own copy, once it has been moved to the server. */
|
||||||
|
export function clearLocalTrusted(): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(TRUSTED_SENDERS_KEY);
|
||||||
|
} catch {
|
||||||
|
// Nothing to clear.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ export function plainSnippet(raw: string | null | undefined): string {
|
|||||||
// A block boundary is a space, or "Hello,</p><p>You" would read "Hello,You".
|
// A block boundary is a space, or "Hello,</p><p>You" would read "Hello,You".
|
||||||
.replace(/<\/(?:p|div|li|tr|h[1-6])>|<br\s*\/?>/gi, " ")
|
.replace(/<\/(?:p|div|li|tr|h[1-6])>|<br\s*\/?>/gi, " ")
|
||||||
.replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
.replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
||||||
.replace(/<[^>]*>/g, "");
|
.replace(/<[^>]*>/g, "")
|
||||||
|
// The server cuts the preview at a fixed length, which can leave the start
|
||||||
|
// of a tag with no end ("...open</p").
|
||||||
|
.replace(/<[^>]*$/, "");
|
||||||
return decodeEntities(withoutTags).replace(/\s+/g, " ").trim();
|
return decodeEntities(withoutTags).replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { senderKey, readTrusted, clearLocalTrusted } from "./remoteImages";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The senders the signed-in account trusts, kept on the server.
|
||||||
|
*
|
||||||
|
* One decision does both jobs: mail from a trusted sender is not filed as
|
||||||
|
* spam, and its remote images load without asking. It used to be a list in one
|
||||||
|
* browser's storage, so another browser, a cleared cache or a private window
|
||||||
|
* asked again - and moving a message out of Junk taught the filter nothing.
|
||||||
|
*
|
||||||
|
* What an earlier version remembered in this browser is moved up the first
|
||||||
|
* time the list loads, so nobody has to decide a second time.
|
||||||
|
*/
|
||||||
|
export function useTrustedSenders() {
|
||||||
|
const [senders, setSenders] = useState<Set<string>>(new Set());
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/user/trusted-senders", { cache: "no-store" });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const known = new Set<string>(((await res.json()) as { senders: string[] }).senders);
|
||||||
|
|
||||||
|
const local = readTrusted().filter((address) => !known.has(address));
|
||||||
|
const moved = await Promise.all(
|
||||||
|
local.map((address) =>
|
||||||
|
fetch("/api/v1/user/trusted-senders", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ address }),
|
||||||
|
}).then((r) => r.ok),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
local.forEach((address, i) => moved[i] && known.add(address));
|
||||||
|
// Only cleared when every entry made it: a failure keeps the browser's
|
||||||
|
// copy, so the next load tries again.
|
||||||
|
if (moved.every(Boolean)) clearLocalTrusted();
|
||||||
|
|
||||||
|
if (!cancelled) setSenders(known);
|
||||||
|
} catch {
|
||||||
|
// Nothing is trusted this session, which is the safe direction.
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isTrusted = useCallback((from: string) => senders.has(senderKey(from)), [senders]);
|
||||||
|
|
||||||
|
const change = useCallback(async (from: string, trusted: boolean): Promise<boolean> => {
|
||||||
|
const address = senderKey(from);
|
||||||
|
if (!address) return false;
|
||||||
|
const apply = (on: boolean) =>
|
||||||
|
setSenders((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (on) next.add(address);
|
||||||
|
else next.delete(address);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
apply(trusted);
|
||||||
|
try {
|
||||||
|
const res = trusted
|
||||||
|
? await fetch("/api/v1/user/trusted-senders", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ address }),
|
||||||
|
})
|
||||||
|
: await fetch(`/api/v1/user/trusted-senders?address=${encodeURIComponent(address)}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error("rejected");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
apply(!trusted);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
isTrusted,
|
||||||
|
trust: (from: string) => change(from, true),
|
||||||
|
revoke: (from: string) => change(from, false),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ describe("plainSnippet", () => {
|
|||||||
it("drops a stylesheet instead of previewing its rules", () => {
|
it("drops a stylesheet instead of previewing its rules", () => {
|
||||||
expect(plainSnippet("<style>p{color:red}</style><p>Hi</p>")).toBe("Hi");
|
expect(plainSnippet("<style>p{color:red}</style><p>Hi</p>")).toBe("Hi");
|
||||||
});
|
});
|
||||||
|
it("drops a tag the preview was cut in the middle of", () => {
|
||||||
|
expect(plainSnippet("<p>Open cyber model access</p")).toBe("Open cyber model access");
|
||||||
|
});
|
||||||
it("keeps an entity it does not know", () => {
|
it("keeps an entity it does not know", () => {
|
||||||
expect(plainSnippet("a &madeup; b")).toBe("a &madeup; b");
|
expect(plainSnippet("a &madeup; b")).toBe("a &madeup; b");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user