From f399b7aaa597b3cbcc448c62863d5a43b81efebd Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Thu, 15 Dec 2022 15:10:28 +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 + .../ConfirmClearDialog.tsx | 57 +++ .../FavouriteMessageTile.tsx | 114 +++++ .../FavouriteMessagesHeader.tsx | 98 ++++ .../FavouriteMessagesPanel.tsx | 64 +++ .../FavouriteMessagesTilesList.tsx | 92 ++++ .../FavouriteMessagesView.tsx | 173 +++++++ src/components/structures/LeftPanel.tsx | 1 + src/components/structures/LoggedInView.tsx | 5 + src/components/structures/MatrixChat.tsx | 17 + .../views/messages/MessageActionBar.tsx | 19 +- src/components/views/rooms/RoomList.tsx | 10 +- src/dispatcher/actions.ts | 10 + src/hooks/useFavouriteMessages.ts | 115 ++++- src/i18n/strings/en_EN.json | 2 + src/stores/RoomViewStore.tsx | 1 + .../structures/FavouriteMessageTile-test.tsx | 88 ++++ .../FavouriteMessagesHeader-test.tsx | 79 ++++ .../FavouriteMessagesPanel-test.tsx | 107 +++++ .../structures/FavouriteMessagesView-test.tsx | 254 ++++++++++ .../FavouriteMessageTile-test.tsx.snap | 323 +++++++++++++ .../FavouriteMessagesHeader-test.tsx.snap | 119 +++++ .../FavouriteMessagesPanel-test.tsx.snap | 265 +++++++++++ .../FavouriteMessagesView-test.tsx.snap | 436 ++++++++++++++++++ .../views/messages/MessageActionBar-test.tsx | 39 +- test/hooks/useFavouriteMessages-test.tsx | 60 +++ 31 files changed, 2626 insertions(+), 33 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/ConfirmClearDialog.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 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/hooks/useFavouriteMessages-test.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 9cd446ecbc8a..3121bc563e1f 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 000000000000..94eeba3cf93e --- /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 000000000000..dde0bb131b19 --- /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 000000000000..c1c68e3e87ec --- /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 1e181b4e3f12..ca17522c9903 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 09c7225a3d35..15a775c61cea 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/ConfirmClearDialog.tsx b/src/components/structures/FavouriteMessagesView/ConfirmClearDialog.tsx new file mode 100644 index 000000000000..441d1ab5f413 --- /dev/null +++ b/src/components/structures/FavouriteMessagesView/ConfirmClearDialog.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 } 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 starred messages. + */ +const ConfirmClearDialog: FC = (props: IDialogProps) => { + const { clearFavouriteMessages } = useFavouriteMessages(); + + const onConfirmClick = () => { + clearFavouriteMessages(); + props.onFinished(); + }; + + return ( + +
+
+ {_t("Are you sure you wish to clear all your starred messages? ")} +
+
+ +
+ ); +}; + +export default ConfirmClearDialog; diff --git a/src/components/structures/FavouriteMessagesView/FavouriteMessageTile.tsx b/src/components/structures/FavouriteMessagesView/FavouriteMessageTile.tsx new file mode 100644 index 000000000000..95adfef59cff --- /dev/null +++ b/src/components/structures/FavouriteMessagesView/FavouriteMessageTile.tsx @@ -0,0 +1,114 @@ +/* +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"; + +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[]; +} + +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_threadstable"); + + 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 000000000000..f0c152409591 --- /dev/null +++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesHeader.tsx @@ -0,0 +1,98 @@ +/* +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, { useCallback, useState } from "react"; + +import { Action } from "../../../dispatcher/actions"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import useFavouriteMessages from "../../../hooks/useFavouriteMessages"; +import { _t } from "../../../languageHandler"; +import RoomAvatar from "../../views/avatars/RoomAvatar"; +import AccessibleTooltipButton from "../../views/elements/AccessibleTooltipButton"; + +interface IProps { + query: string; + handleSearchQuery: (query: string) => void; +} + +const FavouriteMessagesHeader = ({ query, handleSearchQuery }: IProps) => { + const { getFavouriteMessages } = useFavouriteMessages(); + const favouriteMessagesIds = getFavouriteMessages(); + + const [isSearchClicked, setSearchClicked] = useState(false); + + const onChange = useCallback((e) => handleSearchQuery(e.target.value), [handleSearchQuery]); + const onCancelClick = useCallback(() => setSearchClicked(false), [setSearchClicked]); + const onSearchClick = useCallback(() => setSearchClicked(true), [setSearchClicked]); + + const onClearClick = useCallback(() => { + if (favouriteMessagesIds.length > 0) { + defaultDispatcher.dispatch({ action: Action.OpenClearModal }); + } + }, [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 000000000000..195fb394253b --- /dev/null +++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesPanel.tsx @@ -0,0 +1,64 @@ +/* +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, { useRef } from "react"; + +import { _t } from "../../../languageHandler"; +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; +} + +const FavouriteMessagesPanel = (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 000000000000..facff1be6fb0 --- /dev/null +++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesTilesList.tsx @@ -0,0 +1,92 @@ +/* +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 from "react"; + +import { _t } from "../../../languageHandler"; +import Spinner from "../../views/elements/Spinner"; +import FavouriteMessageTile from "./FavouriteMessageTile"; + +interface IProps { + favouriteMessageEvents: MatrixEvent[] | null; + favouriteMessagesPanelRef: any; + searchQuery: string; + cli: MatrixClient; +} + +// eslint-disable-next-line max-len +const FavouriteMessagesTilesList = ({ + cli, + favouriteMessageEvents, + favouriteMessagesPanelRef, + searchQuery, +}: IProps) => { + const ret: JSX.Element[] = []; + let lastRoomId: string; + const highlights: string[] = []; + + if (!favouriteMessageEvents) { + ret.push(); + } else { + favouriteMessageEvents.reverse().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!; + } + // once dynamic content in the favourite messages panel loads, make the scrollPanel check + // the scroll offsets. + const onHeightChanged = () => { + const scrollPanel = favouriteMessagesPanelRef.current; + if (scrollPanel) { + scrollPanel.checkScroll(); + } + }; + + 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 000000000000..c6ffd63a5198 --- /dev/null +++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesView.tsx @@ -0,0 +1,173 @@ +/* +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, { 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, { FavouriteStorage } from "../../../hooks/useFavouriteMessages"; +import ResizeNotifier from "../../../utils/ResizeNotifier"; +import FavouriteMessagesPanel from "./FavouriteMessagesPanel"; + +class RunState { + private _isCancelled: boolean; + + public constructor() { + this._isCancelled = false; + } + + public cancel() { + this._isCancelled = true; + } + + public isCancelled(): boolean { + return this._isCancelled; + } +} + +interface IProps { + resizeNotifier?: ResizeNotifier; +} + +const FavouriteMessagesView = ({ resizeNotifier }: IProps) => { + const matrixClient = useContext(MatrixClientContext); + const { getFavouriteMessages, registerFavouritesChangedListener } = useFavouriteMessages(); + const [searchQuery, setSearchQuery] = useState(""); + const [favouriteMessageEvents, setFavouriteMessageEvents] = useState(null); + + const recalcEvents = useCallback( + async (runState: RunState) => { + const faves = getFavouriteMessages(); + const newEvents = await calcEvents(runState, searchQuery, faves, matrixClient); + if (runState.isCancelled()) { + return; + } + setFavouriteMessageEvents(newEvents); + }, + [searchQuery, matrixClient, getFavouriteMessages], + ); + + // Because finding events is async, we do it in useEffect, not useState. + useEffect(() => { + const runState = new RunState(); + recalcEvents(runState); + return () => { + runState.cancel(); + }; + }, [searchQuery, recalcEvents]); + + registerFavouritesChangedListener(() => recalcEvents(new RunState())); + + const handleSearchQuery = (query: string) => { + setSearchQuery(query); + }; + + const props = { + favouriteMessageEvents, + resizeNotifier, + searchQuery, + handleSearchQuery, + cli: matrixClient, + }; + + 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) { + 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( + runState: RunState, + favourite: FavouriteStorage, + matrixClient: MatrixClient, +): Promise { + try { + const evJson = await matrixClient.fetchRoomEvent(favourite.roomId, favourite.eventId); + if (runState.isCancelled()) { + return null; + } + 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); + } + if (runState.isCancelled()) { + return null; + } + + // 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( + runState: RunState, + searchQuery: string, + favouriteMessages: FavouriteStorage[], + matrixClient: MatrixClient, +): Promise { + const displayedFavourites: FavouriteStorage[] = filterFavourites(searchQuery, favouriteMessages); + const promises: Promise[] = displayedFavourites.map((f) => + fetchEvent(runState, f, matrixClient), + ); + const events = await Promise.all(promises); + return events.filter((e) => e !== null); +} + +export default FavouriteMessagesView; diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 487734543791..7d6fd58a6a4e 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 6e18f8a6f770..911af734c68b 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 536626f27085..cf6080d1b4f9 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 ConfirmClearDialog from "./FavouriteMessagesView/ConfirmClearDialog"; // legacy export export { default as Views } from "../../Views"; @@ -740,6 +741,10 @@ export default class MatrixChat extends React.PureComponent { this.viewSomethingBehindModal(); break; } + case Action.OpenClearModal: { + Modal.createDialog(ConfirmClearDialog); + 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); @@ -1037,6 +1045,11 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher.recheck(); } + private viewFavouriteMessages() { + this.setPage(PageType.FavouriteMessagesView); + this.notifyNewScreen("favourite_messages"); + } + private viewUser(userId: string, subAction: string) { // Wait for the first sync so that `getRoom` gives us a room object if it's // in the sync response @@ -1755,6 +1768,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 1ec7fae75175..91bc880eb88b 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, { 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"; @@ -277,11 +277,14 @@ interface IFavouriteButtonProp { } const FavouriteButton = ({ mxEvent }: IFavouriteButtonProp) => { - const { isFavourite, toggleFavourite } = useFavouriteMessages(); + const { isFavourite, toggleFavourite, registerFavouritesChangedListener } = useFavouriteMessages(); + 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 +293,9 @@ const FavouriteButton = ({ mxEvent }: IFavouriteButtonProp) => { e.preventDefault(); e.stopPropagation(); - toggleFavourite(eventId); + toggleFavourite(mxEvent); }, - [toggleFavourite, eventId], + [mxEvent, toggleFavourite], ); return ( @@ -442,7 +445,9 @@ export default class MessageActionBar extends React.PureComponent void; @@ -72,6 +73,7 @@ interface IProps { resizeNotifier: ResizeNotifier; isMinimized: boolean; activeSpace: SpaceKey; + pageType?: PageType; } interface IState { @@ -611,13 +613,17 @@ export default class RoomList extends React.PureComponent { /> ); + const onFavouriteClicked = () => { + defaultDispatcher.dispatch({ action: Action.ViewFavouriteMessages }); + }; + return [ ""} + onClick={onFavouriteClicked} key="favMessagesTile_key" />, ]; diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 774fbc1e8ffd..ba38ac54cddd 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 + */ + ViewFavouriteMessages = "view_favourite_messages", + + /** + * Fired when we want to clear all favourited messages + */ + OpenClearModal = "open_clear_modal", } diff --git a/src/hooks/useFavouriteMessages.ts b/src/hooks/useFavouriteMessages.ts index 877643776397..313424e1a086 100644 --- a/src/hooks/useFavouriteMessages.ts +++ b/src/hooks/useFavouriteMessages.ts @@ -14,27 +14,114 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useState } from "react"; +import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { useCallback, useEffect, useRef } from "react"; -const favouriteMessageIds = JSON.parse(localStorage?.getItem("io_element_favouriteMessages") ?? "[]") as string[]; +export interface FavouriteStorage { + eventId: string; + roomId: string; + content: IContent; +} + +// Global variable tracking LocalStorage state +let ioElementFavouriteMessages: FavouriteStorage[] | null = null; + +function loadFavourites(): FavouriteStorage[] { + try { + return JSON.parse(window.localStorage?.getItem("io_element_favouriteMessages") ?? "[]"); + } catch (e) { + console.error(e); + return []; + } +} + +// Global list of people interested in when favourites change +let favouritesChangedListeners: (() => void)[] = []; + +function favouritesChanged() { + favouritesChangedListeners.forEach((fn) => fn()); +} + +// ts-prune-ignore-next (exported for tests) +export function forceReloadFavourites() { + ioElementFavouriteMessages = loadFavourites(); +} + +function saveFavourites(): void { + window.localStorage?.setItem("io_element_favouriteMessages", JSON.stringify(ioElementFavouriteMessages ?? [])); +} + +function clearFavourites(): void { + if (ioElementFavouriteMessages !== null) { + ioElementFavouriteMessages.length = 0; + } + window.localStorage.removeItem("io_element_favouriteMessages"); +} export default function useFavouriteMessages() { - const [, setX] = useState(); + if (ioElementFavouriteMessages === null) { + ioElementFavouriteMessages = loadFavourites(); + } + + const myListeners = useRef<(() => void)[]>([]); + + const isFavourite = (eventId: string): boolean => { + return ioElementFavouriteMessages?.some((f) => f.eventId === eventId) ?? false; + }; + + const toggleFavourite = (mxEvent: MatrixEvent) => { + if (!ioElementFavouriteMessages) { + return; + } - //checks if an id already exist - const isFavourite = (eventId: string): boolean => favouriteMessageIds.includes(eventId); + const eventId = mxEvent.getId() ?? ""; + const roomId = mxEvent.getRoomId() ?? ""; + const content = mxEvent.getContent() ?? {}; - const toggleFavourite = (eventId: string) => { - isFavourite(eventId) - ? favouriteMessageIds.splice(favouriteMessageIds.indexOf(eventId), 1) - : favouriteMessageIds.push(eventId); + const idx = ioElementFavouriteMessages.findIndex((f) => f.eventId === eventId); - //update the local storage - localStorage.setItem("io_element_favouriteMessages", JSON.stringify(favouriteMessageIds)); + if (idx !== -1) { + ioElementFavouriteMessages.splice(idx, 1); + } else { + ioElementFavouriteMessages.push({ eventId, roomId, content }); + } - // This forces a re-render to account for changes in appearance in real-time when the favourite button is toggled - setX([]); + saveFavourites(); + favouritesChanged(); }; - return { isFavourite, toggleFavourite }; + const clearFavouriteMessages = () => { + clearFavourites(); + favouritesChanged(); + }; + + // getFavouriteMessages has useCallback so that it can be used as a + // dependency of e.g. useEffect later (without useCallback it will change + // every time this function is called). + const getFavouriteMessages: () => FavouriteStorage[] = useCallback(() => { + return JSON.parse(JSON.stringify(ioElementFavouriteMessages ?? [])); + }, []); + + const registerFavouritesChangedListener = useCallback( + (listener: () => void) => { + favouritesChangedListeners.push(listener); + myListeners.current.push(listener); + }, + [myListeners], + ); + + useEffect(() => { + const myLists = myListeners.current; + return () => { + favouritesChangedListeners = favouritesChangedListeners.filter((l) => !myLists.includes(l)); + }; + }, []); + + return { + getFavouriteMessages, + isFavourite, + toggleFavourite, + clearFavouriteMessages, + registerFavouritesChangedListener, + }; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 952dba459004..ddcd358f4e9e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3449,6 +3449,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 starred messages? ": "Are you sure you wish to clear all your starred 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/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 79b6f2f6897b..479ca9b109d9 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -266,6 +266,7 @@ export class RoomViewStore extends EventEmitter { // for these events blank out the roomId as we are no longer in the RoomView case "view_welcome_page": case Action.ViewHomePage: + case Action.ViewFavouriteMessages: this.setState({ roomId: null, roomAlias: null, diff --git a/test/components/structures/FavouriteMessageTile-test.tsx b/test/components/structures/FavouriteMessageTile-test.tsx new file mode 100644 index 000000000000..7166a405e460 --- /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 000000000000..c1151278979a --- /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 000000000000..01ea51091a00 --- /dev/null +++ b/test/components/structures/FavouriteMessagesPanel-test.tsx @@ -0,0 +1,107 @@ +/* +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 props = { + favouriteMessageEvents: null, + handleSearchQuery: jest.fn(), + searchQuery: "", + cli, + }; + const panel = render(); + expect(panel.findByText("No Favourite Messages")).toBeTruthy(); + }); + + it("renders starred 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 starred 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("$bobs_message"); + expect(panel.getAllByRole("link")[3].getAttribute("href")).toContain("$alices_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 000000000000..d8d3e8673161 --- /dev/null +++ b/test/components/structures/FavouriteMessagesView-test.tsx @@ -0,0 +1,254 @@ +/* +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, MsgType } from "matrix-js-sdk/src/matrix"; +import { render, waitFor, waitForElementToBeRemoved, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import _FavouriteMessagesView from "../../../src/components/structures/FavouriteMessagesView/FavouriteMessagesView"; +import { FavouriteStorage, forceReloadFavourites } from "../../../src/hooks/useFavouriteMessages"; +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"; + +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, + }; + + const twoFavourites: FavouriteStorage[] = [ + { + eventId: alicesEvent.event_id, + roomId: alicesEvent.room_id, + content: alicesEvent.content, + }, + { + eventId: bobsEvent.event_id, + roomId: bobsEvent.room_id, + content: bobsEvent.content, + }, + ]; + const localStorageGetSpy = jest.spyOn(window.localStorage.__proto__, "getItem").mockReturnValue(null); + const localStorageSetSpy = jest.spyOn(window.localStorage.__proto__, "setItem").mockImplementation(() => {}); + const dispatcherSpy = jest.spyOn(defaultDispatcher, "dispatch"); + + beforeEach(async () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_favourite_messages"); + 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"); + } + }); + localStorageGetSpy.mockClear().mockReturnValue(null); + localStorageSetSpy.mockClear(); + }); + + afterEach(async () => { + jest.resetAllMocks(); + }); + + it("renders a loading page initially", async () => { + 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 () => { + localStorageGetSpy.mockReturnValue(JSON.stringify([])); + + const view = render(); + await view.findByText("No Favourite Messages"); + expect(view.asFragment()).toMatchSnapshot(); + }); + + it("renders your favourites", async () => { + localStorageGetSpy.mockReturnValue(JSON.stringify(twoFavourites)); + forceReloadFavourites(); + + const view = render(); + await view.findByText("i am ALICE"); + await view.findByText("i am bob"); + expect(view.asFragment()).toMatchSnapshot(); + }); + + it("shows no favourites when I search for something nonexistent", async () => { + // Given a view with 2 favourites + localStorageGetSpy.mockReturnValue(JSON.stringify(twoFavourites)); + forceReloadFavourites(); + const view = render(); + 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 + localStorageGetSpy.mockReturnValue(JSON.stringify(twoFavourites)); + forceReloadFavourites(); + const view = render(); + 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 + localStorageGetSpy.mockReturnValue(JSON.stringify(twoFavourites)); + forceReloadFavourites(); + const view = render(); + 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 + localStorageGetSpy.mockReturnValue(JSON.stringify(twoFavourites)); + forceReloadFavourites(); + const view = render(); + 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("rerenders without your favourite if you unfave it", async () => { + localStorageGetSpy.mockReturnValue(JSON.stringify(twoFavourites)); + forceReloadFavourites(); + + const view = render(); + 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; + + dispatcherSpy.mockImplementation(({ action }) => { + if (action !== Action.OpenClearModal) { + throw new Error(`Unexpected action ${action}`); + } + clearModalOpened = true; + }); + + // Given 2 favourites + localStorageGetSpy.mockReturnValue(JSON.stringify(twoFavourites)); + forceReloadFavourites(); + const view = render(); + 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 000000000000..72012ce31ea7 --- /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 000000000000..e041e3f1874c --- /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 000000000000..301436c2de03 --- /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 starred 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 000000000000..1de89274e2f5 --- /dev/null +++ b/test/components/structures/__snapshots__/FavouriteMessagesView-test.tsx.snap @@ -0,0 +1,436 @@ +// 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-test.tsx b/test/components/views/messages/MessageActionBar-test.tsx index 8b64f205f0c5..bb29db5f55ed 100644 --- a/test/components/views/messages/MessageActionBar-test.tsx +++ b/test/components/views/messages/MessageActionBar-test.tsx @@ -528,7 +528,7 @@ describe("", () => { expect(queryByLabelText("Favourite")).toBeFalsy(); }); - it("remembers favourited state of multiple events, and handles the localStorage of the events accordingly", () => { + it("remembers favourited state of events, and stores them in localStorage", () => { const alicesAction = favButton(alicesMessageEvent); const bobsAction = favButton(bobsMessageEvent); @@ -546,7 +546,11 @@ describe("", () => { expect(bobsAction.classList).not.toContain("mx_MessageActionBar_favouriteButton_fillstar"); expect(localStorageMock.setItem).toHaveBeenCalledWith( "io_element_favouriteMessages", - '["$alices_message"]', + "[{" + + '"eventId":"$alices_message",' + + '"roomId":"!room:server.org",' + + '"content":{"msgtype":"m.text","body":"Hello"}' + + "}]", ); //when bob's event is fired,both should be styled and stored in localStorage @@ -554,17 +558,24 @@ describe("", () => { fireEvent.click(bobsAction); }); + const aliceAndBob = JSON.stringify([ + { + eventId: "$alices_message", + roomId: "!room:server.org", + content: { msgtype: "m.text", body: "Hello" }, + }, + { + eventId: "$bobs_message", + roomId: "!room:server.org", + content: { msgtype: "m.text", body: "I am bob" }, + }, + ]); 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"]', - ); + expect(localStorageMock.setItem).toHaveBeenCalledWith("io_element_favouriteMessages", aliceAndBob); //finally, at this point the localStorage should contain the two eventids - expect(localStorageMock.getItem("io_element_favouriteMessages")).toEqual( - '["$alices_message","$bobs_message"]', - ); + expect(localStorageMock.getItem("io_element_favouriteMessages")).toEqual(aliceAndBob); //if decided to unfavourite bob's event by clicking again act(() => { @@ -572,7 +583,15 @@ describe("", () => { }); 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"]'); + expect(localStorageMock.getItem("io_element_favouriteMessages")).toEqual( + JSON.stringify([ + { + eventId: "$alices_message", + roomId: "!room:server.org", + content: { msgtype: "m.text", body: "Hello" }, + }, + ]), + ); }); }); diff --git a/test/hooks/useFavouriteMessages-test.tsx b/test/hooks/useFavouriteMessages-test.tsx new file mode 100644 index 000000000000..06963c811b5d --- /dev/null +++ b/test/hooks/useFavouriteMessages-test.tsx @@ -0,0 +1,60 @@ +/* +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 } from "@testing-library/react"; +import { EventType, MsgType } from "matrix-js-sdk/src/matrix"; +import React from "react"; + +import useFavouriteMessages from "../../src/hooks/useFavouriteMessages"; +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 localStorageGetSpy = jest.spyOn(window.localStorage.__proto__, "getItem"); + const localStorageRemoveSpy = jest.spyOn(window.localStorage.__proto__, "removeItem"); + + const ClearFavesComponent = () => { + localStorageGetSpy.mockReturnValue(JSON.stringify([alicesEvent])); + 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 () => { + render(); + expect(localStorageRemoveSpy).toHaveBeenCalledWith("io_element_favouriteMessages2"); + }); +});