/* Copyright 2024 New Vector Ltd. Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> Copyright 2015-2017 , 2019-2021 The Matrix.org Foundation C.I.C. 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 } from "react"; import { Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import classNames from "classnames"; import type { Call } from "../../../models/Call"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { _t } from "../../../languageHandler"; import { ChevronFace, ContextMenuTooltipButton, MenuProps } from "../../structures/ContextMenu"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { MessagePreview, MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import { RoomNotifState } from "../../../RoomNotifs"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { RoomNotificationContextMenu } from "../context_menus/RoomNotificationContextMenu"; import NotificationBadge from "./NotificationBadge"; import { ActionPayload } from "../../../dispatcher/payloads"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import { EchoChamber } from "../../../stores/local-echo/EchoChamber"; import { CachedRoomKey, RoomEchoChamber } from "../../../stores/local-echo/RoomEchoChamber"; import { PROPERTY_UPDATED } from "../../../stores/local-echo/GenericEchoChamber"; import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu"; import { CallStore, CallStoreEvent } from "../../../stores/CallStore"; import { SdkContextClass } from "../../../contexts/SDKContext"; import { useHasRoomLiveVoiceBroadcast } from "../../../voice-broadcast"; import { RoomTileSubtitle } from "./RoomTileSubtitle"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; import { isKnockDenied } from "../../../utils/membership"; import SettingsStore from "../../../settings/SettingsStore"; interface Props { room: Room; showMessagePreview: boolean; isMinimized: boolean; tag: TagID; } interface ClassProps extends Props { hasLiveVoiceBroadcast: boolean; } type PartialDOMRect = Pick; interface State { selected: boolean; notificationsMenuPosition: PartialDOMRect | null; generalMenuPosition: PartialDOMRect | null; call: Call | null; messagePreview: MessagePreview | null; } const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`; export const contextMenuBelow = (elementRect: PartialDOMRect): MenuProps => { // align the context menu's icons with the icon which opened the context menu const left = elementRect.left + window.scrollX - 9; const top = elementRect.bottom + window.scrollY + 17; const chevronFace = ChevronFace.None; return { left, top, chevronFace }; }; export class RoomTile extends React.PureComponent { private dispatcherRef?: string; private roomTileRef = createRef(); private notificationState: NotificationState; private roomProps: RoomEchoChamber; public constructor(props: ClassProps) { super(props); this.state = { selected: SdkContextClass.instance.roomViewStore.getRoomId() === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, call: CallStore.instance.getCall(this.props.room.roomId), // generatePreview() will return nothing if the user has previews disabled messagePreview: null, }; this.generatePreview(); this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room); this.roomProps = EchoChamber.forRoom(this.props.room); } private onRoomNameUpdate = (room: Room): void => { this.forceUpdate(); }; private onNotificationUpdate = (): void => { this.forceUpdate(); // notification state changed - update }; private onRoomPropertyUpdate = (property: CachedRoomKey): void => { if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate(); // else ignore - not important for this tile }; private get showContextMenu(): boolean { return ( this.props.tag !== DefaultTagID.Invite && this.props.room.getMyMembership() !== KnownMembership.Knock && !isKnockDenied(this.props.room) && shouldShowComponent(UIComponent.RoomOptionsMenu) ); } private get showMessagePreview(): boolean { return !this.props.isMinimized && this.props.showMessagePreview; } public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { const showMessageChanged = prevProps.showMessagePreview !== this.props.showMessagePreview; const minimizedChanged = prevProps.isMinimized !== this.props.isMinimized; if (showMessageChanged || minimizedChanged) { this.generatePreview(); } if (prevProps.room?.roomId !== this.props.room?.roomId) { MessagePreviewStore.instance.off( MessagePreviewStore.getPreviewChangedEventName(prevProps.room), this.onRoomPreviewChanged, ); MessagePreviewStore.instance.on( MessagePreviewStore.getPreviewChangedEventName(this.props.room), this.onRoomPreviewChanged, ); prevProps.room?.off(RoomEvent.Name, this.onRoomNameUpdate); this.props.room?.on(RoomEvent.Name, this.onRoomNameUpdate); } } public componentDidMount(): void { // when we're first rendered (or our sublist is expanded) make sure we are visible if we're active if (this.state.selected) { this.scrollIntoView(); } SdkContextClass.instance.roomViewStore.addRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); this.dispatcherRef = defaultDispatcher.register(this.onAction); MessagePreviewStore.instance.on( MessagePreviewStore.getPreviewChangedEventName(this.props.room), this.onRoomPreviewChanged, ); this.notificationState.on(NotificationStateEvents.Update, this.onNotificationUpdate); this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate); this.props.room.on(RoomEvent.Name, this.onRoomNameUpdate); CallStore.instance.on(CallStoreEvent.Call, this.onCallChanged); // Recalculate the call for this room, since it could've changed between // construction and mounting this.setState({ call: CallStore.instance.getCall(this.props.room.roomId) }); } public componentWillUnmount(): void { SdkContextClass.instance.roomViewStore.removeRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); MessagePreviewStore.instance.off( MessagePreviewStore.getPreviewChangedEventName(this.props.room), this.onRoomPreviewChanged, ); this.props.room.off(RoomEvent.Name, this.onRoomNameUpdate); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate); this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate); CallStore.instance.off(CallStoreEvent.Call, this.onCallChanged); } private onAction = (payload: ActionPayload): void => { if ( payload.action === Action.ViewRoom && payload.room_id === this.props.room.roomId && payload.show_room_tile ) { setTimeout(() => { this.scrollIntoView(); }); } }; private onRoomPreviewChanged = (room: Room): void => { if (this.props.room && room.roomId === this.props.room.roomId) { this.generatePreview(); } }; private onCallChanged = (call: Call, roomId: string): void => { if (roomId === this.props.room?.roomId) this.setState({ call }); }; private async generatePreview(): Promise { if (!this.showMessagePreview) { return; } const messagePreview = (await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag)) ?? null; this.setState({ messagePreview }); } private scrollIntoView = (): void => { if (!this.roomTileRef.current) return; this.roomTileRef.current.scrollIntoView({ block: "nearest", behavior: "auto", }); }; private onTileClick = async (ev: ButtonEvent): Promise => { ev.preventDefault(); ev.stopPropagation(); const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent); const clearSearch = ([KeyBindingAction.Enter, KeyBindingAction.Space] as Array).includes( action, ); defaultDispatcher.dispatch({ action: Action.ViewRoom, show_room_tile: true, // make sure the room is visible in the list room_id: this.props.room.roomId, clear_search: clearSearch, metricsTrigger: "RoomList", metricsViaKeyboard: ev.type !== "click", }); }; private onActiveRoomUpdate = (isActive: boolean): void => { this.setState({ selected: isActive }); }; private onNotificationsMenuOpenClick = (ev: ButtonEvent): void => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; this.setState({ notificationsMenuPosition: target.getBoundingClientRect() }); PosthogTrackers.trackInteraction("WebRoomListRoomTileNotificationsMenu", ev); }; private onCloseNotificationsMenu = (): void => { this.setState({ notificationsMenuPosition: null }); }; private onGeneralMenuOpenClick = (ev: ButtonEvent): void => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; this.setState({ generalMenuPosition: target.getBoundingClientRect() }); }; private onContextMenu = (ev: React.MouseEvent): void => { // If we don't have a context menu to show, ignore the action. if (!this.showContextMenu) return; ev.preventDefault(); ev.stopPropagation(); this.setState({ generalMenuPosition: { left: ev.clientX, bottom: ev.clientY, }, }); }; private onCloseGeneralMenu = (): void => { this.setState({ generalMenuPosition: null }); }; private renderNotificationsMenu(isActive: boolean): React.ReactElement | null { if ( MatrixClientPeg.safeGet().isGuest() || this.props.tag === DefaultTagID.Archived || !this.showContextMenu || this.props.isMinimized ) { // the menu makes no sense in these cases so do not show one return null; } const state = this.roomProps.notificationVolume; const classes = classNames("mx_RoomTile_notificationsButton", { // Show bell icon for the default case too. mx_RoomNotificationContextMenu_iconBell: state === RoomNotifState.AllMessages, mx_RoomNotificationContextMenu_iconBellDot: state === RoomNotifState.AllMessagesLoud, mx_RoomNotificationContextMenu_iconBellMentions: state === RoomNotifState.MentionsOnly, mx_RoomNotificationContextMenu_iconBellCrossed: state === RoomNotifState.Mute, // Only show the icon by default if the room is overridden to muted. // TODO: [FTUE Notifications] Probably need to detect global mute state mx_RoomTile_notificationsButton_show: state === RoomNotifState.Mute, }); return ( {this.state.notificationsMenuPosition && ( )} ); } private renderGeneralMenu(): React.ReactElement | null { if (!this.showContextMenu) return null; // no menu to show return ( {this.state.generalMenuPosition && ( PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", ev) } onPostInviteClick={(ev: ButtonEvent) => PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", ev) } onPostSettingsClick={(ev: ButtonEvent) => PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuSettingsItem", ev) } onPostLeaveClick={(ev: ButtonEvent) => PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", ev) } onPostMarkAsReadClick={(ev: ButtonEvent) => PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", ev) } onPostMarkAsUnreadClick={(ev: ButtonEvent) => PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", ev) } /> )} ); } /** * RoomTile has a subtile if one of the following applies: * - there is a call * - there is a live voice broadcast * - message previews are enabled and there is a previewable message */ private get shouldRenderSubtitle(): boolean { return ( !!this.state.call || this.props.hasLiveVoiceBroadcast || (this.props.showMessagePreview && !!this.state.messagePreview) ); } public render(): React.ReactElement { const classes = classNames({ mx_RoomTile: true, mx_RoomTile_sticky: SettingsStore.getValue("feature_ask_to_join") && (this.props.room.getMyMembership() === KnownMembership.Knock || isKnockDenied(this.props.room)), mx_RoomTile_selected: this.state.selected, mx_RoomTile_hasMenuOpen: !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition), mx_RoomTile_minimized: this.props.isMinimized, }); let name = this.props.room.name; if (typeof name !== "string") name = ""; name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon let badge: React.ReactNode; if (!this.props.isMinimized && this.notificationState) { // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below badge = ( ); } const subtitle = this.shouldRenderSubtitle ? ( ) : null; const titleClasses = classNames({ mx_RoomTile_title: true, mx_RoomTile_titleWithSubtitle: !!subtitle, mx_RoomTile_titleHasUnreadEvents: this.notificationState.isUnread, }); const titleContainer = this.props.isMinimized ? null : (
{name}
{subtitle}
); let ariaLabel = name; // The following labels are written in such a fashion to increase screen reader efficiency (speed). if (this.props.tag === DefaultTagID.Invite) { // append nothing } else if (this.notificationState.hasMentions) { ariaLabel += " " + _t("a11y|n_unread_messages_mentions", { count: this.notificationState.count, }); } else if (this.notificationState.hasUnreadCount) { ariaLabel += " " + _t("a11y|n_unread_messages", { count: this.notificationState.count, }); } else if (this.notificationState.isUnread) { ariaLabel += " " + _t("a11y|unread_messages"); } let ariaDescribedBy: string; if (this.showMessagePreview) { ariaDescribedBy = messagePreviewId(this.props.room.roomId); } return ( {({ onFocus, isActive, ref }) => ( {titleContainer} {badge} {this.renderGeneralMenu()} {this.renderNotificationsMenu(isActive)} )} ); } } const RoomTileHOC: React.FC = (props: Props) => { const hasLiveVoiceBroadcast = useHasRoomLiveVoiceBroadcast(props.room); return ; }; export default RoomTileHOC;