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

Commit 6bfdb3e

Browse files
authored
Fix read receipt animation (#12923)
* Fix read receipt animation The way it was done involved remembering dom nodes and then getting their position later when animating the receipt to its next position, but I'm not sure how this worked since the DOM node may not neccessarily be in the DOM anymore. Instead, just remember the bounding box coordinates. At worst it might go weird if the window is resized but seems fine in practice. Also, keeping references to dom nodes feels like a fast road to memory leaks. Fixes element-hq/element-web#27916 * Attempt to write a test for read receipts and fix naming * Another test also change a condition to make it testable
1 parent 5ff3fd6 commit 6bfdb3e

File tree

5 files changed

+108
-53
lines changed

5 files changed

+108
-53
lines changed

src/components/structures/MessagePanel.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
4747
import EditorStateTransfer from "../../utils/EditorStateTransfer";
4848
import { Action } from "../../dispatcher/actions";
4949
import { getEventDisplayInfo } from "../../utils/EventRenderingUtils";
50-
import { IReadReceiptInfo } from "../views/rooms/ReadReceiptMarker";
50+
import { IReadReceiptPosition } from "../views/rooms/ReadReceiptMarker";
5151
import { haveRendererForEvent } from "../../events/EventTileFactory";
5252
import { editorRoomKey } from "../../Editing";
5353
import { hasThreadSummary } from "../../utils/EventUtils";
@@ -213,7 +213,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
213213

214214
// opaque readreceipt info for each userId; used by ReadReceiptMarker
215215
// to manage its animations
216-
private readReceiptMap: { [userId: string]: IReadReceiptInfo } = {};
216+
private readReceiptMap: { [userId: string]: IReadReceiptPosition } = {};
217217

218218
// Track read receipts by event ID. For each _shown_ event ID, we store
219219
// the list of read receipts to display:

src/components/views/rooms/EventTile.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ import PlatformPeg from "../../../PlatformPeg";
6060
import MemberAvatar from "../avatars/MemberAvatar";
6161
import SenderProfile from "../messages/SenderProfile";
6262
import MessageTimestamp from "../messages/MessageTimestamp";
63-
import { IReadReceiptInfo } from "./ReadReceiptMarker";
63+
import { IReadReceiptPosition } from "./ReadReceiptMarker";
6464
import MessageActionBar from "../messages/MessageActionBar";
6565
import ReactionsRow from "../messages/ReactionsRow";
6666
import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
@@ -167,7 +167,7 @@ export interface EventTileProps {
167167
// opaque readreceipt info for each userId; used by ReadReceiptMarker
168168
// to manage its animations. Should be an empty object when the room
169169
// first loads
170-
readReceiptMap?: { [userId: string]: IReadReceiptInfo };
170+
readReceiptMap?: { [userId: string]: IReadReceiptPosition };
171171

172172
// A function which is used to check if the parent panel is being
173173
// unmounted, to avoid unnecessary work. Should return true if we

src/components/views/rooms/ReadReceiptGroup.tsx

+8-8
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import React, { PropsWithChildren } from "react";
1818
import { User } from "matrix-js-sdk/src/matrix";
1919
import { Tooltip } from "@vector-im/compound-web";
2020

21-
import ReadReceiptMarker, { IReadReceiptInfo } from "./ReadReceiptMarker";
21+
import ReadReceiptMarker, { IReadReceiptPosition } from "./ReadReceiptMarker";
2222
import { IReadReceiptProps } from "./EventTile";
2323
import AccessibleButton from "../elements/AccessibleButton";
2424
import MemberAvatar from "../avatars/MemberAvatar";
@@ -41,7 +41,7 @@ export const READ_AVATAR_SIZE = 16;
4141

4242
interface Props {
4343
readReceipts: IReadReceiptProps[];
44-
readReceiptMap: { [userId: string]: IReadReceiptInfo };
44+
readReceiptMap: { [userId: string]: IReadReceiptPosition };
4545
checkUnmounting?: () => boolean;
4646
suppressAnimation: boolean;
4747
isTwelveHour?: boolean;
@@ -111,13 +111,13 @@ export function ReadReceiptGroup({
111111
const { hidden, position } = determineAvatarPosition(index, maxAvatars);
112112

113113
const userId = receipt.userId;
114-
let readReceiptInfo: IReadReceiptInfo | undefined;
114+
let readReceiptPosition: IReadReceiptPosition | undefined;
115115

116116
if (readReceiptMap) {
117-
readReceiptInfo = readReceiptMap[userId];
118-
if (!readReceiptInfo) {
119-
readReceiptInfo = {};
120-
readReceiptMap[userId] = readReceiptInfo;
117+
readReceiptPosition = readReceiptMap[userId];
118+
if (!readReceiptPosition) {
119+
readReceiptPosition = {};
120+
readReceiptMap[userId] = readReceiptPosition;
121121
}
122122
}
123123

@@ -128,7 +128,7 @@ export function ReadReceiptGroup({
128128
fallbackUserId={userId}
129129
offset={position * READ_AVATAR_OFFSET}
130130
hidden={hidden}
131-
readReceiptInfo={readReceiptInfo}
131+
readReceiptPosition={readReceiptPosition}
132132
checkUnmounting={checkUnmounting}
133133
suppressAnimation={suppressAnimation}
134134
timestamp={receipt.ts}

src/components/views/rooms/ReadReceiptMarker.tsx

+17-41
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ import { toPx } from "../../../utils/units";
2424
import MemberAvatar from "../avatars/MemberAvatar";
2525
import { READ_AVATAR_SIZE } from "./ReadReceiptGroup";
2626

27-
export interface IReadReceiptInfo {
27+
// The top & right from the bounding client rect of each read receipt
28+
export interface IReadReceiptPosition {
2829
top?: number;
2930
right?: number;
30-
parent?: Element;
3131
}
3232

3333
interface IProps {
@@ -48,7 +48,7 @@ interface IProps {
4848
suppressAnimation?: boolean;
4949

5050
// an opaque object for storing information about this user's RR in this room
51-
readReceiptInfo?: IReadReceiptInfo;
51+
readReceiptPosition?: IReadReceiptPosition;
5252

5353
// A function which is used to check if the parent panel is being
5454
// unmounted, to avoid unnecessary work. Should return true if we
@@ -90,7 +90,7 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
9090
public componentWillUnmount(): void {
9191
// before we remove the rr, store its location in the map, so that if
9292
// it reappears, it can be animated from the right place.
93-
const rrInfo = this.props.readReceiptInfo;
93+
const rrInfo = this.props.readReceiptPosition;
9494
if (!rrInfo) {
9595
return;
9696
}
@@ -121,63 +121,39 @@ export default class ReadReceiptMarker extends React.PureComponent<IProps, IStat
121121
}
122122
}
123123

124-
private buildReadReceiptInfo(target: IReadReceiptInfo = {}): IReadReceiptInfo {
124+
private buildReadReceiptInfo(target: IReadReceiptPosition = {}): IReadReceiptPosition {
125125
const element = this.avatar.current;
126126
// this is the mx_ReadReceiptsGroup_container
127127
const horizontalContainer = element?.offsetParent;
128-
if (!horizontalContainer || !(horizontalContainer instanceof HTMLElement)) {
128+
if (!horizontalContainer || !horizontalContainer.getBoundingClientRect) {
129129
// this seems to happen sometimes for reasons I don't understand
130130
// the docs for `offsetParent` say it may be null if `display` is
131131
// `none`, but I can't see why that would happen.
132132
logger.warn(`ReadReceiptMarker for ${this.props.fallbackUserId} has no valid horizontalContainer`);
133133

134134
target.top = 0;
135135
target.right = 0;
136-
target.parent = undefined;
137136
return target;
138137
}
139-
// this is the mx_ReadReceiptsGroup
140-
const verticalContainer = horizontalContainer.offsetParent;
141-
if (!verticalContainer || !(verticalContainer instanceof HTMLElement)) {
142-
// this seems to happen sometimes for reasons I don't understand
143-
// the docs for `offsetParent` say it may be null if `display` is
144-
// `none`, but I can't see why that would happen.
145-
logger.warn(`ReadReceiptMarker for ${this.props.fallbackUserId} has no valid verticalContainer`);
146138

147-
target.top = 0;
148-
target.right = 0;
149-
target.parent = undefined;
150-
return target;
151-
}
139+
const elementRect = element.getBoundingClientRect();
152140

153-
target.top = element.offsetTop;
154-
target.right = element.getBoundingClientRect().right - horizontalContainer.getBoundingClientRect().right;
155-
target.parent = verticalContainer;
141+
target.top = elementRect.top;
142+
target.right = elementRect.right - horizontalContainer.getBoundingClientRect().right;
156143
return target;
157144
}
158145

159-
private readReceiptPosition(info: IReadReceiptInfo): number {
160-
if (!info.parent) {
161-
// this seems to happen sometimes for reasons I don't understand
162-
// the docs for `offsetParent` say it may be null if `display` is
163-
// `none`, but I can't see why that would happen.
164-
logger.warn(`ReadReceiptMarker for ${this.props.fallbackUserId} has no offsetParent`);
165-
return 0;
166-
}
167-
168-
return (info.top ?? 0) + info.parent.getBoundingClientRect().top;
169-
}
170-
171146
private animateMarker(): void {
172-
const oldInfo = this.props.readReceiptInfo;
147+
const oldInfo = this.props.readReceiptPosition;
173148
const newInfo = this.buildReadReceiptInfo();
174149

175-
const newPosition = this.readReceiptPosition(newInfo);
176-
const oldPosition = oldInfo
177-
? // start at the old height and in the old h pos
178-
this.readReceiptPosition(oldInfo)
179-
: // treat new RRs as though they were off the top of the screen
180-
-READ_AVATAR_SIZE;
150+
const newPosition = newInfo.top ?? 0;
151+
const oldPosition =
152+
oldInfo && oldInfo.top !== undefined
153+
? // start at the old height and in the old h pos
154+
oldInfo.top
155+
: // treat new RRs as though they were off the top of the screen
156+
-READ_AVATAR_SIZE;
181157

182158
const startStyles: IReadReceiptMarkerStyle[] = [];
183159
if (oldInfo?.right) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
Copyright 2024 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from "react";
18+
import { render, screen } from "@testing-library/react";
19+
20+
import ReadReceiptMarker, { IReadReceiptPosition } from "../../../../src/components/views/rooms/ReadReceiptMarker";
21+
22+
describe("ReadReceiptMarker", () => {
23+
afterEach(() => {
24+
jest.restoreAllMocks();
25+
jest.useRealTimers();
26+
});
27+
28+
it("should position at -16px if given no previous position", () => {
29+
render(<ReadReceiptMarker fallbackUserId="bob" offset={0} />);
30+
31+
expect(screen.getByTestId("avatar-img").style.top).toBe("-16px");
32+
});
33+
34+
it("should position at previous top if given", () => {
35+
render(<ReadReceiptMarker fallbackUserId="bob" offset={0} readReceiptPosition={{ top: 100, right: 0 }} />);
36+
37+
expect(screen.getByTestId("avatar-img").style.top).toBe("100px");
38+
});
39+
40+
it("should apply new styles after mounted to animate", () => {
41+
jest.useFakeTimers();
42+
43+
render(<ReadReceiptMarker fallbackUserId="bob" offset={0} readReceiptPosition={{ top: 100, right: 0 }} />);
44+
expect(screen.getByTestId("avatar-img").style.top).toBe("100px");
45+
46+
jest.runAllTimers();
47+
48+
expect(screen.getByTestId("avatar-img").style.top).toBe("0px");
49+
});
50+
51+
it("should update readReceiptPosition when unmounted", () => {
52+
const pos: IReadReceiptPosition = {};
53+
const { unmount } = render(<ReadReceiptMarker fallbackUserId="bob" offset={0} readReceiptPosition={pos} />);
54+
55+
expect(pos.top).toBeUndefined();
56+
57+
unmount();
58+
59+
expect(pos.top).toBe(0);
60+
});
61+
62+
it("should update readReceiptPosition to current position", () => {
63+
const pos: IReadReceiptPosition = {};
64+
jest.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation(function (): Element | null {
65+
return {
66+
getBoundingClientRect: jest.fn().mockReturnValue({ top: 0, right: 0 } as DOMRect),
67+
} as unknown as Element;
68+
});
69+
jest.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ top: 100, right: 0 } as DOMRect);
70+
71+
const { unmount } = render(<ReadReceiptMarker fallbackUserId="bob" offset={0} readReceiptPosition={pos} />);
72+
73+
expect(pos.top).toBeUndefined();
74+
75+
unmount();
76+
77+
expect(pos.top).toBe(100);
78+
});
79+
});

0 commit comments

Comments
 (0)