diff --git a/res/css/_components.pcss b/res/css/_components.pcss
index cb025f5b8c72..ce702e1ce1a8 100644
--- a/res/css/_components.pcss
+++ b/res/css/_components.pcss
@@ -48,6 +48,7 @@
@import "./compound/_Icon.pcss";
@import "./structures/_AutoHideScrollbar.pcss";
@import "./structures/_AutocompleteInput.pcss";
+@import "./structures/_FavouriteMessagesView.pcss";
@import "./structures/_BackdropPanel.pcss";
@import "./structures/_CompatibilityPage.pcss";
@import "./structures/_ContextualMenu.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..888f00b830d4
--- /dev/null
+++ b/src/components/structures/FavouriteMessagesView/FavouriteMessageTile.tsx
@@ -0,0 +1,103 @@
+/*
+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 { 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";
+
+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 =
+ prevEv &&
+ !wantsDateSeparator(prevEv.getDate()!, mxEv.getDate()) &&
+ shouldFormContinuation(prevEv, mxEv, context?.showHiddenEvents, threadsEnabled);
+
+ let lastInSection = true;
+ const nextEv = props?.timeline[j + 1];
+ if (nextEv) {
+ const willWantDateSeparator = wantsDateSeparator(mxEv.getDate()!, nextEv.getDate());
+ lastInSection =
+ willWantDateSeparator ||
+ mxEv.getSender() !== nextEv.getSender() ||
+ !shouldFormContinuation(mxEv, nextEv, context?.showHiddenEvents, threadsEnabled);
+ }
+
+ ret.push(
+ ,
+ );
+ }
+ }
+
+ return (
+
+ {ret}
+
+ );
+};
+
+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..dbe7a16758b0
--- /dev/null
+++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesHeader.tsx
@@ -0,0 +1,101 @@
+/*
+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, { 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 [sortAscending, setSortAscending] = useState();
+
+ const onClearClick = () => {
+ if (favouriteMessagesIds.length > 0) {
+ defaultDispatcher.dispatch({ action: Action.OpenClearModal });
+ }
+ };
+
+ 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..70c743018513
--- /dev/null
+++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesPanel.tsx
@@ -0,0 +1,64 @@
+/*
+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 { 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..5c5aa982b253
--- /dev/null
+++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesTilesList.tsx
@@ -0,0 +1,92 @@
+/*
+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 { 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.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..90f879d94ae4
--- /dev/null
+++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesView.tsx
@@ -0,0 +1,119 @@
+/*
+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, { 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";
+
+interface IProps {
+ resizeNotifier?: ResizeNotifier;
+}
+
+const FavouriteMessagesView = ({ resizeNotifier }: IProps) => {
+ const cli = useContext(MatrixClientContext);
+ const { getFavouriteMessages, onFavouritesChanged } = useFavouriteMessages();
+ const [searchQuery, setSearchQuery] = useState("");
+ const [favouriteMessageEvents, setFavouriteMessageEvents] = useState(null);
+
+ function filterFavourites(searchQuery: string, favouriteMessages: FavouriteStorage[]): FavouriteStorage[] {
+ return favouriteMessages.filter((f) => f.content.body.trim().toLowerCase().includes(searchQuery));
+ }
+
+ /** If the event was edited, update it with the replacement content */
+ const updateEventIfEdited = useCallback(
+ async (event: MatrixEvent) => {
+ const roomId = event.getRoomId();
+ const eventId = event.getId();
+ const { events } = await cli.relations(roomId, eventId, RelationType.Replace, null, { limit: 1 });
+ const editEvent = events?.length > 0 ? events[0] : null;
+ if (editEvent) {
+ event.makeReplaced(editEvent);
+ }
+ },
+ [cli],
+ );
+
+ const fetchEvent = useCallback(
+ async (favourite: FavouriteStorage): Promise => {
+ try {
+ const evJson = await cli.fetchRoomEvent(favourite.roomId, favourite.eventId);
+ const event = new MatrixEvent(evJson);
+ const roomId = event?.getRoomId();
+ const room = roomId ? cli.getRoom(roomId) : null;
+ if (!event || !room) {
+ return null;
+ }
+
+ // Decrypt the event
+ if (event.isEncrypted()) {
+ // Modifies the event in-place (!)
+ await cli.decryptEventIfNeeded(event);
+ }
+
+ // Inject sender information
+ event.sender = room.getMember(event.getSender())!;
+
+ await updateEventIfEdited(event);
+
+ return event;
+ } catch (err) {
+ logger.error(err);
+ return null;
+ }
+ },
+ [cli, updateEventIfEdited],
+ );
+
+ const calcEvents = useCallback(
+ (searchQuery: string, favouriteMessages: FavouriteStorage[]): Promise => {
+ const displayedFavourites = filterFavourites(searchQuery, favouriteMessages);
+ return Promise.all(displayedFavourites.map(fetchEvent).filter((e) => e != null));
+ },
+ [fetchEvent],
+ );
+
+ const recalcEvents = useCallback(async () => {
+ setFavouriteMessageEvents(await calcEvents(searchQuery, getFavouriteMessages()));
+ }, [searchQuery, calcEvents, getFavouriteMessages]);
+
+ // Because finding events is async, we do it in useEffect, not useState.
+ useEffect(() => {
+ recalcEvents();
+ }, [searchQuery, recalcEvents]);
+
+ onFavouritesChanged(recalcEvents);
+
+ const handleSearchQuery = (query: string) => {
+ setSearchQuery(query);
+ };
+
+ const props = {
+ favouriteMessageEvents,
+ resizeNotifier,
+ searchQuery,
+ handleSearchQuery,
+ cli,
+ };
+
+ return ;
+};
+
+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 f0e48f44bea8..164e1439431c 100644
--- a/src/components/structures/LoggedInView.tsx
+++ b/src/components/structures/LoggedInView.tsx
@@ -71,6 +71,7 @@ import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload
import { IConfigOptions } from "../../IConfigOptions";
import LeftPanelLiveShareWarning from "../views/beacon/LeftPanelLiveShareWarning";
import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage";
+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 3cac66a79cb7..999d32c03c08 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";
@@ -276,11 +276,14 @@ interface IFavouriteButtonProp {
}
const FavouriteButton = ({ mxEvent }: IFavouriteButtonProp) => {
- const { isFavourite, toggleFavourite } = useFavouriteMessages();
+ const { isFavourite, toggleFavourite, onFavouritesChanged } = useFavouriteMessages();
+ const [, forceRefresh] = useState([]);
+
+ onFavouritesChanged(() => 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(
@@ -289,9 +292,9 @@ const FavouriteButton = ({ mxEvent }: IFavouriteButtonProp) => {
e.preventDefault();
e.stopPropagation();
- toggleFavourite(eventId);
+ toggleFavourite(mxEvent);
},
- [toggleFavourite, eventId],
+ [mxEvent, toggleFavourite],
);
return (
@@ -440,7 +443,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..b5c705fe440a 100644
--- a/src/hooks/useFavouriteMessages.ts
+++ b/src/hooks/useFavouriteMessages.ts
@@ -14,27 +14,82 @@ 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 { 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;
+
+function loadFavourites(): FavouriteStorage[] {
+ try {
+ return JSON.parse(localStorage?.getItem("io_element_favouriteMessages") ?? "[]");
+ } catch (e) {
+ console.error(e);
+ return [];
+ }
+}
+
+function saveFavourites(): void {
+ localStorage?.setItem("io_element_favouriteMessages", JSON.stringify(ioElementFavouriteMessages));
+}
+
+function clearFavourites(): void {
+ ioElementFavouriteMessages.length = 0;
+ localStorage.removeItem("io_element_favouriteMessages");
+}
export default function useFavouriteMessages() {
- const [, setX] = useState();
+ if (ioElementFavouriteMessages === null) {
+ ioElementFavouriteMessages = loadFavourites();
+ }
+
+ const favChange = useRef(null);
- //checks if an id already exist
- const isFavourite = (eventId: string): boolean => favouriteMessageIds.includes(eventId);
+ const isFavourite = (eventId: string): boolean => {
+ return ioElementFavouriteMessages.some((f) => f.eventId === eventId);
+ };
+
+ const toggleFavourite = (mxEvent: MatrixEvent) => {
+ 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();
+ favChange.current?.();
};
- return { isFavourite, toggleFavourite };
+ const clearFavouriteMessages = () => {
+ clearFavourites();
+ favChange.current?.();
+ };
+
+ const getFavouriteMessages = (): FavouriteStorage[] => {
+ return JSON.parse(JSON.stringify(ioElementFavouriteMessages));
+ };
+
+ const onFavouritesChanged = (listener: () => void) => {
+ favChange.current = listener;
+ };
+
+ return {
+ getFavouriteMessages,
+ isFavourite,
+ toggleFavourite,
+ clearFavouriteMessages,
+ onFavouritesChanged,
+ };
}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index e9f5aabb76fe..977389e51846 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -3451,6 +3451,9 @@
"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? ",
+ "Reorder": "Reorder",
+ "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/FavouriteMessagesView-test.tsx b/test/components/structures/FavouriteMessagesView-test.tsx
new file mode 100644
index 000000000000..386ce98f4fd9
--- /dev/null
+++ b/test/components/structures/FavouriteMessagesView-test.tsx
@@ -0,0 +1,122 @@
+/*
+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 from "react";
+// eslint-disable-next-line deprecate/import
+import { mount } from "enzyme";
+import { mocked, MockedObject } from "jest-mock";
+import { EventType, MatrixClient, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
+
+import _FavouriteMessagesView from "../../../src/components/structures/FavouriteMessagesView/FavouriteMessagesView";
+import { stubClient, mockPlatformPeg, unmockPlatformPeg, wrapInMatrixClientContext } from "../../test-utils";
+import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
+import FavouriteMessagesPanel from "../../../src/components/structures/FavouriteMessagesView/FavouriteMessagesPanel";
+import SettingsStore from "../../../src/settings/SettingsStore";
+
+const FavouriteMessagesView = wrapInMatrixClientContext(_FavouriteMessagesView);
+
+describe("FavouriteMessagesView", () => {
+ 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",
+ });
+
+ 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",
+ });
+
+ beforeEach(async () => {
+ mockPlatformPeg({ reload: () => {} });
+ stubClient();
+ cli = mocked(MatrixClientPeg.get());
+ });
+
+ afterEach(async () => {
+ unmockPlatformPeg();
+ jest.restoreAllMocks();
+ });
+
+ describe("favourite_messages feature when enabled", () => {
+ beforeEach(() => {
+ jest.spyOn(SettingsStore, "getValue").mockImplementation(
+ (setting) => setting === "feature_favourite_messages",
+ );
+ });
+
+ it("renders correctly", () => {
+ const view = mount();
+ expect(view.html()).toMatchSnapshot();
+ });
+
+ it("renders component with empty or default props correctly", () => {
+ const props = {
+ favouriteMessageEvents: [],
+ handleSearchQuery: jest.fn(),
+ searchQuery: "",
+ cli,
+ };
+ const view = mount();
+ expect(view.prop("favouriteMessageEvents")).toHaveLength(0);
+ expect(view.contains("No Favourite Messages")).toBeTruthy();
+ });
+
+ it("renders starred messages correctly for a single event", () => {
+ const props = {
+ favouriteMessageEvents: [bobsFavouriteMessageEvent],
+ handleSearchQuery: jest.fn(),
+ searchQuery: "",
+ cli,
+ };
+ const view = mount();
+
+ expect(view.find(".mx_EventTile_body").text()).toEqual("i am bob");
+ });
+
+ it("renders starred messages correctly for multiple single event", () => {
+ const props = {
+ favouriteMessageEvents: [alicesFavouriteMessageEvent, bobsFavouriteMessageEvent],
+ handleSearchQuery: jest.fn(),
+ searchQuery: "",
+ cli,
+ };
+ const view = mount();
+ //for alice
+ expect(view.find("li[data-event-id='$alices_message']")).toBeDefined();
+ expect(view.find("li[data-event-id='$alices_message']").contains("i am alice")).toBeTruthy();
+
+ //for bob
+ expect(view.find("li[data-event-id='$bobs_message']")).toBeDefined();
+ expect(view.find("li[data-event-id='$bobs_message']").contains("i am bob")).toBeTruthy();
+ });
+ });
+});
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..17844b573dde
--- /dev/null
+++ b/test/components/structures/__snapshots__/FavouriteMessagesView-test.tsx.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FavouriteMessagesView favourite_messages feature when enabled renders correctly 1`] = `"
FFavourite Messages
"`;
diff --git a/test/components/views/messages/MessageActionBar-test.tsx b/test/components/views/messages/MessageActionBar-test.tsx
index a3a3ffe14186..d7becce63faf 100644
--- a/test/components/views/messages/MessageActionBar-test.tsx
+++ b/test/components/views/messages/MessageActionBar-test.tsx
@@ -516,7 +516,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);
@@ -534,7 +534,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
@@ -542,17 +546,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(() => {
@@ -560,7 +571,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" },
+ },
+ ]),
+ );
});
});