Refactor ThreadSummary to MVVM (#33603)
* Refactor ThreadSummary to MVVM * Stories Snapshot images * Add ThreadSummary ViewModel coverage * Fix ThreadSummary preview avatar rendering * Remove ThreadSummary classnames helper * Fix Prettier * Match ThreadMessagePreview typography * Move folder to correct path and fix storybook path * Catch ThreadSummary preview refresh errors * Update snapshot images + fix prettier * Fix ThreadSummary classNames import * Update Images * Remove wrong path
This commit is contained in:
@@ -64,8 +64,8 @@ test.describe("Threads", () => {
|
|||||||
|
|
||||||
// User asserts timeline thread summary visible & clicks it
|
// User asserts timeline thread summary visible & clicks it
|
||||||
let locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
let locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
||||||
await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached();
|
await expect(locator.getByText("BotBob")).toBeAttached();
|
||||||
await expect(locator.locator(".mx_ThreadSummary_content").getByText(MessageLong)).toBeAttached();
|
await expect(locator.getByText(MessageLong)).toBeAttached();
|
||||||
await locator.click();
|
await locator.click();
|
||||||
|
|
||||||
// Wait until the both messages are read
|
// Wait until the both messages are read
|
||||||
@@ -122,8 +122,8 @@ test.describe("Threads", () => {
|
|||||||
|
|
||||||
// User asserts summary was updated correctly
|
// User asserts summary was updated correctly
|
||||||
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
||||||
await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached();
|
await expect(locator.getByText("Tom")).toBeAttached();
|
||||||
await expect(locator.locator(".mx_ThreadSummary_content").getByText("Test")).toBeAttached();
|
await expect(locator.getByText("Test")).toBeAttached();
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// Check reactions and hidden events
|
// Check reactions and hidden events
|
||||||
@@ -243,8 +243,8 @@ test.describe("Threads", () => {
|
|||||||
|
|
||||||
// User asserts summary was updated correctly
|
// User asserts summary was updated correctly
|
||||||
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
||||||
await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached();
|
await expect(locator.getByText("BotBob")).toBeAttached();
|
||||||
await expect(locator.locator(".mx_ThreadSummary_content").getByText(MessageLong)).toBeAttached();
|
await expect(locator.getByText(MessageLong)).toBeAttached();
|
||||||
|
|
||||||
// User closes right panel after clicking back to thread list
|
// User closes right panel after clicking back to thread list
|
||||||
locator = page.locator(".mx_ThreadPanel");
|
locator = page.locator(".mx_ThreadPanel");
|
||||||
@@ -255,8 +255,8 @@ test.describe("Threads", () => {
|
|||||||
await bot.sendMessage(roomId, "How are things?", threadId);
|
await bot.sendMessage(roomId, "How are things?", threadId);
|
||||||
|
|
||||||
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
||||||
await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached();
|
await expect(locator.getByText("BotBob")).toBeAttached();
|
||||||
await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached();
|
await expect(locator.getByText("How are things?")).toBeAttached();
|
||||||
|
|
||||||
locator = page.getByRole("banner").getByRole("button", { name: "Threads" });
|
locator = page.getByRole("banner").getByRole("button", { name: "Threads" });
|
||||||
await expect(locator).toHaveAttribute("data-indicator", "success"); // User asserts thread list unread indicator
|
await expect(locator).toHaveAttribute("data-indicator", "success"); // User asserts thread list unread indicator
|
||||||
@@ -265,7 +265,7 @@ test.describe("Threads", () => {
|
|||||||
// User asserts thread with correct root & latest events & unread dot
|
// User asserts thread with correct root & latest events & unread dot
|
||||||
locator = page.locator(".mx_ThreadPanel .mx_EventTile_last");
|
locator = page.locator(".mx_ThreadPanel .mx_EventTile_last");
|
||||||
await expect(locator.locator(".mx_EventTile_body").getByText("Hello Mr. Bot")).toBeAttached();
|
await expect(locator.locator(".mx_EventTile_body").getByText("Hello Mr. Bot")).toBeAttached();
|
||||||
await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached();
|
await expect(locator.getByText("How are things?")).toBeAttached();
|
||||||
// Check the number of the replies
|
// Check the number of the replies
|
||||||
await expect(locator.locator(".mx_ThreadPanel_replies_amount").getByText("2")).toBeAttached();
|
await expect(locator.locator(".mx_ThreadPanel_replies_amount").getByText("2")).toBeAttached();
|
||||||
// Make sure the notification dot is visible
|
// Make sure the notification dot is visible
|
||||||
@@ -279,8 +279,8 @@ test.describe("Threads", () => {
|
|||||||
await locator.press("Enter");
|
await locator.press("Enter");
|
||||||
|
|
||||||
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
||||||
await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached();
|
await expect(locator.getByText("Tom")).toBeAttached();
|
||||||
await expect(locator.locator(".mx_ThreadSummary_content").getByText("Great!")).toBeAttached();
|
await expect(locator.getByText("Great!")).toBeAttached();
|
||||||
|
|
||||||
// User edits & asserts
|
// User edits & asserts
|
||||||
locator = page.locator(".mx_ThreadView .mx_EventTile_last");
|
locator = page.locator(".mx_ThreadView .mx_EventTile_last");
|
||||||
@@ -291,8 +291,8 @@ test.describe("Threads", () => {
|
|||||||
await locator.getByRole("textbox").press("Enter");
|
await locator.getByRole("textbox").press("Enter");
|
||||||
|
|
||||||
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
||||||
await expect(locator.locator(".mx_ThreadSummary_sender").getByText("Tom")).toBeAttached();
|
await expect(locator.getByText("Tom")).toBeAttached();
|
||||||
await expect(locator.locator(".mx_ThreadSummary_content")).toHaveText("Great! How about yourself?");
|
await expect(locator).toContainText("Great! How about yourself?");
|
||||||
|
|
||||||
// User closes right panel
|
// User closes right panel
|
||||||
await page.locator(".mx_ThreadPanel").getByRole("button", { name: "Close" }).click();
|
await page.locator(".mx_ThreadPanel").getByRole("button", { name: "Close" }).click();
|
||||||
@@ -302,8 +302,8 @@ test.describe("Threads", () => {
|
|||||||
|
|
||||||
// User asserts
|
// User asserts
|
||||||
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
||||||
await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached();
|
await expect(locator.getByText("BotBob")).toBeAttached();
|
||||||
await expect(locator.locator(".mx_ThreadSummary_content").getByText("I'm very good thanks")).toBeAttached();
|
await expect(locator.getByText("I'm very good thanks")).toBeAttached();
|
||||||
|
|
||||||
// Bot edits their latest event
|
// Bot edits their latest event
|
||||||
await bot.sendMessage(roomId, {
|
await bot.sendMessage(roomId, {
|
||||||
@@ -321,8 +321,8 @@ test.describe("Threads", () => {
|
|||||||
|
|
||||||
// User asserts
|
// User asserts
|
||||||
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
locator = page.locator(".mx_RoomView_body .mx_ThreadSummary");
|
||||||
await expect(locator.locator(".mx_ThreadSummary_sender").getByText("BotBob")).toBeAttached();
|
await expect(locator.getByText("BotBob")).toBeAttached();
|
||||||
await expect(locator.locator(".mx_ThreadSummary_content").getByText("I'm very good thanks :)")).toBeAttached();
|
await expect(locator.getByText("I'm very good thanks :)")).toBeAttached();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("with larger viewport", async () => {
|
test.describe("with larger viewport", async () => {
|
||||||
|
|||||||
@@ -293,7 +293,6 @@
|
|||||||
@import "./views/rooms/_SendMessageComposer.pcss";
|
@import "./views/rooms/_SendMessageComposer.pcss";
|
||||||
@import "./views/rooms/_Stickers.pcss";
|
@import "./views/rooms/_Stickers.pcss";
|
||||||
@import "./views/rooms/_ThirdPartyMemberInfo.pcss";
|
@import "./views/rooms/_ThirdPartyMemberInfo.pcss";
|
||||||
@import "./views/rooms/_ThreadSummary.pcss";
|
|
||||||
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
|
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
|
||||||
@import "./views/rooms/_UserIdentityWarning.pcss";
|
@import "./views/rooms/_UserIdentityWarning.pcss";
|
||||||
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
|
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
|
||||||
|
|||||||
@@ -1066,12 +1066,23 @@ $left-gutter: 64px;
|
|||||||
.mx_ThreadPanel_replies_amount {
|
.mx_ThreadPanel_replies_amount {
|
||||||
@mixin ThreadRepliesAmount;
|
@mixin ThreadRepliesAmount;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mx_ThreadSummary_content {
|
.mx_ThreadSummary_icon {
|
||||||
text-overflow: ellipsis;
|
display: inline-block;
|
||||||
overflow: hidden;
|
margin-top: $spacing-8;
|
||||||
white-space: nowrap;
|
margin-bottom: $spacing-8;
|
||||||
}
|
|
||||||
|
> svg {
|
||||||
|
@mixin ThreadSummaryIcon;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline-end: $spacing-8;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a& {
|
||||||
|
color: $secondary-content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.mx_ThreadSummary,
|
|
||||||
.mx_ThreadSummary_content {
|
|
||||||
color: $secondary-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_ThreadSummary,
|
|
||||||
.mx_ThreadSummary_icon {
|
|
||||||
margin-top: $spacing-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_ThreadSummary {
|
|
||||||
min-width: 267px;
|
|
||||||
/* leave space on both left & right gutters */
|
|
||||||
max-width: min(calc(100% - var(--EventTile_group_line-spacing-inline-start)), 600px);
|
|
||||||
width: fit-content;
|
|
||||||
height: 40px;
|
|
||||||
position: relative;
|
|
||||||
background-color: $panels;
|
|
||||||
padding-inline: $spacing-12 $spacing-16;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
clear: both;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid $panels; /* always render a border so the hover effect doesn't require a re-layout */
|
|
||||||
font: var(--cpd-font-body-sm-regular);
|
|
||||||
|
|
||||||
.mx_ThreadSummary_chevron {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 60px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
/* XXX: We use an HEXA `transparent` to work around a Safari <15.4 bug */
|
|
||||||
background: linear-gradient(270deg, $system 50%, $system-transparent 100%);
|
|
||||||
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(60px);
|
|
||||||
transition: all 0.1s ease-in-out;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: var(--cpd-space-1x);
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
color: $secondary-content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
border-color: $quinary-content;
|
|
||||||
|
|
||||||
.mx_ThreadSummary_chevron {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_ThreadSummary_sender,
|
|
||||||
.mx_ThreadSummary_content,
|
|
||||||
.mx_ThreadSummary_replies_amount {
|
|
||||||
line-height: var(--EventTile_ThreadSummary-line-height);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_ThreadSummary_sender,
|
|
||||||
.mx_ThreadSummary_content {
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_ThreadSummary_sender {
|
|
||||||
font-weight: var(--cpd-font-weight-semibold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_ThreadSummary_content {
|
|
||||||
margin-left: $spacing-4;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_ThreadSummary_replies_amount {
|
|
||||||
@mixin ThreadRepliesAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_MessagePanel_narrow & {
|
|
||||||
min-width: initial;
|
|
||||||
max-width: 100%; /* prevent overflow */
|
|
||||||
width: initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_ThreadSummary_avatar {
|
|
||||||
margin-inline-end: $spacing-8;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_ThreadSummary_icon {
|
|
||||||
display: inline-block;
|
|
||||||
margin-bottom: $spacing-8;
|
|
||||||
|
|
||||||
> svg {
|
|
||||||
@mixin ThreadSummaryIcon;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-inline-end: $spacing-8;
|
|
||||||
margin-top: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a& {
|
|
||||||
color: $secondary-content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@ import React, {
|
|||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
import {
|
import {
|
||||||
type EventStatus,
|
type EventStatus,
|
||||||
EventType,
|
EventType,
|
||||||
@@ -46,6 +47,8 @@ import {
|
|||||||
PinnedMessageBadge,
|
PinnedMessageBadge,
|
||||||
ReactionsRowButtonView,
|
ReactionsRowButtonView,
|
||||||
ReactionsRowView,
|
ReactionsRowView,
|
||||||
|
ThreadMessagePreviewView,
|
||||||
|
ThreadSummaryView,
|
||||||
TileErrorView,
|
TileErrorView,
|
||||||
useViewModel,
|
useViewModel,
|
||||||
} from "@element-hq/web-shared-components";
|
} from "@element-hq/web-shared-components";
|
||||||
@@ -80,7 +83,6 @@ import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
|
|||||||
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
import PosthogTrackers from "../../../PosthogTrackers";
|
import PosthogTrackers from "../../../PosthogTrackers";
|
||||||
import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory";
|
import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory";
|
||||||
import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary";
|
|
||||||
import { ReadReceiptGroup } from "./ReadReceiptGroup";
|
import { ReadReceiptGroup } from "./ReadReceiptGroup";
|
||||||
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
||||||
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
|
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
|
||||||
@@ -91,6 +93,7 @@ import { EventPreview } from "./EventPreview";
|
|||||||
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
|
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import { CardContext } from "../right_panel/context";
|
import { CardContext } from "../right_panel/context";
|
||||||
|
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
||||||
import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
import { E2eMessageSharedIconViewModel } from "../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel";
|
import { E2eMessageSharedIconViewModel } from "../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel";
|
||||||
import {
|
import {
|
||||||
@@ -119,6 +122,10 @@ import {
|
|||||||
MessageTimestampViewModel,
|
MessageTimestampViewModel,
|
||||||
type MessageTimestampViewModelProps,
|
type MessageTimestampViewModelProps,
|
||||||
} from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts";
|
} from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts";
|
||||||
|
import {
|
||||||
|
ThreadMessagePreviewViewModel,
|
||||||
|
ThreadSummaryViewModel,
|
||||||
|
} from "../../../viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx";
|
||||||
import { ReactionsRowButtonViewModel } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel";
|
import { ReactionsRowButtonViewModel } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel";
|
||||||
import {
|
import {
|
||||||
MAX_ITEMS_WHEN_LIMITED,
|
MAX_ITEMS_WHEN_LIMITED,
|
||||||
@@ -535,7 +542,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
<div className="mx_ThreadPanel_replies">
|
<div className="mx_ThreadPanel_replies">
|
||||||
<ThreadsIcon />
|
<ThreadsIcon />
|
||||||
<span className="mx_ThreadPanel_replies_amount">{threadState.thread.length}</span>
|
<span className="mx_ThreadPanel_replies_amount">{threadState.thread.length}</span>
|
||||||
<ThreadMessagePreview thread={threadState.thread} />
|
<ThreadMessagePreviewWrapper thread={threadState.thread} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -543,7 +550,11 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode {
|
private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode {
|
||||||
if (threadState.shouldShowThreadSummary && threadState.thread) {
|
if (threadState.shouldShowThreadSummary && threadState.thread) {
|
||||||
return (
|
return (
|
||||||
<ThreadSummary mxEvent={this.props.mxEvent} thread={threadState.thread} data-testid="thread-summary" />
|
<ThreadSummaryWrapper
|
||||||
|
mxEvent={this.props.mxEvent}
|
||||||
|
thread={threadState.thread}
|
||||||
|
data-testid="thread-summary"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1552,6 +1563,100 @@ function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Ele
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ThreadMessagePreviewWrapperProps {
|
||||||
|
thread: Thread;
|
||||||
|
showDisplayName?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThreadMessagePreviewWrapper({
|
||||||
|
thread,
|
||||||
|
showDisplayName = false,
|
||||||
|
}: Readonly<ThreadMessagePreviewWrapperProps>): JSX.Element {
|
||||||
|
const cli = useMatrixClientContext();
|
||||||
|
const { room, timelineRenderingType, lowBandwidth } = useScopedRoomContext(
|
||||||
|
"room",
|
||||||
|
"timelineRenderingType",
|
||||||
|
"lowBandwidth",
|
||||||
|
);
|
||||||
|
const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles");
|
||||||
|
const vm = useCreateAutoDisposedViewModel(
|
||||||
|
() =>
|
||||||
|
new ThreadMessagePreviewViewModel({
|
||||||
|
cli,
|
||||||
|
thread,
|
||||||
|
room,
|
||||||
|
timelineRenderingType,
|
||||||
|
lowBandwidth,
|
||||||
|
useOnlyCurrentProfiles,
|
||||||
|
showDisplayName,
|
||||||
|
avatarClassName: "mx_BaseAvatar",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setClient(cli);
|
||||||
|
vm.setThread(thread);
|
||||||
|
vm.setRoom(room);
|
||||||
|
vm.setTimelineRenderingType(timelineRenderingType);
|
||||||
|
vm.setLowBandwidth(lowBandwidth);
|
||||||
|
vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles);
|
||||||
|
vm.setShowDisplayName(showDisplayName);
|
||||||
|
}, [vm, cli, thread, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles, showDisplayName]);
|
||||||
|
|
||||||
|
return <ThreadMessagePreviewView vm={vm} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadSummaryWrapperProps extends Omit<React.ComponentPropsWithoutRef<"button">, "aria-label" | "onClick"> {
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
thread: Thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThreadSummaryWrapper({
|
||||||
|
mxEvent,
|
||||||
|
thread,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: Readonly<ThreadSummaryWrapperProps>): JSX.Element {
|
||||||
|
const cli = useMatrixClientContext();
|
||||||
|
const { isCard } = useContext(CardContext);
|
||||||
|
const { narrow, room, timelineRenderingType, lowBandwidth } = useScopedRoomContext(
|
||||||
|
"narrow",
|
||||||
|
"room",
|
||||||
|
"timelineRenderingType",
|
||||||
|
"lowBandwidth",
|
||||||
|
);
|
||||||
|
const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles");
|
||||||
|
const vm = useCreateAutoDisposedViewModel(
|
||||||
|
() =>
|
||||||
|
new ThreadSummaryViewModel({
|
||||||
|
cli,
|
||||||
|
mxEvent,
|
||||||
|
thread,
|
||||||
|
narrow,
|
||||||
|
isCard,
|
||||||
|
room,
|
||||||
|
timelineRenderingType,
|
||||||
|
lowBandwidth,
|
||||||
|
useOnlyCurrentProfiles,
|
||||||
|
avatarClassName: "mx_BaseAvatar",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setClient(cli);
|
||||||
|
vm.setRootEvent(mxEvent);
|
||||||
|
vm.setThread(thread);
|
||||||
|
vm.setNarrow(narrow);
|
||||||
|
vm.setIsCard(isCard);
|
||||||
|
vm.setRoom(room);
|
||||||
|
vm.setTimelineRenderingType(timelineRenderingType);
|
||||||
|
vm.setLowBandwidth(lowBandwidth);
|
||||||
|
vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles);
|
||||||
|
}, [vm, cli, mxEvent, thread, narrow, isCard, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles]);
|
||||||
|
|
||||||
|
return <ThreadSummaryView {...props} vm={vm} className={classNames("mx_ThreadSummary", className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
interface ReactionsRowButtonItemProps {
|
interface ReactionsRowButtonItemProps {
|
||||||
mxEvent: MatrixEvent;
|
mxEvent: MatrixEvent;
|
||||||
content: string;
|
content: string;
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useContext } from "react";
|
|
||||||
import { type Thread, ThreadEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
|
||||||
import { IndicatorIcon } from "@vector-im/compound-web";
|
|
||||||
import { ThreadsSolidIcon, ChevronRightIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
|
||||||
import { CardContext } from "../right_panel/context";
|
|
||||||
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
|
|
||||||
import PosthogTrackers from "../../../PosthogTrackers";
|
|
||||||
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
|
|
||||||
import MemberAvatar from "../avatars/MemberAvatar";
|
|
||||||
import { Action } from "../../../dispatcher/actions";
|
|
||||||
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
|
||||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
|
||||||
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
|
|
||||||
import { notificationLevelToIndicator } from "../../../utils/notifications";
|
|
||||||
import { EventPreviewTile, useEventPreview } from "./EventPreview.tsx";
|
|
||||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
mxEvent: MatrixEvent;
|
|
||||||
thread: Thread;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThreadSummary: React.FC<IProps> = ({ mxEvent, thread, ...props }) => {
|
|
||||||
const roomContext = useScopedRoomContext("narrow");
|
|
||||||
const cardContext = useContext(CardContext);
|
|
||||||
const count = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.length);
|
|
||||||
const { level } = useUnreadNotifications(thread.room, thread.id);
|
|
||||||
|
|
||||||
if (!count) return null; // We don't want to show a thread summary if the thread doesn't have replies yet
|
|
||||||
|
|
||||||
let countSection: string | number = count;
|
|
||||||
if (!roomContext.narrow) {
|
|
||||||
countSection = _t("threads|count_of_reply", { count });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AccessibleButton
|
|
||||||
{...props}
|
|
||||||
className="mx_ThreadSummary"
|
|
||||||
onClick={(ev: ButtonEvent) => {
|
|
||||||
defaultDispatcher.dispatch<ShowThreadPayload>({
|
|
||||||
action: Action.ShowThread,
|
|
||||||
rootEvent: mxEvent,
|
|
||||||
push: cardContext.isCard,
|
|
||||||
});
|
|
||||||
PosthogTrackers.trackInteraction("WebRoomTimelineThreadSummaryButton", ev);
|
|
||||||
}}
|
|
||||||
aria-label={_t("threads|open_thread")}
|
|
||||||
>
|
|
||||||
<IndicatorIcon size="24px" indicator={notificationLevelToIndicator(level)}>
|
|
||||||
<ThreadsSolidIcon />
|
|
||||||
</IndicatorIcon>
|
|
||||||
<span className="mx_ThreadSummary_replies_amount">{countSection}</span>
|
|
||||||
<ThreadMessagePreview thread={thread} showDisplayname={!roomContext.narrow} />
|
|
||||||
<div className="mx_ThreadSummary_chevron">
|
|
||||||
<ChevronRightIcon />
|
|
||||||
</div>
|
|
||||||
</AccessibleButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IPreviewProps {
|
|
||||||
thread: Thread;
|
|
||||||
showDisplayname?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ThreadMessagePreview: React.FC<IPreviewProps> = ({ thread, showDisplayname = false }) => {
|
|
||||||
const lastReply = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.replyToEvent) ?? undefined;
|
|
||||||
const preview = useEventPreview(lastReply);
|
|
||||||
|
|
||||||
if (!preview || !lastReply) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MemberAvatar
|
|
||||||
member={lastReply.sender}
|
|
||||||
fallbackUserId={lastReply.getSender()}
|
|
||||||
size="24px"
|
|
||||||
className="mx_ThreadSummary_avatar"
|
|
||||||
/>
|
|
||||||
{showDisplayname && (
|
|
||||||
<div className="mx_ThreadSummary_sender">{lastReply.sender?.name ?? lastReply.getSender()}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{lastReply.isDecryptionFailure() ? (
|
|
||||||
<div
|
|
||||||
className="mx_ThreadSummary_content mx_DecryptionFailureBody"
|
|
||||||
title={_t("timeline|decryption_failure|unable_to_decrypt")}
|
|
||||||
>
|
|
||||||
{_t("timeline|decryption_failure|unable_to_decrypt")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EventPreviewTile preview={preview} className="mx_ThreadSummary_content" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ThreadSummary;
|
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations Ltd.
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
* Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { type MouseEvent, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
M_POLL_START,
|
||||||
|
type MatrixClient,
|
||||||
|
type MatrixEvent,
|
||||||
|
MatrixEventEvent,
|
||||||
|
MsgType,
|
||||||
|
type NotificationCount,
|
||||||
|
RoomEvent,
|
||||||
|
type Room,
|
||||||
|
type RoomMember,
|
||||||
|
type RoomState,
|
||||||
|
RoomStateEvent,
|
||||||
|
type Thread,
|
||||||
|
ThreadEvent,
|
||||||
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import {
|
||||||
|
BaseViewModel,
|
||||||
|
type ThreadMessagePreviewAvatar,
|
||||||
|
type ThreadMessagePreviewViewModel as ThreadMessagePreviewViewModelInterface,
|
||||||
|
type ThreadMessagePreviewViewSnapshot,
|
||||||
|
type ThreadSummaryViewModel as ThreadSummaryViewModelInterface,
|
||||||
|
type ThreadSummaryViewSnapshot,
|
||||||
|
} from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import { _t } from "../../../../languageHandler";
|
||||||
|
import defaultDispatcher from "../../../../dispatcher/dispatcher";
|
||||||
|
import { Action } from "../../../../dispatcher/actions";
|
||||||
|
import { type ShowThreadPayload } from "../../../../dispatcher/payloads/ShowThreadPayload";
|
||||||
|
import PosthogTrackers from "../../../../PosthogTrackers";
|
||||||
|
import { determineUnreadState } from "../../../../RoomNotifs";
|
||||||
|
import { notificationLevelToIndicator } from "../../../../utils/notifications";
|
||||||
|
import { MessagePreviewStore } from "../../../../stores/message-preview";
|
||||||
|
import { mediaFromMxc } from "../../../../customisations/Media";
|
||||||
|
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
|
||||||
|
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
|
||||||
|
import { keepIfSame } from "../../../../utils/keepIfSame";
|
||||||
|
|
||||||
|
const AVATAR_SIZE_PX = 24;
|
||||||
|
const THREAD_PROFILE_CONTEXTS = new Set<TimelineRenderingType>([
|
||||||
|
TimelineRenderingType.Thread,
|
||||||
|
TimelineRenderingType.ThreadsList,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface ThreadMessagePreviewViewModelProps {
|
||||||
|
/**
|
||||||
|
* Matrix client used for event decryption and media URL resolution.
|
||||||
|
*/
|
||||||
|
cli: MatrixClient;
|
||||||
|
/**
|
||||||
|
* Thread whose latest reply should be previewed.
|
||||||
|
*/
|
||||||
|
thread: Thread;
|
||||||
|
/**
|
||||||
|
* Room context used to resolve current member profiles when configured.
|
||||||
|
*/
|
||||||
|
room?: Room;
|
||||||
|
/**
|
||||||
|
* Timeline context used to match legacy current-profile behaviour in thread timelines.
|
||||||
|
*/
|
||||||
|
timelineRenderingType: TimelineRenderingType;
|
||||||
|
/**
|
||||||
|
* Whether avatar images should be suppressed for low-bandwidth mode.
|
||||||
|
*/
|
||||||
|
lowBandwidth?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to prefer current member profiles over historical event sender profiles.
|
||||||
|
*/
|
||||||
|
useOnlyCurrentProfiles: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to render the sender display name.
|
||||||
|
*/
|
||||||
|
showDisplayName: boolean;
|
||||||
|
/**
|
||||||
|
* Optional class name for app-side avatar integration styling.
|
||||||
|
*/
|
||||||
|
avatarClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ThreadMessagePreviewViewModel
|
||||||
|
extends BaseViewModel<ThreadMessagePreviewViewSnapshot, ThreadMessagePreviewViewModelProps>
|
||||||
|
implements ThreadMessagePreviewViewModelInterface
|
||||||
|
{
|
||||||
|
private threadListenerCleanups: Array<() => void> = [];
|
||||||
|
private eventListenerCleanups: Array<() => void> = [];
|
||||||
|
private memberListenerCleanups: Array<() => void> = [];
|
||||||
|
private watchedEvent?: MatrixEvent;
|
||||||
|
private watchedMemberRoom?: Room;
|
||||||
|
private watchedMemberUserId?: string;
|
||||||
|
private previewRequestId = 0;
|
||||||
|
private previewContentKey?: string;
|
||||||
|
private previewContent?: ReactNode;
|
||||||
|
|
||||||
|
public constructor(props: ThreadMessagePreviewViewModelProps) {
|
||||||
|
super(props, {
|
||||||
|
isVisible: false,
|
||||||
|
showDisplayName: props.showDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setupThreadListener();
|
||||||
|
this.updateFromThreadSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
this.teardownThreadListener();
|
||||||
|
this.teardownEventListeners();
|
||||||
|
this.teardownMemberListener();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setClient(cli: MatrixClient): void {
|
||||||
|
if (this.props.cli === cli) return;
|
||||||
|
this.props = { ...this.props, cli };
|
||||||
|
this.updateFromThreadSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setThread(thread: Thread): void {
|
||||||
|
if (this.props.thread === thread) return;
|
||||||
|
this.props = { ...this.props, thread };
|
||||||
|
this.setupThreadListener();
|
||||||
|
this.updateFromThreadSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setRoom(room?: Room): void {
|
||||||
|
if (this.props.room === room) return;
|
||||||
|
this.props = { ...this.props, room };
|
||||||
|
this.updateFromThreadSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setTimelineRenderingType(timelineRenderingType: TimelineRenderingType): void {
|
||||||
|
if (this.props.timelineRenderingType === timelineRenderingType) return;
|
||||||
|
this.props = { ...this.props, timelineRenderingType };
|
||||||
|
this.updateFromThreadSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setLowBandwidth(lowBandwidth?: boolean): void {
|
||||||
|
if (this.props.lowBandwidth === lowBandwidth) return;
|
||||||
|
this.props = { ...this.props, lowBandwidth };
|
||||||
|
this.updateFromThreadSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setUseOnlyCurrentProfiles(useOnlyCurrentProfiles: boolean): void {
|
||||||
|
if (this.props.useOnlyCurrentProfiles === useOnlyCurrentProfiles) return;
|
||||||
|
this.props = { ...this.props, useOnlyCurrentProfiles };
|
||||||
|
this.updateFromThreadSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setShowDisplayName(showDisplayName: boolean): void {
|
||||||
|
this.props = { ...this.props, showDisplayName };
|
||||||
|
this.snapshot.merge({ showDisplayName });
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupThreadListener(): void {
|
||||||
|
this.teardownThreadListener();
|
||||||
|
const { thread } = this.props;
|
||||||
|
thread.on(ThreadEvent.Update, this.onThreadUpdate);
|
||||||
|
this.threadListenerCleanups.push(() => {
|
||||||
|
thread.off(ThreadEvent.Update, this.onThreadUpdate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardownThreadListener(): void {
|
||||||
|
for (const cleanup of this.threadListenerCleanups) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
this.threadListenerCleanups = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners(mxEvent?: MatrixEvent): void {
|
||||||
|
if (this.watchedEvent === mxEvent) return;
|
||||||
|
|
||||||
|
this.teardownEventListeners();
|
||||||
|
this.watchedEvent = mxEvent;
|
||||||
|
|
||||||
|
if (!mxEvent) return;
|
||||||
|
|
||||||
|
mxEvent.on(MatrixEventEvent.Replaced, this.onEventContentChanged);
|
||||||
|
mxEvent.on(MatrixEventEvent.Decrypted, this.onEventContentChanged);
|
||||||
|
this.eventListenerCleanups.push(() => {
|
||||||
|
mxEvent.off(MatrixEventEvent.Replaced, this.onEventContentChanged);
|
||||||
|
mxEvent.off(MatrixEventEvent.Decrypted, this.onEventContentChanged);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardownEventListeners(): void {
|
||||||
|
for (const cleanup of this.eventListenerCleanups) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
this.eventListenerCleanups = [];
|
||||||
|
this.watchedEvent = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupMemberListener(mxEvent?: MatrixEvent): void {
|
||||||
|
const userId = ThreadMessagePreviewViewModel.getProfileUserId(mxEvent);
|
||||||
|
const room = this.getProfileRoom();
|
||||||
|
const shouldUseCurrentProfiles = this.shouldUseCurrentProfiles();
|
||||||
|
|
||||||
|
if (!mxEvent || !userId || !room || !shouldUseCurrentProfiles) {
|
||||||
|
this.teardownMemberListener();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.watchedMemberRoom === room && this.watchedMemberUserId === userId) return;
|
||||||
|
|
||||||
|
this.teardownMemberListener();
|
||||||
|
this.watchedMemberRoom = room;
|
||||||
|
this.watchedMemberUserId = userId;
|
||||||
|
|
||||||
|
room.on(RoomStateEvent.Members, this.onRoomStateMember);
|
||||||
|
this.memberListenerCleanups.push(() => {
|
||||||
|
room.off(RoomStateEvent.Members, this.onRoomStateMember);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardownMemberListener(): void {
|
||||||
|
for (const cleanup of this.memberListenerCleanups) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
this.memberListenerCleanups = [];
|
||||||
|
this.watchedMemberRoom = undefined;
|
||||||
|
this.watchedMemberUserId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onThreadUpdate = (): void => {
|
||||||
|
this.updateFromThreadSafely();
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onEventContentChanged = (): void => {
|
||||||
|
this.updateFromThreadSafely();
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onRoomStateMember = (_event: MatrixEvent, _state: RoomState, member: RoomMember): void => {
|
||||||
|
if (member.userId !== this.watchedMemberUserId) return;
|
||||||
|
this.updateProfileSnapshot();
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateFromThreadSafely(): void {
|
||||||
|
void this.updateFromThread().catch((error) => {
|
||||||
|
logger.error("Failed to update thread preview", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateFromThread(): Promise<void> {
|
||||||
|
const requestId = ++this.previewRequestId;
|
||||||
|
const lastReply = this.props.thread.replyToEvent ?? undefined;
|
||||||
|
this.setupEventListeners(lastReply);
|
||||||
|
this.setupMemberListener(lastReply);
|
||||||
|
|
||||||
|
if (!lastReply) {
|
||||||
|
this.setHidden();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateFromEvent(lastReply, requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateFromEvent(mxEvent: MatrixEvent, requestId: number): Promise<void> {
|
||||||
|
if (mxEvent.isRedacted()) {
|
||||||
|
this.setHidden();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseSnapshot = this.computeBaseReplySnapshot(mxEvent);
|
||||||
|
|
||||||
|
if (mxEvent.isDecryptionFailure()) {
|
||||||
|
this.setDecryptionFailure(baseSnapshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.props.cli.decryptEventIfNeeded(mxEvent);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to decrypt thread preview event", error);
|
||||||
|
if (!this.isCurrentPreviewRequest(requestId, mxEvent)) return;
|
||||||
|
|
||||||
|
if (mxEvent.isDecryptionFailure()) {
|
||||||
|
this.setDecryptionFailure(baseSnapshot);
|
||||||
|
} else {
|
||||||
|
this.setHidden();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isCurrentPreviewRequest(requestId, mxEvent)) return;
|
||||||
|
|
||||||
|
if (mxEvent.isRedacted()) {
|
||||||
|
this.setHidden();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mxEvent.isDecryptionFailure()) {
|
||||||
|
this.setDecryptionFailure(baseSnapshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = MessagePreviewStore.instance.generatePreviewForEvent(mxEvent);
|
||||||
|
if (!preview) {
|
||||||
|
this.setHidden();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = ThreadMessagePreviewViewModel.getPreviewPrefix(
|
||||||
|
mxEvent.getType(),
|
||||||
|
mxEvent.getContent().msgtype as MsgType | undefined,
|
||||||
|
);
|
||||||
|
const previewContent = this.getPreviewContent(preview, prefix);
|
||||||
|
|
||||||
|
this.snapshot.merge({
|
||||||
|
...baseSnapshot,
|
||||||
|
previewContent,
|
||||||
|
previewTooltip: prefix ? undefined : preview,
|
||||||
|
isVisible: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCurrentPreviewRequest(requestId: number, mxEvent: MatrixEvent): boolean {
|
||||||
|
return !this.isDisposed && requestId === this.previewRequestId && this.props.thread.replyToEvent === mxEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeBaseReplySnapshot(
|
||||||
|
mxEvent: MatrixEvent,
|
||||||
|
): Pick<ThreadMessagePreviewViewSnapshot, "avatar" | "senderName" | "showDisplayName"> {
|
||||||
|
const member = ThreadMessagePreviewViewModel.getDisplayMember(this.props, mxEvent);
|
||||||
|
const sender = mxEvent.getSender() ?? "";
|
||||||
|
const senderName = member?.name ?? sender;
|
||||||
|
const avatar = ThreadMessagePreviewViewModel.computeAvatar(this.props, mxEvent, member);
|
||||||
|
|
||||||
|
return {
|
||||||
|
avatar: keepIfSame(this.snapshot.current.avatar, avatar),
|
||||||
|
senderName,
|
||||||
|
showDisplayName: this.props.showDisplayName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateProfileSnapshot(): void {
|
||||||
|
const lastReply = this.props.thread.replyToEvent;
|
||||||
|
if (!lastReply || !this.snapshot.current.isVisible) return;
|
||||||
|
|
||||||
|
this.snapshot.merge(this.computeBaseReplySnapshot(lastReply));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setDecryptionFailure(
|
||||||
|
baseSnapshot: Pick<ThreadMessagePreviewViewSnapshot, "avatar" | "senderName" | "showDisplayName">,
|
||||||
|
): void {
|
||||||
|
const label = _t("timeline|decryption_failure|unable_to_decrypt");
|
||||||
|
this.snapshot.merge({
|
||||||
|
...baseSnapshot,
|
||||||
|
previewContent: label,
|
||||||
|
previewTooltip: label,
|
||||||
|
isVisible: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setHidden(): void {
|
||||||
|
this.snapshot.merge({
|
||||||
|
isVisible: false,
|
||||||
|
avatar: undefined,
|
||||||
|
senderName: undefined,
|
||||||
|
previewContent: undefined,
|
||||||
|
previewTooltip: undefined,
|
||||||
|
showDisplayName: this.props.showDisplayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPreviewContent(preview: string, prefix: string | null): ReactNode {
|
||||||
|
const key = `${prefix ?? ""}\u0000${preview}`;
|
||||||
|
if (this.previewContentKey === key) {
|
||||||
|
return this.previewContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.previewContentKey = key;
|
||||||
|
this.previewContent = prefix
|
||||||
|
? _t(
|
||||||
|
"event_preview|preview",
|
||||||
|
{
|
||||||
|
prefix,
|
||||||
|
preview,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bold: (sub) => <strong>{sub}</strong>,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: preview;
|
||||||
|
|
||||||
|
return this.previewContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getPreviewPrefix(type: string, msgType?: MsgType): string | null {
|
||||||
|
switch (type) {
|
||||||
|
case M_POLL_START.name:
|
||||||
|
return _t("event_preview|prefix|poll");
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (msgType) {
|
||||||
|
case MsgType.Audio:
|
||||||
|
return _t("event_preview|prefix|audio");
|
||||||
|
case MsgType.Image:
|
||||||
|
return _t("event_preview|prefix|image");
|
||||||
|
case MsgType.Video:
|
||||||
|
return _t("event_preview|prefix|video");
|
||||||
|
case MsgType.File:
|
||||||
|
return _t("event_preview|prefix|file");
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getDisplayMember(
|
||||||
|
props: ThreadMessagePreviewViewModelProps,
|
||||||
|
mxEvent: MatrixEvent,
|
||||||
|
): RoomMember | null | undefined {
|
||||||
|
const userId = ThreadMessagePreviewViewModel.getProfileUserId(mxEvent);
|
||||||
|
if (userId && ThreadMessagePreviewViewModel.shouldUseCurrentProfilesForProps(props)) {
|
||||||
|
const currentMember = ThreadMessagePreviewViewModel.getProfileRoomForProps(props)?.getMember(userId);
|
||||||
|
if (currentMember) return currentMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mxEvent.sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getProfileRoom(): Room | undefined {
|
||||||
|
return ThreadMessagePreviewViewModel.getProfileRoomForProps(this.props);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldUseCurrentProfiles(): boolean {
|
||||||
|
return ThreadMessagePreviewViewModel.shouldUseCurrentProfilesForProps(this.props);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getProfileRoomForProps(props: ThreadMessagePreviewViewModelProps): Room | undefined {
|
||||||
|
return props.room ?? props.thread.room;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static shouldUseCurrentProfilesForProps(props: ThreadMessagePreviewViewModelProps): boolean {
|
||||||
|
return props.useOnlyCurrentProfiles || THREAD_PROFILE_CONTEXTS.has(props.timelineRenderingType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getProfileUserId(mxEvent?: MatrixEvent): string {
|
||||||
|
return mxEvent?.sender?.userId ?? mxEvent?.getSender() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static computeAvatar(
|
||||||
|
props: ThreadMessagePreviewViewModelProps,
|
||||||
|
mxEvent: MatrixEvent,
|
||||||
|
member: RoomMember | null | undefined,
|
||||||
|
): ThreadMessagePreviewAvatar {
|
||||||
|
const fallbackUserId = mxEvent.getSender() ?? "";
|
||||||
|
const name = member?.name ?? fallbackUserId;
|
||||||
|
let src: string | undefined;
|
||||||
|
let title: string | undefined;
|
||||||
|
|
||||||
|
if (member?.name) {
|
||||||
|
const mxcAvatarUrl = member.getMxcAvatarUrl();
|
||||||
|
if (mxcAvatarUrl && !props.lowBandwidth) {
|
||||||
|
src =
|
||||||
|
mediaFromMxc(mxcAvatarUrl, props.cli).getThumbnailOfSourceHttp(
|
||||||
|
AVATAR_SIZE_PX,
|
||||||
|
AVATAR_SIZE_PX,
|
||||||
|
"crop",
|
||||||
|
) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
title =
|
||||||
|
UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, {
|
||||||
|
roomId: member.roomId,
|
||||||
|
}) ?? fallbackUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
className: props.avatarClassName,
|
||||||
|
id: member?.userId ?? fallbackUserId,
|
||||||
|
name,
|
||||||
|
src,
|
||||||
|
title,
|
||||||
|
label: _t("common|user_avatar"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadSummaryViewModelProps extends Omit<ThreadMessagePreviewViewModelProps, "showDisplayName"> {
|
||||||
|
/**
|
||||||
|
* Thread root event opened by the summary.
|
||||||
|
*/
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
/**
|
||||||
|
* Whether the timeline is in narrow mode.
|
||||||
|
*/
|
||||||
|
narrow: boolean;
|
||||||
|
/**
|
||||||
|
* Whether opening the thread should push a card.
|
||||||
|
*/
|
||||||
|
isCard: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ThreadSummaryViewModel
|
||||||
|
extends BaseViewModel<ThreadSummaryViewSnapshot, ThreadSummaryViewModelProps>
|
||||||
|
implements ThreadSummaryViewModelInterface
|
||||||
|
{
|
||||||
|
private listenerCleanups: Array<() => void> = [];
|
||||||
|
private readonly previewVm: ThreadMessagePreviewViewModel;
|
||||||
|
|
||||||
|
public constructor(props: ThreadSummaryViewModelProps) {
|
||||||
|
const previewVm = new ThreadMessagePreviewViewModel({
|
||||||
|
...props,
|
||||||
|
showDisplayName: !props.narrow,
|
||||||
|
});
|
||||||
|
|
||||||
|
super(props, ThreadSummaryViewModel.computeSnapshot(props, previewVm));
|
||||||
|
this.previewVm = previewVm;
|
||||||
|
this.disposables.track(previewVm);
|
||||||
|
this.setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
this.teardownListeners();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setRootEvent(mxEvent: MatrixEvent): void {
|
||||||
|
this.props = { ...this.props, mxEvent };
|
||||||
|
}
|
||||||
|
|
||||||
|
public setClient(cli: MatrixClient): void {
|
||||||
|
if (this.props.cli === cli) return;
|
||||||
|
this.props = { ...this.props, cli };
|
||||||
|
this.previewVm.setClient(cli);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setThread(thread: Thread): void {
|
||||||
|
if (this.props.thread === thread) return;
|
||||||
|
this.props = { ...this.props, thread };
|
||||||
|
this.previewVm.setThread(thread);
|
||||||
|
this.setupListeners();
|
||||||
|
this.updateSnapshotFromProps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setRoom(room?: Room): void {
|
||||||
|
if (this.props.room === room) return;
|
||||||
|
this.props = { ...this.props, room };
|
||||||
|
this.previewVm.setRoom(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setTimelineRenderingType(timelineRenderingType: TimelineRenderingType): void {
|
||||||
|
if (this.props.timelineRenderingType === timelineRenderingType) return;
|
||||||
|
this.props = { ...this.props, timelineRenderingType };
|
||||||
|
this.previewVm.setTimelineRenderingType(timelineRenderingType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setLowBandwidth(lowBandwidth?: boolean): void {
|
||||||
|
if (this.props.lowBandwidth === lowBandwidth) return;
|
||||||
|
this.props = { ...this.props, lowBandwidth };
|
||||||
|
this.previewVm.setLowBandwidth(lowBandwidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setUseOnlyCurrentProfiles(useOnlyCurrentProfiles: boolean): void {
|
||||||
|
if (this.props.useOnlyCurrentProfiles === useOnlyCurrentProfiles) return;
|
||||||
|
this.props = { ...this.props, useOnlyCurrentProfiles };
|
||||||
|
this.previewVm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setNarrow(narrow: boolean): void {
|
||||||
|
this.props = { ...this.props, narrow };
|
||||||
|
this.previewVm.setShowDisplayName(!narrow);
|
||||||
|
this.snapshot.merge(ThreadSummaryViewModel.computeLayoutSnapshot(this.props));
|
||||||
|
}
|
||||||
|
|
||||||
|
public setIsCard(isCard: boolean): void {
|
||||||
|
this.props = { ...this.props, isCard };
|
||||||
|
}
|
||||||
|
|
||||||
|
public onClick = (event: MouseEvent<HTMLButtonElement>): void => {
|
||||||
|
defaultDispatcher.dispatch<ShowThreadPayload>({
|
||||||
|
action: Action.ShowThread,
|
||||||
|
rootEvent: this.props.mxEvent,
|
||||||
|
push: this.props.isCard,
|
||||||
|
});
|
||||||
|
PosthogTrackers.trackInteraction("WebRoomTimelineThreadSummaryButton", event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private setupListeners(): void {
|
||||||
|
this.teardownListeners();
|
||||||
|
|
||||||
|
const { thread } = this.props;
|
||||||
|
const { room } = thread;
|
||||||
|
thread.on(ThreadEvent.Update, this.onThreadUpdate);
|
||||||
|
this.listenerCleanups.push(() => {
|
||||||
|
thread.off(ThreadEvent.Update, this.onThreadUpdate);
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.UnreadNotifications, this.onRoomUnreadNotifications);
|
||||||
|
room.on(RoomEvent.Receipt, this.onNotificationChanged);
|
||||||
|
room.on(RoomEvent.Timeline, this.onNotificationChanged);
|
||||||
|
room.on(RoomEvent.Redaction, this.onNotificationChanged);
|
||||||
|
room.on(RoomEvent.LocalEchoUpdated, this.onNotificationChanged);
|
||||||
|
room.on(RoomEvent.MyMembership, this.onNotificationChanged);
|
||||||
|
this.listenerCleanups.push(() => {
|
||||||
|
room.off(RoomEvent.UnreadNotifications, this.onRoomUnreadNotifications);
|
||||||
|
room.off(RoomEvent.Receipt, this.onNotificationChanged);
|
||||||
|
room.off(RoomEvent.Timeline, this.onNotificationChanged);
|
||||||
|
room.off(RoomEvent.Redaction, this.onNotificationChanged);
|
||||||
|
room.off(RoomEvent.LocalEchoUpdated, this.onNotificationChanged);
|
||||||
|
room.off(RoomEvent.MyMembership, this.onNotificationChanged);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardownListeners(): void {
|
||||||
|
for (const cleanup of this.listenerCleanups) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
this.listenerCleanups = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onThreadUpdate = (): void => {
|
||||||
|
this.updateSnapshotFromProps();
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onRoomUnreadNotifications = (
|
||||||
|
_unreadNotifications?: NotificationCount,
|
||||||
|
eventThreadId?: string,
|
||||||
|
): void => {
|
||||||
|
if (eventThreadId && eventThreadId !== this.props.thread.id) return;
|
||||||
|
this.updateNotificationSnapshot();
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onNotificationChanged = (): void => {
|
||||||
|
this.updateNotificationSnapshot();
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateSnapshotFromProps(): void {
|
||||||
|
this.snapshot.merge({
|
||||||
|
...ThreadSummaryViewModel.computeLayoutSnapshot(this.props),
|
||||||
|
notificationIndicator: ThreadSummaryViewModel.computeNotificationIndicator(this.props),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateNotificationSnapshot(): void {
|
||||||
|
this.snapshot.merge({
|
||||||
|
notificationIndicator: ThreadSummaryViewModel.computeNotificationIndicator(this.props),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static computeSnapshot(
|
||||||
|
props: ThreadSummaryViewModelProps,
|
||||||
|
previewVm: ThreadMessagePreviewViewModel,
|
||||||
|
): ThreadSummaryViewSnapshot {
|
||||||
|
return {
|
||||||
|
openThreadLabel: _t("threads|open_thread"),
|
||||||
|
previewVm,
|
||||||
|
notificationIndicator: ThreadSummaryViewModel.computeNotificationIndicator(props),
|
||||||
|
...ThreadSummaryViewModel.computeLayoutSnapshot(props),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static computeLayoutSnapshot(
|
||||||
|
props: ThreadSummaryViewModelProps,
|
||||||
|
): Pick<ThreadSummaryViewSnapshot, "isVisible" | "replyCountLabel" | "narrow"> {
|
||||||
|
const count = props.thread.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isVisible: count > 0,
|
||||||
|
replyCountLabel: props.narrow ? String(count) : _t("threads|count_of_reply", { count }),
|
||||||
|
narrow: props.narrow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static computeNotificationIndicator(
|
||||||
|
props: ThreadSummaryViewModelProps,
|
||||||
|
): ThreadSummaryViewSnapshot["notificationIndicator"] {
|
||||||
|
const { level } = determineUnreadState(props.thread.room, props.thread.id, false);
|
||||||
|
return notificationLevelToIndicator(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,888 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import { type MouseEvent } from "react";
|
||||||
|
import {
|
||||||
|
EventType,
|
||||||
|
M_POLL_START,
|
||||||
|
type MatrixClient,
|
||||||
|
type MatrixEvent,
|
||||||
|
MatrixEventEvent,
|
||||||
|
MsgType,
|
||||||
|
type Room,
|
||||||
|
RoomEvent,
|
||||||
|
type RoomMember,
|
||||||
|
RoomStateEvent,
|
||||||
|
type Thread,
|
||||||
|
ThreadEvent,
|
||||||
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { waitFor } from "jest-matrix-react";
|
||||||
|
|
||||||
|
import { mkEvent } from "../../test-utils";
|
||||||
|
import { NotificationLevel } from "../../../src/stores/notifications/NotificationLevel";
|
||||||
|
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
|
||||||
|
import { MessagePreviewStore } from "../../../src/stores/message-preview";
|
||||||
|
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
||||||
|
import PosthogTrackers from "../../../src/PosthogTrackers";
|
||||||
|
import { determineUnreadState } from "../../../src/RoomNotifs";
|
||||||
|
import {
|
||||||
|
ThreadMessagePreviewViewModel,
|
||||||
|
type ThreadMessagePreviewViewModelProps,
|
||||||
|
ThreadSummaryViewModel,
|
||||||
|
type ThreadSummaryViewModelProps,
|
||||||
|
} from "../../../src/viewmodels/room/timeline/event-tile/ThreadSummaryViewModel";
|
||||||
|
|
||||||
|
jest.mock("../../../src/dispatcher/dispatcher", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
dispatch: jest.fn(),
|
||||||
|
register: jest.fn(),
|
||||||
|
unregister: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../src/PosthogTrackers", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
trackInteraction: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../src/RoomNotifs", () => ({
|
||||||
|
determineUnreadState: jest.fn(() => ({
|
||||||
|
symbol: null,
|
||||||
|
count: 0,
|
||||||
|
level: 0,
|
||||||
|
invited: false,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const roomId = "!room:example.org";
|
||||||
|
const userId = "@alice:example.org";
|
||||||
|
const clientMxcUrlToHttpMocks = new WeakMap<MatrixClient, jest.Mock>();
|
||||||
|
|
||||||
|
class TestRoom extends EventEmitter {
|
||||||
|
public roomId = roomId;
|
||||||
|
public getMember = jest.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestThread extends EventEmitter {
|
||||||
|
public id = "$root";
|
||||||
|
public length = 2;
|
||||||
|
public replyToEvent?: MatrixEvent;
|
||||||
|
|
||||||
|
public constructor(public room: Room) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeClient(decryptEventIfNeeded = jest.fn().mockResolvedValue(undefined)): MatrixClient {
|
||||||
|
const mxcUrlToHttp = jest.fn(
|
||||||
|
(mxc: string, width?: number, height?: number, resizeMethod?: string) =>
|
||||||
|
`https://matrix.example.org/_matrix/media/${mxc}/${width}x${height}/${resizeMethod}`,
|
||||||
|
);
|
||||||
|
const cli = {
|
||||||
|
decryptEventIfNeeded,
|
||||||
|
mxcUrlToHttp,
|
||||||
|
} as unknown as MatrixClient;
|
||||||
|
clientMxcUrlToHttpMocks.set(cli, mxcUrlToHttp);
|
||||||
|
return cli;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMxcUrlToHttpMock(cli: MatrixClient): jest.Mock {
|
||||||
|
const mock = clientMxcUrlToHttpMocks.get(cli);
|
||||||
|
if (!mock) throw new Error("Missing mxcUrlToHttp mock for test client");
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMember(name: string, senderUserId = userId, mxcAvatarUrl?: string): RoomMember {
|
||||||
|
return {
|
||||||
|
userId: senderUserId,
|
||||||
|
membership: "join",
|
||||||
|
name,
|
||||||
|
rawDisplayName: name,
|
||||||
|
roomId,
|
||||||
|
getAvatarUrl: jest.fn(),
|
||||||
|
getMxcAvatarUrl: jest.fn(() => mxcAvatarUrl),
|
||||||
|
} as unknown as RoomMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEvent(
|
||||||
|
body = "Latest reply",
|
||||||
|
{
|
||||||
|
type = EventType.RoomMessage,
|
||||||
|
msgtype = MsgType.Text,
|
||||||
|
senderName = "Alice",
|
||||||
|
senderUserId = userId,
|
||||||
|
mxcAvatarUrl,
|
||||||
|
}: Partial<{
|
||||||
|
type: string;
|
||||||
|
msgtype: MsgType;
|
||||||
|
senderName: string;
|
||||||
|
senderUserId: string;
|
||||||
|
mxcAvatarUrl: string;
|
||||||
|
}> = {},
|
||||||
|
): MatrixEvent {
|
||||||
|
const mxEvent = mkEvent({
|
||||||
|
event: true,
|
||||||
|
id: `$${body}`,
|
||||||
|
type,
|
||||||
|
room: roomId,
|
||||||
|
user: senderUserId,
|
||||||
|
content: {
|
||||||
|
msgtype,
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mxEvent.sender = makeMember(senderName, senderUserId, mxcAvatarUrl);
|
||||||
|
return mxEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePreviewVm(overrides: Partial<ThreadMessagePreviewViewModelProps> = {}): {
|
||||||
|
vm: ThreadMessagePreviewViewModel;
|
||||||
|
thread: TestThread & Thread;
|
||||||
|
room: TestRoom & Room;
|
||||||
|
cli: MatrixClient;
|
||||||
|
} {
|
||||||
|
const room = (overrides.room as (TestRoom & Room) | undefined) ?? (new TestRoom() as TestRoom & Room);
|
||||||
|
const thread =
|
||||||
|
(overrides.thread as (TestThread & Thread) | undefined) ?? (new TestThread(room) as TestThread & Thread);
|
||||||
|
if (!thread.replyToEvent) {
|
||||||
|
thread.replyToEvent = makeEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cli = overrides.cli ?? makeClient();
|
||||||
|
const vm = new ThreadMessagePreviewViewModel({
|
||||||
|
cli,
|
||||||
|
thread,
|
||||||
|
room,
|
||||||
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
lowBandwidth: false,
|
||||||
|
useOnlyCurrentProfiles: false,
|
||||||
|
showDisplayName: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { vm, thread, room, cli };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSummaryVm(overrides: Partial<ThreadSummaryViewModelProps> = {}): {
|
||||||
|
vm: ThreadSummaryViewModel;
|
||||||
|
thread: TestThread & Thread;
|
||||||
|
rootEvent: MatrixEvent;
|
||||||
|
room: TestRoom & Room;
|
||||||
|
cli: MatrixClient;
|
||||||
|
} {
|
||||||
|
const room = new TestRoom() as TestRoom & Room;
|
||||||
|
const thread = new TestThread(room) as TestThread & Thread;
|
||||||
|
thread.replyToEvent = makeEvent();
|
||||||
|
const rootEvent = makeEvent("Root");
|
||||||
|
const cli = overrides.cli ?? makeClient();
|
||||||
|
const vm = new ThreadSummaryViewModel({
|
||||||
|
cli,
|
||||||
|
mxEvent: rootEvent,
|
||||||
|
thread,
|
||||||
|
room,
|
||||||
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
lowBandwidth: false,
|
||||||
|
useOnlyCurrentProfiles: false,
|
||||||
|
narrow: true,
|
||||||
|
isCard: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { vm, thread, rootEvent, room, cli };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ThreadSummaryViewModel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.mocked(determineUnreadState).mockReturnValue({
|
||||||
|
symbol: null,
|
||||||
|
count: 0,
|
||||||
|
level: NotificationLevel.None,
|
||||||
|
invited: false,
|
||||||
|
});
|
||||||
|
jest.spyOn(MessagePreviewStore.instance, "generatePreviewForEvent").mockReturnValue("Latest reply");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes initial summary state from the thread", () => {
|
||||||
|
const { vm } = makeSummaryVm();
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().isVisible).toBe(true);
|
||||||
|
expect(vm.getSnapshot().replyCountLabel).toBe("2");
|
||||||
|
expect(vm.getSnapshot().narrow).toBe(true);
|
||||||
|
expect(vm.getSnapshot().openThreadLabel).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates visibility and reply count when the thread mutates", () => {
|
||||||
|
const { vm, thread } = makeSummaryVm();
|
||||||
|
|
||||||
|
thread.length = 0;
|
||||||
|
thread.emit(ThreadEvent.Update, thread);
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().isVisible).toBe(false);
|
||||||
|
expect(vm.getSnapshot().replyCountLabel).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses plural reply labels outside narrow mode and synchronizes the nested preview display name", () => {
|
||||||
|
const { vm } = makeSummaryVm({ narrow: false });
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().replyCountLabel).toBe("2 replies");
|
||||||
|
expect(vm.getSnapshot().previewVm.getSnapshot().showDisplayName).toBe(true);
|
||||||
|
|
||||||
|
vm.setNarrow(true);
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().replyCountLabel).toBe("2");
|
||||||
|
expect(vm.getSnapshot().narrow).toBe(true);
|
||||||
|
expect(vm.getSnapshot().previewVm.getSnapshot().showDisplayName).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not emit when setters receive unchanged values", () => {
|
||||||
|
const { vm, thread, rootEvent, room, cli } = makeSummaryVm();
|
||||||
|
const previewVm = vm.getSnapshot().previewVm as ThreadMessagePreviewViewModel;
|
||||||
|
const setClientSpy = jest.spyOn(previewVm, "setClient");
|
||||||
|
const setRoomSpy = jest.spyOn(previewVm, "setRoom");
|
||||||
|
const setTimelineRenderingTypeSpy = jest.spyOn(previewVm, "setTimelineRenderingType");
|
||||||
|
const setLowBandwidthSpy = jest.spyOn(previewVm, "setLowBandwidth");
|
||||||
|
const setUseOnlyCurrentProfilesSpy = jest.spyOn(previewVm, "setUseOnlyCurrentProfiles");
|
||||||
|
const listener = jest.fn();
|
||||||
|
vm.subscribe(listener);
|
||||||
|
|
||||||
|
vm.setRootEvent(rootEvent);
|
||||||
|
vm.setClient(cli);
|
||||||
|
vm.setThread(thread);
|
||||||
|
vm.setRoom(room);
|
||||||
|
vm.setTimelineRenderingType(TimelineRenderingType.Room);
|
||||||
|
vm.setLowBandwidth(false);
|
||||||
|
vm.setUseOnlyCurrentProfiles(false);
|
||||||
|
vm.setNarrow(true);
|
||||||
|
vm.setIsCard(false);
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(setClientSpy).not.toHaveBeenCalled();
|
||||||
|
expect(setRoomSpy).not.toHaveBeenCalled();
|
||||||
|
expect(setTimelineRenderingTypeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(setLowBandwidthSpy).not.toHaveBeenCalled();
|
||||||
|
expect(setUseOnlyCurrentProfilesSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards changed context inputs to the preview view model", () => {
|
||||||
|
const { vm } = makeSummaryVm();
|
||||||
|
const previewVm = vm.getSnapshot().previewVm as ThreadMessagePreviewViewModel;
|
||||||
|
const nextClient = makeClient();
|
||||||
|
const nextRoom = new TestRoom() as TestRoom & Room;
|
||||||
|
|
||||||
|
const setClientSpy = jest.spyOn(previewVm, "setClient");
|
||||||
|
const setRoomSpy = jest.spyOn(previewVm, "setRoom");
|
||||||
|
const setTimelineRenderingTypeSpy = jest.spyOn(previewVm, "setTimelineRenderingType");
|
||||||
|
const setLowBandwidthSpy = jest.spyOn(previewVm, "setLowBandwidth");
|
||||||
|
const setUseOnlyCurrentProfilesSpy = jest.spyOn(previewVm, "setUseOnlyCurrentProfiles");
|
||||||
|
|
||||||
|
vm.setClient(nextClient);
|
||||||
|
vm.setRoom(nextRoom);
|
||||||
|
vm.setTimelineRenderingType(TimelineRenderingType.Thread);
|
||||||
|
vm.setLowBandwidth(true);
|
||||||
|
vm.setUseOnlyCurrentProfiles(true);
|
||||||
|
|
||||||
|
expect(setClientSpy).toHaveBeenCalledWith(nextClient);
|
||||||
|
expect(setRoomSpy).toHaveBeenCalledWith(nextRoom);
|
||||||
|
expect(setTimelineRenderingTypeSpy).toHaveBeenCalledWith(TimelineRenderingType.Thread);
|
||||||
|
expect(setLowBandwidthSpy).toHaveBeenCalledWith(true);
|
||||||
|
expect(setUseOnlyCurrentProfilesSpy).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes notification indicator for thread unread events", () => {
|
||||||
|
const { vm, room, thread } = makeSummaryVm();
|
||||||
|
jest.mocked(determineUnreadState).mockReturnValue({
|
||||||
|
symbol: null,
|
||||||
|
count: 1,
|
||||||
|
level: NotificationLevel.Highlight,
|
||||||
|
invited: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
room.emit(RoomEvent.UnreadNotifications, {}, thread.id);
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().notificationIndicator).toBe("critical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unread notifications for other threads and refreshes on room notification events", () => {
|
||||||
|
const { vm, room } = makeSummaryVm();
|
||||||
|
jest.mocked(determineUnreadState).mockClear();
|
||||||
|
jest.mocked(determineUnreadState).mockReturnValue({
|
||||||
|
symbol: null,
|
||||||
|
count: 1,
|
||||||
|
level: NotificationLevel.Notification,
|
||||||
|
invited: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
room.emit(RoomEvent.UnreadNotifications, {}, "$other-thread");
|
||||||
|
|
||||||
|
expect(determineUnreadState).not.toHaveBeenCalled();
|
||||||
|
expect(vm.getSnapshot().notificationIndicator).toBeUndefined();
|
||||||
|
|
||||||
|
room.emit(RoomEvent.Receipt);
|
||||||
|
|
||||||
|
expect(determineUnreadState).toHaveBeenCalledWith(room, "$root", false);
|
||||||
|
expect(vm.getSnapshot().notificationIndicator).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebinds thread listeners when the thread changes and removes them on dispose", () => {
|
||||||
|
const { vm, thread, room } = makeSummaryVm();
|
||||||
|
const nextRoom = new TestRoom() as TestRoom & Room;
|
||||||
|
const nextThread = new TestThread(nextRoom) as TestThread & Thread;
|
||||||
|
nextThread.length = 4;
|
||||||
|
nextThread.replyToEvent = makeEvent("Next thread reply");
|
||||||
|
|
||||||
|
vm.setThread(nextThread);
|
||||||
|
|
||||||
|
expect(thread.listenerCount(ThreadEvent.Update)).toBe(0);
|
||||||
|
expect(room.listenerCount(RoomEvent.Receipt)).toBe(0);
|
||||||
|
expect(vm.getSnapshot().replyCountLabel).toBe("4");
|
||||||
|
|
||||||
|
thread.length = 0;
|
||||||
|
thread.emit(ThreadEvent.Update, thread);
|
||||||
|
expect(vm.getSnapshot().replyCountLabel).toBe("4");
|
||||||
|
|
||||||
|
nextThread.length = 5;
|
||||||
|
nextThread.emit(ThreadEvent.Update, nextThread);
|
||||||
|
expect(vm.getSnapshot().replyCountLabel).toBe("5");
|
||||||
|
|
||||||
|
vm.dispose();
|
||||||
|
|
||||||
|
expect(nextThread.listenerCount(ThreadEvent.Update)).toBe(0);
|
||||||
|
expect(nextRoom.listenerCount(RoomEvent.Receipt)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches ShowThread and tracks interaction on click", () => {
|
||||||
|
const { vm, rootEvent } = makeSummaryVm({ isCard: true });
|
||||||
|
const event = { type: "click" } as MouseEvent<HTMLButtonElement>;
|
||||||
|
|
||||||
|
vm.onClick(event);
|
||||||
|
|
||||||
|
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
|
||||||
|
action: "show_thread",
|
||||||
|
rootEvent,
|
||||||
|
push: true,
|
||||||
|
});
|
||||||
|
expect(PosthogTrackers.trackInteraction).toHaveBeenCalledWith("WebRoomTimelineThreadSummaryButton", event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the latest root event and card mode from setters when opening the thread", () => {
|
||||||
|
const { vm } = makeSummaryVm();
|
||||||
|
const nextRootEvent = makeEvent("Next root");
|
||||||
|
const event = { type: "click" } as MouseEvent<HTMLButtonElement>;
|
||||||
|
|
||||||
|
vm.setRootEvent(nextRootEvent);
|
||||||
|
vm.setIsCard(true);
|
||||||
|
vm.onClick(event);
|
||||||
|
|
||||||
|
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
|
||||||
|
action: "show_thread",
|
||||||
|
rootEvent: nextRootEvent,
|
||||||
|
push: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ThreadMessagePreviewViewModel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.mocked(determineUnreadState).mockReturnValue({
|
||||||
|
symbol: null,
|
||||||
|
count: 0,
|
||||||
|
level: NotificationLevel.None,
|
||||||
|
invited: false,
|
||||||
|
});
|
||||||
|
jest.spyOn(MessagePreviewStore.instance, "generatePreviewForEvent").mockImplementation((event) => {
|
||||||
|
return event.getContent().body;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the preview when the thread has no latest reply", () => {
|
||||||
|
const room = new TestRoom() as TestRoom & Room;
|
||||||
|
const thread = new TestThread(room) as TestThread & Thread;
|
||||||
|
const vm = new ThreadMessagePreviewViewModel({
|
||||||
|
cli: makeClient(),
|
||||||
|
thread,
|
||||||
|
room,
|
||||||
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
lowBandwidth: false,
|
||||||
|
useOnlyCurrentProfiles: false,
|
||||||
|
showDisplayName: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().isVisible).toBe(false);
|
||||||
|
expect(vm.getSnapshot().avatar).toBeUndefined();
|
||||||
|
expect(vm.getSnapshot().senderName).toBeUndefined();
|
||||||
|
expect(vm.getSnapshot().previewContent).toBeUndefined();
|
||||||
|
expect(vm.getSnapshot().previewTooltip).toBeUndefined();
|
||||||
|
expect(vm.getSnapshot().showDisplayName).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the latest reply preview", async () => {
|
||||||
|
const room = new TestRoom() as TestRoom & Room;
|
||||||
|
const thread = new TestThread(room) as TestThread & Thread;
|
||||||
|
thread.replyToEvent = makeEvent("First reply");
|
||||||
|
const vm = new ThreadMessagePreviewViewModel({
|
||||||
|
cli: makeClient(),
|
||||||
|
thread,
|
||||||
|
room,
|
||||||
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
lowBandwidth: false,
|
||||||
|
useOnlyCurrentProfiles: false,
|
||||||
|
showDisplayName: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().isVisible).toBe(true));
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().senderName).toBe("Alice");
|
||||||
|
expect(vm.getSnapshot().showDisplayName).toBe(true);
|
||||||
|
expect(vm.getSnapshot().previewContent).toBe("First reply");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips preview recomputation when guarded setters receive unchanged values", async () => {
|
||||||
|
const { vm, thread, room, cli } = makePreviewVm();
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().isVisible).toBe(true));
|
||||||
|
jest.mocked(cli.decryptEventIfNeeded).mockClear();
|
||||||
|
|
||||||
|
vm.setClient(cli);
|
||||||
|
vm.setThread(thread);
|
||||||
|
vm.setRoom(room);
|
||||||
|
vm.setTimelineRenderingType(TimelineRenderingType.Room);
|
||||||
|
vm.setLowBandwidth(false);
|
||||||
|
vm.setUseOnlyCurrentProfiles(false);
|
||||||
|
|
||||||
|
expect(cli.decryptEventIfNeeded).not.toHaveBeenCalled();
|
||||||
|
expect(thread.listenerCount(ThreadEvent.Update)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["audio", EventType.RoomMessage, MsgType.Audio],
|
||||||
|
["image", EventType.RoomMessage, MsgType.Image],
|
||||||
|
["video", EventType.RoomMessage, MsgType.Video],
|
||||||
|
["file", EventType.RoomMessage, MsgType.File],
|
||||||
|
["poll", M_POLL_START.name, undefined],
|
||||||
|
])("renders a prefixed preview for %s replies", async (_name, type, msgtype) => {
|
||||||
|
const { vm } = makePreviewVm({
|
||||||
|
thread: new TestThread(new TestRoom() as TestRoom & Room) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
vm.setThread(
|
||||||
|
Object.assign(new TestThread(new TestRoom() as TestRoom & Room), {
|
||||||
|
replyToEvent: makeEvent("Attachment preview", msgtype === undefined ? { type } : { type, msgtype }),
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().isVisible).toBe(true));
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().previewContent).not.toBe("Attachment preview");
|
||||||
|
expect(vm.getSnapshot().previewTooltip).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses prefixed preview content when the generated text has not changed", async () => {
|
||||||
|
const event = makeEvent("Image preview", { msgtype: MsgType.Image });
|
||||||
|
const { vm } = makePreviewVm({
|
||||||
|
thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), {
|
||||||
|
replyToEvent: event,
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().isVisible).toBe(true));
|
||||||
|
const previewContent = vm.getSnapshot().previewContent;
|
||||||
|
|
||||||
|
event.emit(MatrixEventEvent.Decrypted, event);
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().previewContent).toBe(previewContent));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates when the thread latest reply changes in place", async () => {
|
||||||
|
const room = new TestRoom() as TestRoom & Room;
|
||||||
|
const thread = new TestThread(room) as TestThread & Thread;
|
||||||
|
thread.replyToEvent = makeEvent("First reply");
|
||||||
|
const vm = new ThreadMessagePreviewViewModel({
|
||||||
|
cli: makeClient(),
|
||||||
|
thread,
|
||||||
|
room,
|
||||||
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
lowBandwidth: false,
|
||||||
|
useOnlyCurrentProfiles: false,
|
||||||
|
showDisplayName: false,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("First reply"));
|
||||||
|
|
||||||
|
thread.replyToEvent = makeEvent("Second reply");
|
||||||
|
thread.emit(ThreadEvent.Update, thread);
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Second reply"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates when the watched event is replaced or decrypted", async () => {
|
||||||
|
const event = makeEvent("First reply");
|
||||||
|
const { vm } = makePreviewVm({
|
||||||
|
thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), {
|
||||||
|
replyToEvent: event,
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("First reply"));
|
||||||
|
|
||||||
|
jest.mocked(MessagePreviewStore.instance.generatePreviewForEvent).mockReturnValue("Edited reply");
|
||||||
|
event.emit(MatrixEventEvent.Replaced, event);
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Edited reply"));
|
||||||
|
|
||||||
|
jest.mocked(MessagePreviewStore.instance.generatePreviewForEvent).mockReturnValue("Decrypted reply");
|
||||||
|
event.emit(MatrixEventEvent.Decrypted, event);
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Decrypted reply"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides redacted replies and replies without generated previews", async () => {
|
||||||
|
const redactedEvent = makeEvent("Redacted reply");
|
||||||
|
jest.spyOn(redactedEvent, "isRedacted").mockReturnValue(true);
|
||||||
|
const redactedVm = makePreviewVm({
|
||||||
|
thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), {
|
||||||
|
replyToEvent: redactedEvent,
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
}).vm;
|
||||||
|
|
||||||
|
expect(redactedVm.getSnapshot().isVisible).toBe(false);
|
||||||
|
|
||||||
|
jest.mocked(MessagePreviewStore.instance.generatePreviewForEvent).mockReturnValue("");
|
||||||
|
const emptyPreviewVm = makePreviewVm({
|
||||||
|
thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), {
|
||||||
|
replyToEvent: makeEvent("Unsupported reply"),
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
}).vm;
|
||||||
|
|
||||||
|
await waitFor(() => expect(MessagePreviewStore.instance.generatePreviewForEvent).toHaveBeenCalled());
|
||||||
|
expect(emptyPreviewVm.getSnapshot().isVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a decryption failure preview without trying to decrypt again", async () => {
|
||||||
|
const decryptionFailureEvent = makeEvent("Encrypted reply");
|
||||||
|
jest.spyOn(decryptionFailureEvent, "isDecryptionFailure").mockReturnValue(true);
|
||||||
|
const cli = makeClient();
|
||||||
|
const { vm } = makePreviewVm({
|
||||||
|
cli,
|
||||||
|
thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), {
|
||||||
|
replyToEvent: decryptionFailureEvent,
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().isVisible).toBe(true));
|
||||||
|
|
||||||
|
expect(cli.decryptEventIfNeeded).not.toHaveBeenCalled();
|
||||||
|
expect(vm.getSnapshot().previewContent).toBe("Unable to decrypt message");
|
||||||
|
expect(vm.getSnapshot().previewTooltip).toBe("Unable to decrypt message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and hides the preview when decryption rejects", async () => {
|
||||||
|
const decryptError = new Error("No key");
|
||||||
|
const cli = makeClient(jest.fn().mockRejectedValue(decryptError));
|
||||||
|
const loggerSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
|
||||||
|
const { vm } = makePreviewVm({ cli });
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(loggerSpy).toHaveBeenCalledWith("Failed to decrypt thread preview event", decryptError),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().isVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches fire-and-forget preview update errors", async () => {
|
||||||
|
const previewError = new Error("Preview failed");
|
||||||
|
jest.mocked(MessagePreviewStore.instance.generatePreviewForEvent).mockImplementation(() => {
|
||||||
|
throw previewError;
|
||||||
|
});
|
||||||
|
const loggerSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
makePreviewVm();
|
||||||
|
|
||||||
|
await waitFor(() => expect(loggerSpy).toHaveBeenCalledWith("Failed to update thread preview", previewError));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a decryption failure preview when decryption rejects into a failure state", async () => {
|
||||||
|
const decryptError = new Error("No key");
|
||||||
|
const event = makeEvent("Encrypted reply");
|
||||||
|
const isDecryptionFailureSpy = jest.spyOn(event, "isDecryptionFailure").mockReturnValue(false);
|
||||||
|
const cli = makeClient(
|
||||||
|
jest.fn().mockImplementation(async () => {
|
||||||
|
isDecryptionFailureSpy.mockReturnValue(true);
|
||||||
|
throw decryptError;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
jest.spyOn(logger, "error").mockImplementation(() => {});
|
||||||
|
const { vm } = makePreviewVm({
|
||||||
|
cli,
|
||||||
|
thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), {
|
||||||
|
replyToEvent: event,
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Unable to decrypt message"));
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().isVisible).toBe(true);
|
||||||
|
expect(vm.getSnapshot().previewTooltip).toBe("Unable to decrypt message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the preview when an event is redacted during decryption", async () => {
|
||||||
|
const event = makeEvent("Redacted during decrypt");
|
||||||
|
const isRedactedSpy = jest.spyOn(event, "isRedacted").mockReturnValue(false);
|
||||||
|
const cli = makeClient(
|
||||||
|
jest.fn().mockImplementation(async () => {
|
||||||
|
isRedactedSpy.mockReturnValue(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { vm } = makePreviewVm({
|
||||||
|
cli,
|
||||||
|
thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), {
|
||||||
|
replyToEvent: event,
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(isRedactedSpy).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().isVisible).toBe(false);
|
||||||
|
expect(MessagePreviewStore.instance.generatePreviewForEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a decryption failure preview when an event fails after decryption", async () => {
|
||||||
|
const event = makeEvent("Failure during decrypt");
|
||||||
|
const isDecryptionFailureSpy = jest.spyOn(event, "isDecryptionFailure").mockReturnValue(false);
|
||||||
|
const cli = makeClient(
|
||||||
|
jest.fn().mockImplementation(async () => {
|
||||||
|
isDecryptionFailureSpy.mockReturnValue(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { vm } = makePreviewVm({
|
||||||
|
cli,
|
||||||
|
thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), {
|
||||||
|
replyToEvent: event,
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Unable to decrypt message"));
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().isVisible).toBe(true);
|
||||||
|
expect(MessagePreviewStore.instance.generatePreviewForEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale decrypt results when the latest reply changes during decryption", async () => {
|
||||||
|
let resolveFirstDecrypt!: () => void;
|
||||||
|
const firstDecrypt = new Promise<void>((resolve) => {
|
||||||
|
resolveFirstDecrypt = resolve;
|
||||||
|
});
|
||||||
|
const cli = makeClient(jest.fn().mockReturnValueOnce(firstDecrypt).mockResolvedValue(undefined));
|
||||||
|
const room = new TestRoom() as TestRoom & Room;
|
||||||
|
const thread = new TestThread(room) as TestThread & Thread;
|
||||||
|
thread.replyToEvent = makeEvent("First reply");
|
||||||
|
const vm = new ThreadMessagePreviewViewModel({
|
||||||
|
cli,
|
||||||
|
thread,
|
||||||
|
room,
|
||||||
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
lowBandwidth: false,
|
||||||
|
useOnlyCurrentProfiles: false,
|
||||||
|
showDisplayName: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
thread.replyToEvent = makeEvent("Second reply");
|
||||||
|
thread.emit(ThreadEvent.Update, thread);
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Second reply"));
|
||||||
|
|
||||||
|
resolveFirstDecrypt();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().previewContent).toBe("Second reply");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale decrypt errors when the latest reply changes before rejection", async () => {
|
||||||
|
let rejectFirstDecrypt!: (error: Error) => void;
|
||||||
|
const firstDecrypt = new Promise<void>((_resolve, reject) => {
|
||||||
|
rejectFirstDecrypt = reject;
|
||||||
|
});
|
||||||
|
const cli = makeClient(jest.fn().mockReturnValueOnce(firstDecrypt).mockResolvedValue(undefined));
|
||||||
|
const loggerSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
|
||||||
|
const room = new TestRoom() as TestRoom & Room;
|
||||||
|
const thread = new TestThread(room) as TestThread & Thread;
|
||||||
|
thread.replyToEvent = makeEvent("First reply");
|
||||||
|
const vm = new ThreadMessagePreviewViewModel({
|
||||||
|
cli,
|
||||||
|
thread,
|
||||||
|
room,
|
||||||
|
timelineRenderingType: TimelineRenderingType.Room,
|
||||||
|
lowBandwidth: false,
|
||||||
|
useOnlyCurrentProfiles: false,
|
||||||
|
showDisplayName: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
thread.replyToEvent = makeEvent("Second reply");
|
||||||
|
thread.emit(ThreadEvent.Update, thread);
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Second reply"));
|
||||||
|
|
||||||
|
rejectFirstDecrypt(new Error("Stale decrypt"));
|
||||||
|
await waitFor(() => expect(loggerSpy).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().previewContent).toBe("Second reply");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses current room profiles and avatar thumbnails when requested", async () => {
|
||||||
|
const room = new TestRoom() as TestRoom & Room;
|
||||||
|
const currentMember = makeMember("Current Alice", userId, "mxc://example.org/avatar");
|
||||||
|
room.getMember.mockReturnValue(currentMember);
|
||||||
|
const { vm, cli } = makePreviewVm({
|
||||||
|
room,
|
||||||
|
useOnlyCurrentProfiles: true,
|
||||||
|
thread: Object.assign(new TestThread(room), {
|
||||||
|
replyToEvent: makeEvent("Profile reply", { senderName: "Historical Alice" }),
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().senderName).toBe("Current Alice"));
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().avatar).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: userId,
|
||||||
|
name: "Current Alice",
|
||||||
|
src: "https://matrix.example.org/_matrix/media/mxc://example.org/avatar/24x24/crop",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(getMxcUrlToHttpMock(cli)).toHaveBeenCalledWith("mxc://example.org/avatar", 24, 24, "crop", false, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the avatar and sender when the current room member profile changes", async () => {
|
||||||
|
const room = new TestRoom() as TestRoom & Room;
|
||||||
|
const historicalEvent = makeEvent("Profile update reply", { senderName: "Historical Alice" });
|
||||||
|
const { vm } = makePreviewVm({
|
||||||
|
room,
|
||||||
|
timelineRenderingType: TimelineRenderingType.ThreadsList,
|
||||||
|
thread: Object.assign(new TestThread(room), {
|
||||||
|
replyToEvent: historicalEvent,
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().senderName).toBe("Historical Alice"));
|
||||||
|
|
||||||
|
const currentMember = makeMember("Current Alice", userId, "mxc://example.org/current-avatar");
|
||||||
|
room.getMember.mockReturnValue(currentMember);
|
||||||
|
room.emit(RoomStateEvent.Members, makeEvent("Member event"), room, currentMember);
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().senderName).toBe("Current Alice"));
|
||||||
|
expect(vm.getSnapshot().avatar).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: userId,
|
||||||
|
name: "Current Alice",
|
||||||
|
src: "https://matrix.example.org/_matrix/media/mxc://example.org/current-avatar/24x24/crop",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores current room member profile updates for other users and unsubscribes on dispose", async () => {
|
||||||
|
const room = new TestRoom() as TestRoom & Room;
|
||||||
|
const { vm } = makePreviewVm({
|
||||||
|
room,
|
||||||
|
timelineRenderingType: TimelineRenderingType.ThreadsList,
|
||||||
|
thread: Object.assign(new TestThread(room), {
|
||||||
|
replyToEvent: makeEvent("Profile listener reply", { senderName: "Historical Alice" }),
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().senderName).toBe("Historical Alice"));
|
||||||
|
|
||||||
|
expect(room.listenerCount(RoomStateEvent.Members)).toBe(1);
|
||||||
|
|
||||||
|
room.getMember.mockReturnValue(makeMember("Current Alice", userId, "mxc://example.org/avatar"));
|
||||||
|
room.emit(
|
||||||
|
RoomStateEvent.Members,
|
||||||
|
makeEvent("Other member event"),
|
||||||
|
room,
|
||||||
|
makeMember("Other User", "@other:example.org"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().senderName).toBe("Historical Alice");
|
||||||
|
|
||||||
|
vm.dispose();
|
||||||
|
|
||||||
|
expect(room.listenerCount(RoomStateEvent.Members)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses current room profiles in thread timelines and suppresses avatar URLs in low-bandwidth mode", async () => {
|
||||||
|
const room = new TestRoom() as TestRoom & Room;
|
||||||
|
room.getMember.mockReturnValue(makeMember("Thread Alice", userId, "mxc://example.org/avatar"));
|
||||||
|
const { vm, cli } = makePreviewVm({
|
||||||
|
room,
|
||||||
|
timelineRenderingType: TimelineRenderingType.Thread,
|
||||||
|
lowBandwidth: true,
|
||||||
|
useOnlyCurrentProfiles: false,
|
||||||
|
thread: Object.assign(new TestThread(room), {
|
||||||
|
replyToEvent: makeEvent("Low bandwidth reply", { senderName: "Historical Alice" }),
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().senderName).toBe("Thread Alice"));
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().avatar).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Thread Alice",
|
||||||
|
src: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(getMxcUrlToHttpMock(cli)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to historical sender profiles when no current member exists", async () => {
|
||||||
|
const room = new TestRoom() as TestRoom & Room;
|
||||||
|
room.getMember.mockReturnValue(null);
|
||||||
|
const { vm } = makePreviewVm({
|
||||||
|
room,
|
||||||
|
useOnlyCurrentProfiles: true,
|
||||||
|
thread: Object.assign(new TestThread(room), {
|
||||||
|
replyToEvent: makeEvent("Historical profile reply", { senderName: "Historical Alice" }),
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().senderName).toBe("Historical Alice"));
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().avatar).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: userId,
|
||||||
|
name: "Historical Alice",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the sender id when event sender profile data is missing", async () => {
|
||||||
|
const mxEvent = makeEvent("Missing sender profile");
|
||||||
|
mxEvent.sender = null;
|
||||||
|
jest.spyOn(mxEvent, "getSender").mockReturnValue("@fallback:example.org");
|
||||||
|
const { vm } = makePreviewVm({
|
||||||
|
thread: Object.assign(new TestThread(new TestRoom() as TestRoom & Room), {
|
||||||
|
replyToEvent: mxEvent,
|
||||||
|
}) as TestThread & Thread,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().senderName).toBe("@fallback:example.org"));
|
||||||
|
|
||||||
|
expect(vm.getSnapshot().avatar).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "@fallback:example.org",
|
||||||
|
name: "@fallback:example.org",
|
||||||
|
title: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
@@ -53,6 +53,7 @@ export * from "./room/timeline/event-tile/EventTileView/TextualEventView";
|
|||||||
export * from "./room/timeline/event-tile/body/AudioPlayerView";
|
export * from "./room/timeline/event-tile/body/AudioPlayerView";
|
||||||
export * from "./room/timeline/event-tile/body/DecryptionFailureBodyView";
|
export * from "./room/timeline/event-tile/body/DecryptionFailureBodyView";
|
||||||
export * from "./room/timeline/event-tile/body/MediaBody";
|
export * from "./room/timeline/event-tile/body/MediaBody";
|
||||||
|
export * from "./room/timeline/event-tile/EventTileView/ThreadSummary";
|
||||||
export * from "./room/timeline/event-tile/reactions/ReactionsRow";
|
export * from "./room/timeline/event-tile/reactions/ReactionsRow";
|
||||||
export * from "./room/timeline/event-tile/reactions/ReactionsRowButton";
|
export * from "./room/timeline/event-tile/reactions/ReactionsRowButton";
|
||||||
export * from "./room/timeline/event-tile/reactions/ReactionsRowButtonTooltip";
|
export * from "./room/timeline/event-tile/reactions/ReactionsRowButtonTooltip";
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.threadSummary,
|
||||||
|
.content {
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threadSummary {
|
||||||
|
margin-top: var(--cpd-space-2x);
|
||||||
|
min-width: 267px;
|
||||||
|
max-width: min(calc(100% - var(--EventTile_group_line-spacing-inline-start, 0px)), 600px);
|
||||||
|
width: fit-content;
|
||||||
|
height: 40px;
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||||
|
padding-inline: var(--cpd-space-3x) var(--cpd-space-4x);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
clear: both;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--cpd-color-bg-subtle-secondary);
|
||||||
|
font: var(--cpd-font-body-sm-regular);
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threadSummary:hover,
|
||||||
|
.threadSummary:focus {
|
||||||
|
border-color: var(--cpd-color-border-interactive-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threadSummary:hover .chevron,
|
||||||
|
.threadSummary:focus .chevron {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender,
|
||||||
|
.content {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font: var(--cpd-font-body-sm-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender {
|
||||||
|
font-weight: var(--cpd-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content strong {
|
||||||
|
font: var(--cpd-font-body-sm-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threadSummary .repliesAmount,
|
||||||
|
.threadSummary .sender,
|
||||||
|
.threadSummary .content {
|
||||||
|
line-height: var(--EventTile_ThreadSummary-line-height, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threadSummary .content {
|
||||||
|
margin-left: var(--cpd-space-1x);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repliesAmount {
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
font-weight: var(--cpd-font-weight-semibold);
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
padding: 0 var(--cpd-space-3x) 0 var(--cpd-space-2x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
margin-inline-end: var(--cpd-space-2x);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threadIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 60px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: linear-gradient(270deg, var(--cpd-color-bg-canvas-default) 50%, transparent 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(60px);
|
||||||
|
transition: all 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: var(--cpd-space-1x);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow {
|
||||||
|
min-width: initial;
|
||||||
|
max-width: 100%;
|
||||||
|
width: initial;
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { type JSX } from "react";
|
||||||
|
import { fn } from "storybook/test";
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { useMockedViewModel } from "../../../../../core/viewmodel";
|
||||||
|
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
|
||||||
|
import {
|
||||||
|
ThreadSummaryView,
|
||||||
|
type ThreadMessagePreviewViewSnapshot,
|
||||||
|
type ThreadSummaryViewActions,
|
||||||
|
type ThreadSummaryViewSnapshot,
|
||||||
|
} from "./ThreadSummaryView";
|
||||||
|
|
||||||
|
type WrapperProps = Omit<ThreadSummaryViewSnapshot, "previewVm"> &
|
||||||
|
Partial<ThreadSummaryViewActions> & {
|
||||||
|
preview: ThreadMessagePreviewViewSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThreadSummaryViewWrapperImpl = ({ onClick, preview, ...snapshotProps }: Readonly<WrapperProps>): JSX.Element => {
|
||||||
|
const previewVm = useMockedViewModel(preview, {});
|
||||||
|
const vm = useMockedViewModel(
|
||||||
|
{
|
||||||
|
...snapshotProps,
|
||||||
|
previewVm,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onClick: onClick ?? fn(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ThreadSummaryView vm={vm} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThreadSummaryViewWrapper = withViewDocs(ThreadSummaryViewWrapperImpl, ThreadSummaryView);
|
||||||
|
|
||||||
|
const defaultPreview: ThreadMessagePreviewViewSnapshot = {
|
||||||
|
isVisible: true,
|
||||||
|
avatar: {
|
||||||
|
id: "@alice:example.org",
|
||||||
|
name: "Alice",
|
||||||
|
label: "User avatar",
|
||||||
|
},
|
||||||
|
showDisplayName: true,
|
||||||
|
senderName: "Alice",
|
||||||
|
previewContent: "Can you review the draft?",
|
||||||
|
previewTooltip: "Can you review the draft?",
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Timeline/Timeline Event//ThreadSummary",
|
||||||
|
component: ThreadSummaryViewWrapper,
|
||||||
|
tags: ["autodocs"],
|
||||||
|
args: {
|
||||||
|
isVisible: true,
|
||||||
|
replyCountLabel: "3 replies",
|
||||||
|
openThreadLabel: "Open thread",
|
||||||
|
notificationIndicator: undefined,
|
||||||
|
narrow: false,
|
||||||
|
preview: defaultPreview,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof ThreadSummaryViewWrapper>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const Narrow: Story = {
|
||||||
|
args: {
|
||||||
|
replyCountLabel: "3",
|
||||||
|
narrow: true,
|
||||||
|
preview: {
|
||||||
|
...defaultPreview,
|
||||||
|
showDisplayName: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithNotification: Story = {
|
||||||
|
args: {
|
||||||
|
notificationIndicator: "critical",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DecryptionFailure: Story = {
|
||||||
|
args: {
|
||||||
|
preview: {
|
||||||
|
...defaultPreview,
|
||||||
|
previewContent: "Unable to decrypt message",
|
||||||
|
previewTooltip: "Unable to decrypt message",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Hidden: Story = {
|
||||||
|
args: {
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { composeStories } from "@storybook/react-vite";
|
||||||
|
import { render, screen } from "@test-utils";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { MockViewModel } from "../../../../../core/viewmodel";
|
||||||
|
import {
|
||||||
|
ThreadSummaryView,
|
||||||
|
type ThreadMessagePreviewViewSnapshot,
|
||||||
|
type ThreadSummaryViewActions,
|
||||||
|
type ThreadSummaryViewModel,
|
||||||
|
type ThreadSummaryViewSnapshot,
|
||||||
|
} from "./ThreadSummaryView";
|
||||||
|
import * as stories from "./ThreadSummary.stories";
|
||||||
|
|
||||||
|
const { Default, Narrow, WithNotification, DecryptionFailure, Hidden } = composeStories(stories);
|
||||||
|
|
||||||
|
describe("ThreadSummaryView", () => {
|
||||||
|
it("renders the default thread summary", () => {
|
||||||
|
const { container } = render(<Default />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the narrow thread summary", () => {
|
||||||
|
const { container } = render(<Narrow />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a notification indicator", () => {
|
||||||
|
const { container } = render(<WithNotification />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a decryption failure preview", () => {
|
||||||
|
const { container } = render(<DecryptionFailure />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render when hidden", () => {
|
||||||
|
render(<Hidden />);
|
||||||
|
expect(screen.queryByRole("button", { name: "Open thread" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes the click action", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const previewVm = new MockViewModel<ThreadMessagePreviewViewSnapshot>({
|
||||||
|
isVisible: true,
|
||||||
|
showDisplayName: false,
|
||||||
|
previewContent: "Latest reply",
|
||||||
|
});
|
||||||
|
const vm = new (class extends MockViewModel<ThreadSummaryViewSnapshot> implements ThreadSummaryViewActions {
|
||||||
|
public constructor() {
|
||||||
|
super({
|
||||||
|
isVisible: true,
|
||||||
|
replyCountLabel: "1 reply",
|
||||||
|
openThreadLabel: "Open thread",
|
||||||
|
narrow: false,
|
||||||
|
previewVm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onClick = onClick;
|
||||||
|
})() as ThreadSummaryViewModel;
|
||||||
|
|
||||||
|
render(<ThreadSummaryView vm={vm} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Open thread" }));
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations Ltd.
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
* Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { type ComponentPropsWithoutRef, type JSX, type MouseEventHandler, type ReactNode } from "react";
|
||||||
|
import { Avatar, IndicatorIcon, Tooltip } from "@vector-im/compound-web";
|
||||||
|
import { ChevronRightIcon, ThreadsSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
|
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
|
||||||
|
import styles from "./ThreadSummary.module.css";
|
||||||
|
|
||||||
|
export type ThreadSummaryNotificationIndicator = ComponentPropsWithoutRef<typeof IndicatorIcon>["indicator"];
|
||||||
|
|
||||||
|
export interface ThreadMessagePreviewAvatar {
|
||||||
|
/**
|
||||||
|
* Optional class name supplied by the app for integration styling.
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Stable id used for avatar colour hashing.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Name used by the avatar fallback.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Optional avatar image URL.
|
||||||
|
*/
|
||||||
|
src?: string;
|
||||||
|
/**
|
||||||
|
* Accessible label for the avatar.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* Optional tooltip/title text for the avatar image.
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadMessagePreviewViewSnapshot {
|
||||||
|
/**
|
||||||
|
* Controls whether the preview should render.
|
||||||
|
*/
|
||||||
|
isVisible: boolean;
|
||||||
|
/**
|
||||||
|
* Sender avatar data.
|
||||||
|
*/
|
||||||
|
avatar?: ThreadMessagePreviewAvatar;
|
||||||
|
/**
|
||||||
|
* Whether to render the sender display name.
|
||||||
|
*/
|
||||||
|
showDisplayName: boolean;
|
||||||
|
/**
|
||||||
|
* Sender display name.
|
||||||
|
*/
|
||||||
|
senderName?: string;
|
||||||
|
/**
|
||||||
|
* Rendered preview content.
|
||||||
|
*/
|
||||||
|
previewContent?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Optional styled tooltip text for the preview content.
|
||||||
|
*/
|
||||||
|
previewTooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThreadMessagePreviewViewModel = ViewModel<ThreadMessagePreviewViewSnapshot>;
|
||||||
|
|
||||||
|
export interface ThreadSummaryViewSnapshot {
|
||||||
|
/**
|
||||||
|
* Controls whether the summary should render.
|
||||||
|
*/
|
||||||
|
isVisible: boolean;
|
||||||
|
/**
|
||||||
|
* Text for the reply count section.
|
||||||
|
*/
|
||||||
|
replyCountLabel: string;
|
||||||
|
/**
|
||||||
|
* Accessible label for opening the thread.
|
||||||
|
*/
|
||||||
|
openThreadLabel: string;
|
||||||
|
/**
|
||||||
|
* Notification indicator shown on the thread icon.
|
||||||
|
*/
|
||||||
|
notificationIndicator?: ThreadSummaryNotificationIndicator;
|
||||||
|
/**
|
||||||
|
* Whether the summary is being rendered in the narrow timeline layout.
|
||||||
|
*/
|
||||||
|
narrow: boolean;
|
||||||
|
/**
|
||||||
|
* View model for the last-message preview.
|
||||||
|
*/
|
||||||
|
previewVm: ThreadMessagePreviewViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadSummaryViewActions {
|
||||||
|
/**
|
||||||
|
* Invoked when the user opens the thread.
|
||||||
|
*/
|
||||||
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThreadSummaryViewModel = ViewModel<ThreadSummaryViewSnapshot, ThreadSummaryViewActions>;
|
||||||
|
|
||||||
|
type ThreadSummaryViewProps = Omit<ComponentPropsWithoutRef<"button">, "aria-label" | "onClick"> & {
|
||||||
|
/**
|
||||||
|
* The view model for the thread summary.
|
||||||
|
*/
|
||||||
|
vm: ThreadSummaryViewModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ThreadMessagePreviewViewProps {
|
||||||
|
/**
|
||||||
|
* The view model for the thread message preview.
|
||||||
|
*/
|
||||||
|
vm: ThreadMessagePreviewViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreadMessagePreviewView({ vm }: Readonly<ThreadMessagePreviewViewProps>): JSX.Element {
|
||||||
|
const { isVisible, avatar, showDisplayName, senderName, previewContent, previewTooltip } = useViewModel(vm);
|
||||||
|
|
||||||
|
if (!isVisible || !previewContent) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = <span className={styles.content}>{previewContent}</span>;
|
||||||
|
const avatarClassName = avatar?.className ? `${styles.avatar} ${avatar.className}` : styles.avatar;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{avatar && (
|
||||||
|
<Avatar
|
||||||
|
id={avatar.id}
|
||||||
|
name={avatar.name}
|
||||||
|
src={avatar.src}
|
||||||
|
title={avatar.title}
|
||||||
|
aria-label={avatar.label}
|
||||||
|
type="round"
|
||||||
|
size="24px"
|
||||||
|
className={avatarClassName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showDisplayName && senderName && <div className={styles.sender}>{senderName}</div>}
|
||||||
|
{previewTooltip ? <Tooltip description={previewTooltip}>{content}</Tooltip> : content}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreadSummaryView({
|
||||||
|
vm,
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
...props
|
||||||
|
}: Readonly<ThreadSummaryViewProps>): JSX.Element {
|
||||||
|
const { isVisible, replyCountLabel, openThreadLabel, notificationIndicator, narrow, previewVm } = useViewModel(vm);
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonClassName = [styles.threadSummary, className, narrow ? styles.narrow : undefined]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button {...props} type={type} className={buttonClassName} onClick={vm.onClick} aria-label={openThreadLabel}>
|
||||||
|
<IndicatorIcon size="24px" indicator={notificationIndicator} className={styles.threadIcon}>
|
||||||
|
<ThreadsSolidIcon />
|
||||||
|
</IndicatorIcon>
|
||||||
|
<span className={styles.repliesAmount}>{replyCountLabel}</span>
|
||||||
|
<ThreadMessagePreviewView vm={previewVm} />
|
||||||
|
<div className={styles.chevron}>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`ThreadSummaryView > renders a decryption failure preview 1`] = `
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
aria-label="Open thread"
|
||||||
|
class="ThreadSummary-module_threadSummary"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_147l5_17 ThreadSummary-module_threadIcon"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2m3 7h10q.424 0 .712-.287A.97.97 0 0 0 18 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 17 8H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 9q0 .424.287.713Q6.576 10 7 10m0 4h6q.424 0 .713-.287A.97.97 0 0 0 14 13a.97.97 0 0 0-.287-.713A.97.97 0 0 0 13 12H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 13q0 .424.287.713Q6.576 14 7 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="ThreadSummary-module_repliesAmount"
|
||||||
|
>
|
||||||
|
3 replies
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-label="User avatar"
|
||||||
|
class="_avatar_va14e_8 ThreadSummary-module_avatar _avatar-imageless_va14e_55"
|
||||||
|
data-color="3"
|
||||||
|
data-type="round"
|
||||||
|
role="img"
|
||||||
|
style="--cpd-avatar-size: 24px;"
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="ThreadSummary-module_sender"
|
||||||
|
>
|
||||||
|
Alice
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="ThreadSummary-module_content"
|
||||||
|
>
|
||||||
|
Unable to decrypt message
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="ThreadSummary-module_chevron"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ThreadSummaryView > renders a notification indicator 1`] = `
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
aria-label="Open thread"
|
||||||
|
class="ThreadSummary-module_threadSummary"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_147l5_17 ThreadSummary-module_threadIcon"
|
||||||
|
data-indicator="critical"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2m3 7h10q.424 0 .712-.287A.97.97 0 0 0 18 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 17 8H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 9q0 .424.287.713Q6.576 10 7 10m0 4h6q.424 0 .713-.287A.97.97 0 0 0 14 13a.97.97 0 0 0-.287-.713A.97.97 0 0 0 13 12H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 13q0 .424.287.713Q6.576 14 7 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="ThreadSummary-module_repliesAmount"
|
||||||
|
>
|
||||||
|
3 replies
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-label="User avatar"
|
||||||
|
class="_avatar_va14e_8 ThreadSummary-module_avatar _avatar-imageless_va14e_55"
|
||||||
|
data-color="3"
|
||||||
|
data-type="round"
|
||||||
|
role="img"
|
||||||
|
style="--cpd-avatar-size: 24px;"
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="ThreadSummary-module_sender"
|
||||||
|
>
|
||||||
|
Alice
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="ThreadSummary-module_content"
|
||||||
|
>
|
||||||
|
Can you review the draft?
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="ThreadSummary-module_chevron"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ThreadSummaryView > renders the default thread summary 1`] = `
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
aria-label="Open thread"
|
||||||
|
class="ThreadSummary-module_threadSummary"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_147l5_17 ThreadSummary-module_threadIcon"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2m3 7h10q.424 0 .712-.287A.97.97 0 0 0 18 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 17 8H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 9q0 .424.287.713Q6.576 10 7 10m0 4h6q.424 0 .713-.287A.97.97 0 0 0 14 13a.97.97 0 0 0-.287-.713A.97.97 0 0 0 13 12H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 13q0 .424.287.713Q6.576 14 7 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="ThreadSummary-module_repliesAmount"
|
||||||
|
>
|
||||||
|
3 replies
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-label="User avatar"
|
||||||
|
class="_avatar_va14e_8 ThreadSummary-module_avatar _avatar-imageless_va14e_55"
|
||||||
|
data-color="3"
|
||||||
|
data-type="round"
|
||||||
|
role="img"
|
||||||
|
style="--cpd-avatar-size: 24px;"
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="ThreadSummary-module_sender"
|
||||||
|
>
|
||||||
|
Alice
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="ThreadSummary-module_content"
|
||||||
|
>
|
||||||
|
Can you review the draft?
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="ThreadSummary-module_chevron"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ThreadSummaryView > renders the narrow thread summary 1`] = `
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
aria-label="Open thread"
|
||||||
|
class="ThreadSummary-module_threadSummary ThreadSummary-module_narrow"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_147l5_17 ThreadSummary-module_threadIcon"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2m3 7h10q.424 0 .712-.287A.97.97 0 0 0 18 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 17 8H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 9q0 .424.287.713Q6.576 10 7 10m0 4h6q.424 0 .713-.287A.97.97 0 0 0 14 13a.97.97 0 0 0-.287-.713A.97.97 0 0 0 13 12H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 13q0 .424.287.713Q6.576 14 7 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="ThreadSummary-module_repliesAmount"
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-label="User avatar"
|
||||||
|
class="_avatar_va14e_8 ThreadSummary-module_avatar _avatar-imageless_va14e_55"
|
||||||
|
data-color="3"
|
||||||
|
data-type="round"
|
||||||
|
role="img"
|
||||||
|
style="--cpd-avatar-size: 24px;"
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="ThreadSummary-module_content"
|
||||||
|
>
|
||||||
|
Can you review the draft?
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="ThreadSummary-module_chevron"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
ThreadMessagePreviewView,
|
||||||
|
ThreadSummaryView,
|
||||||
|
type ThreadMessagePreviewAvatar,
|
||||||
|
type ThreadMessagePreviewViewModel,
|
||||||
|
type ThreadMessagePreviewViewSnapshot,
|
||||||
|
type ThreadSummaryNotificationIndicator,
|
||||||
|
type ThreadSummaryViewActions,
|
||||||
|
type ThreadSummaryViewModel,
|
||||||
|
type ThreadSummaryViewSnapshot,
|
||||||
|
} from "./ThreadSummaryView";
|
||||||
Reference in New Issue
Block a user