@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.320",
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
- touchHandlers={swipeHandlers}
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 { isMessageNotFoundError } from "@/components/ui/error-banners";
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: react-aria's pressProps carries
192
- // its own onClick for its internal press bookkeeping, and a plain object
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": "",
@@ -117,6 +117,7 @@ const press = (
117
117
  const event = new PointerEvent("pointerdown", {
118
118
  bubbles: true,
119
119
  cancelable: true,
120
+ buttons: 1,
120
121
  pointerId: 1,
121
122
  clientX: 10,
122
123
  clientY: 10,
@@ -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
  *
@@ -5,6 +5,7 @@ const touch = (target: HTMLElement, type: string) => {
5
5
  target.dispatchEvent(
6
6
  new PointerEvent(type, {
7
7
  bubbles: true,
8
+ buttons: type === "pointerup" ? 0 : 1,
8
9
  pointerType: "touch",
9
10
  pointerId: 1,
10
11
  isPrimary: true,
@@ -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
- };