@remit/web-client 0.0.313 → 0.0.315
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/CalendarComposePane.tsx +15 -2
- package/src/components/calendar/CalendarWaiting.tsx +3 -0
- package/src/components/calendar/ChangeCalendarSuggestion.stories.tsx +184 -0
- package/src/components/calendar/ChangeCalendarSuggestion.tsx +159 -0
- package/src/components/calendar/PendingSuggestions.tsx +35 -5
- package/src/components/calendar/calendar-story-server.tsx +5 -0
- package/src/components/calendar/change-suggestion-first.render.test.ts +301 -0
- package/src/components/calendar/pending-suggestions.render.test.ts +70 -3
- package/src/components/mail/BriefPane.tsx +6 -1
- package/src/components/mail/FlaggedPane.tsx +6 -1
- package/src/components/mail/IntelligenceDrawer.tsx +4 -0
- package/src/components/mail/IntelligencePane.tsx +11 -2
- package/src/components/mail/MailboxPane.tsx +5 -0
- package/src/components/mail/offer-times-reply.render.test.ts +2 -0
- package/src/hooks/calendar/draft.ts +1 -1
- package/src/hooks/calendar/index.ts +7 -0
- package/src/hooks/calendar/suggestion-draft.ts +77 -0
- package/src/hooks/calendar/useCalendarSuggestions.ts +59 -1
- package/src/hooks/intelligence-calendar.render.test.ts +192 -3
- package/src/hooks/useIntelligenceCalendar.ts +46 -5
- package/src/lib/calendar-suggestion.test.ts +12 -1
- package/src/lib/calendar-suggestion.ts +12 -2
- package/src/lib/calendar-suggestion.west.test.ts +2 -0
- package/src/routeTree.gen.ts +23 -0
- package/src/routes/calendar/$view.$date/suggestion.$suggestionId.tsx +22 -0
- package/src/routes/calendar/$view.$date.tsx +9 -3
- package/src/routes/calendar/-calendar-tree.test.ts +18 -0
- package/src/routing/calendar.ts +36 -1
- package/src/routing/index.ts +2 -0
- package/src/test-support/thread-router.ts +54 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, afterEach, before, describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
RemitImapCalendarResponse,
|
|
5
|
+
RemitImapCalendarSuggestionResponse,
|
|
6
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
7
|
+
import {
|
|
8
|
+
type AnyRouter,
|
|
9
|
+
createMemoryHistory,
|
|
10
|
+
createRootRoute,
|
|
11
|
+
createRoute,
|
|
12
|
+
createRouter,
|
|
13
|
+
Outlet,
|
|
14
|
+
type RouteComponent,
|
|
15
|
+
RouterProvider,
|
|
16
|
+
} from "@tanstack/react-router";
|
|
17
|
+
import { createElement } from "react";
|
|
18
|
+
import { calendarSearchSchema } from "@/lib/calendar-route";
|
|
19
|
+
import { stringifySearch } from "@/lib/search-params";
|
|
20
|
+
import { Route as SuggestionRoute } from "../../routes/calendar/$view.$date/suggestion.$suggestionId.js";
|
|
21
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
22
|
+
import {
|
|
23
|
+
type HttpCall,
|
|
24
|
+
type HttpMock,
|
|
25
|
+
httpError,
|
|
26
|
+
mockFetch,
|
|
27
|
+
} from "../../test-support/http";
|
|
28
|
+
|
|
29
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
30
|
+
|
|
31
|
+
const WORK = "11111111-1111-4111-8111-111111111111";
|
|
32
|
+
const SUGGESTION = "22222222-2222-4222-8222-222222222222";
|
|
33
|
+
const WEEK = "/calendar/week/2026-06-10";
|
|
34
|
+
const ENTRY = `${WEEK}/suggestion/${SUGGESTION}`;
|
|
35
|
+
|
|
36
|
+
const calendars = [
|
|
37
|
+
{
|
|
38
|
+
calendarId: WORK,
|
|
39
|
+
accountConfigId: "cfg-1",
|
|
40
|
+
urlSegment: "work",
|
|
41
|
+
displayName: "Work",
|
|
42
|
+
color: "Cal1",
|
|
43
|
+
componentSet: "VeventOnly",
|
|
44
|
+
source: "Default",
|
|
45
|
+
timezone: "Europe/Amsterdam",
|
|
46
|
+
syncSequence: 1,
|
|
47
|
+
subscriptionUrl: "",
|
|
48
|
+
subscriptionEnabled: false,
|
|
49
|
+
subscriptionCheckedAt: 0,
|
|
50
|
+
subscriptionFetchedAt: 0,
|
|
51
|
+
subscriptionError: "",
|
|
52
|
+
createdAt: 0,
|
|
53
|
+
updatedAt: 0,
|
|
54
|
+
},
|
|
55
|
+
] satisfies RemitImapCalendarResponse[];
|
|
56
|
+
|
|
57
|
+
const reading = (
|
|
58
|
+
overrides: Partial<RemitImapCalendarSuggestionResponse> = {},
|
|
59
|
+
): RemitImapCalendarSuggestionResponse => ({
|
|
60
|
+
suggestionId: SUGGESTION,
|
|
61
|
+
accountConfigId: "cfg-1",
|
|
62
|
+
messageId: "msg-1",
|
|
63
|
+
bodyPartId: "part-1",
|
|
64
|
+
icalUid: "uid-1",
|
|
65
|
+
sequence: 0,
|
|
66
|
+
method: "Request",
|
|
67
|
+
source: "IcalendarPart",
|
|
68
|
+
state: "Pending",
|
|
69
|
+
summary: "Billing migration kickoff",
|
|
70
|
+
dtStart: "2026-06-11T12:00:00+00:00",
|
|
71
|
+
dtEnd: "2026-06-11T13:00:00+00:00",
|
|
72
|
+
allDay: false,
|
|
73
|
+
location: "Room Noord",
|
|
74
|
+
organizer: "priya@example.test",
|
|
75
|
+
zoneCertainty: "Explicit",
|
|
76
|
+
acceptedCalendarObjectId: "",
|
|
77
|
+
supersededByMessageId: "",
|
|
78
|
+
supersededByThreadId: "",
|
|
79
|
+
createdAt: 0,
|
|
80
|
+
updatedAt: 0,
|
|
81
|
+
...overrides,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
let harness: DomHarness | undefined;
|
|
85
|
+
let http: HttpMock | undefined;
|
|
86
|
+
const runnerZone = process.env.TZ;
|
|
87
|
+
|
|
88
|
+
before(() => {
|
|
89
|
+
process.env.TZ = "Europe/Amsterdam";
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
after(() => {
|
|
93
|
+
if (runnerZone === undefined) Reflect.deleteProperty(process.env, "TZ");
|
|
94
|
+
else process.env.TZ = runnerZone;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
harness?.close();
|
|
99
|
+
harness = undefined;
|
|
100
|
+
http?.restore();
|
|
101
|
+
http = undefined;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const componentOf = (route: unknown): RouteComponent => {
|
|
105
|
+
const { component } = (route as { options: { component?: RouteComponent } })
|
|
106
|
+
.options;
|
|
107
|
+
if (!component) throw new Error("the route mounts no component");
|
|
108
|
+
return component;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const testRouter = (): AnyRouter => {
|
|
112
|
+
const rootRoute = createRootRoute({ component: Outlet });
|
|
113
|
+
const viewRoute = createRoute({
|
|
114
|
+
getParentRoute: () => rootRoute,
|
|
115
|
+
path: "/calendar/$view/$date",
|
|
116
|
+
validateSearch: calendarSearchSchema,
|
|
117
|
+
component: Outlet,
|
|
118
|
+
});
|
|
119
|
+
const routeTree = rootRoute.addChildren([
|
|
120
|
+
viewRoute.addChildren([
|
|
121
|
+
createRoute({
|
|
122
|
+
getParentRoute: () => viewRoute,
|
|
123
|
+
path: "/",
|
|
124
|
+
component: () => null,
|
|
125
|
+
}),
|
|
126
|
+
createRoute({
|
|
127
|
+
getParentRoute: () => viewRoute,
|
|
128
|
+
path: "suggestion/$suggestionId",
|
|
129
|
+
component: componentOf(SuggestionRoute),
|
|
130
|
+
}),
|
|
131
|
+
]),
|
|
132
|
+
]);
|
|
133
|
+
return createRouter({
|
|
134
|
+
routeTree,
|
|
135
|
+
stringifySearch,
|
|
136
|
+
history: createMemoryHistory({ initialEntries: [ENTRY] }),
|
|
137
|
+
}) as unknown as AnyRouter;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const server = (
|
|
141
|
+
suggestion: RemitImapCalendarSuggestionResponse,
|
|
142
|
+
answer: (call: HttpCall) => Response | undefined = () => undefined,
|
|
143
|
+
) => {
|
|
144
|
+
let pending = [suggestion];
|
|
145
|
+
return (call: HttpCall): unknown => {
|
|
146
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
147
|
+
if (call.path.endsWith("/calendar-suggestions")) return { items: pending };
|
|
148
|
+
if (call.path.endsWith("/accept")) {
|
|
149
|
+
const refusal = answer(call);
|
|
150
|
+
if (refusal) return refusal;
|
|
151
|
+
pending = [];
|
|
152
|
+
return { ...suggestion, state: "Accepted" };
|
|
153
|
+
}
|
|
154
|
+
return { items: [] };
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const mount = async (respond: (call: HttpCall) => unknown) => {
|
|
159
|
+
http = mockFetch(respond);
|
|
160
|
+
const router = testRouter();
|
|
161
|
+
await router.load();
|
|
162
|
+
harness = createDomHarness();
|
|
163
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
164
|
+
await harness.waitFor(
|
|
165
|
+
() =>
|
|
166
|
+
(harness?.queryAll<HTMLInputElement>("input") ?? []).some(
|
|
167
|
+
(input) => input.value === "Billing migration kickoff",
|
|
168
|
+
),
|
|
169
|
+
"the editor to open seeded from the suggestion",
|
|
170
|
+
);
|
|
171
|
+
await harness.waitFor(
|
|
172
|
+
() => harness?.query("input[type=radio]:checked") !== null,
|
|
173
|
+
"the default calendar to be picked",
|
|
174
|
+
);
|
|
175
|
+
return router;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const fieldValue = (label: string): string => {
|
|
179
|
+
if (!harness) throw new Error("not mounted");
|
|
180
|
+
return (harness.byLabel(label) as HTMLInputElement).value;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const acceptedEdits = (): HttpCall[] =>
|
|
184
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/accept`) ?? [];
|
|
185
|
+
|
|
186
|
+
describe("changing a suggestion before adding it", () => {
|
|
187
|
+
it("opens the editor on what the mail said, on this device's clock", async () => {
|
|
188
|
+
await mount(server(reading()));
|
|
189
|
+
|
|
190
|
+
assert.equal(fieldValue("Title"), "Billing migration kickoff");
|
|
191
|
+
assert.equal(fieldValue("Location"), "Room Noord");
|
|
192
|
+
assert.equal(fieldValue("Date"), "2026-06-11");
|
|
193
|
+
assert.equal(fieldValue("Start time"), "14:00");
|
|
194
|
+
assert.equal(fieldValue("End time"), "15:00");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("adds the corrected event, settles the suggestion and closes the editor", async () => {
|
|
198
|
+
const router = await mount(server(reading()));
|
|
199
|
+
|
|
200
|
+
harness?.type(harness.byLabel("Title"), "Billing migration kickoff, moved");
|
|
201
|
+
harness?.type(harness.byLabel("Start time"), "16:00");
|
|
202
|
+
harness?.type(harness.byLabel("End time"), "17:30");
|
|
203
|
+
await harness?.flush();
|
|
204
|
+
harness?.click(harness.byText("button", "Add"));
|
|
205
|
+
|
|
206
|
+
await harness?.waitFor(
|
|
207
|
+
() => router.state.location.pathname === WEEK,
|
|
208
|
+
"the editor to close once the server settled the suggestion",
|
|
209
|
+
);
|
|
210
|
+
const sent = acceptedEdits();
|
|
211
|
+
assert.equal(sent.length, 1);
|
|
212
|
+
assert.deepEqual(sent[0].body, {
|
|
213
|
+
calendarId: WORK,
|
|
214
|
+
event: {
|
|
215
|
+
summary: "Billing migration kickoff, moved",
|
|
216
|
+
start: "2026-06-11T16:00:00+02:00",
|
|
217
|
+
end: "2026-06-11T17:30:00+02:00",
|
|
218
|
+
allDay: false,
|
|
219
|
+
timeZone: "Europe/Amsterdam",
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
const listings = http?.to("/calendar-suggestions") ?? [];
|
|
223
|
+
assert.ok(
|
|
224
|
+
listings.length > 1,
|
|
225
|
+
"the waiting list is read again once the suggestion is settled",
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("places a reading nobody could put on a clock, even when the time is left alone", async () => {
|
|
230
|
+
await mount(
|
|
231
|
+
server(
|
|
232
|
+
reading({
|
|
233
|
+
dtStart: "2026-06-11T09:00:00+00:00",
|
|
234
|
+
dtEnd: "2026-06-11T10:00:00+00:00",
|
|
235
|
+
zoneCertainty: "Ambiguous",
|
|
236
|
+
}),
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
assert.equal(fieldValue("Start time"), "09:00");
|
|
241
|
+
assert.match(harness?.text() ?? "", /time zone nothing could resolve/);
|
|
242
|
+
harness?.click(harness.byText("button", "Add"));
|
|
243
|
+
await harness?.waitFor(
|
|
244
|
+
() => acceptedEdits().length === 1,
|
|
245
|
+
"the correction to be sent",
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
assert.deepEqual(acceptedEdits()[0].body?.event, {
|
|
249
|
+
start: "2026-06-11T09:00:00+02:00",
|
|
250
|
+
end: "2026-06-11T10:00:00+02:00",
|
|
251
|
+
allDay: false,
|
|
252
|
+
timeZone: "Europe/Amsterdam",
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("states a refusal and keeps the editor open with what was typed", async () => {
|
|
257
|
+
const router = await mount(
|
|
258
|
+
server(reading(), () => httpError(400, "Already declined.")),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
harness?.type(harness.byLabel("Title"), "Kickoff, renamed");
|
|
262
|
+
await harness?.flush();
|
|
263
|
+
harness?.click(harness.byText("button", "Add"));
|
|
264
|
+
|
|
265
|
+
await harness?.waitFor(
|
|
266
|
+
() => harness?.query('[role="alert"]') !== null,
|
|
267
|
+
"the refusal to be stated",
|
|
268
|
+
);
|
|
269
|
+
assert.match(harness?.text() ?? "", /Couldn't add this to your calendar/);
|
|
270
|
+
assert.equal(fieldValue("Title"), "Kickoff, renamed");
|
|
271
|
+
assert.equal(router.state.location.pathname, ENTRY);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("finds a suggestion that is not on the first page of what waits", async () => {
|
|
275
|
+
http = mockFetch((call: HttpCall): unknown => {
|
|
276
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
277
|
+
if (call.path.endsWith("/calendar-suggestions"))
|
|
278
|
+
return call.url.includes("continuationToken=page-2")
|
|
279
|
+
? { items: [reading()] }
|
|
280
|
+
: {
|
|
281
|
+
items: [{ ...reading(), suggestionId: "other" }],
|
|
282
|
+
continuationToken: "page-2",
|
|
283
|
+
};
|
|
284
|
+
return { items: [] };
|
|
285
|
+
});
|
|
286
|
+
const router = testRouter();
|
|
287
|
+
await router.load();
|
|
288
|
+
harness = createDomHarness();
|
|
289
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
290
|
+
|
|
291
|
+
await harness.waitFor(
|
|
292
|
+
() =>
|
|
293
|
+
harness
|
|
294
|
+
?.queryAll<HTMLInputElement>("input")
|
|
295
|
+
.some((input) => input.value === "Billing migration kickoff") ===
|
|
296
|
+
true,
|
|
297
|
+
"the editor to open on the suggestion from the second page",
|
|
298
|
+
);
|
|
299
|
+
assert.doesNotMatch(harness.text(), /no longer waiting/);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -63,22 +63,29 @@ const pending: RemitImapCalendarSuggestionResponse = {
|
|
|
63
63
|
organizer: "organizer@example.test",
|
|
64
64
|
zoneCertainty: "Explicit",
|
|
65
65
|
acceptedCalendarObjectId: "",
|
|
66
|
+
supersededByMessageId: "",
|
|
67
|
+
supersededByThreadId: "",
|
|
66
68
|
createdAt: 0,
|
|
67
69
|
updatedAt: 0,
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
let harness: DomHarness | undefined;
|
|
71
73
|
let http: HttpMock | undefined;
|
|
74
|
+
let changedFirst: { suggestionId: string; date: string }[] = [];
|
|
72
75
|
|
|
73
76
|
afterEach(() => {
|
|
74
77
|
harness?.close();
|
|
75
78
|
harness = undefined;
|
|
76
79
|
http?.restore();
|
|
77
80
|
http = undefined;
|
|
81
|
+
changedFirst = [];
|
|
78
82
|
});
|
|
79
83
|
|
|
80
|
-
const server = (
|
|
81
|
-
|
|
84
|
+
const server = (
|
|
85
|
+
accept: () => Response | undefined,
|
|
86
|
+
reading: RemitImapCalendarSuggestionResponse = pending,
|
|
87
|
+
) => {
|
|
88
|
+
let waiting = [reading];
|
|
82
89
|
return (call: HttpCall): unknown => {
|
|
83
90
|
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
84
91
|
if (call.path.endsWith("/calendar-suggestions")) return { items: waiting };
|
|
@@ -95,7 +102,12 @@ const server = (accept: () => Response | undefined) => {
|
|
|
95
102
|
const mount = async (respond: (call: HttpCall) => unknown) => {
|
|
96
103
|
http = mockFetch(respond);
|
|
97
104
|
harness = createDomHarness();
|
|
98
|
-
harness.renderApp(
|
|
105
|
+
harness.renderApp(
|
|
106
|
+
createElement(PendingSuggestions, {
|
|
107
|
+
onChangeFirst: (suggestionId: string, date: string) =>
|
|
108
|
+
changedFirst.push({ suggestionId, date }),
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
99
111
|
await harness.waitFor(
|
|
100
112
|
() => harness?.text().includes("Quarterly review") === true,
|
|
101
113
|
"the waiting reading to be drawn",
|
|
@@ -134,4 +146,59 @@ describe("what the mail is waiting on, beside the calendar", () => {
|
|
|
134
146
|
assert.match(harness?.text() ?? "", /Couldn't add this to your calendar/);
|
|
135
147
|
assert.match(harness?.text() ?? "", /Quarterly review/);
|
|
136
148
|
});
|
|
149
|
+
|
|
150
|
+
it("hands the top reading to the editor on the day it falls", async () => {
|
|
151
|
+
await mount(server(() => undefined));
|
|
152
|
+
harness?.click(harness.byText("button", "Change first"));
|
|
153
|
+
|
|
154
|
+
assert.deepEqual(changedFirst, [
|
|
155
|
+
{ suggestionId: SUGGESTION, date: "2026-09-01" },
|
|
156
|
+
]);
|
|
157
|
+
assert.equal(
|
|
158
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/accept`).length,
|
|
159
|
+
0,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("sends a reading nobody could put on a clock to the editor instead of adding it", async () => {
|
|
164
|
+
await mount(
|
|
165
|
+
server(() => undefined, { ...pending, zoneCertainty: "Ambiguous" }),
|
|
166
|
+
);
|
|
167
|
+
harness?.click(harness.byText("button", "Add"));
|
|
168
|
+
|
|
169
|
+
assert.deepEqual(
|
|
170
|
+
changedFirst.map((entry) => entry.suggestionId),
|
|
171
|
+
[SUGGESTION],
|
|
172
|
+
);
|
|
173
|
+
assert.equal(
|
|
174
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/accept`).length,
|
|
175
|
+
0,
|
|
176
|
+
);
|
|
177
|
+
assert.match(harness?.text() ?? "", /Quarterly review/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("applies a cancellation nobody could put on a clock, and offers no editor for it", async () => {
|
|
181
|
+
await mount(
|
|
182
|
+
server(() => undefined, {
|
|
183
|
+
...pending,
|
|
184
|
+
method: "Cancel",
|
|
185
|
+
zoneCertainty: "Ambiguous",
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
assert.equal(
|
|
189
|
+
harness
|
|
190
|
+
?.queryAll("button")
|
|
191
|
+
.some((button) => button.textContent?.includes("Change first")),
|
|
192
|
+
false,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
harness?.click(harness.byText("button", "Add"));
|
|
196
|
+
await harness?.waitFor(
|
|
197
|
+
() =>
|
|
198
|
+
(http?.to(`/calendar-suggestions/${SUGGESTION}/accept`).length ?? 0) >
|
|
199
|
+
0,
|
|
200
|
+
"the cancellation to be applied",
|
|
201
|
+
);
|
|
202
|
+
assert.deepEqual(changedFirst, []);
|
|
203
|
+
});
|
|
137
204
|
});
|
|
@@ -325,6 +325,7 @@ function BriefReading() {
|
|
|
325
325
|
actions,
|
|
326
326
|
onReply,
|
|
327
327
|
handleDeselectIfRemoved,
|
|
328
|
+
onOpenThread,
|
|
328
329
|
} = useBriefPane();
|
|
329
330
|
const hasThread = Boolean(conversation);
|
|
330
331
|
const intelligence = useIntelligenceSurface(conversation?.threadId);
|
|
@@ -377,6 +378,7 @@ function BriefReading() {
|
|
|
377
378
|
accountId={selectedThread?.accountId}
|
|
378
379
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
379
380
|
onReplyWithText={replyWithText}
|
|
381
|
+
onOpenThread={onOpenThread}
|
|
380
382
|
/>
|
|
381
383
|
</>
|
|
382
384
|
);
|
|
@@ -388,7 +390,8 @@ function BriefReading() {
|
|
|
388
390
|
*/
|
|
389
391
|
function BriefIntelligence() {
|
|
390
392
|
const replyWithText = useReplyWithText();
|
|
391
|
-
const { selectedThread, handleDeselectIfRemoved } =
|
|
393
|
+
const { selectedThread, handleDeselectIfRemoved, onOpenThread } =
|
|
394
|
+
useBriefPane();
|
|
392
395
|
const { onToggleIntelligence } = useMailContext();
|
|
393
396
|
|
|
394
397
|
return (
|
|
@@ -399,6 +402,7 @@ function BriefIntelligence() {
|
|
|
399
402
|
accountId={selectedThread?.accountId}
|
|
400
403
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
401
404
|
onReplyWithText={replyWithText}
|
|
405
|
+
onOpenThread={onOpenThread}
|
|
402
406
|
/>
|
|
403
407
|
);
|
|
404
408
|
}
|
|
@@ -444,6 +448,7 @@ function BriefPhone() {
|
|
|
444
448
|
accountId={selectedThread?.accountId}
|
|
445
449
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
446
450
|
onReplyWithText={replyWithText}
|
|
451
|
+
onOpenThread={onOpenThread}
|
|
447
452
|
/>
|
|
448
453
|
</>
|
|
449
454
|
);
|
|
@@ -328,6 +328,7 @@ function FlaggedReading() {
|
|
|
328
328
|
actions,
|
|
329
329
|
onReply,
|
|
330
330
|
handleDeselectIfRemoved,
|
|
331
|
+
onOpenThread,
|
|
331
332
|
} = useFlaggedPane();
|
|
332
333
|
const hasThread = Boolean(conversation);
|
|
333
334
|
const intelligence = useIntelligenceSurface(conversation?.threadId);
|
|
@@ -380,6 +381,7 @@ function FlaggedReading() {
|
|
|
380
381
|
accountId={selectedThread?.accountId}
|
|
381
382
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
382
383
|
onReplyWithText={replyWithText}
|
|
384
|
+
onOpenThread={onOpenThread}
|
|
383
385
|
/>
|
|
384
386
|
</>
|
|
385
387
|
);
|
|
@@ -391,7 +393,8 @@ function FlaggedReading() {
|
|
|
391
393
|
*/
|
|
392
394
|
function FlaggedIntelligence() {
|
|
393
395
|
const replyWithText = useReplyWithText();
|
|
394
|
-
const { selectedThread, handleDeselectIfRemoved } =
|
|
396
|
+
const { selectedThread, handleDeselectIfRemoved, onOpenThread } =
|
|
397
|
+
useFlaggedPane();
|
|
395
398
|
const { onToggleIntelligence } = useMailContext();
|
|
396
399
|
|
|
397
400
|
return (
|
|
@@ -402,6 +405,7 @@ function FlaggedIntelligence() {
|
|
|
402
405
|
accountId={selectedThread?.accountId}
|
|
403
406
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
404
407
|
onReplyWithText={replyWithText}
|
|
408
|
+
onOpenThread={onOpenThread}
|
|
405
409
|
/>
|
|
406
410
|
);
|
|
407
411
|
}
|
|
@@ -445,6 +449,7 @@ function FlaggedPhone() {
|
|
|
445
449
|
accountId={selectedThread?.accountId}
|
|
446
450
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
447
451
|
onReplyWithText={replyWithText}
|
|
452
|
+
onOpenThread={onOpenThread}
|
|
448
453
|
/>
|
|
449
454
|
</>
|
|
450
455
|
);
|
|
@@ -2,6 +2,7 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
|
|
|
2
2
|
import type { ReplyWithText } from "@/components/compose/reply-with-times";
|
|
3
3
|
import { Drawer } from "@/components/layout/Drawer";
|
|
4
4
|
import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
5
|
+
import type { OpenThreadTarget } from "@/routing";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The intelligence pane as a modal drawer — the surface every list pane uses
|
|
@@ -18,6 +19,7 @@ export function IntelligenceDrawer({
|
|
|
18
19
|
accountId,
|
|
19
20
|
onAfterOptimisticRemove,
|
|
20
21
|
onReplyWithText,
|
|
22
|
+
onOpenThread,
|
|
21
23
|
}: {
|
|
22
24
|
isOpen: boolean;
|
|
23
25
|
onClose: () => void;
|
|
@@ -26,6 +28,7 @@ export function IntelligenceDrawer({
|
|
|
26
28
|
accountId?: string;
|
|
27
29
|
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
28
30
|
onReplyWithText?: ReplyWithText;
|
|
31
|
+
onOpenThread?: (target: OpenThreadTarget) => void;
|
|
29
32
|
}) {
|
|
30
33
|
return (
|
|
31
34
|
<Drawer
|
|
@@ -45,6 +48,7 @@ export function IntelligenceDrawer({
|
|
|
45
48
|
hideCloseButton
|
|
46
49
|
onAfterOptimisticRemove={onAfterOptimisticRemove}
|
|
47
50
|
onReplyWithText={onReplyWithText}
|
|
51
|
+
onOpenThread={onOpenThread}
|
|
48
52
|
/>
|
|
49
53
|
</Drawer>
|
|
50
54
|
);
|
|
@@ -19,7 +19,7 @@ import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
|
|
|
19
19
|
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
20
20
|
import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
|
|
21
21
|
import { useTelemetry } from "@/lib/telemetry-context";
|
|
22
|
-
import { NavLink } from "@/routing";
|
|
22
|
+
import { NavLink, type OpenThreadTarget } from "@/routing";
|
|
23
23
|
import type { ReplyWithText } from "../compose/reply-with-times";
|
|
24
24
|
|
|
25
25
|
export interface IntelligencePaneProps {
|
|
@@ -55,6 +55,7 @@ export interface IntelligencePaneProps {
|
|
|
55
55
|
*/
|
|
56
56
|
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
57
57
|
onReplyWithText?: ReplyWithText;
|
|
58
|
+
onOpenThread?: (target: OpenThreadTarget) => void;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|
|
@@ -254,6 +255,7 @@ interface WiredPanelProps {
|
|
|
254
255
|
hideCloseButton?: boolean;
|
|
255
256
|
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
256
257
|
onReplyWithText?: ReplyWithText;
|
|
258
|
+
onOpenThread?: (target: OpenThreadTarget) => void;
|
|
257
259
|
}
|
|
258
260
|
|
|
259
261
|
/**
|
|
@@ -267,6 +269,7 @@ function WiredPanel({
|
|
|
267
269
|
hideCloseButton,
|
|
268
270
|
onAfterOptimisticRemove,
|
|
269
271
|
onReplyWithText,
|
|
272
|
+
onOpenThread,
|
|
270
273
|
}: WiredPanelProps) {
|
|
271
274
|
const {
|
|
272
275
|
data,
|
|
@@ -277,7 +280,11 @@ function WiredPanel({
|
|
|
277
280
|
similarErrorIsFatal,
|
|
278
281
|
semanticEnabled,
|
|
279
282
|
} = useIntelligenceData(thread, mailboxId);
|
|
280
|
-
const calendar = useIntelligenceCalendar(
|
|
283
|
+
const calendar = useIntelligenceCalendar(
|
|
284
|
+
thread,
|
|
285
|
+
onReplyWithText,
|
|
286
|
+
onOpenThread,
|
|
287
|
+
);
|
|
281
288
|
const [reclassifyOpen, setReclassifyOpen] = useState(false);
|
|
282
289
|
const senderEmail = thread.fromEmail ?? undefined;
|
|
283
290
|
|
|
@@ -437,6 +444,7 @@ export const IntelligencePane = ({
|
|
|
437
444
|
hideCloseButton,
|
|
438
445
|
onAfterOptimisticRemove,
|
|
439
446
|
onReplyWithText,
|
|
447
|
+
onOpenThread,
|
|
440
448
|
}: IntelligencePaneProps) => {
|
|
441
449
|
if (!thread) {
|
|
442
450
|
return (
|
|
@@ -467,6 +475,7 @@ export const IntelligencePane = ({
|
|
|
467
475
|
hideCloseButton={hideCloseButton}
|
|
468
476
|
onAfterOptimisticRemove={onAfterOptimisticRemove}
|
|
469
477
|
onReplyWithText={onReplyWithText}
|
|
478
|
+
onOpenThread={onOpenThread}
|
|
470
479
|
/>
|
|
471
480
|
);
|
|
472
481
|
};
|
|
@@ -1130,6 +1130,7 @@ function MailboxReading() {
|
|
|
1130
1130
|
onToolbarMove,
|
|
1131
1131
|
handleDeselectIfRemoved,
|
|
1132
1132
|
intelligenceRef,
|
|
1133
|
+
onOpenThread,
|
|
1133
1134
|
} = useMailboxPane();
|
|
1134
1135
|
const hasThread = Boolean(conversation);
|
|
1135
1136
|
const intelligence = useIntelligenceSurface(conversation?.threadId);
|
|
@@ -1207,6 +1208,7 @@ function MailboxReading() {
|
|
|
1207
1208
|
accountId={mailboxAccountId}
|
|
1208
1209
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1209
1210
|
onReplyWithText={replyWithText}
|
|
1211
|
+
onOpenThread={onOpenThread}
|
|
1210
1212
|
/>
|
|
1211
1213
|
</>
|
|
1212
1214
|
);
|
|
@@ -1224,6 +1226,7 @@ function MailboxIntelligence() {
|
|
|
1224
1226
|
selectedThread,
|
|
1225
1227
|
onToggleIntelligence,
|
|
1226
1228
|
handleDeselectIfRemoved,
|
|
1229
|
+
onOpenThread,
|
|
1227
1230
|
} = useMailboxPane();
|
|
1228
1231
|
|
|
1229
1232
|
return (
|
|
@@ -1234,6 +1237,7 @@ function MailboxIntelligence() {
|
|
|
1234
1237
|
accountId={mailboxAccountId}
|
|
1235
1238
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1236
1239
|
onReplyWithText={replyWithText}
|
|
1240
|
+
onOpenThread={onOpenThread}
|
|
1237
1241
|
/>
|
|
1238
1242
|
);
|
|
1239
1243
|
}
|
|
@@ -1288,6 +1292,7 @@ function MailboxPhone() {
|
|
|
1288
1292
|
accountId={mailboxAccountId}
|
|
1289
1293
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1290
1294
|
onReplyWithText={replyWithText}
|
|
1295
|
+
onOpenThread={onOpenThread}
|
|
1291
1296
|
/>
|
|
1292
1297
|
</>
|
|
1293
1298
|
);
|
|
@@ -149,6 +149,8 @@ const invitation: RemitImapCalendarSuggestionResponse = {
|
|
|
149
149
|
organizer: "organizer@example.test",
|
|
150
150
|
zoneCertainty: "Explicit",
|
|
151
151
|
acceptedCalendarObjectId: "",
|
|
152
|
+
supersededByMessageId: "",
|
|
153
|
+
supersededByThreadId: "",
|
|
152
154
|
createdAt: 0,
|
|
153
155
|
updatedAt: 0,
|
|
154
156
|
};
|
|
@@ -85,7 +85,7 @@ export function draftFromEvent(
|
|
|
85
85
|
* Stamping any other zone's offset onto those digits moves the event by the
|
|
86
86
|
* difference between the two — silently, and only for whoever is travelling.
|
|
87
87
|
*/
|
|
88
|
-
function timesFor(
|
|
88
|
+
export function timesFor(
|
|
89
89
|
draft: EventDraft,
|
|
90
90
|
clock: string,
|
|
91
91
|
): { start: string; end: string } {
|
|
@@ -63,6 +63,11 @@ export {
|
|
|
63
63
|
useCalendarSelection,
|
|
64
64
|
useDrawnEvents,
|
|
65
65
|
} from "./selection";
|
|
66
|
+
export {
|
|
67
|
+
type AcceptEditedInput,
|
|
68
|
+
acceptEditedInputFromDrafts,
|
|
69
|
+
draftFromSuggestion,
|
|
70
|
+
} from "./suggestion-draft";
|
|
66
71
|
export {
|
|
67
72
|
type CalendarCollectionOutcome,
|
|
68
73
|
type CalendarCollectionWrites,
|
|
@@ -89,9 +94,11 @@ export { type CalendarJumpSearch, useCalendarJump } from "./useCalendarJump";
|
|
|
89
94
|
export {
|
|
90
95
|
type CalendarSuggestionAnswers,
|
|
91
96
|
type CalendarSuggestionsResult,
|
|
97
|
+
type PendingCalendarSuggestionResult,
|
|
92
98
|
type SuggestionAnswer,
|
|
93
99
|
useCalendarSuggestionAnswers,
|
|
94
100
|
useMessageCalendarSuggestions,
|
|
101
|
+
usePendingCalendarSuggestion,
|
|
95
102
|
usePendingCalendarSuggestions,
|
|
96
103
|
} from "./useCalendarSuggestions";
|
|
97
104
|
export { type CalendarsResult, useCalendars } from "./useCalendars";
|