@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,270 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The roll-call no run can take: which surfaces exist, and which function each
|
|
3
|
-
* one's verbs name.
|
|
4
|
-
*
|
|
5
|
-
* Everything a selection surface *does* — the review screen naming what a verb
|
|
6
|
-
* covers, a run reaching the mail server — is driven end to end by
|
|
7
|
-
* `packages/e2e`. What no run observes is a surface that was never wired: a
|
|
8
|
-
* fourth pane growing its own direct handler while every other route was moved,
|
|
9
|
-
* a fourth list rendering the make-filter row with no wizard behind it, or a
|
|
10
|
-
* verb on a bar nobody presses in a spec quietly keeping a direct call. The
|
|
11
|
-
* suite has locators for Delete, Move and Organize and none for Junk or
|
|
12
|
-
* Mark-read, so three of the five are reachable only from here.
|
|
13
|
-
*
|
|
14
|
-
* So this reads the source, and reads it for the census: who raises a bar, what
|
|
15
|
-
* each of its verbs is wired to, who declares a keyboard verb over a selection,
|
|
16
|
-
* who renders the make-filter row. Every census is discovered from the
|
|
17
|
-
* directory rather than listed, so a fourth surface is a failure here until it
|
|
18
|
-
* is wired like the other three.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import assert from "node:assert/strict";
|
|
22
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
23
|
-
import { dirname, resolve } from "node:path";
|
|
24
|
-
import { describe, it } from "node:test";
|
|
25
|
-
import { fileURLToPath } from "node:url";
|
|
26
|
-
|
|
27
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
-
const read = (file: string): string =>
|
|
29
|
-
readFileSync(resolve(here, file), "utf8");
|
|
30
|
-
|
|
31
|
-
const sourceFiles = (): string[] =>
|
|
32
|
-
readdirSync(here).filter((file) => file.endsWith(".tsx"));
|
|
33
|
-
|
|
34
|
-
const BAR_VERB_PROPS = [
|
|
35
|
-
"onDelete",
|
|
36
|
-
"onMove",
|
|
37
|
-
"onOrganize",
|
|
38
|
-
"onJunk",
|
|
39
|
-
"onMarkRead",
|
|
40
|
-
] as const;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* How a surface may answer a verb prop. `startWizard`/`wizard.start`/
|
|
44
|
-
* `startSelectionVerb` open the wizard on the verb; `organizeSelection` and
|
|
45
|
-
* `startFromSearch` open it on the search entry, which is the same wizard
|
|
46
|
-
* reached by its other door (#477 1.8). Anything else routes around the review
|
|
47
|
-
* screen that names what the verb covers (#477 1.4).
|
|
48
|
-
*/
|
|
49
|
-
const OPENS_THE_WIZARD =
|
|
50
|
-
/^(startWizard|wizard\.start|startSelectionVerb|organizeSelection|startFromSearch)\b/;
|
|
51
|
-
|
|
52
|
-
const bracedFrom = (source: string, open: number): string => {
|
|
53
|
-
let depth = 0;
|
|
54
|
-
for (let i = open; i < source.length; i++) {
|
|
55
|
-
if (source[i] === "{") depth++;
|
|
56
|
-
else if (source[i] === "}") {
|
|
57
|
-
depth--;
|
|
58
|
-
if (depth === 0) return source.slice(open, i + 1);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return source.slice(open);
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const namedCallbackBody = (source: string, name: string): string => {
|
|
65
|
-
const at = source.indexOf(`const ${name} = useCallback(`);
|
|
66
|
-
if (at === -1) return "";
|
|
67
|
-
const open = source.indexOf("{", at);
|
|
68
|
-
return open === -1 ? "" : bracedFrom(source, open);
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The bar element as written, to its own closing tag. Found by matching tags
|
|
73
|
-
* rather than by stopping at the first `/>` on a line: a nested self-closing
|
|
74
|
-
* element matches that too, so the scan would end early and silently skip
|
|
75
|
-
* whatever a surface happened to write after it.
|
|
76
|
-
*/
|
|
77
|
-
const barMarkup = (source: string): string => {
|
|
78
|
-
const start = source.indexOf("<SelectionTopBar");
|
|
79
|
-
if (start === -1) return "";
|
|
80
|
-
let depth = 0;
|
|
81
|
-
for (let i = start; i < source.length; i++) {
|
|
82
|
-
if (source[i] === "<" && /[A-Za-z]/.test(source[i + 1] ?? "")) depth++;
|
|
83
|
-
else if (source.startsWith("/>", i)) {
|
|
84
|
-
depth--;
|
|
85
|
-
if (depth === 0) return source.slice(start, i + 2);
|
|
86
|
-
i++;
|
|
87
|
-
} else if (source.startsWith("</", i)) depth--;
|
|
88
|
-
}
|
|
89
|
-
return "";
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* What a verb prop is wired to, with the whitespace, the guard a verb is
|
|
94
|
-
* offered behind, and the arrow taken off — what is left is the call itself.
|
|
95
|
-
*/
|
|
96
|
-
const verbHandler = (bar: string, prop: string): string | undefined => {
|
|
97
|
-
const assigned = bar.match(new RegExp(`${prop}=\\{([\\s\\S]*?)\\}\\n`))?.[1];
|
|
98
|
-
if (assigned === undefined) return undefined;
|
|
99
|
-
return assigned
|
|
100
|
-
.replace(/\s+/g, " ")
|
|
101
|
-
.replace(/^.*? \? /, "")
|
|
102
|
-
.replace(/ : undefined$/, "")
|
|
103
|
-
.replace(/^\(\)\s*=>\s*/, "")
|
|
104
|
-
.trim();
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
const declaredHandlers = (source: string): string[] => {
|
|
108
|
-
const at = source.indexOf("handlers: {");
|
|
109
|
-
if (at === -1) return [];
|
|
110
|
-
return Array.from(
|
|
111
|
-
bracedFrom(source, source.indexOf("{", at)).matchAll(/\n\t{3}(\w+): /g),
|
|
112
|
-
(match) => match[1],
|
|
113
|
-
);
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* What a pane does for one keyboard verb: the handler it wired into the triage
|
|
118
|
-
* layer, plus any callback that handler defers to for its target. A handler
|
|
119
|
-
* that offers the list the press and then falls through to a helper picking the
|
|
120
|
-
* selection is exactly as wrong as one picking it inline, so both are read.
|
|
121
|
-
*/
|
|
122
|
-
const paneVerbBody = (source: string, handler: string): string => {
|
|
123
|
-
const at = source.search(new RegExp(`\\n\\t{3}${handler}: `));
|
|
124
|
-
if (at === -1) return "";
|
|
125
|
-
const value =
|
|
126
|
-
source.slice(at).match(new RegExp(`${handler}: (.*)`))?.[1] ?? "";
|
|
127
|
-
const body = value.startsWith("(")
|
|
128
|
-
? bracedFrom(source, source.indexOf("{", at))
|
|
129
|
-
: namedCallbackBody(source, value.replace(/[,\s].*$/, ""));
|
|
130
|
-
const helpers = Array.from(body.matchAll(/\b(\w+MessageIds)\(\)/g), (match) =>
|
|
131
|
-
namedCallbackBody(source, match[1]),
|
|
132
|
-
);
|
|
133
|
-
return [body, ...helpers].join("\n");
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
describe("the surfaces that raise a selection bar", () => {
|
|
137
|
-
const surfaces = (): string[] =>
|
|
138
|
-
sourceFiles()
|
|
139
|
-
.filter((file) => /<SelectionTopBar/.test(read(file)))
|
|
140
|
-
.sort();
|
|
141
|
-
|
|
142
|
-
it("are these three and no others", () => {
|
|
143
|
-
assert.deepEqual(surfaces(), [
|
|
144
|
-
"DailyBrief.tsx",
|
|
145
|
-
"MessageList.tsx",
|
|
146
|
-
"ThreadListInteraction.tsx",
|
|
147
|
-
]);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
for (const file of surfaces()) {
|
|
151
|
-
it(`${file} routes every verb it offers through the wizard`, () => {
|
|
152
|
-
const bar = barMarkup(read(file));
|
|
153
|
-
assert.notEqual(bar, "", `${file} renders a SelectionTopBar`);
|
|
154
|
-
const offered = BAR_VERB_PROPS.filter((prop) => bar.includes(`${prop}=`));
|
|
155
|
-
assert.ok(offered.length > 0, `${file} offers at least one verb`);
|
|
156
|
-
for (const prop of offered) {
|
|
157
|
-
const handler = verbHandler(bar, prop);
|
|
158
|
-
assert.ok(handler, `${file}'s ${prop} is wired to something`);
|
|
159
|
-
assert.match(
|
|
160
|
-
handler,
|
|
161
|
-
OPENS_THE_WIZARD,
|
|
162
|
-
`${file}'s ${prop} must open the wizard, not run the verb`,
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
for (const file of ["DailyBrief.tsx", "MessageList.tsx"]) {
|
|
169
|
-
it(`${file} drops none of the five verbs from the bar it raises`, () => {
|
|
170
|
-
const bar = barMarkup(read(file));
|
|
171
|
-
for (const prop of BAR_VERB_PROPS) {
|
|
172
|
-
assert.match(bar, new RegExp(`${prop}=`), `${file} offers ${prop}`);
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// A pane that runs a verb over `selectedIds` without offering the list the
|
|
179
|
-
// press first is the hole no run can see: the pane's fallback and the list's
|
|
180
|
-
// claim are indistinguishable from outside until a selection outlives the
|
|
181
|
-
// list's commands. That the list claims the press is rendered in
|
|
182
|
-
// `./ThreadListInteraction.test.ts`.
|
|
183
|
-
describe("the panes that answer a keyboard verb over a selection", () => {
|
|
184
|
-
const PANE_VERB_ROUTE: Record<string, string> = {
|
|
185
|
-
delete: "delete",
|
|
186
|
-
toggleRead: "markRead",
|
|
187
|
-
markJunk: "junk",
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
const panes = (): string[] =>
|
|
191
|
-
sourceFiles()
|
|
192
|
-
.filter((file) => read(file).includes("handlers: {"))
|
|
193
|
-
.sort();
|
|
194
|
-
|
|
195
|
-
const routed = (): string[] =>
|
|
196
|
-
panes()
|
|
197
|
-
.flatMap((file) =>
|
|
198
|
-
declaredHandlers(read(file))
|
|
199
|
-
.filter((handler) => handler in PANE_VERB_ROUTE)
|
|
200
|
-
.map((handler) => `${file}:${handler}`),
|
|
201
|
-
)
|
|
202
|
-
.sort();
|
|
203
|
-
|
|
204
|
-
it("are these three panes and no others", () => {
|
|
205
|
-
assert.deepEqual(panes(), [
|
|
206
|
-
"BriefPane.tsx",
|
|
207
|
-
"FlaggedPane.tsx",
|
|
208
|
-
"MailboxPane.tsx",
|
|
209
|
-
]);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("declare these seven routed handlers and no others", () => {
|
|
213
|
-
assert.deepEqual(routed(), [
|
|
214
|
-
"BriefPane.tsx:delete",
|
|
215
|
-
"BriefPane.tsx:toggleRead",
|
|
216
|
-
"FlaggedPane.tsx:delete",
|
|
217
|
-
"FlaggedPane.tsx:toggleRead",
|
|
218
|
-
"MailboxPane.tsx:delete",
|
|
219
|
-
"MailboxPane.tsx:markJunk",
|
|
220
|
-
"MailboxPane.tsx:toggleRead",
|
|
221
|
-
]);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
for (const entry of routed()) {
|
|
225
|
-
const [file, handler] = entry.split(":");
|
|
226
|
-
const verb = PANE_VERB_ROUTE[handler];
|
|
227
|
-
|
|
228
|
-
it(`${entry} offers the list ${verb} before running anything`, () => {
|
|
229
|
-
const body = paneVerbBody(read(file), handler);
|
|
230
|
-
assert.notEqual(body, "", `${handler}'s body was found`);
|
|
231
|
-
assert.match(body, new RegExp(`requestVerb\\("${verb}"\\)`));
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it(`${entry} falls back to the focused row, never to a selection`, () => {
|
|
235
|
-
const body = paneVerbBody(read(file), handler);
|
|
236
|
-
assert.notEqual(body, "", `${handler}'s body was found`);
|
|
237
|
-
assert.doesNotMatch(body, /selectedIds/i);
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
// The row is built once by `MailListHeader` and handed down as `makeFilterSlot`,
|
|
243
|
-
// but the wizard answering the step it pushes is mounted per surface. A surface
|
|
244
|
-
// that renders the row without one leaves the press on a URL nothing answers.
|
|
245
|
-
describe("the surfaces that render the make-filter row", () => {
|
|
246
|
-
const ANSWERED_BY: Record<string, string> = {
|
|
247
|
-
"DailyBrief.tsx": "DailyBrief.tsx",
|
|
248
|
-
"MessageList.tsx": "MessageList.tsx",
|
|
249
|
-
"ThreadListInteraction.tsx": "FlaggedList.tsx",
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
it("are these three and no others", () => {
|
|
253
|
-
assert.deepEqual(
|
|
254
|
-
sourceFiles()
|
|
255
|
-
.filter((file) => /\.makeFilterSlot/.test(read(file)))
|
|
256
|
-
.sort(),
|
|
257
|
-
Object.keys(ANSWERED_BY).sort(),
|
|
258
|
-
);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it("each name a file that mounts a wizard to answer the step", () => {
|
|
262
|
-
for (const [surface, host] of Object.entries(ANSWERED_BY)) {
|
|
263
|
-
assert.match(
|
|
264
|
-
read(host),
|
|
265
|
-
/<SelectionWizardHost/,
|
|
266
|
-
`${surface} is answered by ${host}`,
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
});
|
|
270
|
-
});
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The pane in Settings › Advanced that is the only place a quarantined message
|
|
3
|
-
* is ever mentioned. Every read state has to say something: an empty list is
|
|
4
|
-
* the normal one, and a failed read is never a blank panel.
|
|
5
|
-
*/
|
|
6
|
-
import assert from "node:assert/strict";
|
|
7
|
-
import { describe, it } from "node:test";
|
|
8
|
-
import { type QuarantineEntry, quarantineDemoEntries } from "@remit/ui";
|
|
9
|
-
import React, { createElement } from "react";
|
|
10
|
-
import { renderToString } from "react-dom/server";
|
|
11
|
-
import { QuarantinePanelView } from "./QuarantinePanel";
|
|
12
|
-
|
|
13
|
-
// See MessageToolbar.render.test.ts: the SSR test loader transpiles remit-ui
|
|
14
|
-
// `.tsx` with the classic JSX runtime, which needs a global `React`.
|
|
15
|
-
(globalThis as { React?: typeof React }).React = React;
|
|
16
|
-
|
|
17
|
-
const render = (props: {
|
|
18
|
-
entries: readonly QuarantineEntry[];
|
|
19
|
-
isPending: boolean;
|
|
20
|
-
error: unknown;
|
|
21
|
-
}): string =>
|
|
22
|
-
renderToString(createElement(QuarantinePanelView, props) as never);
|
|
23
|
-
|
|
24
|
-
describe("QuarantinePanelView", () => {
|
|
25
|
-
it("says nothing was set aside rather than rendering nothing", () => {
|
|
26
|
-
const html = render({ entries: [], isPending: false, error: null });
|
|
27
|
-
assert.match(html, /Messages set aside/);
|
|
28
|
-
assert.match(html, /Nothing is set aside/);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("lists a set-aside message with a way to report it", () => {
|
|
32
|
-
const html = render({
|
|
33
|
-
entries: quarantineDemoEntries.slice(0, 1),
|
|
34
|
-
isPending: false,
|
|
35
|
-
error: null,
|
|
36
|
-
});
|
|
37
|
-
assert.match(html, /Cut a bug/);
|
|
38
|
-
assert.match(html, /could not read/i);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("calls more than one set aside a pattern, not a list", () => {
|
|
42
|
-
const html = render({
|
|
43
|
-
entries: quarantineDemoEntries,
|
|
44
|
-
isPending: false,
|
|
45
|
-
error: null,
|
|
46
|
-
});
|
|
47
|
-
assert.match(
|
|
48
|
-
html,
|
|
49
|
-
new RegExp(`${quarantineDemoEntries.length} messages could not be read`),
|
|
50
|
-
);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("reports a failed read in the pane, with a way to file it", () => {
|
|
54
|
-
const html = render({
|
|
55
|
-
entries: [],
|
|
56
|
-
isPending: false,
|
|
57
|
-
error: new Error("Failed to fetch"),
|
|
58
|
-
});
|
|
59
|
-
assert.match(html, /role="alert"/);
|
|
60
|
-
assert.match(html, /could not be read/);
|
|
61
|
-
assert.match(html, /Failed to fetch/);
|
|
62
|
-
assert.match(html, /Report this/);
|
|
63
|
-
assert.doesNotMatch(html, /Nothing is set aside/);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("says it is still reading rather than claiming an empty list", () => {
|
|
67
|
-
const html = render({ entries: [], isPending: true, error: null });
|
|
68
|
-
assert.match(html, /Checking for messages set aside/);
|
|
69
|
-
assert.doesNotMatch(html, /Nothing is set aside/);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Settings › Advanced — the messages sync set aside.
|
|
3
|
-
*
|
|
4
|
-
* A quarantined message exists on the server and nowhere else in the app, so
|
|
5
|
-
* this pane is the only place it is ever mentioned. Three states are all
|
|
6
|
-
* visible: the list, the reassurance that there is nothing in it, and the
|
|
7
|
-
* failure to read it at all. None of them is an empty panel.
|
|
8
|
-
*/
|
|
9
|
-
import {
|
|
10
|
-
configOperationsGetConfigOptions,
|
|
11
|
-
meOperationsListQuarantineOptions,
|
|
12
|
-
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
13
|
-
import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
|
|
14
|
-
import {
|
|
15
|
-
Button,
|
|
16
|
-
QuarantineBugDialog,
|
|
17
|
-
type QuarantineEntry,
|
|
18
|
-
QuarantineSection,
|
|
19
|
-
quarantineIssueTitle,
|
|
20
|
-
quarantineReportSections,
|
|
21
|
-
} from "@remit/ui";
|
|
22
|
-
import { useQuery } from "@tanstack/react-query";
|
|
23
|
-
import { Bug } from "lucide-react";
|
|
24
|
-
import { useMemo, useState } from "react";
|
|
25
|
-
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
26
|
-
import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
|
|
27
|
-
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
28
|
-
import { toQuarantineEntry } from "@/lib/quarantine-entries";
|
|
29
|
-
import type { ResultFolderIndex } from "@/lib/result-folder";
|
|
30
|
-
|
|
31
|
-
/** Stable identity, so an empty list does not rebuild the memos below. */
|
|
32
|
-
const EMPTY_WIRE_ENTRIES: readonly RemitImapQuarantineResponse[] = [];
|
|
33
|
-
|
|
34
|
-
function openIssue(url: string): void {
|
|
35
|
-
window.open(url, "_blank", "noopener,noreferrer");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function issueUrlFor(entry: QuarantineEntry): string {
|
|
39
|
-
return buildGitHubIssueUrl(
|
|
40
|
-
buildBugReportContext({
|
|
41
|
-
quarantine: quarantineReportSections(entry),
|
|
42
|
-
title: quarantineIssueTitle(entry),
|
|
43
|
-
}),
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function QuarantineReadFailure({ error }: { error: unknown }) {
|
|
48
|
-
return (
|
|
49
|
-
<section role="alert" className="space-y-3">
|
|
50
|
-
<h2 className="text-sm font-semibold text-fg">Messages set aside</h2>
|
|
51
|
-
<div className="space-y-2 rounded-sm border border-danger/40 bg-danger-soft px-row-inset py-3">
|
|
52
|
-
<p className="text-sm font-medium text-danger">
|
|
53
|
-
The list of set-aside messages could not be read.
|
|
54
|
-
</p>
|
|
55
|
-
<p className="break-words text-sm text-fg-muted">
|
|
56
|
-
{formatErrorMessage(error)}
|
|
57
|
-
</p>
|
|
58
|
-
<p className="text-xs text-fg-muted">
|
|
59
|
-
Mail may still have been set aside — this page cannot say either way
|
|
60
|
-
until it can read the list. Reporting this gets it fixed.
|
|
61
|
-
</p>
|
|
62
|
-
<Button
|
|
63
|
-
variant="secondary"
|
|
64
|
-
size="sm"
|
|
65
|
-
icon={<Bug className="size-3.5" />}
|
|
66
|
-
onClick={() =>
|
|
67
|
-
openIssue(buildGitHubIssueUrl(buildBugReportContext()))
|
|
68
|
-
}
|
|
69
|
-
>
|
|
70
|
-
Report this
|
|
71
|
-
</Button>
|
|
72
|
-
</div>
|
|
73
|
-
</section>
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export interface QuarantinePanelViewProps {
|
|
78
|
-
entries: readonly QuarantineEntry[];
|
|
79
|
-
isPending: boolean;
|
|
80
|
-
error: unknown;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function QuarantinePanelView({
|
|
84
|
-
entries,
|
|
85
|
-
isPending,
|
|
86
|
-
error,
|
|
87
|
-
}: QuarantinePanelViewProps) {
|
|
88
|
-
const [reporting, setReporting] = useState<QuarantineEntry | null>(null);
|
|
89
|
-
const [copyFailed, setCopyFailed] = useState(false);
|
|
90
|
-
|
|
91
|
-
if (isPending) {
|
|
92
|
-
return (
|
|
93
|
-
<p className="animate-pulse text-sm text-fg-subtle">
|
|
94
|
-
Checking for messages set aside…
|
|
95
|
-
</p>
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (error) return <QuarantineReadFailure error={error} />;
|
|
100
|
-
|
|
101
|
-
const handleCopy = (report: string) => {
|
|
102
|
-
navigator.clipboard.writeText(report).then(
|
|
103
|
-
() => {
|
|
104
|
-
setCopyFailed(false);
|
|
105
|
-
setReporting(null);
|
|
106
|
-
},
|
|
107
|
-
() => {
|
|
108
|
-
setCopyFailed(true);
|
|
109
|
-
setReporting(null);
|
|
110
|
-
},
|
|
111
|
-
);
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
return (
|
|
115
|
-
<>
|
|
116
|
-
{copyFailed && (
|
|
117
|
-
<p role="alert" className="mb-3 text-sm text-danger">
|
|
118
|
-
The report was not copied — this browser refused clipboard access. Cut
|
|
119
|
-
the bug again and use Open on GitHub, which needs no clipboard.
|
|
120
|
-
</p>
|
|
121
|
-
)}
|
|
122
|
-
<QuarantineSection entries={entries} onCutBug={setReporting} />
|
|
123
|
-
<QuarantineBugDialog
|
|
124
|
-
entry={reporting}
|
|
125
|
-
issueUrl={reporting ? issueUrlFor(reporting) : ""}
|
|
126
|
-
onClose={() => setReporting(null)}
|
|
127
|
-
onCopy={handleCopy}
|
|
128
|
-
/>
|
|
129
|
-
</>
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* A row names the folder it came from by the leaf of its path, which cannot be
|
|
135
|
-
* cut until that account's own delimiter has arrived. A mailbox the account no
|
|
136
|
-
* longer lists never gets one, and keeps its whole path — `folderLeaf` on an
|
|
137
|
-
* empty delimiter is the path itself.
|
|
138
|
-
*/
|
|
139
|
-
function delimiterForMailbox(
|
|
140
|
-
folders: ResultFolderIndex,
|
|
141
|
-
mailboxId: string,
|
|
142
|
-
): string {
|
|
143
|
-
return folders.get(mailboxId)?.hierarchyDelimiter ?? "";
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* The folder names come from the account's mailbox lists, which are a second
|
|
148
|
-
* read — started alongside the quarantine list, never behind it. Until both have
|
|
149
|
-
* landed the pane says it is still reading, because a path cut on a delimiter
|
|
150
|
-
* that has not arrived is a wrong folder name rather than a late one, and a
|
|
151
|
-
* failure on either read is reported rather than papered over with a guess.
|
|
152
|
-
*/
|
|
153
|
-
export function QuarantinePanel() {
|
|
154
|
-
const quarantine = useQuery(meOperationsListQuarantineOptions());
|
|
155
|
-
const config = useQuery(configOperationsGetConfigOptions());
|
|
156
|
-
const accounts = useMemo(() => config.data?.accounts ?? [], [config.data]);
|
|
157
|
-
const folders = useResultFolderIndex(accounts);
|
|
158
|
-
|
|
159
|
-
const wireEntries = quarantine.data?.entries ?? EMPTY_WIRE_ENTRIES;
|
|
160
|
-
const entries = useMemo(
|
|
161
|
-
() =>
|
|
162
|
-
wireEntries.map((entry) =>
|
|
163
|
-
toQuarantineEntry(
|
|
164
|
-
entry,
|
|
165
|
-
delimiterForMailbox(folders.index, entry.mailboxId),
|
|
166
|
-
),
|
|
167
|
-
),
|
|
168
|
-
[wireEntries, folders.index],
|
|
169
|
-
);
|
|
170
|
-
|
|
171
|
-
return (
|
|
172
|
-
<QuarantinePanelView
|
|
173
|
-
entries={entries}
|
|
174
|
-
isPending={quarantine.isPending || config.isPending || folders.isPending}
|
|
175
|
-
error={quarantine.error ?? config.error ?? folders.error}
|
|
176
|
-
/>
|
|
177
|
-
);
|
|
178
|
-
}
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { cn, useOverlayScope } from "@remit/ui";
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
|
|
4
|
-
interface DropdownMenuProps {
|
|
5
|
-
trigger: React.ReactNode;
|
|
6
|
-
children: React.ReactNode;
|
|
7
|
-
align?: "left" | "right";
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export const DropdownMenu = ({
|
|
11
|
-
trigger,
|
|
12
|
-
children,
|
|
13
|
-
align = "right",
|
|
14
|
-
}: DropdownMenuProps) => {
|
|
15
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
16
|
-
const menuRef = useRef<HTMLDivElement>(null);
|
|
17
|
-
|
|
18
|
-
const handleClickOutside = useCallback((event: MouseEvent) => {
|
|
19
|
-
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
20
|
-
setIsOpen(false);
|
|
21
|
-
}
|
|
22
|
-
}, []);
|
|
23
|
-
|
|
24
|
-
useOverlayScope({
|
|
25
|
-
id: "dropdown-menu",
|
|
26
|
-
open: isOpen,
|
|
27
|
-
answers: { back: () => setIsOpen(false) },
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
useEffect(() => {
|
|
31
|
-
if (isOpen) document.addEventListener("mousedown", handleClickOutside);
|
|
32
|
-
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
33
|
-
}, [isOpen, handleClickOutside]);
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
<div ref={menuRef} className="relative">
|
|
37
|
-
<button
|
|
38
|
-
type="button"
|
|
39
|
-
onClick={(e) => {
|
|
40
|
-
e.stopPropagation();
|
|
41
|
-
setIsOpen(!isOpen);
|
|
42
|
-
}}
|
|
43
|
-
className={cn(
|
|
44
|
-
"p-1.5 min-h-11 min-w-11 inline-flex items-center justify-center rounded-md transition-colors",
|
|
45
|
-
"hover:bg-surface-raised text-fg-muted hover:text-fg",
|
|
46
|
-
)}
|
|
47
|
-
>
|
|
48
|
-
{trigger}
|
|
49
|
-
</button>
|
|
50
|
-
{isOpen && (
|
|
51
|
-
<div
|
|
52
|
-
className={cn(
|
|
53
|
-
"absolute z-50 mt-1 min-w-[180px] py-1",
|
|
54
|
-
"bg-surface border border-line rounded-md shadow-lg",
|
|
55
|
-
align === "right" ? "right-0" : "left-0",
|
|
56
|
-
)}
|
|
57
|
-
>
|
|
58
|
-
{children}
|
|
59
|
-
</div>
|
|
60
|
-
)}
|
|
61
|
-
</div>
|
|
62
|
-
);
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
interface DropdownMenuItemProps {
|
|
66
|
-
onClick: () => void;
|
|
67
|
-
disabled?: boolean;
|
|
68
|
-
destructive?: boolean;
|
|
69
|
-
children: React.ReactNode;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export const DropdownMenuItem = ({
|
|
73
|
-
onClick,
|
|
74
|
-
disabled,
|
|
75
|
-
destructive,
|
|
76
|
-
children,
|
|
77
|
-
}: DropdownMenuItemProps) => (
|
|
78
|
-
<button
|
|
79
|
-
type="button"
|
|
80
|
-
onClick={(e) => {
|
|
81
|
-
e.stopPropagation();
|
|
82
|
-
if (!disabled) onClick();
|
|
83
|
-
}}
|
|
84
|
-
disabled={disabled}
|
|
85
|
-
className={cn(
|
|
86
|
-
"w-full px-3 py-2 text-left text-sm transition-colors",
|
|
87
|
-
"flex items-center gap-2",
|
|
88
|
-
disabled && "opacity-50 cursor-not-allowed",
|
|
89
|
-
!disabled && "hover:bg-surface-raised",
|
|
90
|
-
destructive && "text-danger hover:text-danger",
|
|
91
|
-
)}
|
|
92
|
-
>
|
|
93
|
-
{children}
|
|
94
|
-
</button>
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
export const DropdownMenuSeparator = () => (
|
|
98
|
-
<div className="my-1 h-px bg-line" />
|
|
99
|
-
);
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
interface EmptyStateProps {
|
|
2
|
-
message: string;
|
|
3
|
-
icon?: React.ReactNode;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export const EmptyState = ({ message, icon }: EmptyStateProps) => (
|
|
7
|
-
<div className="flex flex-col items-center justify-center p-8 text-center">
|
|
8
|
-
{icon && <div className="mb-4 text-fg-muted">{icon}</div>}
|
|
9
|
-
<p className="text-fg-muted">{message}</p>
|
|
10
|
-
</div>
|
|
11
|
-
);
|