Merge branch 'main' into feat/spam-score-panel
# Conflicts: # web/src/store/mail.ts # web/src/styles/app.css
This commit is contained in:
@@ -4,9 +4,10 @@ import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-rea
|
||||
import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
|
||||
import { useSwipeNav } from "@/lib/touch";
|
||||
import { formatMonthYear, formatTime } from "@/lib/format";
|
||||
import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
|
||||
import { Empty, useIsMobile } from "@/ui/misc";
|
||||
import { Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { EventPopover } from "./EventPopover";
|
||||
import { EventEditor, type EditorInit } from "./EventEditor";
|
||||
@@ -90,6 +91,25 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
||||
else go(view, addDays(anchor, 30 * n));
|
||||
};
|
||||
|
||||
/*
|
||||
* Swipe sideways to step the calendar, on a touchscreen only and only in the
|
||||
* two views where a period is a page: day and month. Week and agenda scroll
|
||||
* through a range rather than turning to the next one, so there is nothing a
|
||||
* sideways flick would obviously mean.
|
||||
*
|
||||
* The buttons in the toolbar stay, and so does n/p. A gesture with no
|
||||
* visible control is one only the people who already know about it can use.
|
||||
*/
|
||||
const [mainEl, setMainEl] = useState<HTMLDivElement | null>(null);
|
||||
const isTouch = useIsTouch();
|
||||
useSwipeNav(mainEl, {
|
||||
enabled: isTouch && (effectiveView === "day" || effectiveView === "month"),
|
||||
onStep: (n) => step(n),
|
||||
// Buttons live in the toolbar; an event is where a future drag-to-move
|
||||
// gesture has to start, so this one keeps out of both.
|
||||
ignore: ".cal-toolbar, .ev-chip, .ev-block, .agenda-ev",
|
||||
});
|
||||
|
||||
const openNew = useCallback(
|
||||
(start?: Date, end?: Date, allDay = false) => {
|
||||
const s = start ?? roundToNext(new Date(), 30);
|
||||
@@ -147,7 +167,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cal-main">
|
||||
<div className="cal-main" ref={setMainEl}>
|
||||
<div className="cal-toolbar">
|
||||
<button className="btn btn-sm" onClick={() => go(view, new Date())}>{translate("Today")}</button>
|
||||
<button className="icon-btn sm" onClick={() => step(-1)} aria-label={translate("Previous")}><ChevronLeft size={18} /></button>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { Archive, ArrowLeft, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
|
||||
import { Archive, ArrowLeft, CalendarDays, CalendarRange, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useMail, type ListState } from "@/store/mail";
|
||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
import { formatListDate } from "@/lib/format";
|
||||
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||
import { displayName, shortName } from "@/lib/address";
|
||||
import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
||||
@@ -224,6 +225,22 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
);
|
||||
|
||||
const ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]);
|
||||
|
||||
/*
|
||||
* Name the destination where there is only one, so the menu says where the
|
||||
* mail is actually going rather than describing the rule. A selection that
|
||||
* spans months has no single answer, and claiming one would be worse than
|
||||
* naming the rule -- so that case falls back to it.
|
||||
*/
|
||||
const archiveDateLabel = useCallback(
|
||||
(granularity: ArchiveGranularity) => {
|
||||
const groups = groupByArchivePath(ctxTargets.map((id) => ({ id, receivedAt: emails[id]?.receivedAt })), granularity);
|
||||
const only = groups.length === 1 ? groups[0]! : null;
|
||||
if (only?.segments.length) return t("Archive to {folder}", { folder: archivePath(only.segments) });
|
||||
return granularity === "year" ? t("Archive by year") : t("Archive by month");
|
||||
},
|
||||
[ctxTargets, emails],
|
||||
);
|
||||
const allSelected = ids.length > 0 && ids.every((id) => selected[id]);
|
||||
const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen);
|
||||
const someUnstarred = ctxTargets.some((id) => !emails[id]?.keywords.$flagged);
|
||||
@@ -478,6 +495,8 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
<MenuItem icon={<MailPlus size={16} />} label={t("Compose as new")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().composeAsNew(e); }} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Archive size={16} />} label={t("Archive")} kbd="e" onClick={() => void actions.archive(ctxTargets)} />
|
||||
<MenuItem icon={<CalendarRange size={16} />} label={archiveDateLabel("year")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "year")} />
|
||||
<MenuItem icon={<CalendarDays size={16} />} label={archiveDateLabel("month")} onClick={() => void useMail.getState().archiveByDate(ctxTargets, "month")} />
|
||||
<MenuItem icon={<Trash2 size={16} />} label={t("Delete")} kbd="#" onClick={() => void actions.trash(ctxTargets)} />
|
||||
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
|
||||
<MenuSep />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Dialog } from "@/ui/dialog";
|
||||
import { RichEditor } from "../compose/RichEditor";
|
||||
import { htmlToText } from "@/lib/text";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { PLACEHOLDER_NAMES } from "@/lib/templatePlaceholders";
|
||||
|
||||
export function TemplatesSettings() {
|
||||
const templates = useSettings((s) => s.settings.templates);
|
||||
@@ -37,8 +38,48 @@ export function TemplatesSettings() {
|
||||
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder={translate("Template text…")} />
|
||||
</div>
|
||||
</div>
|
||||
<PlaceholderReference />
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What each placeholder answers, shown under the body so the names are
|
||||
* discoverable without documentation. Rendered from `PLACEHOLDER_NAMES` rather
|
||||
* than from this map, so a name added to the lib and forgotten here appears
|
||||
* with no description instead of quietly not appearing at all.
|
||||
*/
|
||||
function placeholderHint(name: string): string {
|
||||
switch (name) {
|
||||
case "recipientName": return translate("Who the message is addressed to");
|
||||
case "recipientFirstName": return translate("Their first name alone");
|
||||
case "recipientEmail": return translate("Their address");
|
||||
case "myName": return translate("The name on the identity you are sending as");
|
||||
case "myEmail": return translate("That identity's address");
|
||||
case "subject": return translate("The subject already on the message");
|
||||
case "date": return translate("Today, in your date format");
|
||||
case "time": return translate("Now, on your clock");
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
function PlaceholderReference() {
|
||||
return (
|
||||
<div className="field">
|
||||
<label>{translate("Placeholders")}</label>
|
||||
<div className="placeholder-list">
|
||||
{PLACEHOLDER_NAMES.map((name) => (
|
||||
<div key={name} className="placeholder-row">
|
||||
<code>{`{{${name}}}`}</code>
|
||||
<span className="hint">{placeholderHint(name)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="hint">
|
||||
{translate("Filled in when the template is inserted, so you can edit the result before sending. One that cannot be answered yet — a recipient's name on a message you have not addressed — is left in the body as written, rather than becoming a blank.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user