From 3ab24174b1bcf95f1f701efc6158849ec1bb2203 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Thu, 19 Jan 2023 16:04:30 +0000 Subject: [PATCH] Favourite Messages view --- res/css/_components.pcss | 1 + .../structures/_FavouriteMessagesView.pcss | 96 ++++ res/img/element-icons/room/clear-all.svg | 1 + res/img/element-icons/room/sort-twoway.svg | 11 + src/PageTypes.ts | 1 + src/PosthogTrackers.ts | 1 + .../ConfirmClearFavouritesDialog.tsx | 57 +++ .../FavouriteMessageTile.tsx | 121 +++++ .../FavouriteMessagesHeader.tsx | 106 +++++ .../FavouriteMessagesPanel.tsx | 78 ++++ .../FavouriteMessagesTilesList.tsx | 99 ++++ .../FavouriteMessagesView.tsx | 140 ++++++ src/components/structures/LeftPanel.tsx | 1 + src/components/structures/LoggedInView.tsx | 5 + src/components/structures/MatrixChat.tsx | 17 + .../views/messages/MessageActionBar.tsx | 40 +- src/components/views/rooms/EventTile.tsx | 11 + src/components/views/rooms/RoomList.tsx | 10 +- src/dispatcher/actions.ts | 10 + src/hooks/useFavouriteMessages.ts | 63 ++- src/i18n/strings/en_EN.json | 2 + src/settings/Settings.tsx | 5 + src/stores/FavouriteMessagesStore.ts | 136 ++++++ .../structures/FavouriteMessageTile-test.tsx | 88 ++++ .../FavouriteMessagesHeader-test.tsx | 79 ++++ .../FavouriteMessagesPanel-test.tsx | 108 +++++ .../structures/FavouriteMessagesView-test.tsx | 317 +++++++++++++ .../FavouriteMessageTile-test.tsx.snap | 323 +++++++++++++ .../FavouriteMessagesHeader-test.tsx.snap | 119 +++++ .../FavouriteMessagesPanel-test.tsx.snap | 265 +++++++++++ .../FavouriteMessagesView-test.tsx.snap | 434 ++++++++++++++++++ .../MessageActionBar-favourites-test.tsx | 135 ++++++ .../views/messages/MessageActionBar-test.tsx | 53 --- test/hooks/useFavouriteMessages-test.tsx | 65 +++ 34 files changed, 2918 insertions(+), 80 deletions(-) create mode 100644 res/css/structures/_FavouriteMessagesView.pcss create mode 100644 res/img/element-icons/room/clear-all.svg create mode 100644 res/img/element-icons/room/sort-twoway.svg create mode 100644 src/components/structures/FavouriteMessagesView/ConfirmClearFavouritesDialog.tsx create mode 100644 src/components/structures/FavouriteMessagesView/FavouriteMessageTile.tsx create mode 100644 src/components/structures/FavouriteMessagesView/FavouriteMessagesHeader.tsx create mode 100644 src/components/structures/FavouriteMessagesView/FavouriteMessagesPanel.tsx create mode 100644 src/components/structures/FavouriteMessagesView/FavouriteMessagesTilesList.tsx create mode 100644 src/components/structures/FavouriteMessagesView/FavouriteMessagesView.tsx create mode 100644 src/stores/FavouriteMessagesStore.ts create mode 100644 test/components/structures/FavouriteMessageTile-test.tsx create mode 100644 test/components/structures/FavouriteMessagesHeader-test.tsx create mode 100644 test/components/structures/FavouriteMessagesPanel-test.tsx create mode 100644 test/components/structures/FavouriteMessagesView-test.tsx create mode 100644 test/components/structures/__snapshots__/FavouriteMessageTile-test.tsx.snap create mode 100644 test/components/structures/__snapshots__/FavouriteMessagesHeader-test.tsx.snap create mode 100644 test/components/structures/__snapshots__/FavouriteMessagesPanel-test.tsx.snap create mode 100644 test/components/structures/__snapshots__/FavouriteMessagesView-test.tsx.snap create mode 100644 test/components/views/messages/MessageActionBar-favourites-test.tsx create mode 100644 test/hooks/useFavouriteMessages-test.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index fe50417c006..906e5e56c49 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -53,6 +53,7 @@ @import "./structures/_CompatibilityPage.pcss"; @import "./structures/_ContextualMenu.pcss"; @import "./structures/_ErrorMessage.pcss"; +@import "./structures/_FavouriteMessagesView.pcss"; @import "./structures/_FileDropTarget.pcss"; @import "./structures/_FilePanel.pcss"; @import "./structures/_GenericDropdownMenu.pcss"; diff --git a/res/css/structures/_FavouriteMessagesView.pcss b/res/css/structures/_FavouriteMessagesView.pcss new file mode 100644 index 00000000000..94eeba3cf93 --- /dev/null +++ b/res/css/structures/_FavouriteMessagesView.pcss @@ -0,0 +1,96 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_FavMessagesHeader { + position: fixed; + top: 0; + left: 0; + width: 100%; + flex: 0 0 50px; + border-bottom: 1px solid $primary-hairline-color; + background-color: $background; + z-index: 999; +} + +.mx_FavMessagesHeader_Wrapper { + height: 44px; + display: flex; + align-items: center; + min-width: 0; + margin: 0 20px 0 16px; + padding-top: 8px; + border-bottom: 1px solid $system; + justify-content: space-between; + + .mx_FavMessagesHeader_Wrapper_left { + display: flex; + align-items: center; + flex: 0.4; + + & > span { + color: $primary-content; + font-weight: $font-semi-bold; + font-size: $font-18px; + margin: 0 8px; + } + } + + .mx_FavMessagesHeader_Wrapper_right { + display: flex; + align-items: center; + flex: 0.6; + justify-content: flex-end; + } +} + +.mx_FavMessagesHeader_sortButton::before { + mask-image: url("$(res)/img/element-icons/room/sort-twoway.svg"); +} + +.mx_FavMessagesHeader_clearAllButton::before { + mask-image: url("$(res)/img/element-icons/room/clear-all.svg"); +} + +.mx_FavMessagesHeader_cancelButton { + background-color: $alert; + mask: url("$(res)/img/cancel.svg"); + mask-repeat: no-repeat; + mask-position: center; + mask-size: 17px; + padding: 9px; + margin: 0 12px 0 3px; + cursor: pointer; +} + +.mx_FavMessagesHeader_Search { + width: 70%; +} + +.mx_FavouriteMessages_emptyMarker { + display: flex; + align-items: center; + justify-content: center; + font-size: 25px; + font-weight: 600; +} + +.mx_FavouriteMessages_scrollPanel { + margin-top: 25px; +} + +.mx_ClearDialog { + width: 100%; +} diff --git a/res/img/element-icons/room/clear-all.svg b/res/img/element-icons/room/clear-all.svg new file mode 100644 index 00000000000..dde0bb131b1 --- /dev/null +++ b/res/img/element-icons/room/clear-all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/element-icons/room/sort-twoway.svg b/res/img/element-icons/room/sort-twoway.svg new file mode 100644 index 00000000000..c1c68e3e87e --- /dev/null +++ b/res/img/element-icons/room/sort-twoway.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/PageTypes.ts b/src/PageTypes.ts index 1e181b4e3f1..ca17522c990 100644 --- a/src/PageTypes.ts +++ b/src/PageTypes.ts @@ -20,6 +20,7 @@ enum PageType { HomePage = "home_page", RoomView = "room_view", UserView = "user_view", + FavouriteMessagesView = "favourite_messages_view", } export default PageType; diff --git a/src/PosthogTrackers.ts b/src/PosthogTrackers.ts index 8a8b02965ce..5ec2e4578a8 100644 --- a/src/PosthogTrackers.ts +++ b/src/PosthogTrackers.ts @@ -41,6 +41,7 @@ const loggedInPageTypeMap: Record = { [PageType.HomePage]: "Home", [PageType.RoomView]: "Room", [PageType.UserView]: "User", + [PageType.FavouriteMessagesView]: "FavouriteMessages", }; export default class PosthogTrackers { diff --git a/src/components/structures/FavouriteMessagesView/ConfirmClearFavouritesDialog.tsx b/src/components/structures/FavouriteMessagesView/ConfirmClearFavouritesDialog.tsx new file mode 100644 index 00000000000..cbbd517e925 --- /dev/null +++ b/src/components/structures/FavouriteMessagesView/ConfirmClearFavouritesDialog.tsx @@ -0,0 +1,57 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { FC, useCallback } from "react"; + +import useFavouriteMessages from "../../../hooks/useFavouriteMessages"; +import { _t } from "../../../languageHandler"; +import BaseDialog from "../../views/dialogs/BaseDialog"; +import { IDialogProps } from "../../views/dialogs/IDialogProps"; +import DialogButtons from "../../views/elements/DialogButtons"; + +/** + * A dialog for confirming a clearing of favourite messages. + */ +const ConfirmClearFavouritesDialog: FC = (props: IDialogProps) => { + const { clearFavouriteMessages } = useFavouriteMessages(); + + const onConfirmClick = useCallback(() => { + clearFavouriteMessages(); + props.onFinished(); + }, [props, clearFavouriteMessages]); + + return ( + +
+
+ {_t("Are you sure you wish to clear all your favourite messages? ")} +
+
+ +
+ ); +}; + +export default ConfirmClearFavouritesDialog; diff --git a/src/components/structures/FavouriteMessagesView/FavouriteMessageTile.tsx b/src/components/structures/FavouriteMessagesView/FavouriteMessageTile.tsx new file mode 100644 index 00000000000..f6903b3db24 --- /dev/null +++ b/src/components/structures/FavouriteMessagesView/FavouriteMessageTile.tsx @@ -0,0 +1,121 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { FC } from "react"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +import RoomContext from "../../../contexts/RoomContext"; +import SettingsStore from "../../../settings/SettingsStore"; +import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import DateSeparator from "../../views/messages/DateSeparator"; +import EventTile from "../../views/rooms/EventTile"; +import { shouldFormContinuation } from "../MessagePanel"; +import { wantsDateSeparator } from "../../../DateUtils"; +import { haveRendererForEvent } from "../../../events/EventTileFactory"; +import { Layout } from "../../../settings/enums/Layout"; +import { FavouriteMessagesStore } from "../../../stores/FavouriteMessagesStore"; + +interface IProps { + // an event result object + result: MatrixEvent; + // href for the highlights in this result + resultLink: string; + // a list of strings to be highlighted in the results + searchHighlights?: string[]; + onHeightChanged?: () => void; + permalinkCreator?: RoomPermalinkCreator; + //a list containing the saved items events + timeline: MatrixEvent[]; + + // Provide this to use a different store for favourite messages + // e.g. in tests. If not supplied, we use the global default. + favouriteMessagesStore?: FavouriteMessagesStore; +} + +const FavouriteMessageTile: FC = (props: IProps) => { + let context!: React.ContextType; + + const result = props.result; + const eventId = result.getId(); + + const ts1 = result?.getTs(); + const ret = []; + const layout = SettingsStore.getValue("layout"); + const isTwelveHour = !!SettingsStore.getValue("showTwelveHourTimestamps"); + const alwaysShowTimestamps = !!SettingsStore.getValue("alwaysShowTimestamps"); + const threadsEnabled = !!SettingsStore.getValue("feature_threadenabled"); + + for (let j = 0; j < props?.timeline.length; j++) { + const mxEv = props?.timeline[j]; + const highlights = props?.searchHighlights; + + if (haveRendererForEvent(mxEv, context?.showHiddenEvents)) { + // do we need a date separator since the last event? + const prevEv = props.timeline[j - 1]; + // is this a continuation of the previous message? + const continuation = + !dateSeparator(prevEv, mxEv) && + shouldFormContinuation(prevEv, mxEv, context?.showHiddenEvents, threadsEnabled); + + let lastInSection = true; + const nextEv = props?.timeline[j + 1]; + if (nextEv) { + lastInSection = + dateSeparator(mxEv, nextEv) || + mxEv.getSender() !== nextEv.getSender() || + !shouldFormContinuation(mxEv, nextEv, context?.showHiddenEvents, threadsEnabled); + } + + ret.push( + , + ); + } + } + + return ( +
  • +
      {ret}
    +
  • + ); +}; + +function dateSeparator(event1: MatrixEvent | undefined, event2: MatrixEvent | undefined): boolean { + if (!event1 || !event2) { + return false; + } + const date1 = event1.getDate(); + const date2 = event2.getDate(); + if (!date1 || !date2) { + return false; + } + return wantsDateSeparator(date1, date2); +} + +export default FavouriteMessageTile; diff --git a/src/components/structures/FavouriteMessagesView/FavouriteMessagesHeader.tsx b/src/components/structures/FavouriteMessagesView/FavouriteMessagesHeader.tsx new file mode 100644 index 00000000000..63901c2b8d0 --- /dev/null +++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesHeader.tsx @@ -0,0 +1,106 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { FC, useCallback, useState } from "react"; + +import { Action } from "../../../dispatcher/actions"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import useFavouriteMessages from "../../../hooks/useFavouriteMessages"; +import { _t } from "../../../languageHandler"; +import { FavouriteMessagesStore } from "../../../stores/FavouriteMessagesStore"; +import RoomAvatar from "../../views/avatars/RoomAvatar"; +import AccessibleTooltipButton from "../../views/elements/AccessibleTooltipButton"; + +interface IProps { + query: string; + handleSearchQuery: (query: string) => void; + + // Provide this to use a different store for favourite messages + // e.g. in tests. If not supplied, we use the global default. + favouriteMessagesStore?: FavouriteMessagesStore; +} + +const FavouriteMessagesHeader: FC = ({ query, handleSearchQuery, favouriteMessagesStore }: IProps) => { + const { getFavouriteMessages } = useFavouriteMessages(favouriteMessagesStore); + const favouriteMessagesIds = getFavouriteMessages(); + + const [isSearchClicked, setSearchClicked] = useState(false); + + const onChange = useCallback((e) => handleSearchQuery(e.target.value), [handleSearchQuery]); + const onCancelClick = useCallback(() => { + setSearchClicked(false); + handleSearchQuery(""); + }, [handleSearchQuery, setSearchClicked]); + const onSearchClick = useCallback(() => setSearchClicked(true), [setSearchClicked]); + + const onClearClick = useCallback(() => { + if (favouriteMessagesIds.length > 0) { + defaultDispatcher.dispatch({ action: Action.OpenClearFavourites }); + } + }, [favouriteMessagesIds]); + + return ( +
    +
    +
    + + Favourite Messages +
    +
    + {isSearchClicked ? ( + <> + + + + ) : ( + + )} + +
    +
    +
    + ); +}; + +export default FavouriteMessagesHeader; diff --git a/src/components/structures/FavouriteMessagesView/FavouriteMessagesPanel.tsx b/src/components/structures/FavouriteMessagesView/FavouriteMessagesPanel.tsx new file mode 100644 index 00000000000..6bc890c10a8 --- /dev/null +++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesPanel.tsx @@ -0,0 +1,78 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import React, { FC, useRef } from "react"; + +import { _t } from "../../../languageHandler"; +import { FavouriteMessagesStore } from "../../../stores/FavouriteMessagesStore"; +import ResizeNotifier from "../../../utils/ResizeNotifier"; +import ScrollPanel from "../ScrollPanel"; +import FavouriteMessagesHeader from "./FavouriteMessagesHeader"; +import FavouriteMessagesTilesList from "./FavouriteMessagesTilesList"; + +interface IProps { + favouriteMessageEvents: MatrixEvent[] | null; + resizeNotifier?: ResizeNotifier; + searchQuery: string; + handleSearchQuery: (query: string) => void; + cli: MatrixClient; + + // Provide this to use a different store for favourite messages + // e.g. in tests. If not supplied, we use the global default. + favouriteMessagesStore?: FavouriteMessagesStore; +} + +const FavouriteMessagesPanel: FC = (props: IProps) => { + const favouriteMessagesPanelRef = useRef(); + + if (props.favouriteMessageEvents?.length === 0) { + return ( + <> + +

    {_t("No Favourite Messages")}

    + + ); + } else { + return ( + <> + + + + + + ); + } +}; +export default FavouriteMessagesPanel; diff --git a/src/components/structures/FavouriteMessagesView/FavouriteMessagesTilesList.tsx b/src/components/structures/FavouriteMessagesView/FavouriteMessagesTilesList.tsx new file mode 100644 index 00000000000..4f0de04795d --- /dev/null +++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesTilesList.tsx @@ -0,0 +1,99 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import React, { FC, useCallback } from "react"; + +import { _t } from "../../../languageHandler"; +import { FavouriteMessagesStore } from "../../../stores/FavouriteMessagesStore"; +import Spinner from "../../views/elements/Spinner"; +import FavouriteMessageTile from "./FavouriteMessageTile"; + +interface IProps { + favouriteMessageEvents: MatrixEvent[] | null; + favouriteMessagesPanelRef: any; + searchQuery: string; + cli: MatrixClient; + + // Provide this to use a different store for favourite messages + // e.g. in tests. If not supplied, we use the global default. + favouriteMessagesStore?: FavouriteMessagesStore; +} + +const FavouriteMessagesTilesList: FC = ({ + cli, + favouriteMessageEvents, + favouriteMessagesPanelRef, + searchQuery, + favouriteMessagesStore, +}: IProps) => { + const ret: JSX.Element[] = []; + let lastRoomId: string; + const highlights: string[] = []; + + // once dynamic content in the favourite messages panel loads, make the scrollPanel check + // the scroll offsets. + const onHeightChanged = useCallback(() => { + const scrollPanel = favouriteMessagesPanelRef.current; + if (scrollPanel) { + scrollPanel.checkScroll(); + } + }, [favouriteMessagesPanelRef]); + + if (!favouriteMessageEvents) { + ret.push(); + } else { + favouriteMessageEvents.forEach((mxEvent) => { + const timeline = [] as MatrixEvent[]; + const roomId = mxEvent.getRoomId(); + const room = cli?.getRoom(roomId); + + timeline.push(mxEvent); + if (searchQuery) { + highlights.push(searchQuery); + } + + if (roomId !== lastRoomId) { + ret.push( +
  • +

    + {_t("Room")}: {room ? room.name : ""} +

    +
  • , + ); + lastRoomId = roomId!; + } + + const resultLink = "#/room/" + roomId + "/" + mxEvent.getId(); + + ret.push( + , + ); + }); + } + + return <>{ret}; +}; + +export default FavouriteMessagesTilesList; diff --git a/src/components/structures/FavouriteMessagesView/FavouriteMessagesView.tsx b/src/components/structures/FavouriteMessagesView/FavouriteMessagesView.tsx new file mode 100644 index 00000000000..c666d35dc2c --- /dev/null +++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesView.tsx @@ -0,0 +1,140 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { FC, useCallback, useContext, useEffect, useState } from "react"; +import { MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import useFavouriteMessages from "../../../hooks/useFavouriteMessages"; +import ResizeNotifier from "../../../utils/ResizeNotifier"; +import FavouriteMessagesPanel from "./FavouriteMessagesPanel"; +import { FavouriteMessagesStore, FavouriteStorage } from "../../../stores/FavouriteMessagesStore"; + +interface IProps { + resizeNotifier?: ResizeNotifier; + + // Provide this to use a different store for favourite messages + // e.g. in tests. If not supplied, we use the global default. + favouriteMessagesStore?: FavouriteMessagesStore; +} + +const FavouriteMessagesView: FC = ({ resizeNotifier, favouriteMessagesStore }: IProps) => { + const matrixClient = useContext(MatrixClientContext); + const { getFavouriteMessages, registerFavouritesChangedListener } = useFavouriteMessages(favouriteMessagesStore); + const [searchQuery, setSearchQuery] = useState(""); + const [favouriteMessageEvents, setFavouriteMessageEvents] = useState(null); + + const recalcEvents = useCallback(async () => { + const faves = getFavouriteMessages(); + const newEvents = await calcEvents(searchQuery, faves, matrixClient); + setFavouriteMessageEvents(newEvents); + }, [searchQuery, matrixClient, getFavouriteMessages]); + + // Because finding events is async, we do it in useEffect, not useState. + useEffect(() => { + recalcEvents(); + }, [searchQuery, recalcEvents]); + + registerFavouritesChangedListener(() => recalcEvents()); + + const handleSearchQuery = (query: string): void => { + setSearchQuery(query); + }; + + const props = { + favouriteMessageEvents, + resizeNotifier, + searchQuery, + handleSearchQuery, + cli: matrixClient, + favouriteMessagesStore, + }; + + return ; +}; + +function filterFavourites(searchQuery: string, favouriteMessages: FavouriteStorage[]): FavouriteStorage[] { + return favouriteMessages.filter((f) => + f.content.body.trim().toLowerCase().includes(searchQuery.trim().toLowerCase()), + ); +} + +/** If the event was edited, update it with the replacement content */ +async function updateEventIfEdited(event: MatrixEvent, matrixClient: MatrixClient): Promise { + const roomId = event.getRoomId(); + const eventId = event.getId(); + if (roomId && eventId) { + const { events } = await matrixClient.relations(roomId, eventId, RelationType.Replace, null, { limit: 1 }); + const editEvent = events?.length > 0 ? events[0] : null; + if (editEvent) { + event.makeReplaced(editEvent); + } + } +} + +/** + * Use the supplied MatrixClient to fetch the event specified in favourite. + * Takes a RunState and gives up early if runState.isCancelled(). + */ +async function fetchEvent(favourite: FavouriteStorage, matrixClient: MatrixClient): Promise { + try { + const evJson = await matrixClient.fetchRoomEvent(favourite.roomId, favourite.eventId); + const event = new MatrixEvent(evJson); + const roomId = event?.getRoomId(); + const room = roomId ? matrixClient.getRoom(roomId) : null; + if (!event || !room) { + return null; + } + + // Decrypt the event + if (event.isEncrypted()) { + // Modifies the event in-place (!) + await matrixClient.decryptEventIfNeeded(event); + } + + // Inject sender information + const sender = event.getSender(); + if (sender) { + event.sender = room.getMember(sender)!; + } + + await updateEventIfEdited(event, matrixClient); + + return event; + } catch (err) { + logger.error(err); + return null; + } +} + +/** + * Use the supplied MatrixClient to fetch all the events for the supplies + * favouriteMessages, filtered using searchQuery. + * Takes a RunState and gives up early if runState.isCancelled(). + */ +async function calcEvents( + searchQuery: string, + favouriteMessages: FavouriteStorage[], + matrixClient: MatrixClient, +): Promise { + const displayedFavourites: FavouriteStorage[] = filterFavourites(searchQuery, favouriteMessages); + const promises: Promise[] = displayedFavourites.map((f) => fetchEvent(f, matrixClient)); + const events = await Promise.all(promises); + return events.filter((e) => e !== null) as MatrixEvent[]; // force cast because typescript doesn't understand what `filter` does +} + +export default FavouriteMessagesView; diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index c1ae12019cd..b8322650b34 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -378,6 +378,7 @@ export default class LeftPanel extends React.Component { onResize={this.refreshStickyHeaders} onListCollapse={this.refreshStickyHeaders} ref={this.roomListRef} + pageType={this.props.pageType} /> ); diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 242bbdc0280..3a4a28b06e7 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -71,6 +71,7 @@ import { IConfigOptions } from "../../IConfigOptions"; import LeftPanelLiveShareWarning from "../views/beacon/LeftPanelLiveShareWarning"; import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage"; import { PipContainer } from "./PipContainer"; +import FavouriteMessagesView from "./FavouriteMessagesView/FavouriteMessagesView"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -647,6 +648,10 @@ class LoggedInView extends React.Component { case PageTypes.UserView: pageElement = ; break; + + case PageTypes.FavouriteMessagesView: + pageElement = ; + break; } const wrapperClasses = classNames({ diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index dbbb40cfeaf..41d72354991 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -138,6 +138,7 @@ import { VoiceBroadcastResumer } from "../../voice-broadcast"; import GenericToast from "../views/toasts/GenericToast"; import { Linkify } from "../views/elements/Linkify"; import RovingSpotlightDialog, { Filter } from "../views/dialogs/spotlight/SpotlightDialog"; +import ConfirmClearFavouritesDialog from "./FavouriteMessagesView/ConfirmClearFavouritesDialog"; // legacy export export { default as Views } from "../../Views"; @@ -740,6 +741,10 @@ export default class MatrixChat extends React.PureComponent { this.viewSomethingBehindModal(); break; } + case Action.OpenClearFavourites: { + Modal.createDialog(ConfirmClearFavouritesDialog); + break; + } case "view_welcome_page": this.viewWelcome(); break; @@ -839,6 +844,9 @@ export default class MatrixChat extends React.PureComponent { hideToSRUsers: false, }); break; + case Action.ViewFavouriteMessages: + this.viewFavouriteMessages(); + break; case Action.PseudonymousAnalyticsAccept: hideAnalyticsToast(); SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, true); @@ -1039,6 +1047,11 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher.recheck(); } + private viewFavouriteMessages(): void { + this.setPage(PageType.FavouriteMessagesView); + this.notifyNewScreen("favourite_messages"); + } + private viewUser(userId: string, subAction: string): void { // Wait for the first sync so that `getRoom` gives us a room object if it's // in the sync response @@ -1757,6 +1770,10 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: Action.ViewHomePage, }); + } else if (screen === "favourite_messages") { + dis.dispatch({ + action: Action.ViewFavouriteMessages, + }); } else if (screen === "start") { this.showScreen("home"); dis.dispatch({ diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 25cb8211236..54fbfb875bf 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactElement, useCallback, useContext, useEffect } from "react"; +import React, { FC, ReactElement, useCallback, useContext, useEffect, useState } from "react"; import { EventStatus, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import classNames from "classnames"; import { MsgType, RelationType } from "matrix-js-sdk/src/@types/event"; @@ -60,6 +60,7 @@ import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayloa import useFavouriteMessages from "../../../hooks/useFavouriteMessages"; import { GetRelationsForEvent } from "../rooms/EventTile"; import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast/types"; +import { FavouriteMessagesStore } from "../../../stores/FavouriteMessagesStore"; interface IOptionsButtonProps { mxEvent: MatrixEvent; @@ -274,14 +275,19 @@ const ReplyInThreadButton: React.FC = ({ mxEvent }) => { interface IFavouriteButtonProp { mxEvent: MatrixEvent; + favouriteMessagesStore?: FavouriteMessagesStore; } -const FavouriteButton: React.FC = ({ mxEvent }) => { - const { isFavourite, toggleFavourite } = useFavouriteMessages(); +const FavouriteButton: FC = ({ mxEvent, favouriteMessagesStore }: IFavouriteButtonProp) => { + const { isFavourite, toggleFavourite, registerFavouritesChangedListener } = + useFavouriteMessages(favouriteMessagesStore); + const [, forceRefresh] = useState([]); + + registerFavouritesChangedListener(() => forceRefresh([])); const eventId = mxEvent.getId(); - const classes = classNames("mx_MessageActionBar_iconButton mx_MessageActionBar_favouriteButton", { - mx_MessageActionBar_favouriteButton_fillstar: isFavourite(eventId), + const classes = classNames("mx_MessageActionBar_iconButton", { + mx_MessageActionBar_favouriteButton_fillstar: isFavourite(mxEvent.getId() ?? ""), }); const onClick = useCallback( @@ -290,9 +296,9 @@ const FavouriteButton: React.FC = ({ mxEvent }) => { e.preventDefault(); e.stopPropagation(); - toggleFavourite(eventId); + toggleFavourite(mxEvent); }, - [toggleFavourite, eventId], + [mxEvent, toggleFavourite], ); return ( @@ -319,6 +325,14 @@ interface IMessageActionBarProps { toggleThreadExpanded: () => void; isQuoteExpanded?: boolean; getRelationsForEvent?: GetRelationsForEvent; + + // Is this tile being shown in the FavouritesView? + // (If so, we don't allow editing messages.) + isFavouritesView?: boolean; + + // Provide this to use a different store for favourite messages + // e.g. in tests. If not supplied, we use the global default. + favouriteMessagesStore?: FavouriteMessagesStore; } export default class MessageActionBar extends React.PureComponent { @@ -442,7 +456,7 @@ export default class MessageActionBar extends React.PureComponent); + toolbarOpts.splice( + -1, + 0, + , + ); } // XXX: Assuming that the underlying tile will be a media event if it is eligible media. diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 5620ab93566..35637d0e635 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -83,6 +83,7 @@ import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import { ElementCall } from "../../../models/Call"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar"; +import { FavouriteMessagesStore } from "../../../stores/FavouriteMessagesStore"; export type GetRelationsForEvent = ( eventId: string, @@ -224,6 +225,14 @@ export interface EventTileProps { // displayed to the current user either because they're // the author or they are a moderator isSeeingThroughMessageHiddenForModeration?: boolean; + + // Is this tile being shown in the FavouritesView? + // (If so, we don't allow editing messages.) + isFavouritesView?: boolean; + + // Provide this to use a different store for favourite messages + // e.g. in tests. If not supplied, we use the global default. + favouriteMessagesStore?: FavouriteMessagesStore; } interface IState { @@ -1110,6 +1119,8 @@ export class UnwrappedEventTile extends React.Component isQuoteExpanded={isQuoteExpanded} toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)} getRelationsForEvent={this.props.getRelationsForEvent} + isFavouritesView={this.props.isFavouritesView} + favouriteMessagesStore={this.props.favouriteMessagesStore} /> ) : undefined; diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index b28e87022bb..59c789b7c0e 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -62,6 +62,7 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import ExtraTile from "./ExtraTile"; import RoomSublist, { IAuxButtonProps } from "./RoomSublist"; import { SdkContextClass } from "../../../contexts/SDKContext"; +import PageType from "../../../PageTypes"; interface IProps { onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void; @@ -72,6 +73,7 @@ interface IProps { resizeNotifier: ResizeNotifier; isMinimized: boolean; activeSpace: SpaceKey; + pageType?: PageType; } interface IState { @@ -614,10 +616,10 @@ export default class RoomList extends React.PureComponent { return [ ""} + onClick={onFavouriteClicked} key="favMessagesTile_key" />, ]; @@ -709,3 +711,7 @@ export default class RoomList extends React.PureComponent { ); } } + +const onFavouriteClicked = (): void => { + defaultDispatcher.dispatch({ action: Action.ViewFavouriteMessages }); +}; diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 774fbc1e8ff..77d8c957d81 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -346,4 +346,14 @@ export enum Action { * Fired when we want to view a thread, either a new one or an existing one */ ShowThread = "show_thread", + + /** + * Fired when we want to view favourited messages panel. No additional payload information required. + */ + ViewFavouriteMessages = "view_favourite_messages", + + /** + * Fired when we want to clear all favourited messages. No additional payload information required. + */ + OpenClearFavourites = "open_clear_favourites", } diff --git a/src/hooks/useFavouriteMessages.ts b/src/hooks/useFavouriteMessages.ts index fe5b23dc9ed..4f5096e08ce 100644 --- a/src/hooks/useFavouriteMessages.ts +++ b/src/hooks/useFavouriteMessages.ts @@ -14,30 +14,61 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useState } from "react"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { useCallback, useEffect } from "react"; -const favouriteMessageIds = JSON.parse(localStorage?.getItem("io_element_favouriteMessages") ?? "[]") as string[]; +import { FavouriteMessagesStore, FavouriteStorage } from "../stores/FavouriteMessagesStore"; -export default function useFavouriteMessages(): { - toggleFavourite: (eventId: string) => void; +export default function useFavouriteMessages(favouriteMessageStore = FavouriteMessagesStore.instance): { + getFavouriteMessages: () => FavouriteStorage[]; isFavourite: (eventId: string) => boolean; + toggleFavourite: (mxEvent: MatrixEvent) => void; + clearFavouriteMessages: () => void; + registerFavouritesChangedListener: (listener: () => void) => void; } { - const [, setX] = useState(); + const myListeners = []; - //checks if an id already exist - const isFavourite = (eventId: string): boolean => favouriteMessageIds.includes(eventId); + const isFavourite: (eventId: string) => boolean = useCallback( + (eventId: string): boolean => favouriteMessageStore.isFavourite(eventId), + [favouriteMessageStore], + ); - const toggleFavourite = (eventId: string): void => { - isFavourite(eventId) - ? favouriteMessageIds.splice(favouriteMessageIds.indexOf(eventId), 1) - : favouriteMessageIds.push(eventId); + const toggleFavourite: (mxEvent: MatrixEvent) => void = useCallback( + async (mxEvent: MatrixEvent) => { + const eventId = mxEvent.getId() ?? ""; + const roomId = mxEvent.getRoomId() ?? ""; + const content = mxEvent.getContent() ?? {}; + await favouriteMessageStore.toggleFavourite(eventId, roomId, content); + }, + [favouriteMessageStore], + ); - //update the local storage - localStorage.setItem("io_element_favouriteMessages", JSON.stringify(favouriteMessageIds)); + const clearFavouriteMessages: () => void = useCallback(async () => { + await favouriteMessageStore.clearAll(); + }, [favouriteMessageStore]); - // This forces a re-render to account for changes in appearance in real-time when the favourite button is toggled - setX([]); + const getFavouriteMessages: () => FavouriteStorage[] = useCallback(() => { + return favouriteMessageStore.allFavourites(); + }, [favouriteMessageStore]); + + const registerFavouritesChangedListener = (listener: () => void): void => { + favouriteMessageStore.addUpdatedListener(listener); + myListeners.push(listener); }; - return { isFavourite, toggleFavourite }; + useEffect(() => { + return () => { + for (const listener of myListeners) { + favouriteMessageStore.removeUpdatedListener(listener); + } + }; + }); + + return { + getFavouriteMessages, + isFavourite, + toggleFavourite, + clearFavouriteMessages, + registerFavouritesChangedListener, + }; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f3768067cd3..2f23c8341ab 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3459,6 +3459,8 @@ "Original event source": "Original event source", "Event ID: %(eventId)s": "Event ID: %(eventId)s", "Thread root ID: %(threadRootId)s": "Thread root ID: %(threadRootId)s", + "Are you sure you wish to clear all your favourite messages? ": "Are you sure you wish to clear all your favourite messages? ", + "No Favourite Messages": "No Favourite Messages", "Unable to verify this device": "Unable to verify this device", "Verify this device": "Verify this device", "Device verified": "Device verified", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index bfd40e96ce1..b99ddd654c1 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -768,6 +768,11 @@ export const SETTINGS: { [setting: string]: ISetting } = { supportedLevels: [SettingLevel.ACCOUNT], default: [], // list of room IDs, most recent first }, + "favourite_messages": { + // not really a setting + supportedLevels: [SettingLevel.ACCOUNT], + default: [], // list of FavouriteStorage + }, "room_directory_servers": { supportedLevels: [SettingLevel.ACCOUNT], default: [], diff --git a/src/stores/FavouriteMessagesStore.ts b/src/stores/FavouriteMessagesStore.ts new file mode 100644 index 00000000000..f1cf75bc22b --- /dev/null +++ b/src/stores/FavouriteMessagesStore.ts @@ -0,0 +1,136 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IContent } from "matrix-js-sdk/src/models/event"; + +import SettingsStore from "../settings/SettingsStore"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import { Action } from "../dispatcher/actions"; +import { ActionPayload } from "../dispatcher/payloads"; +import { AsyncStore } from "./AsyncStore"; +import { SettingLevel } from "../settings/SettingLevel"; + +export interface FavouriteStorage { + eventId: string; + roomId: string; + content: IContent; +} + +interface IState { + favourite_messages: FavouriteStorage[]; +} + +const settingName = "favourite_messages"; + +/** + * Stores favourite messages, backed by the SettingsStore. + */ +export class FavouriteMessagesStore extends AsyncStore { + private static internalInstance = new FavouriteMessagesStore(); + private updateListeners: (() => void)[] = []; + + /** + * Public for test. For normal usage, use the global instance at + * FavouriteMessagesStore.instance + */ + public constructor() { + super(defaultDispatcher, { favourite_messages: SettingsStore.getValue(settingName) }); + SettingsStore.monitorSetting(settingName, null); + } + + /** + * Get the global instance of this store. + * + * NOTE: you can also make your own instance for testing. If you do make a + * new instance, anyone who is listening to a different instane will not + * be notified about changes to this instance. + */ + public static get instance(): FavouriteMessagesStore { + return FavouriteMessagesStore.internalInstance; + } + + /** + * @returns true if the message with this eventId is a favourite. + */ + public isFavourite(eventId: string): boolean { + return this.state.favourite_messages.some((f) => f.eventId === eventId); + } + + /** + * If the message with the supplied eventId is a favourite, make it not a + * favourite. If it's not a favourite, make it a favourite. + * + * Requires only eventId to match to find an existing message. + * + * Ignores roomId and content if the favourite already exists and we are just + * unfavouriting it. + * + * If we are making a new favourite, stores roomId and content alongside it. + */ + public async toggleFavourite(eventId: string, roomId: string, content: IContent): Promise { + // Take a copy to modify + const favouriteMessages = this.state.favourite_messages.slice(); + + const idx = favouriteMessages.findIndex((f) => f.eventId === eventId); + if (idx !== -1) { + favouriteMessages.splice(idx, 1); + } else { + favouriteMessages.push({ eventId, roomId, content }); + } + + const newState: IState = { favourite_messages: favouriteMessages }; + await this.updateState(newState); + + // Don't await SettingsStore.setValue - fire and forget + SettingsStore.setValue(settingName, null, SettingLevel.ACCOUNT, favouriteMessages); + } + + /** + * Clear the entire list of favourite messages, so no messages are favourite + * any more. + */ + public async clearAll(): Promise { + const newState: IState = { favourite_messages: [] }; + await this.reset(newState); + // Don't await SettingsStore.setValue - fire and forget + SettingsStore.setValue(settingName, null, SettingLevel.ACCOUNT, []); + } + + /** + * @returns a copy of the list of all favourite messages. + */ + public allFavourites(): FavouriteStorage[] { + return JSON.parse(JSON.stringify(this.state.favourite_messages)); + } + + public addUpdatedListener(listener: () => void): void { + this.updateListeners.push(listener); + } + + public removeUpdatedListener(listener: () => void): void { + this.updateListeners = this.updateListeners.filter((l) => l !== listener); + } + + protected async onDispatch(payload: ActionPayload): Promise { + if (payload.action === Action.SettingUpdated && payload.settingName === settingName) { + const newValue = payload.newValueAtLevel; + this.updateState({ favourite_messages: newValue }); + for (const listener of this.updateListeners) { + listener(); + } + } + } +} diff --git a/test/components/structures/FavouriteMessageTile-test.tsx b/test/components/structures/FavouriteMessageTile-test.tsx new file mode 100644 index 00000000000..7166a405e46 --- /dev/null +++ b/test/components/structures/FavouriteMessageTile-test.tsx @@ -0,0 +1,88 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { render } from "@testing-library/react"; +import { EventType, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; + +import { stubClient } from "../../test-utils"; +import SettingsStore from "../../../src/settings/SettingsStore"; +import FavouriteMessageTile from "../../../src/components/structures/FavouriteMessagesView/FavouriteMessageTile"; + +describe("FavouriteMessageTile", () => { + const userId = "@alice:server.org"; + const roomId = "!room:server.org"; + const eventBefore = new MatrixEvent({ + type: EventType.RoomMessage, + sender: userId, + room_id: roomId, + content: { + msgtype: MsgType.Text, + body: "i am before", + }, + event_id: "$alices_message", + origin_server_ts: 111111111111, + }); + const alicesEvent = new MatrixEvent({ + type: EventType.RoomMessage, + sender: userId, + room_id: roomId, + content: { + msgtype: MsgType.Text, + body: "i am alice", + }, + event_id: "$alices_message", + origin_server_ts: 222222222222, + }); + const eventAfter = new MatrixEvent({ + type: EventType.RoomMessage, + sender: userId, + room_id: roomId, + content: { + msgtype: MsgType.Text, + body: "i am after", + }, + event_id: "$alices_message", + origin_server_ts: 333333333333, + }); + + beforeEach(async () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_favourite_messages"); + stubClient(); + }); + + afterEach(async () => { + jest.resetAllMocks(); + }); + + it("displays the favourite content", async () => { + const view = render(); + view.getByText("i am alice"); + expect(view.asFragment()).toMatchSnapshot(); + }); + + it("displays the favourite content within a timeline", async () => { + const view = render( + , + ); + view.getByText("i am alice"); + expect(view.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/structures/FavouriteMessagesHeader-test.tsx b/test/components/structures/FavouriteMessagesHeader-test.tsx new file mode 100644 index 00000000000..c1151278979 --- /dev/null +++ b/test/components/structures/FavouriteMessagesHeader-test.tsx @@ -0,0 +1,79 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { render } from "@testing-library/react"; + +import { stubClient } from "../../test-utils"; +import SettingsStore from "../../../src/settings/SettingsStore"; +import FavouriteMessagesHeader from "../../../src/components/structures/FavouriteMessagesView/FavouriteMessagesHeader"; + +describe("FavouriteMessagesHeader", () => { + beforeEach(async () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_favourite_messages"); + stubClient(); + }); + + afterEach(async () => { + jest.resetAllMocks(); + }); + + it("displays a title and buttons", async () => { + const view = render( {}} />); + view.getByTestId("avatar-img"); + view.getByText("Favourite Messages"); + view.getByLabelText("Search"); + view.getByLabelText("Clear"); + expect(view.asFragment()).toMatchSnapshot(); + }); + + it("displays a search box after Search is clicked", async () => { + // Given a favourites header with a (hidden) search query + const view = render( {}} />); + expect(view.queryByRole("textbox")).toBeNull(); + + // When we click Search + view.getByLabelText("Search").click(); + + // Then the search box appears + const textbox = view.getByRole("textbox"); + // And it contains our search query + expect(textbox.getAttribute("placeholder")).toBe("Search..."); + expect(textbox.getAttribute("value")).toBe("foo"); + + expect(view.asFragment()).toMatchSnapshot(); + }); + + it("hides the search box when you click Cancel", async () => { + // Given a favourites header where Search has been clicked + const view = render( {}} />); + expect(view.queryByRole("textbox")).toBeNull(); + view.getByLabelText("Search").click(); + + // Sanity: Search button has disappeared and textbox has appeared + expect(view.queryByLabelText("Search")).toBeNull(); + view.getByRole("textbox"); + + // When we click Cancel + view.getByLabelText("Cancel").click(); + + // Then the search box disappeared + expect(view.queryByRole("textbox")).toBeNull(); + // And the Cancel button transformed back into Search + expect(view.queryByLabelText("Cancel")).toBeNull(); + view.getByLabelText("Search"); + }); +}); diff --git a/test/components/structures/FavouriteMessagesPanel-test.tsx b/test/components/structures/FavouriteMessagesPanel-test.tsx new file mode 100644 index 00000000000..bb415aa6a59 --- /dev/null +++ b/test/components/structures/FavouriteMessagesPanel-test.tsx @@ -0,0 +1,108 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { mocked, MockedObject } from "jest-mock"; +import { EventType, MatrixClient, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; +import { render } from "@testing-library/react"; + +import { stubClient } from "../../test-utils"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import FavouriteMessagesPanel from "../../../src/components/structures/FavouriteMessagesView/FavouriteMessagesPanel"; +import SettingsStore from "../../../src/settings/SettingsStore"; + +describe("FavouriteMessagesPanel", () => { + let cli: MockedObject; + // let room: Room; + const userId = "@alice:server.org"; + const roomId = "!room:server.org"; + const alicesFavouriteMessageEvent = new MatrixEvent({ + type: EventType.RoomMessage, + sender: userId, + room_id: roomId, + content: { + msgtype: MsgType.Text, + body: "i am alice", + }, + event_id: "$alices_message", + origin_server_ts: 123214, + }); + + const bobsFavouriteMessageEvent = new MatrixEvent({ + type: EventType.RoomMessage, + sender: "@bob:server.org", + room_id: roomId, + content: { + msgtype: MsgType.Text, + body: "i am bob", + }, + event_id: "$bobs_message", + origin_server_ts: 123213, + }); + + beforeEach(async () => { + stubClient(); + cli = mocked(MatrixClientPeg.get()); + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_favourite_messages"); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + }); + + it("renders component with empty or default props correctly", () => { + const favouriteMessageEvents: MatrixEvent[] | null = null; + const props = { + favouriteMessageEvents, + handleSearchQuery: jest.fn(), + searchQuery: "", + cli, + }; + const panel = render(); + expect(panel.findByText("No Favourite Messages")).toBeTruthy(); + }); + + it("renders favourite messages correctly for a single event", () => { + const props = { + favouriteMessageEvents: [bobsFavouriteMessageEvent], + handleSearchQuery: jest.fn(), + searchQuery: "", + cli, + }; + const panel = render(); + + panel.getByText("i am bob"); + }); + + it("renders favourite messages correctly for multiple single event", () => { + const props = { + favouriteMessageEvents: [alicesFavouriteMessageEvent, bobsFavouriteMessageEvent], + handleSearchQuery: jest.fn(), + searchQuery: "", + cli, + }; + + const panel = render(); + + panel.getByText("i am alice"); + panel.getByText("i am bob"); + + expect(panel.getAllByRole("link")[1].getAttribute("href")).toContain("$alices_message"); + expect(panel.getAllByRole("link")[3].getAttribute("href")).toContain("$bobs_message"); + + expect(panel.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/structures/FavouriteMessagesView-test.tsx b/test/components/structures/FavouriteMessagesView-test.tsx new file mode 100644 index 00000000000..ded3aeae79f --- /dev/null +++ b/test/components/structures/FavouriteMessagesView-test.tsx @@ -0,0 +1,317 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { mocked, MockedObject } from "jest-mock"; +import { + EventType, + MatrixClient, + MatrixEvent, + MsgType, + RelationType, + IRelationsRequestOpts, +} from "matrix-js-sdk/src/matrix"; +import { render, RenderResult, waitFor, waitForElementToBeRemoved, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import _FavouriteMessagesView from "../../../src/components/structures/FavouriteMessagesView/FavouriteMessagesView"; +import { stubClient, wrapInMatrixClientContext } from "../../test-utils"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import SettingsStore from "../../../src/settings/SettingsStore"; +import defaultDispatcher from "../../../src/dispatcher/dispatcher"; +import { Action } from "../../../src/dispatcher/actions"; +import { FavouriteMessagesStore } from "../../../src/stores/FavouriteMessagesStore"; + +const FavouriteMessagesView = wrapInMatrixClientContext(_FavouriteMessagesView); + +describe("FavouriteMessagesView", () => { + let matrixClient: MockedObject; + const userId = "@alice:server.org"; + const roomId = "!room:server.org"; + const alicesEvent = { + type: EventType.RoomMessage, + sender: userId, + room_id: roomId, + content: { + msgtype: MsgType.Text, + body: "i am ALICE", + }, + event_id: "$alices_message", + origin_server_ts: 123214, + }; + + const bobsEvent = { + type: EventType.RoomMessage, + sender: "@bob:server.org", + room_id: roomId, + content: { + msgtype: MsgType.Text, + body: "i am bob", + }, + event_id: "$bobs_message", + origin_server_ts: 123215, + }; + + async function renderTwoFavourites(): Promise { + const store = new FavouriteMessagesStore(); + await store.toggleFavourite(alicesEvent.event_id, alicesEvent.room_id, alicesEvent.content); + await store.toggleFavourite(bobsEvent.event_id, bobsEvent.room_id, bobsEvent.content); + + return render(); + } + + beforeEach(async () => { + //SettingsStore.setValue("feature_favourite_messages", null, SettingLevel.DEVICE, true); + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + switch (setting) { + case "feature_favourite_messages": + return true; + case "favourite_messages": + return []; + default: + return null; + } + }); + stubClient(); + matrixClient = mocked(MatrixClientPeg.get()); + matrixClient.fetchRoomEvent.mockClear().mockImplementation((roomId: string, eventId: string) => { + if (roomId === alicesEvent.room_id && eventId === alicesEvent.event_id) { + return Promise.resolve(alicesEvent); + } else if (roomId === bobsEvent.room_id && eventId === bobsEvent.event_id) { + return Promise.resolve(bobsEvent); + } else { + return Promise.reject("Unknown event"); + } + }); + }); + + afterEach(async () => { + jest.resetAllMocks(); + }); + + it("renders a loading page initially", async () => { + const store = new FavouriteMessagesStore(); + const view = render(); + view.getByLabelText("Loading..."); + expect(view.asFragment()).toMatchSnapshot(); + + // Wait for the async stuff to run - otherwise we get errors about + // finishing before everything is completed. + await view.findByText("No Favourite Messages"); + }); + + it("renders an empty message if there are no favourites", async () => { + const store = new FavouriteMessagesStore(); + const view = render(); + await view.findByText("No Favourite Messages"); + expect(view.asFragment()).toMatchSnapshot(); + }); + + it("renders your favourites", async () => { + const view = await renderTwoFavourites(); + await view.findByText("i am ALICE"); + await view.findByText("i am bob"); + expect(view.asFragment()).toMatchSnapshot(); + }); + + it("renders an edited favourite", async () => { + const editedEvent = new MatrixEvent({ + type: EventType.RoomMessage, + room_id: roomId, + sender: userId, + content: { + "msgtype": MsgType.Text, + "body": "I got edited", + "m.new_content": { + msgtype: MsgType.Text, + body: "I got edited", + }, + "m.relates_to": { + rel_type: RelationType.Replace, + event_id: alicesEvent.event_id, + }, + }, + }); + matrixClient.relations.mockImplementation( + ( + _roomId: string, + eventId: string, + _relationType?: string, + _eventType?: string, + _opts?: IRelationsRequestOpts, + ) => { + if (eventId === alicesEvent.event_id) { + return Promise.resolve({ + originalEvent: new MatrixEvent(alicesEvent), + events: [editedEvent], + }); + } else { + return Promise.resolve({ + originalEvent: new MatrixEvent(bobsEvent), + events: [], + }); + } + }, + ); + + const view = await renderTwoFavourites(); + await view.findByText("I got edited"); + await view.findByText("i am bob"); + expect(view.queryByText("i am ALICE")).toBeNull(); + }); + + it("shows no favourites when I search for something nonexistent", async () => { + // Given a view with 2 favourites + const view = await renderTwoFavourites(); + await view.findByText("i am ALICE"); + await view.findByText("i am bob"); + + // When I click search + view.getByLabelText("Search").click(); + + // And type something that does not match anything + const searchBox = view.getByRole("textbox"); + await userEvent.type(searchBox, "STrIGN THAT SI NOT THERE"); + + // No favourites are displayed + await waitFor(() => { + expect(view.queryByText("i am ALICE")).toBeNull(); + expect(view.queryByText("i am bob")).toBeNull(); + }); + }); + + it("shows 1 favourite when only one matches the search", async () => { + // Given a view with 2 favourites + const view = await renderTwoFavourites(); + await view.findByText("ALICE", { exact: false }); + await view.findByText("bob", { exact: false }); + + // When I click search + view.getByLabelText("Search").click(); + + // And type something that matches just one + const searchBox = view.getByRole("textbox"); + await userEvent.type(searchBox, "bob"); + + // Then only that one is displayed + await waitFor(() => { + expect(view.queryByText("ALICE", { exact: false })).toBeNull(); + }); + await view.findByText("bob", { exact: false }); + }); + + it("successfully searches for upper-case query strings", async () => { + // This is inspired by a bug we had during implementation, where + // upper-case strings could not be found. + + // Given a view with 2 favourites + const view = await renderTwoFavourites(); + await view.findByText("ALICE", { exact: false }); + await view.findByText("bob", { exact: false }); + + // When I click search + view.getByLabelText("Search").click(); + + // And type something uppercase that matches just one + const searchBox = view.getByRole("textbox"); + await userEvent.type(searchBox, "ALICE"); + + // Then only that one is displayed + await waitFor(() => { + expect(view.queryByText("bob", { exact: false })).toBeNull(); + }); + await view.findByText("ALICE", { exact: false }); + }); + + it("searches case-insensitively", async () => { + // Given a view with 2 favourites + const view = await renderTwoFavourites(); + await view.findByText("ALICE", { exact: false }); + await view.findByText("bob", { exact: false }); + + // When I click search + view.getByLabelText("Search").click(); + + // And type something that matches one but with different case + const searchBox = view.getByRole("textbox"); + await userEvent.type(searchBox, "aLiCe"); + + // Then the matching one is displayed + await waitFor(() => { + expect(view.queryByText("bob", { exact: false })).toBeNull(); + }); + await view.findByText("ALICE", { exact: false }); + }); + + it("clears the search when I close it", async () => { + // Given my search hides a favourite + const view = await renderTwoFavourites(); + await view.findByText("ALICE", { exact: false }); + await view.findByText("bob", { exact: false }); + view.getByLabelText("Search").click(); + const searchBox = view.getByRole("textbox"); + await userEvent.type(searchBox, "bob"); + await waitFor(() => { + expect(view.queryByText("ALICE", { exact: false })).toBeNull(); + }); + await view.findByText("bob", { exact: false }); + + // When I clear the search by pressing X + view.getByLabelText("Cancel").click(); + + // Then both favourites are visible because the search is cancelled + await view.findByText("ALICE", { exact: false }); + await view.findByText("bob", { exact: false }); + }); + + it("rerenders without your favourite if you unfave it", async () => { + const view = await renderTwoFavourites(); + const alice = await view.findByText("i am ALICE"); + const parent = alice.parentElement?.parentElement?.parentElement; + expect(parent).toBeTruthy(); + if (parent) { + within(parent).getByLabelText("Favourite").click(); + await waitForElementToBeRemoved(alice); + } + }); + + it("clears all your favourites when you click Clear", async () => { + let clearModalOpened = false; + + const dispatcherSpy = jest.spyOn(defaultDispatcher, "dispatch"); + + dispatcherSpy.mockImplementation(({ action }) => { + if (action === "setting_updated") { + return; + } + if (action !== Action.OpenClearFavourites) { + throw new Error(`Unexpected action ${action}`); + } + clearModalOpened = true; + }); + + // Given 2 favourites + const view = await renderTwoFavourites(); + await view.findByText("i am ALICE"); + await view.findByText("i am bob"); + + // When I clear all favourites + view.getByLabelText("Clear").click(); + + // Then the confirmation modal was launched + expect(clearModalOpened).toBe(true); + }); +}); diff --git a/test/components/structures/__snapshots__/FavouriteMessageTile-test.tsx.snap b/test/components/structures/__snapshots__/FavouriteMessageTile-test.tsx.snap new file mode 100644 index 00000000000..62389badc0e --- /dev/null +++ b/test/components/structures/__snapshots__/FavouriteMessageTile-test.tsx.snap @@ -0,0 +1,323 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FavouriteMessageTile displays the favourite content 1`] = ` + +
  • +
      + +
    1. +
      + + @alice:server.org + +
      +
    2. +
    +
  • +
    +`; + +exports[`FavouriteMessageTile displays the favourite content within a timeline 1`] = ` + +
  • +
      + +
    1. +
      + + @alice:server.org + +
      +
    2. +
    3. +
      + + @alice:server.org + +
      +
    4. +
    5. +
      + + @alice:server.org + +
      +
    6. +
    +
  • + +`; diff --git a/test/components/structures/__snapshots__/FavouriteMessagesHeader-test.tsx.snap b/test/components/structures/__snapshots__/FavouriteMessagesHeader-test.tsx.snap new file mode 100644 index 00000000000..e041e3f1874 --- /dev/null +++ b/test/components/structures/__snapshots__/FavouriteMessagesHeader-test.tsx.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FavouriteMessagesHeader displays a search box after Search is clicked 1`] = ` + +
    +
    +
    + + + + + + Favourite Messages + +
    +
    + +
    +
    +
    +
    +
    + +`; + +exports[`FavouriteMessagesHeader displays a title and buttons 1`] = ` + +
    +
    +
    + + + + + + Favourite Messages + +
    +
    +
    +
    +
    +
    +
    + +`; diff --git a/test/components/structures/__snapshots__/FavouriteMessagesPanel-test.tsx.snap b/test/components/structures/__snapshots__/FavouriteMessagesPanel-test.tsx.snap new file mode 100644 index 00000000000..db834eae02a --- /dev/null +++ b/test/components/structures/__snapshots__/FavouriteMessagesPanel-test.tsx.snap @@ -0,0 +1,265 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FavouriteMessagesPanel renders favourite messages correctly for multiple single event 1`] = ` + +
    +
    +
    + + + + + + Favourite Messages + +
    +
    +
    +
    +
    +
    +
    +
    + +`; diff --git a/test/components/structures/__snapshots__/FavouriteMessagesView-test.tsx.snap b/test/components/structures/__snapshots__/FavouriteMessagesView-test.tsx.snap new file mode 100644 index 00000000000..afd73144f90 --- /dev/null +++ b/test/components/structures/__snapshots__/FavouriteMessagesView-test.tsx.snap @@ -0,0 +1,434 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FavouriteMessagesView renders a loading page initially 1`] = ` + +
    +
    +
    + + + + + + Favourite Messages + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
      +
      +
      +
      +
    +
    +
    + +`; + +exports[`FavouriteMessagesView renders an empty message if there are no favourites 1`] = ` + +
    +
    +
    + + + + + + Favourite Messages + +
    +
    +
    +
    +
    +
    +
    +

    + No Favourite Messages +

    + +`; + +exports[`FavouriteMessagesView renders your favourites 1`] = ` + +
    +
    +
    + + + + + + Favourite Messages + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
      +
    1. +

      + Room: My room +

      +
    2. +
    3. +
        + +
      1. +
        + + Member + +
        +
        + Avatar +
        +
        + + +
      2. +
      +
    4. +
    5. +
        + +
      1. +
        + + Member + +
        +
        + Avatar +
        +
        + + +
      2. +
      +
    6. +
    +
    +
    + +`; diff --git a/test/components/views/messages/MessageActionBar-favourites-test.tsx b/test/components/views/messages/MessageActionBar-favourites-test.tsx new file mode 100644 index 00000000000..f9488d83467 --- /dev/null +++ b/test/components/views/messages/MessageActionBar-favourites-test.tsx @@ -0,0 +1,135 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { act, fireEvent, render, waitFor } from "@testing-library/react"; +import { mocked } from "jest-mock"; + +import SettingsStore from "../../../../src/settings/SettingsStore"; +import { SettingLevel } from "../../../../src/settings/SettingLevel"; +import { EventType, MatrixEvent, MsgType, Room } from "../../../../../matrix-js-sdk"; +import { IRoomState } from "../../../../src/components/structures/RoomView"; +import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; +import MessageActionBar from "../../../../src/components/views/messages/MessageActionBar"; +import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; +import { stubClient } from "../../../test-utils"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; + +/** + * Broken into a separate test file since we need a real dispatcher and + * SettingsStore to validate the behaviour when we respond to clicks. + */ +describe(" favourites", () => { + const userId = "@alice:server.org"; + const roomId = "!room:server.org"; + const alicesMessageEvent = new MatrixEvent({ + type: EventType.RoomMessage, + sender: userId, + room_id: roomId, + content: { + msgtype: MsgType.Text, + body: "Hello", + }, + event_id: "$alices_message", + }); + + const bobsMessageEvent = new MatrixEvent({ + type: EventType.RoomMessage, + sender: "@bob:server.org", + room_id: roomId, + content: { + msgtype: MsgType.Text, + body: "I am bob", + }, + event_id: "$bobs_message", + }); + + stubClient(); + const matrixClient = mocked(MatrixClientPeg.get()); + const room = new Room(roomId, matrixClient, userId); + jest.spyOn(room, "getPendingEvents").mockReturnValue([]); + matrixClient.getRoom.mockReturnValue(room); + + const defaultProps = { + getTile: jest.fn(), + getReplyChain: jest.fn(), + toggleThreadExpanded: jest.fn(), + mxEvent: alicesMessageEvent, + permalinkCreator: new RoomPermalinkCreator(room), + }; + const defaultRoomContext = { + ...RoomContext, + timelineRenderingType: TimelineRenderingType.Room, + canSendMessages: true, + canReact: true, + } as unknown as IRoomState; + + const getComponent = (props = {}, roomContext: Partial = {}) => + render( + + + , + ); + const favButton = (evt: MatrixEvent) => { + return getComponent({ mxEvent: evt }).getByTestId(evt.getId()); + }; + + beforeEach(() => { + SettingsStore.setValue("feature_favourite_messages", null, SettingLevel.DEVICE, true); + }); + + it("remembers favourited state of events", async () => { + // Given two buttons, both unclicked + const alicesAction = favButton(alicesMessageEvent); + const bobsAction = favButton(bobsMessageEvent); + + expect(alicesAction.classList).not.toContain("mx_MessageActionBar_favouriteButton_fillstar"); + expect(bobsAction.classList).not.toContain("mx_MessageActionBar_favouriteButton_fillstar"); + + // When I click one + act(() => { + fireEvent.click(alicesAction); + }); + + // Then it becomes selected + await waitFor(() => { + expect(alicesAction.classList).toContain("mx_MessageActionBar_favouriteButton_fillstar"); + expect(bobsAction.classList).not.toContain("mx_MessageActionBar_favouriteButton_fillstar"); + }); + + // And when I click the other + act(() => { + fireEvent.click(bobsAction); + }); + + // They are both selected + await waitFor(() => { + expect(alicesAction.classList).toContain("mx_MessageActionBar_favouriteButton_fillstar"); + expect(bobsAction.classList).toContain("mx_MessageActionBar_favouriteButton_fillstar"); + }); + + // And when I click Bob's message again + act(() => { + fireEvent.click(bobsAction); + }); + + // Bob's message becomes unselected + await waitFor(() => { + expect(bobsAction.classList).not.toContain("mx_MessageActionBar_favouriteButton_fillstar"); + expect(alicesAction.classList).toContain("mx_MessageActionBar_favouriteButton_fillstar"); + }); + }); +}); diff --git a/test/components/views/messages/MessageActionBar-test.tsx b/test/components/views/messages/MessageActionBar-test.tsx index c5f6c03b784..f2f91ef1c2c 100644 --- a/test/components/views/messages/MessageActionBar-test.tsx +++ b/test/components/views/messages/MessageActionBar-test.tsx @@ -499,17 +499,11 @@ describe("", () => { }); describe("favourite button", () => { - //for multiple event usecase - const favButton = (evt: MatrixEvent) => { - return getComponent({ mxEvent: evt }).getByTestId(evt.getId()); - }; - describe("when favourite_messages feature is enabled", () => { beforeEach(() => { jest.spyOn(SettingsStore, "getValue").mockImplementation( (setting) => setting === "feature_favourite_messages", ); - localStorageMock.clear(); }); it("renders favourite button on own actionable event", () => { @@ -527,53 +521,6 @@ describe("", () => { const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }); expect(queryByLabelText("Favourite")).toBeFalsy(); }); - - it("remembers favourited state of multiple events, and handles the localStorage of the events accordingly", () => { - const alicesAction = favButton(alicesMessageEvent); - const bobsAction = favButton(bobsMessageEvent); - - //default state before being clicked - expect(alicesAction.classList).not.toContain("mx_MessageActionBar_favouriteButton_fillstar"); - expect(bobsAction.classList).not.toContain("mx_MessageActionBar_favouriteButton_fillstar"); - expect(localStorageMock.getItem("io_element_favouriteMessages")).toBeNull(); - - //if only alice's event is fired - act(() => { - fireEvent.click(alicesAction); - }); - - expect(alicesAction.classList).toContain("mx_MessageActionBar_favouriteButton_fillstar"); - expect(bobsAction.classList).not.toContain("mx_MessageActionBar_favouriteButton_fillstar"); - expect(localStorageMock.setItem).toHaveBeenCalledWith( - "io_element_favouriteMessages", - '["$alices_message"]', - ); - - //when bob's event is fired,both should be styled and stored in localStorage - act(() => { - fireEvent.click(bobsAction); - }); - - expect(alicesAction.classList).toContain("mx_MessageActionBar_favouriteButton_fillstar"); - expect(bobsAction.classList).toContain("mx_MessageActionBar_favouriteButton_fillstar"); - expect(localStorageMock.setItem).toHaveBeenCalledWith( - "io_element_favouriteMessages", - '["$alices_message","$bobs_message"]', - ); - - //finally, at this point the localStorage should contain the two eventids - expect(localStorageMock.getItem("io_element_favouriteMessages")).toEqual( - '["$alices_message","$bobs_message"]', - ); - - //if decided to unfavourite bob's event by clicking again - act(() => { - fireEvent.click(bobsAction); - }); - expect(bobsAction.classList).not.toContain("mx_MessageActionBar_favouriteButton_fillstar"); - expect(alicesAction.classList).toContain("mx_MessageActionBar_favouriteButton_fillstar"); - expect(localStorageMock.getItem("io_element_favouriteMessages")).toEqual('["$alices_message"]'); - }); }); describe("when favourite_messages feature is disabled", () => { diff --git a/test/hooks/useFavouriteMessages-test.tsx b/test/hooks/useFavouriteMessages-test.tsx new file mode 100644 index 00000000000..a08f4c0e335 --- /dev/null +++ b/test/hooks/useFavouriteMessages-test.tsx @@ -0,0 +1,65 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { render, waitFor } from "@testing-library/react"; +import { EventType, MsgType } from "matrix-js-sdk/src/matrix"; +import React from "react"; + +import useFavouriteMessages from "../../src/hooks/useFavouriteMessages"; +import { SettingLevel } from "../../src/settings/SettingLevel"; +import SettingsStore from "../../src/settings/SettingsStore"; +import { stubClient } from "../test-utils"; + +describe("useFavouriteMessages", () => { + const userId = "@alice:server.org"; + const roomId = "!room:server.org"; + const alicesEvent = { + type: EventType.RoomMessage, + sender: userId, + room_id: roomId, + content: { + msgtype: MsgType.Text, + body: "i am ALICE", + }, + event_id: "$alices_message", + origin_server_ts: 123214, + }; + + const getValueSpy = jest.spyOn(SettingsStore, "getValue"); + const setValueSpy = jest.spyOn(SettingsStore, "setValue"); + + const ClearFavesComponent = () => { + const { clearFavouriteMessages } = useFavouriteMessages(); + clearFavouriteMessages(); + return
    ; + }; + + beforeEach(() => { + stubClient(); + }); + + // Most cases are covered in a more user-facing way in e.g. + // FavouriteMessagesView-test, but for stuff we can't cover there, we do it + // here. + + it("clears all favourites when asked", async () => { + getValueSpy.mockReturnValue([alicesEvent]); + render(); + await waitFor(() => + expect(setValueSpy).toHaveBeenCalledWith("favourite_messages", null, SettingLevel.ACCOUNT, []), + ); + }); +});