@remit/web-client 0.0.294 → 0.0.296
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/CalendarUnavailable.tsx +56 -0
- package/src/components/calendar/CalendarWaiting.tsx +99 -0
- package/src/components/calendar/CalendarWorkspace.stories.tsx +100 -0
- package/src/components/calendar/CalendarWorkspace.tsx +49 -40
- package/src/components/calendar/PendingSuggestions.tsx +70 -0
- package/src/components/calendar/pending-suggestions.render.test.ts +132 -0
- package/src/components/mail/DailyBrief.tsx +2 -1
- package/src/components/mail/IntelligencePane.tsx +5 -6
- package/src/components/mail/authenticity-warning.stories.tsx +6 -1
- package/src/components/mail/intelligence-drawer.stories.tsx +11 -2
- package/src/components/mail/intelligence-story-calendar.ts +19 -0
- package/src/hooks/calendar/agenda-window.test.ts +216 -132
- package/src/hooks/calendar/index.ts +8 -0
- package/src/hooks/calendar/useCalendarSuggestions.ts +180 -0
- package/src/hooks/calendar/useCalendars.ts +14 -2
- package/src/hooks/intelligence-calendar.render.test.ts +277 -0
- package/src/hooks/useBriefSections.ts +2 -1
- package/src/hooks/useIntelligenceCalendar.ts +347 -0
- package/src/hooks/useIntelligenceData.ts +0 -1
- package/src/hooks/useUpdateAddressFlags.ts +1 -1
- package/src/lib/calendar-format.ts +41 -1
- package/src/lib/calendar-report.ts +11 -0
- package/src/lib/calendar-suggestion.test.ts +169 -0
- package/src/lib/calendar-suggestion.ts +251 -0
- package/src/lib/calendar-suggestion.west.test.ts +56 -0
- package/src/lib/starred-rows.ts +4 -2
- package/src/routes/calendar/$view.$date.tsx +2 -0
- package/src/routing/calendar.ts +29 -0
- package/src/routing/index.ts +1 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The events mail offers, and the answers the reader gives them.
|
|
3
|
+
*
|
|
4
|
+
* Every card is drawn from what the server holds. An answer is a request, and
|
|
5
|
+
* the card moves when the listing it came from is read again — never because
|
|
6
|
+
* this side decided the press worked — so a refused answer leaves the card
|
|
7
|
+
* where it was, with the refusal stated beside it.
|
|
8
|
+
*
|
|
9
|
+
* Answers come back as outcomes rather than throws, for the reason the event
|
|
10
|
+
* writes do: a surface switching on a caught `unknown` cannot tell a refusal it
|
|
11
|
+
* must state from a fault it must not swallow. A lapsed session is neither, and
|
|
12
|
+
* escalates the way it does everywhere else.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
calendarSuggestionActionOperationsAcceptCalendarSuggestionMutation,
|
|
16
|
+
calendarSuggestionActionOperationsDeclineCalendarSuggestionMutation,
|
|
17
|
+
calendarSuggestionActionOperationsDismissCalendarSuggestionMutation,
|
|
18
|
+
calendarSuggestionOperationsListCalendarSuggestionsOptions,
|
|
19
|
+
messageCalendarSuggestionOperationsListMessageCalendarSuggestionsOptions,
|
|
20
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
21
|
+
import type { RemitImapCalendarSuggestionResponse } from "@remit/api-http-client/types.gen.ts";
|
|
22
|
+
import {
|
|
23
|
+
type QueryClient,
|
|
24
|
+
useMutation,
|
|
25
|
+
useQuery,
|
|
26
|
+
useQueryClient,
|
|
27
|
+
} from "@tanstack/react-query";
|
|
28
|
+
import { useCallback, useMemo } from "react";
|
|
29
|
+
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
30
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
31
|
+
|
|
32
|
+
const SUGGESTION_META = softErrorStatuses(400, 404);
|
|
33
|
+
|
|
34
|
+
/** Every read an answer can change: the cards, the calendar, the sender rules. */
|
|
35
|
+
const ANSWERED_QUERY_IDS = new Set([
|
|
36
|
+
"calendarSuggestionOperationsListCalendarSuggestions",
|
|
37
|
+
"messageCalendarSuggestionOperationsListMessageCalendarSuggestions",
|
|
38
|
+
"calendarEventOperationsListCalendarEvents",
|
|
39
|
+
"calendarEventDetailOperationsGetCalendarEvent",
|
|
40
|
+
"calendarFreeBusyOperationsListCalendarFreeBusy",
|
|
41
|
+
"filterOperationsListFilters",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const invalidateAnswered = (queryClient: QueryClient): Promise<void> =>
|
|
45
|
+
queryClient.invalidateQueries({
|
|
46
|
+
predicate: (query) => {
|
|
47
|
+
const head = query.queryKey[0];
|
|
48
|
+
if (typeof head !== "object" || head === null) return false;
|
|
49
|
+
const id = (head as { _id?: unknown })._id;
|
|
50
|
+
return typeof id === "string" && ANSWERED_QUERY_IDS.has(id);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export interface CalendarSuggestionsResult {
|
|
55
|
+
suggestions: RemitImapCalendarSuggestionResponse[];
|
|
56
|
+
isLoading: boolean;
|
|
57
|
+
error: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every suggestion one message produced, whatever became of each. */
|
|
61
|
+
export function useMessageCalendarSuggestions(
|
|
62
|
+
messageId: string,
|
|
63
|
+
): CalendarSuggestionsResult {
|
|
64
|
+
const { data, isLoading, error } = useQuery({
|
|
65
|
+
...messageCalendarSuggestionOperationsListMessageCalendarSuggestionsOptions(
|
|
66
|
+
{ path: { messageId } },
|
|
67
|
+
),
|
|
68
|
+
meta: SUGGESTION_META,
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
suggestions: data?.items ?? [],
|
|
72
|
+
isLoading,
|
|
73
|
+
error: error ?? null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The suggestions still waiting on the reader, newest first. */
|
|
78
|
+
export function usePendingCalendarSuggestions(): CalendarSuggestionsResult {
|
|
79
|
+
const { data, isLoading, error } = useQuery({
|
|
80
|
+
...calendarSuggestionOperationsListCalendarSuggestionsOptions({
|
|
81
|
+
query: { state: "Pending" },
|
|
82
|
+
}),
|
|
83
|
+
meta: SUGGESTION_META,
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
suggestions: data?.items ?? [],
|
|
87
|
+
isLoading,
|
|
88
|
+
error: error ?? null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type SuggestionAnswer =
|
|
93
|
+
| { kind: "answered" }
|
|
94
|
+
| { kind: "refused"; message: string };
|
|
95
|
+
|
|
96
|
+
const ANSWERED: SuggestionAnswer = { kind: "answered" };
|
|
97
|
+
|
|
98
|
+
const refused = (error: unknown): SuggestionAnswer => ({
|
|
99
|
+
kind: "refused",
|
|
100
|
+
message: formatErrorMessage(error),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export interface CalendarSuggestionAnswers {
|
|
104
|
+
accept: (
|
|
105
|
+
suggestionId: string,
|
|
106
|
+
calendarId: string,
|
|
107
|
+
) => Promise<SuggestionAnswer>;
|
|
108
|
+
decline: (suggestionId: string) => Promise<SuggestionAnswer>;
|
|
109
|
+
dismiss: (
|
|
110
|
+
suggestionId: string,
|
|
111
|
+
muteSender: boolean,
|
|
112
|
+
) => Promise<SuggestionAnswer>;
|
|
113
|
+
isAnswering: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
117
|
+
const queryClient = useQueryClient();
|
|
118
|
+
const onSuccess = useCallback(
|
|
119
|
+
() => invalidateAnswered(queryClient),
|
|
120
|
+
[queryClient],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const accept = useMutation({
|
|
124
|
+
...calendarSuggestionActionOperationsAcceptCalendarSuggestionMutation(),
|
|
125
|
+
meta: SUGGESTION_META,
|
|
126
|
+
onSuccess,
|
|
127
|
+
});
|
|
128
|
+
const decline = useMutation({
|
|
129
|
+
...calendarSuggestionActionOperationsDeclineCalendarSuggestionMutation(),
|
|
130
|
+
meta: SUGGESTION_META,
|
|
131
|
+
onSuccess,
|
|
132
|
+
});
|
|
133
|
+
const dismiss = useMutation({
|
|
134
|
+
...calendarSuggestionActionOperationsDismissCalendarSuggestionMutation(),
|
|
135
|
+
meta: SUGGESTION_META,
|
|
136
|
+
onSuccess,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const acceptSuggestion = useCallback(
|
|
140
|
+
(suggestionId: string, calendarId: string) =>
|
|
141
|
+
accept
|
|
142
|
+
.mutateAsync({ path: { suggestionId }, body: { calendarId } })
|
|
143
|
+
.then(() => ANSWERED)
|
|
144
|
+
.catch(refused),
|
|
145
|
+
[accept],
|
|
146
|
+
);
|
|
147
|
+
const declineSuggestion = useCallback(
|
|
148
|
+
(suggestionId: string) =>
|
|
149
|
+
decline
|
|
150
|
+
.mutateAsync({ path: { suggestionId } })
|
|
151
|
+
.then(() => ANSWERED)
|
|
152
|
+
.catch(refused),
|
|
153
|
+
[decline],
|
|
154
|
+
);
|
|
155
|
+
const dismissSuggestion = useCallback(
|
|
156
|
+
(suggestionId: string, muteSender: boolean) =>
|
|
157
|
+
dismiss
|
|
158
|
+
.mutateAsync({ path: { suggestionId }, body: { muteSender } })
|
|
159
|
+
.then(() => ANSWERED)
|
|
160
|
+
.catch(refused),
|
|
161
|
+
[dismiss],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
return useMemo(
|
|
165
|
+
() => ({
|
|
166
|
+
accept: acceptSuggestion,
|
|
167
|
+
decline: declineSuggestion,
|
|
168
|
+
dismiss: dismissSuggestion,
|
|
169
|
+
isAnswering: accept.isPending || decline.isPending || dismiss.isPending,
|
|
170
|
+
}),
|
|
171
|
+
[
|
|
172
|
+
acceptSuggestion,
|
|
173
|
+
declineSuggestion,
|
|
174
|
+
dismissSuggestion,
|
|
175
|
+
accept.isPending,
|
|
176
|
+
decline.isPending,
|
|
177
|
+
dismiss.isPending,
|
|
178
|
+
],
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -18,11 +18,19 @@ export interface CalendarsResult {
|
|
|
18
18
|
colorByCalendarId: Record<string, CalendarColorId>;
|
|
19
19
|
/** The zone each collection's floating times are read in. */
|
|
20
20
|
timeZoneByCalendarId: Record<string, string>;
|
|
21
|
+
/**
|
|
22
|
+
* Where an accepted invitation lands: the collection the account was
|
|
23
|
+
* provisioned with, or the first one when that is gone. `""` until the
|
|
24
|
+
* listing has answered.
|
|
25
|
+
*/
|
|
26
|
+
defaultCalendarId: string;
|
|
21
27
|
isLoading: boolean;
|
|
28
|
+
/** Why the listing could not be read; null when it was. */
|
|
29
|
+
error: unknown;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
export function useCalendars(): CalendarsResult {
|
|
25
|
-
const { data, isLoading } = useQuery(
|
|
33
|
+
const { data, isLoading, error } = useQuery(
|
|
26
34
|
calendarOperationsListCalendarsOptions(),
|
|
27
35
|
);
|
|
28
36
|
|
|
@@ -37,7 +45,11 @@ export function useCalendars(): CalendarsResult {
|
|
|
37
45
|
timeZoneByCalendarId: Object.fromEntries(
|
|
38
46
|
items.map((item) => [item.calendarId, item.timezone]),
|
|
39
47
|
),
|
|
48
|
+
defaultCalendarId:
|
|
49
|
+
(items.find((item) => item.source === "Default") ?? items[0])
|
|
50
|
+
?.calendarId ?? "",
|
|
40
51
|
isLoading,
|
|
52
|
+
error: error ?? null,
|
|
41
53
|
};
|
|
42
|
-
}, [data, isLoading]);
|
|
54
|
+
}, [data, isLoading, error]);
|
|
43
55
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The invitation beside the open message, answered through the API (#1277).
|
|
3
|
+
*
|
|
4
|
+
* Drives the real chain: `useIntelligenceCalendar` reads the message's
|
|
5
|
+
* suggestions and the calendars, the real `IntelligencePanel` draws the card,
|
|
6
|
+
* and a press sends the request the server acts on. What is asserted is the
|
|
7
|
+
* request that left and what the card says after the server answered — a card
|
|
8
|
+
* that moves on its own before the answer is the thing this rules out.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { afterEach, describe, it } from "node:test";
|
|
12
|
+
import type {
|
|
13
|
+
RemitImapCalendarResponse,
|
|
14
|
+
RemitImapCalendarSuggestionResponse,
|
|
15
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
16
|
+
import { type IntelligenceData, IntelligencePanel } from "@remit/ui";
|
|
17
|
+
import { createElement } from "react";
|
|
18
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
19
|
+
import { makeThreadMessage } from "@/test-support/fixtures";
|
|
20
|
+
import {
|
|
21
|
+
type HttpCall,
|
|
22
|
+
type HttpMock,
|
|
23
|
+
httpError,
|
|
24
|
+
mockFetch,
|
|
25
|
+
} from "@/test-support/http";
|
|
26
|
+
import { useIntelligenceCalendar } from "./useIntelligenceCalendar";
|
|
27
|
+
|
|
28
|
+
const CALENDAR = "11111111-1111-4111-8111-111111111111";
|
|
29
|
+
const SUGGESTION = "22222222-2222-4222-8222-222222222222";
|
|
30
|
+
|
|
31
|
+
const thread = makeThreadMessage({ messageId: "msg-1" });
|
|
32
|
+
|
|
33
|
+
const sender: IntelligenceData = {
|
|
34
|
+
sender: {
|
|
35
|
+
name: "Organizer",
|
|
36
|
+
email: "organizer@example.test",
|
|
37
|
+
trust: "wellknown",
|
|
38
|
+
firstSeenLabel: "Jan 2025",
|
|
39
|
+
},
|
|
40
|
+
authenticity: {
|
|
41
|
+
verdict: "aligned",
|
|
42
|
+
fromDomain: "example.test",
|
|
43
|
+
summary: "Signed by example.test.",
|
|
44
|
+
},
|
|
45
|
+
category: { value: "personal" },
|
|
46
|
+
similar: [],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const calendars = [
|
|
50
|
+
{
|
|
51
|
+
calendarId: CALENDAR,
|
|
52
|
+
accountConfigId: "cfg-1",
|
|
53
|
+
urlSegment: "default",
|
|
54
|
+
displayName: "Personal",
|
|
55
|
+
color: "Cal2",
|
|
56
|
+
componentSet: "VeventOnly",
|
|
57
|
+
source: "Default",
|
|
58
|
+
timezone: "Europe/Amsterdam",
|
|
59
|
+
syncSequence: 1,
|
|
60
|
+
createdAt: 0,
|
|
61
|
+
updatedAt: 0,
|
|
62
|
+
},
|
|
63
|
+
] satisfies RemitImapCalendarResponse[];
|
|
64
|
+
|
|
65
|
+
const invitation = (
|
|
66
|
+
state: RemitImapCalendarSuggestionResponse["state"],
|
|
67
|
+
): RemitImapCalendarSuggestionResponse => ({
|
|
68
|
+
suggestionId: SUGGESTION,
|
|
69
|
+
accountConfigId: "cfg-1",
|
|
70
|
+
messageId: "msg-1",
|
|
71
|
+
bodyPartId: "part-1",
|
|
72
|
+
icalUid: "uid-1",
|
|
73
|
+
sequence: 0,
|
|
74
|
+
method: "Request",
|
|
75
|
+
source: "IcalendarPart",
|
|
76
|
+
state,
|
|
77
|
+
summary: "Quarterly review",
|
|
78
|
+
dtStart: "2026-09-01T08:00:00+00:00",
|
|
79
|
+
dtEnd: "2026-09-01T09:00:00+00:00",
|
|
80
|
+
allDay: false,
|
|
81
|
+
location: "",
|
|
82
|
+
organizer: "organizer@example.test",
|
|
83
|
+
zoneCertainty: "Explicit",
|
|
84
|
+
acceptedCalendarObjectId: "",
|
|
85
|
+
createdAt: 0,
|
|
86
|
+
updatedAt: 0,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const Harness = () => {
|
|
90
|
+
const calendar = useIntelligenceCalendar(thread);
|
|
91
|
+
return createElement(IntelligencePanel, {
|
|
92
|
+
data: sender,
|
|
93
|
+
calendar: calendar.surface,
|
|
94
|
+
tab: calendar.tab,
|
|
95
|
+
onTabChange: calendar.onTabChange,
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
let harness: DomHarness | undefined;
|
|
100
|
+
let http: HttpMock | undefined;
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
harness?.close();
|
|
104
|
+
harness = undefined;
|
|
105
|
+
http?.restore();
|
|
106
|
+
http = undefined;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A server holding one pending invitation. `answer` decides what an answer
|
|
111
|
+
* does: the state it moves the suggestion to, or a refusal to send back.
|
|
112
|
+
*/
|
|
113
|
+
const server = (
|
|
114
|
+
answer: (
|
|
115
|
+
call: HttpCall,
|
|
116
|
+
) => RemitImapCalendarSuggestionResponse["state"] | Response,
|
|
117
|
+
) => {
|
|
118
|
+
let state: RemitImapCalendarSuggestionResponse["state"] = "Pending";
|
|
119
|
+
return (call: HttpCall): unknown => {
|
|
120
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
121
|
+
if (call.path.endsWith("/messages/msg-1/calendar-suggestions"))
|
|
122
|
+
return { items: [invitation(state)] };
|
|
123
|
+
if (
|
|
124
|
+
call.method === "POST" &&
|
|
125
|
+
call.path.includes("/calendar-suggestions/")
|
|
126
|
+
) {
|
|
127
|
+
const next = answer(call);
|
|
128
|
+
if (next instanceof Response) return next;
|
|
129
|
+
state = next;
|
|
130
|
+
return invitation(state);
|
|
131
|
+
}
|
|
132
|
+
return { items: [] };
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const mount = async (respond: (call: HttpCall) => unknown) => {
|
|
137
|
+
http = mockFetch(respond);
|
|
138
|
+
harness = createDomHarness();
|
|
139
|
+
harness.renderApp(createElement(Harness));
|
|
140
|
+
await harness.waitFor(
|
|
141
|
+
() => harness?.text().includes("Quarterly review") === true,
|
|
142
|
+
"the invitation to be drawn",
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const press = (label: string) => {
|
|
147
|
+
if (!harness) throw new Error("not mounted");
|
|
148
|
+
harness.click(harness.byText("button", label));
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
describe("the invitation beside the open message", () => {
|
|
152
|
+
it("opens on the calendar tab while an invitation waits", async () => {
|
|
153
|
+
await mount(server(() => "Accepted"));
|
|
154
|
+
assert.match(
|
|
155
|
+
harness?.text() ?? "",
|
|
156
|
+
/Invitation from organizer@example\.test/,
|
|
157
|
+
);
|
|
158
|
+
assert.match(harness?.text() ?? "", /Add to calendar/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("adds it to the default calendar, and says so once the server has", async () => {
|
|
162
|
+
await mount(server(() => "Accepted"));
|
|
163
|
+
press("Add to calendar");
|
|
164
|
+
|
|
165
|
+
await harness?.waitFor(
|
|
166
|
+
() => harness?.text().includes("On your calendar") === true,
|
|
167
|
+
"the card to read back the accepted state",
|
|
168
|
+
);
|
|
169
|
+
const accepted =
|
|
170
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/accept`) ?? [];
|
|
171
|
+
assert.equal(accepted.length, 1);
|
|
172
|
+
assert.deepEqual(accepted[0].body, { calendarId: CALENDAR });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("states a refused answer where it was pressed, and leaves the button live", async () => {
|
|
176
|
+
await mount(
|
|
177
|
+
server(() => httpError(400, "The calendar it was going into is gone.")),
|
|
178
|
+
);
|
|
179
|
+
press("Add to calendar");
|
|
180
|
+
|
|
181
|
+
await harness?.waitFor(
|
|
182
|
+
() =>
|
|
183
|
+
harness?.text().includes("Couldn't add this to your calendar") === true,
|
|
184
|
+
"the refusal to be stated",
|
|
185
|
+
);
|
|
186
|
+
const alert = harness?.query('[role="alert"]');
|
|
187
|
+
assert.ok(alert, "a refusal is an alert, not a quiet line");
|
|
188
|
+
const button = harness?.byText("button", "Add to calendar") as
|
|
189
|
+
| HTMLButtonElement
|
|
190
|
+
| undefined;
|
|
191
|
+
assert.equal(button?.disabled, false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("keeps declining live when the calendars cannot be read, and says why adding waits", async () => {
|
|
195
|
+
const answering = server(() => "Declined");
|
|
196
|
+
await mount((call) =>
|
|
197
|
+
call.path.endsWith("/calendars") ? httpError(500) : answering(call),
|
|
198
|
+
);
|
|
199
|
+
await harness?.waitFor(
|
|
200
|
+
() => harness?.text().includes("Couldn't read your calendars") === true,
|
|
201
|
+
"the calendar read failure to be stated",
|
|
202
|
+
);
|
|
203
|
+
const add = harness?.byText("button", "Add to calendar") as
|
|
204
|
+
| HTMLButtonElement
|
|
205
|
+
| undefined;
|
|
206
|
+
assert.equal(add?.disabled, true);
|
|
207
|
+
assert.ok(harness?.query('a[href*="github.com"]'), "a report link");
|
|
208
|
+
|
|
209
|
+
press("Decline");
|
|
210
|
+
await harness?.waitFor(
|
|
211
|
+
() => harness?.text().includes("You declined") === true,
|
|
212
|
+
"the decline to land without a calendar",
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("offers the picked times to select by hand where the page has no clipboard", async () => {
|
|
217
|
+
await mount(server(() => "Accepted"));
|
|
218
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
219
|
+
value: undefined,
|
|
220
|
+
configurable: true,
|
|
221
|
+
});
|
|
222
|
+
press("Offer other times");
|
|
223
|
+
const slot = harness?.query("[aria-pressed]");
|
|
224
|
+
if (!slot) throw new Error("no slot offered");
|
|
225
|
+
harness?.click(slot);
|
|
226
|
+
press("Copy picked times");
|
|
227
|
+
|
|
228
|
+
await harness?.waitFor(
|
|
229
|
+
() => harness?.query('textarea[aria-label="Picked times"]') !== null,
|
|
230
|
+
"the times to be offered for selecting",
|
|
231
|
+
);
|
|
232
|
+
assert.match(harness?.text() ?? "", /can't copy for you/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("declines through the API", async () => {
|
|
236
|
+
await mount(server(() => "Declined"));
|
|
237
|
+
press("Decline");
|
|
238
|
+
|
|
239
|
+
await harness?.waitFor(
|
|
240
|
+
() => harness?.text().includes("You declined") === true,
|
|
241
|
+
"the card to read back the declined state",
|
|
242
|
+
);
|
|
243
|
+
assert.equal(
|
|
244
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/decline`).length,
|
|
245
|
+
1,
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("names the mail's sender on the mute button, which is who the rule matches", async () => {
|
|
250
|
+
await mount(server(() => "Dismissed"));
|
|
251
|
+
const text = harness?.text() ?? "";
|
|
252
|
+
assert.match(text, /Stop offering invitations from Alice/);
|
|
253
|
+
assert.doesNotMatch(
|
|
254
|
+
text,
|
|
255
|
+
/Stop offering invitations from organizer@example\.test/,
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("mutes the sender by dismissing with a sender rule, and the tab stays put", async () => {
|
|
260
|
+
await mount(server(() => "Dismissed"));
|
|
261
|
+
press("Stop offering invitations from Alice");
|
|
262
|
+
|
|
263
|
+
await harness?.waitFor(
|
|
264
|
+
() => harness?.text().includes("Add to calendar") === false,
|
|
265
|
+
"the card to leave once the server dismissed it",
|
|
266
|
+
);
|
|
267
|
+
assert.match(
|
|
268
|
+
harness?.text() ?? "",
|
|
269
|
+
/Nothing in this message is about a time/,
|
|
270
|
+
"answering the last card keeps the Calendar tab",
|
|
271
|
+
);
|
|
272
|
+
const dismissed =
|
|
273
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/dismiss`) ?? [];
|
|
274
|
+
assert.equal(dismissed.length, 1);
|
|
275
|
+
assert.deepEqual(dismissed[0].body, { muteSender: true });
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -276,7 +276,8 @@ export interface UseBriefSearchRows {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
/**
|
|
279
|
-
* The brief under a search: one request, one
|
|
279
|
+
* The brief under a search: one request, one list — sender matches first, then
|
|
280
|
+
* mentions, each newest first.
|
|
280
281
|
*
|
|
281
282
|
* Not seven category queries stitched back together. A match's category must not
|
|
282
283
|
* lift it above a newer match, and section headers between the rows would do
|