Add message preview support to the new room list (#29784)

* Add message preview support to the new room list

 * Support showing message previews in the room list items
 * Add the secondary filters bar with the '...' menu, containing
   just the option for message previews for now
 * Change message preview toggle hook to update when setting is updated

* Use new compund release

* Unused i18n keys

* Unused imports

* Fix test & update snapshot

* Fix more snapshots

* Fix test

Split into two tests that test setting & updating

* Type import

* Snapshots

* Remove unnecessary Flex container

and update screenshots as the room list has got shorter from the added bar

* More snapshots & screenshots

* More snapshots

* Add test and remove active filter that's not done yet

* Update snapshots & screenshots again

* Other screenshot

* Add more tests

* Fix syntax

* Fix tests

* Use setter directly

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix CSS

* Remopve filter button css for now

* Update to remove forwardRef

* Add comment on why lack of TypedEventEmitter

* snapshots again

* Screenshots again

* Use original screenshots, maybe they'll work now

* Add comment

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
David Baker
2025-04-24 16:03:39 +01:00
committed by GitHub
parent 22d5c00174
commit 714f8f40dd
30 changed files with 674 additions and 92 deletions

View File

@@ -16,11 +16,14 @@ import { _t } from "../../../languageHandler";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { useEventEmitter, 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";
import DMRoomMap from "../../../utils/DMRoomMap";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import { useMessagePreviewToggle } from "./useMessagePreviewToggle";
export interface RoomListItemViewState {
/**
@@ -60,6 +63,11 @@ export interface RoomListItemViewState {
* Whether there are participants in the call.
*/
hasParticipantInCall: boolean;
/**
* 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.
*/
@@ -104,6 +112,8 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
!invited &&
(hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
const messagePreview = useRoomMessagePreview(room);
// Video room
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
// EC video call or video room
@@ -134,6 +144,7 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
isVideoRoom,
callConnectionState,
hasParticipantInCall,
messagePreview,
showNotificationDecoration,
};
}
@@ -190,3 +201,36 @@ function getA11yLabel(roomName: string, notificationState: RoomNotificationState
return _t("room_list|room|open_room", { roomName });
}
}
function useRoomMessagePreview(room: Room): string | undefined {
const { shouldShowMessagePreview } = useMessagePreviewToggle();
const [previewText, setPreviewText] = useState<string | undefined>(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,
);
if (messagePreview) 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;
}

View File

@@ -4,10 +4,11 @@
* 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, useState } from "react";
import { useCallback } from "react";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import { useSettingValue } from "../../../hooks/useSettings";
interface MessagePreviewToggleState {
shouldShowMessagePreview: boolean;
@@ -20,17 +21,12 @@ interface MessagePreviewToggleState {
* - Provides a function to toggle message previews.
*/
export function useMessagePreviewToggle(): MessagePreviewToggleState {
const [shouldShowMessagePreview, setShouldShowMessagePreview] = useState(() =>
SettingsStore.getValue("RoomList.showMessagePreview"),
);
const shouldShowMessagePreview = useSettingValue("RoomList.showMessagePreview");
const toggleMessagePreview = useCallback((): void => {
setShouldShowMessagePreview((current) => {
const toggled = !current;
SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled);
return toggled;
});
}, []);
const toggled = !shouldShowMessagePreview;
SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled);
}, [shouldShowMessagePreview]);
return { toggleMessagePreview, shouldShowMessagePreview };
}

View File

@@ -15,7 +15,7 @@ import { RoomListItemMenuView } from "./RoomListItemMenuView";
import { NotificationDecoration } from "../NotificationDecoration";
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
interface RoomListItemViewPropsProps extends React.HTMLAttributes<HTMLButtonElement> {
interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement> {
/**
* The room to display
*/
@@ -33,7 +33,7 @@ export const RoomListItemView = memo(function RoomListItemView({
room,
isSelected,
...props
}: RoomListItemViewPropsProps): JSX.Element {
}: RoomListItemViewProps): JSX.Element {
const vm = useRoomListItemViewModel(room);
const [isHover, setIsHover] = useState(false);
@@ -73,9 +73,12 @@ export const RoomListItemView = memo(function RoomListItemView({
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={vm.name}>
{vm.name}
</span>
<div className="mx_RoomListItemView_text">
<div className="mx_RoomListItemView_roomName" title={vm.name}>
{vm.name}
</div>
<div className="mx_RoomListItemView_messagePreview">{vm.messagePreview}</div>
</div>
{showHoverDecoration ? (
<RoomListItemMenuView
room={room}

View File

@@ -0,0 +1,59 @@
/*
* 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 { IconButton, Menu, MenuTitle, CheckboxMenuItem, Tooltip } from "@vector-im/compound-web";
import React, { type Ref, type JSX, useState } from "react";
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { _t } from "../../../../languageHandler";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
interface MenuTriggerProps extends React.ComponentProps<typeof IconButton> {
ref?: Ref<HTMLButtonElement>;
}
const MenuTrigger = ({ ref, ...props }: MenuTriggerProps): JSX.Element => (
<Tooltip label={_t("room_list|room_options")}>
<IconButton
className="mx_RoomListSecondaryFilters_roomOptionsButton"
aria-label={_t("room_list|room_options")}
{...props}
ref={ref}
>
<OverflowHorizontalIcon />
</IconButton>
</Tooltip>
);
interface Props {
/**
* The view model for the room list view
*/
vm: RoomListViewState;
}
export function RoomListOptionsMenu({ vm }: Props): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|room_options")}
showTitle={false}
align="start"
trigger={<MenuTrigger />}
>
<MenuTitle title={_t("room_list|appearance")} />
<CheckboxMenuItem
label={_t("room_list|show_message_previews")}
onSelect={vm.toggleMessagePreview}
checked={vm.shouldShowMessagePreview}
/>
</Menu>
);
}

View File

@@ -0,0 +1,36 @@
/*
* 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 React, { type JSX } from "react";
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { Flex } from "../../../utils/Flex";
import { _t } from "../../../../languageHandler";
import { RoomListOptionsMenu } from "./RoomListOptionsMenu";
interface Props {
/**
* The view model for the room list
*/
vm: RoomListViewState;
}
/**
* The secondary filters for the room list (eg. mentions only / invites only).
*/
export function RoomListSecondaryFilters({ vm }: Props): JSX.Element {
return (
<Flex
aria-label={_t("room_list|secondary_filters")}
className="mx_RoomListSecondaryFilters"
align="center"
gap="8px"
>
<RoomListOptionsMenu vm={vm} />
</Flex>
);
}

View File

@@ -11,6 +11,7 @@ import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewM
import { RoomList } from "./RoomList";
import { EmptyRoomList } from "./EmptyRoomList";
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
import { RoomListSecondaryFilters } from "./RoomListSecondaryFilters";
/**
* Host the room list and the (future) room filters
@@ -22,6 +23,7 @@ export function RoomListView(): JSX.Element {
return (
<>
<RoomListPrimaryFilters vm={vm} />
<RoomListSecondaryFilters vm={vm} />
{isRoomListEmpty ? <EmptyRoomList vm={vm} /> : <RoomList vm={vm} />}
</>
);

View File

@@ -2102,6 +2102,7 @@
"room_list": {
"add_room_label": "Add room",
"add_space_label": "Add space",
"appearance": "Appearance",
"breadcrumbs_empty": "No recently visited rooms",
"breadcrumbs_label": "Recently visited rooms",
"empty": {
@@ -2152,7 +2153,10 @@
"more_options": "More Options",
"open_room": "Open room %(roomName)s"
},
"room_options": "Room Options",
"secondary_filters": "Secondary filters",
"show_less": "Show less",
"show_message_previews": "Show message previews",
"show_n_more": {
"one": "Show %(count)s more",
"other": "Show %(count)s more"