@remit/web-client 0.0.298 → 0.0.300
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/calendar/CalendarEventPane.stories.tsx +16 -0
- package/src/components/calendar/OpenCalendarEvent.tsx +23 -6
- package/src/components/calendar/WriteCalendarEvent.tsx +8 -4
- package/src/components/calendar/calendar-write-flow.render.test.ts +39 -22
- package/src/components/compose/ComposeForm.tsx +116 -0
- package/src/components/compose/attachment-only-source.render.test.ts +1 -0
- package/src/components/compose/compose-send-stops-autosave.render.test.ts +1 -0
- package/src/components/compose/compose-starts-a-second-message.render.test.ts +1 -0
- package/src/components/compose/forward-autosave-is-not-fatal.render.test.ts +1 -0
- package/src/components/compose/quote-leaves-with-the-send.render.test.ts +1 -0
- package/src/components/compose/reopened-draft-writes-nothing.render.test.ts +1 -0
- package/src/components/compose/reply-all-on-resumed-draft.render.test.ts +1 -0
- package/src/components/mail/MailSidebarAdapter.tsx +2 -0
- package/src/hooks/calendar/calendar-collections.render.test.ts +23 -1
- package/src/hooks/calendar/draft.test.ts +179 -30
- package/src/hooks/calendar/draft.ts +59 -33
- package/src/hooks/calendar/index.ts +5 -1
- package/src/hooks/calendar/instance.test.ts +70 -0
- package/src/hooks/calendar/instance.ts +28 -15
- package/src/hooks/calendar/recurrence-rule.test.ts +43 -1
- package/src/hooks/calendar/recurrence-rule.ts +44 -0
- package/src/hooks/calendar/resource.test.ts +44 -1
- package/src/hooks/calendar/resource.ts +30 -0
- package/src/hooks/calendar/selection.ts +7 -4
- package/src/hooks/calendar/useDraftClashes.ts +3 -1
- package/src/hooks/calendar/window.test.ts +28 -0
- package/src/hooks/calendar/window.ts +20 -0
- package/src/hooks/useComposeAttachments.ts +103 -0
- package/src/hooks/useIntelligenceCalendar.ts +2 -0
- package/src/lib/calendar-format.ts +7 -2
- package/src/lib/compose-attachment-controller.test.ts +242 -0
- package/src/lib/compose-attachment-controller.ts +415 -0
- package/src/lib/folder-failure.test.ts +29 -0
- package/src/lib/folder-failure.ts +10 -0
- package/src/routes/settings/folders.tsx +2 -0
- package/src/test-support/fixtures.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.300",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -83,6 +83,22 @@ export const Occurrence: Story = {
|
|
|
83
83
|
},
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* An all-day event is a civil date, so the pane names that day on every
|
|
88
|
+
* device — a reader west of UTC does not see it on the day before.
|
|
89
|
+
*/
|
|
90
|
+
export const AllDay: Story = {
|
|
91
|
+
args: {
|
|
92
|
+
event: {
|
|
93
|
+
...event,
|
|
94
|
+
title: "Audit day",
|
|
95
|
+
start: "2026-06-12",
|
|
96
|
+
end: "2026-06-13",
|
|
97
|
+
allDay: true,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
86
102
|
/** An old link, or a week the event is not in. Never a blank pane. */
|
|
87
103
|
export const NotOnThisWeek: Story = {
|
|
88
104
|
args: { event: undefined, calendar: undefined },
|
|
@@ -9,11 +9,15 @@ import { CalendarEventPane } from "@/components/calendar/CalendarEventPane";
|
|
|
9
9
|
import {
|
|
10
10
|
type CalendarWriteOutcome,
|
|
11
11
|
calendarInstanceId,
|
|
12
|
+
deviceTimeZone,
|
|
12
13
|
draftFromEvent,
|
|
13
14
|
emptyDraft,
|
|
15
|
+
isoOnClock,
|
|
16
|
+
moveRule,
|
|
14
17
|
patchFromDrafts,
|
|
15
18
|
rruleFromIcalData,
|
|
16
19
|
type ScopedWrite,
|
|
20
|
+
storedAnchorZone,
|
|
17
21
|
textFromIcalData,
|
|
18
22
|
textFromRrule,
|
|
19
23
|
UNZONED_CALENDAR,
|
|
@@ -87,8 +91,22 @@ export function OpenCalendarEvent({
|
|
|
87
91
|
const stored = resource ? textFromIcalData(resource.icalData) : undefined;
|
|
88
92
|
const rrule = resource ? rruleFromIcalData(resource.icalData) : "";
|
|
89
93
|
const startTime = event && !event.allDay ? event.start.slice(11, 16) : "";
|
|
94
|
+
const anchor = resource
|
|
95
|
+
? storedAnchorZone(
|
|
96
|
+
resource.icalData,
|
|
97
|
+
timeZoneByCalendarId[event?.calendarId ?? ""] ?? UNZONED_CALENDAR,
|
|
98
|
+
)
|
|
99
|
+
: UNZONED_CALENDAR;
|
|
100
|
+
const shownRule =
|
|
101
|
+
event && !event.allDay
|
|
102
|
+
? moveRule(
|
|
103
|
+
rrule,
|
|
104
|
+
isoOnClock(event.start, anchor === "" ? "UTC" : anchor).slice(0, 10),
|
|
105
|
+
event.start.slice(0, 10),
|
|
106
|
+
)
|
|
107
|
+
: rrule;
|
|
90
108
|
const repeat =
|
|
91
|
-
rrule === "" ? "" : (textFromRrule(
|
|
109
|
+
rrule === "" ? "" : (textFromRrule(shownRule, startTime) ?? "Repeats");
|
|
92
110
|
|
|
93
111
|
// Scoping needs the occurrence to anchor at, so it is offered only where the
|
|
94
112
|
// address names one. A series opened by itself has one honest answer, which
|
|
@@ -141,11 +159,10 @@ export function OpenCalendarEvent({
|
|
|
141
159
|
|
|
142
160
|
const saveEdit = () => {
|
|
143
161
|
if (!editing || !draft) return;
|
|
144
|
-
const patch = patchFromDrafts(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
);
|
|
162
|
+
const patch = patchFromDrafts(editing.before, draft, {
|
|
163
|
+
clock: deviceTimeZone(),
|
|
164
|
+
anchor,
|
|
165
|
+
});
|
|
149
166
|
if (!patch.ok) {
|
|
150
167
|
setProblem(patch.problem);
|
|
151
168
|
return;
|
|
@@ -3,7 +3,9 @@ import { useEffect, useState } from "react";
|
|
|
3
3
|
import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
|
|
4
4
|
import { useCalendarComposeSeed } from "@/components/calendar/CalendarComposeSeed";
|
|
5
5
|
import {
|
|
6
|
+
anchorZoneFor,
|
|
6
7
|
createInputFromDraft,
|
|
8
|
+
deviceTimeZone,
|
|
7
9
|
emptyDraft,
|
|
8
10
|
UNZONED_CALENDAR,
|
|
9
11
|
useCalendars,
|
|
@@ -59,10 +61,12 @@ export function WriteCalendarEvent({ onClose }: WriteCalendarEventProps) {
|
|
|
59
61
|
}, [firstCalendarId]);
|
|
60
62
|
|
|
61
63
|
const save = () => {
|
|
62
|
-
const built = createInputFromDraft(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
const built = createInputFromDraft(draft, {
|
|
65
|
+
clock: deviceTimeZone(),
|
|
66
|
+
anchor: anchorZoneFor(
|
|
67
|
+
timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR,
|
|
68
|
+
),
|
|
69
|
+
});
|
|
66
70
|
if (!built.ok) {
|
|
67
71
|
setProblem(built.problem);
|
|
68
72
|
return;
|
|
@@ -296,30 +296,47 @@ describe("deleting an event from the event route", () => {
|
|
|
296
296
|
});
|
|
297
297
|
});
|
|
298
298
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
299
|
+
const askedOnDevice = async (clock: string): Promise<void> => {
|
|
300
|
+
await mount(
|
|
301
|
+
`${WEEK}/${OBJECT}/${RECURRENCE}`,
|
|
302
|
+
answering(() => ({}), [occurrence], series),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
assert.doesNotMatch(
|
|
306
|
+
harness?.text() ?? "",
|
|
307
|
+
/What should the change apply to/,
|
|
308
|
+
"the question is asked when Edit is pressed, not before",
|
|
309
|
+
);
|
|
310
|
+
harness?.click(harness.byText("button", "Edit"));
|
|
311
|
+
await settle();
|
|
305
312
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
+
const asked = harness?.text() ?? "";
|
|
314
|
+
assert.match(asked, /Standup repeats/);
|
|
315
|
+
assert.match(asked, /What should the change apply to/);
|
|
316
|
+
assert.match(
|
|
317
|
+
asked,
|
|
318
|
+
new RegExp(`Every weekday, ${clock}`),
|
|
319
|
+
"the rule is read back in words, not as an RRULE",
|
|
320
|
+
);
|
|
321
|
+
};
|
|
313
322
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
+
describe("editing one morning of a repeating event", () => {
|
|
324
|
+
for (const [zone, clock] of [
|
|
325
|
+
["UTC", "07:15"],
|
|
326
|
+
["Europe/Amsterdam", "09:15"],
|
|
327
|
+
["America/New_York", "03:15"],
|
|
328
|
+
]) {
|
|
329
|
+
it(`asks what the change applies to before opening the form, on ${zone}`, async () => {
|
|
330
|
+
const runner = process.env.TZ;
|
|
331
|
+
process.env.TZ = zone;
|
|
332
|
+
try {
|
|
333
|
+
await askedOnDevice(clock);
|
|
334
|
+
} finally {
|
|
335
|
+
if (runner === undefined) Reflect.deleteProperty(process.env, "TZ");
|
|
336
|
+
else process.env.TZ = runner;
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
323
340
|
|
|
324
341
|
it("sends the answer with the edit, naming the occurrence it meant", async () => {
|
|
325
342
|
await mount(
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
import {
|
|
12
12
|
Banner,
|
|
13
13
|
ComposeActionBar,
|
|
14
|
+
ComposeAttachments,
|
|
14
15
|
ComposeBodySkeleton,
|
|
15
16
|
ComposeFormShell,
|
|
16
17
|
ComposeHeader,
|
|
@@ -41,6 +42,10 @@ import {
|
|
|
41
42
|
useRef,
|
|
42
43
|
useState,
|
|
43
44
|
} from "react";
|
|
45
|
+
import {
|
|
46
|
+
type DraftForAttachment,
|
|
47
|
+
useComposeAttachments,
|
|
48
|
+
} from "../../hooks/useComposeAttachments";
|
|
44
49
|
import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
|
|
45
50
|
import { useSaveDraft } from "../../hooks/useSaveDraft";
|
|
46
51
|
import { useSignature } from "../../hooks/useSignature.js";
|
|
@@ -228,6 +233,13 @@ const QUOTE_LOADING_MESSAGE = "Loading the message you're quoting.";
|
|
|
228
233
|
const QUOTE_FAILED_MESSAGE =
|
|
229
234
|
"The message you're quoting couldn't be loaded, so it won't be included.";
|
|
230
235
|
|
|
236
|
+
/**
|
|
237
|
+
* A file needs a draft to hang from, and a draft is created against its To
|
|
238
|
+
* address, so a message with none cannot take a file yet.
|
|
239
|
+
*/
|
|
240
|
+
const ATTACH_NEEDS_TO_ADDRESS =
|
|
241
|
+
"Add a To address before attaching a file — the draft the file is kept with is saved against it.";
|
|
242
|
+
|
|
231
243
|
/** A field holding text that is not an address, and which field it is. */
|
|
232
244
|
interface UnparsedField {
|
|
233
245
|
label: string;
|
|
@@ -763,6 +775,92 @@ export const ComposeForm = ({
|
|
|
763
775
|
onDraftCreated: adoptCreatedDraft,
|
|
764
776
|
});
|
|
765
777
|
|
|
778
|
+
const ensureDraftForAttachment =
|
|
779
|
+
useCallback(async (): Promise<DraftForAttachment> => {
|
|
780
|
+
if (outboxMessageId) return { outcome: "ready", outboxMessageId };
|
|
781
|
+
if (!selectedAccountId) {
|
|
782
|
+
return {
|
|
783
|
+
outcome: "refused",
|
|
784
|
+
reason: "Choose an account to send from before attaching a file.",
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
if (toAddresses.length === 0) {
|
|
788
|
+
return { outcome: "refused", reason: ATTACH_NEEDS_TO_ADDRESS };
|
|
789
|
+
}
|
|
790
|
+
const { htmlBody, textBody } = outgoingBody(
|
|
791
|
+
bodyMode,
|
|
792
|
+
body,
|
|
793
|
+
composeLanguage,
|
|
794
|
+
quotedBlock,
|
|
795
|
+
);
|
|
796
|
+
const saved = await saveImmediately({
|
|
797
|
+
accountId: selectedAccountId,
|
|
798
|
+
toAddresses: toAddresses.map((a) => a.email),
|
|
799
|
+
ccAddresses:
|
|
800
|
+
ccAddresses.length > 0 ? ccAddresses.map((a) => a.email) : undefined,
|
|
801
|
+
bccAddresses:
|
|
802
|
+
bccAddresses.length > 0
|
|
803
|
+
? bccAddresses.map((a) => a.email)
|
|
804
|
+
: undefined,
|
|
805
|
+
subject: subject || undefined,
|
|
806
|
+
textBody,
|
|
807
|
+
htmlBody,
|
|
808
|
+
...(sourceMessage && (mode === "reply" || mode === "reply-all")
|
|
809
|
+
? getReferences(sourceMessage)
|
|
810
|
+
: {}),
|
|
811
|
+
});
|
|
812
|
+
if (saved.outcome === "failed") {
|
|
813
|
+
return {
|
|
814
|
+
outcome: "refused",
|
|
815
|
+
reason: `The draft could not be saved, so the file was not attached: ${formatErrorDetail(saved.error) ?? "the request failed"}.`,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
return { outcome: "ready", outboxMessageId: saved.outboxMessageId };
|
|
819
|
+
}, [
|
|
820
|
+
outboxMessageId,
|
|
821
|
+
selectedAccountId,
|
|
822
|
+
toAddresses,
|
|
823
|
+
ccAddresses,
|
|
824
|
+
bccAddresses,
|
|
825
|
+
subject,
|
|
826
|
+
body,
|
|
827
|
+
bodyMode,
|
|
828
|
+
composeLanguage,
|
|
829
|
+
quotedBlock,
|
|
830
|
+
sourceMessage,
|
|
831
|
+
mode,
|
|
832
|
+
saveImmediately,
|
|
833
|
+
]);
|
|
834
|
+
|
|
835
|
+
const {
|
|
836
|
+
items: attachmentItems,
|
|
837
|
+
attach,
|
|
838
|
+
retry: retryAttachment,
|
|
839
|
+
remove: removeAttachment,
|
|
840
|
+
load: loadAttachments,
|
|
841
|
+
reset: resetAttachments,
|
|
842
|
+
blockingReason: attachmentBlockingReason,
|
|
843
|
+
} = useComposeAttachments({
|
|
844
|
+
ensureDraft: ensureDraftForAttachment,
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const attachmentDocumentRef = useRef(outboxMessageId);
|
|
848
|
+
useEffect(() => {
|
|
849
|
+
const previous = attachmentDocumentRef.current;
|
|
850
|
+
if (previous === outboxMessageId) return;
|
|
851
|
+
attachmentDocumentRef.current = outboxMessageId;
|
|
852
|
+
if (previous === undefined) return;
|
|
853
|
+
resetAttachments(outboxMessageId);
|
|
854
|
+
}, [outboxMessageId, resetAttachments]);
|
|
855
|
+
|
|
856
|
+
const attachmentsLoadedForRef = useRef<string | undefined>(undefined);
|
|
857
|
+
useEffect(() => {
|
|
858
|
+
if (!draftData || draftData.outboxMessageId !== outboxMessageId) return;
|
|
859
|
+
if (attachmentsLoadedForRef.current === draftData.outboxMessageId) return;
|
|
860
|
+
attachmentsLoadedForRef.current = draftData.outboxMessageId;
|
|
861
|
+
loadAttachments(draftData.outboxMessageId, draftData.attachments);
|
|
862
|
+
}, [draftData, outboxMessageId, loadAttachments]);
|
|
863
|
+
|
|
766
864
|
// Auto-save runs on a debounce, so a failure has no inline call site to
|
|
767
865
|
// surface it. Push the real error detail to a banner instead of leaving only
|
|
768
866
|
// the muted "Save failed" status dot. A fatal 5xx also hits the global
|
|
@@ -850,6 +948,9 @@ export const ComposeForm = ({
|
|
|
850
948
|
if (quoteIsLoading || quoteSourceIsLoading) {
|
|
851
949
|
return { status: "blocked", reason: QUOTE_LOADING_MESSAGE };
|
|
852
950
|
}
|
|
951
|
+
if (attachmentBlockingReason) {
|
|
952
|
+
return { status: "blocked", reason: attachmentBlockingReason };
|
|
953
|
+
}
|
|
853
954
|
if (unparsed) {
|
|
854
955
|
return { status: "blocked", reason: unparsedRefusal(unparsed) };
|
|
855
956
|
}
|
|
@@ -874,6 +975,7 @@ export const ComposeForm = ({
|
|
|
874
975
|
selectedAccountMissingSmtp,
|
|
875
976
|
quoteIsLoading,
|
|
876
977
|
quoteSourceIsLoading,
|
|
978
|
+
attachmentBlockingReason,
|
|
877
979
|
nothingToForward,
|
|
878
980
|
],
|
|
879
981
|
);
|
|
@@ -1187,6 +1289,17 @@ export const ComposeForm = ({
|
|
|
1187
1289
|
/>
|
|
1188
1290
|
) : undefined
|
|
1189
1291
|
}
|
|
1292
|
+
attachments={
|
|
1293
|
+
<ComposeAttachments
|
|
1294
|
+
items={attachmentItems}
|
|
1295
|
+
onRemove={(key) => {
|
|
1296
|
+
void removeAttachment(key);
|
|
1297
|
+
}}
|
|
1298
|
+
onRetry={(key) => {
|
|
1299
|
+
void retryAttachment(key);
|
|
1300
|
+
}}
|
|
1301
|
+
/>
|
|
1302
|
+
}
|
|
1190
1303
|
actionBar={
|
|
1191
1304
|
<ComposeActionBar
|
|
1192
1305
|
send={sendState}
|
|
@@ -1194,6 +1307,9 @@ export const ComposeForm = ({
|
|
|
1194
1307
|
onBlocked={reportBlocked}
|
|
1195
1308
|
onDiscard={handleDiscard}
|
|
1196
1309
|
save={saveState}
|
|
1310
|
+
onAttach={(files) => {
|
|
1311
|
+
void attach(files);
|
|
1312
|
+
}}
|
|
1197
1313
|
/>
|
|
1198
1314
|
}
|
|
1199
1315
|
>
|
|
@@ -12,6 +12,7 @@ import { NavSidebar } from "@remit/ui";
|
|
|
12
12
|
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
13
13
|
import { useCallback, useMemo } from "react";
|
|
14
14
|
import { useTranslation } from "react-i18next";
|
|
15
|
+
import { folderFailure } from "@/lib/folder-failure";
|
|
15
16
|
import {
|
|
16
17
|
buildMailboxRoleMap,
|
|
17
18
|
labelForMailbox,
|
|
@@ -103,6 +104,7 @@ function toNavMailbox(
|
|
|
103
104
|
unseen: showBadge ? mb.unseenCount : undefined,
|
|
104
105
|
role,
|
|
105
106
|
fullPath: mb.fullPath,
|
|
107
|
+
failure: folderFailure(mb),
|
|
106
108
|
};
|
|
107
109
|
}
|
|
108
110
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { afterEach, describe, it } from "node:test";
|
|
2
|
+
import { after, afterEach, before, describe, it } from "node:test";
|
|
3
3
|
import type {
|
|
4
4
|
RemitImapCalendarEventInstance,
|
|
5
5
|
RemitImapCalendarResponse,
|
|
@@ -203,6 +203,28 @@ const clashesFor = async (
|
|
|
203
203
|
};
|
|
204
204
|
|
|
205
205
|
describe("the clashes a draft runs into", () => {
|
|
206
|
+
const runner = process.env.TZ;
|
|
207
|
+
before(() => {
|
|
208
|
+
process.env.TZ = "UTC";
|
|
209
|
+
});
|
|
210
|
+
after(() => {
|
|
211
|
+
if (runner === undefined) Reflect.deleteProperty(process.env, "TZ");
|
|
212
|
+
else process.env.TZ = runner;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("reads the draft on the device's clock, not the calendar's", async () => {
|
|
216
|
+
process.env.TZ = "Europe/Amsterdam";
|
|
217
|
+
try {
|
|
218
|
+
const found = await clashesFor(
|
|
219
|
+
draft({ startTime: "12:00", endTime: "13:00" }),
|
|
220
|
+
[instance({})],
|
|
221
|
+
);
|
|
222
|
+
assert.equal(found?.length, 1);
|
|
223
|
+
} finally {
|
|
224
|
+
process.env.TZ = "UTC";
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
206
228
|
it("names an event the span overlaps", async () => {
|
|
207
229
|
const found = await clashesFor(draft({}), [instance({})]);
|
|
208
230
|
assert.equal(found?.length, 1);
|