@remit/web-client 0.0.28 → 0.0.30
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/auth/auth-interceptor.test.ts +23 -1
- package/src/auth/auth-interceptor.ts +5 -0
- package/src/auth/better-auth-config.ts +52 -7
- package/src/auth/better-auth-token.test.ts +152 -0
- package/src/auth/provider.tsx +8 -3
- package/src/components/mail/MessageListItem.tsx +45 -21
- package/src/hooks/useVisualViewport.test.ts +1 -2
- package/src/hooks/useLongPress.test.ts +0 -291
- package/src/hooks/useLongPress.ts +0 -87
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.30",
|
|
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": {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
+
import { AuthTokenError } from "./better-auth-config";
|
|
3
4
|
import { type AuthProvider, noneAuthProvider } from "./provider";
|
|
4
5
|
|
|
5
6
|
type RequestFn = (req: Request) => Promise<Request>;
|
|
@@ -47,7 +48,28 @@ describe("installAuthInterceptor", () => {
|
|
|
47
48
|
assert.equal(out.headers.get("Authorization"), "Bearer ID-TOKEN-123");
|
|
48
49
|
});
|
|
49
50
|
|
|
50
|
-
test("
|
|
51
|
+
test("abandons the request when the token cannot be minted — never sends it unauthenticated", async () => {
|
|
52
|
+
const mod = await loadInterceptor();
|
|
53
|
+
mod.installAuthInterceptor(
|
|
54
|
+
providerWithToken(async () => {
|
|
55
|
+
throw new AuthTokenError("Could not mint a session token: 429", 429);
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
|
|
59
|
+
assert.ok(fn);
|
|
60
|
+
const req = new Request("https://api.example.com/thing");
|
|
61
|
+
await assert.rejects(
|
|
62
|
+
() => fn(req),
|
|
63
|
+
(error: unknown) => {
|
|
64
|
+
assert.ok(error instanceof AuthTokenError);
|
|
65
|
+
assert.equal(error.status, 429);
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
assert.equal(req.headers.get("Authorization"), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("omits Authorization header when the deployment presents no identity", async () => {
|
|
51
73
|
const mod = await loadInterceptor();
|
|
52
74
|
mod.installAuthInterceptor(providerWithToken(async () => null));
|
|
53
75
|
const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
|
|
@@ -7,6 +7,11 @@ let installed = false;
|
|
|
7
7
|
* Attach the composed provider's bearer token to every API request. Called once
|
|
8
8
|
* from `mountApp` with the app's chosen provider, so the token source is the
|
|
9
9
|
* same `AuthProvider` the shell and screens read — no separate seam.
|
|
10
|
+
*
|
|
11
|
+
* A provider that cannot produce a token throws, and that throw is left to
|
|
12
|
+
* propagate: the request is abandoned rather than sent without an Authorization
|
|
13
|
+
* header. Only the deployments that present no identity at all resolve to null,
|
|
14
|
+
* and for those an unauthenticated request is the intended one.
|
|
10
15
|
*/
|
|
11
16
|
export const installAuthInterceptor = (authProvider: AuthProvider): void => {
|
|
12
17
|
if (installed) return;
|
|
@@ -51,11 +51,32 @@ const decodeExp = (token: string): number => {
|
|
|
51
51
|
}
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* A session exists but no bearer token could be produced for it. Distinct from
|
|
56
|
+
* "signed out": there is nothing to fall back to, so the request that needed the
|
|
57
|
+
* token must not be sent. Carries the mint's HTTP status where there was one, so
|
|
58
|
+
* the shared classifier reads it like any other API failure.
|
|
59
|
+
*/
|
|
60
|
+
export class AuthTokenError extends Error {
|
|
61
|
+
readonly status: number | undefined;
|
|
62
|
+
|
|
63
|
+
constructor(message: string, status?: number) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = "AuthTokenError";
|
|
66
|
+
this.status = status;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const requestToken = async (): Promise<string> => {
|
|
55
71
|
const res = await taggedFetch("/api/auth/token", {
|
|
56
72
|
credentials: "include",
|
|
57
73
|
});
|
|
58
|
-
if (!res.ok)
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
throw new AuthTokenError(
|
|
76
|
+
`Could not mint a session token: ${res.status} ${res.statusText}`,
|
|
77
|
+
res.status,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
59
80
|
const body: unknown = await res.json();
|
|
60
81
|
if (
|
|
61
82
|
body &&
|
|
@@ -65,7 +86,29 @@ const requestToken = async (): Promise<string | null> => {
|
|
|
65
86
|
) {
|
|
66
87
|
return (body as { token: string }).token;
|
|
67
88
|
}
|
|
68
|
-
|
|
89
|
+
throw new AuthTokenError("The token endpoint returned no token");
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
let inFlight: Promise<string> | null = null;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One mint at a time. A cold load renders many screens at once and each of their
|
|
96
|
+
* requests needs the same token; without this every one of them mints its own,
|
|
97
|
+
* and the burst is large enough to spend the auth tier's rate-limit budget. The
|
|
98
|
+
* slot is released once the mint settles, so a failure is not replayed to later
|
|
99
|
+
* callers — they mint again.
|
|
100
|
+
*/
|
|
101
|
+
const mint = (): Promise<string> => {
|
|
102
|
+
if (inFlight) return inFlight;
|
|
103
|
+
inFlight = requestToken()
|
|
104
|
+
.then((token) => {
|
|
105
|
+
cached = { value: token, expiresAt: decodeExp(token) };
|
|
106
|
+
return token;
|
|
107
|
+
})
|
|
108
|
+
.finally(() => {
|
|
109
|
+
inFlight = null;
|
|
110
|
+
});
|
|
111
|
+
return inFlight;
|
|
69
112
|
};
|
|
70
113
|
|
|
71
114
|
/**
|
|
@@ -73,14 +116,16 @@ const requestToken = async (): Promise<string | null> => {
|
|
|
73
116
|
* when the cached token is missing or within the skew window of expiry. The API
|
|
74
117
|
* interceptor attaches it as a Bearer token; the backend verifies it against the
|
|
75
118
|
* JWKS.
|
|
119
|
+
*
|
|
120
|
+
* Never resolves to null: a failed mint throws. Returning null would put the
|
|
121
|
+
* request on the wire without an Authorization header, and the backend answers
|
|
122
|
+
* that with a 401 that names a missing token rather than the mint that failed.
|
|
76
123
|
*/
|
|
77
|
-
export const fetchBetterAuthToken = async (): Promise<string
|
|
124
|
+
export const fetchBetterAuthToken = async (): Promise<string> => {
|
|
78
125
|
if (cached && cached.expiresAt - EXPIRY_SKEW_SECONDS > nowSeconds()) {
|
|
79
126
|
return cached.value;
|
|
80
127
|
}
|
|
81
|
-
|
|
82
|
-
cached = token ? { value: token, expiresAt: decodeExp(token) } : null;
|
|
83
|
-
return token;
|
|
128
|
+
return mint();
|
|
84
129
|
};
|
|
85
130
|
|
|
86
131
|
export const resetBetterAuthTokenCache = (): void => {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { afterEach, describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
AuthTokenError,
|
|
5
|
+
fetchBetterAuthToken,
|
|
6
|
+
resetBetterAuthTokenCache,
|
|
7
|
+
} from "./better-auth-config";
|
|
8
|
+
|
|
9
|
+
const base64url = (value: string): string =>
|
|
10
|
+
Buffer.from(value, "utf8")
|
|
11
|
+
.toString("base64")
|
|
12
|
+
.replace(/\+/g, "-")
|
|
13
|
+
.replace(/\//g, "_")
|
|
14
|
+
.replace(/=+$/, "");
|
|
15
|
+
|
|
16
|
+
/** A token shaped like the one better-auth mints, valid for an hour. */
|
|
17
|
+
const jwt = (label: string): string =>
|
|
18
|
+
`header.${base64url(
|
|
19
|
+
JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, label }),
|
|
20
|
+
)}.signature`;
|
|
21
|
+
|
|
22
|
+
const realFetch = globalThis.fetch;
|
|
23
|
+
|
|
24
|
+
interface Stub {
|
|
25
|
+
calls: number;
|
|
26
|
+
release: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Stand in for the token endpoint, holding every request open until released. */
|
|
30
|
+
const stubTokenEndpoint = (respond: (call: number) => Response): Stub => {
|
|
31
|
+
let open: () => void = () => {};
|
|
32
|
+
const gate = new Promise<void>((resolve) => {
|
|
33
|
+
open = resolve;
|
|
34
|
+
});
|
|
35
|
+
const stub: Stub = { calls: 0, release: () => open() };
|
|
36
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
37
|
+
assert.match(String(input), /\/api\/auth\/token$/);
|
|
38
|
+
stub.calls += 1;
|
|
39
|
+
const call = stub.calls;
|
|
40
|
+
await gate;
|
|
41
|
+
return respond(call);
|
|
42
|
+
}) as typeof fetch;
|
|
43
|
+
return stub;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const tokenResponse = (token: string): Response =>
|
|
47
|
+
new Response(JSON.stringify({ token }), {
|
|
48
|
+
status: 200,
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
globalThis.fetch = realFetch;
|
|
54
|
+
resetBetterAuthTokenCache();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("fetchBetterAuthToken", () => {
|
|
58
|
+
test("concurrent callers share one mint instead of each firing their own", async () => {
|
|
59
|
+
const stub = stubTokenEndpoint((call) => tokenResponse(jwt(`t${call}`)));
|
|
60
|
+
|
|
61
|
+
const pending = Array.from({ length: 12 }, () => fetchBetterAuthToken());
|
|
62
|
+
stub.release();
|
|
63
|
+
const tokens = await Promise.all(pending);
|
|
64
|
+
|
|
65
|
+
assert.equal(stub.calls, 1);
|
|
66
|
+
assert.equal(new Set(tokens).size, 1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a token minted once is reused from cache, without a second request", async () => {
|
|
70
|
+
const stub = stubTokenEndpoint(() => tokenResponse(jwt("cached")));
|
|
71
|
+
stub.release();
|
|
72
|
+
|
|
73
|
+
const first = await fetchBetterAuthToken();
|
|
74
|
+
const second = await fetchBetterAuthToken();
|
|
75
|
+
|
|
76
|
+
assert.equal(stub.calls, 1);
|
|
77
|
+
assert.equal(second, first);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a rejected mint throws with its status rather than resolving to null", async () => {
|
|
81
|
+
const stub = stubTokenEndpoint(
|
|
82
|
+
() => new Response("", { status: 429, statusText: "Too Many Requests" }),
|
|
83
|
+
);
|
|
84
|
+
stub.release();
|
|
85
|
+
|
|
86
|
+
await assert.rejects(
|
|
87
|
+
() => fetchBetterAuthToken(),
|
|
88
|
+
(error: unknown) => {
|
|
89
|
+
assert.ok(error instanceof AuthTokenError);
|
|
90
|
+
assert.equal(error.status, 429);
|
|
91
|
+
return true;
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a response carrying no token throws rather than resolving to null", async () => {
|
|
97
|
+
const stub = stubTokenEndpoint(
|
|
98
|
+
() =>
|
|
99
|
+
new Response(JSON.stringify({}), {
|
|
100
|
+
status: 200,
|
|
101
|
+
headers: { "Content-Type": "application/json" },
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
stub.release();
|
|
105
|
+
|
|
106
|
+
await assert.rejects(() => fetchBetterAuthToken(), AuthTokenError);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("every caller sharing a failed mint sees the failure", async () => {
|
|
110
|
+
const stub = stubTokenEndpoint(
|
|
111
|
+
() => new Response("", { status: 429, statusText: "Too Many Requests" }),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const pending = Array.from({ length: 5 }, () =>
|
|
115
|
+
fetchBetterAuthToken().then(
|
|
116
|
+
() => "resolved",
|
|
117
|
+
(error: unknown) =>
|
|
118
|
+
error instanceof AuthTokenError ? "threw" : "other",
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
stub.release();
|
|
122
|
+
const outcomes = await Promise.all(pending);
|
|
123
|
+
|
|
124
|
+
assert.equal(stub.calls, 1);
|
|
125
|
+
assert.deepEqual(new Set(outcomes), new Set(["threw"]));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a failed mint is not replayed — the next caller mints again", async () => {
|
|
129
|
+
const failing = stubTokenEndpoint(() => new Response("", { status: 500 }));
|
|
130
|
+
failing.release();
|
|
131
|
+
await assert.rejects(() => fetchBetterAuthToken());
|
|
132
|
+
|
|
133
|
+
const succeeding = stubTokenEndpoint(() =>
|
|
134
|
+
tokenResponse(jwt("after-failure")),
|
|
135
|
+
);
|
|
136
|
+
succeeding.release();
|
|
137
|
+
|
|
138
|
+
assert.ok(await fetchBetterAuthToken());
|
|
139
|
+
assert.equal(succeeding.calls, 1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a network failure propagates instead of yielding a tokenless request", async () => {
|
|
143
|
+
globalThis.fetch = (async () => {
|
|
144
|
+
throw new TypeError("Failed to fetch");
|
|
145
|
+
}) as typeof fetch;
|
|
146
|
+
|
|
147
|
+
await assert.rejects(
|
|
148
|
+
() => fetchBetterAuthToken(),
|
|
149
|
+
/could not be completed|Failed to fetch/i,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
});
|
package/src/auth/provider.tsx
CHANGED
|
@@ -22,9 +22,14 @@ export interface AuthProvider {
|
|
|
22
22
|
/** Called once at boot, before render, to initialize the identity SDK. */
|
|
23
23
|
configure(): void;
|
|
24
24
|
/**
|
|
25
|
-
* The current session's bearer token
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* The current session's bearer token. The API interceptor and the raw content
|
|
26
|
+
* fetch read it through here, so the identity SDK stays inside the provider.
|
|
27
|
+
*
|
|
28
|
+
* `null` means this deployment has no identity to present — no identity system
|
|
29
|
+
* is composed, or none is configured — and the request is expected to travel
|
|
30
|
+
* unauthenticated. It never means the token could not be produced: a provider
|
|
31
|
+
* that holds a session and fails to mint a token throws, because a request
|
|
32
|
+
* that cannot be authenticated must not be sent.
|
|
28
33
|
*/
|
|
29
34
|
getToken(): Promise<string | null>;
|
|
30
35
|
/** Drop any cached token so the next `getToken` re-mints. */
|
|
@@ -7,14 +7,15 @@ import {
|
|
|
7
7
|
comfortableRowClass,
|
|
8
8
|
compactRowClass,
|
|
9
9
|
type Density,
|
|
10
|
+
mergeProps,
|
|
10
11
|
type SenderTrustLevel,
|
|
11
12
|
type ThreadRowData,
|
|
13
|
+
useLongPress,
|
|
12
14
|
} from "@remit/ui";
|
|
13
15
|
import { useQueryClient } from "@tanstack/react-query";
|
|
14
16
|
import { Link } from "@tanstack/react-router";
|
|
15
17
|
import { Check } from "lucide-react";
|
|
16
18
|
import { type MouseEvent, memo, useCallback } from "react";
|
|
17
|
-
import { useLongPress } from "@/hooks/useLongPress";
|
|
18
19
|
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
19
20
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
20
21
|
import { formatEmailDate } from "@/lib/format";
|
|
@@ -125,16 +126,27 @@ const MessageListItemComponent = ({
|
|
|
125
126
|
|
|
126
127
|
// Desktop mouse selection semantics. Plain click falls through to the Link's
|
|
127
128
|
// navigation; shift / cmd / ctrl click is routed to selection and the
|
|
128
|
-
// navigation is suppressed.
|
|
129
|
+
// navigation is suppressed.
|
|
129
130
|
//
|
|
130
131
|
// A modified click must preventDefault: the router skips navigation for any
|
|
131
132
|
// modified click and leaves the anchor's own default in place, which in a
|
|
132
133
|
// browser means shift-click opens a new window and cmd-click a new tab.
|
|
133
134
|
// Shift-click also drags a native text selection across the rows it spans,
|
|
134
135
|
// so drop it — the row highlight is the selection the user asked for.
|
|
136
|
+
//
|
|
137
|
+
// On mobile, once selection mode is active (#92) a plain tap toggles the
|
|
138
|
+
// row instead of opening it — the same tap-to-toggle contract every
|
|
139
|
+
// reference mail client uses once you're mid-selection. Outside selection
|
|
140
|
+
// mode a short tap still falls through to the Link's native navigation.
|
|
135
141
|
const handleRowClick = useCallback(
|
|
136
142
|
(e: MouseEvent) => {
|
|
137
|
-
if (!isDesktop)
|
|
143
|
+
if (!isDesktop) {
|
|
144
|
+
if (!isMultiSelectMode) return;
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
e.stopPropagation();
|
|
147
|
+
onToggleCheck(messageId);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
138
150
|
const modifiers = {
|
|
139
151
|
shiftKey: e.shiftKey,
|
|
140
152
|
metaKey: e.metaKey,
|
|
@@ -148,7 +160,7 @@ const MessageListItemComponent = ({
|
|
|
148
160
|
window.getSelection()?.removeAllRanges();
|
|
149
161
|
}
|
|
150
162
|
},
|
|
151
|
-
[isDesktop, onRowSelect, messageId],
|
|
163
|
+
[isDesktop, isMultiSelectMode, onToggleCheck, onRowSelect, messageId],
|
|
152
164
|
);
|
|
153
165
|
|
|
154
166
|
// Shift-click starts a native text selection on mousedown; suppressing it
|
|
@@ -165,9 +177,10 @@ const MessageListItemComponent = ({
|
|
|
165
177
|
onLongPress?.(messageId);
|
|
166
178
|
}, [onLongPress, messageId]);
|
|
167
179
|
|
|
168
|
-
const
|
|
180
|
+
const { longPressProps } = useLongPress({
|
|
169
181
|
onLongPress: handleLongPress,
|
|
170
182
|
delayMs: 500,
|
|
183
|
+
accessibilityDescription: isChecked ? "Deselect message" : "Select message",
|
|
171
184
|
});
|
|
172
185
|
|
|
173
186
|
// Intent-based prefetch: by the time the user clicks, the body is in
|
|
@@ -185,18 +198,25 @@ const MessageListItemComponent = ({
|
|
|
185
198
|
onFocusRow?.(messageId);
|
|
186
199
|
}, [prefetchMessage, onFocusRow, messageId]);
|
|
187
200
|
|
|
188
|
-
// Listbox semantics + roving tabindex, shared by both densities.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
201
|
+
// Listbox semantics + roving tabindex, shared by both densities. Merged
|
|
202
|
+
// (not spread) with the mobile long-press props: react-aria's pressProps
|
|
203
|
+
// carries its own onClick for its internal press bookkeeping, and a plain
|
|
204
|
+
// object spread would silently drop whichever onClick landed second
|
|
205
|
+
// instead of running both.
|
|
206
|
+
const rowInteractionProps = mergeProps(
|
|
207
|
+
{
|
|
208
|
+
"data-message-row": true,
|
|
209
|
+
"data-message-id": messageId,
|
|
210
|
+
role: "option" as const,
|
|
211
|
+
"aria-selected": isChecked,
|
|
212
|
+
tabIndex: isTabStop ? 0 : -1,
|
|
213
|
+
onClick: handleRowClick,
|
|
214
|
+
onMouseDown: handleRowMouseDown,
|
|
215
|
+
onMouseEnter: prefetchMessage,
|
|
216
|
+
onFocus: handleRowFocus,
|
|
217
|
+
},
|
|
218
|
+
isDesktop ? {} : longPressProps,
|
|
219
|
+
);
|
|
200
220
|
|
|
201
221
|
const unread = !thread.isRead;
|
|
202
222
|
|
|
@@ -210,12 +230,16 @@ const MessageListItemComponent = ({
|
|
|
210
230
|
selectedMessageId: thread.messageId,
|
|
211
231
|
})}
|
|
212
232
|
{...rowInteractionProps}
|
|
213
|
-
{...(!isDesktop && longPressHandlers.handlers)}
|
|
214
233
|
className={cn(
|
|
215
234
|
compactRowClass({ active: isSelected, focused: isFocused }),
|
|
216
235
|
"outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
|
|
217
236
|
isChecked && "bg-accent-soft",
|
|
218
|
-
|
|
237
|
+
// Long-press enters selection mode; without these, Android Chrome
|
|
238
|
+
// opens the link context menu / starts text selection and iOS
|
|
239
|
+
// Safari fires the callout, racing the app's handler. react-aria
|
|
240
|
+
// suppresses contextmenu/text-selection but not iOS's callout —
|
|
241
|
+
// it fires no cancelable event, so CSS is the only lever.
|
|
242
|
+
!isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
|
|
219
243
|
)}
|
|
220
244
|
>
|
|
221
245
|
<CompactRowBody thread={rowData} />
|
|
@@ -235,13 +259,13 @@ const MessageListItemComponent = ({
|
|
|
235
259
|
selectedMessageId: thread.messageId,
|
|
236
260
|
})}
|
|
237
261
|
{...rowInteractionProps}
|
|
238
|
-
{...(!isDesktop && longPressHandlers.handlers)}
|
|
239
262
|
className={cn(
|
|
240
263
|
"group",
|
|
241
264
|
comfortableRowClass({ active: isSelected, focused: isFocused }),
|
|
242
265
|
"outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
|
|
243
266
|
isChecked && "bg-accent-soft",
|
|
244
|
-
|
|
267
|
+
// See the compact-density Link above for why both are required.
|
|
268
|
+
!isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
|
|
245
269
|
)}
|
|
246
270
|
>
|
|
247
271
|
{/* Absolute unread dot — 6px gutter from the left pane hairline */}
|
|
@@ -3,8 +3,7 @@ import { beforeEach, describe, test } from "node:test";
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Unit test for useVisualViewport hook logic.
|
|
6
|
-
* Tests the core behaviour without React
|
|
7
|
-
* by useLongPress.test.ts in this project.
|
|
6
|
+
* Tests the core behaviour without React.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
const KEYBOARD_THRESHOLD = 150;
|
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { beforeEach, describe, mock, test } from "node:test";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Test harness for useLongPress. Since we can't use React Testing Library,
|
|
6
|
-
* we test the handler logic directly by simulating pointer events and timers.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
interface MockPointerEvent {
|
|
10
|
-
clientX: number;
|
|
11
|
-
clientY: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const createPointerEvent = (x: number, y: number): MockPointerEvent => ({
|
|
15
|
-
clientX: x,
|
|
16
|
-
clientY: y,
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Minimal implementation of useLongPress logic for testing.
|
|
21
|
-
* Matches the hook's behavior without React dependencies.
|
|
22
|
-
*/
|
|
23
|
-
class LongPressSimulator {
|
|
24
|
-
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
25
|
-
private startPos: { x: number; y: number } | null = null;
|
|
26
|
-
private readonly MOVEMENT_THRESHOLD = 8;
|
|
27
|
-
|
|
28
|
-
constructor(
|
|
29
|
-
private readonly onLongPress: () => void,
|
|
30
|
-
private readonly delayMs: number = 500,
|
|
31
|
-
) {}
|
|
32
|
-
|
|
33
|
-
onPointerDown(e: MockPointerEvent): void {
|
|
34
|
-
this.clearTimer();
|
|
35
|
-
this.startPos = { x: e.clientX, y: e.clientY };
|
|
36
|
-
this.timer = setTimeout(() => {
|
|
37
|
-
this.onLongPress();
|
|
38
|
-
this.clearTimer();
|
|
39
|
-
}, this.delayMs);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
onPointerMove(e: MockPointerEvent): void {
|
|
43
|
-
if (!this.startPos) return;
|
|
44
|
-
|
|
45
|
-
const dx = e.clientX - this.startPos.x;
|
|
46
|
-
const dy = e.clientY - this.startPos.y;
|
|
47
|
-
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
48
|
-
|
|
49
|
-
if (distance > this.MOVEMENT_THRESHOLD) {
|
|
50
|
-
this.clearTimer();
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
onPointerUp(): void {
|
|
55
|
-
this.clearTimer();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
onPointerCancel(): void {
|
|
59
|
-
this.clearTimer();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
private clearTimer(): void {
|
|
63
|
-
if (this.timer) {
|
|
64
|
-
clearTimeout(this.timer);
|
|
65
|
-
this.timer = null;
|
|
66
|
-
}
|
|
67
|
-
this.startPos = null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Test helper: check if timer is active */
|
|
71
|
-
hasActiveTimer(): boolean {
|
|
72
|
-
return this.timer !== null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Test helper: cleanup */
|
|
76
|
-
cleanup(): void {
|
|
77
|
-
this.clearTimer();
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
describe("useLongPress", () => {
|
|
82
|
-
beforeEach(() => {
|
|
83
|
-
// Reset any timers between tests
|
|
84
|
-
mock.restoreAll();
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("fires callback after delay with no movement", async () => {
|
|
88
|
-
const onLongPress = mock.fn();
|
|
89
|
-
const sim = new LongPressSimulator(onLongPress, 500);
|
|
90
|
-
|
|
91
|
-
sim.onPointerDown(createPointerEvent(100, 100));
|
|
92
|
-
|
|
93
|
-
// Wait for the delay
|
|
94
|
-
await new Promise((resolve) => setTimeout(resolve, 550));
|
|
95
|
-
|
|
96
|
-
assert.strictEqual(onLongPress.mock.calls.length, 1);
|
|
97
|
-
sim.cleanup();
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("does not fire if pointer moves more than 8px", async () => {
|
|
101
|
-
const onLongPress = mock.fn();
|
|
102
|
-
const sim = new LongPressSimulator(onLongPress, 500);
|
|
103
|
-
|
|
104
|
-
sim.onPointerDown(createPointerEvent(100, 100));
|
|
105
|
-
|
|
106
|
-
// Move 10 pixels (more than threshold of 8)
|
|
107
|
-
sim.onPointerMove(createPointerEvent(110, 100));
|
|
108
|
-
|
|
109
|
-
// Wait beyond the delay
|
|
110
|
-
await new Promise((resolve) => setTimeout(resolve, 550));
|
|
111
|
-
|
|
112
|
-
assert.strictEqual(
|
|
113
|
-
onLongPress.mock.calls.length,
|
|
114
|
-
0,
|
|
115
|
-
"callback should not fire after moving > threshold",
|
|
116
|
-
);
|
|
117
|
-
sim.cleanup();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("does not fire on early pointerup", async () => {
|
|
121
|
-
const onLongPress = mock.fn();
|
|
122
|
-
const sim = new LongPressSimulator(onLongPress, 500);
|
|
123
|
-
|
|
124
|
-
sim.onPointerDown(createPointerEvent(100, 100));
|
|
125
|
-
|
|
126
|
-
// Release before delay completes
|
|
127
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
128
|
-
sim.onPointerUp();
|
|
129
|
-
|
|
130
|
-
// Wait beyond the original delay
|
|
131
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
132
|
-
|
|
133
|
-
assert.strictEqual(
|
|
134
|
-
onLongPress.mock.calls.length,
|
|
135
|
-
0,
|
|
136
|
-
"callback should not fire after early pointerup",
|
|
137
|
-
);
|
|
138
|
-
sim.cleanup();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("does not fire on pointercancel", async () => {
|
|
142
|
-
const onLongPress = mock.fn();
|
|
143
|
-
const sim = new LongPressSimulator(onLongPress, 500);
|
|
144
|
-
|
|
145
|
-
sim.onPointerDown(createPointerEvent(100, 100));
|
|
146
|
-
|
|
147
|
-
// Cancel before delay completes
|
|
148
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
149
|
-
sim.onPointerCancel();
|
|
150
|
-
|
|
151
|
-
// Wait beyond the original delay
|
|
152
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
153
|
-
|
|
154
|
-
assert.strictEqual(
|
|
155
|
-
onLongPress.mock.calls.length,
|
|
156
|
-
0,
|
|
157
|
-
"callback should not fire after pointercancel",
|
|
158
|
-
);
|
|
159
|
-
sim.cleanup();
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("respects custom delayMs", async () => {
|
|
163
|
-
const onLongPress = mock.fn();
|
|
164
|
-
const customDelay = 200;
|
|
165
|
-
const sim = new LongPressSimulator(onLongPress, customDelay);
|
|
166
|
-
|
|
167
|
-
sim.onPointerDown(createPointerEvent(100, 100));
|
|
168
|
-
|
|
169
|
-
// Wait less than custom delay
|
|
170
|
-
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
171
|
-
assert.strictEqual(
|
|
172
|
-
onLongPress.mock.calls.length,
|
|
173
|
-
0,
|
|
174
|
-
"should not fire before custom delay",
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
// Wait past custom delay
|
|
178
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
179
|
-
assert.strictEqual(
|
|
180
|
-
onLongPress.mock.calls.length,
|
|
181
|
-
1,
|
|
182
|
-
"should fire after custom delay",
|
|
183
|
-
);
|
|
184
|
-
sim.cleanup();
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test("allows movement within 8px threshold", async () => {
|
|
188
|
-
const onLongPress = mock.fn();
|
|
189
|
-
const sim = new LongPressSimulator(onLongPress, 500);
|
|
190
|
-
|
|
191
|
-
sim.onPointerDown(createPointerEvent(100, 100));
|
|
192
|
-
|
|
193
|
-
// Move 7 pixels (within threshold)
|
|
194
|
-
sim.onPointerMove(createPointerEvent(107, 100));
|
|
195
|
-
|
|
196
|
-
// Wait for the delay
|
|
197
|
-
await new Promise((resolve) => setTimeout(resolve, 550));
|
|
198
|
-
|
|
199
|
-
assert.strictEqual(
|
|
200
|
-
onLongPress.mock.calls.length,
|
|
201
|
-
1,
|
|
202
|
-
"callback should fire when movement is within threshold",
|
|
203
|
-
);
|
|
204
|
-
sim.cleanup();
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("calculates diagonal movement correctly", async () => {
|
|
208
|
-
const onLongPress = mock.fn();
|
|
209
|
-
const sim = new LongPressSimulator(onLongPress, 500);
|
|
210
|
-
|
|
211
|
-
sim.onPointerDown(createPointerEvent(100, 100));
|
|
212
|
-
|
|
213
|
-
// Move 6px in both x and y (diagonal distance ~8.48px > 8px threshold)
|
|
214
|
-
sim.onPointerMove(createPointerEvent(106, 106));
|
|
215
|
-
|
|
216
|
-
// Wait beyond the delay
|
|
217
|
-
await new Promise((resolve) => setTimeout(resolve, 550));
|
|
218
|
-
|
|
219
|
-
assert.strictEqual(
|
|
220
|
-
onLongPress.mock.calls.length,
|
|
221
|
-
0,
|
|
222
|
-
"callback should not fire when diagonal movement exceeds threshold",
|
|
223
|
-
);
|
|
224
|
-
sim.cleanup();
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test("ignores pointer move before pointer down", async () => {
|
|
228
|
-
const onLongPress = mock.fn();
|
|
229
|
-
const sim = new LongPressSimulator(onLongPress, 500);
|
|
230
|
-
|
|
231
|
-
// Move without pressing down first
|
|
232
|
-
sim.onPointerMove(createPointerEvent(200, 200));
|
|
233
|
-
|
|
234
|
-
sim.onPointerDown(createPointerEvent(100, 100));
|
|
235
|
-
|
|
236
|
-
// Wait for the delay
|
|
237
|
-
await new Promise((resolve) => setTimeout(resolve, 550));
|
|
238
|
-
|
|
239
|
-
assert.strictEqual(
|
|
240
|
-
onLongPress.mock.calls.length,
|
|
241
|
-
1,
|
|
242
|
-
"callback should fire - earlier move should be ignored",
|
|
243
|
-
);
|
|
244
|
-
sim.cleanup();
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test("resets on new pointer down", async () => {
|
|
248
|
-
const onLongPress = mock.fn();
|
|
249
|
-
const sim = new LongPressSimulator(onLongPress, 500);
|
|
250
|
-
|
|
251
|
-
// First press
|
|
252
|
-
sim.onPointerDown(createPointerEvent(100, 100));
|
|
253
|
-
|
|
254
|
-
// Wait a bit
|
|
255
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
256
|
-
|
|
257
|
-
// New press at different location (should reset timer)
|
|
258
|
-
sim.onPointerDown(createPointerEvent(200, 200));
|
|
259
|
-
|
|
260
|
-
// Wait for delay from second press
|
|
261
|
-
await new Promise((resolve) => setTimeout(resolve, 550));
|
|
262
|
-
|
|
263
|
-
// Should only fire once (from second press)
|
|
264
|
-
assert.strictEqual(
|
|
265
|
-
onLongPress.mock.calls.length,
|
|
266
|
-
1,
|
|
267
|
-
"callback should fire once from second press",
|
|
268
|
-
);
|
|
269
|
-
sim.cleanup();
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
test("exact 8px movement does not cancel", async () => {
|
|
273
|
-
const onLongPress = mock.fn();
|
|
274
|
-
const sim = new LongPressSimulator(onLongPress, 500);
|
|
275
|
-
|
|
276
|
-
sim.onPointerDown(createPointerEvent(100, 100));
|
|
277
|
-
|
|
278
|
-
// Move exactly 8 pixels
|
|
279
|
-
sim.onPointerMove(createPointerEvent(108, 100));
|
|
280
|
-
|
|
281
|
-
// Wait for the delay
|
|
282
|
-
await new Promise((resolve) => setTimeout(resolve, 550));
|
|
283
|
-
|
|
284
|
-
assert.strictEqual(
|
|
285
|
-
onLongPress.mock.calls.length,
|
|
286
|
-
1,
|
|
287
|
-
"callback should fire - movement exactly at threshold is allowed",
|
|
288
|
-
);
|
|
289
|
-
sim.cleanup();
|
|
290
|
-
});
|
|
291
|
-
});
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { useCallback, useRef } from "react";
|
|
2
|
-
|
|
3
|
-
interface UseLongPressOptions {
|
|
4
|
-
onLongPress: () => void;
|
|
5
|
-
delayMs?: number;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
interface LongPressHandlers {
|
|
9
|
-
onPointerDown: (e: PointerEvent | React.PointerEvent) => void;
|
|
10
|
-
onPointerMove: (e: PointerEvent | React.PointerEvent) => void;
|
|
11
|
-
onPointerUp: () => void;
|
|
12
|
-
onPointerCancel: () => void;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const MOVEMENT_THRESHOLD = 8; // pixels
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Hook for detecting long-press gestures on touch/pointer devices.
|
|
19
|
-
*
|
|
20
|
-
* @param onLongPress - Callback fired after the delay if the pointer hasn't moved
|
|
21
|
-
* @param delayMs - How long to wait before firing (default 500ms)
|
|
22
|
-
*
|
|
23
|
-
* @returns Pointer event handlers to spread onto your element
|
|
24
|
-
*
|
|
25
|
-
* @example
|
|
26
|
-
* const { handlers } = useLongPress({ onLongPress: () => console.log('long press!') });
|
|
27
|
-
* return <div {...handlers}>Press and hold me</div>;
|
|
28
|
-
*/
|
|
29
|
-
export const useLongPress = ({
|
|
30
|
-
onLongPress,
|
|
31
|
-
delayMs = 500,
|
|
32
|
-
}: UseLongPressOptions): { handlers: LongPressHandlers } => {
|
|
33
|
-
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
|
-
const startPosRef = useRef<{ x: number; y: number } | null>(null);
|
|
35
|
-
|
|
36
|
-
const clearTimer = useCallback(() => {
|
|
37
|
-
if (timerRef.current) {
|
|
38
|
-
clearTimeout(timerRef.current);
|
|
39
|
-
timerRef.current = null;
|
|
40
|
-
}
|
|
41
|
-
startPosRef.current = null;
|
|
42
|
-
}, []);
|
|
43
|
-
|
|
44
|
-
const onPointerDown = useCallback(
|
|
45
|
-
(e: PointerEvent | React.PointerEvent) => {
|
|
46
|
-
clearTimer();
|
|
47
|
-
startPosRef.current = { x: e.clientX, y: e.clientY };
|
|
48
|
-
timerRef.current = setTimeout(() => {
|
|
49
|
-
onLongPress();
|
|
50
|
-
clearTimer();
|
|
51
|
-
}, delayMs);
|
|
52
|
-
},
|
|
53
|
-
[onLongPress, delayMs, clearTimer],
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
const onPointerMove = useCallback(
|
|
57
|
-
(e: PointerEvent | React.PointerEvent) => {
|
|
58
|
-
if (!startPosRef.current) return;
|
|
59
|
-
|
|
60
|
-
const dx = e.clientX - startPosRef.current.x;
|
|
61
|
-
const dy = e.clientY - startPosRef.current.y;
|
|
62
|
-
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
63
|
-
|
|
64
|
-
if (distance > MOVEMENT_THRESHOLD) {
|
|
65
|
-
clearTimer();
|
|
66
|
-
}
|
|
67
|
-
},
|
|
68
|
-
[clearTimer],
|
|
69
|
-
);
|
|
70
|
-
|
|
71
|
-
const onPointerUp = useCallback(() => {
|
|
72
|
-
clearTimer();
|
|
73
|
-
}, [clearTimer]);
|
|
74
|
-
|
|
75
|
-
const onPointerCancel = useCallback(() => {
|
|
76
|
-
clearTimer();
|
|
77
|
-
}, [clearTimer]);
|
|
78
|
-
|
|
79
|
-
return {
|
|
80
|
-
handlers: {
|
|
81
|
-
onPointerDown,
|
|
82
|
-
onPointerMove,
|
|
83
|
-
onPointerUp,
|
|
84
|
-
onPointerCancel,
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
};
|