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

Add Pin/Unpin action in quick access of the message action bar #12897

Merged
merged 6 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading