diff --git a/res/css/views/rooms/_RoomTile.pcss b/res/css/views/rooms/_RoomTile.pcss index d0db686da8..8fcc4e9f7e 100644 --- a/res/css/views/rooms/_RoomTile.pcss +++ b/res/css/views/rooms/_RoomTile.pcss @@ -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 */ } diff --git a/res/img/compound/thread-16px.svg b/res/img/compound/thread-16px.svg new file mode 100644 index 0000000000..f1a678ebc9 --- /dev/null +++ b/res/img/compound/thread-16px.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index a5abb16a8f..af25a5c458 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -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 { 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 { } 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 { ); } + /** + * 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 { ); } - let subtitle; - if (this.state.call) { - subtitle = ( -
- -
- ); - } else if (this.props.hasLiveVoiceBroadcast) { - subtitle = ; - } else if (this.showMessagePreview && this.state.messagePreview) { - subtitle = ( -
- {this.state.messagePreview} -
- ); - } + const subtitle = this.shouldRenderSubtitle ? ( + + ) : null; const titleClasses = classNames({ mx_RoomTile_title: true, diff --git a/src/components/views/rooms/RoomTileSubtitle.tsx b/src/components/views/rooms/RoomTileSubtitle.tsx new file mode 100644 index 0000000000..55ff0dea9a --- /dev/null +++ b/src/components/views/rooms/RoomTileSubtitle.tsx @@ -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 = ({ + call, + hasLiveVoiceBroadcast, + messagePreview, + roomId, + showMessagePreview, +}) => { + if (call) { + return ( +
+ +
+ ); + } + + if (hasLiveVoiceBroadcast) { + return ; + } + + if (showMessagePreview && messagePreview) { + const className = classNames("mx_RoomTile_subtitle", { + "mx_RoomTile_subtitle--thread-reply": messagePreview.isThreadReply, + }); + + const icon = messagePreview.isThreadReply ? : null; + + return ( +
+ {icon} + {messagePreview.text} +
+ ); + } + + return null; +}; diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts index 0202494888..86be6f7861 100644 --- a/src/stores/room-list/MessagePreviewStore.ts +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -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 { private static readonly internalInstance = (() => { const instance = new MessagePreviewStore(); @@ -111,7 +149,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient { } // null indicates the preview is empty / irrelevant - private previews = new Map>(); + private previews = new Map>(); private constructor() { super(defaultDispatcher, {}); @@ -131,7 +169,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient { * @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 { + public async getPreviewForRoom(room: Room, inTagId: TagID): Promise { 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 { 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 { } private async generatePreview(room: Room, tagId?: TagID): Promise { - 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(); + map = new Map(); 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 { 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 { } // At this point, we didn't generate a preview so clear it - this.previews.set(room.roomId, new Map()); + this.previews.set(room.roomId, new Map()); this.emit(UPDATE_EVENT, this); this.emit(MessagePreviewStore.getPreviewChangedEventName(room), room); } diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx index 5f067088aa..506a11f70a 100644 --- a/test/components/views/rooms/EventTile-test.tsx +++ b/test/components/views/rooms/EventTile-test.tsx @@ -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); diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index b581171c97..11df23f97f 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -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( - + , ); }; @@ -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>(); + 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((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] = [mkRoomMember(room.roomId, "@alice:example.org"), new Set(["a"])]; - const bob: [RoomMember, Set] = [ - mkRoomMember(room.roomId, "@bob:example.org"), - new Set(["b1", "b2"]), - ]; - const carol: [RoomMember, Set] = [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((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] = [ + mkRoomMember(room.roomId, "@alice:example.org"), + new Set(["a"]), + ]; + const bob: [RoomMember, Set] = [ + mkRoomMember(room.roomId, "@bob:example.org"), + new Set(["b1", "b2"]), + ]; + const carol: [RoomMember, Set] = [ + 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(); }); }); }); diff --git a/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap b/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap index 157ed4cac8..2c11fcbe9b 100644 --- a/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap +++ b/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap @@ -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`] = ` + +
+
+ + + + +
+
+
+ + !1:​example.org + +
+
+ + test thread reply + +
+
+