@remit/web-client 0.0.320 → 0.0.322
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/package.json +1 -1
- package/src/components/mail/ConversationView.tsx +3 -8
- package/src/components/mail/MessageCard.tsx +8 -1
- package/src/components/mail/MessageRow.tsx +2 -4
- package/src/components/mail/SwipeableMessageRow.modifier-select.test.ts +1 -0
- package/src/components/mail/message-gone.stories.tsx +42 -0
- package/src/components/ui/error-banners.ts +4 -0
- package/src/mocks/story-frame/touch.ts +1 -0
- package/src/hooks/useSwipeNavigation.test.ts +0 -35
- package/src/hooks/useSwipeNavigation.ts +0 -99
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.322",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -10,7 +10,6 @@ import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
|
|
10
10
|
import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
11
11
|
import { useMarkAsRead } from "@/hooks/useMarkAsRead";
|
|
12
12
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
13
|
-
import { useSwipeNavigation } from "@/hooks/useSwipeNavigation";
|
|
14
13
|
import { useToggleStar } from "@/hooks/useToggleStar";
|
|
15
14
|
import { type ReplyMode, useOpenReply, useReplySurface } from "@/routing";
|
|
16
15
|
import { AuthenticityBanner } from "./AuthenticityBanner";
|
|
@@ -98,10 +97,6 @@ export const ConversationView = ({
|
|
|
98
97
|
listOnScreen = false,
|
|
99
98
|
}: ConversationViewProps) => {
|
|
100
99
|
const isDesktop = useIsDesktop();
|
|
101
|
-
const { handlers: swipeHandlers } = useSwipeNavigation({
|
|
102
|
-
onSwipeLeft: onSwipeNext,
|
|
103
|
-
onSwipeRight: onSwipePrevious,
|
|
104
|
-
});
|
|
105
100
|
const { accountId: mailboxAccountId } = useMailboxAccount(mailboxId);
|
|
106
101
|
const {
|
|
107
102
|
data: messagesResponse,
|
|
@@ -482,8 +477,7 @@ export const ConversationView = ({
|
|
|
482
477
|
// Mobile: the kit MobileReadingPane owns the chrome — a top app bar with
|
|
483
478
|
// back, the email subject and the intelligence toggle (the host owns the
|
|
484
479
|
// drawer). Each expanded card owns its per-message action bar; there is no
|
|
485
|
-
// thread-level reply footer. The phishing warning leads the scroll content
|
|
486
|
-
// horizontal swipe between messages is wired through touchHandlers (#693).
|
|
480
|
+
// thread-level reply footer. The phishing warning leads the scroll content.
|
|
487
481
|
if (!isDesktop) {
|
|
488
482
|
return (
|
|
489
483
|
<MobileReadingPane
|
|
@@ -491,7 +485,8 @@ export const ConversationView = ({
|
|
|
491
485
|
onBack={onBack ?? (() => undefined)}
|
|
492
486
|
intelligenceOpen={mobileIntelligenceOpen}
|
|
493
487
|
onToggleIntelligence={onOpenIntelligence}
|
|
494
|
-
|
|
488
|
+
onSwipeNext={onSwipeNext}
|
|
489
|
+
onSwipePrevious={onSwipePrevious}
|
|
495
490
|
>
|
|
496
491
|
{authenticity?.dkimMismatch && (
|
|
497
492
|
<AuthenticityBanner
|
|
@@ -10,7 +10,10 @@ import { BadgeCheck, Paperclip, Star } from "lucide-react";
|
|
|
10
10
|
import { useState } from "react";
|
|
11
11
|
import { EmptyState } from "@/components/ui/EmptyState";
|
|
12
12
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
isMessageGoneFromServerError,
|
|
15
|
+
isMessageNotFoundError,
|
|
16
|
+
} from "@/components/ui/error-banners";
|
|
14
17
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
15
18
|
import { formatDatePreset } from "@/lib/format";
|
|
16
19
|
import { AutoMovedIndicator } from "./AutoMovedIndicator";
|
|
@@ -310,6 +313,10 @@ const ExpandedCard = ({
|
|
|
310
313
|
<div className="h-4 bg-surface-sunken rounded w-3/4" />
|
|
311
314
|
<div className="h-4 bg-surface-sunken rounded w-1/2" />
|
|
312
315
|
</div>
|
|
316
|
+
) : isError && isMessageGoneFromServerError(error) ? (
|
|
317
|
+
<div className="mt-3">
|
|
318
|
+
<EmptyState message="This message is no longer on the mail server. It was moved or deleted in another mail app." />
|
|
319
|
+
</div>
|
|
313
320
|
) : isError && isMessageNotFoundError(error) ? (
|
|
314
321
|
<div className="mt-3">
|
|
315
322
|
<EmptyState message="This message has been deleted" />
|
|
@@ -188,10 +188,8 @@ const MessageRowComponent = ({
|
|
|
188
188
|
}, [prefetchMessage, onFocusRow, messageId]);
|
|
189
189
|
|
|
190
190
|
// Listbox semantics + roving tabindex, shared by both densities. Merged (not
|
|
191
|
-
// spread) with the mobile long-press props
|
|
192
|
-
//
|
|
193
|
-
// spread would silently drop whichever onClick landed second instead of
|
|
194
|
-
// running both.
|
|
191
|
+
// spread) with the mobile long-press props, so the gesture's pointer
|
|
192
|
+
// handlers chain with the row's own instead of replacing them.
|
|
195
193
|
const interactionProps = mergeProps(
|
|
196
194
|
{
|
|
197
195
|
"data-list-row": "",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { HttpResponse, http } from "msw";
|
|
3
|
+
import { expect, within } from "storybook/test";
|
|
4
|
+
import { AppStory } from "@/mocks/story-frame/AppStory";
|
|
5
|
+
import { composeWorld, messageUrl } from "@/mocks/story-frame/compose-world";
|
|
6
|
+
import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
|
|
7
|
+
|
|
8
|
+
const world = composeWorld();
|
|
9
|
+
|
|
10
|
+
const goneFromServer = http.get("/api/messages/msg-q3-planning", () =>
|
|
11
|
+
HttpResponse.json(
|
|
12
|
+
{
|
|
13
|
+
code: "message_gone_from_server",
|
|
14
|
+
message: "This message is no longer on the mail server.",
|
|
15
|
+
details: { messageId: "msg-q3-planning" },
|
|
16
|
+
},
|
|
17
|
+
{ status: 404 },
|
|
18
|
+
),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const meta = {
|
|
22
|
+
title: "Playground/Shipped/Mail/Message gone from server",
|
|
23
|
+
component: AppStory,
|
|
24
|
+
args: { url: messageUrl("q3-planning") },
|
|
25
|
+
parameters: {
|
|
26
|
+
layout: "fullscreen",
|
|
27
|
+
msw: { handlers: [goneFromServer, ...mailHandlers(world)] },
|
|
28
|
+
},
|
|
29
|
+
} satisfies Meta<typeof AppStory>;
|
|
30
|
+
export default meta;
|
|
31
|
+
|
|
32
|
+
type Story = StoryObj<typeof meta>;
|
|
33
|
+
|
|
34
|
+
export const MovedOrDeletedElsewhere: Story = {
|
|
35
|
+
play: async () => {
|
|
36
|
+
await expect(
|
|
37
|
+
await within(document.body).findByText(
|
|
38
|
+
"This message is no longer on the mail server. It was moved or deleted in another mail app.",
|
|
39
|
+
),
|
|
40
|
+
).toBeVisible();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { codedApiErrorBody } from "@/lib/api";
|
|
1
2
|
export type ErrorBannerSeverity = "error" | "warning" | "info";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -67,6 +68,9 @@ export const formatErrorDetail = (error: unknown): string | undefined => {
|
|
|
67
68
|
return undefined;
|
|
68
69
|
};
|
|
69
70
|
|
|
71
|
+
export const isMessageGoneFromServerError = (error: unknown): boolean =>
|
|
72
|
+
codedApiErrorBody(error)?.code === "message_gone_from_server";
|
|
73
|
+
|
|
70
74
|
/**
|
|
71
75
|
* Detect a "describeMessage hit a deleted/missing row" error.
|
|
72
76
|
*
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { describe, test } from "node:test";
|
|
3
|
-
import { resolveSwipe } from "./useSwipeNavigation.js";
|
|
4
|
-
|
|
5
|
-
const THRESHOLD = 60;
|
|
6
|
-
|
|
7
|
-
describe("resolveSwipe", () => {
|
|
8
|
-
test("classifies a left swipe (next) when dragging right→left", () => {
|
|
9
|
-
assert.strictEqual(resolveSwipe(-80, 0, THRESHOLD).direction, "left");
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test("classifies a right swipe (previous) when dragging left→right", () => {
|
|
13
|
-
assert.strictEqual(resolveSwipe(80, 0, THRESHOLD).direction, "right");
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test("ignores a gesture shorter than the threshold", () => {
|
|
17
|
-
assert.strictEqual(resolveSwipe(-40, 0, THRESHOLD).direction, null);
|
|
18
|
-
assert.strictEqual(resolveSwipe(59, 0, THRESHOLD).direction, null);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("ignores a mostly-vertical gesture so it never hijacks scroll", () => {
|
|
22
|
-
// Long horizontal travel but even longer vertical travel: this is a scroll.
|
|
23
|
-
assert.strictEqual(resolveSwipe(70, 200, THRESHOLD).direction, null);
|
|
24
|
-
assert.strictEqual(resolveSwipe(-70, -200, THRESHOLD).direction, null);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test("accepts a horizontal swipe with mild vertical drift", () => {
|
|
28
|
-
assert.strictEqual(resolveSwipe(-100, 20, THRESHOLD).direction, "left");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("rejects a diagonal gesture that is not horizontally dominant", () => {
|
|
32
|
-
// dx 70, dy 60: not 1.5x dominant, so treated as ambiguous → no swipe.
|
|
33
|
-
assert.strictEqual(resolveSwipe(70, 60, THRESHOLD).direction, null);
|
|
34
|
-
});
|
|
35
|
-
});
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { useCallback, useRef } from "react";
|
|
2
|
-
|
|
3
|
-
export type SwipeDirection = "left" | "right";
|
|
4
|
-
|
|
5
|
-
interface UseSwipeNavigationOptions {
|
|
6
|
-
/**
|
|
7
|
-
* Swipe left (content advances, finger moves right→left): go to the NEXT
|
|
8
|
-
* item — the platform convention for paging through content (photos, mail).
|
|
9
|
-
*/
|
|
10
|
-
onSwipeLeft?: () => void;
|
|
11
|
-
/** Swipe right: go to the PREVIOUS item. */
|
|
12
|
-
onSwipeRight?: () => void;
|
|
13
|
-
/** Minimum horizontal travel (px) before a swipe registers. */
|
|
14
|
-
thresholdPx?: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface SwipeHandlers {
|
|
18
|
-
onPointerDown: (e: React.PointerEvent) => void;
|
|
19
|
-
onPointerMove: (e: React.PointerEvent) => void;
|
|
20
|
-
onPointerUp: (e: React.PointerEvent) => void;
|
|
21
|
-
onPointerCancel: () => void;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const DEFAULT_THRESHOLD = 60;
|
|
25
|
-
/**
|
|
26
|
-
* A gesture only counts as a horizontal swipe when its horizontal travel
|
|
27
|
-
* dominates the vertical. Anything more vertical than this is the user
|
|
28
|
-
* scrolling the message body, and we leave it alone.
|
|
29
|
-
*/
|
|
30
|
-
const HORIZONTAL_DOMINANCE = 1.5;
|
|
31
|
-
|
|
32
|
-
export interface SwipeResolution {
|
|
33
|
-
direction: SwipeDirection | null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Pure swipe classifier. Returns the swipe direction, or `null` when the
|
|
38
|
-
* gesture is too short or too vertical to count as a horizontal swipe (so it
|
|
39
|
-
* never hijacks vertical scrolling).
|
|
40
|
-
*/
|
|
41
|
-
export const resolveSwipe = (
|
|
42
|
-
dx: number,
|
|
43
|
-
dy: number,
|
|
44
|
-
thresholdPx: number,
|
|
45
|
-
): SwipeResolution => {
|
|
46
|
-
const absX = Math.abs(dx);
|
|
47
|
-
const absY = Math.abs(dy);
|
|
48
|
-
if (absX < thresholdPx) return { direction: null };
|
|
49
|
-
if (absX < absY * HORIZONTAL_DOMINANCE) return { direction: null };
|
|
50
|
-
return { direction: dx < 0 ? "left" : "right" };
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Detects horizontal swipe gestures from pointer events and routes them to
|
|
55
|
-
* next/previous callbacks. Only reacts to touch/pen pointers so mouse drags on
|
|
56
|
-
* desktop never trigger navigation. Vertical scroll is left untouched: a
|
|
57
|
-
* gesture must travel mostly sideways to register.
|
|
58
|
-
*/
|
|
59
|
-
export const useSwipeNavigation = ({
|
|
60
|
-
onSwipeLeft,
|
|
61
|
-
onSwipeRight,
|
|
62
|
-
thresholdPx = DEFAULT_THRESHOLD,
|
|
63
|
-
}: UseSwipeNavigationOptions): { handlers: SwipeHandlers } => {
|
|
64
|
-
const startRef = useRef<{ x: number; y: number } | null>(null);
|
|
65
|
-
|
|
66
|
-
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
|
67
|
-
if (e.pointerType === "mouse") {
|
|
68
|
-
startRef.current = null;
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
startRef.current = { x: e.clientX, y: e.clientY };
|
|
72
|
-
}, []);
|
|
73
|
-
|
|
74
|
-
const onPointerMove = useCallback(() => {}, []);
|
|
75
|
-
|
|
76
|
-
const onPointerUp = useCallback(
|
|
77
|
-
(e: React.PointerEvent) => {
|
|
78
|
-
const start = startRef.current;
|
|
79
|
-
startRef.current = null;
|
|
80
|
-
if (!start) return;
|
|
81
|
-
const { direction } = resolveSwipe(
|
|
82
|
-
e.clientX - start.x,
|
|
83
|
-
e.clientY - start.y,
|
|
84
|
-
thresholdPx,
|
|
85
|
-
);
|
|
86
|
-
if (direction === "left") onSwipeLeft?.();
|
|
87
|
-
if (direction === "right") onSwipeRight?.();
|
|
88
|
-
},
|
|
89
|
-
[onSwipeLeft, onSwipeRight, thresholdPx],
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
const onPointerCancel = useCallback(() => {
|
|
93
|
-
startRef.current = null;
|
|
94
|
-
}, []);
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel },
|
|
98
|
-
};
|
|
99
|
-
};
|