@rapidmx/web-client 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +22 -10
- package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +60 -0
- package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +108 -0
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +25 -1
- package/apps/shared/components/calendar/CalendarSearchBox.tsx +83 -0
- package/apps/shared/components/calendar/EventMatchNav.tsx +72 -0
- package/apps/shared/components/calendar/EventModal.tsx +33 -12
- package/apps/shared/components/calendar/ListView.tsx +207 -0
- package/apps/shared/components/calendar/MonthView.tsx +14 -2
- package/apps/shared/components/calendar/SlideIn.tsx +47 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +5 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +23 -12
- package/apps/shared/components/calendar/activeOccurrence.ts +28 -0
- package/apps/shared/components/calendar/allDay.ts +16 -1
- package/apps/shared/components/calendar/calendarView.ts +41 -0
- package/apps/shared/components/layout/AppShell.tsx +13 -6
- package/apps/shared/components/mail/ConversationList.tsx +29 -3
- package/apps/shared/components/mail/ConversationThreadPane.tsx +15 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +117 -55
- package/apps/shared/components/mail/MessageDetailPane.tsx +3 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
- package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
- package/apps/shared/components/mail/labelMenu.tsx +4 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
- package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
- package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
- package/apps/shared/gestures/useEnterSlide.ts +1 -1
- package/apps/shared/keyboard/keymap.ts +2 -0
- package/apps/shared/mail/rangeSelection.ts +45 -0
- package/apps/shared/mail/rowExit.ts +39 -0
- package/apps/www/calendar/index.tsx +251 -53
- package/apps/www/index.tsx +522 -343
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
- package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
- package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
- package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
- package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
- package/dist/apps/shared/components/calendar/EventModal.js +13 -11
- package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
- package/dist/apps/shared/components/calendar/ListView.js +111 -0
- package/dist/apps/shared/components/calendar/MonthView.js +12 -3
- package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
- package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
- package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
- package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
- package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
- package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
- package/dist/apps/shared/components/calendar/allDay.js +14 -1
- package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
- package/dist/apps/shared/components/calendar/calendarView.js +36 -0
- package/dist/apps/shared/components/layout/AppShell.js +8 -4
- package/dist/apps/shared/components/mail/ConversationList.d.ts +9 -1
- package/dist/apps/shared/components/mail/ConversationList.js +10 -2
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
- package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
- package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
- package/dist/apps/shared/components/mail/labelMenu.js +2 -2
- package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
- package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
- package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
- package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
- package/dist/apps/shared/keyboard/keymap.js +2 -0
- package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
- package/dist/apps/shared/mail/rangeSelection.js +34 -0
- package/dist/apps/shared/mail/rowExit.d.ts +12 -0
- package/dist/apps/shared/mail/rowExit.js +33 -0
- package/dist/apps/www/calendar/index.js +177 -18
- package/dist/apps/www/index.js +220 -65
- package/dist/lib/calendar/eventSearch.d.ts +42 -0
- package/dist/lib/calendar/eventSearch.js +152 -0
- package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
- package/dist/lib/components/navigation/BottomTabBar.js +52 -15
- package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
- package/dist/lib/components/navigation/NavBadge.js +18 -0
- package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
- package/dist/lib/mail/compose/quotedImages.js +41 -0
- package/dist/lib/mail/inlineImages.d.ts +38 -0
- package/dist/lib/mail/inlineImages.js +90 -0
- package/dist/lib/mail/mailApi.d.ts +8 -1
- package/dist/lib/mail/mailApi.js +57 -5
- package/lib/calendar/eventSearch.ts +172 -0
- package/lib/components/navigation/BottomTabBar.tsx +107 -22
- package/lib/components/navigation/NavBadge.tsx +33 -0
- package/lib/mail/compose/quotedImages.ts +49 -0
- package/lib/mail/inlineImages.ts +104 -0
- package/lib/mail/mailApi.ts +64 -5
- package/package.json +1 -1
- package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
|
@@ -3,6 +3,7 @@ import { useDraggable, useDroppable } from "@dnd-kit/core";
|
|
|
3
3
|
import { addDays, eachDayOfInterval, endOfMonth, endOfWeek, format, isSameMonth, isToday, startOfMonth, startOfWeek } from "date-fns";
|
|
4
4
|
import { dayDropId, eventDragId } from "../../../../lib/calendar/calendarDragIds.js";
|
|
5
5
|
import { occursOnDay, startsOnDay } from "./allDay.js";
|
|
6
|
+
import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
|
|
6
7
|
import { anchorOf } from "./EventShell.js";
|
|
7
8
|
const MAX_CHIPS_PER_DAY = 3;
|
|
8
9
|
/** A real 6-week month grid (Mon-start), matching Outlook/Gmail's month view. Multi-day and all-day
|
|
@@ -15,7 +16,12 @@ export default function MonthView({ viewDate, occurrences, folderColors, onSelec
|
|
|
15
16
|
}
|
|
16
17
|
function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }) {
|
|
17
18
|
const { setNodeRef, isOver } = useDroppable({ id: dayDropId(day) });
|
|
18
|
-
const
|
|
19
|
+
const activeKey = useActiveOccurrenceKey();
|
|
20
|
+
// The search's current result is always shown, even when it would be one of the "+N more".
|
|
21
|
+
const activeIndex = occurrences.findIndex((occ) => occ.occurrenceKey === activeKey);
|
|
22
|
+
const visible = activeIndex >= MAX_CHIPS_PER_DAY
|
|
23
|
+
? [...occurrences.slice(0, MAX_CHIPS_PER_DAY - 1), occurrences[activeIndex]]
|
|
24
|
+
: occurrences.slice(0, MAX_CHIPS_PER_DAY);
|
|
19
25
|
const overflowCount = occurrences.length - visible.length;
|
|
20
26
|
return (_jsxs("div", { ref: setNodeRef, onClick: (e) => {
|
|
21
27
|
// Only the empty part of the cell: the day number, the chips and "+N more" have their own clicks.
|
|
@@ -34,18 +40,21 @@ function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay,
|
|
|
34
40
|
function EventChip({ occurrence, color, onSelect, }) {
|
|
35
41
|
const { setNodeRef, listeners, attributes, transform, isDragging } = useDraggable({ id: eventDragId(occurrence) });
|
|
36
42
|
const isFree = occurrence.busyStatus === "free";
|
|
37
|
-
|
|
43
|
+
const marker = occurrenceMarker(useActiveOccurrenceKey(), occurrence);
|
|
44
|
+
return (_jsxs("button", { ref: setNodeRef, ...marker.attrs, type: "button", onClick: () => onSelect(occurrence), style: {
|
|
38
45
|
...(transform ? { transform: `translate(${transform.x}px, ${transform.y}px)`, zIndex: 10 } : undefined),
|
|
39
46
|
...(isFree ? undefined : { backgroundColor: color, color: "#fff" }),
|
|
40
47
|
}, className: [
|
|
41
48
|
"text-xs text-left truncate rounded-sm px-1.5 py-0.5 shrink-0",
|
|
42
49
|
isFree ? "bg-surface-alt text-text-muted" : "",
|
|
43
50
|
isDragging ? "opacity-50" : "",
|
|
51
|
+
marker.className,
|
|
44
52
|
].join(" "), ...listeners, ...attributes, children: [occurrence.allDay ? "" : `${format(new Date(occurrence.startDate), "h:mma")} `, occurrence.title] }));
|
|
45
53
|
}
|
|
46
54
|
/** A multi-day event's chip on a day after its first: clickable, but not draggable (its drag id is
|
|
47
55
|
* already taken by the start day's chip) and without a start-time prefix. */
|
|
48
56
|
function ContinuationChip({ occurrence, color, onSelect, }) {
|
|
49
57
|
const isFree = occurrence.busyStatus === "free";
|
|
50
|
-
|
|
58
|
+
const marker = occurrenceMarker(useActiveOccurrenceKey(), occurrence);
|
|
59
|
+
return (_jsx("button", { type: "button", ...marker.attrs, onClick: () => onSelect(occurrence), style: isFree ? undefined : { backgroundColor: color, color: "#fff" }, className: ["text-xs text-left truncate rounded-sm px-1.5 py-0.5 shrink-0", isFree ? "bg-surface-alt text-text-muted" : "", marker.className].join(" "), children: occurrence.title }));
|
|
51
60
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React, { ReactNode } from "react";
|
|
2
|
+
/** The side a card slides in from: the Previous match's comes from the left, the Next match's from the right. */
|
|
3
|
+
export type SlideFrom = "left" | "right";
|
|
4
|
+
/** How long the card takes to settle, and how far off it starts, in px. */
|
|
5
|
+
export declare const SLIDE_IN_MS = 220;
|
|
6
|
+
/**
|
|
7
|
+
* Slides what it holds in from `from` (and fades it in) as it mounts - mount it afresh (a `key`) for each card. Without `from` (the card was just
|
|
8
|
+
* opened, not stepped to) nothing moves. The animation is the Web Animations API's, so nothing is left behind in the styles, and it is skipped
|
|
9
|
+
* for someone who asked for less motion and where the browser has no `Element.animate()`. `data-slide-from` says which way it came, for tests and
|
|
10
|
+
* styling. Its own clip (`overflow-x-clip`, which makes no scroller) keeps the sliding card inside the dialog's edges; the dialog itself, the
|
|
11
|
+
* backdrop and the buttons beside the card are not moved.
|
|
12
|
+
*/
|
|
13
|
+
export default function SlideIn({ from, children }: {
|
|
14
|
+
from?: SlideFrom;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useLayoutEffect, useRef } from "react";
|
|
7
|
+
import { prefersReducedMotion } from "../../gestures/useEnterSlide.js";
|
|
8
|
+
/** How long the card takes to settle, and how far off it starts, in px. */
|
|
9
|
+
export const SLIDE_IN_MS = 220;
|
|
10
|
+
const SLIDE_IN_OFFSET_PX = 48;
|
|
11
|
+
/**
|
|
12
|
+
* Slides what it holds in from `from` (and fades it in) as it mounts - mount it afresh (a `key`) for each card. Without `from` (the card was just
|
|
13
|
+
* opened, not stepped to) nothing moves. The animation is the Web Animations API's, so nothing is left behind in the styles, and it is skipped
|
|
14
|
+
* for someone who asked for less motion and where the browser has no `Element.animate()`. `data-slide-from` says which way it came, for tests and
|
|
15
|
+
* styling. Its own clip (`overflow-x-clip`, which makes no scroller) keeps the sliding card inside the dialog's edges; the dialog itself, the
|
|
16
|
+
* backdrop and the buttons beside the card are not moved.
|
|
17
|
+
*/
|
|
18
|
+
export default function SlideIn({ from, children }) {
|
|
19
|
+
const ref = useRef(null);
|
|
20
|
+
useLayoutEffect(() => {
|
|
21
|
+
const element = ref.current;
|
|
22
|
+
if (!from || typeof element.animate !== "function" || prefersReducedMotion()) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const offset = from === "left" ? -SLIDE_IN_OFFSET_PX : SLIDE_IN_OFFSET_PX;
|
|
26
|
+
element.animate([
|
|
27
|
+
{ transform: `translateX(${offset}px)`, opacity: 0 },
|
|
28
|
+
{ transform: "translateX(0)", opacity: 1 },
|
|
29
|
+
], { duration: SLIDE_IN_MS, easing: "ease-out" });
|
|
30
|
+
}, []);
|
|
31
|
+
return (_jsx("div", { className: "overflow-x-clip", children: _jsx("div", { ref: ref, "data-slide-from": from, children: children }) }));
|
|
32
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { addDays, addMinutes, format, startOfDay } from "date-fns";
|
|
3
3
|
import { occursOnDay } from "./allDay.js";
|
|
4
|
+
import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
|
|
4
5
|
import { anchorOf } from "./EventShell.js";
|
|
5
6
|
const HOUR_HEIGHT_PX = 48;
|
|
6
7
|
const SLOT_MINUTES = 30;
|
|
@@ -22,6 +23,7 @@ export default function SplitDayView({ day, columns, occurrences, onSelectEvent,
|
|
|
22
23
|
return (_jsxs("div", { role: "region", "aria-label": "Split view", "data-calendar-scroller": true, className: "flex-1 flex flex-col min-h-0 overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), columns.map((col) => (_jsxs("div", { className: "flex-1 min-w-0 py-1 flex items-center justify-center gap-1.5 border-l border-border", children: [_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: col.color } }), _jsx("span", { className: "text-xs font-semibold truncate", children: col.name })] }, col.folderUid)))] }), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "text-[10px] text-text-muted text-right pr-1.5 -mt-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), columns.map((col) => (_jsx(SplitColumn, { column: col, dayStart: dayStart, occurrences: dayOccurrences.filter((occ) => occ.folderUid === col.folderUid), onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, col.folderUid)))] })] }));
|
|
23
24
|
}
|
|
24
25
|
function SplitColumn({ column, dayStart, occurrences, onSelectEvent, onSelectSlot, }) {
|
|
26
|
+
const activeKey = useActiveOccurrenceKey();
|
|
25
27
|
return (_jsxs("div", { className: "flex-1 min-w-0 relative border-l border-border", children: [Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
|
|
26
28
|
const slotStart = addMinutes(dayStart, i * SLOT_MINUTES);
|
|
27
29
|
return (_jsx("button", { type: "button", onClick: (e) => onSelectSlot(slotStart, addMinutes(slotStart, SLOT_MINUTES), column.folderUid, anchorOf(e.currentTarget)), style: { height: HOUR_HEIGHT_PX / 2 }, className: "block w-full border-b border-border/50 text-left", "aria-label": `New event at ${format(slotStart, "h:mm a")} in ${column.name}` }, i));
|
|
@@ -36,7 +38,8 @@ function SplitColumn({ column, dayStart, occurrences, onSelectEvent, onSelectSlo
|
|
|
36
38
|
const top = ((visibleStart - dayStart.getTime()) / 60000 / 60) * HOUR_HEIGHT_PX;
|
|
37
39
|
const height = Math.max(((visibleEnd - visibleStart) / 60000 / 60) * HOUR_HEIGHT_PX, 16);
|
|
38
40
|
const isFree = occurrence.busyStatus === "free";
|
|
39
|
-
|
|
41
|
+
const marker = occurrenceMarker(activeKey, occurrence);
|
|
42
|
+
return (_jsxs("div", { ...marker.attrs, onClick: () => onSelectEvent(occurrence), style: {
|
|
40
43
|
position: "absolute",
|
|
41
44
|
top,
|
|
42
45
|
height,
|
|
@@ -46,6 +49,7 @@ function SplitColumn({ column, dayStart, occurrences, onSelectEvent, onSelectSlo
|
|
|
46
49
|
}, className: [
|
|
47
50
|
"rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
|
|
48
51
|
isFree ? "bg-surface-alt text-text-muted" : "",
|
|
52
|
+
marker.className,
|
|
49
53
|
].join(" "), children: [_jsx("div", { className: "font-medium truncate", children: occurrence.title }), _jsx("div", { className: "truncate", children: format(start, "h:mma") })] }, occurrence.occurrenceKey));
|
|
50
54
|
})] }));
|
|
51
55
|
}
|
|
@@ -3,6 +3,7 @@ import { useDraggable, useDroppable } from "@dnd-kit/core";
|
|
|
3
3
|
import { addDays, addMinutes, format, isToday, startOfDay } from "date-fns";
|
|
4
4
|
import { eventDragId, resizeDragId, slotDropId } from "../../../../lib/calendar/calendarDragIds.js";
|
|
5
5
|
import { occursOnDay, startsOnDay } from "./allDay.js";
|
|
6
|
+
import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
|
|
6
7
|
import { anchorOf } from "./EventShell.js";
|
|
7
8
|
const HOUR_HEIGHT_PX = 48;
|
|
8
9
|
const SLOT_MINUTES = 30;
|
|
@@ -14,9 +15,13 @@ const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
|
|
14
15
|
export default function TimeGridView({ days, occurrences, folderColors, onSelectEvent, onSelectSlot }) {
|
|
15
16
|
const allDayEvents = occurrences.filter((occ) => occ.allDay);
|
|
16
17
|
const timedEvents = occurrences.filter((occ) => !occ.allDay);
|
|
18
|
+
const activeKey = useActiveOccurrenceKey();
|
|
17
19
|
return (_jsxs("div", { "data-calendar-scroller": true, className: "flex-1 flex flex-col min-h-0 overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: ["flex-1 min-w-0 py-1 text-center text-xs font-semibold border-l border-border", isToday(day) ? "text-primary-dark" : ""].join(" "), children: format(day, "EEE d") }, day.toISOString())))] }), allDayEvents.length > 0 && (_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: "flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border", children: allDayEvents
|
|
18
20
|
.filter((occ) => occursOnDay(occ, day))
|
|
19
|
-
.map((occ) =>
|
|
21
|
+
.map((occ) => {
|
|
22
|
+
const marker = occurrenceMarker(activeKey, occ);
|
|
23
|
+
return (_jsx("button", { type: "button", ...marker.attrs, onClick: () => onSelectEvent(occ), style: { backgroundColor: folderColors[occ.folderUid], color: "#fff" }, className: ["text-xs text-left truncate rounded-sm px-1.5 py-0.5", marker.className].join(" "), children: occ.title }, occ.occurrenceKey));
|
|
24
|
+
}) }, day.toISOString())))] })), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "relative -top-1.5 text-[10px] text-text-muted text-right pr-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), days.map((day) => (_jsx(DayColumn, { day: day, occurrences: timedEvents.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString())))] })] }));
|
|
20
25
|
}
|
|
21
26
|
function DayColumn({ day, occurrences, folderColors, onSelectEvent, onSelectSlot }) {
|
|
22
27
|
const dayStart = startOfDay(day);
|
|
@@ -44,7 +49,8 @@ function blockGeometry(occurrence, dayStart) {
|
|
|
44
49
|
function ContinuationBlock({ occurrence, color, dayStart, onSelect, }) {
|
|
45
50
|
const isFree = occurrence.busyStatus === "free";
|
|
46
51
|
const { top, height } = blockGeometry(occurrence, dayStart);
|
|
47
|
-
|
|
52
|
+
const marker = occurrenceMarker(useActiveOccurrenceKey(), occurrence);
|
|
53
|
+
return (_jsx("div", { ...marker.attrs, onClick: () => onSelect(occurrence), style: { position: "absolute", top, height, left: 2, right: 2, ...(isFree ? undefined : { backgroundColor: color, color: "#fff" }) }, className: ["rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer", isFree ? "bg-surface-alt text-text-muted" : "", marker.className].join(" "), children: _jsx("div", { className: "font-medium truncate", children: occurrence.title }) }));
|
|
48
54
|
}
|
|
49
55
|
function EventBlock({ occurrence, color, dayStart, onSelect, }) {
|
|
50
56
|
const { setNodeRef, listeners, attributes, transform, isDragging } = useDraggable({ id: eventDragId(occurrence) });
|
|
@@ -54,11 +60,12 @@ function EventBlock({ occurrence, color, dayStart, onSelect, }) {
|
|
|
54
60
|
const isFree = occurrence.busyStatus === "free";
|
|
55
61
|
const start = new Date(occurrence.startDate);
|
|
56
62
|
const { top, height } = blockGeometry(occurrence, dayStart);
|
|
63
|
+
const marker = occurrenceMarker(useActiveOccurrenceKey(), occurrence);
|
|
57
64
|
// The resize handle's own drag position has no live pixel preview (it only applies its ns-resize
|
|
58
65
|
// affordance) — the actual new end time is computed from whichever slot it's dropped onto (see
|
|
59
66
|
// `resolveDragAction`'s "resize" case), snapped to the grid rather than following the pointer
|
|
60
67
|
// continuously. Simpler, and avoids a visual preview that could disagree with the snapped result.
|
|
61
|
-
return (_jsxs("div", { ref: setNodeRef, onClick: () => onSelect(occurrence), style: {
|
|
68
|
+
return (_jsxs("div", { ref: setNodeRef, ...marker.attrs, onClick: () => onSelect(occurrence), style: {
|
|
62
69
|
position: "absolute",
|
|
63
70
|
top,
|
|
64
71
|
height,
|
|
@@ -71,5 +78,6 @@ function EventBlock({ occurrence, color, dayStart, onSelect, }) {
|
|
|
71
78
|
"rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
|
|
72
79
|
isFree ? "bg-surface-alt text-text-muted" : "",
|
|
73
80
|
isDragging ? "opacity-50" : "",
|
|
81
|
+
marker.className,
|
|
74
82
|
].join(" "), ...listeners, ...attributes, children: [_jsx("div", { className: "font-medium truncate", children: occurrence.title }), _jsx("div", { className: "truncate", children: format(start, "h:mma") }), _jsx("div", { ref: setResizeRef, className: "absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize", "aria-label": `Resize "${occurrence.title}"`, ...resizeListeners, ...resizeAttributes })] }));
|
|
75
83
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
|
|
2
|
+
/** The `occurrenceKey` of the search result the calendar is pointing at (`null`: none), for the views to mark - a context, so the month, week and split
|
|
3
|
+
* views and their many kinds of chip need no prop threaded through each. */
|
|
4
|
+
export declare const ActiveOccurrenceContext: import("react").Context<string | null>;
|
|
5
|
+
/** The key of the occurrence the calendar's search is on. */
|
|
6
|
+
export declare function useActiveOccurrenceKey(): string | null;
|
|
7
|
+
/** What an occurrence's chip or block spreads on itself: `data-occurrence-key` (how the page finds it to scroll to) and, when it is the
|
|
8
|
+
* `activeKey` one, a ring and `data-active-match`. */
|
|
9
|
+
export declare function occurrenceMarker(activeKey: string | null, occurrence: CalendarOccurrence): {
|
|
10
|
+
attrs: {
|
|
11
|
+
"data-occurrence-key": string;
|
|
12
|
+
"data-active-match"?: "true";
|
|
13
|
+
};
|
|
14
|
+
className: string;
|
|
15
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { createContext, useContext } from "react";
|
|
6
|
+
/** The `occurrenceKey` of the search result the calendar is pointing at (`null`: none), for the views to mark - a context, so the month, week and split
|
|
7
|
+
* views and their many kinds of chip need no prop threaded through each. */
|
|
8
|
+
export const ActiveOccurrenceContext = createContext(null);
|
|
9
|
+
/** The key of the occurrence the calendar's search is on. */
|
|
10
|
+
export function useActiveOccurrenceKey() {
|
|
11
|
+
return useContext(ActiveOccurrenceContext);
|
|
12
|
+
}
|
|
13
|
+
/** What an occurrence's chip or block spreads on itself: `data-occurrence-key` (how the page finds it to scroll to) and, when it is the
|
|
14
|
+
* `activeKey` one, a ring and `data-active-match`. */
|
|
15
|
+
export function occurrenceMarker(activeKey, occurrence) {
|
|
16
|
+
const active = activeKey === occurrence.occurrenceKey;
|
|
17
|
+
return {
|
|
18
|
+
attrs: { "data-occurrence-key": occurrence.occurrenceKey, ...(active ? { "data-active-match": "true" } : undefined) },
|
|
19
|
+
className: active ? "ring-2 ring-text ring-offset-1 ring-offset-surface" : "",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -44,6 +44,10 @@ export declare function startWeekdayCode(start: string, allDay: boolean, timezon
|
|
|
44
44
|
* each day rather than only on its start day.
|
|
45
45
|
*/
|
|
46
46
|
export declare function occursOnDay(occurrence: CalendarOccurrence, day: Date): boolean;
|
|
47
|
+
/** Whether `occurrence` is on show on `today` (a local day) or later: it ends after that day begins, so one still running today counts and one that ended is not. */
|
|
48
|
+
export declare function isUpcoming(occurrence: CalendarOccurrence, today: Date): boolean;
|
|
49
|
+
/** The local calendar day (midnight) `occurrence` starts on - the day a view shows to bring it into sight. */
|
|
50
|
+
export declare function occurrenceDay(occurrence: CalendarOccurrence): Date;
|
|
47
51
|
/**
|
|
48
52
|
* Whether `day` is the first day `occurrence` covers. Grid views only make that day's chip/block
|
|
49
53
|
* draggable — a drag id must be unique, so the continuation copies on later days are display-only.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import { format } from "date-fns";
|
|
5
|
+
import { format, parseISO, startOfDay } from "date-fns";
|
|
6
6
|
import { toEventWallClock } from "../../../../lib/calendar/recurrence.js";
|
|
7
7
|
const MS_PER_DAY = 86400000;
|
|
8
8
|
/**
|
|
@@ -99,6 +99,19 @@ export function occursOnDay(occurrence, day) {
|
|
|
99
99
|
}
|
|
100
100
|
return start < dayEnd && end > dayStart;
|
|
101
101
|
}
|
|
102
|
+
/** Whether `occurrence` is on show on `today` (a local day) or later: it ends after that day begins, so one still running today counts and one that ended is not. */
|
|
103
|
+
export function isUpcoming(occurrence, today) {
|
|
104
|
+
if (occurrence.allDay) {
|
|
105
|
+
const key = localDateKey(today);
|
|
106
|
+
return allDayDateKey(occurrence.endDate) > key || allDayDateKey(occurrence.startDate) >= key;
|
|
107
|
+
}
|
|
108
|
+
const dayStart = startOfDay(today);
|
|
109
|
+
return new Date(occurrence.endDate) > dayStart || new Date(occurrence.startDate) >= dayStart;
|
|
110
|
+
}
|
|
111
|
+
/** The local calendar day (midnight) `occurrence` starts on - the day a view shows to bring it into sight. */
|
|
112
|
+
export function occurrenceDay(occurrence) {
|
|
113
|
+
return occurrence.allDay ? parseISO(allDayDateKey(occurrence.startDate)) : startOfDay(new Date(occurrence.startDate));
|
|
114
|
+
}
|
|
102
115
|
/**
|
|
103
116
|
* Whether `day` is the first day `occurrence` covers. Grid views only make that day's chip/block
|
|
104
117
|
* draggable — a drag id must be unique, so the continuation copies on later days are display-only.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The calendar's views, and the one the reader last chose - kept in `localStorage` so coming back to the Calendar from elsewhere in the app (or
|
|
3
|
+
* reopening it) lands on the same view, the way the mail list remembers its own arrangement (see `listPreferences.ts`). Per device, not synced.
|
|
4
|
+
* A read that finds nothing, something this client doesn't know or a blocked store gives `null`, and a write that fails is swallowed: the
|
|
5
|
+
* choice then only doesn't outlive the page.
|
|
6
|
+
*/
|
|
7
|
+
/** The views, in the order the switcher lists them: the List sits left of the Month. */
|
|
8
|
+
export type CalendarView = "list" | "month" | "week" | "workWeek" | "day" | "split";
|
|
9
|
+
export declare const CALENDAR_VIEWS: CalendarView[];
|
|
10
|
+
/** Whether `value` names a view. */
|
|
11
|
+
export declare function isCalendarView(value: unknown): value is CalendarView;
|
|
12
|
+
/** The view last chosen, or `null` when none was (or it cannot be read). The calendar page does not restore a stored `"list"`: that view is the phone's
|
|
13
|
+
* default and is never written by a desktop. */
|
|
14
|
+
export declare function getStoredCalendarView(): CalendarView | null;
|
|
15
|
+
/** Remembers `view` as the one to open the Calendar on. */
|
|
16
|
+
export declare function storeCalendarView(view: CalendarView): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The calendar's views, and the one the reader last chose - kept in `localStorage` so coming back to the Calendar from elsewhere in the app (or
|
|
7
|
+
* reopening it) lands on the same view, the way the mail list remembers its own arrangement (see `listPreferences.ts`). Per device, not synced.
|
|
8
|
+
* A read that finds nothing, something this client doesn't know or a blocked store gives `null`, and a write that fails is swallowed: the
|
|
9
|
+
* choice then only doesn't outlive the page.
|
|
10
|
+
*/
|
|
11
|
+
export const CALENDAR_VIEWS = ["list", "month", "week", "workWeek", "day", "split"];
|
|
12
|
+
const STORAGE_KEY = "rapidmx:calendar-view";
|
|
13
|
+
/** Whether `value` names a view. */
|
|
14
|
+
export function isCalendarView(value) {
|
|
15
|
+
return CALENDAR_VIEWS.includes(value);
|
|
16
|
+
}
|
|
17
|
+
/** The view last chosen, or `null` when none was (or it cannot be read). The calendar page does not restore a stored `"list"`: that view is the phone's
|
|
18
|
+
* default and is never written by a desktop. */
|
|
19
|
+
export function getStoredCalendarView() {
|
|
20
|
+
try {
|
|
21
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
22
|
+
return isCalendarView(stored) ? stored : null;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Remembers `view` as the one to open the Calendar on. */
|
|
29
|
+
export function storeCalendarView(view) {
|
|
30
|
+
try {
|
|
31
|
+
localStorage.setItem(STORAGE_KEY, view);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// A blocked store only means the choice does not outlive the page.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -17,6 +17,7 @@ import ComposeProvider from "../mail/compose/ComposeContext.js";
|
|
|
17
17
|
import ContactCardProvider from "../contacts/ContactCardProvider.js";
|
|
18
18
|
import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
|
|
19
19
|
import BottomTabBar from "../../../../lib/components/navigation/BottomTabBar.js";
|
|
20
|
+
import NavBadge, { navItemLabel } from "../../../../lib/components/navigation/NavBadge.js";
|
|
20
21
|
import { FrameBrandingFooter, FrameBrandingHeader, useBrandingHtml } from "./BrandingChrome.js";
|
|
21
22
|
import RailIcon from "./RailIcon.js";
|
|
22
23
|
import AppearanceProvider from "../../appearance/AppearanceProvider.js";
|
|
@@ -237,13 +238,16 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
|
|
|
237
238
|
}, [fetchedBranding?.title, inFrame]);
|
|
238
239
|
// The router sets the document's title to the page's own whenever the page changes, which drops the unread count this puts in front
|
|
239
240
|
// of it, so a new pathname is what puts the count back. (A folder change is shallow and keeps the title.)
|
|
240
|
-
|
|
241
|
+
// The same count is the chip on the Mail icon (rail and bottom tab bar). It is zero - no chip - until the folders are listed, with no
|
|
242
|
+
// mailbox, and wherever the connection is off, so nothing shows when mail isn't available.
|
|
243
|
+
const unreadTotal = inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts);
|
|
244
|
+
useUnreadTitle(unreadTotal, { enabled: inFrame, resetKey: pathname });
|
|
241
245
|
// Plugin rail entries that depend on the user (the meet plugin's personal room) are asked for once they are signed in.
|
|
242
246
|
const resolvedRail = useResolvedRailItems(pluginNav?.appRail, userUid);
|
|
243
247
|
if (!userUid) {
|
|
244
248
|
return _jsx("div", { className: "min-h-screen" });
|
|
245
249
|
}
|
|
246
|
-
const apps = appRailItems(pluginNav, resolvedRail);
|
|
250
|
+
const apps = appRailItems(pluginNav, resolvedRail).map((app) => (app.id === "mail" ? { ...app, badge: unreadTotal } : app));
|
|
247
251
|
// The header title: "settings" has no rail item, everything else is labelled by its own rail item.
|
|
248
252
|
const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
|
|
249
253
|
// A custom header (`Branding.headerHtml`) replaces the app's own title bar and the icon at the top of the rail: it is the top of the app, and the
|
|
@@ -259,12 +263,12 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
|
|
|
259
263
|
"hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center gap-1",
|
|
260
264
|
// Under a custom header the rail starts with the app icons; without one its own icon is flush with the top of the window.
|
|
261
265
|
customHeader ? "py-3" : "pb-3",
|
|
262
|
-
].join(" "), children: [!customHeader && (_jsx(RailIcon, { src: iconSrc })), apps.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
|
|
266
|
+
].join(" "), children: [!customHeader && (_jsx(RailIcon, { src: iconSrc })), apps.map(({ id, href, label, icon: Icon, badge }) => (_jsx("a", { href: href, "aria-label": navItemLabel(label, badge), "aria-current": id === active ? "page" : undefined, title: navItemLabel(label, badge), className: [
|
|
263
267
|
"w-10 h-10 flex items-center justify-center rounded-sm",
|
|
264
268
|
id === active
|
|
265
269
|
? "bg-primary/10 text-primary-dark"
|
|
266
270
|
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
267
|
-
].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] })), !hideChrome && _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [!hideChrome && !customHeader && (_jsxs("header", { ref: headerRef, className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), renderUserMenu("down")] })), _jsx(NotificationCenter, {}), _jsx("div", { id: "app-content", tabIndex: -1, "aria-busy": busy || undefined, className: ["flex-1 flex min-h-0 outline-none", hideChrome ? "" : "pb-14 md:pb-0"].join(" "), children: children })] })] }), !hideChrome && _jsx(FrameBrandingFooter, { parsed: footer, userMenu: menuInFooter ? renderUserMenu("up") : undefined, appTitle: activeLabel })] }) }) })] }), _jsx(NotificationHistoryDialog, { open: historyOpen, onClose: () => setHistoryOpen(false) })] }) }) }));
|
|
271
|
+
].join(" "), children: _jsxs("span", { className: "relative inline-flex", children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), _jsx(NavBadge, { count: badge })] }) }, id)))] })), !hideChrome && _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [!hideChrome && !customHeader && (_jsxs("header", { ref: headerRef, className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), renderUserMenu("down")] })), _jsx(NotificationCenter, {}), _jsx("div", { id: "app-content", tabIndex: -1, "aria-busy": busy || undefined, className: ["flex-1 flex min-h-0 outline-none", hideChrome ? "" : "pb-14 md:pb-0"].join(" "), children: children })] })] }), !hideChrome && _jsx(FrameBrandingFooter, { parsed: footer, userMenu: menuInFooter ? renderUserMenu("up") : undefined, appTitle: activeLabel })] }) }) })] }), _jsx(NotificationHistoryDialog, { open: historyOpen, onClose: () => setHistoryOpen(false) })] }) }) }));
|
|
268
272
|
}
|
|
269
273
|
/**
|
|
270
274
|
* What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Message } from "../../../../lib/mail/mailApi.js";
|
|
3
3
|
import { ConversationSummary } from "../../../../lib/mail/conversationsApi.js";
|
|
4
|
+
import { ClickModifiers } from "../../mail/rangeSelection.js";
|
|
4
5
|
/** A conversation row of the list. `mailboxUid` is set only by a view that merges several mailboxes ("All Mailboxes"), whose rows are
|
|
5
6
|
* each from a different one; a list of one mailbox's own leaves it out and every row is that mailbox's. */
|
|
6
7
|
export type ListedConversation = ConversationSummary & {
|
|
@@ -33,6 +34,13 @@ export interface ConversationListProps {
|
|
|
33
34
|
/** The `conversationRowKey()`s currently ticked. */
|
|
34
35
|
selectedConversationIds?: Set<string>;
|
|
35
36
|
onToggleSelected?: (conversation: ListedConversation) => void;
|
|
37
|
+
/**
|
|
38
|
+
* A Shift, Ctrl or Cmd click on a parent row (or on its checkbox): the caller extends or toggles the selection instead of this list opening or
|
|
39
|
+
* ticking the row itself (`onOpenMessage` / `onToggleSelected` are not called for it). Absent, those clicks behave as plain ones.
|
|
40
|
+
*/
|
|
41
|
+
onSelectWithModifier?: (conversation: ListedConversation, modifiers: ClickModifiers) => void;
|
|
42
|
+
/** The `conversationRowKey()`s of rows on their way out of the list (moved, deleted): drawn non-interactive and hidden from assistive technology while the page collapses them. */
|
|
43
|
+
exitingKeys?: Set<string>;
|
|
36
44
|
/**
|
|
37
45
|
* What swiping a parent row does on a phone: right to left archives the conversation (resolving whether it went through), left
|
|
38
46
|
* to right asks where to move it. Absent, or `enabled: false` (a desktop), rows don't follow a finger.
|
|
@@ -62,4 +70,4 @@ export interface ConversationListProps {
|
|
|
62
70
|
* nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
|
|
63
71
|
* its own name.
|
|
64
72
|
*/
|
|
65
|
-
export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, swipe, newestFirst, }: ConversationListProps): React.JSX.Element;
|
|
73
|
+
export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, onSelectWithModifier, exitingKeys, swipe, newestFirst, }: ConversationListProps): React.JSX.Element;
|
|
@@ -15,6 +15,7 @@ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
|
15
15
|
import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "./unreadStyle.js";
|
|
16
16
|
import SwipeRow from "./SwipeRow.js";
|
|
17
17
|
import InviteRowChip from "./invite/InviteRowChip.js";
|
|
18
|
+
import { isSelectionClick } from "../../mail/rangeSelection.js";
|
|
18
19
|
/** What tells a row apart from the others: the same conversation id in two mailboxes (a message sent to both) is two rows. */
|
|
19
20
|
export function conversationRowKey(conversation) {
|
|
20
21
|
return conversation.mailboxUid ? `${conversation.mailboxUid}\u0000${conversation.conversationId}` : conversation.conversationId;
|
|
@@ -36,7 +37,7 @@ function participantList(conversation) {
|
|
|
36
37
|
* nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
|
|
37
38
|
* its own name.
|
|
38
39
|
*/
|
|
39
|
-
export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, swipe, newestFirst, }) {
|
|
40
|
+
export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, onSelectWithModifier, exitingKeys, swipe, newestFirst, }) {
|
|
40
41
|
const client = useApiClient();
|
|
41
42
|
// What this device has decrypted of the encrypted messages on screen: their real subjects and previews.
|
|
42
43
|
const decrypted = useDecryptedMessages();
|
|
@@ -105,7 +106,14 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
|
|
|
105
106
|
const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
|
|
106
107
|
const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
|
|
107
108
|
const ticked = selectedConversationIds?.has(id) ?? false;
|
|
108
|
-
|
|
109
|
+
const exiting = exitingKeys?.has(id) ?? false;
|
|
110
|
+
return (_jsxs("li", { "data-exiting": exiting ? "true" : undefined, "aria-hidden": exiting ? "true" : undefined, inert: exiting, className: exiting ? "pointer-events-none" : undefined, children: [_jsxs(SwipeRow, { as: "div", enabled: !!swipe?.enabled && !selectMode, onArchive: () => swipe.onArchive(conversation), onMove: () => swipe.onMove(conversation), "data-message-uid": conversation.latestMessageUid, "data-unread": unread ? "true" : undefined, className: rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: unread }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: (event) =>
|
|
111
|
+
// The change of a checkbox is caused by a click (or the Space key, which makes one): its modifiers are that event's.
|
|
112
|
+
onSelectWithModifier && isSelectionClick(event.nativeEvent)
|
|
113
|
+
? onSelectWithModifier(conversation, event.nativeEvent)
|
|
114
|
+
: onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: ["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" "), children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: (event) => onSelectWithModifier && isSelectionClick(event)
|
|
115
|
+
? onSelectWithModifier(conversation, event)
|
|
116
|
+
: onOpenMessage(conversation, conversation.latestMessageUid), className: ["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: unread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: ["flex-1", senderClass(unread)].join(" ") }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: ["text-xs shrink-0", dateClass(unread)].join(" "), children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: ["text-sm truncate", subjectClass(unread)].join(" "), children: decrypted[conversation.latestMessageUid]?.subject || displaySubject(conversation.subject) || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversationLooksEncrypted(conversation) ? decrypted[conversation.latestMessageUid]?.preview || _jsx(EncryptedPreview, {}) : conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: {
|
|
109
117
|
uid: conversation.latestMessageUid,
|
|
110
118
|
meetingMethod: conversation.latestMeetingMethod ?? undefined,
|
|
111
119
|
meetingResponse: answers[conversation.latestMessageUid] ??
|
|
@@ -15,6 +15,7 @@ import { ENCRYPTED_SUBJECT_PLACEHOLDER, EncryptedPreview, displaySubject } from
|
|
|
15
15
|
import { useDecryptedMessages } from "../../mail/decryptedMessages.js";
|
|
16
16
|
import { CollapsedCard, SkeletonCards, SubjectCard } from "./reading/MessageCard.js";
|
|
17
17
|
import PendingMessageCard from "./reading/PendingMessageCard.js";
|
|
18
|
+
import { InlineComposeSlot, useInlineCompose } from "./compose/ComposeContext.js";
|
|
18
19
|
import { useMailShell } from "./layout/MailShell.js";
|
|
19
20
|
import { setReadState } from "../../mail/messageReadState.js";
|
|
20
21
|
import { adoptedOutgoing, belongsToThread, settleOutgoing, useOutgoingReplies } from "../../mail/outbox/outgoingReplies.js";
|
|
@@ -137,6 +138,9 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
137
138
|
const [error, setError] = useState(null);
|
|
138
139
|
/** The message to scroll to and focus once it has rendered, or `null` once that has happened. */
|
|
139
140
|
const [pendingFocusUid, setPendingFocusUid] = useState(null);
|
|
141
|
+
// The Reply / Reply all / Forward compose windows of this thread's messages, drawn as cards at the top of the list; the thread's messages are
|
|
142
|
+
// what makes the compose provider open a reply here rather than floating at the bottom right.
|
|
143
|
+
const inlineSessions = useInlineCompose(messages.map((message) => message.uid));
|
|
140
144
|
// Bumped on every conversation switch - an in-flight load/attachments/mark-read response carrying an
|
|
141
145
|
// older generation belongs to a superseded conversation and is dropped rather than applied.
|
|
142
146
|
const generationRef = useRef(0);
|
|
@@ -436,7 +440,11 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
436
440
|
: undefined
|
|
437
441
|
: error
|
|
438
442
|
? undefined
|
|
439
|
-
: `${messages.length + pendingCards.length} message${messages.length + pendingCards.length === 1 ? "" : "s"}`, children: truncated && !loading && !error && (_jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Only the oldest ", THREAD_MESSAGE_LIMIT, " messages of this conversation are shown here. The rest are still in the message list."] })) }), loading ? (_jsx(SkeletonCards, { messageCount: conversation.messageCount })) : error ? (_jsx("div", { className: "p-2 sm:p-3", children: _jsx(Alert, { children: error }) })) : (_jsxs("ul", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: [
|
|
443
|
+
: `${messages.length + pendingCards.length} message${messages.length + pendingCards.length === 1 ? "" : "s"}`, children: truncated && !loading && !error && (_jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Only the oldest ", THREAD_MESSAGE_LIMIT, " messages of this conversation are shown here. The rest are still in the message list."] })) }), loading ? (_jsx(SkeletonCards, { messageCount: conversation.messageCount })) : error ? (_jsx("div", { className: "p-2 sm:p-3", children: _jsx(Alert, { children: error }) })) : (_jsxs("ul", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: [inlineSessions.map((session) => (_jsx(InlineComposeSlot, { id: session.id, onPlaced: (slot) => {
|
|
444
|
+
// Brought into view like a message that has just been sent: the same scroll, inside the list.
|
|
445
|
+
rowRefs.current[session.id] = slot;
|
|
446
|
+
setPendingFocusUid(session.id);
|
|
447
|
+
} }, session.id))), pendingCards.map((reply) => (_jsx("li", { ref: (node) => {
|
|
440
448
|
rowRefs.current[reply.uid] = node;
|
|
441
449
|
}, "data-outgoing": "true", children: _jsx(PendingMessageCard, { reply: reply, headerRef: (node) => {
|
|
442
450
|
headerRefs.current[reply.uid] = node;
|
|
@@ -4,13 +4,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useState } from "react";
|
|
7
|
+
import { HiOutlineArchiveBox, HiOutlineCheckCircle, HiOutlineEnvelope, HiOutlineEnvelopeOpen, HiOutlineFlag, HiOutlineFolderArrowDown, HiOutlineMinusCircle, HiOutlineNoSymbol, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
|
|
7
8
|
import LabelMenuButton from "./labelMenu.js";
|
|
8
9
|
import MoveToFolderDialog, { MOVE_TARGET_TYPES } from "./MoveToFolderDialog.js";
|
|
9
10
|
import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
|
|
10
11
|
import { SHORTCUTS } from "../../keyboard/keymap.js";
|
|
11
12
|
import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
/** The look of the bar's icon buttons - the reading pane's own command row (`ICON_BUTTON_CLASS` in `MessageDetailPane`), without its border, which nine of them in a row don't need. */
|
|
14
|
+
function iconClassName(destructive = false) {
|
|
15
|
+
return `inline-flex items-center justify-center p-1.5 rounded-md text-sm ${destructive ? "text-danger" : "text-text"} hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent`;
|
|
16
|
+
}
|
|
17
|
+
/** One of the bar's actions as an icon button: the action's name is its `aria-label` (so it has the accessible name its text button had) and its tooltip,
|
|
18
|
+
* unless there is a `reason` it is unavailable, or a keyboard shortcut to name (`title` and `aria-keyshortcuts` from `hint()`). */
|
|
19
|
+
function BarAction({ icon, label, onClick, disabled, reason, destructive, hint, }) {
|
|
20
|
+
return (_jsx("button", { type: "button", "aria-label": label, onClick: onClick, disabled: disabled, className: iconClassName(destructive), ...hint, title: reason ?? hint?.title ?? label, children: icon }));
|
|
14
21
|
}
|
|
15
22
|
/**
|
|
16
23
|
* The header that replaces the list's own toolbar while select mode is on: how many rows are selected,
|
|
@@ -47,7 +54,7 @@ export default function MailSelectionBar({ selected, listed, totals, onSelectAll
|
|
|
47
54
|
// partially applied, and is left exactly as it is unless the reader touches that row.
|
|
48
55
|
const appliedToAll = labels.filter((label) => !none && selected.every((m) => m.labelUids?.includes(label.uid)));
|
|
49
56
|
const appliedToSome = labels.filter((label) => selected.some((m) => m.labelUids?.includes(label.uid)) && !appliedToAll.includes(label));
|
|
50
|
-
return (_jsxs("div", { className: "border-b border-border", children: [_jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: mailboxUid, folders: folders, currentFolderUid: currentFolderUid, count: selected.length, onMove: onMoveTo, onFolderCreated: onFolderCreated }), _jsxs("div", { className: "flex items-center gap-
|
|
57
|
+
return (_jsxs("div", { className: "border-b border-border", children: [_jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: mailboxUid, folders: folders, currentFolderUid: currentFolderUid, count: selected.length, onMove: onMoveTo, onFolderCreated: onFolderCreated }), _jsxs("div", { className: "flex items-center gap-1 px-3 py-1.5 bg-surface-alt", children: [_jsxs("span", { "aria-live": "polite", className: "text-sm font-semibold text-text mr-auto", children: [selectedCount, totals ? ` ${totals.noun}${selectedCount === 1 ? "" : "s"}` : "", " selected"] }), _jsx(BarAction, { icon: _jsx(HiOutlineCheckCircle, { size: 16, "aria-hidden": "true" }), label: "Select all", onClick: onSelectAll, disabled: allSelected || listedCount === 0 }), _jsx(BarAction, { icon: _jsx(HiOutlineMinusCircle, { size: 16, "aria-hidden": "true" }), label: "Clear", onClick: onClearSelection, disabled: selectedCount === 0 }), _jsx(BarAction, { icon: _jsx(HiOutlineXMark, { size: 16, "aria-hidden": "true" }), label: "Cancel", onClick: onCancel })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-0.5 px-1.5 py-1", children: [_jsx(BarAction, { icon: _jsx(HiOutlineEnvelopeOpen, { size: 16, "aria-hidden": "true" }), label: "Mark read", onClick: () => onSetRead(true), disabled: none || busy, hint: hint("Mark read", SHORTCUTS.mail.markRead) }), _jsx(BarAction, { icon: _jsx(HiOutlineEnvelope, { size: 16, "aria-hidden": "true" }), label: "Mark unread", onClick: () => onSetRead(false), disabled: none || busy, hint: hint("Mark unread", SHORTCUTS.mail.markUnread) }), _jsx(BarAction, { icon: _jsx(HiOutlineFlag, { size: 16, "aria-hidden": "true" }), label: "Flag", onClick: () => onSetFlagged(true), disabled: none || busy, hint: hint("Flag", SHORTCUTS.mail.flag) }), _jsx(BarAction, { icon: _jsxs("span", { className: "relative inline-flex", "aria-hidden": "true", children: [_jsx(HiOutlineFlag, { size: 16 }), _jsx("span", { className: "absolute left-1/2 top-[-1px] h-[18px] w-[1.5px] -translate-x-1/2 rotate-45 rounded bg-current" })] }), label: "Unflag", onClick: () => onSetFlagged(false), disabled: none || busy }), _jsx(BarAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", onClick: onArchive, disabled: none || busy || !!archiveReason, reason: archiveReason }), _jsx(LabelMenuButton, { "aria-label": "Apply label", label: "Apply label", iconOnly: true, className: iconClassName(), labels: labels, mailboxUid: mailboxUid, onLabelCreated: onLabelCreated, applied: appliedToAll.map((label) => label.uid), partial: appliedToSome.map((label) => label.uid), onCommit: onApplyLabels, busy: busy, disabled: none || busy || !!labelsDisabledReason, title: labelsDisabledReason ?? "Apply label", note: selected.length === 1
|
|
51
58
|
? "Ticked labels are applied, unticked ones removed."
|
|
52
|
-
: "Ticked labels are applied to every selected message and unticked ones removed from all of them; a dash means only some have that label, and leaving it alone keeps it that way.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }), _jsx(
|
|
59
|
+
: "Ticked labels are applied to every selected message and unticked ones removed from all of them; a dash means only some have that label, and leaving it alone keeps it that way.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } }), _jsx(BarAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", onClick: () => setMovePrompt(true), disabled: none || busy || !!moveDisabledReason, reason: moveDisabledReason }), _jsx(BarAction, { icon: _jsx(HiOutlineNoSymbol, { size: 16, "aria-hidden": "true" }), label: "Report junk", onClick: onReportJunk, disabled: none || busy || !!junkReason, reason: junkReason }), _jsx(BarAction, { icon: _jsx(HiOutlineTrash, { size: 16, "aria-hidden": "true" }), label: deleteLabel, onClick: onDelete, disabled: none || busy, destructive: deletesPermanently, hint: hint(deleteLabel, SHORTCUTS.mail.delete) })] })] }));
|
|
53
60
|
}
|
|
@@ -569,6 +569,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
569
569
|
mailboxUid: message.mailboxUid,
|
|
570
570
|
subject: forwardSubject(message.subject),
|
|
571
571
|
signatureContext: "reply_forward",
|
|
572
|
+
inlineFor: message.uid,
|
|
572
573
|
encrypt,
|
|
573
574
|
threading,
|
|
574
575
|
pending: late,
|
|
@@ -583,6 +584,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
583
584
|
subject: replySubject(message.subject),
|
|
584
585
|
signatureContext: "reply_forward",
|
|
585
586
|
suppressSigning: isLikelyMailingList({ listUnsubscribe: message.listUnsubscribeHeader }),
|
|
587
|
+
// A card above this message in the reading pane when it is in one, else the floating window (see `OpenComposeInput.inlineFor`).
|
|
588
|
+
inlineFor: message.uid,
|
|
586
589
|
encrypt,
|
|
587
590
|
threading,
|
|
588
591
|
pending: late,
|