Display pinned messages on a banner at the top of a room (#12917)
* Move pinned message hooks to a dedicated file * Add a banner at the top of a room to display the pinned messages * Put the pinning banner behind labs pinning labs flag * Add redacted event support * Handle UTD in pinning message banner * Add tests for redaction * Make all the banner clickable * Add tests for PinnedMessageBanner.tsx * Add e2e tests for the pinned message banner * Review changes
|
@ -168,9 +168,8 @@ export class Helpers {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the right panel
|
* Return the right panel
|
||||||
* @private
|
|
||||||
*/
|
*/
|
||||||
private getRightPanel() {
|
public getRightPanel() {
|
||||||
return this.page.locator("#mx_RightPanel");
|
return this.page.locator("#mx_RightPanel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,7 +182,6 @@ export class Helpers {
|
||||||
await expect(rightPanel.getByRole("heading", { name: "Pinned messages" })).toHaveText(
|
await expect(rightPanel.getByRole("heading", { name: "Pinned messages" })).toHaveText(
|
||||||
`${messages.length} Pinned messages`,
|
`${messages.length} Pinned messages`,
|
||||||
);
|
);
|
||||||
await expect(rightPanel).toMatchScreenshot(`pinned-messages-list-messages-${messages.length}.png`);
|
|
||||||
|
|
||||||
const list = rightPanel.getByRole("list");
|
const list = rightPanel.getByRole("list");
|
||||||
await expect(list.getByRole("listitem")).toHaveCount(messages.length);
|
await expect(list.getByRole("listitem")).toHaveCount(messages.length);
|
||||||
|
@ -243,6 +241,36 @@ export class Helpers {
|
||||||
await item.getByRole("button").click();
|
await item.getByRole("button").click();
|
||||||
await this.page.getByRole("menu", { name: "Open menu" }).getByRole("menuitem", { name: "Unpin" }).click();
|
await this.page.getByRole("menu", { name: "Open menu" }).getByRole("menuitem", { name: "Unpin" }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the banner
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
public getBanner() {
|
||||||
|
return this.page.getByTestId("pinned-message-banner");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that the banner contains the given message
|
||||||
|
* @param msg
|
||||||
|
*/
|
||||||
|
async assertMessageInBanner(msg: string) {
|
||||||
|
await expect(this.getBanner().getByText(msg)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the view all button
|
||||||
|
*/
|
||||||
|
public getViewAllButton() {
|
||||||
|
return this.page.getByRole("button", { name: "View all" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the close list button
|
||||||
|
*/
|
||||||
|
public getCloseListButton() {
|
||||||
|
return this.page.getByRole("button", { name: "Close list" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { expect };
|
export { expect };
|
||||||
|
|
|
@ -48,6 +48,7 @@ test.describe("Pinned messages", () => {
|
||||||
await util.openRoomInfo();
|
await util.openRoomInfo();
|
||||||
await util.openPinnedMessagesList();
|
await util.openPinnedMessagesList();
|
||||||
await util.assertPinnedMessagesList(["Msg1", "Msg2", "Msg4"]);
|
await util.assertPinnedMessagesList(["Msg1", "Msg2", "Msg4"]);
|
||||||
|
await expect(util.getRightPanel()).toMatchScreenshot(`pinned-messages-list-pin-3.png`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should unpin one message", async ({ page, app, room1, util }) => {
|
test("should unpin one message", async ({ page, app, room1, util }) => {
|
||||||
|
@ -59,6 +60,7 @@ test.describe("Pinned messages", () => {
|
||||||
await util.openPinnedMessagesList();
|
await util.openPinnedMessagesList();
|
||||||
await util.unpinMessageFromMessageList("Msg2");
|
await util.unpinMessageFromMessageList("Msg2");
|
||||||
await util.assertPinnedMessagesList(["Msg1", "Msg4"]);
|
await util.assertPinnedMessagesList(["Msg1", "Msg4"]);
|
||||||
|
await expect(util.getRightPanel()).toMatchScreenshot(`pinned-messages-list-unpin-2.png`);
|
||||||
await util.backPinnedMessagesList();
|
await util.backPinnedMessagesList();
|
||||||
await util.assertPinnedCountInRoomInfo(2);
|
await util.assertPinnedCountInRoomInfo(2);
|
||||||
});
|
});
|
||||||
|
@ -87,4 +89,65 @@ test.describe("Pinned messages", () => {
|
||||||
await util.pinMessagesFromQuickActions(["Msg1"], true);
|
await util.pinMessagesFromQuickActions(["Msg1"], true);
|
||||||
await util.assertPinnedCountInRoomInfo(0);
|
await util.assertPinnedCountInRoomInfo(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should display one message in the banner", async ({ page, app, room1, util }) => {
|
||||||
|
await util.goTo(room1);
|
||||||
|
await util.receiveMessages(room1, ["Msg1"]);
|
||||||
|
await util.pinMessages(["Msg1"]);
|
||||||
|
await util.assertMessageInBanner("Msg1");
|
||||||
|
await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-1-Msg1.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should display 2 messages in the banner", async ({ page, app, room1, util }) => {
|
||||||
|
await util.goTo(room1);
|
||||||
|
await util.receiveMessages(room1, ["Msg1", "Msg2"]);
|
||||||
|
await util.pinMessages(["Msg1", "Msg2"]);
|
||||||
|
|
||||||
|
await util.assertMessageInBanner("Msg1");
|
||||||
|
await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-2-Msg1.png");
|
||||||
|
|
||||||
|
await util.getBanner().click();
|
||||||
|
await util.assertMessageInBanner("Msg2");
|
||||||
|
await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-2-Msg2.png");
|
||||||
|
|
||||||
|
await util.getBanner().click();
|
||||||
|
await util.assertMessageInBanner("Msg1");
|
||||||
|
await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-2-Msg1.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should display 4 messages in the banner", async ({ page, app, room1, util }) => {
|
||||||
|
await util.goTo(room1);
|
||||||
|
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
|
||||||
|
await util.pinMessages(["Msg1", "Msg2", "Msg3", "Msg4"]);
|
||||||
|
|
||||||
|
for (const msg of ["Msg1", "Msg4", "Msg3", "Msg2"]) {
|
||||||
|
await util.assertMessageInBanner(msg);
|
||||||
|
await expect(util.getBanner()).toMatchScreenshot(`pinned-message-banner-4-${msg}.png`);
|
||||||
|
await util.getBanner().click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should open the pinned messages list from the banner", async ({ page, app, room1, util }) => {
|
||||||
|
await util.goTo(room1);
|
||||||
|
await util.receiveMessages(room1, ["Msg1", "Msg2"]);
|
||||||
|
await util.pinMessages(["Msg1", "Msg2"]);
|
||||||
|
|
||||||
|
await util.getViewAllButton().click();
|
||||||
|
await util.assertPinnedMessagesList(["Msg1", "Msg2"]);
|
||||||
|
await expect(util.getRightPanel()).toMatchScreenshot("pinned-message-banner-2.png");
|
||||||
|
|
||||||
|
await expect(util.getCloseListButton()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("banner should listen to pinned message list", async ({ page, app, room1, util }) => {
|
||||||
|
await util.goTo(room1);
|
||||||
|
await util.receiveMessages(room1, ["Msg1", "Msg2"]);
|
||||||
|
await util.pinMessages(["Msg1", "Msg2"]);
|
||||||
|
|
||||||
|
await expect(util.getViewAllButton()).toBeVisible();
|
||||||
|
|
||||||
|
await util.openRoomInfo();
|
||||||
|
await util.openPinnedMessagesList();
|
||||||
|
await expect(util.getCloseListButton()).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 4.2 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 4 KiB |
After Width: | Height: | Size: 4.3 KiB |
After Width: | Height: | Size: 4.3 KiB |
After Width: | Height: | Size: 4 KiB |
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 13 KiB |
|
@ -298,6 +298,7 @@
|
||||||
@import "./views/rooms/_NewRoomIntro.pcss";
|
@import "./views/rooms/_NewRoomIntro.pcss";
|
||||||
@import "./views/rooms/_NotificationBadge.pcss";
|
@import "./views/rooms/_NotificationBadge.pcss";
|
||||||
@import "./views/rooms/_PinnedEventTile.pcss";
|
@import "./views/rooms/_PinnedEventTile.pcss";
|
||||||
|
@import "./views/rooms/_PinnedMessageBanner.pcss";
|
||||||
@import "./views/rooms/_PresenceLabel.pcss";
|
@import "./views/rooms/_PresenceLabel.pcss";
|
||||||
@import "./views/rooms/_ReadReceiptGroup.pcss";
|
@import "./views/rooms/_ReadReceiptGroup.pcss";
|
||||||
@import "./views/rooms/_ReplyPreview.pcss";
|
@import "./views/rooms/_ReplyPreview.pcss";
|
||||||
|
|
119
res/css/views/rooms/_PinnedMessageBanner.pcss
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--cpd-space-4x);
|
||||||
|
/* 80px = 79px + 1px from the bottom border */
|
||||||
|
height: 79px;
|
||||||
|
padding: 0 var(--cpd-space-4x);
|
||||||
|
|
||||||
|
background-color: var(--cpd-color-bg-canvas-default);
|
||||||
|
border-bottom: 1px solid var(--cpd-color-gray-400);
|
||||||
|
|
||||||
|
/* From figma */
|
||||||
|
box-shadow: 0 var(--cpd-space-2x) var(--cpd-space-6x) calc(var(--cpd-space-2x) * -1) rgba(27, 29, 34, 0.1);
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_main {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
text-align: start;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_content {
|
||||||
|
display: grid;
|
||||||
|
grid-template:
|
||||||
|
"indicators pinIcon title" auto
|
||||||
|
"indicators pinIcon message" auto;
|
||||||
|
column-gap: var(--cpd-space-2x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_Indicators {
|
||||||
|
grid-area: indicators;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--cpd-space-0-5x);
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_Indicator {
|
||||||
|
width: var(--cpd-space-0-5x);
|
||||||
|
background-color: var(--cpd-color-gray-600);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_Indicator--active {
|
||||||
|
background-color: var(--cpd-color-icon-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_Indicator--hidden {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_PinIcon {
|
||||||
|
grid-area: pinIcon;
|
||||||
|
align-self: center;
|
||||||
|
fill: var(--cpd-color-icon-secondary-alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_title {
|
||||||
|
grid-area: title;
|
||||||
|
font: var(--cpd-font-body-sm-regular);
|
||||||
|
color: var(--cpd-color-text-action-accent);
|
||||||
|
height: 20px;
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_title_counter {
|
||||||
|
font: var(--cpd-font-body-sm-semibold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_message {
|
||||||
|
grid-area: message;
|
||||||
|
font: var(--cpd-font-body-sm-regular);
|
||||||
|
height: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_redactedMessage {
|
||||||
|
grid-area: message;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner[data-single-message="true"] {
|
||||||
|
/* 64px = 63px + 1px from the bottom border */
|
||||||
|
height: 63px;
|
||||||
|
|
||||||
|
.mx_PinnedMessageBanner_content {
|
||||||
|
grid-template: "pinIcon message" auto;
|
||||||
|
}
|
||||||
|
}
|
|
@ -133,6 +133,7 @@ import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoi
|
||||||
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
|
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
|
||||||
import { onView3pidInvite } from "../../stores/right-panel/action-handlers";
|
import { onView3pidInvite } from "../../stores/right-panel/action-handlers";
|
||||||
import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel";
|
import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel";
|
||||||
|
import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner";
|
||||||
|
|
||||||
const DEBUG = false;
|
const DEBUG = false;
|
||||||
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
|
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
|
||||||
|
@ -2409,6 +2410,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
</AuxPanel>
|
</AuxPanel>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isPinningEnabled = SettingsStore.getValue<boolean>("feature_pinning");
|
||||||
|
let pinnedMessageBanner;
|
||||||
|
if (isPinningEnabled) {
|
||||||
|
pinnedMessageBanner = (
|
||||||
|
<PinnedMessageBanner room={this.state.room} permalinkCreator={this.permalinkCreator} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let messageComposer;
|
let messageComposer;
|
||||||
const showComposer =
|
const showComposer =
|
||||||
// joined and not showing search results
|
// joined and not showing search results
|
||||||
|
@ -2537,6 +2546,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
<Measured sensor={this.roomViewBody.current} onMeasurement={this.onMeasurement} />
|
<Measured sensor={this.roomViewBody.current} onMeasurement={this.onMeasurement} />
|
||||||
)}
|
)}
|
||||||
{auxPanel}
|
{auxPanel}
|
||||||
|
{pinnedMessageBanner}
|
||||||
<main className={timelineClasses}>
|
<main className={timelineClasses}>
|
||||||
<FileDropTarget parent={this.roomView.current} onFileDrop={this.onFileDrop} />
|
<FileDropTarget parent={this.roomView.current} onFileDrop={this.onFileDrop} />
|
||||||
{topUnreadMessagesBar}
|
{topUnreadMessagesBar}
|
||||||
|
|
|
@ -35,7 +35,6 @@ import { RoomNotifState } from "../../../RoomNotifs";
|
||||||
import Modal from "../../../Modal";
|
import Modal from "../../../Modal";
|
||||||
import ExportDialog from "../dialogs/ExportDialog";
|
import ExportDialog from "../dialogs/ExportDialog";
|
||||||
import { useFeatureEnabled } from "../../../hooks/useSettings";
|
import { useFeatureEnabled } from "../../../hooks/useSettings";
|
||||||
import { usePinnedEvents } from "../right_panel/PinnedMessagesCard";
|
|
||||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||||
import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog";
|
import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog";
|
||||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||||
|
@ -53,6 +52,7 @@ import { UIComponent } from "../../../settings/UIFeature";
|
||||||
import { DeveloperToolsOption } from "./DeveloperToolsOption";
|
import { DeveloperToolsOption } from "./DeveloperToolsOption";
|
||||||
import { tagRoom } from "../../../utils/room/tagRoom";
|
import { tagRoom } from "../../../utils/room/tagRoom";
|
||||||
import { useIsVideoRoom } from "../../../utils/video-rooms";
|
import { useIsVideoRoom } from "../../../utils/video-rooms";
|
||||||
|
import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
|
||||||
|
|
||||||
interface IProps extends IContextMenuProps {
|
interface IProps extends IContextMenuProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
|
@ -28,7 +28,6 @@ import HeaderButtons, { HeaderKind } from "./HeaderButtons";
|
||||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||||
import { useReadPinnedEvents, usePinnedEvents } from "./PinnedMessagesCard";
|
|
||||||
import { showThreadPanel } from "../../../dispatcher/dispatch-actions/threads";
|
import { showThreadPanel } from "../../../dispatcher/dispatch-actions/threads";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import {
|
import {
|
||||||
|
@ -40,6 +39,7 @@ import { SummarizedNotificationState } from "../../../stores/notifications/Summa
|
||||||
import PosthogTrackers from "../../../PosthogTrackers";
|
import PosthogTrackers from "../../../PosthogTrackers";
|
||||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||||
import { doesRoomOrThreadHaveUnreadMessages } from "../../../Unread";
|
import { doesRoomOrThreadHaveUnreadMessages } from "../../../Unread";
|
||||||
|
import { usePinnedEvents, useReadPinnedEvents } from "../../../hooks/usePinnedEvents";
|
||||||
|
|
||||||
const ROOM_INFO_PHASES = [
|
const ROOM_INFO_PHASES = [
|
||||||
RightPanelPhases.RoomSummary,
|
RightPanelPhases.RoomSummary,
|
||||||
|
|
|
@ -14,17 +14,8 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useState, JSX } from "react";
|
import React, { useCallback, useEffect, JSX } from "react";
|
||||||
import {
|
import { Room, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
|
||||||
Room,
|
|
||||||
RoomEvent,
|
|
||||||
RoomStateEvent,
|
|
||||||
MatrixEvent,
|
|
||||||
EventType,
|
|
||||||
RelationType,
|
|
||||||
EventTimeline,
|
|
||||||
} from "matrix-js-sdk/src/matrix";
|
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
|
||||||
import { Button, Separator } from "@vector-im/compound-web";
|
import { Button, Separator } from "@vector-im/compound-web";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin";
|
import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin";
|
||||||
|
@ -33,9 +24,6 @@ import { _t } from "../../../languageHandler";
|
||||||
import BaseCard from "./BaseCard";
|
import BaseCard from "./BaseCard";
|
||||||
import Spinner from "../elements/Spinner";
|
import Spinner from "../elements/Spinner";
|
||||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
|
||||||
import PinningUtils from "../../../utils/PinningUtils";
|
|
||||||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
|
||||||
import { PinnedEventTile } from "../rooms/PinnedEventTile";
|
import { PinnedEventTile } from "../rooms/PinnedEventTile";
|
||||||
import { useRoomState } from "../../../hooks/useRoomState";
|
import { useRoomState } from "../../../hooks/useRoomState";
|
||||||
import RoomContext, { TimelineRenderingType, useRoomContext } from "../../../contexts/RoomContext";
|
import RoomContext, { TimelineRenderingType, useRoomContext } from "../../../contexts/RoomContext";
|
||||||
|
@ -46,155 +34,7 @@ import { filterBoolean } from "../../../utils/arrays";
|
||||||
import Modal from "../../../Modal";
|
import Modal from "../../../Modal";
|
||||||
import { UnpinAllDialog } from "../dialogs/UnpinAllDialog";
|
import { UnpinAllDialog } from "../dialogs/UnpinAllDialog";
|
||||||
import EmptyState from "./EmptyState";
|
import EmptyState from "./EmptyState";
|
||||||
|
import { useFetchedPinnedEvents, usePinnedEvents, useReadPinnedEvents } from "../../../hooks/usePinnedEvents";
|
||||||
/**
|
|
||||||
* Get the pinned event IDs from a room.
|
|
||||||
* @param room
|
|
||||||
*/
|
|
||||||
function getPinnedEventIds(room?: Room): string[] {
|
|
||||||
return (
|
|
||||||
room
|
|
||||||
?.getLiveTimeline()
|
|
||||||
.getState(EventTimeline.FORWARDS)
|
|
||||||
?.getStateEvents(EventType.RoomPinnedEvents, "")
|
|
||||||
?.getContent()?.pinned ?? []
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the pinned event IDs from a room.
|
|
||||||
* @param room
|
|
||||||
*/
|
|
||||||
export const usePinnedEvents = (room?: Room): string[] => {
|
|
||||||
const [pinnedEvents, setPinnedEvents] = useState<string[]>(getPinnedEventIds(room));
|
|
||||||
|
|
||||||
// Update the pinned events when the room state changes
|
|
||||||
// Filter out events that are not pinned events
|
|
||||||
const update = useCallback(
|
|
||||||
(ev?: MatrixEvent) => {
|
|
||||||
if (ev && ev.getType() !== EventType.RoomPinnedEvents) return;
|
|
||||||
setPinnedEvents(getPinnedEventIds(room));
|
|
||||||
},
|
|
||||||
[room],
|
|
||||||
);
|
|
||||||
|
|
||||||
useTypedEventEmitter(room?.getLiveTimeline().getState(EventTimeline.FORWARDS), RoomStateEvent.Events, update);
|
|
||||||
useEffect(() => {
|
|
||||||
setPinnedEvents(getPinnedEventIds(room));
|
|
||||||
return () => {
|
|
||||||
setPinnedEvents([]);
|
|
||||||
};
|
|
||||||
}, [room]);
|
|
||||||
return pinnedEvents;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the read pinned event IDs from a room.
|
|
||||||
* @param room
|
|
||||||
*/
|
|
||||||
function getReadPinnedEventIds(room?: Room): Set<string> {
|
|
||||||
return new Set(room?.getAccountData(ReadPinsEventId)?.getContent()?.event_ids ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the read pinned event IDs from a room.
|
|
||||||
* @param room
|
|
||||||
*/
|
|
||||||
export const useReadPinnedEvents = (room?: Room): Set<string> => {
|
|
||||||
const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Update the read pinned events when the room state changes
|
|
||||||
// Filter out events that are not read pinned events
|
|
||||||
const update = useCallback(
|
|
||||||
(ev?: MatrixEvent) => {
|
|
||||||
if (ev && ev.getType() !== ReadPinsEventId) return;
|
|
||||||
setReadPinnedEvents(getReadPinnedEventIds(room));
|
|
||||||
},
|
|
||||||
[room],
|
|
||||||
);
|
|
||||||
|
|
||||||
useTypedEventEmitter(room, RoomEvent.AccountData, update);
|
|
||||||
useEffect(() => {
|
|
||||||
setReadPinnedEvents(getReadPinnedEventIds(room));
|
|
||||||
return () => {
|
|
||||||
setReadPinnedEvents(new Set());
|
|
||||||
};
|
|
||||||
}, [room]);
|
|
||||||
return readPinnedEvents;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the pinned events
|
|
||||||
* @param room
|
|
||||||
* @param pinnedEventIds
|
|
||||||
*/
|
|
||||||
function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array<MatrixEvent | null> | null {
|
|
||||||
const cli = useMatrixClientContext();
|
|
||||||
|
|
||||||
return useAsyncMemo(
|
|
||||||
() => {
|
|
||||||
const promises = pinnedEventIds.map(async (eventId): Promise<MatrixEvent | null> => {
|
|
||||||
const timelineSet = room.getUnfilteredTimelineSet();
|
|
||||||
// Get the event from the local timeline
|
|
||||||
const localEvent = timelineSet
|
|
||||||
?.getTimelineForEvent(eventId)
|
|
||||||
?.getEvents()
|
|
||||||
.find((e) => e.getId() === eventId);
|
|
||||||
|
|
||||||
// Decrypt the event if it's encrypted
|
|
||||||
// Can happen when the tab is refreshed and the pinned events card is opened directly
|
|
||||||
if (localEvent?.isEncrypted()) {
|
|
||||||
await cli.decryptEventIfNeeded(localEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the event is available locally, return it if it's pinnable
|
|
||||||
// Otherwise, return null
|
|
||||||
if (localEvent) return PinningUtils.isPinnable(localEvent) ? localEvent : null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// The event is not available locally, so we fetch the event and latest edit in parallel
|
|
||||||
const [
|
|
||||||
evJson,
|
|
||||||
{
|
|
||||||
events: [edit],
|
|
||||||
},
|
|
||||||
] = await Promise.all([
|
|
||||||
cli.fetchRoomEvent(room.roomId, eventId),
|
|
||||||
cli.relations(room.roomId, eventId, RelationType.Replace, null, { limit: 1 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const event = new MatrixEvent(evJson);
|
|
||||||
|
|
||||||
// Decrypt the event if it's encrypted
|
|
||||||
if (event.isEncrypted()) {
|
|
||||||
await cli.decryptEventIfNeeded(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle poll events
|
|
||||||
await room.processPollEvents([event]);
|
|
||||||
|
|
||||||
const senderUserId = event.getSender();
|
|
||||||
if (senderUserId && PinningUtils.isPinnable(event)) {
|
|
||||||
// Inject sender information
|
|
||||||
event.sender = room.getMember(senderUserId);
|
|
||||||
// Also inject any edits we've found
|
|
||||||
if (edit) event.makeReplaced(edit);
|
|
||||||
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("Error looking up pinned event " + eventId + " in room " + room.roomId);
|
|
||||||
logger.error(err);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return Promise.all(promises);
|
|
||||||
},
|
|
||||||
[cli, room, pinnedEventIds],
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List the pinned messages in a room inside a Card.
|
* List the pinned messages in a room inside a Card.
|
||||||
|
|
|
@ -58,7 +58,6 @@ import { E2EStatus } from "../../../utils/ShieldUtils";
|
||||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||||
import { useFeatureEnabled } from "../../../hooks/useSettings";
|
import { useFeatureEnabled } from "../../../hooks/useSettings";
|
||||||
import { usePinnedEvents } from "./PinnedMessagesCard";
|
|
||||||
import RoomName from "../elements/RoomName";
|
import RoomName from "../elements/RoomName";
|
||||||
import ExportDialog from "../dialogs/ExportDialog";
|
import ExportDialog from "../dialogs/ExportDialog";
|
||||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||||
|
@ -81,6 +80,7 @@ import { Action } from "../../../dispatcher/actions";
|
||||||
import { Key } from "../../../Keyboard";
|
import { Key } from "../../../Keyboard";
|
||||||
import { useTransition } from "../../../hooks/useTransition";
|
import { useTransition } from "../../../hooks/useTransition";
|
||||||
import { useIsVideoRoom } from "../../../utils/video-rooms";
|
import { useIsVideoRoom } from "../../../utils/video-rooms";
|
||||||
|
import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
252
src/components/views/rooms/PinnedMessageBanner.tsx
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { JSX, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Icon as PinIcon } from "@vector-im/compound-design-tokens/icons/pin-solid.svg";
|
||||||
|
import { Button } from "@vector-im/compound-web";
|
||||||
|
import { Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { usePinnedEvents, useSortedFetchedPinnedEvents } from "../../../hooks/usePinnedEvents";
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||||
|
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||||
|
import { useEventEmitter } from "../../../hooks/useEventEmitter";
|
||||||
|
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||||
|
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||||
|
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
|
||||||
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
|
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
|
import { Action } from "../../../dispatcher/actions";
|
||||||
|
import MessageEvent from "../messages/MessageEvent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The props for the {@link PinnedMessageBanner} component.
|
||||||
|
*/
|
||||||
|
interface PinnedMessageBannerProps {
|
||||||
|
/**
|
||||||
|
* The permalink creator to use.
|
||||||
|
*/
|
||||||
|
permalinkCreator: RoomPermalinkCreator;
|
||||||
|
/**
|
||||||
|
* The room where the banner is displayed
|
||||||
|
*/
|
||||||
|
room: Room;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A banner that displays the pinned messages in a room.
|
||||||
|
*/
|
||||||
|
export function PinnedMessageBanner({ room, permalinkCreator }: PinnedMessageBannerProps): JSX.Element | null {
|
||||||
|
const pinnedEventIds = usePinnedEvents(room);
|
||||||
|
const pinnedEvents = useSortedFetchedPinnedEvents(room, pinnedEventIds);
|
||||||
|
const eventCount = pinnedEvents.length;
|
||||||
|
const isSinglePinnedEvent = eventCount === 1;
|
||||||
|
|
||||||
|
const [currentEventIndex, setCurrentEventIndex] = useState(eventCount - 1);
|
||||||
|
// If the list of pinned events changes, we need to make sure the current index isn't out of bound
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentEventIndex((currentEventIndex) => {
|
||||||
|
// If the current index is out of bound, we set it to the last index
|
||||||
|
if (currentEventIndex < 0 || currentEventIndex >= eventCount) return eventCount - 1;
|
||||||
|
return currentEventIndex;
|
||||||
|
});
|
||||||
|
}, [eventCount]);
|
||||||
|
|
||||||
|
const pinnedEvent = pinnedEvents[currentEventIndex];
|
||||||
|
// Generate a preview for the pinned event
|
||||||
|
const eventPreview = useMemo(() => {
|
||||||
|
if (!pinnedEvent || pinnedEvent.isRedacted() || pinnedEvent.isDecryptionFailure()) return null;
|
||||||
|
return MessagePreviewStore.instance.generatePreviewForEvent(pinnedEvent);
|
||||||
|
}, [pinnedEvent]);
|
||||||
|
|
||||||
|
if (!pinnedEvent) return null;
|
||||||
|
|
||||||
|
const shouldUseMessageEvent = pinnedEvent.isRedacted() || pinnedEvent.isDecryptionFailure();
|
||||||
|
|
||||||
|
const onBannerClick = (): void => {
|
||||||
|
// Scroll to the pinned message
|
||||||
|
dis.dispatch<ViewRoomPayload>({
|
||||||
|
action: Action.ViewRoom,
|
||||||
|
event_id: pinnedEvent.getId(),
|
||||||
|
highlighted: true,
|
||||||
|
room_id: room.roomId,
|
||||||
|
metricsTrigger: undefined, // room doesn't change
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cycle through the pinned messages
|
||||||
|
// When we reach the first message, we go back to the last message
|
||||||
|
setCurrentEventIndex((currentEventIndex) => (--currentEventIndex === -1 ? eventCount - 1 : currentEventIndex));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mx_PinnedMessageBanner"
|
||||||
|
data-single-message={isSinglePinnedEvent}
|
||||||
|
aria-label={_t("room|pinned_message_banner|description")}
|
||||||
|
data-testid="pinned-message-banner"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label={_t("room|pinned_message_banner|go_to_message")}
|
||||||
|
type="button"
|
||||||
|
className="mx_PinnedMessageBanner_main"
|
||||||
|
onClick={onBannerClick}
|
||||||
|
>
|
||||||
|
<div className="mx_PinnedMessageBanner_content">
|
||||||
|
{!isSinglePinnedEvent && <Indicators count={eventCount} currentIndex={currentEventIndex} />}
|
||||||
|
<PinIcon width="20" className="mx_PinnedMessageBanner_PinIcon" />
|
||||||
|
{!isSinglePinnedEvent && (
|
||||||
|
<div className="mx_PinnedMessageBanner_title" data-testid="banner-counter">
|
||||||
|
{_t(
|
||||||
|
"room|pinned_message_banner|title",
|
||||||
|
{
|
||||||
|
index: currentEventIndex + 1,
|
||||||
|
length: eventCount,
|
||||||
|
},
|
||||||
|
{ bold: (sub) => <span className="mx_PinnedMessageBanner_title_counter">{sub}</span> },
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{eventPreview && <span className="mx_PinnedMessageBanner_message">{eventPreview}</span>}
|
||||||
|
{/* In case of redacted event, we want to display the nice sentence of the message event like in the timeline or in the pinned message list */}
|
||||||
|
{shouldUseMessageEvent && (
|
||||||
|
<div className="mx_PinnedMessageBanner_redactedMessage">
|
||||||
|
<MessageEvent
|
||||||
|
mxEvent={pinnedEvent}
|
||||||
|
maxImageHeight={20}
|
||||||
|
permalinkCreator={permalinkCreator}
|
||||||
|
replacingEventId={pinnedEvent.replacingEventId()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{!isSinglePinnedEvent && <BannerButton room={room} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_INDICATORS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The props for the {@link IndicatorsProps} component.
|
||||||
|
*/
|
||||||
|
interface IndicatorsProps {
|
||||||
|
/**
|
||||||
|
* The number of messages pinned
|
||||||
|
*/
|
||||||
|
count: number;
|
||||||
|
/**
|
||||||
|
* The current index of the pinned message
|
||||||
|
*/
|
||||||
|
currentIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that displays vertical indicators for the pinned messages.
|
||||||
|
*/
|
||||||
|
function Indicators({ count, currentIndex }: IndicatorsProps): JSX.Element {
|
||||||
|
// We only display a maximum of 3 indicators at one time.
|
||||||
|
// When there is more than 3 messages pinned, we will cycle through the indicators
|
||||||
|
|
||||||
|
// If there is only 2 messages pinned, we will display 2 indicators
|
||||||
|
// In case of 1 message pinned, the indicators are not displayed, see {@link PinnedMessageBanner} logic.
|
||||||
|
const numberOfIndicators = Math.min(count, MAX_INDICATORS);
|
||||||
|
// The index of the active indicator
|
||||||
|
const index = currentIndex % numberOfIndicators;
|
||||||
|
|
||||||
|
// We hide the indicators when we are on the last cycle and there are less than 3 remaining messages pinned
|
||||||
|
const numberOfCycles = Math.ceil(count / numberOfIndicators);
|
||||||
|
// If the current index is greater than the last cycle index, we are on the last cycle
|
||||||
|
const isLastCycle = currentIndex >= (numberOfCycles - 1) * MAX_INDICATORS;
|
||||||
|
// The index of the last message in the last cycle
|
||||||
|
const lastCycleIndex = numberOfIndicators - (numberOfCycles * numberOfIndicators - count);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx_PinnedMessageBanner_Indicators">
|
||||||
|
{Array.from({ length: numberOfIndicators }).map((_, i) => (
|
||||||
|
<Indicator key={i} active={i === index} hidden={isLastCycle && lastCycleIndex <= i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The props for the {@link Indicator} component.
|
||||||
|
*/
|
||||||
|
interface IndicatorProps {
|
||||||
|
/**
|
||||||
|
* Whether the indicator is active
|
||||||
|
*/
|
||||||
|
active: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the indicator is hidden
|
||||||
|
*/
|
||||||
|
hidden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that displays a vertical indicator for a pinned message.
|
||||||
|
*/
|
||||||
|
function Indicator({ active, hidden }: IndicatorProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="banner-indicator"
|
||||||
|
className={classNames("mx_PinnedMessageBanner_Indicator", {
|
||||||
|
"mx_PinnedMessageBanner_Indicator--active": active,
|
||||||
|
"mx_PinnedMessageBanner_Indicator--hidden": hidden,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRightPanelPhase(roomId: string): RightPanelPhases | null {
|
||||||
|
if (!RightPanelStore.instance.isOpenForRoom(roomId)) return null;
|
||||||
|
return RightPanelStore.instance.currentCard.phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The props for the {@link BannerButton} component.
|
||||||
|
*/
|
||||||
|
interface BannerButtonProps {
|
||||||
|
/**
|
||||||
|
* The room where the banner is displayed
|
||||||
|
*/
|
||||||
|
room: Room;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A button that allows the user to view or close the list of pinned messages.
|
||||||
|
*/
|
||||||
|
function BannerButton({ room }: BannerButtonProps): JSX.Element {
|
||||||
|
const [currentPhase, setCurrentPhase] = useState<RightPanelPhases | null>(getRightPanelPhase(room.roomId));
|
||||||
|
useEventEmitter(RightPanelStore.instance, UPDATE_EVENT, () => setCurrentPhase(getRightPanelPhase(room.roomId)));
|
||||||
|
const isPinnedMessagesPhase = currentPhase === RightPanelPhases.PinnedMessages;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="mx_PinnedMessageBanner_actions"
|
||||||
|
kind="tertiary"
|
||||||
|
onClick={() => {
|
||||||
|
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.PinnedMessages);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPinnedMessagesPhase
|
||||||
|
? _t("room|pinned_message_banner|button_close_list")
|
||||||
|
: _t("room|pinned_message_banner|button_view_all")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
212
src/hooks/usePinnedEvents.ts
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomStateEvent,
|
||||||
|
MatrixEvent,
|
||||||
|
EventType,
|
||||||
|
RelationType,
|
||||||
|
EventTimeline,
|
||||||
|
MatrixClient,
|
||||||
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
|
import { useTypedEventEmitter } from "./useEventEmitter";
|
||||||
|
import { ReadPinsEventId } from "../components/views/right_panel/types";
|
||||||
|
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
|
||||||
|
import { useAsyncMemo } from "./useAsyncMemo";
|
||||||
|
import PinningUtils from "../utils/PinningUtils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the pinned event IDs from a room.
|
||||||
|
* @param room
|
||||||
|
*/
|
||||||
|
function getPinnedEventIds(room?: Room): string[] {
|
||||||
|
return (
|
||||||
|
room
|
||||||
|
?.getLiveTimeline()
|
||||||
|
.getState(EventTimeline.FORWARDS)
|
||||||
|
?.getStateEvents(EventType.RoomPinnedEvents, "")
|
||||||
|
?.getContent()?.pinned ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the pinned event IDs from a room.
|
||||||
|
* @param room
|
||||||
|
*/
|
||||||
|
export const usePinnedEvents = (room?: Room): string[] => {
|
||||||
|
const [pinnedEvents, setPinnedEvents] = useState<string[]>(getPinnedEventIds(room));
|
||||||
|
|
||||||
|
// Update the pinned events when the room state changes
|
||||||
|
// Filter out events that are not pinned events
|
||||||
|
const update = useCallback(
|
||||||
|
(ev?: MatrixEvent) => {
|
||||||
|
if (ev && ev.getType() !== EventType.RoomPinnedEvents) return;
|
||||||
|
setPinnedEvents(getPinnedEventIds(room));
|
||||||
|
},
|
||||||
|
[room],
|
||||||
|
);
|
||||||
|
|
||||||
|
useTypedEventEmitter(room?.getLiveTimeline().getState(EventTimeline.FORWARDS), RoomStateEvent.Events, update);
|
||||||
|
useEffect(() => {
|
||||||
|
setPinnedEvents(getPinnedEventIds(room));
|
||||||
|
return () => {
|
||||||
|
setPinnedEvents([]);
|
||||||
|
};
|
||||||
|
}, [room]);
|
||||||
|
return pinnedEvents;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the read pinned event IDs from a room.
|
||||||
|
* @param room
|
||||||
|
*/
|
||||||
|
function getReadPinnedEventIds(room?: Room): Set<string> {
|
||||||
|
return new Set(room?.getAccountData(ReadPinsEventId)?.getContent()?.event_ids ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the read pinned event IDs from a room.
|
||||||
|
* @param room
|
||||||
|
*/
|
||||||
|
export const useReadPinnedEvents = (room?: Room): Set<string> => {
|
||||||
|
const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Update the read pinned events when the room state changes
|
||||||
|
// Filter out events that are not read pinned events
|
||||||
|
const update = useCallback(
|
||||||
|
(ev?: MatrixEvent) => {
|
||||||
|
if (ev && ev.getType() !== ReadPinsEventId) return;
|
||||||
|
setReadPinnedEvents(getReadPinnedEventIds(room));
|
||||||
|
},
|
||||||
|
[room],
|
||||||
|
);
|
||||||
|
|
||||||
|
useTypedEventEmitter(room, RoomEvent.AccountData, update);
|
||||||
|
useEffect(() => {
|
||||||
|
setReadPinnedEvents(getReadPinnedEventIds(room));
|
||||||
|
return () => {
|
||||||
|
setReadPinnedEvents(new Set());
|
||||||
|
};
|
||||||
|
}, [room]);
|
||||||
|
return readPinnedEvents;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the pinned event
|
||||||
|
* @param room
|
||||||
|
* @param pinnedEventId
|
||||||
|
* @param cli
|
||||||
|
*/
|
||||||
|
async function fetchPinnedEvent(room: Room, pinnedEventId: string, cli: MatrixClient): Promise<MatrixEvent | null> {
|
||||||
|
const timelineSet = room.getUnfilteredTimelineSet();
|
||||||
|
// Get the event from the local timeline
|
||||||
|
const localEvent = timelineSet
|
||||||
|
?.getTimelineForEvent(pinnedEventId)
|
||||||
|
?.getEvents()
|
||||||
|
.find((e) => e.getId() === pinnedEventId);
|
||||||
|
|
||||||
|
// Decrypt the event if it's encrypted
|
||||||
|
// Can happen when the tab is refreshed and the pinned events card is opened directly
|
||||||
|
if (localEvent?.isEncrypted()) {
|
||||||
|
await cli.decryptEventIfNeeded(localEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the event is available locally, return it if it's pinnable
|
||||||
|
// or if it's redacted (to show the redacted event and to be able to unpin it)
|
||||||
|
// Otherwise, return null
|
||||||
|
if (localEvent) return PinningUtils.isUnpinnable(localEvent) ? localEvent : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The event is not available locally, so we fetch the event and latest edit in parallel
|
||||||
|
const [
|
||||||
|
evJson,
|
||||||
|
{
|
||||||
|
events: [edit],
|
||||||
|
},
|
||||||
|
] = await Promise.all([
|
||||||
|
cli.fetchRoomEvent(room.roomId, pinnedEventId),
|
||||||
|
cli.relations(room.roomId, pinnedEventId, RelationType.Replace, null, { limit: 1 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const event = new MatrixEvent(evJson);
|
||||||
|
|
||||||
|
// Decrypt the event if it's encrypted
|
||||||
|
if (event.isEncrypted()) {
|
||||||
|
await cli.decryptEventIfNeeded(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle poll events
|
||||||
|
await room.processPollEvents([event]);
|
||||||
|
|
||||||
|
const senderUserId = event.getSender();
|
||||||
|
if (senderUserId && PinningUtils.isUnpinnable(event)) {
|
||||||
|
// Inject sender information
|
||||||
|
event.sender = room.getMember(senderUserId);
|
||||||
|
// Also inject any edits we've found
|
||||||
|
if (edit) event.makeReplaced(edit);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Error looking up pinned event ${pinnedEventId} in room ${room.roomId}`);
|
||||||
|
logger.error(err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the pinned events
|
||||||
|
* @param room
|
||||||
|
* @param pinnedEventIds
|
||||||
|
*/
|
||||||
|
export function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array<MatrixEvent | null> | null {
|
||||||
|
const cli = useMatrixClientContext();
|
||||||
|
|
||||||
|
return useAsyncMemo(
|
||||||
|
() =>
|
||||||
|
Promise.all(
|
||||||
|
pinnedEventIds.map(
|
||||||
|
async (eventId): Promise<MatrixEvent | null> => fetchPinnedEvent(room, eventId, cli),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[cli, room, pinnedEventIds],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the pinned events and sort them by from the oldest to the newest
|
||||||
|
* The order is determined by the event timestamp
|
||||||
|
* @param room
|
||||||
|
* @param pinnedEventIds
|
||||||
|
*/
|
||||||
|
export function useSortedFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array<MatrixEvent | null> {
|
||||||
|
const pinnedEvents = useFetchedPinnedEvents(room, pinnedEventIds);
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!pinnedEvents) return [];
|
||||||
|
|
||||||
|
return pinnedEvents.sort((a, b) => {
|
||||||
|
if (!a) return -1;
|
||||||
|
if (!b) return 1;
|
||||||
|
return a.getTs() - b.getTs();
|
||||||
|
});
|
||||||
|
}, [pinnedEvents]);
|
||||||
|
}
|
|
@ -2048,6 +2048,13 @@
|
||||||
"not_found_title": "This room or space does not exist.",
|
"not_found_title": "This room or space does not exist.",
|
||||||
"not_found_title_name": "%(roomName)s does not exist.",
|
"not_found_title_name": "%(roomName)s does not exist.",
|
||||||
"peek_join_prompt": "You're previewing %(roomName)s. Want to join it?",
|
"peek_join_prompt": "You're previewing %(roomName)s. Want to join it?",
|
||||||
|
"pinned_message_banner": {
|
||||||
|
"button_close_list": "Close list",
|
||||||
|
"button_view_all": "View all",
|
||||||
|
"description": "This room has pinned messages. Click to view them.",
|
||||||
|
"go_to_message": "View the pinned message in the timeline.",
|
||||||
|
"title": "<bold>%(index)s of %(length)s</bold> Pinned messages"
|
||||||
|
},
|
||||||
"read_topic": "Click to read topic",
|
"read_topic": "Click to read topic",
|
||||||
"rejecting": "Rejecting invite…",
|
"rejecting": "Rejecting invite…",
|
||||||
"rejoin_button": "Re-join",
|
"rejoin_button": "Re-join",
|
||||||
|
|
|
@ -37,11 +37,19 @@ export default class PinningUtils {
|
||||||
* @return {boolean} True if the event may be pinned, false otherwise.
|
* @return {boolean} True if the event may be pinned, false otherwise.
|
||||||
*/
|
*/
|
||||||
public static isPinnable(event: MatrixEvent): boolean {
|
public static isPinnable(event: MatrixEvent): boolean {
|
||||||
if (!event) return false;
|
|
||||||
if (!this.PINNABLE_EVENT_TYPES.includes(event.getType())) return false;
|
|
||||||
if (event.isRedacted()) return false;
|
if (event.isRedacted()) return false;
|
||||||
|
return PinningUtils.isUnpinnable(event);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
/**
|
||||||
|
* Determines if the given event may be unpinned.
|
||||||
|
* @param {MatrixEvent} event The event to check.
|
||||||
|
* @return {boolean} True if the event may be unpinned, false otherwise.
|
||||||
|
*/
|
||||||
|
public static isUnpinnable(event: MatrixEvent): boolean {
|
||||||
|
if (!event) return false;
|
||||||
|
if (event.isRedacted()) return true;
|
||||||
|
return this.PINNABLE_EVENT_TYPES.includes(event.getType());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -20,7 +20,6 @@ import { mocked, MockedObject } from "jest-mock";
|
||||||
import {
|
import {
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
RoomStateEvent,
|
RoomStateEvent,
|
||||||
IEvent,
|
|
||||||
Room,
|
Room,
|
||||||
IMinimalEvent,
|
IMinimalEvent,
|
||||||
EventType,
|
EventType,
|
||||||
|
@ -266,9 +265,8 @@ describe("<PinnedMessagesCard />", () => {
|
||||||
// Redacted messages are unpinnable
|
// Redacted messages are unpinnable
|
||||||
const pin = mkEvent({
|
const pin = mkEvent({
|
||||||
event: true,
|
event: true,
|
||||||
type: EventType.RoomMessage,
|
type: EventType.RoomCreate,
|
||||||
content: {},
|
content: {},
|
||||||
unsigned: { redacted_because: {} as unknown as IEvent },
|
|
||||||
room: "!room:example.org",
|
room: "!room:example.org",
|
||||||
user: "@alice:example.org",
|
user: "@alice:example.org",
|
||||||
});
|
});
|
||||||
|
@ -280,9 +278,8 @@ describe("<PinnedMessagesCard />", () => {
|
||||||
// Redacted messages are unpinnable
|
// Redacted messages are unpinnable
|
||||||
const pin = mkEvent({
|
const pin = mkEvent({
|
||||||
event: true,
|
event: true,
|
||||||
type: EventType.RoomMessage,
|
type: EventType.RoomCreate,
|
||||||
content: {},
|
content: {},
|
||||||
unsigned: { redacted_because: {} as unknown as IEvent },
|
|
||||||
room: "!room:example.org",
|
room: "!room:example.org",
|
||||||
user: "@alice:example.org",
|
user: "@alice:example.org",
|
||||||
});
|
});
|
||||||
|
|
235
test/components/views/rooms/PinnedMessageBanner-test.tsx
Normal file
|
@ -0,0 +1,235 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { act, screen, render } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
import { EventType, IEvent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
import * as pinnedEventHooks from "../../../../src/hooks/usePinnedEvents";
|
||||||
|
import { PinnedMessageBanner } from "../../../../src/components/views/rooms/PinnedMessageBanner";
|
||||||
|
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
|
||||||
|
import { stubClient } from "../../../test-utils";
|
||||||
|
import dis from "../../../../src/dispatcher/dispatcher";
|
||||||
|
import RightPanelStore from "../../../../src/stores/right-panel/RightPanelStore";
|
||||||
|
import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases";
|
||||||
|
import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore";
|
||||||
|
import { Action } from "../../../../src/dispatcher/actions";
|
||||||
|
|
||||||
|
describe("<PinnedMessageBanner />", () => {
|
||||||
|
const userId = "@alice:server.org";
|
||||||
|
const roomId = "!room:server.org";
|
||||||
|
|
||||||
|
let mockClient: MatrixClient;
|
||||||
|
let room: Room;
|
||||||
|
let permalinkCreator: RoomPermalinkCreator;
|
||||||
|
beforeEach(() => {
|
||||||
|
mockClient = stubClient();
|
||||||
|
room = new Room(roomId, mockClient, userId);
|
||||||
|
permalinkCreator = new RoomPermalinkCreator(room);
|
||||||
|
jest.spyOn(dis, "dispatch").mockReturnValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a pinned event with the given content.
|
||||||
|
* @param content
|
||||||
|
*/
|
||||||
|
function makePinEvent(content?: Partial<IEvent>) {
|
||||||
|
return new MatrixEvent({
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
sender: userId,
|
||||||
|
content: {
|
||||||
|
body: "First pinned message",
|
||||||
|
msgtype: "m.text",
|
||||||
|
},
|
||||||
|
room_id: roomId,
|
||||||
|
origin_server_ts: 0,
|
||||||
|
event_id: "$eventId",
|
||||||
|
...content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const event1 = makePinEvent();
|
||||||
|
const event2 = makePinEvent({
|
||||||
|
event_id: "$eventId2",
|
||||||
|
content: { body: "Second pinned message" },
|
||||||
|
});
|
||||||
|
const event3 = makePinEvent({
|
||||||
|
event_id: "$eventId3",
|
||||||
|
content: { body: "Third pinned message" },
|
||||||
|
});
|
||||||
|
const event4 = makePinEvent({
|
||||||
|
event_id: "$eventId4",
|
||||||
|
content: { body: "Fourth pinned message" },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the banner
|
||||||
|
*/
|
||||||
|
function renderBanner() {
|
||||||
|
return render(<PinnedMessageBanner permalinkCreator={permalinkCreator} room={room} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should render nothing when there are no pinned events", async () => {
|
||||||
|
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([]);
|
||||||
|
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([]);
|
||||||
|
const { container } = renderBanner();
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render a single pinned event", async () => {
|
||||||
|
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!]);
|
||||||
|
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1]);
|
||||||
|
|
||||||
|
const { asFragment } = renderBanner();
|
||||||
|
|
||||||
|
expect(screen.getByText("First pinned message")).toBeVisible();
|
||||||
|
expect(screen.queryByRole("button", { name: "View all" })).toBeNull();
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render 2 pinned event", async () => {
|
||||||
|
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]);
|
||||||
|
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]);
|
||||||
|
|
||||||
|
const { asFragment } = renderBanner();
|
||||||
|
|
||||||
|
expect(screen.getByText("Second pinned message")).toBeVisible();
|
||||||
|
expect(screen.getByTestId("banner-counter")).toHaveTextContent("2 of 2 Pinned messages");
|
||||||
|
expect(screen.getAllByTestId("banner-indicator")).toHaveLength(2);
|
||||||
|
expect(screen.queryByRole("button", { name: "View all" })).toBeVisible();
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render 4 pinned event", async () => {
|
||||||
|
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([
|
||||||
|
event1.getId()!,
|
||||||
|
event2.getId()!,
|
||||||
|
event3.getId()!,
|
||||||
|
event4.getId()!,
|
||||||
|
]);
|
||||||
|
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2, event3, event4]);
|
||||||
|
|
||||||
|
const { asFragment } = renderBanner();
|
||||||
|
|
||||||
|
expect(screen.getByText("Fourth pinned message")).toBeVisible();
|
||||||
|
expect(screen.getByTestId("banner-counter")).toHaveTextContent("4 of 4 Pinned messages");
|
||||||
|
expect(screen.getAllByTestId("banner-indicator")).toHaveLength(3);
|
||||||
|
expect(screen.queryByRole("button", { name: "View all" })).toBeVisible();
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rotate the pinned events when the banner is clicked", async () => {
|
||||||
|
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]);
|
||||||
|
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]);
|
||||||
|
|
||||||
|
renderBanner();
|
||||||
|
expect(screen.getByText("Second pinned message")).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." }));
|
||||||
|
expect(screen.getByText("First pinned message")).toBeVisible();
|
||||||
|
expect(screen.getByTestId("banner-counter")).toHaveTextContent("1 of 2 Pinned messages");
|
||||||
|
expect(dis.dispatch).toHaveBeenCalledWith({
|
||||||
|
action: Action.ViewRoom,
|
||||||
|
event_id: event2.getId(),
|
||||||
|
highlighted: true,
|
||||||
|
room_id: room.roomId,
|
||||||
|
metricsTrigger: undefined, // room doesn't change
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." }));
|
||||||
|
expect(screen.getByText("Second pinned message")).toBeVisible();
|
||||||
|
expect(screen.getByTestId("banner-counter")).toHaveTextContent("2 of 2 Pinned messages");
|
||||||
|
expect(dis.dispatch).toHaveBeenCalledWith({
|
||||||
|
action: Action.ViewRoom,
|
||||||
|
event_id: event1.getId(),
|
||||||
|
highlighted: true,
|
||||||
|
room_id: room.roomId,
|
||||||
|
metricsTrigger: undefined, // room doesn't change
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Right button", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]);
|
||||||
|
jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display View all button if the right panel is closed", async () => {
|
||||||
|
// The Right panel is closed
|
||||||
|
jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(false);
|
||||||
|
|
||||||
|
renderBanner();
|
||||||
|
expect(screen.getByRole("button", { name: "View all" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display View all button if the right panel is not opened on the pinned message list", async () => {
|
||||||
|
// The Right panel is opened on another card
|
||||||
|
jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true);
|
||||||
|
jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({
|
||||||
|
phase: RightPanelPhases.RoomMemberList,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderBanner();
|
||||||
|
expect(screen.getByRole("button", { name: "View all" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display Close list button if the message pinning list is displayed", async () => {
|
||||||
|
// The Right panel is closed
|
||||||
|
jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true);
|
||||||
|
jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({
|
||||||
|
phase: RightPanelPhases.PinnedMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderBanner();
|
||||||
|
expect(screen.getByRole("button", { name: "Close list" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should open or close the message pinning list", async () => {
|
||||||
|
// The Right panel is closed
|
||||||
|
jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true);
|
||||||
|
jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({
|
||||||
|
phase: RightPanelPhases.PinnedMessages,
|
||||||
|
});
|
||||||
|
jest.spyOn(RightPanelStore.instance, "showOrHidePhase").mockReturnValue();
|
||||||
|
|
||||||
|
renderBanner();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Close list" }));
|
||||||
|
expect(RightPanelStore.instance.showOrHidePhase).toHaveBeenCalledWith(RightPanelPhases.PinnedMessages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should listen to the right panel", async () => {
|
||||||
|
// The Right panel is closed
|
||||||
|
jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true);
|
||||||
|
jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({
|
||||||
|
phase: RightPanelPhases.PinnedMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderBanner();
|
||||||
|
expect(screen.getByRole("button", { name: "Close list" })).toBeVisible();
|
||||||
|
|
||||||
|
jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(false);
|
||||||
|
act(() => {
|
||||||
|
RightPanelStore.instance.emit(UPDATE_EVENT);
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "View all" })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,166 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`<PinnedMessageBanner /> should render 2 pinned event 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
aria-label="This room has pinned messages. Click to view them."
|
||||||
|
class="mx_PinnedMessageBanner"
|
||||||
|
data-single-message="false"
|
||||||
|
data-testid="pinned-message-banner"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="View the pinned message in the timeline."
|
||||||
|
class="mx_PinnedMessageBanner_main"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_Indicators"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_Indicator"
|
||||||
|
data-testid="banner-indicator"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
|
||||||
|
data-testid="banner-indicator"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_PinIcon"
|
||||||
|
width="20"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_title"
|
||||||
|
data-testid="banner-counter"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="mx_PinnedMessageBanner_title_counter"
|
||||||
|
>
|
||||||
|
2 of 2
|
||||||
|
</span>
|
||||||
|
Pinned messages
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="mx_PinnedMessageBanner_message"
|
||||||
|
>
|
||||||
|
Second pinned message
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="_button_zt6rp_17 mx_PinnedMessageBanner_actions"
|
||||||
|
data-kind="tertiary"
|
||||||
|
data-size="lg"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<PinnedMessageBanner /> should render 4 pinned event 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
aria-label="This room has pinned messages. Click to view them."
|
||||||
|
class="mx_PinnedMessageBanner"
|
||||||
|
data-single-message="false"
|
||||||
|
data-testid="pinned-message-banner"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="View the pinned message in the timeline."
|
||||||
|
class="mx_PinnedMessageBanner_main"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_Indicators"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--active"
|
||||||
|
data-testid="banner-indicator"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--hidden"
|
||||||
|
data-testid="banner-indicator"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_Indicator mx_PinnedMessageBanner_Indicator--hidden"
|
||||||
|
data-testid="banner-indicator"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_PinIcon"
|
||||||
|
width="20"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_title"
|
||||||
|
data-testid="banner-counter"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="mx_PinnedMessageBanner_title_counter"
|
||||||
|
>
|
||||||
|
4 of 4
|
||||||
|
</span>
|
||||||
|
Pinned messages
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="mx_PinnedMessageBanner_message"
|
||||||
|
>
|
||||||
|
Fourth pinned message
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="_button_zt6rp_17 mx_PinnedMessageBanner_actions"
|
||||||
|
data-kind="tertiary"
|
||||||
|
data-size="lg"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<PinnedMessageBanner /> should render a single pinned event 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
aria-label="This room has pinned messages. Click to view them."
|
||||||
|
class="mx_PinnedMessageBanner"
|
||||||
|
data-single-message="true"
|
||||||
|
data-testid="pinned-message-banner"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="View the pinned message in the timeline."
|
||||||
|
class="mx_PinnedMessageBanner_main"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessageBanner_PinIcon"
|
||||||
|
width="20"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="mx_PinnedMessageBanner_message"
|
||||||
|
>
|
||||||
|
First pinned message
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
|
@ -73,15 +73,27 @@ describe("PinningUtils", () => {
|
||||||
).mockReturnValue(true);
|
).mockReturnValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isPinnable", () => {
|
describe("isUnpinnable", () => {
|
||||||
test.each(PinningUtils.PINNABLE_EVENT_TYPES)("should return true for pinnable event types", (eventType) => {
|
test.each(PinningUtils.PINNABLE_EVENT_TYPES)("should return true for pinnable event types", (eventType) => {
|
||||||
const event = makePinEvent({ type: eventType });
|
const event = makePinEvent({ type: eventType });
|
||||||
expect(PinningUtils.isPinnable(event)).toBe(true);
|
expect(PinningUtils.isUnpinnable(event)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return false for a non pinnable event type", () => {
|
test("should return false for a non pinnable event type", () => {
|
||||||
const event = makePinEvent({ type: EventType.RoomCreate });
|
const event = makePinEvent({ type: EventType.RoomCreate });
|
||||||
expect(PinningUtils.isPinnable(event)).toBe(false);
|
expect(PinningUtils.isUnpinnable(event)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return true for a redacted event", () => {
|
||||||
|
const event = makePinEvent({ unsigned: { redacted_because: "because" as unknown as IEvent } });
|
||||||
|
expect(PinningUtils.isUnpinnable(event)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isPinnable", () => {
|
||||||
|
test.each(PinningUtils.PINNABLE_EVENT_TYPES)("should return true for pinnable event types", (eventType) => {
|
||||||
|
const event = makePinEvent({ type: eventType });
|
||||||
|
expect(PinningUtils.isPinnable(event)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return false for a redacted event", () => {
|
test("should return false for a redacted event", () => {
|
||||||
|
|