@remit/web-client 0.0.302 → 0.0.304
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 +51 -0
- package/src/components/calendar/CalendarEventPane.tsx +79 -14
- package/src/components/calendar/OpenCalendarEvent.stories.tsx +128 -10
- package/src/components/calendar/OpenCalendarEvent.tsx +116 -2
- package/src/components/calendar/calendar-story-fixtures.ts +1 -0
- package/src/components/compose/compose.stories.tsx +374 -0
- package/src/components/layout/ComposeFab.tsx +2 -15
- package/src/components/layout/mail-shell.stories.tsx +142 -0
- package/src/components/mail/detail-surface.stories.tsx +171 -0
- package/src/components/mail/mail-pickers.stories.tsx +82 -0
- package/src/components/mail/search.stories.tsx +280 -0
- package/src/components/mail/selection-wizard.stories.tsx +160 -0
- package/src/hooks/calendar/index.ts +4 -0
- package/src/hooks/calendar/instance.test.ts +137 -1
- package/src/hooks/calendar/instance.ts +68 -0
- package/src/hooks/calendar/useCalendarJump.ts +117 -0
- package/src/lib/calendar-report.ts +10 -0
- package/src/lib/calendar-route.ts +2 -0
- package/src/mocks/story-frame/compose-world.ts +191 -0
- package/src/mocks/story-frame/mail-handlers.ts +195 -22
- package/src/mocks/story-frame/mail-world.ts +44 -18
|
@@ -39,10 +39,13 @@ export {
|
|
|
39
39
|
export {
|
|
40
40
|
anchorZoneFor,
|
|
41
41
|
type CalendarInstanceRef,
|
|
42
|
+
type CalendarJump,
|
|
42
43
|
calendarInstanceId,
|
|
44
|
+
calendarJumpOf,
|
|
43
45
|
deviceTimeZone,
|
|
44
46
|
isDrawnInstance,
|
|
45
47
|
readCalendarInstanceId,
|
|
48
|
+
seriesOccurrenceOf,
|
|
46
49
|
toCalendarDescriptor,
|
|
47
50
|
toCalendarEventData,
|
|
48
51
|
UNZONED_CALENDAR,
|
|
@@ -82,6 +85,7 @@ export {
|
|
|
82
85
|
type CalendarFreeBusyResult,
|
|
83
86
|
useCalendarFreeBusyWeeks,
|
|
84
87
|
} from "./useCalendarFreeBusy";
|
|
88
|
+
export { type CalendarJumpSearch, useCalendarJump } from "./useCalendarJump";
|
|
85
89
|
export {
|
|
86
90
|
type CalendarSuggestionAnswers,
|
|
87
91
|
type CalendarSuggestionsResult,
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import type { RemitImapCalendarEventInstance } from "@remit/api-http-client/types.gen.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
anchorZoneFor,
|
|
7
|
+
calendarJumpOf,
|
|
8
|
+
seriesOccurrenceOf,
|
|
9
|
+
toCalendarEventData,
|
|
10
|
+
} from "./instance";
|
|
6
11
|
|
|
7
12
|
const instance = (
|
|
8
13
|
over: Partial<RemitImapCalendarEventInstance>,
|
|
@@ -68,3 +73,134 @@ describe("the zone a write anchors an event in", () => {
|
|
|
68
73
|
}
|
|
69
74
|
});
|
|
70
75
|
});
|
|
76
|
+
|
|
77
|
+
describe("the occurrence a series opens at from its plain address", () => {
|
|
78
|
+
const weekly = ["06", "13", "20"].map((day) =>
|
|
79
|
+
toCalendarEventData(
|
|
80
|
+
instance({
|
|
81
|
+
recurrenceId: `203403${day}T090000Z`,
|
|
82
|
+
hasRecurrence: true,
|
|
83
|
+
allDay: false,
|
|
84
|
+
start: `2034-03-${day}T09:00:00+00:00`,
|
|
85
|
+
end: `2034-03-${day}T10:00:00+00:00`,
|
|
86
|
+
}),
|
|
87
|
+
"UTC",
|
|
88
|
+
"UTC",
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
const shuffled = [...weekly].reverse();
|
|
92
|
+
|
|
93
|
+
it("is the next one still to finish", () => {
|
|
94
|
+
assert.deepEqual(
|
|
95
|
+
seriesOccurrenceOf(shuffled, "obj_1", "2034-03-13T09:30:00Z"),
|
|
96
|
+
{ calendarObjectId: "obj_1", recurrenceId: "20340313T090000Z" },
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("is the first in the window once every one has passed", () => {
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
seriesOccurrenceOf(shuffled, "obj_1", "2034-04-01T00:00:00Z"),
|
|
103
|
+
{ calendarObjectId: "obj_1", recurrenceId: "20340306T090000Z" },
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("is nothing for an event that does not recur or is not in the window", () => {
|
|
108
|
+
const oneOff = toCalendarEventData(
|
|
109
|
+
instance({ calendarObjectId: "obj_2" }),
|
|
110
|
+
"UTC",
|
|
111
|
+
"UTC",
|
|
112
|
+
);
|
|
113
|
+
assert.equal(
|
|
114
|
+
seriesOccurrenceOf(
|
|
115
|
+
[oneOff, ...shuffled],
|
|
116
|
+
"obj_2",
|
|
117
|
+
"2034-03-01T00:00:00Z",
|
|
118
|
+
),
|
|
119
|
+
undefined,
|
|
120
|
+
);
|
|
121
|
+
assert.equal(
|
|
122
|
+
seriesOccurrenceOf(shuffled, "obj_3", "2034-03-01T00:00:00Z"),
|
|
123
|
+
undefined,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("the day a view moves to for an event it does not hold", () => {
|
|
129
|
+
const weekly = ["06", "13", "20"].map((day) =>
|
|
130
|
+
instance({
|
|
131
|
+
recurrenceId: `2034-03-${day}T01:00:00Z`,
|
|
132
|
+
hasRecurrence: true,
|
|
133
|
+
allDay: false,
|
|
134
|
+
start: `2034-03-${day}T01:00:00+00:00`,
|
|
135
|
+
end: `2034-03-${day}T02:00:00+00:00`,
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
const shuffled = [...weekly].reverse();
|
|
139
|
+
|
|
140
|
+
it("is the day of the next occurrence still to finish, on the device's clock", () => {
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
calendarJumpOf(
|
|
143
|
+
shuffled,
|
|
144
|
+
"obj_1",
|
|
145
|
+
"2034-03-10T00:00:00Z",
|
|
146
|
+
"America/New_York",
|
|
147
|
+
),
|
|
148
|
+
{
|
|
149
|
+
calendarObjectId: "obj_1",
|
|
150
|
+
recurrenceId: "2034-03-13T01:00:00Z",
|
|
151
|
+
calendarId: "cal_work",
|
|
152
|
+
date: "2034-03-12",
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("is the day of the first occurrence once every one has passed", () => {
|
|
158
|
+
assert.deepEqual(
|
|
159
|
+
calendarJumpOf(shuffled, "obj_1", "2035-01-01T00:00:00Z", "UTC"),
|
|
160
|
+
{
|
|
161
|
+
calendarObjectId: "obj_1",
|
|
162
|
+
recurrenceId: "2034-03-06T01:00:00Z",
|
|
163
|
+
calendarId: "cal_work",
|
|
164
|
+
date: "2034-03-06",
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("skips a cancelled occurrence", () => {
|
|
170
|
+
const [first, ...rest] = weekly;
|
|
171
|
+
if (!first) throw new Error("no first occurrence");
|
|
172
|
+
assert.equal(
|
|
173
|
+
calendarJumpOf(
|
|
174
|
+
[{ ...first, status: "Cancelled" }, ...rest],
|
|
175
|
+
"obj_1",
|
|
176
|
+
"2034-03-01T00:00:00Z",
|
|
177
|
+
"UTC",
|
|
178
|
+
)?.date,
|
|
179
|
+
"2034-03-13",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("keeps a one-off event unaddressed by an occurrence", () => {
|
|
184
|
+
assert.deepEqual(
|
|
185
|
+
calendarJumpOf(
|
|
186
|
+
[instance({ calendarObjectId: "obj_2" })],
|
|
187
|
+
"obj_2",
|
|
188
|
+
"2034-01-01T00:00:00Z",
|
|
189
|
+
"Pacific/Pago_Pago",
|
|
190
|
+
),
|
|
191
|
+
{
|
|
192
|
+
calendarObjectId: "obj_2",
|
|
193
|
+
recurrenceId: "",
|
|
194
|
+
calendarId: "cal_work",
|
|
195
|
+
date: "2034-03-01",
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("is nothing when the series has no occurrence", () => {
|
|
201
|
+
assert.equal(
|
|
202
|
+
calendarJumpOf(shuffled, "obj_3", "2034-03-01T00:00:00Z", "UTC"),
|
|
203
|
+
undefined,
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -86,6 +86,74 @@ export function readCalendarInstanceId(id: string): CalendarInstanceRef {
|
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
const nextOrFirst = (
|
|
90
|
+
events: readonly CalendarEventData[],
|
|
91
|
+
now: string,
|
|
92
|
+
): CalendarEventData | undefined => {
|
|
93
|
+
const ordered = [...events].sort(
|
|
94
|
+
(a, b) => Date.parse(a.start) - Date.parse(b.start),
|
|
95
|
+
);
|
|
96
|
+
return (
|
|
97
|
+
ordered.find((event) => Date.parse(event.end) > Date.parse(now)) ??
|
|
98
|
+
ordered[0]
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The occurrence a series opens at when the address names only the series: the
|
|
104
|
+
* next one still to finish, or the first in the window where none is left.
|
|
105
|
+
*/
|
|
106
|
+
export function seriesOccurrenceOf(
|
|
107
|
+
events: readonly CalendarEventData[],
|
|
108
|
+
calendarObjectId: string,
|
|
109
|
+
now: string,
|
|
110
|
+
): CalendarInstanceRef | undefined {
|
|
111
|
+
const picked = nextOrFirst(
|
|
112
|
+
events.filter((event) => {
|
|
113
|
+
const ref = readCalendarInstanceId(event.id);
|
|
114
|
+
return (
|
|
115
|
+
ref.calendarObjectId === calendarObjectId && ref.recurrenceId !== ""
|
|
116
|
+
);
|
|
117
|
+
}),
|
|
118
|
+
now,
|
|
119
|
+
);
|
|
120
|
+
return picked ? readCalendarInstanceId(picked.id) : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface CalendarJump extends CalendarInstanceRef {
|
|
124
|
+
calendarId: string;
|
|
125
|
+
/** On the clock the calendar is drawn in. */
|
|
126
|
+
date: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Where a view has to move to show an event it does not hold: the day of its
|
|
131
|
+
* next occurrence still to finish, or of its first where none is left.
|
|
132
|
+
*/
|
|
133
|
+
export function calendarJumpOf(
|
|
134
|
+
instances: readonly RemitImapCalendarEventInstance[],
|
|
135
|
+
calendarObjectId: string,
|
|
136
|
+
now: string,
|
|
137
|
+
clockZone: string,
|
|
138
|
+
): CalendarJump | undefined {
|
|
139
|
+
const picked = nextOrFirst(
|
|
140
|
+
instances
|
|
141
|
+
.filter(
|
|
142
|
+
(instance) =>
|
|
143
|
+
instance.calendarObjectId === calendarObjectId &&
|
|
144
|
+
isDrawnInstance(instance),
|
|
145
|
+
)
|
|
146
|
+
.map((instance) => toCalendarEventData(instance, "", clockZone)),
|
|
147
|
+
now,
|
|
148
|
+
);
|
|
149
|
+
if (!picked) return undefined;
|
|
150
|
+
return {
|
|
151
|
+
...readCalendarInstanceId(picked.id),
|
|
152
|
+
calendarId: picked.calendarId,
|
|
153
|
+
date: picked.start.slice(0, 10),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
89
157
|
export function toCalendarDescriptor(
|
|
90
158
|
calendar: RemitImapCalendarResponse,
|
|
91
159
|
): CalendarDescriptor {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
calendarEventDetailOperationsGetCalendarEventOptions,
|
|
3
|
+
calendarEventOperationsListCalendarEventsOptions,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
+
import { useQuery } from "@tanstack/react-query";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { getErrorStatus, softErrorStatuses } from "@/lib/error-classifier";
|
|
8
|
+
import { type CalendarJump, calendarJumpOf, deviceTimeZone } from "./instance";
|
|
9
|
+
|
|
10
|
+
export type CalendarJumpSearch =
|
|
11
|
+
| { kind: "Idle" }
|
|
12
|
+
| { kind: "Searching" }
|
|
13
|
+
| { kind: "Found"; jump: CalendarJump }
|
|
14
|
+
| { kind: "Deleted" }
|
|
15
|
+
| { kind: "NoOccurrenceFound" }
|
|
16
|
+
| { kind: "SignedOut" }
|
|
17
|
+
| { kind: "Failed"; reason: string };
|
|
18
|
+
|
|
19
|
+
const WINDOW_MS = 365 * 24 * 60 * 60_000;
|
|
20
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
21
|
+
|
|
22
|
+
const LISTING_META = softErrorStatuses(401);
|
|
23
|
+
const RESOURCE_META = softErrorStatuses(401, 404);
|
|
24
|
+
|
|
25
|
+
const instant = (ms: number): string =>
|
|
26
|
+
new Date(ms).toISOString().replace(/\.\d{3}Z$/, "+00:00");
|
|
27
|
+
|
|
28
|
+
const reasonOf = (error: unknown): string => {
|
|
29
|
+
const message =
|
|
30
|
+
error instanceof Error && error.message !== ""
|
|
31
|
+
? error.message
|
|
32
|
+
: "no reason given";
|
|
33
|
+
const status = getErrorStatus(error);
|
|
34
|
+
return status === undefined ? message : `${message} (HTTP ${status})`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Finds the day an event falls on when the view on screen does not hold it:
|
|
39
|
+
* its occurrences in the coming year first, and failing those, the year from
|
|
40
|
+
* the resource's own start — so an event that has ended opens at its first.
|
|
41
|
+
*/
|
|
42
|
+
export function useCalendarJump(
|
|
43
|
+
calendarObjectId: string,
|
|
44
|
+
enabled: boolean,
|
|
45
|
+
): CalendarJumpSearch {
|
|
46
|
+
const [now] = useState(() => Date.now());
|
|
47
|
+
const clock = deviceTimeZone();
|
|
48
|
+
const nowIso = new Date(now).toISOString();
|
|
49
|
+
|
|
50
|
+
const ahead = useQuery({
|
|
51
|
+
...calendarEventOperationsListCalendarEventsOptions({
|
|
52
|
+
query: {
|
|
53
|
+
from: instant(now),
|
|
54
|
+
to: instant(now + WINDOW_MS),
|
|
55
|
+
calendarObjectId,
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
meta: LISTING_META,
|
|
59
|
+
retry: false,
|
|
60
|
+
enabled,
|
|
61
|
+
});
|
|
62
|
+
const upcoming = ahead.data
|
|
63
|
+
? calendarJumpOf(ahead.data.items, calendarObjectId, nowIso, clock)
|
|
64
|
+
: undefined;
|
|
65
|
+
|
|
66
|
+
const lookup = useQuery({
|
|
67
|
+
...calendarEventDetailOperationsGetCalendarEventOptions({
|
|
68
|
+
path: { calendarObjectId },
|
|
69
|
+
}),
|
|
70
|
+
meta: RESOURCE_META,
|
|
71
|
+
retry: false,
|
|
72
|
+
enabled: enabled && ahead.data !== undefined && upcoming === undefined,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const started = Date.parse(lookup.data?.dtStart ?? "");
|
|
76
|
+
const unreadableStart = lookup.data !== undefined && Number.isNaN(started);
|
|
77
|
+
const firstFrom = Number.isNaN(started) ? 0 : started - DAY_MS;
|
|
78
|
+
const first = useQuery({
|
|
79
|
+
...calendarEventOperationsListCalendarEventsOptions({
|
|
80
|
+
query: {
|
|
81
|
+
from: instant(firstFrom),
|
|
82
|
+
to: instant(firstFrom + WINDOW_MS),
|
|
83
|
+
calendarObjectId,
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
meta: LISTING_META,
|
|
87
|
+
retry: false,
|
|
88
|
+
enabled: enabled && lookup.data !== undefined && !unreadableStart,
|
|
89
|
+
});
|
|
90
|
+
const earliest = first.data
|
|
91
|
+
? calendarJumpOf(first.data.items, calendarObjectId, nowIso, clock)
|
|
92
|
+
: undefined;
|
|
93
|
+
|
|
94
|
+
if (!enabled) return { kind: "Idle" };
|
|
95
|
+
if (upcoming) return { kind: "Found", jump: upcoming };
|
|
96
|
+
if (earliest) return { kind: "Found", jump: earliest };
|
|
97
|
+
|
|
98
|
+
const deleted = getErrorStatus(lookup.error) === 404;
|
|
99
|
+
const failure = [
|
|
100
|
+
ahead.error,
|
|
101
|
+
deleted ? null : lookup.error,
|
|
102
|
+
first.error,
|
|
103
|
+
].find((error) => error !== null);
|
|
104
|
+
if (failure !== undefined) {
|
|
105
|
+
return getErrorStatus(failure) === 401
|
|
106
|
+
? { kind: "SignedOut" }
|
|
107
|
+
: { kind: "Failed", reason: reasonOf(failure) };
|
|
108
|
+
}
|
|
109
|
+
if (deleted) return { kind: "Deleted" };
|
|
110
|
+
if (unreadableStart)
|
|
111
|
+
return {
|
|
112
|
+
kind: "Failed",
|
|
113
|
+
reason: `the stored event names no readable start (${lookup.data?.dtStart ?? ""})`,
|
|
114
|
+
};
|
|
115
|
+
if (first.data !== undefined) return { kind: "NoOccurrenceFound" };
|
|
116
|
+
return { kind: "Searching" };
|
|
117
|
+
}
|
|
@@ -9,3 +9,13 @@ export function calendarReportHref(what: string): string {
|
|
|
9
9
|
}),
|
|
10
10
|
);
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
/** A prefilled issue for an event the calendar could not look up. */
|
|
14
|
+
export function calendarEventReportHref(what: string): string {
|
|
15
|
+
return buildGitHubIssueUrl(
|
|
16
|
+
buildBugReportContext({
|
|
17
|
+
title: `Calendar event: ${what}`,
|
|
18
|
+
errorMessage: what,
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapOutboxMessageResponse,
|
|
3
|
+
RemitImapThreadMessageResponse,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import {
|
|
6
|
+
DAY,
|
|
7
|
+
HOUR,
|
|
8
|
+
type MailWorld,
|
|
9
|
+
MINUTE,
|
|
10
|
+
mailboxIdFor,
|
|
11
|
+
mailWorld,
|
|
12
|
+
outboxMessage,
|
|
13
|
+
threadRow,
|
|
14
|
+
WORK,
|
|
15
|
+
} from "./mail-world";
|
|
16
|
+
|
|
17
|
+
export const INBOX = mailboxIdFor(WORK, "Inbox");
|
|
18
|
+
|
|
19
|
+
export const LUNCH_SUBJECT = "Lunch Thursday?";
|
|
20
|
+
export const LUNCH_BODY = "Are we still on for Thursday? I can do 12:30.";
|
|
21
|
+
export const LONG_SUBJECT = "Billing migration owners";
|
|
22
|
+
export const TURNS_SUBJECT = "Offsite venue";
|
|
23
|
+
export const SCANS_SUBJECT = "Scans";
|
|
24
|
+
export const MISSPELT_DRAFT =
|
|
25
|
+
"<p>Ths report is redy today, and the notes are attachd.</p>";
|
|
26
|
+
export const PLAIN_DRAFT = [
|
|
27
|
+
"Thanks, that works for me.",
|
|
28
|
+
"",
|
|
29
|
+
"| Region | Total |",
|
|
30
|
+
"| --- | --- |",
|
|
31
|
+
"| EMEA | 412 |",
|
|
32
|
+
].join("\n");
|
|
33
|
+
|
|
34
|
+
const longBody = [
|
|
35
|
+
"Longer context, so the pane has more message than it has room for.",
|
|
36
|
+
...Array.from(
|
|
37
|
+
{ length: 14 },
|
|
38
|
+
(_, index) =>
|
|
39
|
+
`Point ${index + 1}. The billing migration touches the export path, the dunning schedule and the invoice numbering, and each of those has a different owner today.`,
|
|
40
|
+
),
|
|
41
|
+
].join("\n\n");
|
|
42
|
+
|
|
43
|
+
const longDraft = [
|
|
44
|
+
"<p>That works. Before Thursday, three things I want written down:</p>",
|
|
45
|
+
...Array.from(
|
|
46
|
+
{ length: 24 },
|
|
47
|
+
(_, index) =>
|
|
48
|
+
`<p>Point ${index + 1}. Whoever owns the dunning mail after self-serve ships also owns the invoice numbering, and today those are two different people on two different rotas.</p>`,
|
|
49
|
+
),
|
|
50
|
+
].join("");
|
|
51
|
+
|
|
52
|
+
const conversations: RemitImapThreadMessageResponse[] = [
|
|
53
|
+
threadRow({
|
|
54
|
+
id: "lunch",
|
|
55
|
+
accountId: WORK,
|
|
56
|
+
fromName: "Ada Lovelace",
|
|
57
|
+
fromEmail: "ada@acme.example",
|
|
58
|
+
subject: LUNCH_SUBJECT,
|
|
59
|
+
snippet: LUNCH_BODY,
|
|
60
|
+
category: "personal",
|
|
61
|
+
ago: 30 * MINUTE,
|
|
62
|
+
}),
|
|
63
|
+
threadRow({
|
|
64
|
+
id: "billing",
|
|
65
|
+
accountId: WORK,
|
|
66
|
+
fromName: "Priya Raman",
|
|
67
|
+
fromEmail: "priya@acme.example",
|
|
68
|
+
subject: LONG_SUBJECT,
|
|
69
|
+
snippet:
|
|
70
|
+
"Longer context, so the pane has more message than it has room for.",
|
|
71
|
+
category: "personal",
|
|
72
|
+
ago: 3 * HOUR,
|
|
73
|
+
}),
|
|
74
|
+
threadRow({
|
|
75
|
+
id: "venue-1",
|
|
76
|
+
inThread: "venue",
|
|
77
|
+
accountId: WORK,
|
|
78
|
+
role: "Archive",
|
|
79
|
+
fromName: "Linus Berg",
|
|
80
|
+
fromEmail: "linus@acme.example",
|
|
81
|
+
subject: TURNS_SUBJECT,
|
|
82
|
+
snippet: "Two options for the offsite: the boathouse or the old mill.",
|
|
83
|
+
category: "personal",
|
|
84
|
+
ago: 3 * DAY,
|
|
85
|
+
isRead: true,
|
|
86
|
+
}),
|
|
87
|
+
threadRow({
|
|
88
|
+
id: "venue-2",
|
|
89
|
+
inThread: "venue",
|
|
90
|
+
accountId: WORK,
|
|
91
|
+
role: "Sent",
|
|
92
|
+
fromName: "Alice Tan",
|
|
93
|
+
fromEmail: "alice.tan@acme.example",
|
|
94
|
+
subject: `Re: ${TURNS_SUBJECT}`,
|
|
95
|
+
snippet: "The boathouse, if it still has the long table.",
|
|
96
|
+
category: "personal",
|
|
97
|
+
ago: 2 * DAY,
|
|
98
|
+
isRead: true,
|
|
99
|
+
}),
|
|
100
|
+
threadRow({
|
|
101
|
+
id: "venue-3",
|
|
102
|
+
inThread: "venue",
|
|
103
|
+
accountId: WORK,
|
|
104
|
+
fromName: "Linus Berg",
|
|
105
|
+
fromEmail: "linus@acme.example",
|
|
106
|
+
subject: `Re: ${TURNS_SUBJECT}`,
|
|
107
|
+
snippet: "It does. Shall I book it for the 14th?",
|
|
108
|
+
category: "personal",
|
|
109
|
+
ago: 4 * HOUR,
|
|
110
|
+
}),
|
|
111
|
+
threadRow({
|
|
112
|
+
id: "scans",
|
|
113
|
+
accountId: WORK,
|
|
114
|
+
fromName: "Office Scanner",
|
|
115
|
+
fromEmail: "scanner@acme.example",
|
|
116
|
+
subject: SCANS_SUBJECT,
|
|
117
|
+
snippet: "",
|
|
118
|
+
category: "automated",
|
|
119
|
+
ago: 5 * HOUR,
|
|
120
|
+
hasAttachment: true,
|
|
121
|
+
}),
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const drafts: RemitImapOutboxMessageResponse[] = [
|
|
125
|
+
{
|
|
126
|
+
...outboxMessage({
|
|
127
|
+
id: "misspelt",
|
|
128
|
+
accountId: WORK,
|
|
129
|
+
to: "ada@acme.example",
|
|
130
|
+
subject: "Report",
|
|
131
|
+
body: "Ths report is redy today, and the notes are attachd.",
|
|
132
|
+
status: "draft",
|
|
133
|
+
ago: 5 * MINUTE,
|
|
134
|
+
}),
|
|
135
|
+
htmlBody: MISSPELT_DRAFT,
|
|
136
|
+
},
|
|
137
|
+
outboxMessage({
|
|
138
|
+
id: "plain",
|
|
139
|
+
accountId: WORK,
|
|
140
|
+
to: "ada@acme.example",
|
|
141
|
+
subject: "Re: Q3 planning",
|
|
142
|
+
body: PLAIN_DRAFT,
|
|
143
|
+
status: "draft",
|
|
144
|
+
ago: 6 * MINUTE,
|
|
145
|
+
}),
|
|
146
|
+
{
|
|
147
|
+
...outboxMessage({
|
|
148
|
+
id: "long-reply",
|
|
149
|
+
accountId: WORK,
|
|
150
|
+
to: "ada@acme.example",
|
|
151
|
+
subject: `Re: ${LUNCH_SUBJECT}`,
|
|
152
|
+
body: "That works.",
|
|
153
|
+
status: "draft",
|
|
154
|
+
ago: 7 * MINUTE,
|
|
155
|
+
}),
|
|
156
|
+
htmlBody: longDraft,
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
export const composeWorld = (overrides: Partial<MailWorld> = {}): MailWorld => {
|
|
161
|
+
const base = mailWorld();
|
|
162
|
+
return {
|
|
163
|
+
...base,
|
|
164
|
+
accounts: base.accounts.map((account) =>
|
|
165
|
+
account.accountId === WORK
|
|
166
|
+
? { ...account, composeLanguages: ["en", "nl", "de"] }
|
|
167
|
+
: account,
|
|
168
|
+
),
|
|
169
|
+
threads: [...base.threads, ...conversations],
|
|
170
|
+
outbox: [...base.outbox, ...drafts],
|
|
171
|
+
bodies: { "msg-billing": longBody },
|
|
172
|
+
...overrides,
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const withoutSmtp = (world: MailWorld): MailWorld => ({
|
|
177
|
+
...world,
|
|
178
|
+
accounts: world.accounts.map((account) => ({
|
|
179
|
+
...account,
|
|
180
|
+
smtpEnabled: false,
|
|
181
|
+
})),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
export const messageUrl = (id: string, mailboxId: string = INBOX): string =>
|
|
185
|
+
`/mail/${mailboxId}/thread-${id}/msg-${id}`;
|
|
186
|
+
|
|
187
|
+
export const threadUrl = (
|
|
188
|
+
threadId: string,
|
|
189
|
+
messageId: string,
|
|
190
|
+
mailboxId: string = INBOX,
|
|
191
|
+
): string => `/mail/${mailboxId}/thread-${threadId}/msg-${messageId}`;
|