Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 62 additions & 13 deletions src/components/viewmodels/roomlist/RoomListItemViewModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/

import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";

import dispatcher from "../../../dispatcher/dispatcher";
Expand All @@ -16,12 +16,17 @@ import { _t } from "../../../languageHandler";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { DefaultTagID } from "../../../stores/room-list/models";
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
import { type ConnectionState } from "../../../models/Call";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";

export interface RoomListItemViewState {
/**
* The name of the room.
*/
name: string;
/**
* Whether the hover menu should be shown.
*/
Expand Down Expand Up @@ -55,6 +60,10 @@ export interface RoomListItemViewState {
* Whether there are participants in the call.
*/
hasParticipantInCall: boolean;
/**
* Whether the notification decoration should be shown.
*/
showNotificationDecoration: boolean;
}

/**
Expand All @@ -65,11 +74,23 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
const matrixClient = useMatrixClientContext();
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
const name = useEventEmitterState(room, RoomEvent.Name, () => room.name);

const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]);
const invited = notificationState.invited;
const a11yLabel = getA11yLabel(room, notificationState);
const isBold = notificationState.hasAnyNotificationOrActivity;

const [a11yLabel, setA11yLabel] = useState(getA11yLabel(name, notificationState));
const [{ isBold, invited, hasVisibleNotification }, setNotificationValues] = useState(
getNotificationValues(notificationState),
);
useEffect(() => {
setA11yLabel(getA11yLabel(name, notificationState));
}, [name, notificationState]);

// Listen to changes in the notification state and update the values
useTypedEventEmitter(notificationState, NotificationStateEvents.Update, () => {
setA11yLabel(getA11yLabel(name, notificationState));
setNotificationValues(getNotificationValues(notificationState));
});

// We don't want to show the hover menu if
// - there is an invitation for this room
Expand All @@ -86,6 +107,8 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
const hasParticipantInCall = useParticipantCount(call) > 0;
const callConnectionState = call ? connectionState : null;

const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall;

// Actions

const openRoom = useCallback((): void => {
Expand All @@ -97,6 +120,7 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
}, [room]);

return {
name,
notificationState,
showHoverMenu,
openRoom,
Expand All @@ -105,34 +129,59 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
isVideoRoom,
callConnectionState,
hasParticipantInCall,
showNotificationDecoration,
};
}

/**
* Calculate the values from the notification state
* @param notificationState
*/
function getNotificationValues(notificationState: RoomNotificationState): {
computeA11yLabel: (name: string) => string;
isBold: boolean;
invited: boolean;
hasVisibleNotification: boolean;
} {
const invited = notificationState.invited;
const computeA11yLabel = (name: string): string => getA11yLabel(name, notificationState);
const isBold = notificationState.hasAnyNotificationOrActivity;

const hasVisibleNotification = notificationState.hasAnyNotificationOrActivity || notificationState.muted;

return {
computeA11yLabel,
isBold,
invited,
hasVisibleNotification,
};
}

/**
* Get the a11y label for the room list item
* @param room
* @param roomName
* @param notificationState
*/
function getA11yLabel(room: Room, notificationState: RoomNotificationState): string {
if (notificationState.isUnsetMessage) {
function getA11yLabel(roomName: string, notificationState: RoomNotificationState): string {
if (notificationState.isUnsentMessage) {
return _t("a11y|room_messsage_not_sent", {
roomName: room.name,
roomName,
});
} else if (notificationState.invited) {
return _t("a11y|room_n_unread_invite", {
roomName: room.name,
roomName,
});
} else if (notificationState.isMention) {
return _t("a11y|room_n_unread_messages_mentions", {
roomName: room.name,
roomName,
count: notificationState.count,
});
} else if (notificationState.hasUnreadCount) {
return _t("a11y|room_n_unread_messages", {
roomName: room.name,
roomName,
count: notificationState.count,
});
} else {
return _t("room_list|room|open_room", { roomName: room.name });
return _t("room_list|room|open_room", { roomName });
}
}
19 changes: 16 additions & 3 deletions src/components/views/rooms/NotificationDecoration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { UnreadCounter, Unread } from "@vector-im/compound-web";

import { Flex } from "../../utils/Flex";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";

interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
/**
Expand All @@ -35,16 +37,27 @@ export function NotificationDecoration({
hasVideoCall,
...props
}: NotificationDecorationProps): JSX.Element | null {
// Listen to the notification state and update the component when it changes
const {
hasAnyNotificationOrActivity,
isUnsetMessage,
isUnsentMessage,
invited,
isMention,
isActivityNotification,
isNotification,
count,
muted,
} = notificationState;
} = useTypedEventEmitterState(notificationState, NotificationStateEvents.Update, () => ({
hasAnyNotificationOrActivity: notificationState.hasAnyNotificationOrActivity,
isUnsentMessage: notificationState.isUnsentMessage,
invited: notificationState.invited,
isMention: notificationState.isMention,
isActivityNotification: notificationState.isActivityNotification,
isNotification: notificationState.isNotification,
count: notificationState.count,
muted: notificationState.muted,
}));

if (!hasAnyNotificationOrActivity && !muted && !hasVideoCall) return null;

return (
Expand All @@ -55,7 +68,7 @@ export function NotificationDecoration({
{...props}
data-testid="notification-decoration"
>
{isUnsetMessage && <ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />}
{isUnsentMessage && <ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />}
{hasVideoCall && <VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{invited && <EmailIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{isMention && <MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
Expand Down
30 changes: 17 additions & 13 deletions src/components/views/rooms/RoomListPanel/RoomListItemView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/

import React, { type JSX, useState } from "react";
import React, { type JSX, memo, useState } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";

Expand All @@ -29,7 +29,11 @@ interface RoomListItemViewPropsProps extends React.HTMLAttributes<HTMLButtonElem
/**
* An item in the room list
*/
export function RoomListItemView({ room, isSelected, ...props }: RoomListItemViewPropsProps): JSX.Element {
export const RoomListItemView = memo(function RoomListItemView({
room,
isSelected,
...props
}: RoomListItemViewPropsProps): JSX.Element {
const vm = useRoomListItemViewModel(room);

const [isHover, setIsHover] = useState(false);
Expand All @@ -38,9 +42,7 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
// Using display: none; and then display:flex when hovered in CSS causes the menu to be misaligned
const showHoverDecoration = (isMenuOpen || isHover) && vm.showHoverMenu;

const isNotificationDecorationVisible =
!showHoverDecoration &&
(vm.notificationState.hasAnyNotificationOrActivity || vm.notificationState.muted || vm.hasParticipantInCall);
const isNotificationDecorationVisible = !showHoverDecoration && vm.showNotificationDecoration;

return (
<button
Expand Down Expand Up @@ -71,8 +73,8 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
justify="space-between"
>
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<span className="mx_RoomListItemView_roomName" title={room.name}>
{room.name}
<span className="mx_RoomListItemView_roomName" title={vm.name}>
{vm.name}
</span>
{showHoverDecoration ? (
<RoomListItemMenuView
Expand All @@ -86,15 +88,17 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
) : (
<>
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
/>
{vm.showNotificationDecoration && (
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
/>
)}
</>
)}
</Flex>
</Flex>
</button>
);
}
});
4 changes: 2 additions & 2 deletions src/stores/notifications/RoomNotificationState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ export class RoomNotificationState extends NotificationState implements IDestroy
}

/**
* True if the notification is an unset message.
* True if the notification is an unsent message.
*/
public get isUnsetMessage(): boolean {
public get isUnsentMessage(): boolean {
return this.level === NotificationLevel.Unsent;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "../../../../../src/components/viewmodels/roomlist/utils";
import { RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore";
import * as UseCallModule from "../../../../../src/hooks/useCall";

jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(false),
Expand Down Expand Up @@ -86,6 +87,49 @@ describe("RoomListItemViewModel", () => {
expect(vm.current.showHoverMenu).toBe(true);
});

describe("notification", () => {
let notificationState: RoomNotificationState;
beforeEach(() => {
notificationState = new RoomNotificationState(room, false);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
});

it("should show notification decoration if there is call has participant", () => {
jest.spyOn(UseCallModule, "useParticipantCount").mockReturnValue(1);

const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showNotificationDecoration).toBe(true);
});

it.each([
{
label: "hasAnyNotificationOrActivity",
mock: () => jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true),
},
{ label: "muted", mock: () => jest.spyOn(notificationState, "muted", "get").mockReturnValue(true) },
])("should show notification decoration if $label=true", ({ mock }) => {
mock();
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showNotificationDecoration).toBe(true);
});

it("should be bold if there is a notification", () => {
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);

const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.isBold).toBe(true);
});
});

describe("a11yLabel", () => {
let notificationState: RoomNotificationState;
beforeEach(() => {
Expand All @@ -96,7 +140,7 @@ describe("RoomListItemViewModel", () => {
it.each([
{
label: "unsent message",
mock: () => jest.spyOn(notificationState, "isUnsetMessage", "get").mockReturnValue(true),
mock: () => jest.spyOn(notificationState, "isUnsentMessage", "get").mockReturnValue(true),
expected: "Open room roomName with an unsent message.",
},
{
Expand Down
Loading
Loading