fix(webmail): acertos de interface e mover mensagens entre pastas
Cc/Cco. Os dois rótulos do compositor eram o último texto em inglês que restava, e "Bcc" tem nome próprio em português - quem não conhece a abreviatura inglesa não adivinha o que o campo faz. Larguras. A barra lateral tinha 244px fixos e nela não cabia "Console de administração", que transbordava no telemóvel e no computador: o botão passa a mostrar um rótulo curto e guarda o nome completo no tooltip. O endereço da conta usava break-words e partia-se a meio do domínio, deixando um "br" sozinho na linha de baixo; passa a truncar com o valor inteiro no tooltip. A lista tinha 404px fixos e o leitor 760px centrados, portanto num ecrã largo os assuntos eram espremidos numa coluna estreita enquanto o leitor ficava entre duas margens enormes. Ambos crescem agora com a janela. O assunto ganha overflow-wrap: os Message-ID dos relatórios do Google são uma única "palavra" sem espaços, mais longa que a coluna, e sem sítio onde quebrar saíam da linha. Imagens remotas. A escolha era reposta sempre que uma mensagem era aberta, por isso a mesma newsletter tinha de ser desbloqueada em cada leitura. A decisão passa a ser lembrada por remetente, com um segundo botão para a retirar. Bloqueado continua a ser o que acontece por omissão: uma imagem remota é um aviso de leitura que o remetente não pediu licença para receber. Assinatura. Era escrita num textarea - texto simples com quebras de linha reais - e injetada no compositor com innerHTML, onde uma quebra de linha é espaço em branco: todas desapareciam. Passa a ser um campo rico, com negrito, itálico, links e imagens, guardado como HTML e sanitizado nos dois sentidos, já que este valor é injetado como marcação na origem do webmail. Assinaturas guardadas pelo campo antigo continuam a funcionar: as suas quebras de linha são convertidas. Mover entre pastas. Faltava a única ação corrente que o leitor não tinha: arquivar uma mensagem para fora da caixa de entrada era impossível. Enviados e Rascunhos nunca são destino - descrevem como a mensagem veio a existir, não onde o leitor a arrumou - mas sair deles é permitido. Pastas criadas pelo utilizador, que não têm papel special-use, são destino como qualquer outra. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac0521cb9b
commit
f179cb1f27
20 files changed
+911
-37
No files matched your search
@@ -19,6 +19,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
import { signatureToHtml } from "../../../../lib/signature";
|
||||
|
||||
/** Splits a comma or semicolon separated field, dropping empty entries. */
|
||||
function splitAddresses(value: string): string[] {
|
||||
@@ -69,8 +70,12 @@ export default function ComposePage({
|
||||
|
||||
useEffect(() => {
|
||||
const savedSig = localStorage.getItem("lm_user_signature");
|
||||
if (editorRef.current && savedSig) {
|
||||
const initial = `<br/><br/>--<br/>${savedSig}`;
|
||||
// signatureToHtml, not the raw value: the signature may be the plain text
|
||||
// of the old field, whose newlines are whitespace once assigned as HTML -
|
||||
// which is why every line break in a signature used to vanish here.
|
||||
const signatureHtml = signatureToHtml(savedSig ?? "");
|
||||
if (editorRef.current && signatureHtml) {
|
||||
const initial = `<br/><br/>--<br/>${signatureHtml}`;
|
||||
editorRef.current.innerHTML = initial;
|
||||
setBody(initial);
|
||||
}
|
||||
@@ -322,7 +327,7 @@ export default function ComposePage({
|
||||
<>
|
||||
<div className={fieldRow}>
|
||||
<label htmlFor="compose-cc" className={fieldLabel}>
|
||||
Cc
|
||||
{t("mail.ccLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="compose-cc"
|
||||
@@ -336,7 +341,7 @@ export default function ComposePage({
|
||||
</div>
|
||||
<div className={fieldRow}>
|
||||
<label htmlFor="compose-bcc" className={fieldLabel}>
|
||||
Bcc
|
||||
{t("mail.bccLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="compose-bcc"
|
||||
|
||||
@@ -167,10 +167,12 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
</span>
|
||||
<Link
|
||||
href="/admin/step-up"
|
||||
title={t("admin.openAdmin")}
|
||||
aria-label={t("admin.openAdmin")}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-white/[0.05] hover:text-slate-100"
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-none" />
|
||||
<span className="min-w-0 truncate">{t("admin.openAdmin")}</span>
|
||||
<span className="min-w-0 truncate">{t("admin.openAdminShort")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -184,7 +186,10 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
{initialsFor(account?.email)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-words text-[12.5px] leading-tight">
|
||||
<span
|
||||
className="block truncate text-[12.5px] leading-tight"
|
||||
title={account?.email ?? undefined}
|
||||
>
|
||||
{account?.email ?? t("common.loading")}
|
||||
</span>
|
||||
{/* Reports what is actually configured. The old fixed "2FA
|
||||
@@ -242,7 +247,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
</div>
|
||||
|
||||
{/* Desktop rail */}
|
||||
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
|
||||
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
|
||||
{SidebarContent}
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Clock,
|
||||
MailOpen,
|
||||
Download,
|
||||
FolderInput,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
@@ -30,9 +31,11 @@ import {
|
||||
listHeaderCount,
|
||||
applySeen,
|
||||
readerActions,
|
||||
moveTargets,
|
||||
type ListFilter,
|
||||
} from "../../../../../lib/mailCounts";
|
||||
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
|
||||
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
|
||||
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
|
||||
import { Button } from "../../../../../components/ui/Button";
|
||||
|
||||
@@ -113,6 +116,11 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
const [messages, setMessages] = useState<MessageSummary[]>([]);
|
||||
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
||||
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
||||
// Whether this sender has a standing decision, so the banner can offer to
|
||||
// take it back rather than only to grant it.
|
||||
const [rememberSender, setRememberSender] = useState(false);
|
||||
// Whether the "move to" menu is open.
|
||||
const [moveOpen, setMoveOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeFilter, setActiveFilter] = useState<ListFilter>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -245,6 +253,30 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
[folder, t, loadMessages, searchQuery]
|
||||
);
|
||||
|
||||
/** Files one message into another folder. */
|
||||
const moveMessage = useCallback(
|
||||
async (uid: number, target: string) => {
|
||||
setMessages((current) => current.filter((m) => m.uid !== uid));
|
||||
setSelected((current) => (current?.uid === uid ? null : current));
|
||||
setMoveOpen(false);
|
||||
try {
|
||||
const res = await fetch("/api/v1/mail/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ folder, uid, target }),
|
||||
});
|
||||
if (!res.ok) throw new Error(t("errors.serverError"));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("errors.serverError"));
|
||||
void loadMessages(searchQuery);
|
||||
} finally {
|
||||
// Both folders changed, so the badges have to re-read.
|
||||
notifyMailStateChanged();
|
||||
}
|
||||
},
|
||||
[folder, t, loadMessages, searchQuery]
|
||||
);
|
||||
|
||||
const openMessage = async (uid: number) => {
|
||||
// A draft is an unfinished message: opening it means carrying on writing,
|
||||
// not reading it in a pane that offers to reply to yourself.
|
||||
@@ -253,13 +285,19 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
return;
|
||||
}
|
||||
setLoadRemoteImages(false);
|
||||
setRememberSender(false);
|
||||
setReaderHeight(420);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(t("errors.serverError"));
|
||||
setSelected(await res.json());
|
||||
const message = await res.json();
|
||||
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
|
||||
// brings the list and the badges into line with what already happened.
|
||||
void setSeen(uid, true, { persist: false });
|
||||
@@ -329,7 +367,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Messages List Column */}
|
||||
<div
|
||||
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:w-[404px] md:flex-none`}
|
||||
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:w-[380px] md:flex-none lg:w-[420px] xl:w-[460px] 2xl:w-[500px]`}
|
||||
>
|
||||
{/* Folder heading, with the counts that belong to the folder rather
|
||||
than to the page of it that happens to be loaded. */}
|
||||
@@ -444,15 +482,20 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
</span>
|
||||
</span>
|
||||
{/* The subject wraps instead of being clipped - a truncated
|
||||
subject is the one thing a mail list must not do. */}
|
||||
subject is the one thing a mail list must not do.
|
||||
overflow-wrap:anywhere as well as wrapping, because a
|
||||
report's Message-ID is a single unbroken "word" longer
|
||||
than the column: with nowhere to break it, it ran
|
||||
straight out of the row. Clamped so one such subject
|
||||
cannot push the rest of the list off the screen. */}
|
||||
<span
|
||||
className={`text-[13.5px] leading-snug ${
|
||||
className={`line-clamp-3 text-[13.5px] leading-snug [overflow-wrap:anywhere] ${
|
||||
msg.seen ? "text-slate-300" : "text-slate-100"
|
||||
}`}
|
||||
>
|
||||
{msg.subject || t("mail.noSubject")}
|
||||
</span>
|
||||
<span className="line-clamp-2 text-xs leading-relaxed text-slate-400">
|
||||
<span className="line-clamp-2 text-xs leading-relaxed text-slate-400 [overflow-wrap:anywhere]">
|
||||
{msg.snippet}
|
||||
</span>
|
||||
{msg.has_attachments && (
|
||||
@@ -503,7 +546,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="mx-auto flex w-full max-w-[760px] flex-col gap-5 px-5 pb-10 pt-6 sm:px-7"
|
||||
className="mx-auto flex w-full max-w-[860px] flex-col gap-5 px-5 pb-10 pt-6 sm:px-7 xl:max-w-[1000px] 2xl:max-w-[1140px]"
|
||||
>
|
||||
{/* Toolbar: closing, replying and read state all in one row, so
|
||||
the reader is not the only pane with no way out of itself. */}
|
||||
@@ -555,6 +598,33 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Filing a message elsewhere: the one ordinary action the
|
||||
reader still had no button for. */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setMoveOpen((open) => !open)}
|
||||
title={t("mail.moveTo")}
|
||||
>
|
||||
<FolderInput className="h-3.5 w-3.5" />
|
||||
<span>{t("mail.moveTo")}</span>
|
||||
</Button>
|
||||
{moveOpen && (
|
||||
<div className="absolute left-0 top-full z-20 mt-1 min-w-[190px] overflow-hidden rounded-xl bg-dark-panel py-1 shadow-edge">
|
||||
{moveTargets(folders, folder).map((target) => (
|
||||
<button
|
||||
key={target.name}
|
||||
type="button"
|
||||
onClick={() => void moveMessage(selected.uid, target.name)}
|
||||
className="block w-full px-3.5 py-2 text-left text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]"
|
||||
>
|
||||
{FOLDER_LABELS[(target.special_use || target.name).toLowerCase()] ?? target.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -623,18 +693,38 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
{/* Remote content banner, shown only when something was actually
|
||||
held back rather than over every message. The wording is the
|
||||
reassurance, not the colour: this is not an error. */}
|
||||
{hasBlockedContent && (
|
||||
{(hasBlockedContent || rememberSender) && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl bg-dark-card p-3 shadow-edge">
|
||||
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" />
|
||||
<span className="min-w-[200px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
|
||||
{t("mail.remoteContentBlocked")}
|
||||
</span>
|
||||
{/* Two separate decisions: show them now, and stop asking
|
||||
about this sender. Keeping them apart lets a reader look
|
||||
at one message without granting anything permanently. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
|
||||
>
|
||||
{loadRemoteImages ? t("mail.imagesEnabled") : t("mail.loadImages")}
|
||||
{loadRemoteImages ? t("mail.hideImages") : t("mail.loadImages")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (rememberSender) {
|
||||
revokeSender(selected.from);
|
||||
setRememberSender(false);
|
||||
setLoadRemoteImages(false);
|
||||
} else {
|
||||
trustSender(selected.from);
|
||||
setRememberSender(true);
|
||||
setLoadRemoteImages(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rememberSender ? t("mail.forgetSender") : t("mail.alwaysFromSender")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Shield,
|
||||
Filter,
|
||||
@@ -19,6 +19,7 @@ import { Badge } from "../../../../components/ui/Badge";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
||||
import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
|
||||
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
|
||||
|
||||
interface SieveRule {
|
||||
id: string;
|
||||
@@ -71,6 +72,15 @@ export default function UserSettingsPage() {
|
||||
const [vacationSaved, setVacationSaved] = useState(false);
|
||||
|
||||
const [signature, setSignature] = useState("");
|
||||
const signatureRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// execCommand edits the DOM directly, so the mirrored value is re-read here
|
||||
// rather than relying on an input event that may not be dispatched.
|
||||
const applySignatureCommand = (command: string, value?: string) => {
|
||||
signatureRef.current?.focus();
|
||||
document.execCommand(command, false, value);
|
||||
setSignature(signatureRef.current?.innerHTML ?? "");
|
||||
};
|
||||
const [sigSaved, setSigSaved] = useState(false);
|
||||
|
||||
// Starts empty rather than with a sample rule. A seeded "[Boletim]" filter
|
||||
@@ -88,7 +98,11 @@ export default function UserSettingsPage() {
|
||||
fetch("/api/v1/user/preferences")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.signature) setSignature(data.signature);
|
||||
if (data?.signature) {
|
||||
const html = signatureToHtml(data.signature);
|
||||
setSignature(html);
|
||||
if (signatureRef.current) signatureRef.current.innerHTML = html;
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
@@ -107,9 +121,9 @@ export default function UserSettingsPage() {
|
||||
await fetch("/api/v1/user/preferences", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ signature, auto_save_drafts: true }),
|
||||
body: JSON.stringify({ signature: sanitizeSignature(signature), auto_save_drafts: true }),
|
||||
});
|
||||
localStorage.setItem("lm_user_signature", signature);
|
||||
localStorage.setItem("lm_user_signature", sanitizeSignature(signature));
|
||||
setSigSaved(true);
|
||||
setTimeout(() => setSigSaved(false), 2000);
|
||||
} catch {
|
||||
@@ -748,13 +762,69 @@ export default function UserSettingsPage() {
|
||||
<label htmlFor="signature-text" className={fieldLabel}>
|
||||
{t("ui.messageBody")}
|
||||
</label>
|
||||
<textarea
|
||||
{/* A rich field rather than a textarea. A signature is a piece
|
||||
of a message - people put a link, a logo and bold type in
|
||||
it - and the plain textarea could hold none of that. It
|
||||
also stored real newlines, which are whitespace once the
|
||||
value is injected into the composer as HTML, so every line
|
||||
break was silently dropped there. */}
|
||||
<div className="flex flex-wrap items-center gap-1 rounded-t-xl bg-dark-card px-2 py-1.5 shadow-edge">
|
||||
{[
|
||||
{ cmd: "bold", label: "B", title: t("ui.bold"), cls: "font-bold" },
|
||||
{ cmd: "italic", label: "I", title: t("ui.italic"), cls: "italic" },
|
||||
{ cmd: "underline", label: "U", title: t("ui.underline"), cls: "underline" },
|
||||
].map((b) => (
|
||||
<button
|
||||
key={b.cmd}
|
||||
type="button"
|
||||
title={b.title}
|
||||
aria-label={b.title}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => applySignatureCommand(b.cmd)}
|
||||
className={`h-7 w-7 rounded-md text-[13px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100 ${b.cls}`}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-1 h-4 w-px bg-white/10" />
|
||||
<button
|
||||
type="button"
|
||||
title={t("ui.insertLink")}
|
||||
aria-label={t("ui.insertLink")}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
const url = window.prompt(t("ui.insertLink"), "https://");
|
||||
if (url) applySignatureCommand("createLink", url);
|
||||
}}
|
||||
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
|
||||
>
|
||||
{t("ui.insertLink")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("ui.insertImage")}
|
||||
aria-label={t("ui.insertImage")}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
const url = window.prompt(t("ui.insertImage"), "https://");
|
||||
if (url) applySignatureCommand("insertImage", url);
|
||||
}}
|
||||
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
|
||||
>
|
||||
{t("ui.insertImage")}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="signature-text"
|
||||
rows={7}
|
||||
value={signature}
|
||||
onChange={(e) => setSignature(e.target.value)}
|
||||
placeholder={t("settings.signaturePlaceholder")}
|
||||
className={`${input} leading-relaxed`}
|
||||
ref={signatureRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={t("settings.signatureTitle")}
|
||||
onInput={(e) => setSignature((e.target as HTMLDivElement).innerHTML)}
|
||||
onBlur={(e) => setSignature((e.target as HTMLDivElement).innerHTML)}
|
||||
className="min-h-[170px] rounded-b-xl bg-dark-panel px-3.5 py-3 text-[13.5px] leading-relaxed text-slate-200 shadow-edge focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user