@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
|
@@ -15,26 +15,29 @@ import type {
|
|
|
15
15
|
CalendarEventData,
|
|
16
16
|
ZoneCertainty,
|
|
17
17
|
} from "@remit/ui";
|
|
18
|
+
import { isoOnClock } from "./window";
|
|
18
19
|
|
|
19
20
|
/** The device's own clock, the only zone a browser can honestly draw in. */
|
|
20
21
|
export const deviceTimeZone = (): string =>
|
|
21
22
|
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
22
23
|
|
|
24
|
+
/** A collection that names no zone of its own, spelled the way the API spells it. */
|
|
25
|
+
export const UNZONED_CALENDAR = "";
|
|
26
|
+
|
|
23
27
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* holds no spelling of UTC at all — not `UTC`, not `Etc/UTC` — and refuses
|
|
30
|
-
* anything else. Sending nothing is what the API asks for, and it is the only
|
|
31
|
-
* thing it accepts.
|
|
32
|
-
*
|
|
33
|
-
* The device's zone is not the fallback either. The listing returns this
|
|
34
|
-
* collection's occurrences on its own clock, so answering with the reader's
|
|
35
|
-
* would rewrite every time they saved by the difference between the two.
|
|
28
|
+
* The zone a write anchors an event in: the collection's, or where it names
|
|
29
|
+
* none, the device's — a weekly 09:00 written from New York into an unzoned
|
|
30
|
+
* calendar would otherwise be stored in UTC and meet at 10:00 once the clocks
|
|
31
|
+
* change. UTC itself has no name the server takes as a TZID, so a device on it
|
|
32
|
+
* sends nothing, which the server reads as UTC.
|
|
36
33
|
*/
|
|
37
|
-
export
|
|
34
|
+
export function anchorZoneFor(collectionZone: string): string {
|
|
35
|
+
if (collectionZone !== UNZONED_CALENDAR) return collectionZone;
|
|
36
|
+
const device = deviceTimeZone();
|
|
37
|
+
return Intl.supportedValuesOf("timeZone").includes(device)
|
|
38
|
+
? device
|
|
39
|
+
: UNZONED_CALENDAR;
|
|
40
|
+
}
|
|
38
41
|
|
|
39
42
|
const COLORS: Record<string, CalendarColorId> = {
|
|
40
43
|
Cal1: "cal-1",
|
|
@@ -99,17 +102,27 @@ export function toCalendarDescriptor(
|
|
|
99
102
|
* One occurrence, drawn. `location`, `notes` and the guest list are projections
|
|
100
103
|
* the listing does not carry — the reading pane fetches the resource itself for
|
|
101
104
|
* those, and a grid chip has no room for them anyway.
|
|
105
|
+
*
|
|
106
|
+
* A timed occurrence is re-read on `clockZone`, the clock every surface draws
|
|
107
|
+
* on. An all-day one is a civil date and keeps only its date: midnight with an
|
|
108
|
+
* offset is an instant, and a device west of it reads that instant as the day
|
|
109
|
+
* before.
|
|
102
110
|
*/
|
|
103
111
|
export function toCalendarEventData(
|
|
104
112
|
instance: RemitImapCalendarEventInstance,
|
|
105
113
|
timeZone: string,
|
|
114
|
+
clockZone: string,
|
|
106
115
|
): CalendarEventData {
|
|
107
116
|
return {
|
|
108
117
|
id: calendarInstanceId(instance.calendarObjectId, instance.recurrenceId),
|
|
109
118
|
calendarId: instance.calendarId,
|
|
110
119
|
title: instance.summary,
|
|
111
|
-
start: instance.
|
|
112
|
-
|
|
120
|
+
start: instance.allDay
|
|
121
|
+
? instance.start.slice(0, 10)
|
|
122
|
+
: isoOnClock(instance.start, clockZone),
|
|
123
|
+
end: instance.allDay
|
|
124
|
+
? instance.end.slice(0, 10)
|
|
125
|
+
: isoOnClock(instance.end, clockZone),
|
|
113
126
|
allDay: instance.allDay,
|
|
114
127
|
location: "",
|
|
115
128
|
notes: "",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import assert from "node:assert/strict";
|
|
13
13
|
import { describe, it } from "node:test";
|
|
14
14
|
import { repeatChoices } from "@remit/ui";
|
|
15
|
-
import { rruleFromText, textFromRrule } from "./recurrence-rule";
|
|
15
|
+
import { moveRule, rruleFromText, textFromRrule } from "./recurrence-rule";
|
|
16
16
|
|
|
17
17
|
const DATE = "2026-06-10";
|
|
18
18
|
const TIME = "09:15";
|
|
@@ -90,3 +90,45 @@ describe("a rule written by something else", () => {
|
|
|
90
90
|
assert.equal(textFromRrule("FREQ=WEEKLY", ""), undefined);
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
describe("a rule moved onto the day the event's own zone shows", () => {
|
|
95
|
+
it("shifts the weekdays of a weekly rule with the day", () => {
|
|
96
|
+
assert.equal(
|
|
97
|
+
moveRule("FREQ=WEEKLY;BYDAY=SU", "2026-06-14", "2026-06-15"),
|
|
98
|
+
"FREQ=WEEKLY;BYDAY=MO",
|
|
99
|
+
);
|
|
100
|
+
assert.equal(
|
|
101
|
+
moveRule("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "2026-06-15", "2026-06-14"),
|
|
102
|
+
"FREQ=WEEKLY;BYDAY=SU,MO,TU,WE,TH",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("reads a monthly position off the new date rather than shifting it", () => {
|
|
107
|
+
assert.equal(
|
|
108
|
+
moveRule("FREQ=MONTHLY;BYDAY=2WE", "2026-06-10", "2026-06-11"),
|
|
109
|
+
"FREQ=MONTHLY;BYDAY=2TH",
|
|
110
|
+
);
|
|
111
|
+
assert.equal(
|
|
112
|
+
moveRule("FREQ=MONTHLY;BYDAY=2WE", "2026-07-14", "2026-07-15"),
|
|
113
|
+
"FREQ=MONTHLY;BYDAY=3WE",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("moves a yearly date, month and all", () => {
|
|
118
|
+
assert.equal(
|
|
119
|
+
moveRule(
|
|
120
|
+
"FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=31",
|
|
121
|
+
"2026-05-31",
|
|
122
|
+
"2026-06-01",
|
|
123
|
+
),
|
|
124
|
+
"FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=1",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("leaves a rule alone on the same day", () => {
|
|
129
|
+
assert.equal(
|
|
130
|
+
moveRule("FREQ=WEEKLY;BYDAY=SU", "2026-06-14", "2026-06-14"),
|
|
131
|
+
"FREQ=WEEKLY;BYDAY=SU",
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -131,3 +131,47 @@ export function textFromRrule(
|
|
|
131
131
|
|
|
132
132
|
return undefined;
|
|
133
133
|
}
|
|
134
|
+
|
|
135
|
+
const dayNumber = (date: string): number =>
|
|
136
|
+
Date.parse(`${date}T00:00:00Z`) / 86_400_000;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A rule picked on one civil date, rewritten for the same moment read on
|
|
140
|
+
* another. The picker offers rules off the day the device shows, and the
|
|
141
|
+
* server expands them on the day the event's own zone shows; 21:00 on a New
|
|
142
|
+
* York Sunday is a Tokyo Monday, so "every Sunday" has to be stored as BYDAY=MO
|
|
143
|
+
* or the series lands on Saturday evenings. Weekdays shift with the day; a
|
|
144
|
+
* monthly or yearly position is read off the new date itself, because the day
|
|
145
|
+
* after the second Wednesday is not always the second Thursday.
|
|
146
|
+
*/
|
|
147
|
+
export function moveRule(
|
|
148
|
+
rule: string,
|
|
149
|
+
fromDate: string,
|
|
150
|
+
toDate: string,
|
|
151
|
+
): string {
|
|
152
|
+
const shift = dayNumber(toDate) - dayNumber(fromDate);
|
|
153
|
+
if (rule === "" || shift === 0 || Number.isNaN(shift)) return rule;
|
|
154
|
+
const [year, month, dayOfMonth] = toDate.split("-").map(Number);
|
|
155
|
+
const weekday = new Date(Date.UTC(year, month - 1, dayOfMonth)).getUTCDay();
|
|
156
|
+
const frequency = /FREQ=(\w+)/i.exec(rule)?.[1]?.toUpperCase();
|
|
157
|
+
return rule
|
|
158
|
+
.split(";")
|
|
159
|
+
.map((part) => {
|
|
160
|
+
const [name, value = ""] = part.split("=");
|
|
161
|
+
const key = name.toUpperCase();
|
|
162
|
+
if (key === "BYDAY" && frequency === "MONTHLY")
|
|
163
|
+
return `BYDAY=${ORDINAL_NUMBERS[Math.min(Math.floor((dayOfMonth - 1) / 7), 4)]}${BYDAY[weekday]}`;
|
|
164
|
+
if (key === "BYDAY")
|
|
165
|
+
return `BYDAY=${value
|
|
166
|
+
.split(",")
|
|
167
|
+
.map((code) => {
|
|
168
|
+
const index = BYDAY.indexOf(code.toUpperCase());
|
|
169
|
+
return index === -1 ? code : BYDAY[(index + shift + 7) % 7];
|
|
170
|
+
})
|
|
171
|
+
.join(",")}`;
|
|
172
|
+
if (key === "BYMONTH") return `BYMONTH=${month}`;
|
|
173
|
+
if (key === "BYMONTHDAY") return `BYMONTHDAY=${dayOfMonth}`;
|
|
174
|
+
return part;
|
|
175
|
+
})
|
|
176
|
+
.join(";");
|
|
177
|
+
}
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
10
|
import { describe, it } from "node:test";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
rruleFromIcalData,
|
|
13
|
+
storedAnchorZone,
|
|
14
|
+
textFromIcalData,
|
|
15
|
+
} from "./resource";
|
|
12
16
|
|
|
13
17
|
const ics = (...lines: string[]): string =>
|
|
14
18
|
[
|
|
@@ -96,3 +100,42 @@ describe("the text a listing does not carry", () => {
|
|
|
96
100
|
});
|
|
97
101
|
});
|
|
98
102
|
});
|
|
103
|
+
|
|
104
|
+
describe("the zone an edit writes an event back in", () => {
|
|
105
|
+
const vevent = (dtstart: string): string =>
|
|
106
|
+
`BEGIN:VEVENT\r\n${dtstart}\r\nEND:VEVENT`;
|
|
107
|
+
|
|
108
|
+
it("is the TZID the stored start names", () => {
|
|
109
|
+
assert.equal(
|
|
110
|
+
storedAnchorZone(
|
|
111
|
+
vevent("DTSTART;TZID=Asia/Tokyo:20340222T080000"),
|
|
112
|
+
"Europe/Amsterdam",
|
|
113
|
+
),
|
|
114
|
+
"Asia/Tokyo",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("is UTC for a start stored as a UTC instant, whatever the calendar says", () => {
|
|
119
|
+
assert.equal(
|
|
120
|
+
storedAnchorZone(vevent("DTSTART:20260615T010000Z"), "Europe/Amsterdam"),
|
|
121
|
+
"",
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("is the calendar's zone for a floating start", () => {
|
|
126
|
+
assert.equal(
|
|
127
|
+
storedAnchorZone(vevent("DTSTART:20260615T010000"), "Europe/Amsterdam"),
|
|
128
|
+
"Europe/Amsterdam",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("is the calendar's zone for a TZID nothing can resolve", () => {
|
|
133
|
+
assert.equal(
|
|
134
|
+
storedAnchorZone(
|
|
135
|
+
vevent("DTSTART;TZID=W. Europe Standard Time:20260615T010000"),
|
|
136
|
+
"Europe/Amsterdam",
|
|
137
|
+
),
|
|
138
|
+
"Europe/Amsterdam",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { anchorZoneFor } from "./instance";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The few properties a reading pane needs that the occurrence listing does not
|
|
3
5
|
* carry, read off the stored resource.
|
|
@@ -72,3 +74,31 @@ export const textFromIcalData = (icalData: string): CalendarResourceText => ({
|
|
|
72
74
|
location: unescapeText(firstProperty(icalData, "LOCATION")),
|
|
73
75
|
description: unescapeText(firstProperty(icalData, "DESCRIPTION")),
|
|
74
76
|
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The zone an edit writes an existing event back in: the one its master's
|
|
80
|
+
* DTSTART is stored in. A UTC start stays UTC (`""`), a floating one stays in
|
|
81
|
+
* the collection's zone, and a TZID nothing can resolve is read the way the
|
|
82
|
+
* server reads it, in the collection's zone. Only a resource with no DTSTART
|
|
83
|
+
* falls back to where a new event would be anchored.
|
|
84
|
+
*/
|
|
85
|
+
export function storedAnchorZone(
|
|
86
|
+
icalData: string,
|
|
87
|
+
collectionZone: string,
|
|
88
|
+
): string {
|
|
89
|
+
const line = unfold(icalData)
|
|
90
|
+
.split(/\r?\n/)
|
|
91
|
+
.find(
|
|
92
|
+
(candidate) =>
|
|
93
|
+
candidate.startsWith("DTSTART") &&
|
|
94
|
+
valueAt(candidate, "DTSTART") !== undefined,
|
|
95
|
+
);
|
|
96
|
+
if (line === undefined) return anchorZoneFor(collectionZone);
|
|
97
|
+
const tzid = /;TZID="?([^;:"]+)"?/i.exec(line)?.[1];
|
|
98
|
+
if (tzid !== undefined)
|
|
99
|
+
return Intl.supportedValuesOf("timeZone").includes(tzid)
|
|
100
|
+
? tzid
|
|
101
|
+
: collectionZone;
|
|
102
|
+
if (valueAt(line, "DTSTART")?.toUpperCase().endsWith("Z")) return "";
|
|
103
|
+
return collectionZone;
|
|
104
|
+
}
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
} from "@remit/ui";
|
|
16
16
|
import { useMemo } from "react";
|
|
17
17
|
import {
|
|
18
|
+
deviceTimeZone,
|
|
18
19
|
isDrawnInstance,
|
|
19
20
|
toCalendarEventData,
|
|
20
21
|
UNZONED_CALENDAR,
|
|
@@ -79,10 +80,10 @@ export function useCalendarSelection(
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
|
-
* Occurrences as rows: the ones on a calendar being drawn, on the
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
83
|
+
* Occurrences as rows: the ones on a calendar being drawn, on the device's
|
|
84
|
+
* clock, which is the one the grid, the strip and the free time all read. Each
|
|
85
|
+
* row still carries its collection's zone, unzoned where the collection names
|
|
86
|
+
* none, because that is the zone a write anchors the event in.
|
|
86
87
|
*/
|
|
87
88
|
export function useDrawnEvents(
|
|
88
89
|
instances: readonly RemitImapCalendarEventInstance[],
|
|
@@ -91,6 +92,7 @@ export function useDrawnEvents(
|
|
|
91
92
|
): CalendarEventData[] {
|
|
92
93
|
const shownKey = shown.join(",");
|
|
93
94
|
return useMemo(() => {
|
|
95
|
+
const clockZone = deviceTimeZone();
|
|
94
96
|
const drawn = new Set(shownKey === "" ? [] : shownKey.split(","));
|
|
95
97
|
return instances
|
|
96
98
|
.filter(
|
|
@@ -101,6 +103,7 @@ export function useDrawnEvents(
|
|
|
101
103
|
toCalendarEventData(
|
|
102
104
|
instance,
|
|
103
105
|
timeZoneByCalendarId[instance.calendarId] ?? UNZONED_CALENDAR,
|
|
106
|
+
clockZone,
|
|
104
107
|
),
|
|
105
108
|
);
|
|
106
109
|
}, [instances, timeZoneByCalendarId, shownKey]);
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { useMemo } from "react";
|
|
19
19
|
import { formatEventWhen } from "@/lib/calendar-format";
|
|
20
20
|
import {
|
|
21
|
+
deviceTimeZone,
|
|
21
22
|
isDrawnInstance,
|
|
22
23
|
toCalendarEventData,
|
|
23
24
|
UNZONED_CALENDAR,
|
|
@@ -53,7 +54,7 @@ export function useDraftClashes(
|
|
|
53
54
|
enabled: checkable,
|
|
54
55
|
});
|
|
55
56
|
|
|
56
|
-
const zone =
|
|
57
|
+
const zone = deviceTimeZone();
|
|
57
58
|
|
|
58
59
|
return useMemo(() => {
|
|
59
60
|
if (!checkable || isLoading || error) return undefined;
|
|
@@ -69,6 +70,7 @@ export function useDraftClashes(
|
|
|
69
70
|
toCalendarEventData(
|
|
70
71
|
instance,
|
|
71
72
|
timeZoneByCalendarId[instance.calendarId] ?? UNZONED_CALENDAR,
|
|
73
|
+
zone,
|
|
72
74
|
),
|
|
73
75
|
);
|
|
74
76
|
return clashesWith({ start, end }, others).map((event) => ({
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
calendarWindowOfDays,
|
|
21
21
|
isoAt,
|
|
22
22
|
isoAtInZone,
|
|
23
|
+
isoOnClock,
|
|
23
24
|
} from "./window";
|
|
24
25
|
|
|
25
26
|
const day = (iso: string): string => iso.slice(0, 10);
|
|
@@ -146,3 +147,30 @@ describe("a clock time in a named zone", () => {
|
|
|
146
147
|
);
|
|
147
148
|
});
|
|
148
149
|
});
|
|
150
|
+
|
|
151
|
+
describe("an occurrence re-read on the device's clock", () => {
|
|
152
|
+
it("moves a Tokyo morning to the New York evening before", () => {
|
|
153
|
+
assert.equal(
|
|
154
|
+
isoOnClock("2034-02-22T08:00:00+09:00", "America/New_York"),
|
|
155
|
+
"2034-02-21T18:00:00-05:00",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("reads each side of a spring-forward on its own offset", () => {
|
|
160
|
+
assert.equal(
|
|
161
|
+
isoOnClock("2034-03-20T08:00:00+00:00", "Europe/Amsterdam"),
|
|
162
|
+
"2034-03-20T09:00:00+01:00",
|
|
163
|
+
);
|
|
164
|
+
assert.equal(
|
|
165
|
+
isoOnClock("2034-03-27T07:00:00+00:00", "Europe/Amsterdam"),
|
|
166
|
+
"2034-03-27T09:00:00+02:00",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("falls back to the device rather than throwing on a zone nothing knows", () => {
|
|
171
|
+
assert.match(
|
|
172
|
+
isoOnClock("2034-02-22T08:00:00+09:00", "Mars/Olympus_Mons"),
|
|
173
|
+
OFFSET,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -112,6 +112,26 @@ export function isoAtInZone(
|
|
|
112
112
|
)}`;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* An instant written again as the wall time it reads as on a zone's clock.
|
|
117
|
+
*
|
|
118
|
+
* The listing serves each occurrence on its collection's clock, and the grid,
|
|
119
|
+
* the strip and the form all read the digits. Drawing those digits as they came
|
|
120
|
+
* puts a Tokyo calendar's 08:00 at 08:00 in New York; re-reading the instant on
|
|
121
|
+
* the device's clock is what puts it at 18:00 the evening before.
|
|
122
|
+
*/
|
|
123
|
+
export function isoOnClock(iso: string, timeZone: string): string {
|
|
124
|
+
const instant = Date.parse(iso);
|
|
125
|
+
const zoned = zoneOffsetAt(timeZone, instant);
|
|
126
|
+
const offset = Number.isNaN(zoned)
|
|
127
|
+
? -new Date(instant).getTimezoneOffset()
|
|
128
|
+
: zoned;
|
|
129
|
+
const wall = new Date(instant + offset * 60_000);
|
|
130
|
+
return `${civil(wall)}T${pad(wall.getUTCHours())}:${pad(
|
|
131
|
+
wall.getUTCMinutes(),
|
|
132
|
+
)}:${pad(wall.getUTCSeconds())}${formatOffset(offset)}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
115
135
|
/** Midnight on a civil date, carrying the offset the device is on that day. */
|
|
116
136
|
export const startOfDay = (date: string): string => isoAt(date, "00:00");
|
|
117
137
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
outboxAttachmentOperationsCompleteOutboxAttachmentMutation,
|
|
3
|
+
outboxDetailOperationsMintOutboxAttachmentMutation,
|
|
4
|
+
outboxDetailOperationsUpdateOutboxMessageMutation,
|
|
5
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
|
+
import { useMutation } from "@tanstack/react-query";
|
|
7
|
+
import { useRef, useState, useSyncExternalStore } from "react";
|
|
8
|
+
import {
|
|
9
|
+
type AttachmentTransport,
|
|
10
|
+
ComposeAttachmentController,
|
|
11
|
+
type DraftForAttachment,
|
|
12
|
+
} from "../lib/compose-attachment-controller";
|
|
13
|
+
import { softErrorMeta } from "../lib/error-classifier";
|
|
14
|
+
|
|
15
|
+
export type { DraftForAttachment };
|
|
16
|
+
|
|
17
|
+
const putFile = (
|
|
18
|
+
uploadUrl: string,
|
|
19
|
+
file: File,
|
|
20
|
+
signal: AbortSignal,
|
|
21
|
+
): Promise<void> =>
|
|
22
|
+
fetch(uploadUrl, { method: "PUT", body: file, signal }).then((response) => {
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(`the server answered ${response.status}`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
interface UseComposeAttachmentsOptions {
|
|
29
|
+
ensureDraft: () => Promise<DraftForAttachment>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The composer's side of `ComposeAttachmentController`, over the app's API. */
|
|
33
|
+
export const useComposeAttachments = ({
|
|
34
|
+
ensureDraft,
|
|
35
|
+
}: UseComposeAttachmentsOptions) => {
|
|
36
|
+
const mintMutation = useMutation({
|
|
37
|
+
...outboxDetailOperationsMintOutboxAttachmentMutation(),
|
|
38
|
+
meta: softErrorMeta,
|
|
39
|
+
});
|
|
40
|
+
const completeMutation = useMutation({
|
|
41
|
+
...outboxAttachmentOperationsCompleteOutboxAttachmentMutation(),
|
|
42
|
+
meta: softErrorMeta,
|
|
43
|
+
});
|
|
44
|
+
const updateMutation = useMutation({
|
|
45
|
+
...outboxDetailOperationsUpdateOutboxMessageMutation(),
|
|
46
|
+
meta: softErrorMeta,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const transportRef = useRef<AttachmentTransport | null>(null);
|
|
50
|
+
transportRef.current = {
|
|
51
|
+
mint: (outboxMessageId, file) =>
|
|
52
|
+
mintMutation.mutateAsync({
|
|
53
|
+
path: { outboxMessageId },
|
|
54
|
+
body: {
|
|
55
|
+
filename: file.name,
|
|
56
|
+
contentType: file.type || "application/octet-stream",
|
|
57
|
+
sizeBytes: file.size,
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
put: putFile,
|
|
61
|
+
complete: (outboxMessageId, outboxAttachmentId) =>
|
|
62
|
+
completeMutation
|
|
63
|
+
.mutateAsync({ path: { outboxMessageId, outboxAttachmentId } })
|
|
64
|
+
.then(() => undefined),
|
|
65
|
+
keep: (outboxMessageId, attachmentIds) =>
|
|
66
|
+
updateMutation
|
|
67
|
+
.mutateAsync({
|
|
68
|
+
path: { outboxMessageId },
|
|
69
|
+
body: { attachmentIds },
|
|
70
|
+
})
|
|
71
|
+
.then(() => undefined),
|
|
72
|
+
};
|
|
73
|
+
const ensureDraftRef = useRef(ensureDraft);
|
|
74
|
+
ensureDraftRef.current = ensureDraft;
|
|
75
|
+
|
|
76
|
+
const [controller] = useState(
|
|
77
|
+
() =>
|
|
78
|
+
new ComposeAttachmentController({
|
|
79
|
+
transport: () => {
|
|
80
|
+
const transport = transportRef.current;
|
|
81
|
+
if (!transport) throw new Error("attachment transport not ready");
|
|
82
|
+
return transport;
|
|
83
|
+
},
|
|
84
|
+
ensureDraft: () => ensureDraftRef.current(),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const items = useSyncExternalStore(
|
|
89
|
+
controller.subscribe,
|
|
90
|
+
controller.getSnapshot,
|
|
91
|
+
controller.getSnapshot,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
items,
|
|
96
|
+
attach: controller.attach,
|
|
97
|
+
retry: controller.retry,
|
|
98
|
+
remove: controller.remove,
|
|
99
|
+
load: controller.load,
|
|
100
|
+
reset: controller.reset,
|
|
101
|
+
blockingReason: controller.blockingReason(),
|
|
102
|
+
};
|
|
103
|
+
};
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "@/components/calendar/CalendarUnavailable";
|
|
25
25
|
import {
|
|
26
26
|
calendarWindowOfDays,
|
|
27
|
+
deviceTimeZone,
|
|
27
28
|
isDrawnInstance,
|
|
28
29
|
readCalendarInstanceId,
|
|
29
30
|
type SuggestionAnswer,
|
|
@@ -214,6 +215,7 @@ export function useIntelligenceCalendar(
|
|
|
214
215
|
const event = toCalendarEventData(
|
|
215
216
|
instance,
|
|
216
217
|
timeZoneByCalendarId[instance.calendarId] ?? "",
|
|
218
|
+
deviceTimeZone(),
|
|
217
219
|
);
|
|
218
220
|
return {
|
|
219
221
|
event,
|
|
@@ -42,12 +42,17 @@ export function formatCivilDay(date: string): string {
|
|
|
42
42
|
/** A span as the kit is handed it: when it starts, when it ends, whether it has hours. */
|
|
43
43
|
export type CalendarSpan = Pick<CalendarEventData, "start" | "end" | "allDay">;
|
|
44
44
|
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* "Wed 10 June, 10:00 – 11:00", or the day alone when it runs all of it. An
|
|
47
|
+
* all-day span is read off its civil date: parsed as an instant, it lands on
|
|
48
|
+
* the day before anywhere west of UTC.
|
|
49
|
+
*/
|
|
46
50
|
export function formatEventWhen(event: CalendarSpan): string {
|
|
51
|
+
if (event.allDay)
|
|
52
|
+
return `${formatCivilDay(event.start.slice(0, 10))}, all day`;
|
|
47
53
|
const start = new Date(event.start);
|
|
48
54
|
const end = new Date(event.end);
|
|
49
55
|
const day = DAY.format(start);
|
|
50
|
-
if (event.allDay) return `${day}, all day`;
|
|
51
56
|
const sameDay = DAY.format(end) === day;
|
|
52
57
|
if (sameDay) return `${day}, ${CLOCK.format(start)} – ${CLOCK.format(end)}`;
|
|
53
58
|
return `${day}, ${CLOCK.format(start)} – ${DAY.format(end)}, ${CLOCK.format(end)}`;
|