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:
Lucas RayanandClaude Opus 5 committed 2026-08-22 13:18:08 -03:00
1 parent ac0521cb9b
commit f179cb1f27
20 files changed
+911 -37

No files matched your search

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