/* * Copyright 2025 New Vector Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial * Please see LICENSE files in the repository root for full details. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; import dispatcher from "../../../dispatcher/dispatcher"; import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../../dispatcher/actions"; import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils"; import { _t } from "../../../languageHandler"; import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { DefaultTagID } from "../../../stores/room-list/models"; import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall"; import { CallEvent, type ConnectionState } from "../../../models/Call"; import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import DMRoomMap from "../../../utils/DMRoomMap"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import { useMessagePreviewToggle } from "./useMessagePreviewToggle"; export interface RoomListItemViewState { /** * The name of the room. */ name: string; /** * Whether the context menu should be shown. */ showContextMenu: boolean; /** * Whether the hover menu should be shown. */ showHoverMenu: boolean; /** * Open the room having given roomId. */ openRoom: () => void; /** * The a11y label for the room list item. */ a11yLabel: string; /** * The notification state of the room. */ notificationState: RoomNotificationState; /** * Whether the room should be bolded. */ isBold: boolean; /** * Whether the room is a video room */ isVideoRoom: boolean; /** * The connection state of the call. * `null` if there is no call in the room. */ callConnectionState: ConnectionState | null; /** * Whether there are participants in the call. */ hasParticipantInCall: boolean; /** * Whether the call is a voice or video call. */ callType: CallType | undefined; /** * Pre-rendered and translated preview for the latest message in the room, or undefined * if no preview should be shown. */ messagePreview: string | undefined; /** * Whether the notification decoration should be shown. */ showNotificationDecoration: boolean; } /** * View model for the room list item * @see {@link RoomListItemViewState} for more information about what this view model returns. */ 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 [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)); }); // If the notification reference change due to room change, update the values useEffect(() => { setNotificationValues(getNotificationValues(notificationState)); }, [notificationState]); // We don't want to show the menus if // - there is an invitation for this room // - the user doesn't have access to notification and more options menus const showContextMenu = !invited && hasAccessToOptionsMenu(room); const showHoverMenu = !invited && (showContextMenu || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived)); const messagePreview = useRoomMessagePreview(room); // Video room const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom(); // EC video call or video room const call = useCall(room.roomId); const connectionState = useConnectionState(call); const participantCount = useParticipantCount(call); const callConnectionState = call ? connectionState : null; const showNotificationDecoration = hasVisibleNotification || participantCount > 0; // Actions const openRoom = useCallback((): void => { dispatcher.dispatch({ action: Action.ViewRoom, room_id: room.roomId, metricsTrigger: "RoomList", }); }, [room]); const [callType, setCallType] = useState(CallType.Video); useTypedEventEmitter(call ?? undefined, CallEvent.CallTypeChanged, setCallType); return { name, notificationState, showContextMenu, showHoverMenu, openRoom, a11yLabel, isBold, isVideoRoom, callConnectionState, hasParticipantInCall: participantCount > 0, messagePreview, showNotificationDecoration, callType: call ? callType : undefined, }; } /** * 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 roomName * @param notificationState */ function getA11yLabel(roomName: string, notificationState: RoomNotificationState): string { if (notificationState.isUnsentMessage) { return _t("a11y|room_messsage_not_sent", { roomName, }); } else if (notificationState.invited) { return _t("a11y|room_n_unread_invite", { roomName, }); } else if (notificationState.isMention) { return _t("a11y|room_n_unread_messages_mentions", { roomName, count: notificationState.count, }); } else if (notificationState.hasUnreadCount) { return _t("a11y|room_n_unread_messages", { roomName, count: notificationState.count, }); } else { return _t("room_list|room|open_room", { roomName }); } } function useRoomMessagePreview(room: Room): string | undefined { const { shouldShowMessagePreview } = useMessagePreviewToggle(); const [previewText, setPreviewText] = useState(undefined); const updatePreview = useCallback(async () => { if (!shouldShowMessagePreview) { setPreviewText(undefined); return; } const roomIsDM = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId)); // For the tag, we only care about whether the room is a DM or not as we don't show // display names in previewsd for DMs, so anything else we just say is 'untagged' // (even though it could actually be have other tags: we don't care about them). const messagePreview = await MessagePreviewStore.instance.getPreviewForRoom( room, roomIsDM ? DefaultTagID.DM : DefaultTagID.Untagged, ); setPreviewText(messagePreview?.text); }, [room, shouldShowMessagePreview]); // MessagePreviewStore and the other AsyncStores need to be converted to TypedEventEmitter useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => { updatePreview(); }); useEffect(() => { updatePreview(); }, [updatePreview]); return previewText; }