diff --git a/CHANGELOG.md b/CHANGELOG.md index 563f34e1dd9..bdcf0d86117 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +Changes in [3.95.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.95.0) (2024-03-14) +===================================================================================================== +## 🐛 Bug Fixes + +* Update `@vector-im/compound-design-tokens` in package.json ([#12340](https://github.com/matrix-org/matrix-react-sdk/pull/12340)). + Changes in [3.94.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.94.0) (2024-03-12) ===================================================================================================== ## ✨ Features diff --git a/package.json b/package.json index 36fc01ecb40..ee387bcf6e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.94.0", + "version": "3.95.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -75,7 +75,7 @@ "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^7.0.0", "@testing-library/react-hooks": "^8.0.1", - "@vector-im/compound-design-tokens": "^1.0.0", + "@vector-im/compound-design-tokens": "^1.2.0", "@vector-im/compound-web": "^3.1.1", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", diff --git a/playwright/e2e/spaces/threads-activity-centre/index.ts b/playwright/e2e/spaces/threads-activity-centre/index.ts index b0fcd4648a8..4360ddb9816 100644 --- a/playwright/e2e/spaces/threads-activity-centre/index.ts +++ b/playwright/e2e/spaces/threads-activity-centre/index.ts @@ -30,30 +30,30 @@ import { ElementAppPage } from "../../../pages/ElementAppPage"; * - Invite the bot to both rooms and ensure that it has joined */ export const test = base.extend<{ - roomAlphaName?: string; - roomAlpha: { name: string; roomId: string }; - roomBetaName?: string; - roomBeta: { name: string; roomId: string }; + room1Name?: string; + room1: { name: string; roomId: string }; + room2Name?: string; + room2: { name: string; roomId: string }; msg: MessageBuilder; util: Helpers; }>({ displayName: "Mae", botCreateOpts: { displayName: "Other User" }, - roomAlphaName: "Room Alpha", - roomAlpha: async ({ roomAlphaName: name, app, user, bot }, use) => { + room1Name: "Room 1", + room1: async ({ room1Name: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); await use({ name, roomId }); }, - roomBetaName: "Room Beta", - roomBeta: async ({ roomBetaName: name, app, user, bot }, use) => { + room2Name: "Room 2", + room2: async ({ room2Name: name, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); await use({ name, roomId }); }, msg: async ({ page, app, util }, use) => { await use(new MessageBuilder(page, app, util)); }, - util: async ({ roomAlpha, roomBeta, page, app, bot }, use) => { + util: async ({ room1, room2, page, app, bot }, use) => { await use(new Helpers(page, app, bot)); }, }); @@ -265,6 +265,13 @@ export class Helpers { return this.getTacButton().click(); } + /** + * Hover over the Threads Activity Centre button + */ + hoverTacButton() { + return this.getTacButton().hover(); + } + /** * Click on a room in the Threads Activity Centre * @param name - room name @@ -330,23 +337,27 @@ export class Helpers { * @param room1 * @param room2 * @param msg - MessageBuilder + * @param hasMention - whether to include a mention in the first message */ async populateThreads( room1: { name: string; roomId: string }, room2: { name: string; roomId: string }, msg: MessageBuilder, + hasMention = true, ) { - await this.receiveMessages(room2, [ - "Msg1", - msg.threadedOff("Msg1", { - "body": "User", - "format": "org.matrix.custom.html", - "formatted_body": "<a href='https://matrix.to/#/@user:localhost'>User</a>", - "m.mentions": { - user_ids: ["@user:localhost"], - }, - }), - ]); + if (hasMention) { + await this.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", { + "body": "User", + "format": "org.matrix.custom.html", + "formatted_body": "<a href='https://matrix.to/#/@user:localhost'>User</a>", + "m.mentions": { + user_ids: ["@user:localhost"], + }, + }), + ]); + } await this.receiveMessages(room2, ["Msg2", msg.threadedOff("Msg2", "Resp2")]); await this.receiveMessages(room1, ["Msg3", msg.threadedOff("Msg3", "Resp3")]); } diff --git a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts index eb4d7c8df06..93094073b31 100644 --- a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts +++ b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts @@ -35,7 +35,7 @@ test.describe("Threads Activity Centre", () => { await expect(util.getSpacePanel()).toMatchScreenshot("tac-button-expanded.png"); }); - test("should not show indicator when there is no thread", async ({ roomAlpha: room1, util }) => { + test("should not show indicator when there is no thread", async ({ room1, util }) => { // No indicator should be shown await util.assertNoTacIndicator(); @@ -46,11 +46,7 @@ test.describe("Threads Activity Centre", () => { await util.assertNoTacIndicator(); }); - test("should show a notification indicator when there is a message in a thread", async ({ - roomAlpha: room1, - util, - msg, - }) => { + test("should show a notification indicator when there is a message in a thread", async ({ room1, util, msg }) => { await util.goTo(room1); await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); @@ -58,11 +54,7 @@ test.describe("Threads Activity Centre", () => { await util.assertNotificationTac(); }); - test("should show a highlight indicator when there is a mention in a thread", async ({ - roomAlpha: room1, - util, - msg, - }) => { + test("should show a highlight indicator when there is a mention in a thread", async ({ room1, util, msg }) => { await util.goTo(room1); await util.receiveMessages(room1, [ "Msg1", @@ -80,7 +72,7 @@ test.describe("Threads Activity Centre", () => { await util.assertHighlightIndicator(); }); - test("should show the rooms with unread threads", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + test("should show the rooms with unread threads", async ({ room1, room2, util, msg }) => { await util.goTo(room2); await util.populateThreads(room1, room2, msg); // The indicator should be shown @@ -97,7 +89,7 @@ test.describe("Threads Activity Centre", () => { await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-mix-unread.png"); }); - test("should update with a thread is read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + test("should update with a thread is read", async ({ room1, room2, util, msg }) => { await util.goTo(room2); await util.populateThreads(room1, room2, msg); @@ -120,6 +112,17 @@ test.describe("Threads Activity Centre", () => { await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-notification-unread.png"); }); + test("should order by recency after notification level", async ({ room1, room2, util, msg }) => { + await util.goTo(room2); + await util.populateThreads(room1, room2, msg, false); + + await util.openTac(); + await util.assertRoomsInTac([ + { room: room1.name, notificationLevel: "notification" }, + { room: room2.name, notificationLevel: "notification" }, + ]); + }); + test("should block the Spotlight to open when the TAC is opened", async ({ util, page }) => { const toggleSpotlight = () => page.keyboard.press(`${CommandOrControl}+k`); @@ -134,4 +137,14 @@ test.describe("Threads Activity Centre", () => { await toggleSpotlight(); await expect(page.locator(".mx_SpotlightDialog")).not.toBeVisible(); }); + + test("should have the correct hover state", async ({ util, page }) => { + await util.hoverTacButton(); + await expect(util.getSpacePanel()).toMatchScreenshot("tac-hovered.png"); + + // Expand the space panel, hover the button and take a screenshot + await util.expandSpacePanel(); + await util.hoverTacButton(); + await expect(util.getSpacePanel()).toMatchScreenshot("tac-hovered-expanded.png"); + }); }); diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png new file mode 100644 index 00000000000..37405cd821a Binary files /dev/null and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png new file mode 100644 index 00000000000..26f5bfdfa98 Binary files /dev/null and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-mix-unread-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-mix-unread-linux.png index f02a943d6bd..ec5a8193d25 100644 Binary files a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-mix-unread-linux.png and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-mix-unread-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-notification-unread-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-notification-unread-linux.png index 1a94524d688..f0f6cee3e6a 100644 Binary files a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-notification-unread-linux.png and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-notification-unread-linux.png differ diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 568b9eea123..b750de0213c 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -335,6 +335,12 @@ legend { max-height: calc(100% - var(--cpd-space-12x)); display: flex; flex-direction: column; + + .mx_Dialog_lightbox & { + /* The lightbox isn't so much of a dialog as a fullscreen overlay. We + don't want the glass border. */ + display: contents; + } } .mx_Dialog { diff --git a/res/css/structures/_ThreadsActivityCentre.pcss b/res/css/structures/_ThreadsActivityCentre.pcss index 4c61d32f776..76b38d6c076 100644 --- a/res/css/structures/_ThreadsActivityCentre.pcss +++ b/res/css/structures/_ThreadsActivityCentre.pcss @@ -25,6 +25,12 @@ margin: 18px auto auto auto; &.expanded { + /** + * override compound default background color when hovered + * should disappear when the space panel will be migrated to compound + */ + background-color: transparent !important; + /* align with settings icon */ margin-left: 21px; diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index ed8afe7b61a..5879dd3b1a7 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -35,7 +35,11 @@ interface IProps { room: Room; size: string; displayBadge?: boolean; - forceCount?: boolean; + /** + * If true, show nothing if the notification would only cause a dot to be shown rather than + * a badge. That is: only display badges and not dots. Default: false. + */ + hideIfDot?: boolean; oobData?: IOOBData; viewAvatarOnClick?: boolean; tooltipProps?: { @@ -178,14 +182,14 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt public render(): React.ReactNode { // Spread the remaining props to make it work with compound component - const { room, size, displayBadge, forceCount, oobData, viewAvatarOnClick, tooltipProps, ...props } = this.props; + const { room, size, displayBadge, hideIfDot, oobData, viewAvatarOnClick, tooltipProps, ...props } = this.props; let badge: React.ReactNode; if (this.props.displayBadge && this.state.notificationState) { badge = ( <NotificationBadge notification={this.state.notificationState} - forceCount={this.props.forceCount} + hideIfDot={this.props.hideIfDot} roomId={this.props.room.roomId} /> ); diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index cb414173387..b36fb972555 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -1311,7 +1311,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState> <UnreadNotificationBadge room={room || undefined} threadId={this.props.mxEvent.getId()} - type="dot" + forceDot={true} /> </div> {isRenderingNotification && room ? ( diff --git a/src/components/views/rooms/ExtraTile.tsx b/src/components/views/rooms/ExtraTile.tsx index 157bfc4d562..3bb3a21525a 100644 --- a/src/components/views/rooms/ExtraTile.tsx +++ b/src/components/views/rooms/ExtraTile.tsx @@ -52,7 +52,7 @@ export default function ExtraTile({ let badge: JSX.Element | null = null; if (notificationState) { - badge = <NotificationBadge notification={notificationState} forceCount={false} />; + badge = <NotificationBadge notification={notificationState} />; } let name = displayName; diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index d152ab6a626..d4f7ee50407 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -28,10 +28,10 @@ interface IProps { notification: NotificationState; /** - * If true, the badge will show a count if at all possible. This is typically - * used to override the user's preference for things like room sublists. + * If true, show nothing if the notification would only cause a dot to be shown rather than + * a badge. That is: only display badges and not dots. Default: false. */ - forceCount?: boolean; + hideIfDot?: boolean; /** * The room ID, if any, the badge represents. @@ -48,7 +48,7 @@ interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> { } interface IState { - showCounts: boolean; // whether to show counts. Independent of props.forceCount + showCounts: boolean; // whether to show counts. } export default class NotificationBadge extends React.PureComponent<XOR<IProps, IClickableProps>, IState> { @@ -97,11 +97,12 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I public render(): ReactNode { /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ - const { notification, showUnsentTooltip, forceCount, onClick, tabIndex } = this.props; + const { notification, showUnsentTooltip, hideIfDot, onClick, tabIndex } = this.props; if (notification.isIdle && !notification.knocked) return null; - if (forceCount) { - if (!notification.hasUnreadCount) return null; // Can't render a badge + if (hideIfDot && notification.level < NotificationLevel.Notification) { + // This would just be a dot and we've been told not to show dots, so don't show it + if (!notification.hasUnreadCount) return null; } const commonProps: React.ComponentProps<typeof StatelessNotificationBadge> = { diff --git a/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx index 69f756b3b7e..1d26083b6a0 100644 --- a/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx @@ -28,7 +28,12 @@ interface Props { count: number; level: NotificationLevel; knocked?: boolean; - type?: "badge" | "dot"; + /** + * If true, where we would normally show a badge, we instead show a dot. No numeric count will + * be displayed (but may affect whether the the dot is displayed). See class doc + * for the difference between the two. + */ + forceDot?: boolean; } interface ClickableProps extends Props { @@ -39,8 +44,17 @@ interface ClickableProps extends Props { tabIndex?: number; } +/** + * A notification indicator that conveys what activity / notifications the user has in whatever + * context it is being used. + * + * Can either be a 'badge': a small circle with a number in it (the 'count'), or a 'dot': a smaller, empty circle. + * The two can be used to convey the same meaning but in different contexts, for example: for unread + * notifications in the room list, it may have a green badge with the number of unread notifications, + * but somewhere else it may just have a green dot as a more compact representation of the same information. + */ export const StatelessNotificationBadge = forwardRef<HTMLDivElement, XOR<Props, ClickableProps>>( - ({ symbol, count, level, knocked, type = "badge", ...props }, ref) => { + ({ symbol, count, level, knocked, forceDot = false, ...props }, ref) => { const hideBold = useSettingValue("feature_hidebold"); // Don't show a badge if we don't need to @@ -61,10 +75,12 @@ export const StatelessNotificationBadge = forwardRef<HTMLDivElement, XOR<Props, mx_NotificationBadge_visible: isEmptyBadge || knocked ? true : hasUnreadCount, mx_NotificationBadge_level_notification: level == NotificationLevel.Notification, mx_NotificationBadge_level_highlight: level >= NotificationLevel.Highlight, - mx_NotificationBadge_dot: (isEmptyBadge && !knocked) || type === "dot", mx_NotificationBadge_knocked: knocked, - mx_NotificationBadge_2char: type === "badge" && symbol && symbol.length > 0 && symbol.length < 3, - mx_NotificationBadge_3char: type === "badge" && symbol && symbol.length > 2, + + // At most one of mx_NotificationBadge_dot, mx_NotificationBadge_2char, mx_NotificationBadge_3char + mx_NotificationBadge_dot: (isEmptyBadge && !knocked) || forceDot, + mx_NotificationBadge_2char: !forceDot && symbol && symbol.length > 0 && symbol.length < 3, + mx_NotificationBadge_3char: !forceDot && symbol && symbol.length > 2, }); if (props.onClick) { diff --git a/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx index 5864a63be01..c3c8cf7df89 100644 --- a/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx @@ -23,11 +23,15 @@ import { StatelessNotificationBadge } from "./StatelessNotificationBadge"; interface Props { room?: Room; threadId?: string; - type?: "badge" | "dot"; + /** + * If true, where we would normally show a badge, we instead show a dot. No numeric count will + * be displayed. + */ + forceDot?: boolean; } -export function UnreadNotificationBadge({ room, threadId, type }: Props): JSX.Element { +export function UnreadNotificationBadge({ room, threadId, forceDot }: Props): JSX.Element { const { symbol, count, level } = useUnreadNotifications(room, threadId); - return <StatelessNotificationBadge symbol={symbol} count={count} level={level} type={type} />; + return <StatelessNotificationBadge symbol={symbol} count={count} level={level} forceDot={forceDot} />; } diff --git a/src/components/views/rooms/RoomBreadcrumbs.tsx b/src/components/views/rooms/RoomBreadcrumbs.tsx index eca8da46240..cd31dbd8e79 100644 --- a/src/components/views/rooms/RoomBreadcrumbs.tsx +++ b/src/components/views/rooms/RoomBreadcrumbs.tsx @@ -61,7 +61,7 @@ const RoomBreadcrumbTile: React.FC<{ room: Room; onClick: (ev: ButtonEvent) => v room={room} size="32px" displayBadge={true} - forceCount={true} + hideIfDot={true} tooltipProps={{ tabIndex: isActive ? 0 : -1 }} /> </AccessibleTooltipButton> diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index 4e84bee0be2..c8ad9d4acab 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -657,7 +657,7 @@ export default class RoomSublist extends React.Component<IProps, IState> { const badge = ( <NotificationBadge - forceCount={true} + hideIfDot={true} notification={this.notificationState} onClick={this.onBadgeClick} tabIndex={tabIndex} diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index ab6562a32cc..e0baf41f191 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -402,11 +402,7 @@ export class RoomTile extends React.PureComponent<ClassProps, State> { // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below badge = ( <div className="mx_RoomTile_badgeContainer" aria-hidden="true"> - <NotificationBadge - notification={this.notificationState} - forceCount={false} - roomId={this.props.room.roomId} - /> + <NotificationBadge notification={this.notificationState} roomId={this.props.room.roomId} /> </div> ); } diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index c0b71709238..315cba3c1cc 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -113,7 +113,6 @@ export const SpaceButton = <T extends keyof JSX.IntrinsicElements>({ <div className="mx_SpacePanel_badgeContainer"> <NotificationBadge onClick={jumpToNotification} - forceCount={false} notification={notificationState} aria-label={ariaLabel} tabIndex={tabIndex} diff --git a/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx b/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx index 098f2d828cf..ddb1dd98d3e 100644 --- a/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx +++ b/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx @@ -147,7 +147,7 @@ function ThreadsActivityCentreRow({ room, onClick, notificationLevel }: ThreadsA label={room.name} Icon={<DecoratedRoomAvatar room={room} size="32px" />} > - <StatelessNotificationBadge level={notificationLevel} count={0} symbol={null} type="dot" /> + <StatelessNotificationBadge level={notificationLevel} count={0} symbol={null} forceDot={true} /> </MenuItem> ); } diff --git a/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts index 115b2085607..72b5380fbd1 100644 --- a/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts +++ b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts @@ -89,12 +89,12 @@ function computeUnreadThreadRooms(mxClient: MatrixClient, msc3946ProcessDynamicP const visibleRooms = mxClient.getVisibleRooms(msc3946ProcessDynamicPredecessor); let greatestNotificationLevel = NotificationLevel.None; - const rooms = []; + const rooms: Result["rooms"] = []; for (const room of visibleRooms) { // We only care about rooms with unread threads if (VisibilityProvider.instance.isRoomVisible(room) && doesRoomHaveUnreadThreads(room)) { - // Get the greatest notification level of all rooms + // Get the greatest notification level of all threads const notificationLevel = getThreadNotificationLevel(room); // If the room has an activity notification or less, we ignore it @@ -110,20 +110,35 @@ function computeUnreadThreadRooms(mxClient: MatrixClient, msc3946ProcessDynamicP } } - const sortedRooms = rooms.sort((a, b) => sortRoom(a.notificationLevel, b.notificationLevel)); + const sortedRooms = rooms.sort((a, b) => sortRoom(a, b)); return { greatestNotificationLevel, rooms: sortedRooms }; } +/** + * Store the room and its thread notification level + */ +type RoomData = Result["rooms"][0]; + /** * Sort notification level by the most important notification level to the least important * Highlight > Notification > Activity - * @param notificationLevelA - notification level of room A - * @param notificationLevelB - notification level of room B + * If the notification level is the same, we sort by the most recent thread + * @param roomDataA - room and notification level of room A + * @param roomDataB - room and notification level of room B * @returns {number} */ -function sortRoom(notificationLevelA: NotificationLevel, notificationLevelB: NotificationLevel): number { +function sortRoom(roomDataA: RoomData, roomDataB: RoomData): number { + const { notificationLevel: notificationLevelA, room: roomA } = roomDataA; + const { notificationLevel: notificationLevelB, room: roomB } = roomDataB; + + const timestampA = roomA.getLastThread()?.events.at(-1)?.getTs(); + const timestampB = roomB.getLastThread()?.events.at(-1)?.getTs(); + // NotificationLevel is a numeric enum, so we can compare them directly if (notificationLevelA > notificationLevelB) return -1; else if (notificationLevelB > notificationLevelA) return 1; - else return 0; + // Display most recent first + else if (!timestampA) return 1; + else if (!timestampB) return -1; + else return timestampB - timestampA; } diff --git a/test/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx index 66ae273e247..6ee93d82db4 100644 --- a/test/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx +++ b/test/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx @@ -35,4 +35,23 @@ describe("StatelessNotificationBadge", () => { expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument(); expect(container.querySelector(".mx_NotificationBadge_knocked")).toBeInTheDocument(); }); + + it("has badge style for notification", () => { + const { container } = render( + <StatelessNotificationBadge symbol={null} count={3} level={NotificationLevel.Notification} />, + ); + expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument(); + }); + + it("has dot style for notification when forced", () => { + const { container } = render( + <StatelessNotificationBadge + symbol={null} + count={3} + level={NotificationLevel.Notification} + forceDot={true} + />, + ); + expect(container.querySelector(".mx_NotificationBadge_dot")).toBeInTheDocument(); + }); }); diff --git a/test/components/views/spaces/ThreadsActivityCentre-test.tsx b/test/components/views/spaces/ThreadsActivityCentre-test.tsx index f5f183e7c6c..8deb27ec7e4 100644 --- a/test/components/views/spaces/ThreadsActivityCentre-test.tsx +++ b/test/components/views/spaces/ThreadsActivityCentre-test.tsx @@ -59,16 +59,23 @@ describe("ThreadsActivityCentre", () => { }); roomWithActivity.name = "Just activity"; - const roomWithNotif = new Room("!room:server", cli, cli.getSafeUserId(), { + const roomWithNotif = new Room("!room2:server", cli, cli.getSafeUserId(), { pendingEventOrdering: PendingEventOrdering.Detached, }); roomWithNotif.name = "A notification"; - const roomWithHighlight = new Room("!room:server", cli, cli.getSafeUserId(), { + const roomWithHighlight = new Room("!room3:server", cli, cli.getSafeUserId(), { pendingEventOrdering: PendingEventOrdering.Detached, }); roomWithHighlight.name = "This is a real highlight"; + const getDefaultThreadArgs = (room: Room) => ({ + room: room, + client: cli, + authorId: "@foo:bar", + participantUserIds: ["@fee:bar"], + }); + beforeAll(async () => { jest.spyOn(MatrixClientPeg, "get").mockReturnValue(cli); jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(cli); @@ -77,26 +84,15 @@ describe("ThreadsActivityCentre", () => { jest.spyOn(dmRoomMap, "getUserIdForRoomId"); jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); - await populateThread({ - room: roomWithActivity, - client: cli, - authorId: "@foo:bar", - participantUserIds: ["@fee:bar"], - }); + await populateThread(getDefaultThreadArgs(roomWithActivity)); - const notifThreadInfo = await populateThread({ - room: roomWithNotif, - client: cli, - authorId: "@foo:bar", - participantUserIds: ["@fee:bar"], - }); + const notifThreadInfo = await populateThread(getDefaultThreadArgs(roomWithNotif)); roomWithNotif.setThreadUnreadNotificationCount(notifThreadInfo.thread.id, NotificationCountType.Total, 1); const highlightThreadInfo = await populateThread({ - room: roomWithHighlight, - client: cli, - authorId: "@foo:bar", - participantUserIds: ["@fee:bar"], + ...getDefaultThreadArgs(roomWithHighlight), + // timestamp + ts: 5, }); roomWithHighlight.setThreadUnreadNotificationCount( highlightThreadInfo.thread.id, @@ -181,6 +177,52 @@ describe("ThreadsActivityCentre", () => { expect(screen.getByRole("menu")).toMatchSnapshot(); }); + it("should order the room with the same notification level by most recent", async () => { + // Generate two new rooms with threads + const secondRoomWithHighlight = new Room("!room4:server", cli, cli.getSafeUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + secondRoomWithHighlight.name = "This is a second real highlight"; + + const secondHighlightThreadInfo = await populateThread({ + ...getDefaultThreadArgs(secondRoomWithHighlight), + // timestamp + ts: 1, + }); + secondRoomWithHighlight.setThreadUnreadNotificationCount( + secondHighlightThreadInfo.thread.id, + NotificationCountType.Highlight, + 1, + ); + + const thirdRoomWithHighlight = new Room("!room5:server", cli, cli.getSafeUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + thirdRoomWithHighlight.name = "This is a third real highlight"; + + const thirdHighlightThreadInfo = await populateThread({ + ...getDefaultThreadArgs(thirdRoomWithHighlight), + // timestamp + ts: 7, + }); + thirdRoomWithHighlight.setThreadUnreadNotificationCount( + thirdHighlightThreadInfo.thread.id, + NotificationCountType.Highlight, + 1, + ); + + cli.getVisibleRooms = jest + .fn() + .mockReturnValue([roomWithHighlight, secondRoomWithHighlight, thirdRoomWithHighlight]); + + renderTAC(); + await userEvent.click(getTACButton()); + + // The room should be ordered by the most recent thread + // thirdHighlightThreadInfo (timestamp 7) > highlightThreadInfo (timestamp 5) > secondHighlightThreadInfo (timestamp 1) + expect(screen.getByRole("menu")).toMatchSnapshot(); + }); + it("should block Ctrl/CMD + k shortcut", async () => { cli.getVisibleRooms = jest.fn().mockReturnValue([roomWithHighlight]); diff --git a/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap b/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap index 5b9e2091b75..0d2841c6148 100644 --- a/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap +++ b/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap @@ -38,7 +38,7 @@ exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] = > <span class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61" - data-color="6" + data-color="3" data-testid="avatar-img" data-type="round" role="presentation" @@ -86,7 +86,7 @@ exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] = > <span class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61" - data-color="6" + data-color="2" data-testid="avatar-img" data-type="round" role="presentation" @@ -158,3 +158,176 @@ exports[`ThreadsActivityCentre should match snapshot when empty 1`] = ` </div> </div> `; + +exports[`ThreadsActivityCentre should order the room with the same notification level by most recent 1`] = ` +<div + aria-labelledby="radix-31" + aria-orientation="vertical" + class="_menu_1x5h1_17" + data-align="end" + data-orientation="vertical" + data-radix-menu-content="" + data-side="right" + data-state="open" + dir="ltr" + id="radix-32" + role="menu" + style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;" + tabindex="-1" +> + <h3 + class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _title_1x5h1_83" + id=":r8:" + > + Threads activity + </h3> + <div + class="mx_ThreadsActivityCentre_rows" + > + <button + class="mx_ThreadsActivityCentreRow _item_1gwvj_17 _interactive_1gwvj_36" + data-kind="primary" + data-orientation="vertical" + data-radix-collection-item="" + role="menuitem" + tabindex="-1" + > + <div + class="_icon_1gwvj_44" + > + <span + class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61" + data-color="5" + data-testid="avatar-img" + data-type="round" + role="presentation" + style="--cpd-avatar-size: 32px;" + > + T + </span> + </div> + <span + class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53" + > + This is a third real highlight + </span> + <svg + aria-hidden="true" + class="_nav-hint_1gwvj_60" + fill="currentColor" + height="24" + viewBox="8 0 8 24" + width="8" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.876.876 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z" + /> + </svg> + <div + class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_dot" + > + <span + class="mx_NotificationBadge_count" + /> + </div> + </button> + <button + class="mx_ThreadsActivityCentreRow _item_1gwvj_17 _interactive_1gwvj_36" + data-kind="primary" + data-orientation="vertical" + data-radix-collection-item="" + role="menuitem" + tabindex="-1" + > + <div + class="_icon_1gwvj_44" + > + <span + class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61" + data-color="3" + data-testid="avatar-img" + data-type="round" + role="presentation" + style="--cpd-avatar-size: 32px;" + > + T + </span> + </div> + <span + class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53" + > + This is a real highlight + </span> + <svg + aria-hidden="true" + class="_nav-hint_1gwvj_60" + fill="currentColor" + height="24" + viewBox="8 0 8 24" + width="8" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.876.876 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z" + /> + </svg> + <div + class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_dot" + > + <span + class="mx_NotificationBadge_count" + /> + </div> + </button> + <button + class="mx_ThreadsActivityCentreRow _item_1gwvj_17 _interactive_1gwvj_36" + data-kind="primary" + data-orientation="vertical" + data-radix-collection-item="" + role="menuitem" + tabindex="-1" + > + <div + class="_icon_1gwvj_44" + > + <span + class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61" + data-color="4" + data-testid="avatar-img" + data-type="round" + role="presentation" + style="--cpd-avatar-size: 32px;" + > + T + </span> + </div> + <span + class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53" + > + This is a second real highlight + </span> + <svg + aria-hidden="true" + class="_nav-hint_1gwvj_60" + fill="currentColor" + height="24" + viewBox="8 0 8 24" + width="8" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.876.876 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z" + /> + </svg> + <div + class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_dot" + > + <span + class="mx_NotificationBadge_count" + /> + </div> + </button> + </div> +</div> +`; diff --git a/yarn.lock b/yarn.lock index ce60176a7a5..6bcc5013577 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3013,7 +3013,7 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== -"@vector-im/compound-design-tokens@^1.0.0": +"@vector-im/compound-design-tokens@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.2.0.tgz#ccb15fffc24cc70d83593bfc5348e6a0198cc08a" integrity sha512-8LSbb38KxvStcOQZDSi7lI4oqtCuHFEgEQi9Q0KUx+5OnklfdyJ638txM1bznX/Cp9lHgMk4dHrTiQHBOE0ZuA== @@ -6860,7 +6860,7 @@ matrix-events-sdk@0.0.1: "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": version "31.5.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/461aeae2815a223c817c9768e26220cec4a69d12" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8e0ef5ff2cd927efa1bd22cabb075a14b10e39d5" dependencies: "@babel/runtime" "^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm" "^4.6.0"