@rapidmx/web-client 0.29.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/apps/shared/components/mail/ConversationList.tsx +9 -2
- package/apps/shared/components/mail/SwipeRow.tsx +26 -4
- package/apps/shared/gestures/useLongPress.ts +138 -0
- package/apps/www/index.tsx +48 -15
- package/dist/apps/shared/components/mail/ConversationList.d.ts +8 -3
- package/dist/apps/shared/components/mail/ConversationList.js +2 -2
- 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/gestures/useLongPress.d.ts +41 -0
- package/dist/apps/shared/gestures/useLongPress.js +112 -0
- package/dist/apps/www/index.js +39 -10
- package/package.json +1 -1
|
@@ -64,9 +64,14 @@ export interface ConversationListProps {
|
|
|
64
64
|
*/
|
|
65
65
|
swipe?: {
|
|
66
66
|
enabled: boolean;
|
|
67
|
-
onArchive: (conversation:
|
|
68
|
-
onMove: (conversation:
|
|
67
|
+
onArchive: (conversation: ListedConversation) => Promise<boolean>;
|
|
68
|
+
onMove: (conversation: ListedConversation) => void;
|
|
69
69
|
};
|
|
70
|
+
/**
|
|
71
|
+
* A finger held on a parent row on a phone: the caller starts select mode with that conversation ticked. Absent, rows have no long press; it is not
|
|
72
|
+
* heard in select mode either, where a tap ticks a row already.
|
|
73
|
+
*/
|
|
74
|
+
onLongPress?: (conversation: ListedConversation) => void;
|
|
70
75
|
/**
|
|
71
76
|
* Whether a conversation's own messages read newest first, matching the order sense the rows themselves
|
|
72
77
|
* are in ("Newest on top"). `listConversationMessages()` always answers oldest first - the order a
|
|
@@ -106,6 +111,7 @@ export default function ConversationList({
|
|
|
106
111
|
onSelectWithModifier,
|
|
107
112
|
exitingKeys,
|
|
108
113
|
swipe,
|
|
114
|
+
onLongPress,
|
|
109
115
|
newestFirst,
|
|
110
116
|
}: ConversationListProps) {
|
|
111
117
|
const client = useApiClient();
|
|
@@ -200,6 +206,7 @@ export default function ConversationList({
|
|
|
200
206
|
enabled={!!swipe?.enabled && !selectMode}
|
|
201
207
|
onArchive={() => swipe!.onArchive(conversation)}
|
|
202
208
|
onMove={() => swipe!.onMove(conversation)}
|
|
209
|
+
onLongPress={onLongPress && !selectMode ? () => onLongPress(conversation) : undefined}
|
|
203
210
|
data-message-uid={conversation.latestMessageUid}
|
|
204
211
|
data-unread={unread ? "true" : undefined}
|
|
205
212
|
className={rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch")}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { HTMLAttributes, ReactNode, useEffect, useRef, useState } from "react";
|
|
5
|
+
import React, { HTMLAttributes, ReactNode, TouchEvent, useEffect, useRef, useState } from "react";
|
|
6
6
|
import { HiOutlineArchiveBox, HiOutlineFolder } from "react-icons/hi2";
|
|
7
|
+
import { useLongPress } from "../../gestures/useLongPress.js";
|
|
7
8
|
import { useSwipe } from "../../gestures/useSwipe.js";
|
|
8
9
|
|
|
9
10
|
export interface SwipeRowProps extends HTMLAttributes<HTMLElement> {
|
|
@@ -15,12 +16,22 @@ export interface SwipeRowProps extends HTMLAttributes<HTMLElement> {
|
|
|
15
16
|
onArchive: () => Promise<boolean>;
|
|
16
17
|
/** Left to right. The row comes back at once, and this asks where to (a prompt for the folder). */
|
|
17
18
|
onMove: () => void;
|
|
19
|
+
/** A finger held on the row (see `useLongPress()`) - the phone's way into select mode. Heard whether or not swipes are `enabled`; absent, the row has none. */
|
|
20
|
+
onLongPress?: () => void;
|
|
18
21
|
children: ReactNode;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
/** How long the row takes to leave, or come back - `motion-reduce` skips it. */
|
|
22
25
|
const SLIDE = "transform 150ms ease-out";
|
|
23
26
|
|
|
27
|
+
/** Both hooks' handlers for one touch event; `useSwipe()` has none while swipes are off. */
|
|
28
|
+
function both(first: (event: TouchEvent<HTMLElement>) => void, second?: (event: TouchEvent<HTMLElement>) => void) {
|
|
29
|
+
return (event: TouchEvent<HTMLElement>) => {
|
|
30
|
+
first(event);
|
|
31
|
+
second?.(event);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
/**
|
|
25
36
|
* A row of a list of mail that slides with a finger: right to left it takes the row away to Archive (a green panel with the
|
|
26
37
|
* archive icon follows the row in), left to right it comes back and asks for a folder to move it to (a blue panel with a
|
|
@@ -29,8 +40,10 @@ const SLIDE = "transform 150ms ease-out";
|
|
|
29
40
|
*
|
|
30
41
|
* Only a swipe that starts out horizontal counts (see `useSwipe()`), so scrolling the list is untouched, and it is an addition
|
|
31
42
|
* to, never the only way of, archiving and moving: the selection bar and the reading pane do the same.
|
|
43
|
+
*
|
|
44
|
+
* The row is also where a press and hold is heard (`onLongPress`), which shares the finger with the swipe: a hold gives way to the first drag.
|
|
32
45
|
*/
|
|
33
|
-
export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove, children, className, style, ...rest }: SwipeRowProps) {
|
|
46
|
+
export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove, onLongPress, children, className, style, ...rest }: SwipeRowProps) {
|
|
34
47
|
const [offset, setOffset] = useState(0);
|
|
35
48
|
const [width, setWidth] = useState(0);
|
|
36
49
|
/** The row is on its way out (or waiting for the server to confirm it), so a finger can't drag it. */
|
|
@@ -71,9 +84,13 @@ export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove,
|
|
|
71
84
|
},
|
|
72
85
|
});
|
|
73
86
|
|
|
87
|
+
// What `useSwipe()` hands over is `{}` while swipes are off.
|
|
88
|
+
const swipeTouch: Partial<ReturnType<typeof useLongPress>["handlers"]> = swipe.handlers;
|
|
89
|
+
const press = useLongPress({ enabled: !!onLongPress, onLongPress: onLongPress! });
|
|
90
|
+
|
|
74
91
|
if (!enabled) {
|
|
75
92
|
return (
|
|
76
|
-
<Tag className={className} style={style} {...rest}>
|
|
93
|
+
<Tag className={className} style={{ ...style, ...press.style }} {...rest} {...press.handlers}>
|
|
77
94
|
{children}
|
|
78
95
|
</Tag>
|
|
79
96
|
);
|
|
@@ -82,7 +99,11 @@ export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove,
|
|
|
82
99
|
return (
|
|
83
100
|
<Tag
|
|
84
101
|
{...rest}
|
|
85
|
-
{...
|
|
102
|
+
{...press.handlers}
|
|
103
|
+
onTouchStart={both(press.handlers.onTouchStart, swipeTouch.onTouchStart)}
|
|
104
|
+
onTouchMove={both(press.handlers.onTouchMove, swipeTouch.onTouchMove)}
|
|
105
|
+
onTouchEnd={both(press.handlers.onTouchEnd, swipeTouch.onTouchEnd)}
|
|
106
|
+
onTouchCancel={both(press.handlers.onTouchCancel, swipeTouch.onTouchCancel)}
|
|
86
107
|
ref={(node: HTMLElement | null) => {
|
|
87
108
|
element.current = node;
|
|
88
109
|
}}
|
|
@@ -90,6 +111,7 @@ export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove,
|
|
|
90
111
|
style={{
|
|
91
112
|
...style,
|
|
92
113
|
...swipe.style,
|
|
114
|
+
...press.style,
|
|
93
115
|
transform: active ? `translateX(${offset}px)` : undefined,
|
|
94
116
|
transition: dragging ? "none" : SLIDE,
|
|
95
117
|
}}
|
|
@@ -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
|
+
}
|
package/apps/www/index.tsx
CHANGED
|
@@ -2037,6 +2037,23 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2037
2037
|
void resolveConversations(listedConversations.filter((c) => inRange.has(conversationKey(c))));
|
|
2038
2038
|
}
|
|
2039
2039
|
|
|
2040
|
+
/**
|
|
2041
|
+
* A press and hold on a phone: select mode, with this row the only one ticked and the anchor of the next Shift+click. From then on a tap ticks
|
|
2042
|
+
* (`handleSelect()`, `handleOpenConversation()`). A conversation is resolved to its messages as a ticked one is.
|
|
2043
|
+
*/
|
|
2044
|
+
function startSelectingMessage(message: Message) {
|
|
2045
|
+
selectionAnchorRef.current = message.uid;
|
|
2046
|
+
setSelectMode(true);
|
|
2047
|
+
setSelectedUids(new Set([message.uid]));
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
function startSelectingConversation(conversation: ListedConversation) {
|
|
2051
|
+
selectionAnchorRef.current = conversationKey(conversation);
|
|
2052
|
+
setSelectMode(true);
|
|
2053
|
+
setSelectedConversationIds(new Set([conversationKey(conversation)]));
|
|
2054
|
+
void resolveConversations([conversation]);
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2040
2057
|
/** A plain click on a conversation's checkbox: ticks (or unticks) it and makes it the anchor of the next Shift+click. */
|
|
2041
2058
|
function tickConversation(conversation: ListedConversation) {
|
|
2042
2059
|
selectionAnchorRef.current = conversationKey(conversation);
|
|
@@ -2253,17 +2270,30 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2253
2270
|
|
|
2254
2271
|
// ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
|
|
2255
2272
|
// ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
|
|
2256
|
-
// ---- implementation.
|
|
2257
|
-
// ----
|
|
2258
|
-
|
|
2259
|
-
const
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
/**
|
|
2264
|
-
|
|
2273
|
+
// ---- implementation. Offered in every listing - a mailbox's own folders, the "All mailboxes" views and search results - and a row acts in
|
|
2274
|
+
// ---- its own mailbox (its Archive, its folders), as a bulk action does. Not offered for the Outbox (the server's send queue) or while rows
|
|
2275
|
+
// ---- are being ticked.
|
|
2276
|
+
const listingOutbox = !isSearching && currentFolders.find((f) => f.uid === folderUid)?.type === "outbox";
|
|
2277
|
+
const swipeEnabled = isMobile && !selectMode && !listingOutbox;
|
|
2278
|
+
/** What a swipe's Move to is about, while its folder prompt is open: one message, or every message of a conversation in the folder being listed. */
|
|
2279
|
+
const [moveDialog, setMoveDialog] = useState<{ messages: Message[] } | { conversation: ListedConversation } | null>(null);
|
|
2280
|
+
/** Where what is being moved is now: its mailbox - whose folders the prompt offers - and the folder it is in. */
|
|
2281
|
+
const movingFrom =
|
|
2282
|
+
moveDialog === null
|
|
2283
|
+
? null
|
|
2284
|
+
: "messages" in moveDialog
|
|
2285
|
+
? { mailboxUid: moveDialog.messages[0].mailboxUid, folderUid: moveDialog.messages[0].folderUid }
|
|
2286
|
+
: { mailboxUid: moveDialog.conversation.mailboxUid ?? activeMailboxUid, folderUid: moveDialog.conversation.latestFolderUid };
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* The messages a swiped conversation stands for - the ones in the folder being listed - or `null` after saying why they couldn't be loaded. A row of
|
|
2290
|
+
* an "All mailboxes" view is a different mailbox's: its messages are asked for there, and only the aggregate's own folders' count (not the Sent Items
|
|
2291
|
+
* copy of a reply), as for a ticked row (`selectedConversationMessages`).
|
|
2292
|
+
*/
|
|
2293
|
+
async function swipedConversationMessages(conversation: ListedConversation): Promise<Message[] | null> {
|
|
2265
2294
|
try {
|
|
2266
|
-
|
|
2295
|
+
const loaded = await loadOpenConversation(conversation, conversation.mailboxUid ?? activeMailboxUid);
|
|
2296
|
+
return aggregateFolderType ? loaded.filter((m) => folderTypeOf(m.folderUid) === aggregateFolderType) : loaded;
|
|
2267
2297
|
} catch (err) {
|
|
2268
2298
|
notifyApiError(err, "Couldn't load the messages in that conversation");
|
|
2269
2299
|
return null;
|
|
@@ -2271,7 +2301,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2271
2301
|
}
|
|
2272
2302
|
|
|
2273
2303
|
/** Archives what a swipe took away. Resolves whether it went through, so the row comes back when it didn't. */
|
|
2274
|
-
async function swipeArchive(target: Message[] |
|
|
2304
|
+
async function swipeArchive(target: Message[] | ListedConversation): Promise<boolean> {
|
|
2275
2305
|
if (bulkBusy || resolvingSelection > 0) {
|
|
2276
2306
|
return false;
|
|
2277
2307
|
}
|
|
@@ -2317,9 +2347,11 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2317
2347
|
return (
|
|
2318
2348
|
<SwipeRow
|
|
2319
2349
|
as="li"
|
|
2320
|
-
|
|
2350
|
+
// A hit of a search over several mailboxes can be in an Outbox, which none of these actions are for.
|
|
2351
|
+
enabled={swipeEnabled && folderTypeOf(message.folderUid) !== "outbox"}
|
|
2321
2352
|
onArchive={() => swipeArchive([message])}
|
|
2322
2353
|
onMove={() => setMoveDialog({ messages: [message] })}
|
|
2354
|
+
onLongPress={isMobile && !selectMode ? () => startSelectingMessage(message) : undefined}
|
|
2323
2355
|
key={message.uid}
|
|
2324
2356
|
data-message-uid={message.uid}
|
|
2325
2357
|
data-unread={isUnread(message) ? "true" : undefined}
|
|
@@ -3075,6 +3107,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
3075
3107
|
onArchive: swipeArchive,
|
|
3076
3108
|
onMove: (conversation) => setMoveDialog({ conversation }),
|
|
3077
3109
|
}}
|
|
3110
|
+
onLongPress={isMobile ? startSelectingConversation : undefined}
|
|
3078
3111
|
/>
|
|
3079
3112
|
{hasMore && !rowCapReached && (
|
|
3080
3113
|
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
@@ -3202,9 +3235,9 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
3202
3235
|
<MoveToFolderDialog
|
|
3203
3236
|
open={moveDialog !== null}
|
|
3204
3237
|
onClose={() => setMoveDialog(null)}
|
|
3205
|
-
mailboxUid={activeMailboxUid}
|
|
3206
|
-
folders={
|
|
3207
|
-
currentFolderUid={folderUid}
|
|
3238
|
+
mailboxUid={movingFrom?.mailboxUid ?? activeMailboxUid}
|
|
3239
|
+
folders={foldersOf(movingFrom?.mailboxUid ?? activeMailboxUid)}
|
|
3240
|
+
currentFolderUid={movingFrom?.folderUid}
|
|
3208
3241
|
count={moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount}
|
|
3209
3242
|
onMove={swipeMove}
|
|
3210
3243
|
onFolderCreated={onFolderCreated}
|
|
@@ -47,9 +47,14 @@ export interface ConversationListProps {
|
|
|
47
47
|
*/
|
|
48
48
|
swipe?: {
|
|
49
49
|
enabled: boolean;
|
|
50
|
-
onArchive: (conversation:
|
|
51
|
-
onMove: (conversation:
|
|
50
|
+
onArchive: (conversation: ListedConversation) => Promise<boolean>;
|
|
51
|
+
onMove: (conversation: ListedConversation) => void;
|
|
52
52
|
};
|
|
53
|
+
/**
|
|
54
|
+
* A finger held on a parent row on a phone: the caller starts select mode with that conversation ticked. Absent, rows have no long press; it is not
|
|
55
|
+
* heard in select mode either, where a tap ticks a row already.
|
|
56
|
+
*/
|
|
57
|
+
onLongPress?: (conversation: ListedConversation) => void;
|
|
53
58
|
/**
|
|
54
59
|
* Whether a conversation's own messages read newest first, matching the order sense the rows themselves
|
|
55
60
|
* are in ("Newest on top"). `listConversationMessages()` always answers oldest first - the order a
|
|
@@ -70,4 +75,4 @@ export interface ConversationListProps {
|
|
|
70
75
|
* nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
|
|
71
76
|
* its own name.
|
|
72
77
|
*/
|
|
73
|
-
export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, onSelectWithModifier, exitingKeys, swipe, newestFirst, }: ConversationListProps): React.JSX.Element;
|
|
78
|
+
export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, onSelectWithModifier, exitingKeys, swipe, onLongPress, newestFirst, }: ConversationListProps): React.JSX.Element;
|
|
@@ -37,7 +37,7 @@ function participantList(conversation) {
|
|
|
37
37
|
* nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
|
|
38
38
|
* its own name.
|
|
39
39
|
*/
|
|
40
|
-
export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, onSelectWithModifier, exitingKeys, swipe, newestFirst, }) {
|
|
40
|
+
export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, onSelectWithModifier, exitingKeys, swipe, onLongPress, newestFirst, }) {
|
|
41
41
|
const client = useApiClient();
|
|
42
42
|
// What this device has decrypted of the encrypted messages on screen: their real subjects and previews.
|
|
43
43
|
const decrypted = useDecryptedMessages();
|
|
@@ -107,7 +107,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
|
|
|
107
107
|
const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
|
|
108
108
|
const ticked = selectedConversationIds?.has(id) ?? false;
|
|
109
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) =>
|
|
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), onLongPress: onLongPress && !selectMode ? () => onLongPress(conversation) : undefined, "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
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
112
|
onSelectWithModifier && isSelectionClick(event.nativeEvent)
|
|
113
113
|
? onSelectWithModifier(conversation, event.nativeEvent)
|
|
@@ -8,6 +8,8 @@ export interface SwipeRowProps extends HTMLAttributes<HTMLElement> {
|
|
|
8
8
|
onArchive: () => Promise<boolean>;
|
|
9
9
|
/** Left to right. The row comes back at once, and this asks where to (a prompt for the folder). */
|
|
10
10
|
onMove: () => void;
|
|
11
|
+
/** A finger held on the row (see `useLongPress()`) - the phone's way into select mode. Heard whether or not swipes are `enabled`; absent, the row has none. */
|
|
12
|
+
onLongPress?: () => void;
|
|
11
13
|
children: ReactNode;
|
|
12
14
|
}
|
|
13
15
|
/**
|
|
@@ -18,5 +20,7 @@ export interface SwipeRowProps extends HTMLAttributes<HTMLElement> {
|
|
|
18
20
|
*
|
|
19
21
|
* Only a swipe that starts out horizontal counts (see `useSwipe()`), so scrolling the list is untouched, and it is an addition
|
|
20
22
|
* to, never the only way of, archiving and moving: the selection bar and the reading pane do the same.
|
|
23
|
+
*
|
|
24
|
+
* The row is also where a press and hold is heard (`onLongPress`), which shares the finger with the swipe: a hold gives way to the first drag.
|
|
21
25
|
*/
|
|
22
|
-
export default function SwipeRow({ as: Tag, enabled, onArchive, onMove, children, className, style, ...rest }: SwipeRowProps): React.JSX.Element;
|
|
26
|
+
export default function SwipeRow({ as: Tag, enabled, onArchive, onMove, onLongPress, children, className, style, ...rest }: SwipeRowProps): React.JSX.Element;
|
|
@@ -5,9 +5,17 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useEffect, useRef, useState } from "react";
|
|
7
7
|
import { HiOutlineArchiveBox, HiOutlineFolder } from "react-icons/hi2";
|
|
8
|
+
import { useLongPress } from "../../gestures/useLongPress.js";
|
|
8
9
|
import { useSwipe } from "../../gestures/useSwipe.js";
|
|
9
10
|
/** How long the row takes to leave, or come back - `motion-reduce` skips it. */
|
|
10
11
|
const SLIDE = "transform 150ms ease-out";
|
|
12
|
+
/** Both hooks' handlers for one touch event; `useSwipe()` has none while swipes are off. */
|
|
13
|
+
function both(first, second) {
|
|
14
|
+
return (event) => {
|
|
15
|
+
first(event);
|
|
16
|
+
second?.(event);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
11
19
|
/**
|
|
12
20
|
* A row of a list of mail that slides with a finger: right to left it takes the row away to Archive (a green panel with the
|
|
13
21
|
* archive icon follows the row in), left to right it comes back and asks for a folder to move it to (a blue panel with a
|
|
@@ -16,8 +24,10 @@ const SLIDE = "transform 150ms ease-out";
|
|
|
16
24
|
*
|
|
17
25
|
* Only a swipe that starts out horizontal counts (see `useSwipe()`), so scrolling the list is untouched, and it is an addition
|
|
18
26
|
* to, never the only way of, archiving and moving: the selection bar and the reading pane do the same.
|
|
27
|
+
*
|
|
28
|
+
* The row is also where a press and hold is heard (`onLongPress`), which shares the finger with the swipe: a hold gives way to the first drag.
|
|
19
29
|
*/
|
|
20
|
-
export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove, children, className, style, ...rest }) {
|
|
30
|
+
export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove, onLongPress, children, className, style, ...rest }) {
|
|
21
31
|
const [offset, setOffset] = useState(0);
|
|
22
32
|
const [width, setWidth] = useState(0);
|
|
23
33
|
/** The row is on its way out (or waiting for the server to confirm it), so a finger can't drag it. */
|
|
@@ -56,15 +66,19 @@ export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove,
|
|
|
56
66
|
});
|
|
57
67
|
},
|
|
58
68
|
});
|
|
69
|
+
// What `useSwipe()` hands over is `{}` while swipes are off.
|
|
70
|
+
const swipeTouch = swipe.handlers;
|
|
71
|
+
const press = useLongPress({ enabled: !!onLongPress, onLongPress: onLongPress });
|
|
59
72
|
if (!enabled) {
|
|
60
|
-
return (_jsx(Tag, { className: className, style: style, ...rest, children: children }));
|
|
73
|
+
return (_jsx(Tag, { className: className, style: { ...style, ...press.style }, ...rest, ...press.handlers, children: children }));
|
|
61
74
|
}
|
|
62
75
|
const active = offset !== 0;
|
|
63
|
-
return (_jsxs(Tag, { ...rest, ...
|
|
76
|
+
return (_jsxs(Tag, { ...rest, ...press.handlers, onTouchStart: both(press.handlers.onTouchStart, swipeTouch.onTouchStart), onTouchMove: both(press.handlers.onTouchMove, swipeTouch.onTouchMove), onTouchEnd: both(press.handlers.onTouchEnd, swipeTouch.onTouchEnd), onTouchCancel: both(press.handlers.onTouchCancel, swipeTouch.onTouchCancel), ref: (node) => {
|
|
64
77
|
element.current = node;
|
|
65
78
|
}, className: className, style: {
|
|
66
79
|
...style,
|
|
67
80
|
...swipe.style,
|
|
81
|
+
...press.style,
|
|
68
82
|
transform: active ? `translateX(${offset}px)` : undefined,
|
|
69
83
|
transition: dragging ? "none" : SLIDE,
|
|
70
84
|
}, "data-swiping": active ? "true" : undefined, children: [children, (active || leaving) && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", "data-swipe-panel": "move", className: "absolute inset-y-0 right-full w-full bg-primary text-white flex items-center justify-end pr-6", children: _jsx(HiOutlineFolder, { size: 24 }) }), _jsx("span", { "aria-hidden": "true", "data-swipe-panel": "archive", style: width ? { width } : undefined, className: "absolute inset-y-0 left-full w-full bg-success text-white flex items-center justify-start pl-6", children: _jsx(HiOutlineArchiveBox, { size: 24 }) })] }))] }));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type MouseEvent, type TouchEvent } from "react";
|
|
2
|
+
export interface UseLongPressOptions {
|
|
3
|
+
/** Nothing starts while this is `false` (a desktop pointer, select mode); a press already held out is still seen through. Default `true`. */
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
/** How long a finger has to stay down, in ms. Default `LONG_PRESS_MS`. */
|
|
6
|
+
delay?: number;
|
|
7
|
+
/** How far it may wander meanwhile, in px. Default `LONG_PRESS_SLOP`. */
|
|
8
|
+
tolerance?: number;
|
|
9
|
+
/** Called once when the press has been held for `delay`. */
|
|
10
|
+
onLongPress: () => void;
|
|
11
|
+
}
|
|
12
|
+
/** 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. */
|
|
13
|
+
export declare const LONG_PRESS_MS = 480;
|
|
14
|
+
export declare const LONG_PRESS_SLOP = 8;
|
|
15
|
+
/**
|
|
16
|
+
* Press and hold by one finger, for the touch screens the app's phone layout is for: mouse and pen are left alone.
|
|
17
|
+
*
|
|
18
|
+
* 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
|
|
19
|
+
* cancelled (the browser took it over to scroll) or something scrolls, so it never fights vertical scrolling or `useSwipe()`: a swipe has to travel
|
|
20
|
+
* 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
|
|
21
|
+
* swallowed once), and the browser's own long-press (the context menu, the text selection and the callout of a link) is suppressed.
|
|
22
|
+
*
|
|
23
|
+
* The handlers stay on even while `enabled` is `false`, because the row that was pressed usually makes itself disabled in answer (select mode),
|
|
24
|
+
* and the click that ends the press still has to be swallowed after that.
|
|
25
|
+
*/
|
|
26
|
+
export declare function useLongPress({ enabled, delay, tolerance, onLongPress }: UseLongPressOptions): {
|
|
27
|
+
handlers: {
|
|
28
|
+
onTouchStart: (event: TouchEvent<HTMLElement>) => void;
|
|
29
|
+
onTouchMove: (event: TouchEvent<HTMLElement>) => void;
|
|
30
|
+
onTouchEnd: (event: TouchEvent<HTMLElement>) => void;
|
|
31
|
+
onTouchCancel: () => void;
|
|
32
|
+
onClickCapture: (event: MouseEvent<HTMLElement>) => void;
|
|
33
|
+
onContextMenu: (event: MouseEvent<HTMLElement>) => void;
|
|
34
|
+
};
|
|
35
|
+
/** No text selection and no callout on what is pressed, while a press can start: the page's text stays selectable elsewhere. */
|
|
36
|
+
style: {
|
|
37
|
+
readonly WebkitTouchCallout: "none";
|
|
38
|
+
readonly WebkitUserSelect: "none";
|
|
39
|
+
readonly userSelect: "none";
|
|
40
|
+
} | undefined;
|
|
41
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
/** 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. */
|
|
7
|
+
export const LONG_PRESS_MS = 480;
|
|
8
|
+
export const LONG_PRESS_SLOP = 8;
|
|
9
|
+
/** 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. */
|
|
10
|
+
const CLICK_WINDOW_MS = 500;
|
|
11
|
+
/**
|
|
12
|
+
* Press and hold by one finger, for the touch screens the app's phone layout is for: mouse and pen are left alone.
|
|
13
|
+
*
|
|
14
|
+
* 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
|
|
15
|
+
* cancelled (the browser took it over to scroll) or something scrolls, so it never fights vertical scrolling or `useSwipe()`: a swipe has to travel
|
|
16
|
+
* 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
|
|
17
|
+
* swallowed once), and the browser's own long-press (the context menu, the text selection and the callout of a link) is suppressed.
|
|
18
|
+
*
|
|
19
|
+
* The handlers stay on even while `enabled` is `false`, because the row that was pressed usually makes itself disabled in answer (select mode),
|
|
20
|
+
* and the click that ends the press still has to be swallowed after that.
|
|
21
|
+
*/
|
|
22
|
+
export function useLongPress({ enabled = true, delay = LONG_PRESS_MS, tolerance = LONG_PRESS_SLOP, onLongPress }) {
|
|
23
|
+
const timer = useRef(null);
|
|
24
|
+
const start = useRef({ x: 0, y: 0 });
|
|
25
|
+
/** The press fired and its click has not come yet. */
|
|
26
|
+
const fired = useRef(false);
|
|
27
|
+
const clickWindow = useRef(null);
|
|
28
|
+
const latest = useRef(onLongPress);
|
|
29
|
+
latest.current = onLongPress;
|
|
30
|
+
// 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.
|
|
31
|
+
const forget = useRef(() => {
|
|
32
|
+
if (timer.current !== null) {
|
|
33
|
+
clearTimeout(timer.current);
|
|
34
|
+
timer.current = null;
|
|
35
|
+
window.removeEventListener("scroll", forget, true);
|
|
36
|
+
}
|
|
37
|
+
}).current;
|
|
38
|
+
function clearClickWindow() {
|
|
39
|
+
if (clickWindow.current !== null) {
|
|
40
|
+
clearTimeout(clickWindow.current);
|
|
41
|
+
clickWindow.current = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// A timer left running would fire into a row that is gone.
|
|
45
|
+
useEffect(() => () => {
|
|
46
|
+
forget();
|
|
47
|
+
if (clickWindow.current !== null) {
|
|
48
|
+
clearTimeout(clickWindow.current);
|
|
49
|
+
}
|
|
50
|
+
}, [forget]);
|
|
51
|
+
function onTouchStart(event) {
|
|
52
|
+
forget();
|
|
53
|
+
clearClickWindow();
|
|
54
|
+
fired.current = false;
|
|
55
|
+
if (!enabled || event.touches.length !== 1) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
start.current = { x: event.touches[0].clientX, y: event.touches[0].clientY };
|
|
59
|
+
// Scroll events don't bubble: only a capturing listener hears the list (or the page) moving under a finger that hasn't moved.
|
|
60
|
+
window.addEventListener("scroll", forget, { capture: true, passive: true });
|
|
61
|
+
timer.current = setTimeout(() => {
|
|
62
|
+
timer.current = null;
|
|
63
|
+
window.removeEventListener("scroll", forget, true);
|
|
64
|
+
fired.current = true;
|
|
65
|
+
navigator.vibrate?.(10);
|
|
66
|
+
latest.current();
|
|
67
|
+
}, delay);
|
|
68
|
+
}
|
|
69
|
+
function onTouchMove(event) {
|
|
70
|
+
if (timer.current === null) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const touch = event.touches[0];
|
|
74
|
+
if (event.touches.length !== 1 || Math.hypot(touch.clientX - start.current.x, touch.clientY - start.current.y) >= tolerance) {
|
|
75
|
+
forget();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function onTouchEnd(event) {
|
|
79
|
+
forget();
|
|
80
|
+
if (fired.current) {
|
|
81
|
+
// No click of the browser's own after this; the one that comes anyway (older browsers) is swallowed by `onClickCapture`.
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
clearClickWindow();
|
|
84
|
+
clickWindow.current = setTimeout(() => {
|
|
85
|
+
clickWindow.current = null;
|
|
86
|
+
fired.current = false;
|
|
87
|
+
}, CLICK_WINDOW_MS);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function onTouchCancel() {
|
|
91
|
+
forget();
|
|
92
|
+
fired.current = false;
|
|
93
|
+
}
|
|
94
|
+
function onClickCapture(event) {
|
|
95
|
+
if (fired.current) {
|
|
96
|
+
fired.current = false;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
event.stopPropagation();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function onContextMenu(event) {
|
|
102
|
+
// The long-press menu of a touch screen comes while the finger is still down (Android) or just after it fired.
|
|
103
|
+
if (timer.current !== null || fired.current) {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
handlers: { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, onClickCapture, onContextMenu },
|
|
109
|
+
/** No text selection and no callout on what is pressed, while a press can start: the page's text stays selectable elsewhere. */
|
|
110
|
+
style: enabled ? { WebkitTouchCallout: "none", WebkitUserSelect: "none", userSelect: "none" } : undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|
package/dist/apps/www/index.js
CHANGED
|
@@ -1760,6 +1760,21 @@ function InboxContent({ userUid }) {
|
|
|
1760
1760
|
const inRange = new Set(range);
|
|
1761
1761
|
void resolveConversations(listedConversations.filter((c) => inRange.has(conversationKey(c))));
|
|
1762
1762
|
}
|
|
1763
|
+
/**
|
|
1764
|
+
* A press and hold on a phone: select mode, with this row the only one ticked and the anchor of the next Shift+click. From then on a tap ticks
|
|
1765
|
+
* (`handleSelect()`, `handleOpenConversation()`). A conversation is resolved to its messages as a ticked one is.
|
|
1766
|
+
*/
|
|
1767
|
+
function startSelectingMessage(message) {
|
|
1768
|
+
selectionAnchorRef.current = message.uid;
|
|
1769
|
+
setSelectMode(true);
|
|
1770
|
+
setSelectedUids(new Set([message.uid]));
|
|
1771
|
+
}
|
|
1772
|
+
function startSelectingConversation(conversation) {
|
|
1773
|
+
selectionAnchorRef.current = conversationKey(conversation);
|
|
1774
|
+
setSelectMode(true);
|
|
1775
|
+
setSelectedConversationIds(new Set([conversationKey(conversation)]));
|
|
1776
|
+
void resolveConversations([conversation]);
|
|
1777
|
+
}
|
|
1763
1778
|
/** A plain click on a conversation's checkbox: ticks (or unticks) it and makes it the anchor of the next Shift+click. */
|
|
1764
1779
|
function tickConversation(conversation) {
|
|
1765
1780
|
selectionAnchorRef.current = conversationKey(conversation);
|
|
@@ -1958,16 +1973,28 @@ function InboxContent({ userUid }) {
|
|
|
1958
1973
|
}
|
|
1959
1974
|
// ---- Swiping a row on a phone (see `SwipeRow`): right to left archives, left to right asks for a folder. Both go through the same
|
|
1960
1975
|
// ---- bulk path as the selection bar's Archive and Move to, so the badges, the optimistic bookkeeping and the failure pop-up are one
|
|
1961
|
-
// ---- implementation.
|
|
1962
|
-
// ----
|
|
1963
|
-
|
|
1964
|
-
const
|
|
1965
|
-
|
|
1976
|
+
// ---- implementation. Offered in every listing - a mailbox's own folders, the "All mailboxes" views and search results - and a row acts in
|
|
1977
|
+
// ---- its own mailbox (its Archive, its folders), as a bulk action does. Not offered for the Outbox (the server's send queue) or while rows
|
|
1978
|
+
// ---- are being ticked.
|
|
1979
|
+
const listingOutbox = !isSearching && currentFolders.find((f) => f.uid === folderUid)?.type === "outbox";
|
|
1980
|
+
const swipeEnabled = isMobile && !selectMode && !listingOutbox;
|
|
1981
|
+
/** What a swipe's Move to is about, while its folder prompt is open: one message, or every message of a conversation in the folder being listed. */
|
|
1966
1982
|
const [moveDialog, setMoveDialog] = useState(null);
|
|
1967
|
-
/**
|
|
1983
|
+
/** Where what is being moved is now: its mailbox - whose folders the prompt offers - and the folder it is in. */
|
|
1984
|
+
const movingFrom = moveDialog === null
|
|
1985
|
+
? null
|
|
1986
|
+
: "messages" in moveDialog
|
|
1987
|
+
? { mailboxUid: moveDialog.messages[0].mailboxUid, folderUid: moveDialog.messages[0].folderUid }
|
|
1988
|
+
: { mailboxUid: moveDialog.conversation.mailboxUid ?? activeMailboxUid, folderUid: moveDialog.conversation.latestFolderUid };
|
|
1989
|
+
/**
|
|
1990
|
+
* The messages a swiped conversation stands for - the ones in the folder being listed - or `null` after saying why they couldn't be loaded. A row of
|
|
1991
|
+
* an "All mailboxes" view is a different mailbox's: its messages are asked for there, and only the aggregate's own folders' count (not the Sent Items
|
|
1992
|
+
* copy of a reply), as for a ticked row (`selectedConversationMessages`).
|
|
1993
|
+
*/
|
|
1968
1994
|
async function swipedConversationMessages(conversation) {
|
|
1969
1995
|
try {
|
|
1970
|
-
|
|
1996
|
+
const loaded = await loadOpenConversation(conversation, conversation.mailboxUid ?? activeMailboxUid);
|
|
1997
|
+
return aggregateFolderType ? loaded.filter((m) => folderTypeOf(m.folderUid) === aggregateFolderType) : loaded;
|
|
1971
1998
|
}
|
|
1972
1999
|
catch (err) {
|
|
1973
2000
|
notifyApiError(err, "Couldn't load the messages in that conversation");
|
|
@@ -2000,7 +2027,9 @@ function InboxContent({ userUid }) {
|
|
|
2000
2027
|
/** One message's row of the flat list - and, in search results grouped by conversation, of a group. */
|
|
2001
2028
|
function renderMessageRow(message) {
|
|
2002
2029
|
const exiting = exitingKeys.has(message.uid);
|
|
2003
|
-
return (_jsxs(SwipeRow, { as: "li",
|
|
2030
|
+
return (_jsxs(SwipeRow, { as: "li",
|
|
2031
|
+
// A hit of a search over several mailboxes can be in an Outbox, which none of these actions are for.
|
|
2032
|
+
enabled: swipeEnabled && folderTypeOf(message.folderUid) !== "outbox", onArchive: () => swipeArchive([message]), onMove: () => setMoveDialog({ messages: [message] }), onLongPress: isMobile && !selectMode ? () => startSelectingMessage(message) : undefined, "data-message-uid": message.uid, "data-unread": isUnread(message) ? "true" : undefined, "data-exiting": exiting ? "true" : undefined, "aria-hidden": exiting ? "true" : undefined, inert: exiting, className: rowClass({ unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) }, exiting ? "flex items-stretch pointer-events-none" : "flex items-stretch"), children: [_jsx(UnreadBar, { unread: isUnread(message) }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: selectedUids.has(message.uid),
|
|
2004
2033
|
// The change of a checkbox is caused by a click (or the Space key, which makes one): its modifiers are that event's.
|
|
2005
2034
|
onChange: (event) => handleSelect(message, event.nativeEvent), "aria-label": `Select ${rowSubject(message)}`, className: "w-4 h-4 accent-primary" }) })), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: (event) => handleSelect(message, event), className: ["w-full text-left px-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: isUnread(message) }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(isUnread(message)) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(isUnread(message))].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), crossMailbox && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: ["text-sm truncate", subjectClass(isUnread(message))].join(" "), children: rowSubject(message) }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: snippets[message.uid] ||
|
|
2006
2035
|
decryptedRows[message.uid]?.preview ||
|
|
@@ -2357,7 +2386,7 @@ function InboxContent({ userUid }) {
|
|
|
2357
2386
|
enabled: swipeEnabled,
|
|
2358
2387
|
onArchive: swipeArchive,
|
|
2359
2388
|
onMove: (conversation) => setMoveDialog({ conversation }),
|
|
2360
|
-
} }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", rowCap, " conversations \u2014 refine your search or filters to see more."] }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
|
|
2389
|
+
}, onLongPress: isMobile ? startSelectingConversation : undefined }), hasMore && !rowCapReached && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), rowCapReached && (_jsxs("p", { className: "p-4 text-center text-xs text-text-muted", children: ["Showing the most recent ", rowCap, " conversations \u2014 refine your search or filters to see more."] }))] })) : messages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
|
|
2361
2390
|
? `No messages match "${searchQuery}".`
|
|
2362
2391
|
: effectiveFilter === "all"
|
|
2363
2392
|
? "No messages in this folder."
|
|
@@ -2377,7 +2406,7 @@ function InboxContent({ userUid }) {
|
|
|
2377
2406
|
// whichever folder is currently being viewed (into Archive), so it's removed
|
|
2378
2407
|
// from the list rather than patched in place.
|
|
2379
2408
|
removeListedMessage(updated.uid);
|
|
2380
|
-
}, onChanged: patchListedMessage, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) }), _jsx(MoveToFolderDialog, { open: moveDialog !== null, onClose: () => setMoveDialog(null), mailboxUid: activeMailboxUid, folders:
|
|
2409
|
+
}, onChanged: patchListedMessage, labels: labels, onLabelsChanged: patchListedMessage, onLabelCreated: (label) => (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label]) })) }), _jsx(MoveToFolderDialog, { open: moveDialog !== null, onClose: () => setMoveDialog(null), mailboxUid: movingFrom?.mailboxUid ?? activeMailboxUid, folders: foldersOf(movingFrom?.mailboxUid ?? activeMailboxUid), currentFolderUid: movingFrom?.folderUid, count: moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount, onMove: swipeMove, onFolderCreated: onFolderCreated })] }));
|
|
2381
2410
|
}
|
|
2382
2411
|
export default InboxPage;
|
|
2383
2412
|
/** The tab's title: `Brand: Mail` (see `pageTitle()`). */
|
package/package.json
CHANGED