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
pull/28788/head^2
Michael Weimann 2023-06-01 09:53:48 +02:00 committed by GitHub
parent 6be09eec09
commit b5727cb463
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 717 additions and 189 deletions

View File

@ -55,13 +55,18 @@ limitations under the License.
flex-direction: column;
justify-content: center;
.mx_RoomTile_title,
.mx_RoomTile_subtitle {
width: 100%;
align-items: center;
color: $secondary-content;
display: flex;
gap: $spacing-4;
line-height: $font-18px;
}
/* Ellipsize any text overflow */
text-overflow: ellipsis;
.mx_RoomTile_title,
.mx_RoomTile_subtitle_text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@ -74,11 +79,6 @@ limitations under the License.
}
}
.mx_RoomTile_subtitle {
line-height: $font-18px;
color: $secondary-content;
}
.mx_RoomTile_titleWithSubtitle {
margin-top: -3px; /* shift the title up a bit more */
}

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.88867 0.138672C1.36989 0.138672 0.138672 1.36989 0.138672 2.88867V15.1109C0.138672 15.395 0.299174 15.6547 0.553262 15.7817C0.80735 15.9088 1.11141 15.8813 1.33867 15.7109L4.36089 13.4442C4.57726 13.2819 4.84043 13.1942 5.11089 13.1942H13.1109C14.6297 13.1942 15.8609 11.963 15.8609 10.4442V2.88822C15.8609 1.36922 14.6295 0.138672 13.1109 0.138672H2.88867ZM3.69421 5.33301C3.69421 4.91879 4.03 4.58301 4.44421 4.58301H11.5553C11.9695 4.58301 12.3053 4.91879 12.3053 5.33301C12.3053 5.74722 11.9695 6.08301 11.5553 6.08301H4.44421C4.03 6.08301 3.69421 5.74722 3.69421 5.33301ZM4.44421 7.24976C4.03 7.24976 3.69421 7.58554 3.69421 7.99976C3.69421 8.41397 4.03 8.74976 4.44421 8.74976H7.99977C8.41398 8.74976 8.74977 8.41397 8.74977 7.99976C8.74977 7.58554 8.41398 7.24976 7.99977 7.24976H4.44421Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 974 B

View File

@ -27,7 +27,7 @@ import { Action } from "../../../dispatcher/actions";
import { _t } from "../../../languageHandler";
import { ChevronFace, ContextMenuTooltipButton, MenuProps } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import { MessagePreview, MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { RoomNotifState } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@ -44,11 +44,11 @@ import PosthogTrackers from "../../../PosthogTrackers";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { RoomTileCallSummary } from "./RoomTileCallSummary";
import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu";
import { CallStore, CallStoreEvent } from "../../../stores/CallStore";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { useHasRoomLiveVoiceBroadcast, VoiceBroadcastRoomSubtitle } from "../../../voice-broadcast";
import { useHasRoomLiveVoiceBroadcast } from "../../../voice-broadcast";
import { RoomTileSubtitle } from "./RoomTileSubtitle";
interface Props {
room: Room;
@ -68,7 +68,7 @@ interface State {
notificationsMenuPosition: PartialDOMRect | null;
generalMenuPosition: PartialDOMRect | null;
call: Call | null;
messagePreview?: string;
messagePreview: MessagePreview | null;
}
const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
@ -96,7 +96,7 @@ export class RoomTile extends React.PureComponent<ClassProps, State> {
generalMenuPosition: null,
call: CallStore.instance.getCall(this.props.room.roomId),
// generatePreview() will return nothing if the user has previews disabled
messagePreview: "",
messagePreview: null,
};
this.generatePreview();
@ -208,7 +208,7 @@ export class RoomTile extends React.PureComponent<ClassProps, State> {
}
const messagePreview =
(await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag)) ?? undefined;
(await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag)) ?? null;
this.setState({ messagePreview });
}
@ -359,6 +359,20 @@ export class RoomTile extends React.PureComponent<ClassProps, State> {
);
}
/**
* RoomTile has a subtile if one of the following applies:
* - there is a call
* - there is a live voice broadcast
* - message previews are enabled and there is a previewable message
*/
private get shouldRenderSubtitle(): boolean {
return (
!!this.state.call ||
this.props.hasLiveVoiceBroadcast ||
(this.props.showMessagePreview && !!this.state.messagePreview)
);
}
public render(): React.ReactElement {
const classes = classNames({
mx_RoomTile: true,
@ -385,26 +399,15 @@ export class RoomTile extends React.PureComponent<ClassProps, State> {
);
}
let subtitle;
if (this.state.call) {
subtitle = (
<div className="mx_RoomTile_subtitle">
<RoomTileCallSummary call={this.state.call} />
</div>
);
} else if (this.props.hasLiveVoiceBroadcast) {
subtitle = <VoiceBroadcastRoomSubtitle />;
} else if (this.showMessagePreview && this.state.messagePreview) {
subtitle = (
<div
className="mx_RoomTile_subtitle"
id={messagePreviewId(this.props.room.roomId)}
title={this.state.messagePreview}
>
{this.state.messagePreview}
</div>
);
}
const subtitle = this.shouldRenderSubtitle ? (
<RoomTileSubtitle
call={this.state.call}
hasLiveVoiceBroadcast={this.props.hasLiveVoiceBroadcast}
messagePreview={this.state.messagePreview}
roomId={this.props.room.roomId}
showMessagePreview={this.props.showMessagePreview}
/>
) : null;
const titleClasses = classNames({
mx_RoomTile_title: true,

View File

@ -0,0 +1,71 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import classNames from "classnames";
import { MessagePreview } from "../../../stores/room-list/MessagePreviewStore";
import { Call } from "../../../models/Call";
import { RoomTileCallSummary } from "./RoomTileCallSummary";
import { VoiceBroadcastRoomSubtitle } from "../../../voice-broadcast";
import { Icon as ThreadIcon } from "../../../../res/img/compound/thread-16px.svg";
interface Props {
call: Call | null;
hasLiveVoiceBroadcast: boolean;
messagePreview: MessagePreview | null;
roomId: string;
showMessagePreview: boolean;
}
const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
export const RoomTileSubtitle: React.FC<Props> = ({
call,
hasLiveVoiceBroadcast,
messagePreview,
roomId,
showMessagePreview,
}) => {
if (call) {
return (
<div className="mx_RoomTile_subtitle">
<RoomTileCallSummary call={call} />
</div>
);
}
if (hasLiveVoiceBroadcast) {
return <VoiceBroadcastRoomSubtitle />;
}
if (showMessagePreview && messagePreview) {
const className = classNames("mx_RoomTile_subtitle", {
"mx_RoomTile_subtitle--thread-reply": messagePreview.isThreadReply,
});
const icon = messagePreview.isThreadReply ? <ThreadIcon className="mx_Icon mx_Icon_16" /> : null;
return (
<div className={className} id={messagePreviewId(roomId)} title={messagePreview.text}>
{icon}
<span className="mx_RoomTile_subtitle_text">{messagePreview.text}</span>
</div>
);
}
return null;
};

View File

@ -18,6 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { M_POLL_START } from "matrix-js-sdk/src/@types/polls";
import { Thread } from "matrix-js-sdk/src/models/thread";
import { RelationType } from "matrix-js-sdk/src/matrix";
import { ActionPayload } from "../../dispatcher/payloads";
@ -96,6 +97,43 @@ interface IState {
// Empty because we don't actually use the state
}
export interface MessagePreview {
event: MatrixEvent;
isThreadReply: boolean;
text: string;
}
const isThreadReply = (event: MatrixEvent): boolean => {
// a thread root event cannot be a thread reply
if (event.isThreadRoot) return false;
const thread = event.getThread();
// it cannot be a thread reply if there is no thread
if (!thread) return false;
const relation = event.getRelation();
if (
!!relation &&
relation.rel_type === RelationType.Annotation &&
relation.event_id === thread.rootEvent?.getId()
) {
// annotations on the thread root are not a thread reply
return false;
}
return true;
};
const mkMessagePreview = (text: string, event: MatrixEvent): MessagePreview => {
return {
event,
text,
isThreadReply: isThreadReply(event),
};
};
export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
private static readonly internalInstance = (() => {
const instance = new MessagePreviewStore();
@ -111,7 +149,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
}
// null indicates the preview is empty / irrelevant
private previews = new Map<string, Map<TagID | TAG_ANY, [MatrixEvent, string] | null>>();
private previews = new Map<string, Map<TagID | TAG_ANY, MessagePreview | null>>();
private constructor() {
super(defaultDispatcher, {});
@ -131,7 +169,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
* @param inTagId The tag ID in which the room resides
* @returns The preview, or null if none present.
*/
public async getPreviewForRoom(room: Room, inTagId: TagID): Promise<string | null> {
public async getPreviewForRoom(room: Room, inTagId: TagID): Promise<MessagePreview | null> {
if (!room) return null; // invalid room, just return nothing
if (!this.previews.has(room.roomId)) await this.generatePreview(room, inTagId);
@ -140,9 +178,9 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
if (!previews) return null;
if (previews.has(inTagId)) {
return previews.get(inTagId)![1];
return previews.get(inTagId)!;
}
return previews.get(TAG_ANY)?.[1] ?? null;
return previews.get(TAG_ANY) ?? null;
}
public generatePreviewForEvent(event: MatrixEvent): string {
@ -166,16 +204,28 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
}
private async generatePreview(room: Room, tagId?: TagID): Promise<void> {
const events = room.timeline;
const events = [...room.getLiveTimeline().getEvents()];
// add last reply from each thread
room.getThreads().forEach((thread: Thread): void => {
const lastReply = thread.lastReply();
if (lastReply) events.push(lastReply);
});
// sort events from oldest to newest
events.sort((a: MatrixEvent, b: MatrixEvent) => {
return a.getTs() - b.getTs();
});
if (!events) return; // should only happen in tests
let map = this.previews.get(room.roomId);
if (!map) {
map = new Map<TagID | TAG_ANY, [MatrixEvent, string] | null>();
map = new Map<TagID | TAG_ANY, MessagePreview | null>();
this.previews.set(room.roomId, map);
}
const previousEventInAny = map.get(TAG_ANY)?.[0];
const previousEventInAny = map.get(TAG_ANY)?.event;
// Set the tags so we know what to generate
if (!map.has(TAG_ANY)) map.set(TAG_ANY, null);
@ -196,27 +246,28 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
if (!previewDef) continue;
if (previewDef.isState && isNullOrUndefined(event.getStateKey())) continue;
const anyPreview = previewDef.previewer.getTextFor(event);
if (!anyPreview) continue; // not previewable for some reason
const anyPreviewText = previewDef.previewer.getTextFor(event);
if (!anyPreviewText) continue; // not previewable for some reason
if (!this.shouldSkipPreview(event, previousEventInAny)) {
changed = changed || anyPreview !== map.get(TAG_ANY)?.[1];
map.set(TAG_ANY, [event, anyPreview]);
changed = changed || anyPreviewText !== map.get(TAG_ANY)?.text;
map.set(TAG_ANY, mkMessagePreview(anyPreviewText, event));
}
const tagsToGenerate = Array.from(map.keys()).filter((t) => t !== TAG_ANY); // we did the any tag above
for (const genTagId of tagsToGenerate) {
const previousEventInTag = map.get(genTagId)?.[0];
const previousEventInTag = map.get(genTagId)?.event;
if (this.shouldSkipPreview(event, previousEventInTag)) continue;
const realTagId = genTagId === TAG_ANY ? undefined : genTagId;
const preview = previewDef.previewer.getTextFor(event, realTagId);
if (preview === anyPreview) {
changed = changed || anyPreview !== map.get(genTagId)?.[1];
if (preview === anyPreviewText) {
changed = changed || anyPreviewText !== map.get(genTagId)?.text;
map.delete(genTagId);
} else {
changed = changed || preview !== map.get(genTagId)?.[1];
map.set(genTagId, preview ? [event, preview] : null);
changed = changed || preview !== map.get(genTagId)?.text;
map.set(genTagId, preview ? mkMessagePreview(anyPreviewText, event) : null);
}
}
@ -230,7 +281,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
}
// At this point, we didn't generate a preview so clear it
this.previews.set(room.roomId, new Map<TagID | TAG_ANY, [MatrixEvent, string] | null>());
this.previews.set(room.roomId, new Map<TagID | TAG_ANY, MessagePreview | null>());
this.emit(UPDATE_EVENT, this);
this.emit(MessagePreviewStore.getPreviewChangedEventName(room), room);
}

View File

@ -131,16 +131,6 @@ describe("EventTile", () => {
});
describe("EventTile renderingType: ThreadsList", () => {
beforeEach(() => {
const { rootEvent } = mkThread({
room,
client,
authorId: "@alice:example.org",
participantUserIds: ["@alice:example.org"],
});
mxEvent = rootEvent;
});
it("shows an unread notification badge", () => {
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);

View File

@ -22,6 +22,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { Widget } from "matrix-widget-api";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { Thread } from "matrix-js-sdk/src/models/thread";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { ClientWidgetApi } from "matrix-widget-api";
@ -33,6 +34,7 @@ import {
setupAsyncStoreWithClient,
filterConsole,
flushPromises,
mkMessage,
} from "../../../test-utils";
import { CallStore } from "../../../../src/stores/CallStore";
import RoomTile from "../../../../src/components/views/rooms/RoomTile";
@ -45,6 +47,7 @@ import { VoiceBroadcastInfoState } from "../../../../src/voice-broadcast";
import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils";
import { TestSdkContext } from "../../../TestSdkContext";
import { SDKContext } from "../../../../src/contexts/SDKContext";
import { MessagePreviewStore } from "../../../../src/stores/room-list/MessagePreviewStore";
describe("RoomTile", () => {
jest.spyOn(PlatformPeg, "get").mockReturnValue({
@ -69,7 +72,12 @@ describe("RoomTile", () => {
const renderRoomTile = (): void => {
renderResult = render(
<SDKContext.Provider value={sdkContext}>
<RoomTile room={room} showMessagePreview={false} isMinimized={false} tag={DefaultTagID.Untagged} />
<RoomTile
room={room}
showMessagePreview={showMessagePreview}
isMinimized={false}
tag={DefaultTagID.Untagged}
/>
</SDKContext.Provider>,
);
};
@ -79,12 +87,44 @@ describe("RoomTile", () => {
let room: Room;
let renderResult: RenderResult;
let sdkContext: TestSdkContext;
let showMessagePreview = false;
filterConsole(
// irrelevant for this test
"Room !1:example.org does not have an m.room.create event",
);
const addMessageToRoom = (ts: number) => {
const message = mkMessage({
event: true,
room: room.roomId,
msg: "test message",
user: client.getSafeUserId(),
ts,
});
room.timeline.push(message);
};
const addThreadMessageToRoom = (ts: number) => {
const message = mkMessage({
event: true,
room: room.roomId,
msg: "test thread reply",
user: client.getSafeUserId(),
ts,
});
// Mock thread reply for tests.
jest.spyOn(room, "getThreads").mockReturnValue([
// @ts-ignore
{
lastReply: () => message,
timeline: [],
} as Thread,
]);
};
beforeEach(() => {
sdkContext = new TestSdkContext();
@ -99,130 +139,199 @@ describe("RoomTile", () => {
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
renderRoomTile();
});
afterEach(() => {
jest.clearAllMocks();
// @ts-ignore
MessagePreviewStore.instance.previews = new Map<string, Map<TagID | TAG_ANY, MessagePreview | null>>();
jest.restoreAllMocks();
});
it("should render the room", () => {
expect(renderResult.container).toMatchSnapshot();
});
describe("when a call starts", () => {
let call: MockedCall;
let widget: Widget;
describe("when message previews are not enabled", () => {
beforeEach(() => {
setupAsyncStoreWithClient(CallStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
MockedCall.create(room, "1");
const maybeCall = CallStore.instance.getCall(room.roomId);
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
call = maybeCall;
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
renderRoomTile();
});
afterEach(() => {
renderResult.unmount();
call.destroy();
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
it("should render the room", () => {
expect(renderResult.container).toMatchSnapshot();
});
it("tracks connection state", async () => {
screen.getByText("Video");
describe("when a call starts", () => {
let call: MockedCall;
let widget: Widget;
// Insert an await point in the connection method so we can inspect
// the intermediate connecting state
let completeConnection: () => void = () => {};
const connectionCompleted = new Promise<void>((resolve) => (completeConnection = resolve));
jest.spyOn(call, "performConnection").mockReturnValue(connectionCompleted);
beforeEach(() => {
setupAsyncStoreWithClient(CallStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
await Promise.all([
(async () => {
await screen.findByText("Joining…");
const joinedFound = screen.findByText("Joined");
completeConnection();
await joinedFound;
})(),
call.connect(),
]);
MockedCall.create(room, "1");
const maybeCall = CallStore.instance.getCall(room.roomId);
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
call = maybeCall;
await Promise.all([screen.findByText("Video"), call.disconnect()]);
});
it("tracks participants", () => {
const alice: [RoomMember, Set<string>] = [mkRoomMember(room.roomId, "@alice:example.org"), new Set(["a"])];
const bob: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@bob:example.org"),
new Set(["b1", "b2"]),
];
const carol: [RoomMember, Set<string>] = [mkRoomMember(room.roomId, "@carol:example.org"), new Set(["c"])];
expect(screen.queryByLabelText(/participant/)).toBe(null);
act(() => {
call.participants = new Map([alice]);
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
});
expect(screen.getByLabelText("1 participant").textContent).toBe("1");
act(() => {
call.participants = new Map([alice, bob, carol]);
afterEach(() => {
renderResult.unmount();
call.destroy();
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
});
expect(screen.getByLabelText("4 participants").textContent).toBe("4");
act(() => {
call.participants = new Map();
it("tracks connection state", async () => {
screen.getByText("Video");
// Insert an await point in the connection method so we can inspect
// the intermediate connecting state
let completeConnection: () => void = () => {};
const connectionCompleted = new Promise<void>((resolve) => (completeConnection = resolve));
jest.spyOn(call, "performConnection").mockReturnValue(connectionCompleted);
await Promise.all([
(async () => {
await screen.findByText("Joining…");
const joinedFound = screen.findByText("Joined");
completeConnection();
await joinedFound;
})(),
call.connect(),
]);
await Promise.all([screen.findByText("Video"), call.disconnect()]);
});
it("tracks participants", () => {
const alice: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@alice:example.org"),
new Set(["a"]),
];
const bob: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@bob:example.org"),
new Set(["b1", "b2"]),
];
const carol: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@carol:example.org"),
new Set(["c"]),
];
expect(screen.queryByLabelText(/participant/)).toBe(null);
act(() => {
call.participants = new Map([alice]);
});
expect(screen.getByLabelText("1 participant").textContent).toBe("1");
act(() => {
call.participants = new Map([alice, bob, carol]);
});
expect(screen.getByLabelText("4 participants").textContent).toBe("4");
act(() => {
call.participants = new Map();
});
expect(screen.queryByLabelText(/participant/)).toBe(null);
});
describe("and a live broadcast starts", () => {
beforeEach(async () => {
await setUpVoiceBroadcast(VoiceBroadcastInfoState.Started);
});
it("should still render the call subtitle", () => {
expect(screen.queryByText("Video")).toBeInTheDocument();
expect(screen.queryByText("Live")).not.toBeInTheDocument();
});
});
expect(screen.queryByLabelText(/participant/)).toBe(null);
});
describe("and a live broadcast starts", () => {
describe("when a live voice broadcast starts", () => {
beforeEach(async () => {
await setUpVoiceBroadcast(VoiceBroadcastInfoState.Started);
});
it("should still render the call subtitle", () => {
expect(screen.queryByText("Video")).toBeInTheDocument();
expect(screen.queryByText("Live")).not.toBeInTheDocument();
it("should render the »Live« subtitle", () => {
expect(screen.queryByText("Live")).toBeInTheDocument();
});
describe("and the broadcast stops", () => {
beforeEach(async () => {
const stopEvent = mkVoiceBroadcastInfoStateEvent(
room.roomId,
VoiceBroadcastInfoState.Stopped,
client.getSafeUserId(),
client.getDeviceId()!,
voiceBroadcastInfoEvent,
);
await act(async () => {
room.currentState.setStateEvents([stopEvent]);
await flushPromises();
});
});
it("should not render the »Live« subtitle", () => {
expect(screen.queryByText("Live")).not.toBeInTheDocument();
});
});
});
});
describe("when a live voice broadcast starts", () => {
beforeEach(async () => {
await setUpVoiceBroadcast(VoiceBroadcastInfoState.Started);
describe("when message previews are enabled", () => {
beforeEach(() => {
showMessagePreview = true;
});
it("should render the »Live« subtitle", () => {
expect(screen.queryByText("Live")).toBeInTheDocument();
it("should render a room without a message as expected", async () => {
renderRoomTile();
// flush promises here because the preview is created asynchronously
await flushPromises();
expect(renderResult.asFragment()).toMatchSnapshot();
});
describe("and the broadcast stops", () => {
beforeEach(async () => {
const stopEvent = mkVoiceBroadcastInfoStateEvent(
room.roomId,
VoiceBroadcastInfoState.Stopped,
client.getSafeUserId(),
client.getDeviceId()!,
voiceBroadcastInfoEvent,
);
await act(async () => {
room.currentState.setStateEvents([stopEvent]);
await flushPromises();
});
describe("and there is a message in the room", () => {
beforeEach(() => {
addMessageToRoom(23);
});
it("should not render the »Live« subtitle", () => {
expect(screen.queryByText("Live")).not.toBeInTheDocument();
it("should render as expected", async () => {
renderRoomTile();
expect(await screen.findByText("test message")).toBeInTheDocument();
expect(renderResult.asFragment()).toMatchSnapshot();
});
});
describe("and there is a message in a thread", () => {
beforeEach(() => {
addThreadMessageToRoom(23);
});
it("should render as expected", async () => {
renderRoomTile();
expect(await screen.findByText("test thread reply")).toBeInTheDocument();
expect(renderResult.asFragment()).toMatchSnapshot();
});
});
describe("and there is a message and a thread without a reply", () => {
beforeEach(() => {
addMessageToRoom(23);
// Mock thread reply for tests.
jest.spyOn(room, "getThreads").mockReturnValue([
// @ts-ignore
{
lastReply: () => null,
timeline: [],
} as Thread,
]);
});
it("should render the message preview", async () => {
renderRoomTile();
expect(await screen.findByText("test message")).toBeInTheDocument();
});
});
});

View File

@ -1,6 +1,250 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RoomTile should render the room 1`] = `
exports[`RoomTile when message previews are enabled and there is a message in a thread should render as expected 1`] = `
<DocumentFragment>
<div
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
aria-label="!1:example.org"
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_DecoratedRoomAvatar"
>
<span
class="mx_BaseAvatar"
role="presentation"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 20.8px; width: 32px; line-height: 32px;"
>
!
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
loading="lazy"
src="data:image/png;base64,00"
style="width: 32px; height: 32px;"
/>
</span>
</div>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title mx_RoomTile_titleWithSubtitle"
tabindex="-1"
title="!1:example.org"
>
<span
dir="auto"
>
!1:example.org
</span>
</div>
<div
class="mx_RoomTile_subtitle"
id="mx_RoomTile_messagePreview_!1:example.org"
title="test thread reply"
>
<span
class="mx_RoomTile_subtitle_text"
>
test thread reply
</span>
</div>
</div>
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Room options"
class="mx_AccessibleButton mx_RoomTile_menuButton"
role="button"
tabindex="0"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Notification options"
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
role="button"
tabindex="-1"
/>
</div>
</DocumentFragment>
`;
exports[`RoomTile when message previews are enabled and there is a message in the room should render as expected 1`] = `
<DocumentFragment>
<div
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
aria-label="!1:example.org"
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_DecoratedRoomAvatar"
>
<span
class="mx_BaseAvatar"
role="presentation"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 20.8px; width: 32px; line-height: 32px;"
>
!
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
loading="lazy"
src="data:image/png;base64,00"
style="width: 32px; height: 32px;"
/>
</span>
</div>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title mx_RoomTile_titleWithSubtitle"
tabindex="-1"
title="!1:example.org"
>
<span
dir="auto"
>
!1:example.org
</span>
</div>
<div
class="mx_RoomTile_subtitle"
id="mx_RoomTile_messagePreview_!1:example.org"
title="test message"
>
<span
class="mx_RoomTile_subtitle_text"
>
test message
</span>
</div>
</div>
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Room options"
class="mx_AccessibleButton mx_RoomTile_menuButton"
role="button"
tabindex="0"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Notification options"
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
role="button"
tabindex="-1"
/>
</div>
</DocumentFragment>
`;
exports[`RoomTile when message previews are enabled should render a room without a message as expected 1`] = `
<DocumentFragment>
<div
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
aria-label="!1:example.org"
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_DecoratedRoomAvatar"
>
<span
class="mx_BaseAvatar"
role="presentation"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 20.8px; width: 32px; line-height: 32px;"
>
!
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
loading="lazy"
src="data:image/png;base64,00"
style="width: 32px; height: 32px;"
/>
</span>
</div>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title"
tabindex="-1"
title="!1:example.org"
>
<span
dir="auto"
>
!1:example.org
</span>
</div>
</div>
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Room options"
class="mx_AccessibleButton mx_RoomTile_menuButton"
role="button"
tabindex="0"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Notification options"
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
role="button"
tabindex="-1"
/>
</div>
</DocumentFragment>
`;
exports[`RoomTile when message previews are not enabled should render the room 1`] = `
<div>
<div
aria-label="!1:example.org"

View File

@ -14,22 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { EventType, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix";
import { Mocked, mocked } from "jest-mock";
import { EventTimeline, EventType, MatrixClient, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix";
import { MessagePreviewStore } from "../../../src/stores/room-list/MessagePreviewStore";
import { mkEvent, mkMessage, mkStubRoom, setupAsyncStoreWithClient, stubClient } from "../../test-utils";
import { mkEvent, mkMessage, mkReaction, setupAsyncStoreWithClient, stubClient } from "../../test-utils";
import { DefaultTagID } from "../../../src/stores/room-list/models";
import { mkThread } from "../../test-utils/threads";
describe("MessagePreviewStore", () => {
let client: Mocked<MatrixClient>;
let room: Room;
let store: MessagePreviewStore;
async function addEvent(
store: MessagePreviewStore,
room: Room,
event: MatrixEvent,
fireAction = true,
): Promise<void> {
room.timeline.push(event);
mocked(room.findEventById).mockImplementation((eventId) => room.timeline.find((e) => e.getId() === eventId));
room.addLiveEvents([event]);
if (fireAction) {
// @ts-ignore private access
await store.onAction({
@ -42,15 +46,17 @@ describe("MessagePreviewStore", () => {
}
}
it("should ignore edits for events other than the latest one", async () => {
const client = stubClient();
const room = mkStubRoom("!roomId:server", "Room", client);
beforeEach(async () => {
client = mocked(stubClient());
room = new Room("!roomId:server", client, client.getSafeUserId());
mocked(client.getRoom).mockReturnValue(room);
const store = MessagePreviewStore.testInstance();
store = MessagePreviewStore.testInstance();
await store.start();
await setupAsyncStoreWithClient(store, client);
});
it("should ignore edits for events other than the latest one", async () => {
const firstMessage = mkMessage({
user: "@sender:server",
event: true,
@ -59,7 +65,7 @@ describe("MessagePreviewStore", () => {
});
await addEvent(store, room, firstMessage, false);
await expect(store.getPreviewForRoom(room, DefaultTagID.Untagged)).resolves.toMatchInlineSnapshot(
expect((await store.getPreviewForRoom(room, DefaultTagID.Untagged))?.text).toMatchInlineSnapshot(
`"@sender:server: First message"`,
);
@ -71,7 +77,7 @@ describe("MessagePreviewStore", () => {
});
await addEvent(store, room, secondMessage);
await expect(store.getPreviewForRoom(room, DefaultTagID.Untagged)).resolves.toMatchInlineSnapshot(
expect((await store.getPreviewForRoom(room, DefaultTagID.Untagged))?.text).toMatchInlineSnapshot(
`"@sender:server: Second message"`,
);
@ -93,7 +99,7 @@ describe("MessagePreviewStore", () => {
});
await addEvent(store, room, firstMessageEdit);
await expect(store.getPreviewForRoom(room, DefaultTagID.Untagged)).resolves.toMatchInlineSnapshot(
expect((await store.getPreviewForRoom(room, DefaultTagID.Untagged))?.text).toMatchInlineSnapshot(
`"@sender:server: Second message"`,
);
@ -115,21 +121,13 @@ describe("MessagePreviewStore", () => {
});
await addEvent(store, room, secondMessageEdit);
await expect(store.getPreviewForRoom(room, DefaultTagID.Untagged)).resolves.toMatchInlineSnapshot(
expect((await store.getPreviewForRoom(room, DefaultTagID.Untagged))?.text).toMatchInlineSnapshot(
`"@sender:server: Second Message Edit"`,
);
});
it("should ignore edits to unknown events", async () => {
const client = stubClient();
const room = mkStubRoom("!roomId:server", "Room", client);
mocked(client.getRoom).mockReturnValue(room);
const store = MessagePreviewStore.testInstance();
await store.start();
await setupAsyncStoreWithClient(store, client);
await expect(store.getPreviewForRoom(room, DefaultTagID.DM)).resolves.toMatchInlineSnapshot(`null`);
await expect(store.getPreviewForRoom(room, DefaultTagID.DM)).resolves.toBeNull();
const firstMessage = mkMessage({
user: "@sender:server",
@ -139,7 +137,7 @@ describe("MessagePreviewStore", () => {
});
await addEvent(store, room, firstMessage, true);
await expect(store.getPreviewForRoom(room, DefaultTagID.DM)).resolves.toMatchInlineSnapshot(
expect((await store.getPreviewForRoom(room, DefaultTagID.DM))?.text).toMatchInlineSnapshot(
`"@sender:server: First message"`,
);
@ -161,22 +159,15 @@ describe("MessagePreviewStore", () => {
});
await addEvent(store, room, randomEdit);
await expect(store.getPreviewForRoom(room, DefaultTagID.Untagged)).resolves.toMatchInlineSnapshot(
expect((await store.getPreviewForRoom(room, DefaultTagID.Untagged))?.text).toMatchInlineSnapshot(
`"@sender:server: First message"`,
);
});
it("should generate correct preview for message events in DMs", async () => {
const client = stubClient();
const room = mkStubRoom("!roomId:server", "Room", client);
mocked(client.getRoom).mockReturnValue(room);
room.currentState.getJoinedMemberCount = jest.fn().mockReturnValue(2);
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!.getJoinedMemberCount = jest.fn().mockReturnValue(2);
const store = MessagePreviewStore.testInstance();
await store.start();
await setupAsyncStoreWithClient(store, client);
await expect(store.getPreviewForRoom(room, DefaultTagID.DM)).resolves.toMatchInlineSnapshot(`null`);
await expect(store.getPreviewForRoom(room, DefaultTagID.DM)).resolves.toBeNull();
const firstMessage = mkMessage({
user: "@sender:server",
@ -186,7 +177,7 @@ describe("MessagePreviewStore", () => {
});
await addEvent(store, room, firstMessage);
await expect(store.getPreviewForRoom(room, DefaultTagID.DM)).resolves.toMatchInlineSnapshot(
expect((await store.getPreviewForRoom(room, DefaultTagID.DM))?.text).toMatchInlineSnapshot(
`"@sender:server: First message"`,
);
@ -198,8 +189,45 @@ describe("MessagePreviewStore", () => {
});
await addEvent(store, room, secondMessage);
await expect(store.getPreviewForRoom(room, DefaultTagID.DM)).resolves.toMatchInlineSnapshot(
expect((await store.getPreviewForRoom(room, DefaultTagID.DM))?.text).toMatchInlineSnapshot(
`"@sender:server: Second message"`,
);
});
it("should generate the correct preview for a reaction", async () => {
const firstMessage = mkMessage({
user: "@sender:server",
event: true,
room: room.roomId,
msg: "First message",
});
await addEvent(store, room, firstMessage);
const reaction = mkReaction(firstMessage);
await addEvent(store, room, reaction);
const preview = await store.getPreviewForRoom(room, DefaultTagID.Untagged);
expect(preview).toBeDefined();
expect(preview.isThreadReply).toBe(false);
expect(preview.text).toMatchInlineSnapshot(`"@sender:server reacted 🙃 to First message"`);
});
it("should generate the correct preview for a reaction on a thread root", async () => {
const { rootEvent, thread } = mkThread({
room,
client,
authorId: client.getSafeUserId(),
participantUserIds: [client.getSafeUserId()],
});
await addEvent(store, room, rootEvent);
const reaction = mkReaction(rootEvent, { ts: 42 });
reaction.setThread(thread);
await addEvent(store, room, reaction);
const preview = await store.getPreviewForRoom(room, DefaultTagID.Untagged);
expect(preview).toBeDefined();
expect(preview.isThreadReply).toBe(false);
expect(preview.text).toContain("You reacted 🙃 to root event message");
});
});

View File

@ -36,6 +36,7 @@ import {
ConditionKind,
PushRuleActionName,
IPushRules,
RelationType,
} from "matrix-js-sdk/src/matrix";
import { normalize } from "matrix-js-sdk/src/utils";
import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
@ -471,6 +472,29 @@ export type MessageEventProps = MakeEventPassThruProps & {
msg?: string;
};
/**
* Creates a "🙃" reaction for the given event.
* Uses the same room and user as for the event.
*
* @returns The reaction event
*/
export const mkReaction = (event: MatrixEvent, opts: Partial<MakeEventProps> = {}): MatrixEvent => {
return mkEvent({
event: true,
room: event.getRoomId(),
type: EventType.Reaction,
user: event.getSender()!,
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: event.getId(),
key: "🙃",
},
},
...opts,
});
};
/**
* Create an m.room.message event.
* @param {Object} opts Values for the message

View File

@ -135,6 +135,11 @@ export const mkThread = ({
}
const thread = room.createThread(rootEvent.getId()!, rootEvent, events, true);
events.forEach((event) => {
thread.timeline.push(event);
});
// So that we do not have to mock the thread loading
thread.initialEventsFetched = true;