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 ( +
    +
    +
    + + Favourite Messages +
    +
    + { isSearchClicked && + ( setQuery(e.target.value)} + />) + } + { !isSearchClicked + ? setSearchClicked(!isSearchClicked)} + title={_t("Search")} + key="search" + /> + : setSearchClicked(!isSearchClicked)} + title={_t("Cancel")} + key="cancel" + /> + } + reorderFavouriteMessages()} + title={_t("Reorder")} + key="reorder" + /> + +
    +
    +
    + ); +}; + +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`] = `"
    F\\"\\"Favourite 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); }); });