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 ( +
    +
    +
    + + Favourite Messages +
    +
    + {isSearchClicked ? ( + <> + handleSearchQuery(e.target.value)} + value={query} + /> + setSearchClicked(false)} + title={_t("Cancel")} + key="cancel" + /> + + ) : ( + setSearchClicked(true)} + title={_t("Search")} + key="search" + /> + )} + setSortAscending(!sortAscending)} + 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 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`] = `"
    Favourite 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" }, + }, + ]), + ); }); });