@rapidmx/web-client 0.14.1 → 0.15.1
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/apps/admin/domains/[uid].tsx +3 -2
- package/apps/shared/appearance/AppearanceForm.tsx +9 -1
- package/apps/shared/calendar/bookingPlugin.ts +100 -0
- package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
- package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
- package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
- package/apps/shared/components/calendar/EventDetails.tsx +401 -0
- package/apps/shared/components/calendar/EventEditor.tsx +640 -0
- package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
- package/apps/shared/components/calendar/EventModal.tsx +126 -866
- package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
- package/apps/shared/components/calendar/EventShell.tsx +202 -0
- package/apps/shared/components/calendar/FindATime.tsx +379 -0
- package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
- package/apps/shared/components/calendar/MonthView.tsx +15 -3
- package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
- package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
- package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
- package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
- package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
- package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
- package/apps/shared/components/calendar/eventForm.ts +108 -0
- package/apps/shared/components/calendar/eventFormat.ts +183 -0
- package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
- package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
- package/apps/shared/components/layout/UserMenu.tsx +80 -28
- package/apps/shared/components/mail/ConversationList.tsx +100 -48
- package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
- package/apps/shared/components/mail/InviteCard.tsx +387 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
- package/apps/shared/components/mail/SwipeRow.tsx +120 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
- package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
- package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
- package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
- package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
- package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/gestures/useEnterSlide.ts +54 -0
- package/apps/shared/gestures/useSwipe.ts +114 -0
- package/apps/shared/gestures/useSwipeSlide.ts +113 -0
- package/apps/shared/gestures/useWheelPaging.ts +107 -0
- package/apps/shared/mail/newMailNotifications.ts +171 -183
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/notifications/preferences.ts +36 -0
- package/apps/shared/notifications/store.ts +560 -551
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +139 -37
- package/apps/www/index.tsx +273 -126
- package/apps/www/messages/[uid].tsx +31 -4
- package/apps/www/settings/profile/index.tsx +248 -0
- package/dist/apps/admin/domains/[uid].js +4 -2
- package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
- package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
- package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
- package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
- package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
- package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
- package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
- package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
- package/dist/apps/shared/components/calendar/EventModal.js +57 -459
- package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
- package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
- package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
- package/dist/apps/shared/components/calendar/EventShell.js +134 -0
- package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
- package/dist/apps/shared/components/calendar/FindATime.js +190 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
- package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
- package/dist/apps/shared/components/calendar/MonthView.js +11 -5
- package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
- package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
- package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
- package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
- package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
- package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
- package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
- package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
- package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
- package/dist/apps/shared/components/calendar/eventForm.js +1 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
- package/dist/apps/shared/components/layout/UserMenu.js +70 -23
- package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
- package/dist/apps/shared/components/mail/ConversationList.js +21 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
- package/dist/apps/shared/components/mail/InviteCard.js +150 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
- package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
- package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
- package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
- package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
- package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
- package/dist/apps/shared/gestures/useSwipe.js +90 -0
- package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
- package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
- package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
- package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
- package/dist/apps/shared/mail/newMailNotifications.js +3 -12
- package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
- package/dist/apps/shared/notifications/preferences.d.ts +13 -0
- package/dist/apps/shared/notifications/preferences.js +36 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -0
- package/dist/apps/shared/notifications/store.js +7 -23
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +76 -6
- package/dist/apps/www/index.js +140 -39
- package/dist/apps/www/messages/[uid].js +13 -5
- package/dist/apps/www/settings/profile/index.d.ts +4 -0
- package/dist/apps/www/settings/profile/index.js +124 -0
- package/package.json +2 -2
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { APP_HREFS } from "../../../navigation/appHrefs.js";
|
|
2
|
+
/** Whether an attachment is a calendar file - what tells the pane a message may carry an invitation worth asking the server about. By type
|
|
3
|
+
* or by name, since mail clients disagree about which one they set (`text/calendar`, `application/ics`, or a bare `.ics` as octet-stream). */
|
|
4
|
+
export function isCalendarAttachment(attachment) {
|
|
5
|
+
const type = (attachment.mimeType ?? "").split(";")[0].trim().toLowerCase();
|
|
6
|
+
return type === "text/calendar" || type === "application/ics" || (attachment.filename ?? "").trim().toLowerCase().endsWith(".ics");
|
|
7
|
+
}
|
|
8
|
+
const DAY_FORMAT = { weekday: "short", year: "numeric", month: "short", day: "numeric" };
|
|
9
|
+
const CLOCK_FORMAT = { hour: "numeric", minute: "2-digit" };
|
|
10
|
+
/** `zone` if the browser knows it, else UTC - an all-day date is a calendar day, and needs a fixed zone to stay the same day for every reader. */
|
|
11
|
+
export function allDayZone(zone) {
|
|
12
|
+
try {
|
|
13
|
+
new Intl.DateTimeFormat(undefined, { timeZone: zone });
|
|
14
|
+
return zone ?? "UTC";
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return "UTC";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** The instant an ISO string names, or `undefined` when it is missing or unparseable. */
|
|
21
|
+
export function validDate(iso) {
|
|
22
|
+
const date = iso ? new Date(iso) : undefined;
|
|
23
|
+
return date && !isNaN(date.getTime()) ? date : undefined;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* When the meeting is, in the reader's own locale and time zone: one date and a time range for a same-day event, both ends in full for
|
|
27
|
+
* one that spans days, and dates only for an all-day event (drawn in the organizer's zone, since a calendar day is not an instant). `undefined`
|
|
28
|
+
* when the invitation names no usable start. A missing or unusable end shows the start alone.
|
|
29
|
+
*/
|
|
30
|
+
export function formatInviteWhen(invite) {
|
|
31
|
+
const start = validDate(invite.startDate);
|
|
32
|
+
if (!start) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const parsedEnd = validDate(invite.endDate);
|
|
36
|
+
const end = parsedEnd && parsedEnd > start ? parsedEnd : undefined;
|
|
37
|
+
if (invite.allDay) {
|
|
38
|
+
const day = new Intl.DateTimeFormat(undefined, { ...DAY_FORMAT, timeZone: allDayZone(invite.timezone) });
|
|
39
|
+
const first = day.format(start);
|
|
40
|
+
// An all-day event's end is the midnight after its last day (iCalendar's DTEND is exclusive), so the last day is a moment before it.
|
|
41
|
+
const last = end ? day.format(new Date(end.getTime() - 1)) : first;
|
|
42
|
+
return last === first ? first : `${first} - ${last}`;
|
|
43
|
+
}
|
|
44
|
+
const date = new Intl.DateTimeFormat(undefined, DAY_FORMAT);
|
|
45
|
+
const time = new Intl.DateTimeFormat(undefined, CLOCK_FORMAT);
|
|
46
|
+
// The zone is named once, after the last time shown: the times are the reader's own, not the organizer's.
|
|
47
|
+
const timeAndZone = new Intl.DateTimeFormat(undefined, { ...CLOCK_FORMAT, timeZoneName: "short" });
|
|
48
|
+
const startDay = date.format(start);
|
|
49
|
+
if (!end) {
|
|
50
|
+
return `${startDay}, ${timeAndZone.format(start)}`;
|
|
51
|
+
}
|
|
52
|
+
return startDay === date.format(end)
|
|
53
|
+
? `${startDay}, ${time.format(start)} - ${timeAndZone.format(end)}`
|
|
54
|
+
: `${startDay}, ${time.format(start)} - ${date.format(end)}, ${timeAndZone.format(end)}`;
|
|
55
|
+
}
|
|
56
|
+
/** The meeting's start the way a list row writes it - `Thu 9/24/2026 1:00 PM` (dates only for an all-day event), in the reader's zone. */
|
|
57
|
+
export function formatRowStart(invite) {
|
|
58
|
+
const start = validDate(invite.startDate);
|
|
59
|
+
if (!start) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const zone = invite.allDay ? allDayZone(invite.timezone) : undefined;
|
|
63
|
+
const weekday = new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: zone }).format(start);
|
|
64
|
+
const date = new Intl.DateTimeFormat(undefined, { year: "numeric", month: "numeric", day: "numeric", timeZone: zone }).format(start);
|
|
65
|
+
return invite.allDay ? `${weekday} ${date}` : `${weekday} ${date} ${new Intl.DateTimeFormat(undefined, CLOCK_FORMAT).format(start)}`;
|
|
66
|
+
}
|
|
67
|
+
/** `YYYY-MM-DD` of an instant in the reader's zone, or in `timeZone` when given (`en-CA` writes a date that way). */
|
|
68
|
+
export function isoDay(date, timeZone) {
|
|
69
|
+
return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone }).format(date);
|
|
70
|
+
}
|
|
71
|
+
/** Where "Open in Calendar" goes: the day view of the day the meeting starts (the `date`/`view` the calendar page reads), or the calendar itself
|
|
72
|
+
* when the invitation names no usable start. An all-day event's day is the organizer's; any other is the reader's. */
|
|
73
|
+
export function calendarHref(invite) {
|
|
74
|
+
const start = validDate(invite.startDate);
|
|
75
|
+
if (!start) {
|
|
76
|
+
return APP_HREFS.calendar;
|
|
77
|
+
}
|
|
78
|
+
return `${APP_HREFS.calendar}?date=${isoDay(start, invite.allDay ? allDayZone(invite.timezone) : undefined)}&view=day`;
|
|
79
|
+
}
|
|
80
|
+
/** The reader's answer as a list row says it. */
|
|
81
|
+
export const ANSWER_LABEL = {
|
|
82
|
+
accepted: "Accepted",
|
|
83
|
+
tentative: "Tentative",
|
|
84
|
+
declined: "Declined",
|
|
85
|
+
};
|
|
86
|
+
/** The most conflicting events that are named; more are counted. */
|
|
87
|
+
const MAX_NAMED_CONFLICTS = 3;
|
|
88
|
+
/** The titles of the events an invitation conflicts with, as "A, B and 2 more". */
|
|
89
|
+
export function conflictSummary(conflicts) {
|
|
90
|
+
const titles = conflicts.map((c) => c.title.trim() || "(no title)");
|
|
91
|
+
return titles.length <= MAX_NAMED_CONFLICTS
|
|
92
|
+
? titles.join(", ")
|
|
93
|
+
: `${titles.slice(0, MAX_NAMED_CONFLICTS).join(", ")} and ${titles.length - MAX_NAMED_CONFLICTS} more`;
|
|
94
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { MessageInvite } from "@rapidmx/react-shared/calendar/inviteApi.js";
|
|
2
|
+
/**
|
|
3
|
+
* A per-message cache of the calendar invitation the server reads out of a message, shared by everything that draws one: the reading pane's
|
|
4
|
+
* card, the list row's RSVP chip and its popover. They ask for the same message a moment apart (the row's chip, then the pane once it is
|
|
5
|
+
* opened), and an answer given in one has to show in the others, so a lookup is made once and every answer goes through `storeInvite()`.
|
|
6
|
+
*
|
|
7
|
+
* A message with no invitation (`null`, the server's 404) is remembered too. A lookup that fails is not - nothing is cached and the next
|
|
8
|
+
* thing to ask (a remount, another row) tries again - but nothing retries on its own. An entry goes stale after `INVITE_TTL_MS`, because the
|
|
9
|
+
* calendar it describes can change elsewhere (the meeting deleted, another device answering); it is refetched when next asked for.
|
|
10
|
+
*/
|
|
11
|
+
/** How long a looked-up invitation is trusted before the next ask reads it again. */
|
|
12
|
+
export declare const INVITE_TTL_MS = 30000;
|
|
13
|
+
/** Reads a message's invitation, from the cache when it is fresh. Rejects when the lookup fails. */
|
|
14
|
+
export declare function loadInvite(uid: string): Promise<MessageInvite | null>;
|
|
15
|
+
/** Records the invitation as it now stands after an answer, so the pane, the chip and the popover all show it. */
|
|
16
|
+
export declare function storeInvite(uid: string, invite: MessageInvite): void;
|
|
17
|
+
/** Forgets everything - for tests, and for a sign-out. */
|
|
18
|
+
export declare function clearInviteCache(): void;
|
|
19
|
+
/**
|
|
20
|
+
* A message's invitation, looked up once (shared with every other user of the same message) when `enabled`. `invite` is `null` until it is
|
|
21
|
+
* known, and stays `null` for a message with none or a lookup that failed - nothing to draw either way. `settled` says the lookup is over.
|
|
22
|
+
* Nothing is asked while disabled, and a failure is never retried from here.
|
|
23
|
+
*/
|
|
24
|
+
export declare function useMessageInvite(uid: string, enabled?: boolean): {
|
|
25
|
+
invite: MessageInvite | null;
|
|
26
|
+
settled: boolean;
|
|
27
|
+
setInvite: (invite: MessageInvite) => void;
|
|
28
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useCallback, useEffect, useReducer } from "react";
|
|
6
|
+
import { getMessageInvite } from "@rapidmx/react-shared/calendar/inviteApi.js";
|
|
7
|
+
/**
|
|
8
|
+
* A per-message cache of the calendar invitation the server reads out of a message, shared by everything that draws one: the reading pane's
|
|
9
|
+
* card, the list row's RSVP chip and its popover. They ask for the same message a moment apart (the row's chip, then the pane once it is
|
|
10
|
+
* opened), and an answer given in one has to show in the others, so a lookup is made once and every answer goes through `storeInvite()`.
|
|
11
|
+
*
|
|
12
|
+
* A message with no invitation (`null`, the server's 404) is remembered too. A lookup that fails is not - nothing is cached and the next
|
|
13
|
+
* thing to ask (a remount, another row) tries again - but nothing retries on its own. An entry goes stale after `INVITE_TTL_MS`, because the
|
|
14
|
+
* calendar it describes can change elsewhere (the meeting deleted, another device answering); it is refetched when next asked for.
|
|
15
|
+
*/
|
|
16
|
+
/** How long a looked-up invitation is trusted before the next ask reads it again. */
|
|
17
|
+
export const INVITE_TTL_MS = 30000;
|
|
18
|
+
const entries = new Map();
|
|
19
|
+
const listeners = new Map();
|
|
20
|
+
function notify(uid) {
|
|
21
|
+
listeners.get(uid)?.forEach((listener) => listener());
|
|
22
|
+
}
|
|
23
|
+
/** Older servers, or a partial answer, may leave a list out: a card must never fail to draw over one. */
|
|
24
|
+
function normalize(invite) {
|
|
25
|
+
if (!invite) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const partial = invite;
|
|
29
|
+
return { ...invite, attendees: partial.attendees ?? [], conflicts: partial.conflicts ?? [], schedule: partial.schedule ?? [] };
|
|
30
|
+
}
|
|
31
|
+
/** Reads a message's invitation, from the cache when it is fresh. Rejects when the lookup fails. */
|
|
32
|
+
export function loadInvite(uid) {
|
|
33
|
+
const existing = entries.get(uid);
|
|
34
|
+
if (existing && (existing.value === undefined || Date.now() - existing.at < INVITE_TTL_MS)) {
|
|
35
|
+
return existing.promise;
|
|
36
|
+
}
|
|
37
|
+
const entry = {
|
|
38
|
+
at: Date.now(),
|
|
39
|
+
promise: getMessageInvite(uid).then((invite) => {
|
|
40
|
+
entry.value = normalize(invite);
|
|
41
|
+
entry.at = Date.now();
|
|
42
|
+
notify(uid);
|
|
43
|
+
return entry.value;
|
|
44
|
+
}, (err) => {
|
|
45
|
+
if (entries.get(uid) === entry) {
|
|
46
|
+
entries.delete(uid);
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
entries.set(uid, entry);
|
|
52
|
+
return entry.promise;
|
|
53
|
+
}
|
|
54
|
+
/** Records the invitation as it now stands after an answer, so the pane, the chip and the popover all show it. */
|
|
55
|
+
export function storeInvite(uid, invite) {
|
|
56
|
+
const value = normalize(invite);
|
|
57
|
+
entries.set(uid, { promise: Promise.resolve(value), value, at: Date.now() });
|
|
58
|
+
notify(uid);
|
|
59
|
+
}
|
|
60
|
+
/** Forgets everything - for tests, and for a sign-out. */
|
|
61
|
+
export function clearInviteCache() {
|
|
62
|
+
entries.clear();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A message's invitation, looked up once (shared with every other user of the same message) when `enabled`. `invite` is `null` until it is
|
|
66
|
+
* known, and stays `null` for a message with none or a lookup that failed - nothing to draw either way. `settled` says the lookup is over.
|
|
67
|
+
* Nothing is asked while disabled, and a failure is never retried from here.
|
|
68
|
+
*/
|
|
69
|
+
export function useMessageInvite(uid, enabled = true) {
|
|
70
|
+
const [, rerender] = useReducer((n) => n + 1, 0);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!enabled) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
let cancelled = false;
|
|
76
|
+
const update = () => {
|
|
77
|
+
if (!cancelled) {
|
|
78
|
+
rerender();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const set = listeners.get(uid) ?? new Set();
|
|
82
|
+
set.add(update);
|
|
83
|
+
listeners.set(uid, set);
|
|
84
|
+
loadInvite(uid).then(update, () => {
|
|
85
|
+
// Nothing to show, and nothing worth interrupting the reader with: the message reads the same without it.
|
|
86
|
+
});
|
|
87
|
+
return () => {
|
|
88
|
+
cancelled = true;
|
|
89
|
+
set.delete(update);
|
|
90
|
+
};
|
|
91
|
+
}, [uid, enabled]);
|
|
92
|
+
const setInvite = useCallback((invite) => storeInvite(uid, invite), [uid]);
|
|
93
|
+
const value = enabled ? entries.get(uid)?.value : undefined;
|
|
94
|
+
return { invite: value ?? null, settled: value !== undefined, setInvite };
|
|
95
|
+
}
|
|
@@ -59,6 +59,11 @@ export interface MailShellContextValue {
|
|
|
59
59
|
* context value, which is only ever read outside a real shell.
|
|
60
60
|
*/
|
|
61
61
|
trackMessageChange: (previous: Message, next: Message | null) => CountTracker;
|
|
62
|
+
/**
|
|
63
|
+
* The phone layout's header row beside the folders button, for the page to put its search box in (`createPortal()`): the shell
|
|
64
|
+
* owns the row, the page owns the search state. `null` until the row has rendered, and on the default context value.
|
|
65
|
+
*/
|
|
66
|
+
mobileSearchSlot: HTMLElement | null;
|
|
62
67
|
}
|
|
63
68
|
/** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
|
|
64
69
|
export declare function useMailShell(): MailShellContextValue;
|
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
7
|
-
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
7
|
+
import { HiOutlineBars3, HiOutlinePencilSquare } from "react-icons/hi2";
|
|
8
8
|
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
9
9
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
10
|
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
@@ -42,6 +42,7 @@ const MailShellContext = createContext({
|
|
|
42
42
|
noteFolderUids: () => undefined,
|
|
43
43
|
live: NO_LIVE_UPDATES,
|
|
44
44
|
trackMessageChange: () => NO_TRACKER,
|
|
45
|
+
mobileSearchSlot: null,
|
|
45
46
|
});
|
|
46
47
|
/** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
|
|
47
48
|
export function useMailShell() {
|
|
@@ -91,6 +92,14 @@ function ComposeButton({ mailboxUid }) {
|
|
|
91
92
|
const env = useKeyEnvironment();
|
|
92
93
|
return (_jsx("button", { type: "button", title: withHint("Compose", SHORTCUTS.mail.create, env), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.mail.create, env), onClick: () => openCompose({ mailboxUid }), onPointerEnter: prefetchComposeWindow, onFocus: prefetchComposeWindow, className: "block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark", children: "Compose" }));
|
|
93
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* A small round Compose button floating at the bottom right on a phone, just above the bottom tab bar (which is `h-14`, fixed), for the
|
|
97
|
+
* layout where the sidebar's Compose button is behind the folders drawer. Opens the same window, for the same mailbox, as that button.
|
|
98
|
+
*/
|
|
99
|
+
function MobileComposeButton({ mailboxUid }) {
|
|
100
|
+
const { openCompose } = useCompose();
|
|
101
|
+
return (_jsx("button", { type: "button", "aria-label": "New message", onClick: () => openCompose({ mailboxUid }), onPointerEnter: prefetchComposeWindow, onFocus: prefetchComposeWindow, className: "md:hidden fixed right-4 bottom-[4.5rem] z-30 w-12 h-12 flex items-center justify-center rounded-full bg-primary text-white shadow-lg hover:bg-primary-dark", children: _jsx(HiOutlinePencilSquare, { size: 22, "aria-hidden": "true" }) }));
|
|
102
|
+
}
|
|
94
103
|
/**
|
|
95
104
|
* Mail's keyboard shortcuts that don't belong to a list or a message: "New message". A component of its own for the reason `ComposeButton`
|
|
96
105
|
* is - `useCompose()` only resolves below `ComposeProvider`, i.e. inside the `AppShell` this shell renders - and mounted once, whatever number
|
|
@@ -169,7 +178,9 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
169
178
|
const trackMessageChange = folderCounts.track;
|
|
170
179
|
// `(3) Acme: Mail` in the tab strip while there is unread mail in an Inbox - kept by the frame when there is one.
|
|
171
180
|
useUnreadTitle(inboxUnreadTotal(mailboxFolders, counts), { enabled: !hosted });
|
|
172
|
-
|
|
181
|
+
// A state rather than a ref: the page reads it during its render, so it has to re-render once the row it names is in the document.
|
|
182
|
+
const [mobileSearchSlot, setMobileSearchSlot] = useState(null);
|
|
183
|
+
const contextValue = useMemo(() => ({ mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot }), [mailboxUid, folderUid, aggregateFolderType, mailboxes, mailboxFolders, onFolderCreated, noteFolderUids, live, trackMessageChange, mobileSearchSlot]);
|
|
173
184
|
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else for a
|
|
174
185
|
// mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all. Checks
|
|
175
186
|
// `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
|
|
@@ -215,7 +226,7 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
215
226
|
].join(" "), children: [_jsx("span", { className: badge?.kind === "unread" ? "font-semibold" : undefined, children: FOLDER_LABELS[folder.type] ?? folder.name }), badge &&
|
|
216
227
|
(folder.type === "outbox" ? (_jsx(OutboxBadge, { total: badge.value, status: outboxStatuses[folder.uid], pendingHere: pendingCountFor(mailbox.uid, pendingSends) })) : (_jsx(FolderBadgeChip, { badge: badge })))] }, folder.uid));
|
|
217
228
|
}) })] }, mailbox.uid)))] }))] }));
|
|
218
|
-
inner = (_jsxs(_Fragment, { children: [_jsx(MailShortcuts, { mailboxUid: defaultMailboxUid }), _jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent() }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent() }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsx("button", { type: "button", className: "
|
|
229
|
+
inner = (_jsxs(_Fragment, { children: [_jsx(MailShortcuts, { mailboxUid: defaultMailboxUid }), _jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent() }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent() }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsxs("div", { className: "md:hidden flex items-center gap-2 p-3", children: [_jsx("button", { type: "button", className: "shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("div", { ref: setMobileSearchSlot, className: "flex-1 min-w-0" })] }), _jsx(MobileComposeButton, { mailboxUid: defaultMailboxUid }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
219
230
|
}
|
|
220
231
|
// Wraps AppShell unconditionally, at a stable tree position regardless of whether mailboxUid has
|
|
221
232
|
// resolved yet - KeyEnrollmentGate itself passes `children` through untouched until a real
|
|
@@ -16,6 +16,7 @@ import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
|
16
16
|
import { mergePluginNavItems } from "../../../plugins/pluginNav.js";
|
|
17
17
|
/** Every settings section with its own sidebar entry. */
|
|
18
18
|
export const SETTINGS_SECTIONS = [
|
|
19
|
+
{ id: "profile", href: "/settings/profile", label: "Profile" },
|
|
19
20
|
{ id: "appearance", href: "/settings/appearance", label: "Appearance" },
|
|
20
21
|
{ id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
|
|
21
22
|
{ id: "filters", href: "/settings/filters", label: "Mail Filters" },
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type CSSProperties } from "react";
|
|
2
|
+
/** How long the new contents take to settle. Short, so a run of steps (a wheel kept turning) never waits on the last one. */
|
|
3
|
+
export declare const ENTER_SLIDE_MS = 140;
|
|
4
|
+
/**
|
|
5
|
+
* The look of a page that has just been swapped for the next or previous one by a vertical step (the wheel): the new contents start
|
|
6
|
+
* a little way off in the direction the step went - below for `1` (the next), above for `-1` - and faded, and settle into place.
|
|
7
|
+
* The old contents are not animated out: the swap is immediate, so a run of steps never queues, and a step in the middle of a
|
|
8
|
+
* settle just starts the next one from its start. Put `style` on the element that holds the contents and call `enter()` as the
|
|
9
|
+
* contents change. Nothing moves for someone who asked for less motion.
|
|
10
|
+
*/
|
|
11
|
+
export declare function useEnterSlide(): {
|
|
12
|
+
style: CSSProperties;
|
|
13
|
+
enter: (direction: 1 | -1) => void;
|
|
14
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
/** How long the new contents take to settle. Short, so a run of steps (a wheel kept turning) never waits on the last one. */
|
|
7
|
+
export const ENTER_SLIDE_MS = 140;
|
|
8
|
+
/** How far off, in pixels, and how faded the new contents start. */
|
|
9
|
+
const ENTER_OFFSET_PX = 56;
|
|
10
|
+
const ENTER_START_OPACITY = 0.35;
|
|
11
|
+
/** The pause that lets the browser draw the contents at their starting place before they move. */
|
|
12
|
+
const ENTER_SETTLE_MS = 20;
|
|
13
|
+
/** Whether the user asked their device for less motion. */
|
|
14
|
+
function prefersReducedMotion() {
|
|
15
|
+
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The look of a page that has just been swapped for the next or previous one by a vertical step (the wheel): the new contents start
|
|
19
|
+
* a little way off in the direction the step went - below for `1` (the next), above for `-1` - and faded, and settle into place.
|
|
20
|
+
* The old contents are not animated out: the swap is immediate, so a run of steps never queues, and a step in the middle of a
|
|
21
|
+
* settle just starts the next one from its start. Put `style` on the element that holds the contents and call `enter()` as the
|
|
22
|
+
* contents change. Nothing moves for someone who asked for less motion.
|
|
23
|
+
*/
|
|
24
|
+
export function useEnterSlide() {
|
|
25
|
+
const [offset, setOffset] = useState(0);
|
|
26
|
+
const [animated, setAnimated] = useState(false);
|
|
27
|
+
const timer = useRef(undefined);
|
|
28
|
+
useEffect(() => () => window.clearTimeout(timer.current), []);
|
|
29
|
+
function enter(direction) {
|
|
30
|
+
if (prefersReducedMotion()) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
window.clearTimeout(timer.current);
|
|
34
|
+
setAnimated(false);
|
|
35
|
+
setOffset(direction * ENTER_OFFSET_PX);
|
|
36
|
+
timer.current = window.setTimeout(() => {
|
|
37
|
+
setAnimated(true);
|
|
38
|
+
setOffset(0);
|
|
39
|
+
}, ENTER_SETTLE_MS);
|
|
40
|
+
}
|
|
41
|
+
const style = {
|
|
42
|
+
transform: offset === 0 ? undefined : `translate3d(0, ${offset}px, 0)`,
|
|
43
|
+
opacity: offset === 0 ? undefined : ENTER_START_OPACITY,
|
|
44
|
+
transition: animated ? `transform ${ENTER_SLIDE_MS}ms ease-out, opacity ${ENTER_SLIDE_MS}ms ease-out` : "none",
|
|
45
|
+
};
|
|
46
|
+
return { style, enter };
|
|
47
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type TouchEvent } from "react";
|
|
2
|
+
/** Which way the finger moved: `"left"` is right to left. */
|
|
3
|
+
export type SwipeDirection = "left" | "right";
|
|
4
|
+
export interface UseSwipeOptions {
|
|
5
|
+
/** Nothing is tracked while this is `false` (a desktop pointer, select mode). Default `true`. */
|
|
6
|
+
enabled?: boolean;
|
|
7
|
+
/** The directions that mean something; a drag the other way doesn't move at all. Default both. */
|
|
8
|
+
directions?: readonly SwipeDirection[];
|
|
9
|
+
/** How far the finger has to travel to commit, in px. Default `MIN_SWIPE_DISTANCE`, or a third of the element's width when that is more. */
|
|
10
|
+
distance?: number;
|
|
11
|
+
/** Called as the finger moves, with the horizontal offset (the direction filter applied), and with `0` once it lifts. */
|
|
12
|
+
onDrag?: (offset: number) => void;
|
|
13
|
+
/** Called once when a swipe commits. `onDrag(0)` follows. */
|
|
14
|
+
onSwipe: (direction: SwipeDirection) => void;
|
|
15
|
+
}
|
|
16
|
+
/** The least distance that commits a swipe, and how far the finger must move before its direction is decided. */
|
|
17
|
+
export declare const MIN_SWIPE_DISTANCE = 80;
|
|
18
|
+
export declare const SWIPE_SLOP = 10;
|
|
19
|
+
/** A quicker flick than this (px per ms) commits after `FLING_MIN_DISTANCE` alone. */
|
|
20
|
+
export declare const FLING_VELOCITY = 0.5;
|
|
21
|
+
export declare const FLING_MIN_DISTANCE = 40;
|
|
22
|
+
/**
|
|
23
|
+
* A horizontal swipe by one finger, for the touch screens the app's phone layout is for: mouse and pen are left alone.
|
|
24
|
+
*
|
|
25
|
+
* Spread the handlers on the element and give it `touch-action: pan-y` (`style`), so the browser keeps scrolling vertically and leaves
|
|
26
|
+
* the horizontal movement to this. A gesture that starts out mostly vertical is a scroll and is never a swipe, however far it later
|
|
27
|
+
* strays sideways; one that starts out horizontal is a swipe, and stays one.
|
|
28
|
+
*/
|
|
29
|
+
export declare function useSwipe({ enabled, directions, distance, onDrag, onSwipe }: UseSwipeOptions): {
|
|
30
|
+
handlers: {
|
|
31
|
+
onTouchStart: (event: TouchEvent<HTMLElement>) => void;
|
|
32
|
+
onTouchMove: (event: TouchEvent<HTMLElement>) => void;
|
|
33
|
+
onTouchEnd: () => void;
|
|
34
|
+
onTouchCancel: () => void;
|
|
35
|
+
} | {
|
|
36
|
+
onTouchStart?: undefined;
|
|
37
|
+
onTouchMove?: undefined;
|
|
38
|
+
onTouchEnd?: undefined;
|
|
39
|
+
onTouchCancel?: undefined;
|
|
40
|
+
};
|
|
41
|
+
/** Leaves vertical panning to the browser. */
|
|
42
|
+
style: {
|
|
43
|
+
readonly touchAction: "pan-y";
|
|
44
|
+
} | undefined;
|
|
45
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useRef } from "react";
|
|
6
|
+
/** The least distance that commits a swipe, and how far the finger must move before its direction is decided. */
|
|
7
|
+
export const MIN_SWIPE_DISTANCE = 80;
|
|
8
|
+
export const SWIPE_SLOP = 10;
|
|
9
|
+
/** A quicker flick than this (px per ms) commits after `FLING_MIN_DISTANCE` alone. */
|
|
10
|
+
export const FLING_VELOCITY = 0.5;
|
|
11
|
+
export const FLING_MIN_DISTANCE = 40;
|
|
12
|
+
/**
|
|
13
|
+
* A horizontal swipe by one finger, for the touch screens the app's phone layout is for: mouse and pen are left alone.
|
|
14
|
+
*
|
|
15
|
+
* Spread the handlers on the element and give it `touch-action: pan-y` (`style`), so the browser keeps scrolling vertically and leaves
|
|
16
|
+
* the horizontal movement to this. A gesture that starts out mostly vertical is a scroll and is never a swipe, however far it later
|
|
17
|
+
* strays sideways; one that starts out horizontal is a swipe, and stays one.
|
|
18
|
+
*/
|
|
19
|
+
export function useSwipe({ enabled = true, directions = ["left", "right"], distance, onDrag, onSwipe }) {
|
|
20
|
+
const start = useRef(null);
|
|
21
|
+
const offset = useRef(0);
|
|
22
|
+
const latest = useRef({ directions, distance, onDrag, onSwipe });
|
|
23
|
+
latest.current = { directions, distance, onDrag, onSwipe };
|
|
24
|
+
function clamp(dx) {
|
|
25
|
+
const allowed = latest.current.directions;
|
|
26
|
+
return (dx < 0 && !allowed.includes("left")) || (dx > 0 && !allowed.includes("right")) ? 0 : dx;
|
|
27
|
+
}
|
|
28
|
+
function onTouchStart(event) {
|
|
29
|
+
if (!enabled || event.touches.length !== 1) {
|
|
30
|
+
start.current = null;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const touch = event.touches[0];
|
|
34
|
+
start.current = { x: touch.clientX, y: touch.clientY, time: Date.now(), width: event.currentTarget.clientWidth };
|
|
35
|
+
offset.current = 0;
|
|
36
|
+
}
|
|
37
|
+
function onTouchMove(event) {
|
|
38
|
+
const gesture = start.current;
|
|
39
|
+
if (!gesture || gesture.mode === "scroll") {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (event.touches.length !== 1) {
|
|
43
|
+
cancel();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const dx = event.touches[0].clientX - gesture.x;
|
|
47
|
+
const dy = event.touches[0].clientY - gesture.y;
|
|
48
|
+
if (!gesture.mode) {
|
|
49
|
+
if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE_SLOP) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
gesture.mode = Math.abs(dx) > Math.abs(dy) * 1.5 ? "swipe" : "scroll";
|
|
53
|
+
if (gesture.mode === "scroll") {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
offset.current = clamp(dx);
|
|
58
|
+
latest.current.onDrag?.(offset.current);
|
|
59
|
+
}
|
|
60
|
+
function onTouchEnd() {
|
|
61
|
+
const gesture = start.current;
|
|
62
|
+
start.current = null;
|
|
63
|
+
if (!gesture || gesture.mode !== "swipe") {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const dx = offset.current;
|
|
67
|
+
offset.current = 0;
|
|
68
|
+
const travelled = Math.abs(dx);
|
|
69
|
+
const velocity = travelled / Math.max(1, Date.now() - gesture.time);
|
|
70
|
+
const needed = latest.current.distance ?? Math.max(MIN_SWIPE_DISTANCE, gesture.width / 3);
|
|
71
|
+
const committed = dx !== 0 && (travelled >= needed || (velocity >= FLING_VELOCITY && travelled >= FLING_MIN_DISTANCE));
|
|
72
|
+
if (committed) {
|
|
73
|
+
latest.current.onSwipe(dx < 0 ? "left" : "right");
|
|
74
|
+
}
|
|
75
|
+
latest.current.onDrag?.(0);
|
|
76
|
+
}
|
|
77
|
+
function cancel() {
|
|
78
|
+
const moved = start.current?.mode === "swipe";
|
|
79
|
+
start.current = null;
|
|
80
|
+
offset.current = 0;
|
|
81
|
+
if (moved) {
|
|
82
|
+
latest.current.onDrag?.(0);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
handlers: enabled ? { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: cancel } : {},
|
|
87
|
+
/** Leaves vertical panning to the browser. */
|
|
88
|
+
style: enabled ? { touchAction: "pan-y" } : undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type CSSProperties } from "react";
|
|
2
|
+
import { SwipeDirection } from "./useSwipe.js";
|
|
3
|
+
/** How long the current contents take to leave, and the new ones to arrive - or the current ones to snap back. */
|
|
4
|
+
export declare const SLIDE_MS = 200;
|
|
5
|
+
export interface UseSwipeSlideOptions {
|
|
6
|
+
/** Nothing is tracked while this is `false`. */
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
/** Changes what is shown to the period the swipe went to - called once the old contents have left, while the new ones wait off-screen. */
|
|
9
|
+
onShift: (direction: SwipeDirection) => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A swipe between pages of content that visibly moves them, the way a phone calendar does: the contents follow the finger, and once
|
|
13
|
+
* the swipe commits they carry on out of view in the direction it went while the next ones - `onShift()` changes what is shown at
|
|
14
|
+
* that moment - slide in from the other side. A swipe that stops short slides back. The animation is timed here (`SLIDE_MS`) rather than
|
|
15
|
+
* left to `transitionend`, so it can't stall if the browser skips a transition.
|
|
16
|
+
*
|
|
17
|
+
* Put `handlers` and `style` on a clipping element (`overflow-hidden`, so the contents don't widen the page while they are off to
|
|
18
|
+
* the side), the contents inside it in an element of their own with `contentStyle`, and `ref` on the clipping element.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useSwipeSlide({ enabled, onShift }: UseSwipeSlideOptions): {
|
|
21
|
+
handlers: {
|
|
22
|
+
onTouchStart: (event: import("react").TouchEvent<HTMLElement>) => void;
|
|
23
|
+
onTouchMove: (event: import("react").TouchEvent<HTMLElement>) => void;
|
|
24
|
+
onTouchEnd: () => void;
|
|
25
|
+
onTouchCancel: () => void;
|
|
26
|
+
} | {
|
|
27
|
+
onTouchStart?: undefined;
|
|
28
|
+
onTouchMove?: undefined;
|
|
29
|
+
onTouchEnd?: undefined;
|
|
30
|
+
onTouchCancel?: undefined;
|
|
31
|
+
};
|
|
32
|
+
/** For the clipping element: `touch-action: pan-y`, and no overflow. */
|
|
33
|
+
style: {
|
|
34
|
+
readonly touchAction: "pan-y";
|
|
35
|
+
} | undefined;
|
|
36
|
+
contentStyle: CSSProperties;
|
|
37
|
+
ref: import("react").RefObject<HTMLDivElement | null>;
|
|
38
|
+
/** For a test or a style hook: `"idle"`, `"dragging"` or `"sliding"`. */
|
|
39
|
+
phase: string;
|
|
40
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
import { useSwipe } from "./useSwipe.js";
|
|
7
|
+
/** How long the current contents take to leave, and the new ones to arrive - or the current ones to snap back. */
|
|
8
|
+
export const SLIDE_MS = 200;
|
|
9
|
+
/** The pause between the new contents being put off-screen and them starting to slide in, long enough for the browser to draw them there. */
|
|
10
|
+
const SETTLE_MS = 30;
|
|
11
|
+
/** How far a slide goes when the element's width can't be measured. */
|
|
12
|
+
const FALLBACK_WIDTH_PX = 320;
|
|
13
|
+
/** Whether the user asked their device for less motion, in which case the change is made at once instead of slid. */
|
|
14
|
+
function prefersReducedMotion() {
|
|
15
|
+
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A swipe between pages of content that visibly moves them, the way a phone calendar does: the contents follow the finger, and once
|
|
19
|
+
* the swipe commits they carry on out of view in the direction it went while the next ones - `onShift()` changes what is shown at
|
|
20
|
+
* that moment - slide in from the other side. A swipe that stops short slides back. The animation is timed here (`SLIDE_MS`) rather than
|
|
21
|
+
* left to `transitionend`, so it can't stall if the browser skips a transition.
|
|
22
|
+
*
|
|
23
|
+
* Put `handlers` and `style` on a clipping element (`overflow-hidden`, so the contents don't widen the page while they are off to
|
|
24
|
+
* the side), the contents inside it in an element of their own with `contentStyle`, and `ref` on the clipping element.
|
|
25
|
+
*/
|
|
26
|
+
export function useSwipeSlide({ enabled, onShift }) {
|
|
27
|
+
const [offset, setOffset] = useState(0);
|
|
28
|
+
/** Whether a change of `offset` is animated: not while the finger is moving it, yes for everything else. */
|
|
29
|
+
const [animated, setAnimated] = useState(false);
|
|
30
|
+
/** A committed slide is under way: further swipes are ignored until it ends. */
|
|
31
|
+
const [sliding, setSliding] = useState(false);
|
|
32
|
+
const slidingRef = useRef(false);
|
|
33
|
+
const ref = useRef(null);
|
|
34
|
+
const timers = useRef([]);
|
|
35
|
+
const latest = useRef({ onShift });
|
|
36
|
+
latest.current = { onShift };
|
|
37
|
+
useEffect(() => () => {
|
|
38
|
+
for (const timer of timers.current) {
|
|
39
|
+
window.clearTimeout(timer);
|
|
40
|
+
}
|
|
41
|
+
}, []);
|
|
42
|
+
function later(work, ms) {
|
|
43
|
+
timers.current.push(window.setTimeout(work, ms));
|
|
44
|
+
}
|
|
45
|
+
const swipe = useSwipe({
|
|
46
|
+
enabled: enabled && !sliding,
|
|
47
|
+
onDrag: (dx) => {
|
|
48
|
+
// `useSwipe()` reports the finger lifting (0) right after a swipe commits: that must not undo the slide out.
|
|
49
|
+
if (slidingRef.current) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setAnimated(dx === 0);
|
|
53
|
+
setOffset(dx);
|
|
54
|
+
},
|
|
55
|
+
onSwipe: (direction) => {
|
|
56
|
+
if (prefersReducedMotion()) {
|
|
57
|
+
latest.current.onShift(direction);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const width = ref.current?.clientWidth || FALLBACK_WIDTH_PX;
|
|
61
|
+
// The finger moved `direction`, so the contents go that way and the next ones come from the opposite side.
|
|
62
|
+
const out = direction === "left" ? -width : width;
|
|
63
|
+
slidingRef.current = true;
|
|
64
|
+
setSliding(true);
|
|
65
|
+
setAnimated(true);
|
|
66
|
+
setOffset(out);
|
|
67
|
+
later(() => {
|
|
68
|
+
latest.current.onShift(direction);
|
|
69
|
+
setAnimated(false);
|
|
70
|
+
setOffset(-out);
|
|
71
|
+
later(() => {
|
|
72
|
+
setAnimated(true);
|
|
73
|
+
setOffset(0);
|
|
74
|
+
later(() => {
|
|
75
|
+
slidingRef.current = false;
|
|
76
|
+
setSliding(false);
|
|
77
|
+
}, SLIDE_MS);
|
|
78
|
+
}, SETTLE_MS);
|
|
79
|
+
}, SLIDE_MS);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const contentStyle = {
|
|
83
|
+
transform: offset === 0 ? undefined : `translate3d(${offset}px, 0, 0)`,
|
|
84
|
+
transition: animated ? `transform ${SLIDE_MS}ms ease-out` : "none",
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
handlers: swipe.handlers,
|
|
88
|
+
/** For the clipping element: `touch-action: pan-y`, and no overflow. */
|
|
89
|
+
style: swipe.style,
|
|
90
|
+
contentStyle,
|
|
91
|
+
ref,
|
|
92
|
+
/** For a test or a style hook: `"idle"`, `"dragging"` or `"sliding"`. */
|
|
93
|
+
phase: sliding ? "sliding" : offset !== 0 && !animated ? "dragging" : "idle",
|
|
94
|
+
};
|
|
95
|
+
}
|