diff --git a/playwright/e2e/pinned-messages/index.ts b/playwright/e2e/pinned-messages/index.ts index 61e09a8111..ac50b62294 100644 --- a/playwright/e2e/pinned-messages/index.ts +++ b/playwright/e2e/pinned-messages/index.ts @@ -91,6 +91,14 @@ export class Helpers { await this.app.viewRoomByName(typeof room === "string" ? room : room.name); } + /** + * Get the timeline tile for the given message + * @param message + */ + getEventTile(message: string) { + return this.page.locator(".mx_EventTile", { hasText: message }); + } + /** * Pin the given message from the quick actions * @param message diff --git a/playwright/e2e/pinned-messages/pinned-messages.spec.ts b/playwright/e2e/pinned-messages/pinned-messages.spec.ts index f9d2abbb08..9f6f38f177 100644 --- a/playwright/e2e/pinned-messages/pinned-messages.spec.ts +++ b/playwright/e2e/pinned-messages/pinned-messages.spec.ts @@ -18,6 +18,22 @@ test.describe("Pinned messages", () => { await util.assertEmptyPinnedMessagesList(); }); + test("should pin one message and to have the pinned message badge in the timeline", async ({ + page, + app, + room1, + util, + }) => { + await util.goTo(room1); + await util.receiveMessages(room1, ["Msg1"]); + await util.pinMessages(["Msg1"]); + + const tile = util.getEventTile("Msg1"); + await expect(tile).toMatchScreenshot("pinned-message-Msg1.png", { + mask: [tile.locator(".mx_MessageTimestamp")], + }); + }); + test("should pin messages and show them in the room info panel", async ({ page, app, room1, util }) => { await util.goTo(room1); await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]); diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png new file mode 100644 index 0000000000..c195186db8 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 78e0524da6..d62c63572a 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -249,6 +249,7 @@ @import "./views/messages/_MessageActionBar.pcss"; @import "./views/messages/_MessageTimestamp.pcss"; @import "./views/messages/_MjolnirBody.pcss"; +@import "./views/messages/_PinnedMessageBadge.pcss"; @import "./views/messages/_ReactionsRow.pcss"; @import "./views/messages/_ReactionsRowButton.pcss"; @import "./views/messages/_RedactedBody.pcss"; diff --git a/res/css/views/messages/_PinnedMessageBadge.pcss b/res/css/views/messages/_PinnedMessageBadge.pcss new file mode 100644 index 0000000000..99770a7037 --- /dev/null +++ b/res/css/views/messages/_PinnedMessageBadge.pcss @@ -0,0 +1,26 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only + * Please see LICENSE files in the repository root for full details. + * + */ + +.mx_PinnedMessageBadge { + position: relative; + display: flex; + align-items: center; + gap: var(--cpd-space-1x); + + padding: var(--cpd-space-1x) var(--cpd-space-3x) var(--cpd-space-1x) var(--cpd-space-1x); + font: var(--cpd-font-body-xs-medium); + background-color: var(--cpd-color-alpha-gray-200); + color: var(--cpd-color-text-secondary); + + border-radius: 99px; + border: 1px solid var(--cpd-color-alpha-gray-400); + + svg { + fill: var(--cpd-color-icon-secondary); + } +} diff --git a/res/css/views/messages/_ReactionsRow.pcss b/res/css/views/messages/_ReactionsRow.pcss index 3a820cfdc7..e07b529ef4 100644 --- a/res/css/views/messages/_ReactionsRow.pcss +++ b/res/css/views/messages/_ReactionsRow.pcss @@ -6,7 +6,6 @@ Please see LICENSE files in the repository root for full details. */ .mx_ReactionsRow { - margin: 6px 0; color: var(--cpd-color-text-primary); .mx_ReactionsRow_addReactionButton { diff --git a/res/css/views/rooms/_EventBubbleTile.pcss b/res/css/views/rooms/_EventBubbleTile.pcss index ec443c44de..3a42cde9bb 100644 --- a/res/css/views/rooms/_EventBubbleTile.pcss +++ b/res/css/views/rooms/_EventBubbleTile.pcss @@ -172,7 +172,8 @@ Please see LICENSE files in the repository root for full details. border-color: $quinary-content; } - .mx_ReactionsRow { + .mx_EventTile_footer { + margin: var(--cpd-space-1-5x) 0; margin-inline: var(--EventTile_bubble_line-margin-inline-start) var(--EventTile_bubble_line-margin-inline-end); } @@ -204,7 +205,8 @@ Please see LICENSE files in the repository root for full details. margin-inline-end: auto; } - .mx_ReactionsRow { + .mx_ReactionsRow, + .mx_EventTile_footer { justify-content: flex-start; } @@ -245,6 +247,10 @@ Please see LICENSE files in the repository root for full details. max-width: 100%; } + .mx_EventTile_footer { + justify-content: flex-end; + } + .mx_ReactionsRow { justify-content: flex-end; diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index e1bd304632..92e4cf78ea 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -463,6 +463,10 @@ $left-gutter: 64px; margin-left: calc(var(--name-width) + var(--icon-width) + 1 * var(--right-padding)); } } + + .mx_EventTile_footer { + margin: var(--cpd-space-1-5x) 0; + } } &[data-layout="group"] { @@ -509,8 +513,8 @@ $left-gutter: 64px; margin-left: $left-gutter; } - .mx_ReactionsRow { - margin: $spacing-4 64px; + .mx_EventTile_footer { + margin: var(--cpd-space-1x) var(--cpd-space-16x); } > .mx_DisambiguatedProfile { @@ -1248,7 +1252,7 @@ $left-gutter: 64px; padding-block-start: $spacing-16; .mx_EventTile_line, - .mx_ReactionsRow { + .mx_EventTile_footer { margin-inline-end: var(--ThreadView_group_spacing-end); } @@ -1266,7 +1270,7 @@ $left-gutter: 64px; } } - .mx_ReactionsRow { + .mx_EventTile_footer { /* Align with message text and summary text */ margin-inline-start: var(--ThreadView_group_spacing-start); } @@ -1456,6 +1460,12 @@ $left-gutter: 64px; display: flex; } +.mx_EventTile_footer { + display: flex; + gap: var(--cpd-space-2x); + align-items: center; +} + /* Media query for mobile UI */ @media only screen and (max-width: 480px) { .mx_EventTile_content { diff --git a/src/components/views/messages/PinnedMessageBadge.tsx b/src/components/views/messages/PinnedMessageBadge.tsx new file mode 100644 index 0000000000..bfe1919597 --- /dev/null +++ b/src/components/views/messages/PinnedMessageBadge.tsx @@ -0,0 +1,24 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only + * Please see LICENSE files in the repository root for full details. + * + */ + +import React, { JSX } from "react"; +import { Icon as PinIcon } from "@vector-im/compound-design-tokens/icons/pin-solid.svg"; + +import { _t } from "../../../languageHandler.tsx"; + +/** + * A badge to indicate that a message is pinned. + */ +export function PinnedMessageBadge(): JSX.Element { + return ( +
+ + {_t("room|pinned_message_badge")} +
+ ); +} diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 746cceabd8..a41dbc218f 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { createRef, forwardRef, MouseEvent, ReactNode } from "react"; +import React, { createRef, forwardRef, JSX, MouseEvent, ReactNode } from "react"; import classNames from "classnames"; import { EventStatus, @@ -76,6 +76,8 @@ import { ElementCall } from "../../../models/Call"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar"; import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; +import PinningUtils from "../../../utils/PinningUtils.ts"; +import { PinnedMessageBadge } from "../messages/PinnedMessageBadge.tsx"; export type GetRelationsForEvent = ( eventId: string, @@ -1123,6 +1125,11 @@ export class UnwrappedEventTile extends React.Component const timestamp = showTimestamp && ts ? messageTimestamp : null; + let pinnedMessageBadge: JSX.Element | undefined; + if (PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent)) { + pinnedMessageBadge = ; + } + let reactionsRow: JSX.Element | undefined; if (!isRedacted) { reactionsRow = ( @@ -1134,6 +1141,9 @@ export class UnwrappedEventTile extends React.Component ); } + // If we have reactions or a pinned message badge, we need a footer + const hasFooter = Boolean((reactionsRow && this.state.reactions) || pinnedMessageBadge); + const linkedTimestamp = !this.props.hideTimestamp ? ( {msgOption} , - reactionsRow, + hasFooter && ( +
+ {(this.props.layout === Layout.Group || !isOwnEvent) && pinnedMessageBadge} + {reactionsRow} + {this.props.layout === Layout.Bubble && isOwnEvent && pinnedMessageBadge} +
+ ), ], ); } @@ -1428,14 +1444,25 @@ export class UnwrappedEventTile extends React.Component {actionBar} {this.props.layout === Layout.IRC && ( <> - {reactionsRow} + {hasFooter && ( +
+ {pinnedMessageBadge} + {reactionsRow} +
+ )} {this.renderThreadInfo()} )} {this.props.layout !== Layout.IRC && ( <> - {reactionsRow} + {hasFooter && ( +
+ {(this.props.layout === Layout.Group || !isOwnEvent) && pinnedMessageBadge} + {reactionsRow} + {this.props.layout === Layout.Bubble && isOwnEvent && pinnedMessageBadge} +
+ )} {this.renderThreadInfo()} )} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8653b0ec4e..21619b99d7 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2034,6 +2034,7 @@ "not_found_title": "This room or space does not exist.", "not_found_title_name": "%(roomName)s does not exist.", "peek_join_prompt": "You're previewing %(roomName)s. Want to join it?", + "pinned_message_badge": "Pinned message", "pinned_message_banner": { "button_close_list": "Close list", "button_view_all": "View all", diff --git a/test/components/views/messages/PinnedMessageBadge-test.tsx b/test/components/views/messages/PinnedMessageBadge-test.tsx new file mode 100644 index 0000000000..ba35e4de39 --- /dev/null +++ b/test/components/views/messages/PinnedMessageBadge-test.tsx @@ -0,0 +1,19 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only + * Please see LICENSE files in the repository root for full details. + * + */ + +import React from "react"; +import { render } from "@testing-library/react"; + +import { PinnedMessageBadge } from "../../../../src/components/views/messages/PinnedMessageBadge.tsx"; + +describe("PinnedMessageBadge", () => { + it("should render", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/messages/__snapshots__/PinnedMessageBadge-test.tsx.snap b/test/components/views/messages/__snapshots__/PinnedMessageBadge-test.tsx.snap new file mode 100644 index 0000000000..6a2beb4864 --- /dev/null +++ b/test/components/views/messages/__snapshots__/PinnedMessageBadge-test.tsx.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PinnedMessageBadge should render 1`] = ` + +
+
+ Pinned message +
+ +`; diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx index ee87e3250a..138cf1cdef 100644 --- a/test/components/views/rooms/EventTile-test.tsx +++ b/test/components/views/rooms/EventTile-test.tsx @@ -32,6 +32,8 @@ import DMRoomMap from "../../../../src/utils/DMRoomMap"; import dis from "../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../src/dispatcher/actions"; import { IRoomState } from "../../../../src/components/structures/RoomView"; +import PinningUtils from "../../../../src/utils/PinningUtils"; +import { Layout } from "../../../../src/settings/enums/Layout"; describe("EventTile", () => { const ROOM_ID = "!roomId:example.org"; @@ -91,6 +93,10 @@ describe("EventTile", () => { }); }); + afterEach(() => { + jest.spyOn(PinningUtils, "isPinned").mockReturnValue(false); + }); + describe("EventTile thread summary", () => { beforeEach(() => { jest.spyOn(client, "supportsThreads").mockReturnValue(true); @@ -154,6 +160,27 @@ describe("EventTile", () => { }); }); + describe("EventTile renderingType: Threads", () => { + it("should display the pinned message badge", async () => { + jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true); + getComponent({}, TimelineRenderingType.Thread); + + expect(screen.getByText("Pinned message")).toBeInTheDocument(); + }); + }); + + describe("EventTile renderingType: default", () => { + it.each([[Layout.Group], [Layout.Bubble], [Layout.IRC]])( + "should display the pinned message badge", + async (layout) => { + jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true); + getComponent({ layout }); + + expect(screen.getByText("Pinned message")).toBeInTheDocument(); + }, + ); + }); + describe("EventTile in the right panel", () => { beforeAll(() => { const dmRoomMap: DMRoomMap = {