@remit/ui 0.0.20 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/mobile-search-view.render.test.ts +5 -8
- package/src/components/mobile-search-view.stories.tsx +20 -11
- package/src/components/mobile-search-view.tsx +0 -8
- package/src/components/search-results.render.test.ts +86 -21
- package/src/components/search-results.stories.tsx +24 -10
- package/src/components/search-results.tsx +39 -30
- package/src/components/self-update-confirm-dialog.tsx +86 -0
- package/src/components/self-update-dot.tsx +32 -0
- package/src/components/self-update-progress-overlay.stories.tsx +80 -0
- package/src/components/self-update-progress-overlay.tsx +203 -0
- package/src/components/self-update-section.tsx +377 -0
- package/src/components/self-update.render.test.ts +294 -0
- package/src/components/self-update.stories.tsx +212 -0
- package/src/components/self-update.ts +127 -0
- package/src/components/spam-results-offer.render.test.ts +1 -1
- package/src/components/spam-results-offer.tsx +7 -1
- package/src/index.ts +32 -0
package/package.json
CHANGED
|
@@ -42,8 +42,7 @@ describe("MobileSearchView search scope", () => {
|
|
|
42
42
|
const html = renderToString(
|
|
43
43
|
createElement(MobileSearchView, {
|
|
44
44
|
...base,
|
|
45
|
-
scope: { kind: "global" as const },
|
|
46
|
-
onScopeToSpam: noop,
|
|
45
|
+
scope: { kind: "global" as const, onScopeToSpam: noop },
|
|
47
46
|
}),
|
|
48
47
|
);
|
|
49
48
|
assert.doesNotMatch(html, /unknown-vendor/);
|
|
@@ -56,7 +55,6 @@ describe("MobileSearchView search scope", () => {
|
|
|
56
55
|
createElement(MobileSearchView, {
|
|
57
56
|
...base,
|
|
58
57
|
scope: { kind: "folder" as const, role: "inbox" as const },
|
|
59
|
-
onScopeToSpam: noop,
|
|
60
58
|
}),
|
|
61
59
|
);
|
|
62
60
|
assert.doesNotMatch(html, /unknown-vendor/);
|
|
@@ -64,15 +62,14 @@ describe("MobileSearchView search scope", () => {
|
|
|
64
62
|
assert.doesNotMatch(html, /Archive/);
|
|
65
63
|
});
|
|
66
64
|
|
|
67
|
-
it("
|
|
65
|
+
it("counts the spam it held out, on the phone tier too", () => {
|
|
68
66
|
const html = renderToString(
|
|
69
67
|
createElement(MobileSearchView, {
|
|
70
68
|
...base,
|
|
71
|
-
scope: { kind: "global" as const },
|
|
72
|
-
spamMatchCount: 42,
|
|
73
|
-
onScopeToSpam: noop,
|
|
69
|
+
scope: { kind: "global" as const, onScopeToSpam: noop },
|
|
74
70
|
}),
|
|
75
71
|
);
|
|
76
|
-
assert.match(html,
|
|
72
|
+
assert.match(html, /from Spam/);
|
|
73
|
+
assert.match(html, /Go to Spam/);
|
|
77
74
|
});
|
|
78
75
|
});
|
|
@@ -156,8 +156,6 @@ function Harness({
|
|
|
156
156
|
sections,
|
|
157
157
|
preset,
|
|
158
158
|
scope,
|
|
159
|
-
spamMatchCount,
|
|
160
|
-
onScopeToSpam,
|
|
161
159
|
}: {
|
|
162
160
|
initialValue?: string;
|
|
163
161
|
initialChips?: SearchChip[];
|
|
@@ -165,8 +163,6 @@ function Harness({
|
|
|
165
163
|
sections?: SearchResultSection[];
|
|
166
164
|
preset: Preset;
|
|
167
165
|
scope?: SearchScope;
|
|
168
|
-
spamMatchCount?: number;
|
|
169
|
-
onScopeToSpam?: () => void;
|
|
170
166
|
}) {
|
|
171
167
|
const [value, setValue] = useState(initialValue);
|
|
172
168
|
const [chips, setChips] = useState<SearchChip[]>(initialChips);
|
|
@@ -240,8 +236,6 @@ function Harness({
|
|
|
240
236
|
loading={loading}
|
|
241
237
|
onSelectResult={setOpened}
|
|
242
238
|
scope={scope}
|
|
243
|
-
spamMatchCount={spamMatchCount}
|
|
244
|
-
onScopeToSpam={onScopeToSpam}
|
|
245
239
|
/>
|
|
246
240
|
);
|
|
247
241
|
}
|
|
@@ -343,8 +337,7 @@ export const GlobalAcrossFolders: Story = {
|
|
|
343
337
|
<Harness
|
|
344
338
|
initialValue="invoice"
|
|
345
339
|
sections={acrossFoldersSections}
|
|
346
|
-
scope={{ kind: "global" }}
|
|
347
|
-
onScopeToSpam={() => {}}
|
|
340
|
+
scope={{ kind: "global", onScopeToSpam: () => {} }}
|
|
348
341
|
preset="brief"
|
|
349
342
|
/>
|
|
350
343
|
),
|
|
@@ -362,7 +355,6 @@ export const ScopedToInbox: Story = {
|
|
|
362
355
|
initialChips={[{ id: "in:inbox", label: "in:inbox" }]}
|
|
363
356
|
sections={acrossFoldersSections}
|
|
364
357
|
scope={{ kind: "folder", role: "inbox" }}
|
|
365
|
-
onScopeToSpam={() => {}}
|
|
366
358
|
preset="inbox"
|
|
367
359
|
/>
|
|
368
360
|
),
|
|
@@ -377,8 +369,25 @@ export const GlobalOnlySpamMatches: Story = {
|
|
|
377
369
|
<Harness
|
|
378
370
|
initialValue="invoice"
|
|
379
371
|
sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
|
|
380
|
-
scope={{ kind: "global" }}
|
|
381
|
-
|
|
372
|
+
scope={{ kind: "global", onScopeToSpam: () => {} }}
|
|
373
|
+
preset="brief"
|
|
374
|
+
/>
|
|
375
|
+
),
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The same rows under a starred search. Starring spans folders, so the rows
|
|
380
|
+
* keep their provenance labels, and the spam among them stays in the list — the
|
|
381
|
+
* user starred it themselves, so there is nothing to hold back and no offer to
|
|
382
|
+
* make.
|
|
383
|
+
*/
|
|
384
|
+
export const StarredCollection: Story = {
|
|
385
|
+
render: () => (
|
|
386
|
+
<Harness
|
|
387
|
+
initialValue="invoice"
|
|
388
|
+
initialChips={[{ id: "is:starred", label: "is:starred" }]}
|
|
389
|
+
sections={acrossFoldersSections}
|
|
390
|
+
scope={{ kind: "collection" }}
|
|
382
391
|
preset="brief"
|
|
383
392
|
/>
|
|
384
393
|
),
|
|
@@ -46,10 +46,6 @@ export interface MobileSearchViewProps {
|
|
|
46
46
|
onRemoveChip?: (id: string) => void;
|
|
47
47
|
/** What the search covers; see `SearchResultsProps`. Defaults to global. */
|
|
48
48
|
scope?: SearchScope;
|
|
49
|
-
/** Total spam matches found; see `SearchResultsProps`. */
|
|
50
|
-
spamMatchCount?: number;
|
|
51
|
-
/** Scope the search to Spam; see `SearchResultsProps`. */
|
|
52
|
-
onScopeToSpam?: () => void;
|
|
53
49
|
}
|
|
54
50
|
|
|
55
51
|
/**
|
|
@@ -81,8 +77,6 @@ export function MobileSearchView({
|
|
|
81
77
|
chips,
|
|
82
78
|
onRemoveChip,
|
|
83
79
|
scope,
|
|
84
|
-
spamMatchCount,
|
|
85
|
-
onScopeToSpam,
|
|
86
80
|
}: MobileSearchViewProps) {
|
|
87
81
|
const body = (
|
|
88
82
|
<SearchResults
|
|
@@ -94,8 +88,6 @@ export function MobileSearchView({
|
|
|
94
88
|
onSelectResult={onSelectResult}
|
|
95
89
|
tokens={tokens}
|
|
96
90
|
scope={scope}
|
|
97
|
-
spamMatchCount={spamMatchCount}
|
|
98
|
-
onScopeToSpam={onScopeToSpam}
|
|
99
91
|
/>
|
|
100
92
|
);
|
|
101
93
|
|
|
@@ -114,8 +114,7 @@ describe("SearchResults spam handling", () => {
|
|
|
114
114
|
createElement(SearchResults, {
|
|
115
115
|
value: "invoice",
|
|
116
116
|
sections: mixed,
|
|
117
|
-
scope: { kind: "global" },
|
|
118
|
-
onScopeToSpam: noop,
|
|
117
|
+
scope: { kind: "global", onScopeToSpam: noop },
|
|
119
118
|
}),
|
|
120
119
|
);
|
|
121
120
|
assert.doesNotMatch(html, /unknown-vendor/);
|
|
@@ -130,8 +129,7 @@ describe("SearchResults spam handling", () => {
|
|
|
130
129
|
sections: [
|
|
131
130
|
{ id: "results", label: "Results", results: [archivedResult] },
|
|
132
131
|
],
|
|
133
|
-
scope: { kind: "global" },
|
|
134
|
-
onScopeToSpam: noop,
|
|
132
|
+
scope: { kind: "global", onScopeToSpam: noop },
|
|
135
133
|
}),
|
|
136
134
|
);
|
|
137
135
|
assert.doesNotMatch(html, /from Spam/);
|
|
@@ -142,8 +140,7 @@ describe("SearchResults spam handling", () => {
|
|
|
142
140
|
createElement(SearchResults, {
|
|
143
141
|
value: "invoice",
|
|
144
142
|
sections: [{ id: "results", label: "Results", results: [spamResult] }],
|
|
145
|
-
scope: { kind: "global" },
|
|
146
|
-
onScopeToSpam: noop,
|
|
143
|
+
scope: { kind: "global", onScopeToSpam: noop },
|
|
147
144
|
}),
|
|
148
145
|
);
|
|
149
146
|
assert.match(html, /No matches for/);
|
|
@@ -156,7 +153,6 @@ describe("SearchResults spam handling", () => {
|
|
|
156
153
|
value: "invoice",
|
|
157
154
|
sections: mixed,
|
|
158
155
|
scope: { kind: "folder", role: "inbox" },
|
|
159
|
-
onScopeToSpam: noop,
|
|
160
156
|
}),
|
|
161
157
|
);
|
|
162
158
|
assert.doesNotMatch(html, /unknown-vendor/);
|
|
@@ -170,7 +166,6 @@ describe("SearchResults spam handling", () => {
|
|
|
170
166
|
value: "invoice",
|
|
171
167
|
sections: mixed,
|
|
172
168
|
scope: { kind: "folder", role: "junk" },
|
|
173
|
-
onScopeToSpam: noop,
|
|
174
169
|
}),
|
|
175
170
|
);
|
|
176
171
|
assert.match(html, /unknown-vendor/);
|
|
@@ -187,19 +182,6 @@ describe("SearchResults spam handling", () => {
|
|
|
187
182
|
);
|
|
188
183
|
assert.doesNotMatch(html, /from Spam/);
|
|
189
184
|
});
|
|
190
|
-
|
|
191
|
-
it("prefers a caller-supplied total over the rows held out of this page", () => {
|
|
192
|
-
const html = renderToString(
|
|
193
|
-
createElement(SearchResults, {
|
|
194
|
-
value: "invoice",
|
|
195
|
-
sections: mixed,
|
|
196
|
-
scope: { kind: "global" },
|
|
197
|
-
spamMatchCount: 42,
|
|
198
|
-
onScopeToSpam: noop,
|
|
199
|
-
}),
|
|
200
|
-
);
|
|
201
|
-
assert.match(html, />42</);
|
|
202
|
-
});
|
|
203
185
|
});
|
|
204
186
|
|
|
205
187
|
describe("SearchResults provenance labels", () => {
|
|
@@ -268,3 +250,86 @@ describe("SearchResults provenance labels", () => {
|
|
|
268
250
|
assert.doesNotMatch(html, /Important/);
|
|
269
251
|
});
|
|
270
252
|
});
|
|
253
|
+
|
|
254
|
+
describe("SearchResults scoped to a collection", () => {
|
|
255
|
+
const spamRow: SearchResult = {
|
|
256
|
+
id: "s1",
|
|
257
|
+
sender: "billing@unknown-vendor.test",
|
|
258
|
+
subject: "URGENT invoice attached",
|
|
259
|
+
snippet: "Wire the amount below.",
|
|
260
|
+
date: "Feb 11",
|
|
261
|
+
folder: { role: "junk" },
|
|
262
|
+
};
|
|
263
|
+
const archivedRow: SearchResult = {
|
|
264
|
+
...result,
|
|
265
|
+
id: "a1",
|
|
266
|
+
folder: { role: "archive" },
|
|
267
|
+
};
|
|
268
|
+
const collectionSections: SearchResultSection[] = [
|
|
269
|
+
{ id: "results", label: "Results", results: [archivedRow, spamRow] },
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
it("keeps a starred spam match in the list", () => {
|
|
273
|
+
const html = renderToString(
|
|
274
|
+
createElement(SearchResults, {
|
|
275
|
+
value: "invoice",
|
|
276
|
+
sections: collectionSections,
|
|
277
|
+
scope: { kind: "collection" },
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
assert.match(html, /billing@unknown-vendor\.test/);
|
|
281
|
+
assert.doesNotMatch(html, /from Spam/);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("still names the folder each row came from", () => {
|
|
285
|
+
const html = renderToString(
|
|
286
|
+
createElement(SearchResults, {
|
|
287
|
+
value: "invoice",
|
|
288
|
+
sections: collectionSections,
|
|
289
|
+
scope: { kind: "collection" },
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
assert.match(html, /Archive/);
|
|
293
|
+
assert.match(html, /Spam/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("holds the same spam row out when the search is scoped to a folder", () => {
|
|
297
|
+
const html = renderToString(
|
|
298
|
+
createElement(SearchResults, {
|
|
299
|
+
value: "invoice",
|
|
300
|
+
sections: collectionSections,
|
|
301
|
+
scope: { kind: "folder", role: "inbox" },
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
assert.doesNotMatch(html, /billing@unknown-vendor\.test/);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("SearchResults spam count", () => {
|
|
309
|
+
const spamIn = (id: string): SearchResult => ({
|
|
310
|
+
id,
|
|
311
|
+
sender: "spammer",
|
|
312
|
+
subject: "Offer",
|
|
313
|
+
snippet: "",
|
|
314
|
+
date: "",
|
|
315
|
+
folder: { role: "junk" },
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("counts every held-out row, not just one account's", () => {
|
|
319
|
+
const html = renderToString(
|
|
320
|
+
createElement(SearchResults, {
|
|
321
|
+
value: "invoice",
|
|
322
|
+
sections: [
|
|
323
|
+
{
|
|
324
|
+
id: "results",
|
|
325
|
+
label: "Results",
|
|
326
|
+
results: [result, spamIn("a1"), spamIn("b1"), spamIn("b2")],
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
scope: { kind: "global", onScopeToSpam: noop },
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
assert.match(html, />3</);
|
|
333
|
+
assert.match(html, /results from Spam/);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -190,8 +190,7 @@ export const GlobalAcrossFolders: Story = {
|
|
|
190
190
|
render: () => (
|
|
191
191
|
<Harness
|
|
192
192
|
value="invoice"
|
|
193
|
-
scope={{ kind: "global" }}
|
|
194
|
-
onScopeToSpam={() => {}}
|
|
193
|
+
scope={{ kind: "global", onScopeToSpam: () => {} }}
|
|
195
194
|
sections={[
|
|
196
195
|
{ id: "top", label: "Top matches", results: globalTopMatches },
|
|
197
196
|
{ id: "related", label: "Related", results: related },
|
|
@@ -209,8 +208,7 @@ export const GlobalWithoutSpamMatches: Story = {
|
|
|
209
208
|
render: () => (
|
|
210
209
|
<Harness
|
|
211
210
|
value="invoice"
|
|
212
|
-
scope={{ kind: "global" }}
|
|
213
|
-
onScopeToSpam={() => {}}
|
|
211
|
+
scope={{ kind: "global", onScopeToSpam: () => {} }}
|
|
214
212
|
sections={[
|
|
215
213
|
{
|
|
216
214
|
id: "top",
|
|
@@ -234,8 +232,7 @@ export const GlobalAccountWithoutSpamFolder: Story = {
|
|
|
234
232
|
render: () => (
|
|
235
233
|
<Harness
|
|
236
234
|
value="invoice"
|
|
237
|
-
scope={{ kind: "global" }}
|
|
238
|
-
onScopeToSpam={() => {}}
|
|
235
|
+
scope={{ kind: "global", onScopeToSpam: () => {} }}
|
|
239
236
|
sections={[
|
|
240
237
|
{ id: "top", label: "Top matches", results: crossFolderMatches },
|
|
241
238
|
]}
|
|
@@ -252,8 +249,7 @@ export const GlobalOnlySpamMatches: Story = {
|
|
|
252
249
|
render: () => (
|
|
253
250
|
<Harness
|
|
254
251
|
value="invoice"
|
|
255
|
-
scope={{ kind: "global" }}
|
|
256
|
-
onScopeToSpam={() => {}}
|
|
252
|
+
scope={{ kind: "global", onScopeToSpam: () => {} }}
|
|
257
253
|
sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
|
|
258
254
|
/>
|
|
259
255
|
),
|
|
@@ -273,7 +269,6 @@ export const ScopedToInbox: Story = {
|
|
|
273
269
|
<Harness
|
|
274
270
|
value="invoice"
|
|
275
271
|
scope={{ kind: "folder", role: "inbox" }}
|
|
276
|
-
onScopeToSpam={() => {}}
|
|
277
272
|
sections={[
|
|
278
273
|
{ id: "top", label: "Top matches", results: globalTopMatches },
|
|
279
274
|
{ id: "related", label: "Related", results: related },
|
|
@@ -293,7 +288,6 @@ export const ScopedToSpam: Story = {
|
|
|
293
288
|
<Harness
|
|
294
289
|
value="invoice"
|
|
295
290
|
scope={{ kind: "folder", role: "junk" }}
|
|
296
|
-
onScopeToSpam={() => {}}
|
|
297
291
|
sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
|
|
298
292
|
/>
|
|
299
293
|
),
|
|
@@ -343,3 +337,23 @@ export const WithFilterTokens: Story = {
|
|
|
343
337
|
/>
|
|
344
338
|
),
|
|
345
339
|
};
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Starred search (`is:starred` in the bar), given the same rows as the global
|
|
343
|
+
* story. Starring is not a folder: the rows come from all over, so each keeps
|
|
344
|
+
* its provenance label, and the two Spam matches stay in the list rather than
|
|
345
|
+
* being held out. The user starred them; holding their own mail back from them
|
|
346
|
+
* would be the surprising behaviour, and there is no offer because nothing was
|
|
347
|
+
* taken away.
|
|
348
|
+
*/
|
|
349
|
+
export const StarredCollection: Story = {
|
|
350
|
+
render: () => (
|
|
351
|
+
<Harness
|
|
352
|
+
value="invoice"
|
|
353
|
+
scope={{ kind: "collection" }}
|
|
354
|
+
sections={[
|
|
355
|
+
{ id: "top", label: "Top matches", results: globalTopMatches },
|
|
356
|
+
]}
|
|
357
|
+
/>
|
|
358
|
+
),
|
|
359
|
+
};
|
|
@@ -18,12 +18,22 @@ export interface SearchResultSection {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* What the search currently covers.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* What the search currently covers.
|
|
22
|
+
*
|
|
23
|
+
* - `global` — the unscoped search the daily brief runs: every account, every
|
|
24
|
+
* folder, no chip in the bar. It is the only scope that holds spam out, so it
|
|
25
|
+
* is the only one that carries a way back to it: without `onScopeToSpam` there
|
|
26
|
+
* is nowhere to send the user and no offer is made.
|
|
27
|
+
* - `folder` — narrowed to one place by the sidebar, which the bar shows as a
|
|
28
|
+
* chip.
|
|
29
|
+
* - `collection` — narrowed by something that is not a folder, `is:starred`
|
|
30
|
+
* being the one that exists. It still spans folders, so rows carry their
|
|
31
|
+
* provenance; and because the narrowing is the user's own (they starred the
|
|
32
|
+
* mail), spam is not held back from them.
|
|
24
33
|
*/
|
|
25
34
|
export type SearchScope =
|
|
26
|
-
| { kind: "global" }
|
|
35
|
+
| { kind: "global"; onScopeToSpam?: () => void }
|
|
36
|
+
| { kind: "collection" }
|
|
27
37
|
| { kind: "folder"; role?: FolderRole };
|
|
28
38
|
|
|
29
39
|
const GLOBAL_SCOPE: SearchScope = { kind: "global" };
|
|
@@ -74,17 +84,6 @@ export interface SearchResultsProps {
|
|
|
74
84
|
tokens?: { label: string; onRemove: () => void }[];
|
|
75
85
|
/** What the search covers. Defaults to the unscoped, global search. */
|
|
76
86
|
scope?: SearchScope;
|
|
77
|
-
/**
|
|
78
|
-
* Total spam matches the search found, for when the app knows more than the
|
|
79
|
-
* rows on this page. Defaults to the number of spam rows held out of
|
|
80
|
-
* {@link sections}.
|
|
81
|
-
*/
|
|
82
|
-
spamMatchCount?: number;
|
|
83
|
-
/**
|
|
84
|
-
* Scope the search to Spam. Without it there is no offer, so an app that has
|
|
85
|
-
* nowhere to navigate simply never makes one.
|
|
86
|
-
*/
|
|
87
|
-
onScopeToSpam?: () => void;
|
|
88
87
|
}
|
|
89
88
|
|
|
90
89
|
/**
|
|
@@ -177,12 +176,15 @@ function CollapsibleResultSection({
|
|
|
177
176
|
*
|
|
178
177
|
* - **Global** — spam rows are held out of the sections and offered instead, as
|
|
179
178
|
* a count with a way into a Spam-scoped search.
|
|
180
|
-
* - **Scoped to
|
|
181
|
-
*
|
|
179
|
+
* - **Scoped to a folder** — spam rows are held out and nothing is offered. A
|
|
180
|
+
* scoped search shows its own scope and no more.
|
|
182
181
|
* - **Scoped to Spam** — ordinary rows, rendered normally, no offer.
|
|
182
|
+
* - **A collection** (`is:starred`) — ordinary rows, spam included. The user
|
|
183
|
+
* picked this mail out by hand; there is nothing to protect them from.
|
|
183
184
|
*
|
|
184
|
-
* Provenance labels follow the
|
|
185
|
-
* folder each row came from,
|
|
185
|
+
* Provenance labels follow whether the search reaches more than one folder: a
|
|
186
|
+
* global or collection search names the folder each row came from, a
|
|
187
|
+
* folder-scoped one would only repeat its own chip.
|
|
186
188
|
*/
|
|
187
189
|
export function SearchResults({
|
|
188
190
|
value,
|
|
@@ -193,8 +195,6 @@ export function SearchResults({
|
|
|
193
195
|
onSelectResult,
|
|
194
196
|
tokens,
|
|
195
197
|
scope = GLOBAL_SCOPE,
|
|
196
|
-
spamMatchCount,
|
|
197
|
-
onScopeToSpam,
|
|
198
198
|
}: SearchResultsProps) {
|
|
199
199
|
const hasQuery = value.trim().length > 0;
|
|
200
200
|
|
|
@@ -249,11 +249,17 @@ export function SearchResults({
|
|
|
249
249
|
);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
const spamScoped = isSpamScope(scope);
|
|
253
252
|
const isGlobal = scope.kind === "global";
|
|
253
|
+
// Only a folder search holds spam back. Scoped to Spam there is nothing to
|
|
254
|
+
// hold back, and a collection is the user's own selection of mail rather than
|
|
255
|
+
// a place, so a starred spam message stays where the user put it.
|
|
256
|
+
const spamInline = isSpamScope(scope) || scope.kind === "collection";
|
|
257
|
+
// A search that reaches more than one folder names the folder each row came
|
|
258
|
+
// from; one confined to a single folder would repeat its own chip.
|
|
259
|
+
const spansFolders = isGlobal || scope.kind === "collection";
|
|
254
260
|
|
|
255
261
|
const partitioned = (sections ?? []).map((section) => {
|
|
256
|
-
if (
|
|
262
|
+
if (spamInline) return { section, spam: [] as SearchResult[] };
|
|
257
263
|
const { kept, spam } = partitionSpamResults(section.results);
|
|
258
264
|
return { section: { ...section, results: kept }, spam };
|
|
259
265
|
});
|
|
@@ -263,12 +269,15 @@ export function SearchResults({
|
|
|
263
269
|
(total, entry) => total + entry.spam.length,
|
|
264
270
|
0,
|
|
265
271
|
);
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
onScopeToSpam !== undefined
|
|
270
|
-
<SpamResultsOffer
|
|
271
|
-
|
|
272
|
+
const spamOffer =
|
|
273
|
+
scope.kind === "global" &&
|
|
274
|
+
heldOutSpamCount > 0 &&
|
|
275
|
+
scope.onScopeToSpam !== undefined ? (
|
|
276
|
+
<SpamResultsOffer
|
|
277
|
+
count={heldOutSpamCount}
|
|
278
|
+
onScopeToSpam={scope.onScopeToSpam}
|
|
279
|
+
/>
|
|
280
|
+
) : undefined;
|
|
272
281
|
|
|
273
282
|
const hasResults = visibleSections.some(
|
|
274
283
|
(section) => section.results.length > 0,
|
|
@@ -302,7 +311,7 @@ export function SearchResults({
|
|
|
302
311
|
key={section.id}
|
|
303
312
|
section={section}
|
|
304
313
|
query={value}
|
|
305
|
-
showFolder={
|
|
314
|
+
showFolder={spansFolders}
|
|
306
315
|
onSelectResult={onSelectResult}
|
|
307
316
|
/>
|
|
308
317
|
))}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Download, ExternalLink } from "lucide-react";
|
|
2
|
+
import { Button, ButtonLink } from "./button.js";
|
|
3
|
+
import { Dialog } from "./dialog.js";
|
|
4
|
+
import { formatReleaseDate, type ReleaseInfo } from "./self-update.js";
|
|
5
|
+
|
|
6
|
+
export interface SelfUpdateConfirmDialogProps {
|
|
7
|
+
open: boolean;
|
|
8
|
+
currentVersion: string;
|
|
9
|
+
release: ReleaseInfo;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
onConfirm: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const consequences = [
|
|
15
|
+
"Remit restarts. Mail stops loading for about a minute and this page will lose its connection while that happens.",
|
|
16
|
+
"Nothing is sent, deleted or moved. Your mail stays at your provider.",
|
|
17
|
+
"If the new version does not come back up, the version you are running now is restored automatically.",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Consent before the server replaces itself. Reflects what will happen in the
|
|
22
|
+
* user's terms, in the order they will feel it, and leaves the way back open
|
|
23
|
+
* until the moment they commit.
|
|
24
|
+
*/
|
|
25
|
+
export function SelfUpdateConfirmDialog({
|
|
26
|
+
open,
|
|
27
|
+
currentVersion,
|
|
28
|
+
release,
|
|
29
|
+
onClose,
|
|
30
|
+
onConfirm,
|
|
31
|
+
}: SelfUpdateConfirmDialogProps) {
|
|
32
|
+
if (!open) return null;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Dialog open onClose={onClose} title={`Install Remit ${release.version}`}>
|
|
36
|
+
<div className="flex max-h-[80vh] flex-col">
|
|
37
|
+
<header className="space-y-1 border-b border-line px-4 py-3">
|
|
38
|
+
<h3 className="text-sm font-semibold text-fg">
|
|
39
|
+
Install Remit {release.version}?
|
|
40
|
+
</h3>
|
|
41
|
+
<p className="text-xs text-fg-muted">
|
|
42
|
+
You are on {currentVersion}. {release.version} was released{" "}
|
|
43
|
+
{formatReleaseDate(release.releasedAt)}.
|
|
44
|
+
</p>
|
|
45
|
+
</header>
|
|
46
|
+
|
|
47
|
+
<div className="flex-1 space-y-3 overflow-auto px-4 py-3">
|
|
48
|
+
<p className="text-sm text-fg-muted">{release.summary}</p>
|
|
49
|
+
<ul className="space-y-2">
|
|
50
|
+
{consequences.map((line) => (
|
|
51
|
+
<li key={line} className="flex gap-2 text-sm text-fg-muted">
|
|
52
|
+
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-subtle" />
|
|
53
|
+
<span>{line}</span>
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
<p className="text-xs text-fg-subtle">
|
|
58
|
+
Good moment for this: when you are not waiting on a message.
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<footer className="flex flex-wrap items-center justify-end gap-2 border-t border-line px-4 py-3">
|
|
63
|
+
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
64
|
+
Not now
|
|
65
|
+
</Button>
|
|
66
|
+
<ButtonLink
|
|
67
|
+
variant="secondary"
|
|
68
|
+
size="sm"
|
|
69
|
+
external
|
|
70
|
+
href={release.releaseNotesUrl}
|
|
71
|
+
icon={<ExternalLink className="size-3.5" />}
|
|
72
|
+
>
|
|
73
|
+
Release notes
|
|
74
|
+
</ButtonLink>
|
|
75
|
+
<Button
|
|
76
|
+
size="sm"
|
|
77
|
+
icon={<Download className="size-3.5" />}
|
|
78
|
+
onClick={onConfirm}
|
|
79
|
+
>
|
|
80
|
+
Install and restart
|
|
81
|
+
</Button>
|
|
82
|
+
</footer>
|
|
83
|
+
</div>
|
|
84
|
+
</Dialog>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface UpdateAvailableDotProps {
|
|
4
|
+
/** The icon or control the hint is attached to. */
|
|
5
|
+
children?: ReactNode;
|
|
6
|
+
show: boolean;
|
|
7
|
+
/** Read out to assistive tech in place of the bare dot. */
|
|
8
|
+
label?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The only place an available update is allowed to reach outside settings: a
|
|
13
|
+
* dot on the way there. It carries no count, no copy and no action, so it can
|
|
14
|
+
* never grow into a prompt over someone's mail.
|
|
15
|
+
*/
|
|
16
|
+
export function UpdateAvailableDot({
|
|
17
|
+
children,
|
|
18
|
+
show,
|
|
19
|
+
label = "Update available",
|
|
20
|
+
}: UpdateAvailableDotProps) {
|
|
21
|
+
return (
|
|
22
|
+
<span className="relative inline-flex">
|
|
23
|
+
{children}
|
|
24
|
+
{show && (
|
|
25
|
+
<>
|
|
26
|
+
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-accent-2" />
|
|
27
|
+
<span className="sr-only">{label}</span>
|
|
28
|
+
</>
|
|
29
|
+
)}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|