Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Add Pin/Unpin action in quick access of the message action bar (#12897)
Browse files Browse the repository at this point in the history
* Add Pin/Unpin action in quick access of the message action bar

* Add tests for `MessageActionBar`

* Add tests for `PinningUtils`

* Fix `MessageContextMenu-test`

* Add e2e test to pin/unpin from message action bar
  • Loading branch information
florianduros authored Aug 21, 2024
1 parent 4064db1 commit 3d80eff
Show file tree
Hide file tree
Showing 9 changed files with 503 additions and 105 deletions.
26 changes: 24 additions & 2 deletions playwright/e2e/pinned-messages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,35 @@ export class Helpers {
}

/**
* Pin the given message
* Pin the given message from the quick actions
* @param message
* @param unpin
*/
async pinMessageFromQuickActions(message: string, unpin = false) {
const timelineMessage = this.page.locator(".mx_MTextBody", { hasText: message });
await timelineMessage.hover();
await this.page.getByRole("button", { name: unpin ? "Unpin" : "Pin", exact: true }).click();
}

/**
* Pin the given messages from the quick actions
* @param messages
* @param unpin
*/
async pinMessagesFromQuickActions(messages: string[], unpin = false) {
for (const message of messages) {
await this.pinMessageFromQuickActions(message, unpin);
}
}

/**
* Pin the given message from the contextual menu
* @param message
*/
async pinMessage(message: string) {
const timelineMessage = this.page.locator(".mx_MTextBody", { hasText: message });
await timelineMessage.click({ button: "right" });
await this.page.getByRole("menuitem", { name: "Pin" }).click();
await this.page.getByRole("menuitem", { name: "Pin", exact: true }).click();
}

/**
Expand Down
11 changes: 11 additions & 0 deletions playwright/e2e/pinned-messages/pinned-messages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,15 @@ test.describe("Pinned messages", () => {
await util.backPinnedMessagesList();
await util.assertPinnedCountInRoomInfo(0);
});

test("should be able to pin and unpin from the quick actions", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
await util.pinMessagesFromQuickActions(["Msg1"]);
await util.openRoomInfo();
await util.assertPinnedCountInRoomInfo(1);

await util.pinMessagesFromQuickActions(["Msg1"], true);
await util.assertPinnedCountInRoomInfo(0);
});
});
4 changes: 2 additions & 2 deletions res/css/views/context_menus/_MessageContextMenu.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ limitations under the License.
}

.mx_MessageContextMenu_iconPin::before {
mask-image: url("$(res)/img/element-icons/room/pin-upright.svg");
mask-image: url("@vector-im/compound-design-tokens/icons/pin.svg");
}

.mx_MessageContextMenu_iconUnpin::before {
mask-image: url("$(res)/img/element-icons/room/pin.svg");
mask-image: url("@vector-im/compound-design-tokens/icons/unpin.svg");
}

.mx_MessageContextMenu_iconCopy::before {
Expand Down
64 changes: 19 additions & 45 deletions src/components/views/context_menus/MessageContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,8 @@ import Modal from "../../../Modal";
import Resend from "../../../Resend";
import SettingsStore from "../../../settings/SettingsStore";
import { isUrlPermitted } from "../../../HtmlUtils";
import { canEditContent, canPinEvent, editEvent, isContentActionable } from "../../../utils/EventUtils";
import { canEditContent, editEvent, isContentActionable } from "../../../utils/EventUtils";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { ReadPinsEventId } from "../right_panel/types";
import { Action } from "../../../dispatcher/actions";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { ButtonEvent } from "../elements/AccessibleButton";
Expand All @@ -60,6 +59,7 @@ import { getForwardableEvent } from "../../../events/forward/getForwardableEvent
import { getShareableLocationEvent } from "../../../events/location/getShareableLocationEvent";
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { CardContext } from "../right_panel/context";
import PinningUtils from "../../../utils/PinningUtils";

interface IReplyInThreadButton {
mxEvent: MatrixEvent;
Expand Down Expand Up @@ -177,24 +177,11 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
this.props.mxEvent.getType() !== EventType.RoomServerAcl &&
this.props.mxEvent.getType() !== EventType.RoomEncryption;

let canPin =
!!room?.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli) &&
canPinEvent(this.props.mxEvent);

// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
if (!SettingsStore.getValue("feature_pinning")) canPin = false;
const canPin = PinningUtils.canPinOrUnpin(cli, this.props.mxEvent);

this.setState({ canRedact, canPin });
};

private isPinned(): boolean {
const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
const pinnedEvent = room?.currentState.getStateEvents(EventType.RoomPinnedEvents, "");
if (!pinnedEvent) return false;
const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
}

private canEndPoll(mxEvent: MatrixEvent): boolean {
return (
M_POLL_START.matches(mxEvent.getType()) &&
Expand Down Expand Up @@ -257,22 +244,8 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
};

private onPinClick = (): void => {
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
if (!room) return;
const eventId = this.props.mxEvent.getId();

const pinnedIds = room.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || [];

if (pinnedIds.includes(eventId)) {
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
} else {
pinnedIds.push(eventId);
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), eventId],
});
}
cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned: pinnedIds }, "");
// Pin or unpin in background
PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
this.closeMenu();
};

Expand Down Expand Up @@ -452,17 +425,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
}

let pinButton: JSX.Element | undefined;
if (contentActionable && this.state.canPin) {
pinButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconPin"
label={this.isPinned() ? _t("action|unpin") : _t("action|pin")}
onClick={this.onPinClick}
/>
);
}

// This is specifically not behind the developerMode flag to give people insight into the Matrix
const viewSourceButton = (
<IconizedContextMenuOption
Expand Down Expand Up @@ -649,6 +611,18 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
}

let pinButton: JSX.Element | undefined;
if (rightClick && this.state.canPin) {
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
pinButton = (
<IconizedContextMenuOption
iconClassName={isPinned ? "mx_MessageContextMenu_iconUnpin" : "mx_MessageContextMenu_iconPin"}
label={isPinned ? _t("action|unpin") : _t("action|pin")}
onClick={this.onPinClick}
/>
);
}

let viewInRoomButton: JSX.Element | undefined;
if (isThreadRootEvent) {
viewInRoomButton = (
Expand All @@ -671,13 +645,14 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
}

let quickItemsList: JSX.Element | undefined;
if (editButton || replyButton || reactButton) {
if (editButton || replyButton || reactButton || pinButton) {
quickItemsList = (
<IconizedContextMenuOptionList>
{reactButton}
{replyButton}
{replyInThreadButton}
{editButton}
{pinButton}
</IconizedContextMenuOptionList>
);
}
Expand All @@ -688,7 +663,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
{openInMapSiteButton}
{endPollButton}
{forwardButton}
{pinButton}
{permalinkButton}
{reportEventButton}
{externalURLButton}
Expand Down
30 changes: 30 additions & 0 deletions src/components/views/messages/MessageActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
M_BEACON_INFO,
} from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { Icon as PinIcon } from "@vector-im/compound-design-tokens/icons/pin.svg";
import { Icon as UnpinIcon } from "@vector-im/compound-design-tokens/icons/unpin.svg";

import { Icon as ContextMenuIcon } from "../../../../res/img/element-icons/context-menu.svg";
import { Icon as EditIcon } from "../../../../res/img/element-icons/room/message-bar/edit.svg";
Expand Down Expand Up @@ -61,6 +63,7 @@ import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayloa
import { GetRelationsForEvent, IEventTileType } from "../rooms/EventTile";
import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast/types";
import { ButtonEvent } from "../elements/AccessibleButton";
import PinningUtils from "../../../utils/PinningUtils";

interface IOptionsButtonProps {
mxEvent: MatrixEvent;
Expand Down Expand Up @@ -384,6 +387,17 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
);
};

/**
* Pin or unpin the event.
*/
private onPinClick = async (event: ButtonEvent): Promise<void> => {
// Don't open the regular browser or our context menu on right-click
event.preventDefault();
event.stopPropagation();

await PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
};

public render(): React.ReactNode {
const toolbarOpts: JSX.Element[] = [];
if (canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
Expand All @@ -401,6 +415,22 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
);
}

if (PinningUtils.canPinOrUnpin(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
toolbarOpts.push(
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={isPinned ? _t("action|unpin") : _t("action|pin")}
onClick={this.onPinClick}
onContextMenu={this.onPinClick}
key="pin"
placement="left"
>
{isPinned ? <UnpinIcon /> : <PinIcon />}
</RovingAccessibleButton>,
);
}

const cancelSendingButton = (
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
Expand Down
81 changes: 78 additions & 3 deletions src/utils/PinningUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { MatrixEvent, EventType, M_POLL_START } from "matrix-js-sdk/src/matrix";
import { MatrixEvent, EventType, M_POLL_START, MatrixClient, EventTimeline } from "matrix-js-sdk/src/matrix";

import { canPinEvent, isContentActionable } from "./EventUtils";
import SettingsStore from "../settings/SettingsStore";
import { ReadPinsEventId } from "../components/views/right_panel/types";

export default class PinningUtils {
/**
* Event types that may be pinned.
*/
public static pinnableEventTypes: (EventType | string)[] = [
public static readonly PINNABLE_EVENT_TYPES: (EventType | string)[] = [
EventType.RoomMessage,
M_POLL_START.name,
M_POLL_START.altName,
Expand All @@ -33,9 +37,80 @@ export default class PinningUtils {
*/
public static isPinnable(event: MatrixEvent): boolean {
if (!event) return false;
if (!this.pinnableEventTypes.includes(event.getType())) return false;
if (!this.PINNABLE_EVENT_TYPES.includes(event.getType())) return false;
if (event.isRedacted()) return false;

return true;
}

/**
* Determines if the given event is pinned.
* @param matrixClient
* @param mxEvent
*/
public static isPinned(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
const room = matrixClient.getRoom(mxEvent.getRoomId());
if (!room) return false;

const pinnedEvent = room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.getStateEvents(EventType.RoomPinnedEvents, "");
if (!pinnedEvent) return false;
const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(mxEvent.getId());
}

/**
* Determines if the given event may be pinned or unpinned.
* @param matrixClient
* @param mxEvent
*/
public static canPinOrUnpin(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
if (!SettingsStore.getValue("feature_pinning")) return false;
if (!isContentActionable(mxEvent)) return false;

const room = matrixClient.getRoom(mxEvent.getRoomId());
if (!room) return false;

return Boolean(
room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.mayClientSendStateEvent(EventType.RoomPinnedEvents, matrixClient) && canPinEvent(mxEvent),
);
}

/**
* Pin or unpin the given event.
* @param matrixClient
* @param mxEvent
*/
public static async pinOrUnpinEvent(matrixClient: MatrixClient, mxEvent: MatrixEvent): Promise<void> {
const room = matrixClient.getRoom(mxEvent.getRoomId());
if (!room) return;

const eventId = mxEvent.getId();
if (!eventId) return;

// Get the current pinned events of the room
const pinnedIds: Array<string> =
room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.getStateEvents(EventType.RoomPinnedEvents, "")
?.getContent().pinned || [];

// If the event is already pinned, unpin it
if (pinnedIds.includes(eventId)) {
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
} else {
// Otherwise, pin it
pinnedIds.push(eventId);
await matrixClient.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), eventId],
});
}
await matrixClient.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned: pinnedIds }, "");
}
}
Loading

0 comments on commit 3d80eff

Please sign in to comment.