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();
+ });
+ });
+ });
});