Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit e5f06df

Browse files
Better error handling in jump to date (#10405)
- Friendly error messages with details - Add a way to submit debug logs for actual errors (non-networking errors) - Don't jump someone back to a room they already navigated away from. Fixes bug mentioned in element-hq/element-web#21263 (comment)
1 parent 1af7108 commit e5f06df

File tree

11 files changed

+425
-84
lines changed

11 files changed

+425
-84
lines changed

src/Modal.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,18 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
149149
return this.appendDialogAsync<C>(Promise.resolve(Element), props, className);
150150
}
151151

152-
public closeCurrentModal(reason: string): void {
152+
/**
153+
* @param reason either "backgroundClick" or undefined
154+
* @return whether a modal was closed
155+
*/
156+
public closeCurrentModal(reason?: string): boolean {
153157
const modal = this.getCurrentModal();
154158
if (!modal) {
155-
return;
159+
return false;
156160
}
157161
modal.closeReason = reason;
158162
modal.close();
163+
return true;
159164
}
160165

161166
private buildModal<C extends ComponentType>(
@@ -346,6 +351,8 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
346351
}
347352

348353
private async reRender(): Promise<void> {
354+
// TODO: We should figure out how to remove this weird sleep. It also makes testing harder
355+
//
349356
// await next tick because sometimes ReactDOM can race with itself and cause the modal to wrongly stick around
350357
await sleep(0);
351358

src/components/views/messages/DateSeparator.tsx

Lines changed: 123 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,19 @@ limitations under the License.
1818
import React from "react";
1919
import { Direction } from "matrix-js-sdk/src/models/event-timeline";
2020
import { logger } from "matrix-js-sdk/src/logger";
21+
import { ConnectionError, MatrixError, HTTPError } from "matrix-js-sdk/src/http-api";
2122

2223
import { _t } from "../../../languageHandler";
23-
import { formatFullDateNoTime } from "../../../DateUtils";
24+
import { formatFullDateNoDay, formatFullDateNoTime } from "../../../DateUtils";
2425
import { MatrixClientPeg } from "../../../MatrixClientPeg";
25-
import dis from "../../../dispatcher/dispatcher";
26+
import dispatcher from "../../../dispatcher/dispatcher";
2627
import { Action } from "../../../dispatcher/actions";
2728
import SettingsStore from "../../../settings/SettingsStore";
2829
import { UIFeature } from "../../../settings/UIFeature";
2930
import Modal from "../../../Modal";
3031
import ErrorDialog from "../dialogs/ErrorDialog";
32+
import BugReportDialog from "../dialogs/BugReportDialog";
33+
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
3134
import { contextMenuBelow } from "../rooms/RoomTile";
3235
import { ContextMenuTooltipButton } from "../../structures/ContextMenu";
3336
import IconizedContextMenu, {
@@ -36,6 +39,7 @@ import IconizedContextMenu, {
3639
} from "../context_menus/IconizedContextMenu";
3740
import JumpToDatePicker from "./JumpToDatePicker";
3841
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
42+
import { SdkContextClass } from "../../../contexts/SDKContext";
3943

4044
function getDaysArray(): string[] {
4145
return [_t("Sunday"), _t("Monday"), _t("Tuesday"), _t("Wednesday"), _t("Thursday"), _t("Friday"), _t("Saturday")];
@@ -76,7 +80,7 @@ export default class DateSeparator extends React.Component<IProps, IState> {
7680
if (this.settingWatcherRef) SettingsStore.unwatchSetting(this.settingWatcherRef);
7781
}
7882

79-
private onContextMenuOpenClick = (e: React.MouseEvent): void => {
83+
private onContextMenuOpenClick = (e: ButtonEvent): void => {
8084
e.preventDefault();
8185
e.stopPropagation();
8286
const target = e.target as HTMLButtonElement;
@@ -118,12 +122,12 @@ export default class DateSeparator extends React.Component<IProps, IState> {
118122

119123
private pickDate = async (inputTimestamp: number | string | Date): Promise<void> => {
120124
const unixTimestamp = new Date(inputTimestamp).getTime();
125+
const roomIdForJumpRequest = this.props.roomId;
121126

122-
const cli = MatrixClientPeg.get();
123127
try {
124-
const roomId = this.props.roomId;
128+
const cli = MatrixClientPeg.get();
125129
const { event_id: eventId, origin_server_ts: originServerTs } = await cli.timestampToEvent(
126-
roomId,
130+
roomIdForJumpRequest,
127131
unixTimestamp,
128132
Direction.Forward,
129133
);
@@ -132,28 +136,113 @@ export default class DateSeparator extends React.Component<IProps, IState> {
132136
`found ${eventId} (${originServerTs}) for timestamp=${unixTimestamp} (looking forward)`,
133137
);
134138

135-
dis.dispatch<ViewRoomPayload>({
136-
action: Action.ViewRoom,
137-
event_id: eventId,
138-
highlighted: true,
139-
room_id: roomId,
140-
metricsTrigger: undefined, // room doesn't change
141-
});
142-
} catch (e) {
143-
const code = e.errcode || e.statusCode;
144-
// only show the dialog if failing for something other than a network error
145-
// (e.g. no errcode or statusCode) as in that case the redactions end up in the
146-
// detached queue and we show the room status bar to allow retry
147-
if (typeof code !== "undefined") {
148-
// display error message stating you couldn't delete this.
139+
// Only try to navigate to the room if the user is still viewing the same
140+
// room. We don't want to jump someone back to a room after a slow request
141+
// if they've already navigated away to another room.
142+
const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
143+
if (currentRoomId === roomIdForJumpRequest) {
144+
dispatcher.dispatch<ViewRoomPayload>({
145+
action: Action.ViewRoom,
146+
event_id: eventId,
147+
highlighted: true,
148+
room_id: roomIdForJumpRequest,
149+
metricsTrigger: undefined, // room doesn't change
150+
});
151+
} else {
152+
logger.debug(
153+
`No longer navigating to date in room (jump to date) because the user already switched ` +
154+
`to another room: currentRoomId=${currentRoomId}, roomIdForJumpRequest=${roomIdForJumpRequest}`,
155+
);
156+
}
157+
} catch (err) {
158+
logger.error(
159+
`Error occured while trying to find event in ${roomIdForJumpRequest} ` +
160+
`at timestamp=${unixTimestamp}:`,
161+
err,
162+
);
163+
164+
// Only display an error if the user is still viewing the same room. We
165+
// don't want to worry someone about an error in a room they no longer care
166+
// about after a slow request if they've already navigated away to another
167+
// room.
168+
const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
169+
if (currentRoomId === roomIdForJumpRequest) {
170+
let friendlyErrorMessage = "An error occured while trying to find and jump to the given date.";
171+
let submitDebugLogsContent: JSX.Element = <></>;
172+
if (err instanceof ConnectionError) {
173+
friendlyErrorMessage = _t(
174+
"A network error occurred while trying to find and jump to the given date. " +
175+
"Your homeserver might be down or there was just a temporary problem with " +
176+
"your internet connection. Please try again. If this continues, please " +
177+
"contact your homeserver administrator.",
178+
);
179+
} else if (err instanceof MatrixError) {
180+
if (err?.errcode === "M_NOT_FOUND") {
181+
friendlyErrorMessage = _t(
182+
"We were unable to find an event looking forwards from %(dateString)s. " +
183+
"Try choosing an earlier date.",
184+
{ dateString: formatFullDateNoDay(new Date(unixTimestamp)) },
185+
);
186+
} else {
187+
friendlyErrorMessage = _t("Server returned %(statusCode)s with error code %(errorCode)s", {
188+
statusCode: err?.httpStatus || _t("unknown status code"),
189+
errorCode: err?.errcode || _t("unavailable"),
190+
});
191+
}
192+
} else if (err instanceof HTTPError) {
193+
friendlyErrorMessage = err.message;
194+
} else {
195+
// We only give the option to submit logs for actual errors, not network problems.
196+
submitDebugLogsContent = (
197+
<p>
198+
{_t(
199+
"Please submit <debugLogsLink>debug logs</debugLogsLink> to help us " +
200+
"track down the problem.",
201+
{},
202+
{
203+
debugLogsLink: (sub) => (
204+
<AccessibleButton
205+
// This is by default a `<div>` which we
206+
// can't nest within a `<p>` here so update
207+
// this to a be a inline anchor element.
208+
element="a"
209+
kind="link"
210+
onClick={() => this.onBugReport(err instanceof Error ? err : undefined)}
211+
data-testid="jump-to-date-error-submit-debug-logs-button"
212+
>
213+
{sub}
214+
</AccessibleButton>
215+
),
216+
},
217+
)}
218+
</p>
219+
);
220+
}
221+
149222
Modal.createDialog(ErrorDialog, {
150-
title: _t("Error"),
151-
description: _t("Unable to find event at that date. (%(code)s)", { code }),
223+
title: _t("Unable to find event at that date"),
224+
description: (
225+
<div data-testid="jump-to-date-error-content">
226+
<p>{friendlyErrorMessage}</p>
227+
{submitDebugLogsContent}
228+
<details>
229+
<summary>{_t("Error details")}</summary>
230+
<p>{String(err)}</p>
231+
</details>
232+
</div>
233+
),
152234
});
153235
}
154236
}
155237
};
156238

239+
private onBugReport = (err?: Error): void => {
240+
Modal.createDialog(BugReportDialog, {
241+
error: err,
242+
initialText: "Error occured while using jump to date #jump-to-date",
243+
});
244+
};
245+
157246
private onLastWeekClicked = (): void => {
158247
const date = new Date();
159248
date.setDate(date.getDate() - 7);
@@ -189,11 +278,20 @@ export default class DateSeparator extends React.Component<IProps, IState> {
189278
onFinished={this.onContextMenuCloseClick}
190279
>
191280
<IconizedContextMenuOptionList first>
192-
<IconizedContextMenuOption label={_t("Last week")} onClick={this.onLastWeekClicked} />
193-
<IconizedContextMenuOption label={_t("Last month")} onClick={this.onLastMonthClicked} />
281+
<IconizedContextMenuOption
282+
label={_t("Last week")}
283+
onClick={this.onLastWeekClicked}
284+
data-testid="jump-to-date-last-week"
285+
/>
286+
<IconizedContextMenuOption
287+
label={_t("Last month")}
288+
onClick={this.onLastMonthClicked}
289+
data-testid="jump-to-date-last-month"
290+
/>
194291
<IconizedContextMenuOption
195292
label={_t("The beginning of the room")}
196293
onClick={this.onTheBeginningClicked}
294+
data-testid="jump-to-date-beginning"
197295
/>
198296
</IconizedContextMenuOptionList>
199297

@@ -207,6 +305,7 @@ export default class DateSeparator extends React.Component<IProps, IState> {
207305
return (
208306
<ContextMenuTooltipButton
209307
className="mx_DateSeparator_jumpToDateMenu mx_DateSeparator_dateContent"
308+
data-testid="jump-to-date-separator-button"
210309
onClick={this.onContextMenuOpenClick}
211310
isExpanded={!!this.state.contextMenuPosition}
212311
title={_t("Jump to date")}

src/i18n/strings/en_EN.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2353,7 +2353,14 @@
23532353
"Saturday": "Saturday",
23542354
"Today": "Today",
23552355
"Yesterday": "Yesterday",
2356-
"Unable to find event at that date. (%(code)s)": "Unable to find event at that date. (%(code)s)",
2356+
"A network error occurred while trying to find and jump to the given date. Your homeserver might be down or there was just a temporary problem with your internet connection. Please try again. If this continues, please contact your homeserver administrator.": "A network error occurred while trying to find and jump to the given date. Your homeserver might be down or there was just a temporary problem with your internet connection. Please try again. If this continues, please contact your homeserver administrator.",
2357+
"We were unable to find an event looking forwards from %(dateString)s. Try choosing an earlier date.": "We were unable to find an event looking forwards from %(dateString)s. Try choosing an earlier date.",
2358+
"Server returned %(statusCode)s with error code %(errorCode)s": "Server returned %(statusCode)s with error code %(errorCode)s",
2359+
"unknown status code": "unknown status code",
2360+
"unavailable": "unavailable",
2361+
"Please submit <debugLogsLink>debug logs</debugLogsLink> to help us track down the problem.": "Please submit <debugLogsLink>debug logs</debugLogsLink> to help us track down the problem.",
2362+
"Unable to find event at that date": "Unable to find event at that date",
2363+
"Error details": "Error details",
23572364
"Last week": "Last week",
23582365
"Last month": "Last month",
23592366
"The beginning of the room": "The beginning of the room",

test/components/structures/auth/ForgotPassword-test.tsx

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@ import { MatrixClient, createClient } from "matrix-js-sdk/src/matrix";
2222

2323
import ForgotPassword from "../../../../src/components/structures/auth/ForgotPassword";
2424
import { ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig";
25-
import { filterConsole, flushPromisesWithFakeTimers, stubClient } from "../../../test-utils";
26-
import Modal from "../../../../src/Modal";
25+
import {
26+
clearAllModals,
27+
filterConsole,
28+
flushPromisesWithFakeTimers,
29+
stubClient,
30+
waitEnoughCyclesForModal,
31+
} from "../../../test-utils";
2732
import AutoDiscoveryUtils from "../../../../src/utils/AutoDiscoveryUtils";
2833

2934
jest.mock("matrix-js-sdk/src/matrix", () => ({
@@ -55,11 +60,6 @@ describe("<ForgotPassword>", () => {
5560
});
5661
};
5762

58-
const waitForDialog = async (): Promise<void> => {
59-
await flushPromisesWithFakeTimers();
60-
await flushPromisesWithFakeTimers();
61-
};
62-
6363
const itShouldCloseTheDialogAndShowThePasswordInput = (): void => {
6464
it("should close the dialog and show the password input", () => {
6565
expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument();
@@ -88,9 +88,9 @@ describe("<ForgotPassword>", () => {
8888
jest.spyOn(AutoDiscoveryUtils, "authComponentStateForError");
8989
});
9090

91-
afterEach(() => {
91+
afterEach(async () => {
9292
// clean up modals
93-
Modal.closeCurrentModal("force");
93+
await clearAllModals();
9494
});
9595

9696
beforeAll(() => {
@@ -322,7 +322,9 @@ describe("<ForgotPassword>", () => {
322322
describe("and submitting it", () => {
323323
beforeEach(async () => {
324324
await click(screen.getByText("Reset password"));
325-
await waitForDialog();
325+
await waitEnoughCyclesForModal({
326+
useFakeTimers: true,
327+
});
326328
});
327329

328330
it("should send the new password and show the click validation link dialog", () => {
@@ -350,7 +352,9 @@ describe("<ForgotPassword>", () => {
350352
await act(async () => {
351353
await userEvent.click(screen.getByTestId("dialog-background"), { delay: null });
352354
});
353-
await waitForDialog();
355+
await waitEnoughCyclesForModal({
356+
useFakeTimers: true,
357+
});
354358
});
355359

356360
itShouldCloseTheDialogAndShowThePasswordInput();
@@ -359,7 +363,9 @@ describe("<ForgotPassword>", () => {
359363
describe("and dismissing the dialog", () => {
360364
beforeEach(async () => {
361365
await click(screen.getByLabelText("Close dialog"));
362-
await waitForDialog();
366+
await waitEnoughCyclesForModal({
367+
useFakeTimers: true,
368+
});
363369
});
364370

365371
itShouldCloseTheDialogAndShowThePasswordInput();
@@ -368,7 +374,9 @@ describe("<ForgotPassword>", () => {
368374
describe("and clicking »Re-enter email address«", () => {
369375
beforeEach(async () => {
370376
await click(screen.getByText("Re-enter email address"));
371-
await waitForDialog();
377+
await waitEnoughCyclesForModal({
378+
useFakeTimers: true,
379+
});
372380
});
373381

374382
it("should close the dialog and go back to the email input", () => {
@@ -400,7 +408,9 @@ describe("<ForgotPassword>", () => {
400408
beforeEach(async () => {
401409
await click(screen.getByText("Sign out of all devices"));
402410
await click(screen.getByText("Reset password"));
403-
await waitForDialog();
411+
await waitEnoughCyclesForModal({
412+
useFakeTimers: true,
413+
});
404414
});
405415

406416
it("should show the sign out warning dialog", async () => {

0 commit comments

Comments
 (0)