@remit/web-client 0.0.297 → 0.0.299

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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarEventPane.stories.tsx +16 -0
  3. package/src/components/calendar/OpenCalendarEvent.tsx +23 -6
  4. package/src/components/calendar/WriteCalendarEvent.tsx +8 -4
  5. package/src/components/calendar/calendar-write-flow.render.test.ts +39 -22
  6. package/src/components/compose/ComposeForm.tsx +116 -0
  7. package/src/components/compose/attachment-only-source.render.test.ts +1 -0
  8. package/src/components/compose/compose-send-stops-autosave.render.test.ts +1 -0
  9. package/src/components/compose/compose-starts-a-second-message.render.test.ts +1 -0
  10. package/src/components/compose/forward-autosave-is-not-fatal.render.test.ts +1 -0
  11. package/src/components/compose/quote-leaves-with-the-send.render.test.ts +1 -0
  12. package/src/components/compose/reopened-draft-writes-nothing.render.test.ts +1 -0
  13. package/src/components/compose/reply-all-on-resumed-draft.render.test.ts +1 -0
  14. package/src/hooks/calendar/calendar-collections.render.test.ts +23 -1
  15. package/src/hooks/calendar/draft.test.ts +179 -30
  16. package/src/hooks/calendar/draft.ts +59 -33
  17. package/src/hooks/calendar/index.ts +5 -1
  18. package/src/hooks/calendar/instance.test.ts +70 -0
  19. package/src/hooks/calendar/instance.ts +28 -15
  20. package/src/hooks/calendar/recurrence-rule.test.ts +43 -1
  21. package/src/hooks/calendar/recurrence-rule.ts +44 -0
  22. package/src/hooks/calendar/resource.test.ts +44 -1
  23. package/src/hooks/calendar/resource.ts +30 -0
  24. package/src/hooks/calendar/selection.ts +7 -4
  25. package/src/hooks/calendar/useDraftClashes.ts +3 -1
  26. package/src/hooks/calendar/window.test.ts +28 -0
  27. package/src/hooks/calendar/window.ts +20 -0
  28. package/src/hooks/useComposeAttachments.ts +103 -0
  29. package/src/hooks/useIntelligenceCalendar.ts +2 -0
  30. package/src/hooks/useIntelligenceData.ts +2 -2
  31. package/src/lib/calendar-format.ts +7 -2
  32. package/src/lib/compose-attachment-controller.test.ts +242 -0
  33. package/src/lib/compose-attachment-controller.ts +415 -0
  34. package/src/routes/settings/-accounts.test.ts +18 -0
  35. package/src/routes/settings/accounts.tsx +8 -1
  36. 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.297",
3
+ "version": "0.0.299",
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(rrule, startTime) ?? "Repeats");
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
- editing.before,
146
- draft,
147
- timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR,
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
- draft,
64
- timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR,
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
- describe("editing one morning of a repeating event", () => {
300
- it("asks what the change applies to before opening the form", async () => {
301
- await mount(
302
- `${WEEK}/${OBJECT}/${RECURRENCE}`,
303
- answering(() => ({}), [occurrence], series),
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
- assert.doesNotMatch(
307
- harness?.text() ?? "",
308
- /What should the change apply to/,
309
- "the question is asked when Edit is pressed, not before",
310
- );
311
- harness?.click(harness.byText("button", "Edit"));
312
- await settle();
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
- const asked = harness?.text() ?? "";
315
- assert.match(asked, /Standup repeats/);
316
- assert.match(asked, /What should the change apply to/);
317
- assert.match(
318
- asked,
319
- /Every weekday, 09:15/,
320
- "the rule is read back in words, not as an RRULE",
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
  >
@@ -76,6 +76,7 @@ const outboxEntry = () => ({
76
76
  accountId: ACCOUNT_ID,
77
77
  fromAddress: account.email,
78
78
  toAddresses: ["dana@example.com"],
79
+ attachments: [],
79
80
  ccAddresses: [],
80
81
  bccAddresses: [],
81
82
  references: [],
@@ -72,6 +72,7 @@ const outboxEntry = (status: string) => ({
72
72
  accountId: ACCOUNT_ID,
73
73
  fromAddress: account.email,
74
74
  toAddresses: ["them@example.com"],
75
+ attachments: [],
75
76
  ccAddresses: [],
76
77
  bccAddresses: [],
77
78
  references: [],
@@ -46,6 +46,7 @@ const draft = {
46
46
  accountId: ACCOUNT_ID,
47
47
  fromAddress: account.email,
48
48
  toAddresses: ["them@example.com"],
49
+ attachments: [],
49
50
  ccAddresses: [],
50
51
  bccAddresses: [],
51
52
  references: [],
@@ -69,6 +69,7 @@ const outboxEntry = () => ({
69
69
  accountId: ACCOUNT_ID,
70
70
  fromAddress: account.email,
71
71
  toAddresses: ["them@example.com"],
72
+ attachments: [],
72
73
  ccAddresses: [],
73
74
  bccAddresses: [],
74
75
  references: [],
@@ -68,6 +68,7 @@ const outboxEntry = () => ({
68
68
  accountId: ACCOUNT_ID,
69
69
  fromAddress: account.email,
70
70
  toAddresses: ["dana@example.com"],
71
+ attachments: [],
71
72
  ccAddresses: [],
72
73
  bccAddresses: [],
73
74
  references: [],
@@ -46,6 +46,7 @@ const failedEntry = {
46
46
  accountId: ACCOUNT_ID,
47
47
  fromAddress: account.email,
48
48
  toAddresses: ["typo@exmaple.com"],
49
+ attachments: [],
49
50
  ccAddresses: [],
50
51
  bccAddresses: [],
51
52
  references: [],
@@ -68,6 +68,7 @@ const draft = {
68
68
  accountId: ACCOUNT_ID,
69
69
  fromAddress: account.email,
70
70
  toAddresses: ["them@example.com"],
71
+ attachments: [],
71
72
  ccAddresses: [],
72
73
  bccAddresses: [],
73
74
  references: [],
@@ -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);