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..32b7fd8c1e50
--- /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 favourite 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 favourite 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 (
+
+ );
+};
+
+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..60c24bc44bbe 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 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/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..662988561b40
--- /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 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("$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`] = `
+
+
+
+
+
+
+ Sun, Jan 16 1977
+
+
+
+
+
+
+ @alice:server.org
+
+
+
+
+
+
+ i am alice
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`FavouriteMessageTile displays the favourite content within a timeline 1`] = `
+
+
+
+
+
+
+ Sun, Jan 16 1977
+
+
+
+
+
+
+ @alice:server.org
+
+
+
+
+
+
+ i am before
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @alice:server.org
+
+
+
+
+
+
+ i am alice
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @alice:server.org
+
+
+
+
+
+
+ i am after
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
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`] = `
+
+
+
+
+
+
+ F
+
+
+
+
+ Favourite Messages
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`FavouriteMessagesHeader displays a title and buttons 1`] = `
+
+
+
+
+
+
+ F
+
+
+
+
+ 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..a3f08146db89
--- /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`] = `
+
+