diff --git a/res/css/_components.pcss b/res/css/_components.pcss
index 3f23e6de572..e10b60e0e05 100644
--- a/res/css/_components.pcss
+++ b/res/css/_components.pcss
@@ -37,6 +37,7 @@
@import "./components/views/settings/shared/_SettingsSubsection.pcss";
@import "./components/views/spaces/_QuickThemeSwitcher.pcss";
@import "./structures/_AutoHideScrollbar.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 00000000000..ed15c9d8d34
--- /dev/null
+++ b/res/css/structures/_FavouriteMessagesView.pcss
@@ -0,0 +1,96 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_FavMessagesHeader {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ flex: 0 0 50px;
+ border-bottom: 1px solid $primary-hairline-color;
+ background-color: $background;
+ z-index: 999;
+}
+
+.mx_FavMessagesHeader_Wrapper {
+ height: 44px;
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ margin: 0 20px 0 16px;
+ padding-top: 8px;
+ border-bottom: 1px solid $system;
+ justify-content: space-between;
+
+ .mx_FavMessagesHeader_Wrapper_left {
+ display: flex;
+ align-items: center;
+ flex: 0.4;
+
+ & > span {
+ color: $primary-content;
+ font-weight: $font-semi-bold;
+ font-size: $font-18px;
+ margin: 0 8px;
+ }
+ }
+
+ .mx_FavMessagesHeader_Wrapper_right {
+ display: flex;
+ align-items: center;
+ flex: 0.6;
+ justify-content: flex-end;
+ }
+}
+
+.mx_FavMessagesHeader_sortButton::before {
+ mask-image: url('$(res)/img/element-icons/room/sort-twoway.svg');
+}
+
+.mx_FavMessagesHeader_clearAllButton::before {
+ mask-image: url('$(res)/img/element-icons/room/clear-all.svg');
+}
+
+.mx_FavMessagesHeader_cancelButton {
+ background-color: $alert;
+ mask: url('$(res)/img/cancel.svg');
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-size: 17px;
+ padding: 9px;
+ margin: 0 12px 0 3px;
+ cursor: pointer;
+}
+
+.mx_FavMessagesHeader_Search {
+ width: 70%;
+}
+
+.mx_FavouriteMessages_emptyMarker {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 25px;
+ font-weight: 600;
+}
+
+.mx_FavouriteMessages_scrollPanel {
+ margin-top: 25px;
+}
+
+.mx_ClearDialog {
+ width: 100%;
+}
diff --git a/res/img/element-icons/room/clear-all.svg b/res/img/element-icons/room/clear-all.svg
new file mode 100644
index 00000000000..dde0bb131b1
--- /dev/null
+++ b/res/img/element-icons/room/clear-all.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/res/img/element-icons/room/sort-twoway.svg b/res/img/element-icons/room/sort-twoway.svg
new file mode 100644
index 00000000000..c1c68e3e87e
--- /dev/null
+++ b/res/img/element-icons/room/sort-twoway.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/PageTypes.ts b/src/PageTypes.ts
index fb0424f6e05..28e9178f6e2 100644
--- a/src/PageTypes.ts
+++ b/src/PageTypes.ts
@@ -21,6 +21,7 @@ enum PageType {
RoomView = "room_view",
UserView = "user_view",
LegacyGroupView = "legacy_group_view",
+ FavouriteMessagesView = "favourite_messages_view"
}
export default PageType;
diff --git a/src/PosthogTrackers.ts b/src/PosthogTrackers.ts
index 0422f0bf9b6..453a4fcdb5d 100644
--- a/src/PosthogTrackers.ts
+++ b/src/PosthogTrackers.ts
@@ -42,6 +42,7 @@ const loggedInPageTypeMap: Record = {
[PageType.RoomView]: "Room",
[PageType.UserView]: "User",
[PageType.LegacyGroupView]: "Group",
+ [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 00000000000..4b9e6db9b9f
--- /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 00000000000..d3a02e75a97
--- /dev/null
+++ b/src/components/structures/FavouriteMessagesView/FavouriteMessageTile.tsx
@@ -0,0 +1,111 @@
+/*
+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_thread");
+
+ 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 00000000000..a052c5c7393
--- /dev/null
+++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesHeader.tsx
@@ -0,0 +1,104 @@
+/*
+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, { useEffect, 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';
+
+const FavouriteMessagesHeader = ({ handleSearchQuery }) => {
+ const { reorderFavouriteMessages, setSearchState, getFavouriteMessagesIds } = useFavouriteMessages();
+ const favouriteMessagesIds = getFavouriteMessagesIds();
+
+ const [isSearchClicked, setSearchClicked] = useState(false);
+ const [query, setQuery] = useState();
+
+ useEffect(() => {
+ handleSearchQuery(query);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [query]);
+
+ useEffect(() => {
+ setSearchState(isSearchClicked);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isSearchClicked]);
+
+ 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 00000000000..8082ebd3292
--- /dev/null
+++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesPanel.tsx
@@ -0,0 +1,63 @@
+/*
+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[];
+ resizeNotifier?: ResizeNotifier;
+ searchQuery?: string;
+ handleSearchQuery?: (query: string) => void;
+ cli?: MatrixClient;
+}
+
+const FavouriteMessagesPanel = (props: IProps) => {
+ const favouriteMessagesPanelRef = useRef();
+ let favouriteMessagesPanel;
+
+ if (props.favouriteMessageEvents?.length === 0) {
+ favouriteMessagesPanel = (
+ <>
+
+
{ _t("No Saved Messages") }
+ >
+ );
+ } else {
+ favouriteMessagesPanel = (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ return <>{ favouriteMessagesPanel }>;
+};
+export default FavouriteMessagesPanel;
+
diff --git a/src/components/structures/FavouriteMessagesView/FavouriteMessagesTilesList.tsx b/src/components/structures/FavouriteMessagesView/FavouriteMessagesTilesList.tsx
new file mode 100644
index 00000000000..ecce971b6ca
--- /dev/null
+++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesTilesList.tsx
@@ -0,0 +1,86 @@
+/*
+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 useFavouriteMessages from '../../../hooks/useFavouriteMessages';
+import { _t } from '../../../languageHandler';
+import Spinner from '../../views/elements/Spinner';
+import FavouriteMessageTile from './FavouriteMessageTile';
+
+interface IProps{
+ favouriteMessageEvents?: MatrixEvent[];
+ favouriteMessagesPanelRef?: any;
+ searchQuery?: string;
+ cli?: MatrixClient;
+}
+
+// eslint-disable-next-line max-len
+const FavouriteMessagesTilesList = ({ cli, favouriteMessageEvents, favouriteMessagesPanelRef, searchQuery }: IProps) => {
+ const { isSearchClicked } = useFavouriteMessages();
+
+ 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 && isSearchClicked) {
+ 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 00000000000..d7b6dc61802
--- /dev/null
+++ b/src/components/structures/FavouriteMessagesView/FavouriteMessagesView.tsx
@@ -0,0 +1,111 @@
+/*
+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, { 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 { useAsyncMemo } from '../../../hooks/useAsyncMemo';
+import useFavouriteMessages from '../../../hooks/useFavouriteMessages';
+import ResizeNotifier from '../../../utils/ResizeNotifier';
+import FavouriteMessagesPanel from './FavouriteMessagesPanel';
+
+interface IProps {
+ resizeNotifier?: ResizeNotifier;
+}
+
+//temporary container for current messageids after filtering
+let temp = JSON.parse(
+ localStorage?.getItem("io_element_favouriteMessages")?? "[]") as any[];
+
+let searchQuery: string;
+
+const FavouriteMessagesView = ({ resizeNotifier }: IProps) => {
+ const { getFavouriteMessagesIds, isSearchClicked } = useFavouriteMessages();
+ const favouriteMessagesIds = getFavouriteMessagesIds();
+
+ const cli = useContext(MatrixClientContext);
+ const [, setX] = useState();
+
+ //not the best solution, temporary implementation till a better approach comes up
+ const handleSearchQuery = (query: string) => {
+ if (query?.length === 0 || query?.length > 2) {
+ temp = favouriteMessagesIds.filter((evtObj) => (
+ evtObj.content.body.trim().toLowerCase().includes(query)));
+ searchQuery = query;
+
+ //force rerender
+ setX([]);
+ }
+ };
+
+ useEffect(() => {
+ if (!isSearchClicked) {
+ searchQuery = '';
+ temp = favouriteMessagesIds;
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isSearchClicked]);
+
+ const favouriteMessageEvents = useAsyncMemo(() => {
+ const currentFavMessageIds = temp.length < favouriteMessagesIds.length ? temp : favouriteMessagesIds;
+
+ const promises = currentFavMessageIds.map(async (resultObj) => {
+ try {
+ // Fetch the events and latest edit in parallel
+ const [evJson, { events: [edit] }] = await Promise.all([
+ cli.fetchRoomEvent(resultObj.roomId, resultObj.eventId),
+ cli.relations(resultObj.roomId, resultObj.eventId, RelationType.Replace, null, { limit: 1 }),
+ ]);
+ const event = new MatrixEvent(evJson);
+ const roomId = event.getRoomId()!;
+ const room = cli.getRoom(roomId);
+
+ if (event.isEncrypted()) {
+ await cli.decryptEventIfNeeded(event);
+ }
+
+ if (event) {
+ // Inject sender information
+ event.sender = room.getMember(event.getSender())!;
+
+ // Also inject any edits found
+ if (edit) event.makeReplaced(edit);
+
+ return event;
+ }
+ } catch (err) {
+ logger.error(err);
+ }
+ return null;
+ });
+
+ return Promise.all(promises);
+ }, [cli, favouriteMessagesIds], null);
+
+ 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 bd120529a90..d039ca69c59 100644
--- a/src/components/structures/LeftPanel.tsx
+++ b/src/components/structures/LeftPanel.tsx
@@ -371,6 +371,7 @@ export default class LeftPanel extends React.Component {
onResize={this.refreshStickyHeaders}
onListCollapse={this.refreshStickyHeaders}
ref={this.roomListRef}
+ pageType={this.props.pageType}
/>;
const containerClasses = classNames({
diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx
index d4737c2fca2..e2a668ea49e 100644
--- a/src/components/structures/LoggedInView.tsx
+++ b/src/components/structures/LoggedInView.tsx
@@ -71,6 +71,7 @@ import LegacyGroupView from "./LegacyGroupView";
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.
@@ -645,6 +646,10 @@ class LoggedInView extends React.Component {
case PageTypes.LegacyGroupView:
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 8c173a36301..35ab6cd1798 100644
--- a/src/components/structures/MatrixChat.tsx
+++ b/src/components/structures/MatrixChat.tsx
@@ -137,6 +137,7 @@ import { TimelineRenderingType } from "../../contexts/RoomContext";
import { UseCaseSelection } from '../views/elements/UseCaseSelection';
import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig';
import { isLocalRoom } from '../../utils/localRoom/isLocalRoom';
+import ConfirmClearDialog from './FavouriteMessagesView/ConfirmClearDialog';
// legacy export
export { default as Views } from "../../Views";
@@ -707,6 +708,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;
@@ -803,6 +808,9 @@ export default class MatrixChat extends React.PureComponent {
hideAnalyticsToast();
SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, true);
break;
+ case Action.ViewFavouriteMessages:
+ this.viewFavouriteMessages();
+ break;
case Action.PseudonymousAnalyticsReject:
hideAnalyticsToast();
SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, false);
@@ -1007,6 +1015,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
@@ -1704,6 +1717,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 c5108051160..07bda1e997c 100644
--- a/src/components/views/messages/MessageActionBar.tsx
+++ b/src/components/views/messages/MessageActionBar.tsx
@@ -271,11 +271,10 @@ interface IFavouriteButtonProp {
}
const FavouriteButton = ({ mxEvent }: IFavouriteButtonProp) => {
- const { isFavourite, toggleFavourite } = useFavouriteMessages();
-
+ const { isFavourite, toggleFavourite } = useFavouriteMessages({ mxEvent });
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(),
});
const onClick = useCallback((e: React.MouseEvent) => {
@@ -283,8 +282,8 @@ const FavouriteButton = ({ mxEvent }: IFavouriteButtonProp) => {
e.preventDefault();
e.stopPropagation();
- toggleFavourite(eventId);
- }, [toggleFavourite, eventId]);
+ toggleFavourite();
+ }, [toggleFavourite]);
return {
resizeMethod="crop"
/>);
+ 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 4e161a70051..306821ef883 100644
--- a/src/dispatcher/actions.ts
+++ b/src/dispatcher/actions.ts
@@ -331,4 +331,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 a6d5c315ed2..9420b05dced 100644
--- a/src/hooks/useFavouriteMessages.ts
+++ b/src/hooks/useFavouriteMessages.ts
@@ -1,4 +1,3 @@
-
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
@@ -15,27 +14,65 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { useState } from "react";
-const favouriteMessageIds = JSON.parse(
- localStorage?.getItem("io_element_favouriteMessages")?? "[]") as string[];
+interface IButtonProp {
+ mxEvent?: MatrixEvent;
+}
+
+let sortState = false;
+let isSearchClicked = false;
+
+export default function useFavouriteMessages(props?: IButtonProp) {
+ const favouriteMessagesIds = JSON.parse(
+ localStorage?.getItem("io_element_favouriteMessages") ?? "[]") as any[];
-export default function useFavouriteMessages() {
const [, setX] = useState();
+ const eventId = props?.mxEvent.getId();
+ const roomId = props?.mxEvent.getRoomId();
+ const content = props?.mxEvent.getContent();
//checks if an id already exist
- const isFavourite = (eventId: string): boolean => favouriteMessageIds.includes(eventId);
+ const isFavourite = (): boolean => {
+ return favouriteMessagesIds.some(val => val.eventId === eventId);
+ };
- const toggleFavourite = (eventId: string) => {
- isFavourite(eventId) ? favouriteMessageIds.splice(favouriteMessageIds.indexOf(eventId), 1)
- : favouriteMessageIds.push(eventId);
+ const toggleFavourite = () => {
+ isFavourite() ? favouriteMessagesIds.splice(favouriteMessagesIds.findIndex(val => val.eventId === eventId), 1)
+ : favouriteMessagesIds.push({ eventId, roomId, content });
//update the local storage
- localStorage.setItem('io_element_favouriteMessages', JSON.stringify(favouriteMessageIds));
+ localStorage.setItem('io_element_favouriteMessages', JSON.stringify(favouriteMessagesIds));
// This forces a re-render to account for changes in appearance in real-time when the favourite button is toggled
setX([]);
};
- return { isFavourite, toggleFavourite };
+ const reorderFavouriteMessages = () => {
+ sortState = !sortState;
+ };
+
+ const setSearchState = (val: boolean) => {
+ isSearchClicked = val;
+ };
+
+ const clearFavouriteMessages = () => {
+ localStorage.removeItem('io_element_favouriteMessages');
+ };
+
+ const getFavouriteMessagesIds = () => {
+ return sortState ? favouriteMessagesIds.reverse(): favouriteMessagesIds;
+ };
+
+ return {
+ isFavourite,
+ toggleFavourite,
+ getFavouriteMessagesIds,
+ reorderFavouriteMessages,
+ clearFavouriteMessages,
+ setSearchState,
+ isSearchClicked,
+
+ };
}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 4a00a99540c..d6062d0c6a4 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -3277,6 +3277,9 @@
"Decrypted event source": "Decrypted event source",
"Original event source": "Original event source",
"Event ID: %(eventId)s": "Event ID: %(eventId)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 Saved Messages": "No Saved 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 4f0e7d5b13b..d2932a0fade 100644
--- a/src/stores/RoomViewStore.tsx
+++ b/src/stores/RoomViewStore.tsx
@@ -176,6 +176,7 @@ export class RoomViewStore extends Store {
// 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 00000000000..1c86dab9b02
--- /dev/null
+++ b/test/components/structures/FavouriteMessagesView-test.tsx
@@ -0,0 +1,118 @@
+/*
+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(),
+ cli,
+ };
+ const view = mount();
+ expect(view.prop('favouriteMessageEvents')).toHaveLength(0);
+ expect(view.contains("No Saved Messages")).toBeTruthy();
+ });
+
+ it('renders starred messages correctly for a single event', () => {
+ const props = {
+ favouriteMessageEvents: [bobsFavouriteMessageEvent],
+ handleSearchQuery: jest.fn(),
+ 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(),
+ 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 00000000000..9d60baba05a
--- /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 d4dcb1a2dff..5bd53847701 100644
--- a/test/components/views/messages/MessageActionBar-test.tsx
+++ b/test/components/views/messages/MessageActionBar-test.tsx
@@ -16,7 +16,7 @@ limitations under the License.
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
-import { act } from 'react-test-renderer';
+import { act } from "react-test-renderer";
import {
EventType,
EventStatus,
@@ -530,8 +530,7 @@ describe('', () => {
expect(alicesAction.classList).toContain('mx_MessageActionBar_favouriteButton_fillstar');
expect(bobsAction.classList).not.toContain('mx_MessageActionBar_favouriteButton_fillstar');
- expect(localStorageMock.setItem)
- .toHaveBeenCalledWith('io_element_favouriteMessages', '["$alices_message"]');
+ expect(JSON.parse(localStorageMock.getItem('io_element_favouriteMessages'))).toHaveLength(1);
//when bob's event is fired,both should be styled and stored in localStorage
act(() => {
@@ -540,20 +539,19 @@ describe('', () => {
expect(alicesAction.classList).toContain('mx_MessageActionBar_favouriteButton_fillstar');
expect(bobsAction.classList).toContain('mx_MessageActionBar_favouriteButton_fillstar');
- expect(localStorageMock.setItem)
- .toHaveBeenCalledWith('io_element_favouriteMessages', '["$alices_message","$bobs_message"]');
- //finally, at this point the localStorage should contain the two eventids
- expect(localStorageMock.getItem('io_element_favouriteMessages'))
- .toEqual('["$alices_message","$bobs_message"]');
+ //if we decided to unfavourite alice's and bob's event by clicking again
+ act(() => {
+ fireEvent.click(alicesAction);
+ });
- //if decided to unfavourite bob's event by clicking again
act(() => {
fireEvent.click(bobsAction);
});
+
expect(bobsAction.classList).not.toContain('mx_MessageActionBar_favouriteButton_fillstar');
- expect(alicesAction.classList).toContain('mx_MessageActionBar_favouriteButton_fillstar');
- expect(localStorageMock.getItem('io_element_favouriteMessages')).toEqual('["$alices_message"]');
+ expect(alicesAction.classList).not.toContain('mx_MessageActionBar_favouriteButton_fillstar');
+ expect(JSON.parse(localStorageMock.getItem('io_element_favouriteMessages'))).toHaveLength(0);
});
});