@rapidmx/web-client 0.28.0 → 0.30.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 +38 -5
- 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/SwipeRow.tsx +26 -4
- 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/gestures/useLongPress.ts +138 -0
- 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 +570 -358
- 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 +16 -3
- 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/SwipeRow.d.ts +5 -1
- package/dist/apps/shared/components/mail/SwipeRow.js +17 -3
- 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/gestures/useLongPress.d.ts +41 -0
- package/dist/apps/shared/gestures/useLongPress.js +112 -0
- 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 +257 -73
- 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
|
@@ -219,6 +219,8 @@ export interface LabelMenuButtonProps extends Omit<LabelSectionsOptions, "state"
|
|
|
219
219
|
disabled?: boolean;
|
|
220
220
|
title?: string;
|
|
221
221
|
className?: string;
|
|
222
|
+
/** The trigger is just its icon (`className` then styles all of it) - `aria-label` and `title` name it. */
|
|
223
|
+
iconOnly?: boolean;
|
|
222
224
|
/** With `onLabelCreated`, adds a "New label" row that opens this component's own dialog. */
|
|
223
225
|
mailboxUid?: string;
|
|
224
226
|
onLabelCreated?: (label: Label) => void;
|
|
@@ -240,6 +242,7 @@ export default function LabelMenuButton({
|
|
|
240
242
|
disabled,
|
|
241
243
|
title,
|
|
242
244
|
className,
|
|
245
|
+
iconOnly,
|
|
243
246
|
mailboxUid,
|
|
244
247
|
onLabelCreated,
|
|
245
248
|
}: LabelMenuButtonProps) {
|
|
@@ -255,6 +258,7 @@ export default function LabelMenuButton({
|
|
|
255
258
|
disabled={disabled}
|
|
256
259
|
title={title}
|
|
257
260
|
className={className}
|
|
261
|
+
iconOnly={iconOnly}
|
|
258
262
|
onOpenChange={setOpen}
|
|
259
263
|
sections={labelSections({
|
|
260
264
|
labels,
|
|
@@ -42,6 +42,13 @@ import { useShortcut } from "../../../keyboard/useShortcut.js";
|
|
|
42
42
|
|
|
43
43
|
export type MailShellProps = Omit<AppShellProps, "active">;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* From `md` up the sidebar and the content beside it are each exactly as tall as the window under the title bar, so each scrolls by itself (the frame's
|
|
47
|
+
* own height is open-ended - `min-h-screen` - so a column's `overflow` would otherwise never apply and the whole page would scroll instead). On a phone
|
|
48
|
+
* the page scrolls as a whole, as it always did. The same height the Contacts page uses; `--rr-header-h` is the title bar's (or branding header's) height.
|
|
49
|
+
*/
|
|
50
|
+
const DESKTOP_PANE_HEIGHT = "md:h-[calc(100dvh_-_var(--rr-header-h,4rem))]";
|
|
51
|
+
|
|
45
52
|
/** One mailbox's own mail folders (already filtered to `MAIL_FOLDER_TYPES` and unsorted) - one entry per
|
|
46
53
|
* mailbox in `mailboxes`, fetched in parallel so every accessible mailbox's folder tree can render
|
|
47
54
|
* simultaneously (see `MailShell`'s own doc comment on why this replaced the old single-mailbox `folders`
|
|
@@ -485,11 +492,11 @@ export default function MailShell({
|
|
|
485
492
|
inner = (
|
|
486
493
|
<>
|
|
487
494
|
<MailShortcuts mailboxUid={defaultMailboxUid} />
|
|
488
|
-
<aside className="hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col">{sidebarContent("desktop")}</aside>
|
|
495
|
+
<aside className={["hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", DESKTOP_PANE_HEIGHT].join(" ")}>{sidebarContent("desktop")}</aside>
|
|
489
496
|
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Folders" fullScreen>
|
|
490
497
|
<div className="flex flex-col">{sidebarContent("mobile")}</div>
|
|
491
498
|
</Drawer>
|
|
492
|
-
<main className="flex-1 min-w-0 overflow-y-auto">
|
|
499
|
+
<main className={["flex-1 min-w-0 overflow-y-auto", DESKTOP_PANE_HEIGHT].join(" ")}>
|
|
493
500
|
<div className="md:hidden flex items-center gap-2 p-3">
|
|
494
501
|
<button
|
|
495
502
|
type="button"
|
|
@@ -156,7 +156,7 @@ function HtmlBody({
|
|
|
156
156
|
const theme = useResolvedTheme();
|
|
157
157
|
const surface = useThemeSurface(theme);
|
|
158
158
|
// Keyed on what the resolver reads rather than on the arrays, which a caller may rebuild on every render.
|
|
159
|
-
const inlineKey = [...(attachments ?? []).map((a) => `${a.uid}:${(a as { contentId?: string }).contentId ?? ""}`), inlineParts ? `parts:${inlineParts.length}` : ""].join("|");
|
|
159
|
+
const inlineKey = [...(attachments ?? []).map((a) => `${a.uid}:${(a as { contentId?: string }).contentId ?? ""}:${a.filename}:${a.mimeType}`), inlineParts ? `parts:${inlineParts.length}` : ""].join("|");
|
|
160
160
|
const resolveCid = useMemo(() => makeCidResolver(attachments, inlineParts), [inlineKey]);
|
|
161
161
|
const prepared = useMemo(() => prepareBodyHtml(html, { resolveCid }), [html, resolveCid]);
|
|
162
162
|
const mode: FrameMode = prepared.declaresDarkSupport ? "native" : original ? "original" : "adapt";
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { ApiRequestError, apiUrl } from "../../../../../lib/util/api.js";
|
|
6
6
|
import { Attachment, attachmentContentUrl } from "../../../../../lib/mail/mailApi.js";
|
|
7
7
|
import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
|
|
8
|
+
import { bytesToBase64, findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
|
|
8
9
|
|
|
9
10
|
/** What a message's body is: server-sanitized HTML, or plain text (a message with no HTML part, or the server's plain-text fallback). */
|
|
10
11
|
export type BodyContent = { kind: "html"; html: string } | { kind: "text"; text: string };
|
|
@@ -61,14 +62,6 @@ export async function fetchBodyContent(uid: string, version: number, signal?: Ab
|
|
|
61
62
|
export const MAX_INLINE_IMAGE_BYTES = 5_000_000;
|
|
62
63
|
const INLINE_IMAGE_TYPES = /^image\/(?:png|jpe?g|gif|webp|avif|bmp|svg\+xml)$/;
|
|
63
64
|
|
|
64
|
-
function base64(bytes: Uint8Array): string {
|
|
65
|
-
let binary = "";
|
|
66
|
-
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
67
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
68
|
-
}
|
|
69
|
-
return btoa(binary);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
65
|
/** A server attachment record as it may really arrive: the model carries the part's `Content-ID`, though the client type doesn't list it. */
|
|
73
66
|
export type InlineCapable = Attachment & { contentId?: string };
|
|
74
67
|
|
|
@@ -86,15 +79,24 @@ export function attachmentSource(): string {
|
|
|
86
79
|
* How an inline `cid:` image is resolved to something the frame may load: for a message shown from the server, the attachment whose
|
|
87
80
|
* `Content-ID` matches (its URL on this server); for a decrypted or verified one, the part inside the signed/encrypted entity, embedded
|
|
88
81
|
* as a `data:` URI (raster and SVG images up to `MAX_INLINE_IMAGE_BYTES`). Anything else has no source, so the image is not shown.
|
|
82
|
+
*
|
|
83
|
+
* The `Content-ID` is matched without regard to angle brackets, percent-encoding or case. An image that names no part (`contentId`
|
|
84
|
+
* undefined: the server left it with only its `alt`, as it does for a picture a sender attached without a `Content-ID`) is matched to the
|
|
85
|
+
* one image attachment of the same file name, when `alt` says it.
|
|
89
86
|
*/
|
|
90
|
-
export function makeCidResolver(
|
|
91
|
-
|
|
87
|
+
export function makeCidResolver(
|
|
88
|
+
attachments: InlineCapable[] | undefined,
|
|
89
|
+
parts: MimeAttachment[] | undefined,
|
|
90
|
+
): (contentId: string | undefined, alt?: string) => string | undefined {
|
|
91
|
+
return (contentId, alt) => {
|
|
92
92
|
if (parts) {
|
|
93
|
-
const
|
|
93
|
+
const candidates = parts.map((part, index) => ({ uid: String(index), filename: part.filename ?? "", mimeType: part.contentType, contentId: part.contentId }));
|
|
94
|
+
const found = findInlineAttachment(candidates, { cid: contentId, alt });
|
|
95
|
+
const part = found ? parts[Number(found.uid)] : undefined;
|
|
94
96
|
const bytes = part && INLINE_IMAGE_TYPES.test(part.contentType) ? part.decode() : undefined;
|
|
95
|
-
return part && bytes && bytes.length <= MAX_INLINE_IMAGE_BYTES ?
|
|
97
|
+
return part && bytes && bytes.length <= MAX_INLINE_IMAGE_BYTES ? toDataUri(part.contentType, bytes) : undefined;
|
|
96
98
|
}
|
|
97
|
-
const attachment = attachments
|
|
99
|
+
const attachment = findInlineAttachment(attachments, { cid: contentId, alt });
|
|
98
100
|
return attachment ? absoluteAttachmentUrl(attachment.uid) : undefined;
|
|
99
101
|
};
|
|
100
102
|
}
|
|
@@ -39,8 +39,9 @@ export interface PreparedBody {
|
|
|
39
39
|
|
|
40
40
|
export interface PrepareOptions {
|
|
41
41
|
/** The URL an inline image's `cid:` reference stands for, or `undefined` when there is no such part. Only `data:` URIs of an
|
|
42
|
-
* image type and `http(s)` URLs are accepted; anything else is treated as unresolved.
|
|
43
|
-
|
|
42
|
+
* image type and `http(s)` URLs are accepted; anything else is treated as unresolved. An image with no source at all (the
|
|
43
|
+
* server removes one it could not point at a part) is asked for with no `contentId` and its `alt`. */
|
|
44
|
+
resolveCid?: (contentId: string | undefined, alt?: string) => string | undefined;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/** What a stylesheet or a `style` attribute may not contain, whatever the sanitizers before it did. */
|
|
@@ -222,10 +223,14 @@ function hardenLink(element: Element): void {
|
|
|
222
223
|
}
|
|
223
224
|
}
|
|
224
225
|
|
|
225
|
-
|
|
226
|
+
/** Whether the image ends up pointing at a part of the message (so the frame's CSP must allow this server's attachment URLs). */
|
|
227
|
+
function hardenImage(element: Element, options: PrepareOptions): boolean {
|
|
226
228
|
const src = (element.getAttribute("src") ?? "").trim();
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
+
const cid = /^cid:/i.test(src);
|
|
230
|
+
// A picture the server could not tie to a part keeps its `alt` and loses its `src`: the message's own attachment of that name, if any.
|
|
231
|
+
const inline = cid || src === "";
|
|
232
|
+
const alt = element.getAttribute("alt") ?? undefined;
|
|
233
|
+
const target = cid ? options.resolveCid?.(src.slice(4).replace(/^<|>$/g, ""), alt) : inline ? options.resolveCid?.(undefined, alt) : src;
|
|
229
234
|
// Remote images are never fetched (they would tell the sender the message was read); an inline image resolves to an embedded
|
|
230
235
|
// image or to this server's own attachment URL, and one that doesn't resolve has nothing to show.
|
|
231
236
|
if (target !== undefined && (IMAGE_DATA_URI.test(target) || (inline && /^https?:\/\//i.test(target)))) {
|
|
@@ -233,6 +238,7 @@ function hardenImage(element: Element, options: PrepareOptions): void {
|
|
|
233
238
|
} else {
|
|
234
239
|
element.removeAttribute("src");
|
|
235
240
|
}
|
|
241
|
+
return cid || (inline && element.hasAttribute("src"));
|
|
236
242
|
}
|
|
237
243
|
|
|
238
244
|
/**
|
|
@@ -265,10 +271,9 @@ function harden(body: HTMLElement, options: PrepareOptions): { elements: number;
|
|
|
265
271
|
} else if (tag === "a" || tag === "area") {
|
|
266
272
|
hardenLink(element);
|
|
267
273
|
} else if (tag === "img") {
|
|
268
|
-
if (
|
|
274
|
+
if (hardenImage(element, options)) {
|
|
269
275
|
inlineImages++;
|
|
270
276
|
}
|
|
271
|
-
hardenImage(element, options);
|
|
272
277
|
}
|
|
273
278
|
}
|
|
274
279
|
return { elements: all.length, inlineImages };
|
|
@@ -13,7 +13,7 @@ const ENTER_START_OPACITY = 0.35;
|
|
|
13
13
|
const ENTER_SETTLE_MS = 20;
|
|
14
14
|
|
|
15
15
|
/** Whether the user asked their device for less motion. */
|
|
16
|
-
function prefersReducedMotion(): boolean {
|
|
16
|
+
export function prefersReducedMotion(): boolean {
|
|
17
17
|
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useRef, type MouseEvent, type TouchEvent } from "react";
|
|
6
|
+
|
|
7
|
+
export interface UseLongPressOptions {
|
|
8
|
+
/** Nothing starts while this is `false` (a desktop pointer, select mode); a press already held out is still seen through. Default `true`. */
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/** How long a finger has to stay down, in ms. Default `LONG_PRESS_MS`. */
|
|
11
|
+
delay?: number;
|
|
12
|
+
/** How far it may wander meanwhile, in px. Default `LONG_PRESS_SLOP`. */
|
|
13
|
+
tolerance?: number;
|
|
14
|
+
/** Called once when the press has been held for `delay`. */
|
|
15
|
+
onLongPress: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** How long a finger holds still to count as a press and hold, and how far it may drift (an unsteady hand) before it is a scroll or a swipe instead. */
|
|
19
|
+
export const LONG_PRESS_MS = 480;
|
|
20
|
+
export const LONG_PRESS_SLOP = 8;
|
|
21
|
+
/** How long after the finger lifts the click it would make is still swallowed; a press that makes none (the browser took it as a context menu) must not eat a later one. */
|
|
22
|
+
const CLICK_WINDOW_MS = 500;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Press and hold by one finger, for the touch screens the app's phone layout is for: mouse and pen are left alone.
|
|
26
|
+
*
|
|
27
|
+
* Spread the handlers on the element. The press is given up the moment the finger moves more than `tolerance`, a second finger lands, the touch is
|
|
28
|
+
* cancelled (the browser took it over to scroll) or something scrolls, so it never fights vertical scrolling or `useSwipe()`: a swipe has to travel
|
|
29
|
+
* further than the tolerance to be one. Once it fires, the finger lifting must not also be a click on whatever is under it (the click that follows is
|
|
30
|
+
* swallowed once), and the browser's own long-press (the context menu, the text selection and the callout of a link) is suppressed.
|
|
31
|
+
*
|
|
32
|
+
* The handlers stay on even while `enabled` is `false`, because the row that was pressed usually makes itself disabled in answer (select mode),
|
|
33
|
+
* and the click that ends the press still has to be swallowed after that.
|
|
34
|
+
*/
|
|
35
|
+
export function useLongPress({ enabled = true, delay = LONG_PRESS_MS, tolerance = LONG_PRESS_SLOP, onLongPress }: UseLongPressOptions) {
|
|
36
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
37
|
+
const start = useRef({ x: 0, y: 0 });
|
|
38
|
+
/** The press fired and its click has not come yet. */
|
|
39
|
+
const fired = useRef(false);
|
|
40
|
+
const clickWindow = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
41
|
+
const latest = useRef(onLongPress);
|
|
42
|
+
latest.current = onLongPress;
|
|
43
|
+
|
|
44
|
+
// One function for the life of the row: it is also the scroll listener, which has to be removed with the very function it was added with.
|
|
45
|
+
const forget: () => void = useRef(() => {
|
|
46
|
+
if (timer.current !== null) {
|
|
47
|
+
clearTimeout(timer.current);
|
|
48
|
+
timer.current = null;
|
|
49
|
+
window.removeEventListener("scroll", forget, true);
|
|
50
|
+
}
|
|
51
|
+
}).current;
|
|
52
|
+
|
|
53
|
+
function clearClickWindow() {
|
|
54
|
+
if (clickWindow.current !== null) {
|
|
55
|
+
clearTimeout(clickWindow.current);
|
|
56
|
+
clickWindow.current = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A timer left running would fire into a row that is gone.
|
|
61
|
+
useEffect(
|
|
62
|
+
() => () => {
|
|
63
|
+
forget();
|
|
64
|
+
if (clickWindow.current !== null) {
|
|
65
|
+
clearTimeout(clickWindow.current);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
[forget],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
function onTouchStart(event: TouchEvent<HTMLElement>) {
|
|
72
|
+
forget();
|
|
73
|
+
clearClickWindow();
|
|
74
|
+
fired.current = false;
|
|
75
|
+
if (!enabled || event.touches.length !== 1) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
start.current = { x: event.touches[0].clientX, y: event.touches[0].clientY };
|
|
79
|
+
// Scroll events don't bubble: only a capturing listener hears the list (or the page) moving under a finger that hasn't moved.
|
|
80
|
+
window.addEventListener("scroll", forget, { capture: true, passive: true });
|
|
81
|
+
timer.current = setTimeout(() => {
|
|
82
|
+
timer.current = null;
|
|
83
|
+
window.removeEventListener("scroll", forget, true);
|
|
84
|
+
fired.current = true;
|
|
85
|
+
navigator.vibrate?.(10);
|
|
86
|
+
latest.current();
|
|
87
|
+
}, delay);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function onTouchMove(event: TouchEvent<HTMLElement>) {
|
|
91
|
+
if (timer.current === null) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const touch = event.touches[0];
|
|
95
|
+
if (event.touches.length !== 1 || Math.hypot(touch.clientX - start.current.x, touch.clientY - start.current.y) >= tolerance) {
|
|
96
|
+
forget();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function onTouchEnd(event: TouchEvent<HTMLElement>) {
|
|
101
|
+
forget();
|
|
102
|
+
if (fired.current) {
|
|
103
|
+
// No click of the browser's own after this; the one that comes anyway (older browsers) is swallowed by `onClickCapture`.
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
clearClickWindow();
|
|
106
|
+
clickWindow.current = setTimeout(() => {
|
|
107
|
+
clickWindow.current = null;
|
|
108
|
+
fired.current = false;
|
|
109
|
+
}, CLICK_WINDOW_MS);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function onTouchCancel() {
|
|
114
|
+
forget();
|
|
115
|
+
fired.current = false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function onClickCapture(event: MouseEvent<HTMLElement>) {
|
|
119
|
+
if (fired.current) {
|
|
120
|
+
fired.current = false;
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
event.stopPropagation();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function onContextMenu(event: MouseEvent<HTMLElement>) {
|
|
127
|
+
// The long-press menu of a touch screen comes while the finger is still down (Android) or just after it fired.
|
|
128
|
+
if (timer.current !== null || fired.current) {
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
handlers: { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, onClickCapture, onContextMenu },
|
|
135
|
+
/** No text selection and no callout on what is pressed, while a press can start: the page's text stays selectable elsewhere. */
|
|
136
|
+
style: enabled ? ({ WebkitTouchCallout: "none", WebkitUserSelect: "none", userSelect: "none" } as const) : undefined,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -95,6 +95,8 @@ export const SHORTCUTS = {
|
|
|
95
95
|
workWeek: def({ id: "calendar.workWeek", keys: ["ctrl+alt+2"], label: "Work week view", scope: "calendar" }),
|
|
96
96
|
week: def({ id: "calendar.week", keys: ["ctrl+alt+3"], label: "Week view", scope: "calendar" }),
|
|
97
97
|
month: def({ id: "calendar.month", keys: ["ctrl+alt+4"], label: "Month view", scope: "calendar" }),
|
|
98
|
+
list: def({ id: "calendar.list", keys: ["ctrl+alt+5"], label: "List view", scope: "calendar" }),
|
|
99
|
+
search: def({ id: "calendar.search", keys: ["/", "mod+e"], label: "Search events", scope: "calendar" }),
|
|
98
100
|
},
|
|
99
101
|
contacts: {
|
|
100
102
|
create: def({ id: "contacts.new", keys: ["alt+n"], electronKeys: ["mod+n"], label: "New contact", scope: "contacts" }),
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** The modifier keys of a click - what a mouse event, or the native event behind a checkbox's change, carries. */
|
|
7
|
+
export interface ClickModifiers {
|
|
8
|
+
shiftKey?: boolean;
|
|
9
|
+
ctrlKey?: boolean;
|
|
10
|
+
metaKey?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Whether a click is a multi-select click (Shift, Ctrl, or Cmd on a Mac) rather than the plain click that opens a row. */
|
|
14
|
+
export function isSelectionClick(modifiers: ClickModifiers | undefined): boolean {
|
|
15
|
+
return !!modifiers && !!(modifiers.shiftKey || modifiers.ctrlKey || modifiers.metaKey);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Ctrl (or Cmd on a Mac): toggle just the clicked row. */
|
|
19
|
+
export function isToggleClick(modifiers: ClickModifiers | undefined): boolean {
|
|
20
|
+
return !!modifiers && !!(modifiers.ctrlKey || modifiers.metaKey);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The anchor of a Shift+click range, as Explorer and Outlook have it: the row last clicked (or opened) - `anchor` - while it is still in
|
|
25
|
+
* the list, else the row open in the reading pane (`open`), else nothing (the range is then just the clicked row).
|
|
26
|
+
*/
|
|
27
|
+
export function resolveAnchor(keys: readonly string[], anchor: string | null, open: string | null): string | null {
|
|
28
|
+
if (anchor !== null && keys.includes(anchor)) {
|
|
29
|
+
return anchor;
|
|
30
|
+
}
|
|
31
|
+
return open !== null && keys.includes(open) ? open : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every key from `anchor` to `target` inclusive, in the order `keys` has them - the order the list is displayed in, whichever end of the
|
|
36
|
+
* range was clicked first. With no usable anchor the range is `target` alone.
|
|
37
|
+
*/
|
|
38
|
+
export function rangeBetween(keys: readonly string[], anchor: string | null, target: string): string[] {
|
|
39
|
+
const from = anchor === null ? -1 : keys.indexOf(anchor);
|
|
40
|
+
const to = keys.indexOf(target);
|
|
41
|
+
if (from === -1 || to === -1) {
|
|
42
|
+
return [target];
|
|
43
|
+
}
|
|
44
|
+
return keys.slice(Math.min(from, to), Math.max(from, to) + 1);
|
|
45
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** How long a row that leaves the list (moved, deleted, archived) takes to collapse. The list drops it from its data after this long. */
|
|
7
|
+
export const ROW_EXIT_MS = 200;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether a row that leaves should collapse rather than vanish: the browser can animate an element (`Element.animate()` - every current one
|
|
11
|
+
* can) and the reader has not asked for less motion (`prefers-reduced-motion`). Otherwise the row is removed at once.
|
|
12
|
+
*/
|
|
13
|
+
export function canAnimateRowExit(): boolean {
|
|
14
|
+
// Only called from event handlers, i.e. in a browser.
|
|
15
|
+
if (typeof Element.prototype.animate !== "function") {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return !(typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Collapses a row that is leaving: its height and opacity run to zero over `ROW_EXIT_MS`, so the rows below slide up into the gap, and it
|
|
23
|
+
* stays collapsed (`fill: forwards`) until the list drops it. The row is marked so a second call for the same element does nothing.
|
|
24
|
+
*/
|
|
25
|
+
export function collapseRow(row: HTMLElement): void {
|
|
26
|
+
if (row.dataset.collapsing) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
row.dataset.collapsing = "true";
|
|
30
|
+
const height = row.getBoundingClientRect().height;
|
|
31
|
+
row.style.overflow = "hidden";
|
|
32
|
+
row.animate(
|
|
33
|
+
[
|
|
34
|
+
{ height: `${height}px`, opacity: 1 },
|
|
35
|
+
{ height: "0px", opacity: 0, borderBottomWidth: "0px" },
|
|
36
|
+
],
|
|
37
|
+
{ duration: ROW_EXIT_MS, easing: "ease-out", fill: "forwards" },
|
|
38
|
+
);
|
|
39
|
+
}
|