@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.
@@ -64,9 +64,14 @@ export interface ConversationListProps {
64
64
  */
65
65
  swipe?: {
66
66
  enabled: boolean;
67
- onArchive: (conversation: ConversationSummary) => Promise<boolean>;
68
- onMove: (conversation: ConversationSummary) => void;
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
- {...swipe.handlers}
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
+ }
@@ -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. Not offered where the actions aren't (an aggregate view spans mailboxes, search results are a mixed bag, the
2257
- // ---- Outbox is the server's send queue) or while rows are being ticked.
2258
- const listingOutbox = currentFolders.find((f) => f.uid === folderUid)?.type === "outbox";
2259
- const swipeEnabled = isMobile && !selectMode && !aggregateFolderType && !isSearching && !listingOutbox;
2260
- /** What a swipe's Move to is about, while its folder prompt is open: one message, or every message of a conversation in this folder. */
2261
- const [moveDialog, setMoveDialog] = useState<{ messages: Message[] } | { conversation: ConversationSummary } | null>(null);
2262
-
2263
- /** The messages a swiped conversation stands for (the ones in the folder being listed), or `null` after saying why they couldn't be loaded. */
2264
- async function swipedConversationMessages(conversation: ConversationThreadHead): Promise<Message[] | null> {
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
- return await loadOpenConversation(conversation);
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[] | ConversationSummary): Promise<boolean> {
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
- enabled={swipeEnabled}
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={currentFolders}
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: ConversationSummary) => Promise<boolean>;
51
- onMove: (conversation: ConversationSummary) => void;
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, ...swipe.handlers, ref: (node) => {
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
+ }
@@ -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. Not offered where the actions aren't (an aggregate view spans mailboxes, search results are a mixed bag, the
1962
- // ---- Outbox is the server's send queue) or while rows are being ticked.
1963
- const listingOutbox = currentFolders.find((f) => f.uid === folderUid)?.type === "outbox";
1964
- const swipeEnabled = isMobile && !selectMode && !aggregateFolderType && !isSearching && !listingOutbox;
1965
- /** What a swipe's Move to is about, while its folder prompt is open: one message, or every message of a conversation in this folder. */
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
- /** The messages a swiped conversation stands for (the ones in the folder being listed), or `null` after saying why they couldn't be loaded. */
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
- return await loadOpenConversation(conversation);
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", enabled: swipeEnabled, onArchive: () => swipeArchive([message]), onMove: () => setMoveDialog({ messages: [message] }), "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),
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: currentFolders, currentFolderUid: folderUid, count: moveDialog === null ? 1 : "messages" in moveDialog ? moveDialog.messages.length : moveDialog.conversation.messageCount, onMove: swipeMove, onFolderCreated: onFolderCreated })] }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",