diff --git a/res/css/views/messages/_MessageActionBar.pcss b/res/css/views/messages/_MessageActionBar.pcss index 9dfb6b7495..1d2cfa6f5f 100644 --- a/res/css/views/messages/_MessageActionBar.pcss +++ b/res/css/views/messages/_MessageActionBar.pcss @@ -21,6 +21,7 @@ limitations under the License. --MessageActionBar-item-hover-background: $panel-actions; --MessageActionBar-item-hover-borderRadius: 6px; --MessageActionBar-item-hover-zIndex: 1; + --MessageActionBar-star-button-color: #ffa534; position: absolute; visibility: hidden; @@ -114,6 +115,14 @@ limitations under the License. } } + &.mx_MessageActionBar_favouriteButton::after { + mask-image: url('$(res)/img/element-icons/room/message-bar/star.svg'); + } + + &.mx_MessageActionBar_favouriteButton_fillstar::after { + background-color: var(--MessageActionBar-star-button-color); + } + &.mx_MessageActionBar_editButton::after { mask-image: url('$(res)/img/element-icons/room/message-bar/edit.svg'); } diff --git a/res/img/element-icons/room/message-bar/star.svg b/res/img/element-icons/room/message-bar/star.svg new file mode 100644 index 0000000000..ecfeb242d6 --- /dev/null +++ b/res/img/element-icons/room/message-bar/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 2788a17e6b..4256cb54c1 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -48,6 +48,7 @@ import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts"; import { UserTab } from '../dialogs/UserTab'; import { Action } from '../../../dispatcher/actions'; import SdkConfig from "../../../SdkConfig"; +import useFavouriteMessages from '../../../hooks/useFavouriteMessages'; interface IOptionsButtonProps { mxEvent: MatrixEvent; @@ -237,6 +238,26 @@ const ReplyInThreadButton = ({ mxEvent }: IReplyInThreadButton) => { ; }; +interface IFavouriteButtonProp { + mxEvent: MatrixEvent; +} + +const FavouriteButton = ({ mxEvent }: IFavouriteButtonProp) => { + const { isFavourite, toggleFavourite } = useFavouriteMessages(); + + const eventId = mxEvent.getId(); + const classes = classNames("mx_MessageActionBar_maskButton mx_MessageActionBar_favouriteButton", { + 'mx_MessageActionBar_favouriteButton_fillstar': isFavourite(eventId), + }); + + return toggleFavourite(eventId)} + data-testid={eventId} + />; +}; + interface IMessageActionBarProps { mxEvent: MatrixEvent; reactions?: Relations; @@ -421,6 +442,7 @@ export default class MessageActionBar extends React.PureComponent); } + if (SettingsStore.getValue("feature_favourite_messages")) { + toolbarOpts.splice(-1, 0, ( + + )); + } // XXX: Assuming that the underlying tile will be a media event if it is eligible media. if (MediaEventHelper.isEligible(this.props.mxEvent)) { diff --git a/src/hooks/useFavouriteMessages.ts b/src/hooks/useFavouriteMessages.ts new file mode 100644 index 0000000000..a6d5c315ed --- /dev/null +++ b/src/hooks/useFavouriteMessages.ts @@ -0,0 +1,41 @@ + +/* +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 { useState } from "react"; + +const favouriteMessageIds = JSON.parse( + localStorage?.getItem("io_element_favouriteMessages")?? "[]") as string[]; + +export default function useFavouriteMessages() { + const [, setX] = useState(); + + //checks if an id already exist + const isFavourite = (eventId: string): boolean => favouriteMessageIds.includes(eventId); + + const toggleFavourite = (eventId: string) => { + isFavourite(eventId) ? favouriteMessageIds.splice(favouriteMessageIds.indexOf(eventId), 1) + : favouriteMessageIds.push(eventId); + + //update the local storage + localStorage.setItem('io_element_favouriteMessages', JSON.stringify(favouriteMessageIds)); + + // This forces a re-render to account for changes in appearance in real-time when the favourite button is toggled + setX([]); + }; + + return { isFavourite, toggleFavourite }; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3cb4800634..875516e0bb 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -902,6 +902,7 @@ "Right-click message context menu": "Right-click message context menu", "Location sharing - pin drop": "Location sharing - pin drop", "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)", + "Favourite Messages (under active development)": "Favourite Messages (under active development)", "Font size": "Font size", "Use custom size": "Use custom size", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", @@ -2115,6 +2116,7 @@ "Can't create a thread from an event with an existing relation": "Can't create a thread from an event with an existing relation", "Beta feature": "Beta feature", "Beta feature. Click to learn more.": "Beta feature. Click to learn more.", + "Favourite": "Favourite", "Edit": "Edit", "Reply": "Reply", "Collapse quotes": "Collapse quotes", @@ -2940,7 +2942,6 @@ "Copy link": "Copy link", "Forget": "Forget", "Favourited": "Favourited", - "Favourite": "Favourite", "Mentions only": "Mentions only", "Copy room link": "Copy room link", "Low Priority": "Low Priority", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 8aa6a30f63..b9871b3bbd 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -429,6 +429,13 @@ export const SETTINGS: {[setting: string]: ISetting} = { ), default: false, }, + "feature_favourite_messages": { + isFeature: true, + labsGroup: LabGroup.Messaging, + supportedLevels: LEVELS_FEATURE, + displayName: _td("Favourite Messages (under active development)"), + default: false, + }, "baseFontSize": { displayName: _td("Font size"), supportedLevels: LEVELS_ACCOUNT_SETTINGS, diff --git a/test/components/views/messages/MessageActionBar-test.tsx b/test/components/views/messages/MessageActionBar-test.tsx index 38daafa65b..2d17544b2c 100644 --- a/test/components/views/messages/MessageActionBar-test.tsx +++ b/test/components/views/messages/MessageActionBar-test.tsx @@ -59,6 +59,7 @@ describe('', () => { msgtype: MsgType.Text, body: 'Hello', }, + event_id: "$alices_message", }); const bobsMessageEvent = new MatrixEvent({ @@ -69,6 +70,7 @@ describe('', () => { msgtype: MsgType.Text, body: 'I am bob', }, + event_id: "$bobs_message", }); const redactedEvent = new MatrixEvent({ @@ -82,6 +84,25 @@ describe('', () => { ...mockClientMethodsEvents(), getRoom: jest.fn(), }); + + const localStorageMock = (() => { + let store = {}; + return { + getItem: jest.fn().mockImplementation(key => store[key] ?? null), + setItem: jest.fn().mockImplementation((key, value) => { + store[key] = value; + }), + clear: jest.fn().mockImplementation(() => { + store = {}; + }), + removeItem: jest.fn().mockImplementation((key) => delete store[key]), + }; + })(); + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, + }); + const room = new Room(roomId, client, userId); jest.spyOn(room, 'getPendingEvents').mockReturnValue([]); @@ -464,4 +485,86 @@ describe('', () => { }); }); }); + + describe('favourite button', () => { + //for multiple event usecase + const favButton = (evt: MatrixEvent) => { + return getComponent({ mxEvent: evt }).getByTestId(evt.getId()); + }; + + describe('when favourite_messages feature is enabled', () => { + beforeEach(() => { + jest.spyOn(SettingsStore, 'getValue') + .mockImplementation(setting => setting === 'feature_favourite_messages'); + localStorageMock.clear(); + }); + + it('renders favourite button on own actionable event', () => { + const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); + expect(queryByLabelText('Favourite')).toBeTruthy(); + }); + + it('renders favourite button on other actionable events', () => { + const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent }); + expect(queryByLabelText('Favourite')).toBeTruthy(); + }); + + it('does not render Favourite button on non-actionable event', () => { + //redacted event is not actionable + const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }); + expect(queryByLabelText('Favourite')).toBeFalsy(); + }); + + it('remembers favourited state of multiple events, and handles the localStorage of the events accordingly', + () => { + const alicesAction = favButton(alicesMessageEvent); + const bobsAction = favButton(bobsMessageEvent); + + //default state before being clicked + expect(alicesAction.classList).not.toContain('mx_MessageActionBar_favouriteButton_fillstar'); + expect(bobsAction.classList).not.toContain('mx_MessageActionBar_favouriteButton_fillstar'); + expect(localStorageMock.getItem('io_element_favouriteMessages')).toBeNull(); + + //if only alice's event is fired + act(() => { + fireEvent.click(alicesAction); + }); + + 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"]'); + + //when bob's event is fired,both should be styled and stored in localStorage + act(() => { + fireEvent.click(bobsAction); + }); + + 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 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"]'); + }); + }); + + describe('when favourite_messages feature is disabled', () => { + it('does not render', () => { + jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); + const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); + expect(queryByLabelText('Favourite')).toBeFalsy(); + }); + }); + }); });