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

Commit b5727cb

Browse files
authored
Include thread replies in message previews (#10631)
* Include thread replies to message previews * Extend tests * Fix type issue * Use currentColor for thread icon * Fix long room name overflow * Update snapshots * Fix preview * Fix typing issue * Fix type issues * Tweak thread reply detection * Extend tests * Fix type issue * Fix test
1 parent 6be09ee commit b5727cb

File tree

11 files changed

+717
-189
lines changed

11 files changed

+717
-189
lines changed

res/css/views/rooms/_RoomTile.pcss

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,18 @@ limitations under the License.
5555
flex-direction: column;
5656
justify-content: center;
5757

58-
.mx_RoomTile_title,
5958
.mx_RoomTile_subtitle {
60-
width: 100%;
59+
align-items: center;
60+
color: $secondary-content;
61+
display: flex;
62+
gap: $spacing-4;
63+
line-height: $font-18px;
64+
}
6165

62-
/* Ellipsize any text overflow */
63-
text-overflow: ellipsis;
66+
.mx_RoomTile_title,
67+
.mx_RoomTile_subtitle_text {
6468
overflow: hidden;
69+
text-overflow: ellipsis;
6570
white-space: nowrap;
6671
}
6772

@@ -74,11 +79,6 @@ limitations under the License.
7479
}
7580
}
7681

77-
.mx_RoomTile_subtitle {
78-
line-height: $font-18px;
79-
color: $secondary-content;
80-
}
81-
8282
.mx_RoomTile_titleWithSubtitle {
8383
margin-top: -3px; /* shift the title up a bit more */
8484
}

res/img/compound/thread-16px.svg

Lines changed: 3 additions & 0 deletions
Loading

src/components/views/rooms/RoomTile.tsx

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { Action } from "../../../dispatcher/actions";
2727
import { _t } from "../../../languageHandler";
2828
import { ChevronFace, ContextMenuTooltipButton, MenuProps } from "../../structures/ContextMenu";
2929
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
30-
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
30+
import { MessagePreview, MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
3131
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
3232
import { RoomNotifState } from "../../../RoomNotifs";
3333
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -44,11 +44,11 @@ import PosthogTrackers from "../../../PosthogTrackers";
4444
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
4545
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
4646
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
47-
import { RoomTileCallSummary } from "./RoomTileCallSummary";
4847
import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu";
4948
import { CallStore, CallStoreEvent } from "../../../stores/CallStore";
5049
import { SdkContextClass } from "../../../contexts/SDKContext";
51-
import { useHasRoomLiveVoiceBroadcast, VoiceBroadcastRoomSubtitle } from "../../../voice-broadcast";
50+
import { useHasRoomLiveVoiceBroadcast } from "../../../voice-broadcast";
51+
import { RoomTileSubtitle } from "./RoomTileSubtitle";
5252

5353
interface Props {
5454
room: Room;
@@ -68,7 +68,7 @@ interface State {
6868
notificationsMenuPosition: PartialDOMRect | null;
6969
generalMenuPosition: PartialDOMRect | null;
7070
call: Call | null;
71-
messagePreview?: string;
71+
messagePreview: MessagePreview | null;
7272
}
7373

7474
const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
@@ -96,7 +96,7 @@ export class RoomTile extends React.PureComponent<ClassProps, State> {
9696
generalMenuPosition: null,
9797
call: CallStore.instance.getCall(this.props.room.roomId),
9898
// generatePreview() will return nothing if the user has previews disabled
99-
messagePreview: "",
99+
messagePreview: null,
100100
};
101101
this.generatePreview();
102102

@@ -208,7 +208,7 @@ export class RoomTile extends React.PureComponent<ClassProps, State> {
208208
}
209209

210210
const messagePreview =
211-
(await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag)) ?? undefined;
211+
(await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag)) ?? null;
212212
this.setState({ messagePreview });
213213
}
214214

@@ -359,6 +359,20 @@ export class RoomTile extends React.PureComponent<ClassProps, State> {
359359
);
360360
}
361361

362+
/**
363+
* RoomTile has a subtile if one of the following applies:
364+
* - there is a call
365+
* - there is a live voice broadcast
366+
* - message previews are enabled and there is a previewable message
367+
*/
368+
private get shouldRenderSubtitle(): boolean {
369+
return (
370+
!!this.state.call ||
371+
this.props.hasLiveVoiceBroadcast ||
372+
(this.props.showMessagePreview && !!this.state.messagePreview)
373+
);
374+
}
375+
362376
public render(): React.ReactElement {
363377
const classes = classNames({
364378
mx_RoomTile: true,
@@ -385,26 +399,15 @@ export class RoomTile extends React.PureComponent<ClassProps, State> {
385399
);
386400
}
387401

388-
let subtitle;
389-
if (this.state.call) {
390-
subtitle = (
391-
<div className="mx_RoomTile_subtitle">
392-
<RoomTileCallSummary call={this.state.call} />
393-
</div>
394-
);
395-
} else if (this.props.hasLiveVoiceBroadcast) {
396-
subtitle = <VoiceBroadcastRoomSubtitle />;
397-
} else if (this.showMessagePreview && this.state.messagePreview) {
398-
subtitle = (
399-
<div
400-
className="mx_RoomTile_subtitle"
401-
id={messagePreviewId(this.props.room.roomId)}
402-
title={this.state.messagePreview}
403-
>
404-
{this.state.messagePreview}
405-
</div>
406-
);
407-
}
402+
const subtitle = this.shouldRenderSubtitle ? (
403+
<RoomTileSubtitle
404+
call={this.state.call}
405+
hasLiveVoiceBroadcast={this.props.hasLiveVoiceBroadcast}
406+
messagePreview={this.state.messagePreview}
407+
roomId={this.props.room.roomId}
408+
showMessagePreview={this.props.showMessagePreview}
409+
/>
410+
) : null;
408411

409412
const titleClasses = classNames({
410413
mx_RoomTile_title: true,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
Copyright 2023 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 classNames from "classnames";
19+
20+
import { MessagePreview } from "../../../stores/room-list/MessagePreviewStore";
21+
import { Call } from "../../../models/Call";
22+
import { RoomTileCallSummary } from "./RoomTileCallSummary";
23+
import { VoiceBroadcastRoomSubtitle } from "../../../voice-broadcast";
24+
import { Icon as ThreadIcon } from "../../../../res/img/compound/thread-16px.svg";
25+
26+
interface Props {
27+
call: Call | null;
28+
hasLiveVoiceBroadcast: boolean;
29+
messagePreview: MessagePreview | null;
30+
roomId: string;
31+
showMessagePreview: boolean;
32+
}
33+
34+
const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
35+
36+
export const RoomTileSubtitle: React.FC<Props> = ({
37+
call,
38+
hasLiveVoiceBroadcast,
39+
messagePreview,
40+
roomId,
41+
showMessagePreview,
42+
}) => {
43+
if (call) {
44+
return (
45+
<div className="mx_RoomTile_subtitle">
46+
<RoomTileCallSummary call={call} />
47+
</div>
48+
);
49+
}
50+
51+
if (hasLiveVoiceBroadcast) {
52+
return <VoiceBroadcastRoomSubtitle />;
53+
}
54+
55+
if (showMessagePreview && messagePreview) {
56+
const className = classNames("mx_RoomTile_subtitle", {
57+
"mx_RoomTile_subtitle--thread-reply": messagePreview.isThreadReply,
58+
});
59+
60+
const icon = messagePreview.isThreadReply ? <ThreadIcon className="mx_Icon mx_Icon_16" /> : null;
61+
62+
return (
63+
<div className={className} id={messagePreviewId(roomId)} title={messagePreview.text}>
64+
{icon}
65+
<span className="mx_RoomTile_subtitle_text">{messagePreview.text}</span>
66+
</div>
67+
);
68+
}
69+
70+
return null;
71+
};

src/stores/room-list/MessagePreviewStore.ts

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
1818
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
1919
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
2020
import { M_POLL_START } from "matrix-js-sdk/src/@types/polls";
21+
import { Thread } from "matrix-js-sdk/src/models/thread";
2122
import { RelationType } from "matrix-js-sdk/src/matrix";
2223

2324
import { ActionPayload } from "../../dispatcher/payloads";
@@ -96,6 +97,43 @@ interface IState {
9697
// Empty because we don't actually use the state
9798
}
9899

100+
export interface MessagePreview {
101+
event: MatrixEvent;
102+
isThreadReply: boolean;
103+
text: string;
104+
}
105+
106+
const isThreadReply = (event: MatrixEvent): boolean => {
107+
// a thread root event cannot be a thread reply
108+
if (event.isThreadRoot) return false;
109+
110+
const thread = event.getThread();
111+
112+
// it cannot be a thread reply if there is no thread
113+
if (!thread) return false;
114+
115+
const relation = event.getRelation();
116+
117+
if (
118+
!!relation &&
119+
relation.rel_type === RelationType.Annotation &&
120+
relation.event_id === thread.rootEvent?.getId()
121+
) {
122+
// annotations on the thread root are not a thread reply
123+
return false;
124+
}
125+
126+
return true;
127+
};
128+
129+
const mkMessagePreview = (text: string, event: MatrixEvent): MessagePreview => {
130+
return {
131+
event,
132+
text,
133+
isThreadReply: isThreadReply(event),
134+
};
135+
};
136+
99137
export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
100138
private static readonly internalInstance = (() => {
101139
const instance = new MessagePreviewStore();
@@ -111,7 +149,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
111149
}
112150

113151
// null indicates the preview is empty / irrelevant
114-
private previews = new Map<string, Map<TagID | TAG_ANY, [MatrixEvent, string] | null>>();
152+
private previews = new Map<string, Map<TagID | TAG_ANY, MessagePreview | null>>();
115153

116154
private constructor() {
117155
super(defaultDispatcher, {});
@@ -131,7 +169,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
131169
* @param inTagId The tag ID in which the room resides
132170
* @returns The preview, or null if none present.
133171
*/
134-
public async getPreviewForRoom(room: Room, inTagId: TagID): Promise<string | null> {
172+
public async getPreviewForRoom(room: Room, inTagId: TagID): Promise<MessagePreview | null> {
135173
if (!room) return null; // invalid room, just return nothing
136174

137175
if (!this.previews.has(room.roomId)) await this.generatePreview(room, inTagId);
@@ -140,9 +178,9 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
140178
if (!previews) return null;
141179

142180
if (previews.has(inTagId)) {
143-
return previews.get(inTagId)![1];
181+
return previews.get(inTagId)!;
144182
}
145-
return previews.get(TAG_ANY)?.[1] ?? null;
183+
return previews.get(TAG_ANY) ?? null;
146184
}
147185

148186
public generatePreviewForEvent(event: MatrixEvent): string {
@@ -166,16 +204,28 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
166204
}
167205

168206
private async generatePreview(room: Room, tagId?: TagID): Promise<void> {
169-
const events = room.timeline;
207+
const events = [...room.getLiveTimeline().getEvents()];
208+
209+
// add last reply from each thread
210+
room.getThreads().forEach((thread: Thread): void => {
211+
const lastReply = thread.lastReply();
212+
if (lastReply) events.push(lastReply);
213+
});
214+
215+
// sort events from oldest to newest
216+
events.sort((a: MatrixEvent, b: MatrixEvent) => {
217+
return a.getTs() - b.getTs();
218+
});
219+
170220
if (!events) return; // should only happen in tests
171221

172222
let map = this.previews.get(room.roomId);
173223
if (!map) {
174-
map = new Map<TagID | TAG_ANY, [MatrixEvent, string] | null>();
224+
map = new Map<TagID | TAG_ANY, MessagePreview | null>();
175225
this.previews.set(room.roomId, map);
176226
}
177227

178-
const previousEventInAny = map.get(TAG_ANY)?.[0];
228+
const previousEventInAny = map.get(TAG_ANY)?.event;
179229

180230
// Set the tags so we know what to generate
181231
if (!map.has(TAG_ANY)) map.set(TAG_ANY, null);
@@ -196,27 +246,28 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
196246
if (!previewDef) continue;
197247
if (previewDef.isState && isNullOrUndefined(event.getStateKey())) continue;
198248

199-
const anyPreview = previewDef.previewer.getTextFor(event);
200-
if (!anyPreview) continue; // not previewable for some reason
249+
const anyPreviewText = previewDef.previewer.getTextFor(event);
250+
if (!anyPreviewText) continue; // not previewable for some reason
201251

202252
if (!this.shouldSkipPreview(event, previousEventInAny)) {
203-
changed = changed || anyPreview !== map.get(TAG_ANY)?.[1];
204-
map.set(TAG_ANY, [event, anyPreview]);
253+
changed = changed || anyPreviewText !== map.get(TAG_ANY)?.text;
254+
map.set(TAG_ANY, mkMessagePreview(anyPreviewText, event));
205255
}
206256

207257
const tagsToGenerate = Array.from(map.keys()).filter((t) => t !== TAG_ANY); // we did the any tag above
208258
for (const genTagId of tagsToGenerate) {
209-
const previousEventInTag = map.get(genTagId)?.[0];
259+
const previousEventInTag = map.get(genTagId)?.event;
210260
if (this.shouldSkipPreview(event, previousEventInTag)) continue;
211261

212262
const realTagId = genTagId === TAG_ANY ? undefined : genTagId;
213263
const preview = previewDef.previewer.getTextFor(event, realTagId);
214-
if (preview === anyPreview) {
215-
changed = changed || anyPreview !== map.get(genTagId)?.[1];
264+
265+
if (preview === anyPreviewText) {
266+
changed = changed || anyPreviewText !== map.get(genTagId)?.text;
216267
map.delete(genTagId);
217268
} else {
218-
changed = changed || preview !== map.get(genTagId)?.[1];
219-
map.set(genTagId, preview ? [event, preview] : null);
269+
changed = changed || preview !== map.get(genTagId)?.text;
270+
map.set(genTagId, preview ? mkMessagePreview(anyPreviewText, event) : null);
220271
}
221272
}
222273

@@ -230,7 +281,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
230281
}
231282

232283
// At this point, we didn't generate a preview so clear it
233-
this.previews.set(room.roomId, new Map<TagID | TAG_ANY, [MatrixEvent, string] | null>());
284+
this.previews.set(room.roomId, new Map<TagID | TAG_ANY, MessagePreview | null>());
234285
this.emit(UPDATE_EVENT, this);
235286
this.emit(MessagePreviewStore.getPreviewChangedEventName(room), room);
236287
}

test/components/views/rooms/EventTile-test.tsx

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,6 @@ describe("EventTile", () => {
131131
});
132132

133133
describe("EventTile renderingType: ThreadsList", () => {
134-
beforeEach(() => {
135-
const { rootEvent } = mkThread({
136-
room,
137-
client,
138-
authorId: "@alice:example.org",
139-
participantUserIds: ["@alice:example.org"],
140-
});
141-
mxEvent = rootEvent;
142-
});
143-
144134
it("shows an unread notification badge", () => {
145135
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
146136

0 commit comments

Comments
 (0)