@remit/web-client 0.0.321 → 0.0.323
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 +4 -4
- package/src/auth/SignInAgain.tsx +18 -0
- package/src/auth/amplify-config.test.ts +1 -107
- package/src/auth/auth-interceptor.test.ts +0 -6
- package/src/auth/cognito/cognito-token.test.ts +2 -4
- package/src/components/calendar/AgendaStrip.tsx +1 -1
- package/src/components/calendar/CalendarWorkspace.tsx +1 -1
- package/src/components/calendar/ChangeCalendarSuggestion.tsx +1 -1
- package/src/components/calendar/OpenCalendarEvent.tsx +1 -18
- package/src/components/compose/ComposeForm.tsx +11 -6
- package/src/components/compose/FullCompose.tsx +1 -1
- package/src/components/compose/MobileComposeSheet.tsx +1 -2
- package/src/components/compose/compose.stories.tsx +18 -0
- package/src/components/compose/draft-discard-failure.render.test.ts +138 -0
- package/src/components/config-import/config-import.fixtures.ts +8 -9
- package/src/components/config-import/config-import.stories.tsx +2 -2
- package/src/components/layout/MailTopBar.tsx +3 -0
- package/src/components/mail/BriefPane.tsx +0 -3
- package/src/components/mail/ConversationView.tsx +1 -3
- package/src/components/mail/DailyBrief.tsx +5 -28
- package/src/components/mail/FlaggedList.tsx +3 -4
- package/src/components/mail/FlaggedPane.tsx +0 -3
- package/src/components/mail/IntelligenceDrawer.tsx +0 -3
- package/src/components/mail/IntelligencePane.render.test.ts +0 -1
- package/src/components/mail/IntelligencePane.test.ts +1 -95
- package/src/components/mail/IntelligencePane.tsx +3 -93
- package/src/components/mail/MailListHeader.tsx +12 -43
- package/src/components/mail/MailNav.tsx +6 -2
- package/src/components/mail/MailViewChrome.tsx +1 -11
- package/src/components/mail/MailboxPane.tsx +4 -34
- package/src/components/mail/MessageActionMenu.tsx +6 -6
- package/src/components/mail/MessageAttachments.tsx +35 -0
- package/src/components/mail/MessageBody.tsx +1 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +0 -30
- package/src/components/mail/MessageCard.tsx +16 -4
- package/src/components/mail/MessageList.run-reporting.test.ts +2 -46
- package/src/components/mail/MessageList.tsx +23 -11
- package/src/components/mail/MessageRow.render.test.ts +0 -7
- package/src/components/mail/MoveToTrigger.tsx +1 -1
- package/src/components/mail/RawMessageView.tsx +1 -1
- package/src/components/mail/SelectionWizardHost.search-entry.test.ts +0 -1
- package/src/components/mail/SelectionWizardHost.tsx +16 -102
- package/src/components/mail/authenticity-warning.stories.tsx +0 -21
- package/src/components/mail/daily-brief.stories.tsx +31 -17
- package/src/components/mail/enter-cursor-late-open.render.test.ts +197 -0
- package/src/components/mail/intelligence-drawer.stories.tsx +0 -2
- package/src/components/mail/message-gone.stories.tsx +44 -0
- package/src/components/mail/offer-times-reply.render.test.ts +25 -1
- package/src/components/mail/reclassify-remove-override.render.test.ts +2 -6
- package/src/components/overlay-escape.render.test.ts +7 -3
- package/src/components/settings/AccountFormPanel.render.test.ts +26 -3
- package/src/components/settings/AccountFormPanel.test.ts +1 -36
- package/src/components/settings/AccountFormPanel.tsx +5 -25
- package/src/components/settings/AccountServices.tsx +1 -1
- package/src/components/settings/AdvancedNavIcon.tsx +3 -3
- package/src/components/settings/CalendarFeedCard.tsx +2 -1
- package/src/components/settings/CalendarSubscribeCard.tsx +1 -1
- package/src/components/settings/CalendarSubscriptionStatus.tsx +1 -2
- package/src/components/settings/FilterActionError.tsx +1 -1
- package/src/components/settings/FilterEditor.render.test.ts +3 -27
- package/src/components/settings/FilterEditor.tsx +4 -24
- package/src/components/settings/FilterEditorSurface.tsx +0 -3
- package/src/components/settings/FiltersList.render.test.ts +5 -24
- package/src/components/settings/FiltersList.stories.tsx +0 -1
- package/src/components/settings/FiltersList.tsx +10 -35
- package/src/components/settings/WarningsNavIcon.tsx +12 -0
- package/src/components/settings/{QuarantinePanel.delimiter.test.ts → WarningsPanel.dom.test.ts} +64 -5
- package/src/components/settings/WarningsPanel.render.test.ts +56 -0
- package/src/components/settings/WarningsPanel.tsx +244 -0
- package/src/components/settings/accounts.stories.tsx +18 -0
- package/src/components/settings/advanced.stories.tsx +3 -125
- package/src/components/settings/settings-filter.stories.tsx +2 -42
- package/src/components/settings/warnings.stories.tsx +290 -0
- package/src/components/ui/ErrorBannerProvider.tsx +5 -3
- package/src/components/ui/error-banners.test.ts +1 -22
- package/src/components/ui/error-banners.ts +9 -20
- package/src/hooks/calendar/useCalendarCollectionWrites.ts +2 -1
- package/src/hooks/calendar/useCalendarSuggestions.ts +2 -1
- package/src/hooks/calendar/useCalendarWrites.ts +2 -1
- package/src/hooks/intelligence-calendar.render.test.ts +0 -1
- package/src/hooks/useDeleteMessages.test.ts +1 -42
- package/src/hooks/useFilters.test.ts +0 -8
- package/src/hooks/useFollowFocusOpen.render.test.ts +1 -11
- package/src/hooks/useIntelligenceData.test.ts +9 -47
- package/src/hooks/useIntelligenceData.ts +7 -100
- package/src/hooks/useLayoutTier.test.ts +0 -48
- package/src/hooks/useReportSpam.ts +1 -1
- package/src/hooks/useRescueCandidates.test.ts +0 -23
- package/src/hooks/useRulePreview.ts +8 -7
- package/src/hooks/useSearchTokenContext.ts +2 -3
- package/src/hooks/useStaleAccountSync.test.ts +0 -17
- package/src/hooks/useStarredThreads.test.ts +0 -10
- package/src/hooks/useTriggerSync.test.ts +0 -6
- package/src/hooks/useVisualViewport.render.test.ts +63 -0
- package/src/hooks/useWarningsAttention.ts +37 -0
- package/src/lib/account-order.test.ts +0 -10
- package/src/lib/adjacent-message.test.ts +0 -9
- package/src/lib/app-info.ts +0 -3
- package/src/lib/autodiscovery.test.ts +0 -47
- package/src/lib/bug-report.test.ts +38 -0
- package/src/lib/bug-report.ts +10 -7
- package/src/lib/bulk-action-copy.ts +1 -1
- package/src/lib/calendar-route.test.ts +0 -5
- package/src/lib/config-import.test.ts +3 -39
- package/src/lib/dedupe-thread-messages.test.ts +0 -4
- package/src/lib/desktop-breakpoint.test.ts +0 -8
- package/src/lib/drafts.test.ts +0 -23
- package/src/lib/fatal-error.test.ts +0 -19
- package/src/lib/folder-roles.test.ts +0 -4
- package/src/lib/hot-sync-window.test.ts +0 -5
- package/src/lib/list-header-chrome.ts +1 -1
- package/src/lib/mail-context.ts +1 -1
- package/src/lib/message-attachments.test.ts +77 -0
- package/src/lib/message-attachments.ts +31 -15
- package/src/lib/message-body-source.test.ts +21 -4
- package/src/lib/message-body-source.ts +5 -2
- package/src/lib/onboarding-completion.test.ts +0 -16
- package/src/lib/organize/filter-edit-model.test.ts +2 -32
- package/src/lib/organize/filter-edit-model.ts +6 -16
- package/src/lib/organize/organize-model.test.ts +7 -40
- package/src/lib/organize/organize-model.ts +16 -18
- package/src/lib/organize/rule-model.test.ts +24 -202
- package/src/lib/organize/rule-model.ts +19 -166
- package/src/lib/organize/search-to-rule.test.ts +2 -36
- package/src/lib/organize/search-to-rule.ts +1 -16
- package/src/lib/provider-presets.test.ts +1 -77
- package/src/lib/query-error-handler.test.ts +0 -61
- package/src/lib/query-escalation.integration.test.ts +4 -4
- package/src/lib/result-count.ts +1 -1
- package/src/lib/result-folder.ts +1 -1
- package/src/lib/rum-adapter.test.ts +0 -104
- package/src/lib/search-query.test.ts +0 -11
- package/src/lib/search-result.test.ts +2 -142
- package/src/lib/search-result.ts +3 -73
- package/src/lib/search-scope.test.ts +1 -92
- package/src/lib/search-scope.ts +0 -29
- package/src/lib/search-surface.ts +2 -2
- package/src/lib/search-token-index.test.ts +0 -8
- package/src/lib/search-tokens.ts +15 -16
- package/src/lib/starred-rows.test.ts +0 -4
- package/src/lib/sync-warning-entries.ts +33 -0
- package/src/lib/wizard-selection.ts +1 -1
- package/src/mocks/story-frame/mail-handlers.ts +12 -8
- package/src/mocks/story-frame/mail-world.ts +5 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/calendar/$view.$date/index.tsx +1 -1
- package/src/routes/calendar.tsx +2 -1
- package/src/routes/mail/brief.tsx +1 -1
- package/src/routes/mail/flagged.tsx +1 -1
- package/src/routes/mail.tsx +5 -3
- package/src/routes/settings/-accounts.test.ts +0 -5
- package/src/routes/settings/-filters.test.ts +0 -1
- package/src/routes/settings/-shared/help-copy.tsx +15 -0
- package/src/routes/settings/accounts.tsx +1 -1
- package/src/routes/settings/advanced.tsx +1 -4
- package/src/routes/settings/filters.tsx +1 -2
- package/src/routes/settings/folders.tsx +1 -1
- package/src/routes/settings/labels.tsx +1 -1
- package/src/routes/settings/senders.tsx +1 -1
- package/src/routes/settings/warnings.tsx +36 -0
- package/src/routes/settings.tsx +7 -0
- package/src/routing/wizard-history.test.ts +1 -45
- package/src/runtime-config.ts +5 -0
- package/src/test-support/fixtures.ts +1 -1
- package/src/components/compose/draft-discard-banner.test.ts +0 -76
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +0 -42
- package/src/components/mail/SpamRescue.render.test.ts +0 -35
- package/src/components/mail/intelligence-semantic-off.render.test.ts +0 -111
- package/src/components/mail/selection-surface-rollcall.test.ts +0 -270
- package/src/components/settings/QuarantinePanel.render.test.ts +0 -71
- package/src/components/settings/QuarantinePanel.tsx +0 -178
- package/src/components/ui/DropdownMenu.tsx +0 -99
- package/src/components/ui/EmptyState.tsx +0 -11
- package/src/components/ui/ErrorBanner.render.test.ts +0 -104
- package/src/components/ui/ErrorBanner.stories.tsx +0 -132
- package/src/components/ui/ErrorBanner.tsx +0 -106
- package/src/components/ui/ErrorBannerStack.tsx +0 -35
- package/src/components/ui/ErrorState.render.test.ts +0 -82
- package/src/components/ui/ErrorState.tsx +0 -80
- package/src/components/ui/KeyboardShortcutsModal.tsx +0 -113
- package/src/hooks/useMoveMessages.test.ts +0 -63
- package/src/hooks/useOrganizeWiden.test.ts +0 -217
- package/src/hooks/useOrganizeWiden.ts +0 -112
- package/src/hooks/useSemanticSearch.ts +0 -132
- package/src/hooks/useSemanticSearchEnabled.ts +0 -23
- package/src/hooks/useVisualViewport.test.ts +0 -126
- package/src/lib/organize/sender-fallback.test.ts +0 -52
- package/src/lib/organize/sender-fallback.ts +0 -38
- package/src/lib/rescue-telemetry.test.ts +0 -75
- package/src/router.test.ts +0 -81
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { describe, test } from "node:test";
|
|
3
|
-
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
-
import { removeMovedMessagesFromItems } from "./useMoveMessages.js";
|
|
5
|
-
|
|
6
|
-
const make = (
|
|
7
|
-
overrides: Partial<RemitImapThreadMessageResponse> & {
|
|
8
|
-
messageId: string;
|
|
9
|
-
threadMessageId: string;
|
|
10
|
-
},
|
|
11
|
-
): RemitImapThreadMessageResponse =>
|
|
12
|
-
({
|
|
13
|
-
threadId: "t1",
|
|
14
|
-
mailboxId: "mb1",
|
|
15
|
-
accountConfigId: "acc-1",
|
|
16
|
-
subject: "s",
|
|
17
|
-
fromName: "n",
|
|
18
|
-
fromEmail: "e",
|
|
19
|
-
sentDate: "2025-01-01T00:00:00Z",
|
|
20
|
-
snippet: "",
|
|
21
|
-
hasAttachment: false,
|
|
22
|
-
hasStars: false,
|
|
23
|
-
isRead: true,
|
|
24
|
-
...overrides,
|
|
25
|
-
}) as RemitImapThreadMessageResponse;
|
|
26
|
-
|
|
27
|
-
describe("removeMovedMessagesFromItems (#236)", () => {
|
|
28
|
-
test("drops items whose messageId is in the set", () => {
|
|
29
|
-
const items = [
|
|
30
|
-
make({ messageId: "m1", threadMessageId: "tm1" }),
|
|
31
|
-
make({ messageId: "m2", threadMessageId: "tm2" }),
|
|
32
|
-
make({ messageId: "m3", threadMessageId: "tm3" }),
|
|
33
|
-
];
|
|
34
|
-
const got = removeMovedMessagesFromItems(items, new Set(["m2"]));
|
|
35
|
-
assert.deepStrictEqual(
|
|
36
|
-
got.map((i) => i.messageId),
|
|
37
|
-
["m1", "m3"],
|
|
38
|
-
);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("returns the input unchanged when nothing matches", () => {
|
|
42
|
-
const items = [
|
|
43
|
-
make({ messageId: "m1", threadMessageId: "tm1" }),
|
|
44
|
-
make({ messageId: "m2", threadMessageId: "tm2" }),
|
|
45
|
-
];
|
|
46
|
-
const got = removeMovedMessagesFromItems(items, new Set(["nope"]));
|
|
47
|
-
assert.equal(got.length, 2);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("returns an empty array when all items match", () => {
|
|
51
|
-
const items = [
|
|
52
|
-
make({ messageId: "m1", threadMessageId: "tm1" }),
|
|
53
|
-
make({ messageId: "m2", threadMessageId: "tm2" }),
|
|
54
|
-
];
|
|
55
|
-
const got = removeMovedMessagesFromItems(items, new Set(["m1", "m2"]));
|
|
56
|
-
assert.deepStrictEqual(got, []);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("returns an empty array for empty input", () => {
|
|
60
|
-
const got = removeMovedMessagesFromItems([], new Set(["m1"]));
|
|
61
|
-
assert.deepStrictEqual(got, []);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The widen step's two-phase preview. On a semantic-capable deployment it runs
|
|
3
|
-
* the anchor preview once and stops. On a deployment that reports
|
|
4
|
-
* `semanticUnavailable` (no vector pipeline — semantic-capability.ts) it
|
|
5
|
-
* re-previews with what the whole selection agrees on (#458) — one `From` or
|
|
6
|
-
* `FromDomain` clause when senders agree, the shared subject when they don't,
|
|
7
|
-
* combined with `Or`, no anchor — and reports the literal match count, so
|
|
8
|
-
* every commit scope acts on the same set the fallback derived.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import assert from "node:assert/strict";
|
|
12
|
-
import { afterEach, describe, it } from "node:test";
|
|
13
|
-
import { createElement, useEffect } from "react";
|
|
14
|
-
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
15
|
-
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
16
|
-
import { useOrganizeWiden } from "./useOrganizeWiden";
|
|
17
|
-
|
|
18
|
-
let harness: DomHarness | undefined;
|
|
19
|
-
let http: HttpMock | undefined;
|
|
20
|
-
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
harness?.close();
|
|
23
|
-
harness = undefined;
|
|
24
|
-
http?.restore();
|
|
25
|
-
http = undefined;
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
type Widen = ReturnType<typeof useOrganizeWiden>;
|
|
29
|
-
type Responder = Parameters<typeof mockFetch>[0];
|
|
30
|
-
|
|
31
|
-
const PREVIEW_PATH = "/organize/preview";
|
|
32
|
-
|
|
33
|
-
const mount = (
|
|
34
|
-
senders: string[],
|
|
35
|
-
responder: Responder,
|
|
36
|
-
subjects: string[] = [],
|
|
37
|
-
) => {
|
|
38
|
-
http = mockFetch(responder);
|
|
39
|
-
harness = createDomHarness();
|
|
40
|
-
const holder: { current: Widen | undefined } = { current: undefined };
|
|
41
|
-
function Probe() {
|
|
42
|
-
const widen = useOrganizeWiden("acc-1", "msg-1", senders, subjects);
|
|
43
|
-
holder.current = widen;
|
|
44
|
-
const { preview } = widen;
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
preview();
|
|
47
|
-
}, [preview]);
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
harness.renderApp(createElement(Probe));
|
|
51
|
-
return holder;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
async function settle(minCalls: number): Promise<void> {
|
|
55
|
-
const dom = harness;
|
|
56
|
-
const mock = http;
|
|
57
|
-
if (!dom || !mock) throw new Error("not mounted");
|
|
58
|
-
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
59
|
-
await dom.flush();
|
|
60
|
-
if (mock.to(PREVIEW_PATH).length >= minCalls) {
|
|
61
|
-
await dom.flush();
|
|
62
|
-
await dom.wait(1);
|
|
63
|
-
await dom.flush();
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
await dom.wait(1);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
describe("useOrganizeWiden — semantic-capable deployment", () => {
|
|
71
|
-
it("previews the anchor once and reports the semantic match, no fallback", async () => {
|
|
72
|
-
const holder = mount([], () => ({
|
|
73
|
-
matchedCount: 42,
|
|
74
|
-
messageIds: ["a", "b"],
|
|
75
|
-
semanticUnavailable: false,
|
|
76
|
-
}));
|
|
77
|
-
await settle(1);
|
|
78
|
-
|
|
79
|
-
assert.equal(http?.to(PREVIEW_PATH).length, 1);
|
|
80
|
-
const [call] = http?.to(PREVIEW_PATH) ?? [];
|
|
81
|
-
assert.equal(call.body?.anchorMessageId, "msg-1");
|
|
82
|
-
assert.deepEqual(call.body?.literalClauses, []);
|
|
83
|
-
|
|
84
|
-
const widen = holder.current;
|
|
85
|
-
assert.equal(widen?.matchedCount, 42);
|
|
86
|
-
assert.equal(widen?.semanticUnavailable, false);
|
|
87
|
-
assert.equal(widen?.senderFallback, false);
|
|
88
|
-
assert.deepEqual(widen?.matchPredicate, {
|
|
89
|
-
anchorMessageId: "msg-1",
|
|
90
|
-
matchOperator: "And",
|
|
91
|
-
literalClauses: [],
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe("useOrganizeWiden — no vector pipeline, senders present", () => {
|
|
97
|
-
it("re-previews with distinct sender From clauses combined with Or and no anchor", async () => {
|
|
98
|
-
const holder = mount(
|
|
99
|
-
["npm@github.com", "npm@github.com", "a@x.com"],
|
|
100
|
-
(call) =>
|
|
101
|
-
call.body?.anchorMessageId
|
|
102
|
-
? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
|
|
103
|
-
: {
|
|
104
|
-
matchedCount: 128,
|
|
105
|
-
messageIds: ["m"],
|
|
106
|
-
semanticUnavailable: false,
|
|
107
|
-
},
|
|
108
|
-
);
|
|
109
|
-
await settle(2);
|
|
110
|
-
|
|
111
|
-
const calls = http?.to(PREVIEW_PATH) ?? [];
|
|
112
|
-
assert.equal(calls.length, 2);
|
|
113
|
-
// The second preview is the literal fallback: no anchor, Or, distinct
|
|
114
|
-
// sender From clauses.
|
|
115
|
-
const second = calls[1];
|
|
116
|
-
assert.equal(second.body?.anchorMessageId, undefined);
|
|
117
|
-
assert.equal(second.body?.matchOperator, "Or");
|
|
118
|
-
assert.deepEqual(second.body?.literalClauses, [
|
|
119
|
-
{ field: "From", value: "npm@github.com" },
|
|
120
|
-
{ field: "From", value: "a@x.com" },
|
|
121
|
-
]);
|
|
122
|
-
|
|
123
|
-
const widen = holder.current;
|
|
124
|
-
assert.equal(widen?.matchedCount, 128);
|
|
125
|
-
assert.equal(widen?.semanticUnavailable, true);
|
|
126
|
-
assert.equal(widen?.senderFallback, true);
|
|
127
|
-
assert.deepEqual(widen?.senders, ["npm@github.com", "a@x.com"]);
|
|
128
|
-
assert.deepEqual(widen?.matchPredicate, {
|
|
129
|
-
matchOperator: "Or",
|
|
130
|
-
literalClauses: [
|
|
131
|
-
{ field: "From", value: "npm@github.com" },
|
|
132
|
-
{ field: "From", value: "a@x.com" },
|
|
133
|
-
],
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
describe("useOrganizeWiden — no vector pipeline, mixed senders sharing a subject", () => {
|
|
139
|
-
it("re-previews with the shared subject rather than one From clause per sender (#458)", async () => {
|
|
140
|
-
const holder = mount(
|
|
141
|
-
["billing@acme.test", "accounts@globex.test"],
|
|
142
|
-
(call) =>
|
|
143
|
-
call.body?.anchorMessageId
|
|
144
|
-
? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
|
|
145
|
-
: {
|
|
146
|
-
matchedCount: 64,
|
|
147
|
-
messageIds: ["m"],
|
|
148
|
-
semanticUnavailable: false,
|
|
149
|
-
},
|
|
150
|
-
["Invoice 1841", "Invoice 1902"],
|
|
151
|
-
);
|
|
152
|
-
await settle(2);
|
|
153
|
-
|
|
154
|
-
const calls = http?.to(PREVIEW_PATH) ?? [];
|
|
155
|
-
assert.equal(calls.length, 2);
|
|
156
|
-
const second = calls[1];
|
|
157
|
-
assert.equal(second.body?.anchorMessageId, undefined);
|
|
158
|
-
assert.equal(second.body?.matchOperator, "Or");
|
|
159
|
-
assert.deepEqual(second.body?.literalClauses, [
|
|
160
|
-
{ field: "Subject", value: "Invoice" },
|
|
161
|
-
]);
|
|
162
|
-
|
|
163
|
-
const widen = holder.current;
|
|
164
|
-
assert.deepEqual(widen?.matchPredicate, {
|
|
165
|
-
matchOperator: "Or",
|
|
166
|
-
literalClauses: [{ field: "Subject", value: "Invoice" }],
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
describe("useOrganizeWiden — no vector pipeline, no senders", () => {
|
|
172
|
-
it("does not re-preview and stays capability-absent with the anchor predicate", async () => {
|
|
173
|
-
const holder = mount([], () => ({
|
|
174
|
-
matchedCount: 0,
|
|
175
|
-
messageIds: [],
|
|
176
|
-
semanticUnavailable: true,
|
|
177
|
-
}));
|
|
178
|
-
await settle(1);
|
|
179
|
-
// Give any spurious second preview a chance to fire before asserting none did.
|
|
180
|
-
await harness?.flush();
|
|
181
|
-
|
|
182
|
-
assert.equal(http?.to(PREVIEW_PATH).length, 1);
|
|
183
|
-
const widen = holder.current;
|
|
184
|
-
assert.equal(widen?.semanticUnavailable, true);
|
|
185
|
-
assert.equal(widen?.senderFallback, false);
|
|
186
|
-
assert.deepEqual(widen?.senders, []);
|
|
187
|
-
assert.equal(widen?.matchPredicate.anchorMessageId, "msg-1");
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("still re-previews on a shared subject alone (#458)", async () => {
|
|
191
|
-
const holder = mount(
|
|
192
|
-
[],
|
|
193
|
-
(call) =>
|
|
194
|
-
call.body?.anchorMessageId
|
|
195
|
-
? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
|
|
196
|
-
: {
|
|
197
|
-
matchedCount: 12,
|
|
198
|
-
messageIds: ["m"],
|
|
199
|
-
semanticUnavailable: false,
|
|
200
|
-
},
|
|
201
|
-
["Invoice 1841", "Invoice 1902"],
|
|
202
|
-
);
|
|
203
|
-
await settle(2);
|
|
204
|
-
|
|
205
|
-
const calls = http?.to(PREVIEW_PATH) ?? [];
|
|
206
|
-
assert.equal(calls.length, 2);
|
|
207
|
-
const second = calls[1];
|
|
208
|
-
assert.equal(second.body?.anchorMessageId, undefined);
|
|
209
|
-
assert.deepEqual(second.body?.literalClauses, [
|
|
210
|
-
{ field: "Subject", value: "Invoice" },
|
|
211
|
-
]);
|
|
212
|
-
|
|
213
|
-
const widen = holder.current;
|
|
214
|
-
assert.equal(widen?.semanticUnavailable, true);
|
|
215
|
-
assert.equal(widen?.senderFallback, true);
|
|
216
|
-
});
|
|
217
|
-
});
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import { derivePropertyClauses, distinctSenders } from "@remit/ui";
|
|
3
|
-
import { useMutation } from "@tanstack/react-query";
|
|
4
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
|
-
import { buildOrganizeInput } from "@/lib/organize/organize-model";
|
|
6
|
-
import {
|
|
7
|
-
buildSenderFallbackDraft,
|
|
8
|
-
type OrganizeMatchPredicate,
|
|
9
|
-
} from "@/lib/organize/sender-fallback";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* "Select similar messages" (the widen step) with a selection-agreement
|
|
13
|
-
* fallback for deployments without the vector pipeline.
|
|
14
|
-
*
|
|
15
|
-
* The first preview runs the semantic anchor (POST /organize/preview). When the
|
|
16
|
-
* server reports `semanticUnavailable` — no vector pipeline on this deployment
|
|
17
|
-
* (semantic-capability.ts) — the widen re-previews with the literal vocabulary
|
|
18
|
-
* that matches vector-free: what the whole selection agrees on (#458) — one
|
|
19
|
-
* sender/domain clause, or the subject the selection shares when senders don't
|
|
20
|
-
* — combined with `Or`, no anchor. The counts shown, the one-time back-apply,
|
|
21
|
-
* and any standing filter then all carry that same literal predicate, so the
|
|
22
|
-
* previewed set equals the set every commit scope acts on.
|
|
23
|
-
*
|
|
24
|
-
* A semantic-capable deployment never fires the second preview and keeps exactly
|
|
25
|
-
* the anchor behaviour.
|
|
26
|
-
*/
|
|
27
|
-
export const useOrganizeWiden = (
|
|
28
|
-
accountId: string | undefined,
|
|
29
|
-
anchorMessageId: string | undefined,
|
|
30
|
-
senders: readonly string[],
|
|
31
|
-
subjects: readonly string[],
|
|
32
|
-
) => {
|
|
33
|
-
const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
34
|
-
const { mutate, reset: mutationReset } = mutation;
|
|
35
|
-
const [fellBack, setFellBack] = useState(false);
|
|
36
|
-
|
|
37
|
-
const distinct = useMemo(() => distinctSenders(senders), [senders]);
|
|
38
|
-
const agreementClauses = useMemo(
|
|
39
|
-
() => derivePropertyClauses(distinct, subjects),
|
|
40
|
-
[distinct, subjects],
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
const preview = useCallback(() => {
|
|
44
|
-
if (!accountId || !anchorMessageId) return;
|
|
45
|
-
setFellBack(false);
|
|
46
|
-
mutate({
|
|
47
|
-
path: { accountId },
|
|
48
|
-
body: buildOrganizeInput({
|
|
49
|
-
anchorMessageId,
|
|
50
|
-
matchOperator: "And",
|
|
51
|
-
literalClauses: [],
|
|
52
|
-
}),
|
|
53
|
-
});
|
|
54
|
-
}, [accountId, anchorMessageId, mutate]);
|
|
55
|
-
|
|
56
|
-
const reset = useCallback(() => {
|
|
57
|
-
setFellBack(false);
|
|
58
|
-
mutationReset();
|
|
59
|
-
}, [mutationReset]);
|
|
60
|
-
|
|
61
|
-
const data = mutation.data;
|
|
62
|
-
// The first preview came back capability-absent and the selection agrees on
|
|
63
|
-
// something to match on — a sender, a domain, or just a shared subject, not
|
|
64
|
-
// senders alone (#458): re-preview with the literal clauses before showing
|
|
65
|
-
// any count, so the count the user sees is the agreement's match count,
|
|
66
|
-
// never a flash of the empty semantic result.
|
|
67
|
-
const willFallBack =
|
|
68
|
-
data?.semanticUnavailable === true &&
|
|
69
|
-
!fellBack &&
|
|
70
|
-
agreementClauses.length > 0;
|
|
71
|
-
|
|
72
|
-
useEffect(() => {
|
|
73
|
-
if (!willFallBack || !accountId) return;
|
|
74
|
-
setFellBack(true);
|
|
75
|
-
mutate({
|
|
76
|
-
path: { accountId },
|
|
77
|
-
body: buildOrganizeInput(buildSenderFallbackDraft(distinct, subjects)),
|
|
78
|
-
});
|
|
79
|
-
}, [willFallBack, accountId, distinct, subjects, mutate]);
|
|
80
|
-
|
|
81
|
-
// The literal re-preview has no anchor, so its response reports
|
|
82
|
-
// `semanticUnavailable: false`; `fellBack` is what remembers the capability
|
|
83
|
-
// was absent once the fallback has run.
|
|
84
|
-
const capabilityAbsent = fellBack || (data?.semanticUnavailable ?? false);
|
|
85
|
-
const isPending = mutation.isPending || willFallBack;
|
|
86
|
-
|
|
87
|
-
const matchPredicate: OrganizeMatchPredicate = fellBack
|
|
88
|
-
? { matchOperator: "Or", literalClauses: agreementClauses }
|
|
89
|
-
: {
|
|
90
|
-
...(anchorMessageId ? { anchorMessageId } : {}),
|
|
91
|
-
matchOperator: "And",
|
|
92
|
-
literalClauses: [],
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
return {
|
|
96
|
-
preview,
|
|
97
|
-
reset,
|
|
98
|
-
matchedCount: isPending ? undefined : data?.matchedCount,
|
|
99
|
-
messageIds: isPending ? undefined : data?.messageIds,
|
|
100
|
-
// The deployment ships no vector pipeline — the widen matched on senders
|
|
101
|
-
// (or, with no senders, on nothing) rather than semantic similarity.
|
|
102
|
-
semanticUnavailable: capabilityAbsent,
|
|
103
|
-
// The widen fell back to sender matching and produced a usable literal
|
|
104
|
-
// match set: the commit scopes reach it, and the copy names the senders.
|
|
105
|
-
senderFallback: fellBack,
|
|
106
|
-
senders: distinct,
|
|
107
|
-
matchPredicate,
|
|
108
|
-
isPending,
|
|
109
|
-
isError: mutation.isError,
|
|
110
|
-
error: mutation.error,
|
|
111
|
-
};
|
|
112
|
-
};
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import { semanticSearchOperationsSemanticSearchOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import type { RemitImapSemanticSearchResult } from "@remit/api-http-client/types.gen.ts";
|
|
3
|
-
import { MessageCategory } from "@remit/domain-enums";
|
|
4
|
-
import { useQuery } from "@tanstack/react-query";
|
|
5
|
-
import { useMailContext } from "@/lib/mail-context";
|
|
6
|
-
import { normalizeSearchQuery } from "@/lib/search-query";
|
|
7
|
-
import { semanticMailboxScope } from "@/lib/search-scope";
|
|
8
|
-
import { parseSearchTokens, type SearchToken } from "@/lib/search-tokens";
|
|
9
|
-
import { useBrowsedList } from "@/routing";
|
|
10
|
-
import { useSearchTokenContext } from "./useSearchTokenContext";
|
|
11
|
-
|
|
12
|
-
/** Cap the "Related" section; the literal "Top matches" is the primary surface. */
|
|
13
|
-
const SEMANTIC_RESULT_LIMIT = 20;
|
|
14
|
-
|
|
15
|
-
const MESSAGE_CATEGORIES = new Set<string>(Object.values(MessageCategory));
|
|
16
|
-
|
|
17
|
-
/** Narrow a filter-chip id to a real category, or `undefined` for `"all"` / any unknown value. */
|
|
18
|
-
const toCategoryParam = (
|
|
19
|
-
filterCategory: string | undefined,
|
|
20
|
-
): (typeof MessageCategory)[keyof typeof MessageCategory] | undefined =>
|
|
21
|
-
filterCategory !== undefined && MESSAGE_CATEGORIES.has(filterCategory)
|
|
22
|
-
? (filterCategory as (typeof MessageCategory)[keyof typeof MessageCategory])
|
|
23
|
-
: undefined;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* `isRead` for the read-state tokens. `is:unread` and `is:read` are opposites,
|
|
27
|
-
* so a query carrying both asks for nothing; unread wins, being the narrower
|
|
28
|
-
* ask and the one the chips also offer.
|
|
29
|
-
*/
|
|
30
|
-
const readStateParam = (tokens: SearchToken[]): boolean | undefined => {
|
|
31
|
-
if (tokens.some((t) => t.type === "isUnread")) return false;
|
|
32
|
-
if (tokens.some((t) => t.type === "isRead")) return true;
|
|
33
|
-
return undefined;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
interface UseSemanticSearchParams {
|
|
37
|
-
/**
|
|
38
|
-
* Restrict results to a single mailbox. A mailbox route pins the scope to its
|
|
39
|
-
* own mailbox regardless of what is passed here, so this only carries a scope
|
|
40
|
-
* a route does not already imply.
|
|
41
|
-
*/
|
|
42
|
-
mailboxId?: string;
|
|
43
|
-
/**
|
|
44
|
-
* Restrict results to a single header-derived category. The sentinel `"all"`
|
|
45
|
-
* means no category scope — the param is omitted from the request entirely.
|
|
46
|
-
*/
|
|
47
|
-
filterCategory?: string;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Fetch semantic-search hits for the active query (one source of truth: the
|
|
52
|
-
* debounced `searchQuery` in `MailContext`). Scoping mirrors the literal search
|
|
53
|
-
* and is taken from the route (`semanticMailboxScope`): no chip means global, a
|
|
54
|
-
* chip means this engine respects it like every other. Disabled until the query
|
|
55
|
-
* is non-empty so an empty field issues no request.
|
|
56
|
-
*
|
|
57
|
-
* Filter tokens (`has:attachment`, `is:unread`/`is:read`, `is:starred`,
|
|
58
|
-
* `category:`, `before:`/`after:`) parsed from the query map onto the search
|
|
59
|
-
* API's own filter params. `from:`, `subject:` and `account:` have no
|
|
60
|
-
* equivalent on `GET /search/semantic` (no sender, subject or
|
|
61
|
-
* account filter) — all still render as chips and narrow the literal engine,
|
|
62
|
-
* but never reach the semantic request (`account:` is a documented gap, see
|
|
63
|
-
* doc/design/flows/06-search.md — the semantic index is per account config).
|
|
64
|
-
* `in:` resolves to a mailboxId, so typing `in:archive` re-scopes the search
|
|
65
|
-
* from the unscoped daily brief. It cannot contradict a scoped view because
|
|
66
|
-
* `useSearchTokenContext` does not resolve the term there at all — the term
|
|
67
|
-
* stays free text and no engine, and no chip, treats it as a filter.
|
|
68
|
-
*
|
|
69
|
-
* With no resolved mailbox the request spans every mailbox of every account the
|
|
70
|
-
* caller owns: the backend partitions the vector index by accountConfigId (one
|
|
71
|
-
* per signed-in user, not per mail account), so unscoped is genuinely global.
|
|
72
|
-
*/
|
|
73
|
-
export function useSemanticSearch({
|
|
74
|
-
mailboxId,
|
|
75
|
-
filterCategory,
|
|
76
|
-
}: UseSemanticSearchParams = {}): {
|
|
77
|
-
hits: RemitImapSemanticSearchResult[];
|
|
78
|
-
isLoading: boolean;
|
|
79
|
-
} {
|
|
80
|
-
const { searchQuery } = useMailContext();
|
|
81
|
-
const tokenContext = useSearchTokenContext();
|
|
82
|
-
const browsed = useBrowsedList();
|
|
83
|
-
const normalizedQuery = normalizeSearchQuery(searchQuery);
|
|
84
|
-
const { freeText, tokens } = parseSearchTokens(normalizedQuery, tokenContext);
|
|
85
|
-
// `freeText`, not the raw query: a query of nothing but tokens
|
|
86
|
-
// (`has:attachment`, `in:archive`) parses to empty free text, and asking a
|
|
87
|
-
// vector index what an empty string means has no answer to give. The literal
|
|
88
|
-
// engines still apply those tokens; the semantic section simply has nothing
|
|
89
|
-
// to rank.
|
|
90
|
-
const enabled = freeText.length > 0;
|
|
91
|
-
|
|
92
|
-
// The chip wins over the token: it is the visible narrowing, and the two can
|
|
93
|
-
// only disagree when the user set both.
|
|
94
|
-
const categoryToken = tokens.find((t) => t.type === "category");
|
|
95
|
-
const category = toCategoryParam(filterCategory) ?? categoryToken?.category;
|
|
96
|
-
const hasAttachment = tokens.some((t) => t.type === "hasAttachment")
|
|
97
|
-
? true
|
|
98
|
-
: undefined;
|
|
99
|
-
const hasStars = tokens.some((t) => t.type === "isStarred")
|
|
100
|
-
? true
|
|
101
|
-
: undefined;
|
|
102
|
-
const isRead = readStateParam(tokens);
|
|
103
|
-
const afterToken = tokens.find((t) => t.type === "after");
|
|
104
|
-
const beforeToken = tokens.find((t) => t.type === "before");
|
|
105
|
-
const inToken = tokens.find((t) => t.type === "in");
|
|
106
|
-
// The route decides the scope, not the call site — see `semanticMailboxScope`.
|
|
107
|
-
const effectiveMailboxId = semanticMailboxScope({
|
|
108
|
-
browsed,
|
|
109
|
-
callerMailboxId: mailboxId,
|
|
110
|
-
inTokenMailboxId: inToken?.mailboxId,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const { data, isLoading } = useQuery({
|
|
114
|
-
...semanticSearchOperationsSemanticSearchOptions({
|
|
115
|
-
query: {
|
|
116
|
-
query: freeText,
|
|
117
|
-
mailboxId: effectiveMailboxId,
|
|
118
|
-
limit: SEMANTIC_RESULT_LIMIT,
|
|
119
|
-
...(category !== undefined ? { category } : {}),
|
|
120
|
-
...(hasAttachment !== undefined ? { hasAttachment } : {}),
|
|
121
|
-
...(hasStars !== undefined ? { hasStars } : {}),
|
|
122
|
-
...(isRead !== undefined ? { isRead } : {}),
|
|
123
|
-
...(afterToken ? { sentDateFrom: afterToken.epochSeconds } : {}),
|
|
124
|
-
...(beforeToken ? { sentDateTo: beforeToken.epochSeconds } : {}),
|
|
125
|
-
},
|
|
126
|
-
}),
|
|
127
|
-
enabled,
|
|
128
|
-
staleTime: 60_000,
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
return { hits: data?.items ?? [], isLoading: enabled && isLoading };
|
|
132
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import { useQuery } from "@tanstack/react-query";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Whether this instance stores message vectors (#1068).
|
|
6
|
-
*
|
|
7
|
-
* A property of the deployment, so it rides the config read every surface
|
|
8
|
-
* already makes rather than a probe of its own: the semantic surfaces need the
|
|
9
|
-
* answer before they have a query to send, and an empty semantic result is
|
|
10
|
-
* indistinguishable from a mailbox with nothing similar in it.
|
|
11
|
-
*
|
|
12
|
-
* `undefined` while the config is in flight, and for a server that predates the
|
|
13
|
-
* field. Callers must therefore branch on `=== false`, so an instance whose
|
|
14
|
-
* answer has not arrived shows what it showed before rather than an off state
|
|
15
|
-
* it may not be in.
|
|
16
|
-
*/
|
|
17
|
-
export const useSemanticSearchEnabled = (): boolean | undefined => {
|
|
18
|
-
const { data } = useQuery({
|
|
19
|
-
...configOperationsGetConfigOptions(),
|
|
20
|
-
staleTime: Infinity,
|
|
21
|
-
});
|
|
22
|
-
return data?.semanticSearchEnabled;
|
|
23
|
-
};
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { beforeEach, describe, test } from "node:test";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Unit test for useVisualViewport hook logic.
|
|
6
|
-
* Tests the core behaviour without React.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const KEYBOARD_THRESHOLD = 150;
|
|
10
|
-
|
|
11
|
-
interface ViewportState {
|
|
12
|
-
viewportHeight: number;
|
|
13
|
-
isKeyboardOpen: boolean;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Simulates the viewport-tracking logic from useVisualViewport.
|
|
18
|
-
*/
|
|
19
|
-
class VisualViewportSimulator {
|
|
20
|
-
private innerHeight: number;
|
|
21
|
-
private viewportHeight: number;
|
|
22
|
-
private listeners: Array<() => void> = [];
|
|
23
|
-
|
|
24
|
-
constructor(innerHeight: number) {
|
|
25
|
-
this.innerHeight = innerHeight;
|
|
26
|
-
this.viewportHeight = innerHeight;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
getState(): ViewportState {
|
|
30
|
-
return {
|
|
31
|
-
viewportHeight: this.viewportHeight,
|
|
32
|
-
isKeyboardOpen:
|
|
33
|
-
this.innerHeight - this.viewportHeight > KEYBOARD_THRESHOLD,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Simulate a visualViewport resize (e.g. keyboard appearing). */
|
|
38
|
-
resize(newViewportHeight: number): ViewportState {
|
|
39
|
-
this.viewportHeight = newViewportHeight;
|
|
40
|
-
for (const fn of this.listeners) fn();
|
|
41
|
-
return this.getState();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
addListener(fn: () => void): void {
|
|
45
|
-
this.listeners.push(fn);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
removeListener(fn: () => void): void {
|
|
49
|
-
this.listeners = this.listeners.filter((l) => l !== fn);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
get listenerCount(): number {
|
|
53
|
-
return this.listeners.length;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
describe("useVisualViewport logic", () => {
|
|
58
|
-
let sim: VisualViewportSimulator;
|
|
59
|
-
|
|
60
|
-
beforeEach(() => {
|
|
61
|
-
// iPhone-like viewport: 844px tall
|
|
62
|
-
sim = new VisualViewportSimulator(844);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("initial state has keyboard closed", () => {
|
|
66
|
-
const state = sim.getState();
|
|
67
|
-
assert.strictEqual(state.viewportHeight, 844);
|
|
68
|
-
assert.strictEqual(state.isKeyboardOpen, false);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("keyboard open when viewport shrinks by more than threshold", () => {
|
|
72
|
-
// Typical iOS keyboard: viewport shrinks to ~400px
|
|
73
|
-
const state = sim.resize(400);
|
|
74
|
-
assert.strictEqual(state.isKeyboardOpen, true);
|
|
75
|
-
assert.strictEqual(state.viewportHeight, 400);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("keyboard stays closed for small viewport changes", () => {
|
|
79
|
-
// Address bar collapsing — only ~50px change
|
|
80
|
-
const state = sim.resize(794);
|
|
81
|
-
assert.strictEqual(state.isKeyboardOpen, false);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("keyboard closes when viewport restores", () => {
|
|
85
|
-
sim.resize(400);
|
|
86
|
-
const state = sim.resize(844);
|
|
87
|
-
assert.strictEqual(state.isKeyboardOpen, false);
|
|
88
|
-
assert.strictEqual(state.viewportHeight, 844);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("exactly at threshold boundary — not open", () => {
|
|
92
|
-
// innerHeight - viewportHeight === 150, not > 150
|
|
93
|
-
const state = sim.resize(844 - KEYBOARD_THRESHOLD);
|
|
94
|
-
assert.strictEqual(state.isKeyboardOpen, false);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("one pixel past threshold — keyboard open", () => {
|
|
98
|
-
const state = sim.resize(844 - KEYBOARD_THRESHOLD - 1);
|
|
99
|
-
assert.strictEqual(state.isKeyboardOpen, true);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("listeners are called on resize", () => {
|
|
103
|
-
let callCount = 0;
|
|
104
|
-
sim.addListener(() => {
|
|
105
|
-
callCount++;
|
|
106
|
-
});
|
|
107
|
-
sim.resize(400);
|
|
108
|
-
sim.resize(844);
|
|
109
|
-
assert.strictEqual(callCount, 2);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("removeListener stops notifications", () => {
|
|
113
|
-
let callCount = 0;
|
|
114
|
-
const fn = () => {
|
|
115
|
-
callCount++;
|
|
116
|
-
};
|
|
117
|
-
sim.addListener(fn);
|
|
118
|
-
sim.resize(400);
|
|
119
|
-
assert.strictEqual(callCount, 1);
|
|
120
|
-
|
|
121
|
-
sim.removeListener(fn);
|
|
122
|
-
sim.resize(844);
|
|
123
|
-
assert.strictEqual(callCount, 1);
|
|
124
|
-
assert.strictEqual(sim.listenerCount, 0);
|
|
125
|
-
});
|
|
126
|
-
});
|