feat(webmail): pastas próprias na barra lateral e regras em linguagem comum
Regras. A tela pedia um tipo de correspondência do Sieve pelo nome - :contains, :is, :matches (regex) - e colava o valor escrito diretamente num script. Punha uma linguagem de padrões à frente de quem só queria dizer "o que vem do banco vai para Faturas", e bastava uma aspa no valor para o script deixar de fazer parse, desativando silenciosamente todas as regras de uma vez. O vocabulário passa a ser o de quem escreve: um campo, uma condição em português, um valor e uma ação. Os curingas são gerados a partir de "começa com" e "termina com" - ninguém os escreve, e um asterisco escrito por acaso continua a ser um asterisco. A pasta de destino é escolhida de uma lista das pastas que existem mesmo, em vez de ser escrita à mão: antes uma regra podia nomear uma pasta inexistente e nunca disparar, sem dizer porquê. A ordem passa a ser visível e alterável, porque a primeira regra que arquiva uma mensagem decide para onde ela vai. Um script que esta tela não consegue exprimir - escrito à mão, ou por um cliente de secretária sobre ManageSieve - é mostrado tal como está, em vez de ser reescrito na coisa mais parecida que este formulário sabe dizer, o que mudaria em silêncio o que o correio de alguém faz. Pastas próprias. Existiam na base de dados e as regras podiam arquivar nelas, mas a barra lateral listava só as seis fixas, portanto não havia como ver uma - nem como criar. Criar fica onde a vontade aparece, na própria barra; apagar avisa que leva as mensagens junto, porque leva. Encontrado a escrever isto: o motor tratava "\\*" como barra seguida de curinga em vez de um asterisco literal. Como é esta tela que gera os curingas, uma regra sobre um assunto com "50% *" teria correspondido a todos os assuntos começados por "50% ". Corrigido no motor, com testes dos dois lados. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
42420a7de2
commit
d403c47a71
12 files changed
+828
-80
No files matched your search
@@ -11,6 +11,8 @@ import {
|
||||
Archive,
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
Folder as FolderIcon,
|
||||
Plus,
|
||||
PenSquare,
|
||||
LogOut,
|
||||
Mail,
|
||||
@@ -24,7 +26,7 @@ import { useTranslations } from "../../../i18n/provider";
|
||||
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||
import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
||||
import { useFolders } from "../../../lib/useFolders";
|
||||
import { folderBadge } from "../../../lib/mailCounts";
|
||||
import { folderBadge, customFolders } from "../../../lib/mailCounts";
|
||||
import { initialsFor } from "../../../lib/initials";
|
||||
|
||||
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -34,7 +36,31 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
const account = useAccount("user");
|
||||
// Live, not fetched once: these badges used to freeze at the value they had
|
||||
// when the tab was opened.
|
||||
const { folders } = useFolders();
|
||||
const { folders, reload: reloadFolders } = useFolders();
|
||||
const [newFolder, setNewFolder] = useState("");
|
||||
const [folderError, setFolderError] = useState<string | null>(null);
|
||||
|
||||
/** Creates, renames or deletes one of the user's own folders. */
|
||||
const manageFolder = async (body: Record<string, string>) => {
|
||||
setFolderError(null);
|
||||
try {
|
||||
const res = await fetch("/api/v1/mail/folders/manage", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setFolderError(data.message ?? t("errors.serverError"));
|
||||
return false;
|
||||
}
|
||||
reloadFolders();
|
||||
return true;
|
||||
} catch {
|
||||
setFolderError(t("errors.serverError"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ href: "/user/mail/inbox", label: t("mail.inbox"), icon: Inbox, role: "inbox" },
|
||||
@@ -137,6 +163,91 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The folders the user made. They exist in the database and the rules
|
||||
can file into them, but the sidebar listed only the fixed six, so
|
||||
there was no way to see one - or to make one. */}
|
||||
{customFolders(folders).length > 0 && <div className="lm-rule my-2.5" />}
|
||||
{customFolders(folders).map((folder) => {
|
||||
const badge = folderBadge(folders, folder.name);
|
||||
const href = `/user/mail/${encodeURIComponent(folder.name)}`;
|
||||
const isActive = decodeURIComponent(pathname) === decodeURIComponent(href);
|
||||
return (
|
||||
<div key={folder.name} className="group/folder relative">
|
||||
<Link
|
||||
href={href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
data-active={isActive}
|
||||
title={folder.name}
|
||||
className="lm-nav"
|
||||
>
|
||||
<span className="lm-nav-mark" />
|
||||
<FolderIcon
|
||||
className={`h-[17px] w-[17px] flex-none ${isActive ? "text-indigo-500" : "text-slate-400"}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] leading-snug">{folder.name}</span>
|
||||
<span className="flex flex-none items-center gap-1.5 pr-5">
|
||||
{badge.total > 0 && (
|
||||
<span className="text-[11.5px] tabular-nums text-slate-500">{badge.total}</span>
|
||||
)}
|
||||
{badge.showsUnread && (
|
||||
<span className="rounded-full bg-indigo-500/20 px-1.5 py-px text-[11px] font-medium tabular-nums text-indigo-300">
|
||||
{badge.unread}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
{/* Deleting takes the mail inside with it, so the confirmation
|
||||
says so rather than asking a bare "are you sure". */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("folders.delete", { name: folder.name })}
|
||||
title={t("folders.delete", { name: folder.name })}
|
||||
onClick={() => {
|
||||
if (!window.confirm(t("folders.deleteConfirm", { name: folder.name }))) return;
|
||||
void manageFolder({ action: "delete", name: folder.name });
|
||||
}}
|
||||
className="absolute right-1.5 top-1/2 hidden h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-white/[0.09] hover:text-slate-200 focus:flex group-hover/folder:flex"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Creating one. Inline rather than behind a settings page: a folder is
|
||||
made at the moment the user realises they want one. */}
|
||||
<form
|
||||
className="mt-1.5 flex items-center gap-1.5 px-1"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const name = newFolder.trim();
|
||||
if (!name) return;
|
||||
if (await manageFolder({ action: "create", name })) setNewFolder("");
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={newFolder}
|
||||
onChange={(e) => setNewFolder(e.target.value)}
|
||||
placeholder={t("folders.newPlaceholder")}
|
||||
aria-label={t("folders.new")}
|
||||
maxLength={100}
|
||||
className="min-w-0 flex-1 rounded-lg bg-dark-panel px-2.5 py-1.5 text-[12.5px] text-slate-200 shadow-edge placeholder:text-slate-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={t("folders.new")}
|
||||
title={t("folders.new")}
|
||||
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
{folderError && (
|
||||
<p role="alert" className="px-1 pt-1 text-[11.5px] leading-snug text-amber-300">
|
||||
{folderError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="lm-rule my-2.5" />
|
||||
|
||||
<Link
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Monitor,
|
||||
CheckCircle2,
|
||||
Trash2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Lock,
|
||||
Languages,
|
||||
@@ -20,6 +22,9 @@ import { Button } from "../../../../components/ui/Button";
|
||||
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
||||
import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
|
||||
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
|
||||
import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules";
|
||||
import { useFolders } from "../../../../lib/useFolders";
|
||||
import { moveTargets } from "../../../../lib/mailCounts";
|
||||
|
||||
interface SieveRule {
|
||||
id: string;
|
||||
@@ -87,11 +92,17 @@ export default function UserSettingsPage() {
|
||||
// used to sit here, and because saving posts the whole list, the first time
|
||||
// anyone added or removed a rule that invented filter was written into their
|
||||
// real Sieve script and began moving their mail.
|
||||
const [rules, setRules] = useState<SieveRule[]>([]);
|
||||
const [newField, setNewField] = useState<"From" | "Subject" | "Header">("Subject");
|
||||
const [newMatch, setNewMatch] = useState<"contains" | "equals" | "matches">("contains");
|
||||
const { folders } = useFolders();
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
// A script this screen cannot express - hand-written, or written by a
|
||||
// desktop client over ManageSieve. Shown as it is rather than rewritten into
|
||||
// the nearest thing this form can say, which would quietly change what the
|
||||
// user's mail does.
|
||||
const [foreignScript, setForeignScript] = useState<string | null>(null);
|
||||
const [newField, setNewField] = useState<RuleField>("subject");
|
||||
const [newCondition, setNewCondition] = useState<RuleCondition>("contains");
|
||||
const [newAction, setNewAction] = useState<RuleAction>("move");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [newAction, setNewAction] = useState<"move" | "flag" | "discard" | "redirect">("move");
|
||||
const [newTarget, setNewTarget] = useState("Archive");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,10 +118,18 @@ export default function UserSettingsPage() {
|
||||
.catch(() => undefined);
|
||||
|
||||
fetch("/api/v1/user/sieve")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.script && data.script.includes("vacation")) {
|
||||
setVacationEnabled(data.is_active);
|
||||
const script = typeof data?.script === "string" ? data.script : "";
|
||||
const parsed = parseSieve(script);
|
||||
if (parsed === null) {
|
||||
// Not expressible in this form; shown verbatim instead of being
|
||||
// rewritten into something simpler that means something else.
|
||||
setForeignScript(script);
|
||||
setRules([]);
|
||||
} else {
|
||||
setForeignScript(null);
|
||||
setRules(parsed);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
@@ -146,44 +165,46 @@ export default function UserSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const addSieveRule = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newValue) return;
|
||||
const updated = [
|
||||
...rules,
|
||||
{
|
||||
id: `rule-${Date.now()}`,
|
||||
field: newField,
|
||||
match: newMatch,
|
||||
value: newValue,
|
||||
action: newAction,
|
||||
targetFolder: newTarget,
|
||||
},
|
||||
];
|
||||
/** Writes the whole rule list to the server as one script. */
|
||||
const persistRules = async (updated: Rule[]) => {
|
||||
setRules(updated);
|
||||
setNewValue("");
|
||||
|
||||
const scriptLines = updated.map((r) =>
|
||||
`if header :${r.match} "${r.field}" "${r.value}" { fileinto "${r.targetFolder || "INBOX"}"; }`
|
||||
);
|
||||
await fetch("/api/v1/user/sieve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "user-rules", script: scriptLines.join("\n"), is_active: true }),
|
||||
body: JSON.stringify({ name: "user-rules", script: buildSieve(updated), is_active: true }),
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const addSieveRule = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const value = newValue.trim();
|
||||
if (!value) return;
|
||||
await persistRules([
|
||||
...rules,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
field: newField,
|
||||
condition: newCondition,
|
||||
value,
|
||||
action: newAction,
|
||||
target: newTarget,
|
||||
},
|
||||
]);
|
||||
setNewValue("");
|
||||
};
|
||||
|
||||
const removeSieveRule = async (id: string) => {
|
||||
const updated = rules.filter((r) => r.id !== id);
|
||||
setRules(updated);
|
||||
const scriptLines = updated.map((r) =>
|
||||
`if header :${r.match} "${r.field}" "${r.value}" { fileinto "${r.targetFolder || "INBOX"}"; }`
|
||||
);
|
||||
await fetch("/api/v1/user/sieve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "user-rules", script: scriptLines.join("\n"), is_active: true }),
|
||||
}).catch(() => undefined);
|
||||
await persistRules(rules.filter((r) => r.id !== id));
|
||||
};
|
||||
|
||||
/** Moves a rule, since the first one that files a message decides. */
|
||||
const moveRule = async (index: number, delta: number) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= rules.length) return;
|
||||
const reordered = [...rules];
|
||||
const [moved] = reordered.splice(index, 1);
|
||||
reordered.splice(target, 0, moved);
|
||||
await persistRules(reordered);
|
||||
};
|
||||
|
||||
const saveVacationResponder = (e: React.FormEvent) => {
|
||||
@@ -561,52 +582,62 @@ export default function UserSettingsPage() {
|
||||
{/* The rule reads as a sentence: IF <field> <match> <value> THEN
|
||||
<action>. Each control keeps its own label, so the row still
|
||||
makes sense when it wraps on a narrow window. */}
|
||||
{/* The rule reads as a sentence: IF <field> <condition>
|
||||
<value> THEN <action>. The vocabulary is deliberately the
|
||||
user's, not Sieve's: the old form offered ":matches (regex)"
|
||||
and pasted whatever was typed into a script, which asked
|
||||
someone who wanted "mail from the bank goes in Faturas" to
|
||||
write a pattern language. Wildcards are now generated from
|
||||
"starts with" and "ends with"; nobody types one. */}
|
||||
<form onSubmit={addSieveRule} className="flex flex-wrap items-end gap-2.5">
|
||||
<div className="pb-2.5 text-xs uppercase tracking-[0.08em] text-slate-400">
|
||||
{t("settings.ruleIf")}
|
||||
</div>
|
||||
<div className="min-w-[150px] flex-1">
|
||||
<div className="min-w-[140px] flex-1">
|
||||
<label htmlFor="rule-field" className={fieldLabel}>
|
||||
{t("ui.eventAction")}
|
||||
{t("settings.ruleField")}
|
||||
</label>
|
||||
<select
|
||||
id="rule-field"
|
||||
value={newField}
|
||||
onChange={(e) => setNewField(e.target.value as "From" | "Subject" | "Header")}
|
||||
onChange={(e) => setNewField(e.target.value as RuleField)}
|
||||
className={input}
|
||||
>
|
||||
<option value="Subject">{t("settings.fieldSubject")}</option>
|
||||
<option value="From">{t("settings.fieldFrom")}</option>
|
||||
<option value="Header">{t("settings.fieldHeader")}</option>
|
||||
<option value="subject">{t("settings.fieldSubject")}</option>
|
||||
<option value="from">{t("settings.fieldFrom")}</option>
|
||||
<option value="to">{t("settings.fieldTo")}</option>
|
||||
<option value="cc">{t("settings.fieldCc")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[150px] flex-1">
|
||||
<label htmlFor="rule-match" className={fieldLabel}>
|
||||
{t("common.status")}
|
||||
<div className="min-w-[160px] flex-1">
|
||||
<label htmlFor="rule-condition" className={fieldLabel}>
|
||||
{t("settings.ruleCondition")}
|
||||
</label>
|
||||
<select
|
||||
id="rule-match"
|
||||
value={newMatch}
|
||||
onChange={(e) => setNewMatch(e.target.value as "contains" | "equals" | "matches")}
|
||||
id="rule-condition"
|
||||
value={newCondition}
|
||||
onChange={(e) => setNewCondition(e.target.value as RuleCondition)}
|
||||
className={input}
|
||||
>
|
||||
<option value="contains">{t("settings.matchContains")}</option>
|
||||
<option value="equals">{t("settings.matchEquals")}</option>
|
||||
<option value="matches">{t("settings.matchRegex")}</option>
|
||||
<option value="contains">{t("settings.condContains")}</option>
|
||||
<option value="is">{t("settings.condIs")}</option>
|
||||
<option value="startsWith">{t("settings.condStartsWith")}</option>
|
||||
<option value="endsWith">{t("settings.condEndsWith")}</option>
|
||||
<option value="notContains">{t("settings.condNotContains")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[200px] flex-[2]">
|
||||
<label htmlFor="rule-value" className={fieldLabel}>
|
||||
{t("settings.valuePlaceholder")}
|
||||
{t("settings.ruleValue")}
|
||||
</label>
|
||||
<input
|
||||
id="rule-value"
|
||||
type="text"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
placeholder={t("settings.valuePlaceholder")}
|
||||
placeholder={t("settings.ruleValuePlaceholder")}
|
||||
required
|
||||
className={input}
|
||||
/>
|
||||
@@ -622,15 +653,39 @@ export default function UserSettingsPage() {
|
||||
<select
|
||||
id="rule-action"
|
||||
value={newAction}
|
||||
onChange={(e) => setNewAction(e.target.value as "move" | "flag" | "discard" | "redirect")}
|
||||
onChange={(e) => setNewAction(e.target.value as RuleAction)}
|
||||
className={input}
|
||||
>
|
||||
<option value="move">{t("settings.actionMove")}</option>
|
||||
<option value="markRead">{t("settings.actionMarkRead")}</option>
|
||||
<option value="flag">{t("settings.actionFlag")}</option>
|
||||
<option value="discard">{t("settings.actionDiscard")}</option>
|
||||
<option value="delete">{t("settings.actionDiscard")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Real folders, listed from the mailbox: the old form typed a
|
||||
folder name into the script by hand, so a rule could name a
|
||||
folder that did not exist and simply never fired. */}
|
||||
{newAction === "move" && (
|
||||
<div className="min-w-[170px] flex-1">
|
||||
<label htmlFor="rule-target" className={fieldLabel}>
|
||||
{t("mail.moveTo")}
|
||||
</label>
|
||||
<select
|
||||
id="rule-target"
|
||||
value={newTarget}
|
||||
onChange={(e) => setNewTarget(e.target.value)}
|
||||
className={input}
|
||||
>
|
||||
{moveTargets(folders, "").map((folder) => (
|
||||
<option key={folder.name} value={folder.name}>
|
||||
{folder.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" variant="primary" size="md" className="flex-none">
|
||||
<Plus className="h-[15px] w-[15px]" />
|
||||
<span>{t("settings.addRule")}</span>
|
||||
@@ -638,18 +693,63 @@ export default function UserSettingsPage() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* A script this form cannot express is shown as it is. Rewriting
|
||||
somebody's rules into the nearest thing this vocabulary can say
|
||||
would quietly change what their mail does. */}
|
||||
{foreignScript !== null && (
|
||||
<section className={panel}>
|
||||
<h2 className={panelTitle}>{t("settings.foreignScriptTitle")}</h2>
|
||||
<p className={panelIntro}>{t("settings.foreignScriptIntro")}</p>
|
||||
<pre className="overflow-x-auto rounded-xl bg-dark-panel p-3.5 font-mono text-[12.5px] leading-relaxed text-slate-300 shadow-edge">
|
||||
{foreignScript}
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{rules.length > 0 && (
|
||||
<section className={panel}>
|
||||
<p className={panelIntro}>{t("settings.ruleOrderNote")}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{rules.map((r, index) => (
|
||||
<div key={r.id} className={tile}>
|
||||
<span className="flex h-[22px] w-[22px] flex-none items-center justify-center rounded-[7px] bg-indigo-900 text-[11px] text-indigo-300">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-[200px] flex-1 break-words font-mono text-[13px] leading-relaxed text-slate-200">
|
||||
{t("settings.ruleIf")} <strong className="font-medium">{r.field}</strong> {r.match} "
|
||||
{r.value}" → {t("settings.ruleThen")} {r.action.toUpperCase()}
|
||||
{/* Read back as the sentence it was written as, not as
|
||||
the Sieve it became. */}
|
||||
<span className="min-w-[200px] flex-1 break-words text-[13px] leading-relaxed text-slate-200">
|
||||
{t("settings.ruleIf")}{" "}
|
||||
<strong className="font-medium">{t(`settings.field${r.field.charAt(0).toUpperCase()}${r.field.slice(1)}`)}</strong>{" "}
|
||||
{t(`settings.cond${r.condition.charAt(0).toUpperCase()}${r.condition.slice(1)}`).toLowerCase()}{" "}
|
||||
<strong className="font-medium">"{r.value}"</strong>
|
||||
{" \u2192 "}
|
||||
{r.action === "move"
|
||||
? `${t("mail.moveTo")} ${r.target}`
|
||||
: t(`settings.action${r.action === "delete" ? "Discard" : r.action.charAt(0).toUpperCase() + r.action.slice(1)}`)}
|
||||
</span>
|
||||
{/* Order matters: the first rule that files a message
|
||||
decides where it lands, so the list has to be
|
||||
rearrangeable. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void moveRule(index, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label={t("settings.ruleMoveUp")}
|
||||
title={t("settings.ruleMoveUp")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100 disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void moveRule(index, 1)}
|
||||
disabled={index === rules.length - 1}
|
||||
aria-label={t("settings.ruleMoveDown")}
|
||||
title={t("settings.ruleMoveDown")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100 disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSieveRule(r.id)}
|
||||
|
||||
Reference in new issue
Block a user