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

Commit 3d80eff

Browse files
authored
Add Pin/Unpin action in quick access of the message action bar (#12897)
* 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
1 parent 4064db1 commit 3d80eff

File tree

9 files changed

+503
-105
lines changed

9 files changed

+503
-105
lines changed

playwright/e2e/pinned-messages/index.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,35 @@ export class Helpers {
100100
}
101101

102102
/**
103-
* Pin the given message
103+
* Pin the given message from the quick actions
104+
* @param message
105+
* @param unpin
106+
*/
107+
async pinMessageFromQuickActions(message: string, unpin = false) {
108+
const timelineMessage = this.page.locator(".mx_MTextBody", { hasText: message });
109+
await timelineMessage.hover();
110+
await this.page.getByRole("button", { name: unpin ? "Unpin" : "Pin", exact: true }).click();
111+
}
112+
113+
/**
114+
* Pin the given messages from the quick actions
115+
* @param messages
116+
* @param unpin
117+
*/
118+
async pinMessagesFromQuickActions(messages: string[], unpin = false) {
119+
for (const message of messages) {
120+
await this.pinMessageFromQuickActions(message, unpin);
121+
}
122+
}
123+
124+
/**
125+
* Pin the given message from the contextual menu
104126
* @param message
105127
*/
106128
async pinMessage(message: string) {
107129
const timelineMessage = this.page.locator(".mx_MTextBody", { hasText: message });
108130
await timelineMessage.click({ button: "right" });
109-
await this.page.getByRole("menuitem", { name: "Pin" }).click();
131+
await this.page.getByRole("menuitem", { name: "Pin", exact: true }).click();
110132
}
111133

112134
/**

playwright/e2e/pinned-messages/pinned-messages.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,15 @@ test.describe("Pinned messages", () => {
7676
await util.backPinnedMessagesList();
7777
await util.assertPinnedCountInRoomInfo(0);
7878
});
79+
80+
test("should be able to pin and unpin from the quick actions", async ({ page, app, room1, util }) => {
81+
await util.goTo(room1);
82+
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
83+
await util.pinMessagesFromQuickActions(["Msg1"]);
84+
await util.openRoomInfo();
85+
await util.assertPinnedCountInRoomInfo(1);
86+
87+
await util.pinMessagesFromQuickActions(["Msg1"], true);
88+
await util.assertPinnedCountInRoomInfo(0);
89+
});
7990
});

res/css/views/context_menus/_MessageContextMenu.pcss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,11 @@ limitations under the License.
8181
}
8282

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

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

9191
.mx_MessageContextMenu_iconCopy::before {

src/components/views/context_menus/MessageContextMenu.tsx

Lines changed: 19 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,8 @@ import Modal from "../../../Modal";
3636
import Resend from "../../../Resend";
3737
import SettingsStore from "../../../settings/SettingsStore";
3838
import { isUrlPermitted } from "../../../HtmlUtils";
39-
import { canEditContent, canPinEvent, editEvent, isContentActionable } from "../../../utils/EventUtils";
39+
import { canEditContent, editEvent, isContentActionable } from "../../../utils/EventUtils";
4040
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
41-
import { ReadPinsEventId } from "../right_panel/types";
4241
import { Action } from "../../../dispatcher/actions";
4342
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
4443
import { ButtonEvent } from "../elements/AccessibleButton";
@@ -60,6 +59,7 @@ import { getForwardableEvent } from "../../../events/forward/getForwardableEvent
6059
import { getShareableLocationEvent } from "../../../events/location/getShareableLocationEvent";
6160
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
6261
import { CardContext } from "../right_panel/context";
62+
import PinningUtils from "../../../utils/PinningUtils";
6363

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

180-
let canPin =
181-
!!room?.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli) &&
182-
canPinEvent(this.props.mxEvent);
183-
184-
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
185-
if (!SettingsStore.getValue("feature_pinning")) canPin = false;
180+
const canPin = PinningUtils.canPinOrUnpin(cli, this.props.mxEvent);
186181

187182
this.setState({ canRedact, canPin });
188183
};
189184

190-
private isPinned(): boolean {
191-
const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
192-
const pinnedEvent = room?.currentState.getStateEvents(EventType.RoomPinnedEvents, "");
193-
if (!pinnedEvent) return false;
194-
const content = pinnedEvent.getContent();
195-
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
196-
}
197-
198185
private canEndPoll(mxEvent: MatrixEvent): boolean {
199186
return (
200187
M_POLL_START.matches(mxEvent.getType()) &&
@@ -257,22 +244,8 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
257244
};
258245

259246
private onPinClick = (): void => {
260-
const cli = MatrixClientPeg.safeGet();
261-
const room = cli.getRoom(this.props.mxEvent.getRoomId());
262-
if (!room) return;
263-
const eventId = this.props.mxEvent.getId();
264-
265-
const pinnedIds = room.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || [];
266-
267-
if (pinnedIds.includes(eventId)) {
268-
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
269-
} else {
270-
pinnedIds.push(eventId);
271-
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
272-
event_ids: [...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), eventId],
273-
});
274-
}
275-
cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned: pinnedIds }, "");
247+
// Pin or unpin in background
248+
PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
276249
this.closeMenu();
277250
};
278251

@@ -452,17 +425,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
452425
);
453426
}
454427

455-
let pinButton: JSX.Element | undefined;
456-
if (contentActionable && this.state.canPin) {
457-
pinButton = (
458-
<IconizedContextMenuOption
459-
iconClassName="mx_MessageContextMenu_iconPin"
460-
label={this.isPinned() ? _t("action|unpin") : _t("action|pin")}
461-
onClick={this.onPinClick}
462-
/>
463-
);
464-
}
465-
466428
// This is specifically not behind the developerMode flag to give people insight into the Matrix
467429
const viewSourceButton = (
468430
<IconizedContextMenuOption
@@ -649,6 +611,18 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
649611
);
650612
}
651613

614+
let pinButton: JSX.Element | undefined;
615+
if (rightClick && this.state.canPin) {
616+
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
617+
pinButton = (
618+
<IconizedContextMenuOption
619+
iconClassName={isPinned ? "mx_MessageContextMenu_iconUnpin" : "mx_MessageContextMenu_iconPin"}
620+
label={isPinned ? _t("action|unpin") : _t("action|pin")}
621+
onClick={this.onPinClick}
622+
/>
623+
);
624+
}
625+
652626
let viewInRoomButton: JSX.Element | undefined;
653627
if (isThreadRootEvent) {
654628
viewInRoomButton = (
@@ -671,13 +645,14 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
671645
}
672646

673647
let quickItemsList: JSX.Element | undefined;
674-
if (editButton || replyButton || reactButton) {
648+
if (editButton || replyButton || reactButton || pinButton) {
675649
quickItemsList = (
676650
<IconizedContextMenuOptionList>
677651
{reactButton}
678652
{replyButton}
679653
{replyInThreadButton}
680654
{editButton}
655+
{pinButton}
681656
</IconizedContextMenuOptionList>
682657
);
683658
}
@@ -688,7 +663,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
688663
{openInMapSiteButton}
689664
{endPollButton}
690665
{forwardButton}
691-
{pinButton}
692666
{permalinkButton}
693667
{reportEventButton}
694668
{externalURLButton}

src/components/views/messages/MessageActionBar.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
M_BEACON_INFO,
2727
} from "matrix-js-sdk/src/matrix";
2828
import classNames from "classnames";
29+
import { Icon as PinIcon } from "@vector-im/compound-design-tokens/icons/pin.svg";
30+
import { Icon as UnpinIcon } from "@vector-im/compound-design-tokens/icons/unpin.svg";
2931

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

6568
interface IOptionsButtonProps {
6669
mxEvent: MatrixEvent;
@@ -384,6 +387,17 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
384387
);
385388
};
386389

390+
/**
391+
* Pin or unpin the event.
392+
*/
393+
private onPinClick = async (event: ButtonEvent): Promise<void> => {
394+
// Don't open the regular browser or our context menu on right-click
395+
event.preventDefault();
396+
event.stopPropagation();
397+
398+
await PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
399+
};
400+
387401
public render(): React.ReactNode {
388402
const toolbarOpts: JSX.Element[] = [];
389403
if (canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
@@ -401,6 +415,22 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
401415
);
402416
}
403417

418+
if (PinningUtils.canPinOrUnpin(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
419+
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
420+
toolbarOpts.push(
421+
<RovingAccessibleButton
422+
className="mx_MessageActionBar_iconButton"
423+
title={isPinned ? _t("action|unpin") : _t("action|pin")}
424+
onClick={this.onPinClick}
425+
onContextMenu={this.onPinClick}
426+
key="pin"
427+
placement="left"
428+
>
429+
{isPinned ? <UnpinIcon /> : <PinIcon />}
430+
</RovingAccessibleButton>,
431+
);
432+
}
433+
404434
const cancelSendingButton = (
405435
<RovingAccessibleButton
406436
className="mx_MessageActionBar_iconButton"

src/utils/PinningUtils.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { MatrixEvent, EventType, M_POLL_START } from "matrix-js-sdk/src/matrix";
17+
import { MatrixEvent, EventType, M_POLL_START, MatrixClient, EventTimeline } from "matrix-js-sdk/src/matrix";
18+
19+
import { canPinEvent, isContentActionable } from "./EventUtils";
20+
import SettingsStore from "../settings/SettingsStore";
21+
import { ReadPinsEventId } from "../components/views/right_panel/types";
1822

1923
export default class PinningUtils {
2024
/**
2125
* Event types that may be pinned.
2226
*/
23-
public static pinnableEventTypes: (EventType | string)[] = [
27+
public static readonly PINNABLE_EVENT_TYPES: (EventType | string)[] = [
2428
EventType.RoomMessage,
2529
M_POLL_START.name,
2630
M_POLL_START.altName,
@@ -33,9 +37,80 @@ export default class PinningUtils {
3337
*/
3438
public static isPinnable(event: MatrixEvent): boolean {
3539
if (!event) return false;
36-
if (!this.pinnableEventTypes.includes(event.getType())) return false;
40+
if (!this.PINNABLE_EVENT_TYPES.includes(event.getType())) return false;
3741
if (event.isRedacted()) return false;
3842

3943
return true;
4044
}
45+
46+
/**
47+
* Determines if the given event is pinned.
48+
* @param matrixClient
49+
* @param mxEvent
50+
*/
51+
public static isPinned(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
52+
const room = matrixClient.getRoom(mxEvent.getRoomId());
53+
if (!room) return false;
54+
55+
const pinnedEvent = room
56+
.getLiveTimeline()
57+
.getState(EventTimeline.FORWARDS)
58+
?.getStateEvents(EventType.RoomPinnedEvents, "");
59+
if (!pinnedEvent) return false;
60+
const content = pinnedEvent.getContent();
61+
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(mxEvent.getId());
62+
}
63+
64+
/**
65+
* Determines if the given event may be pinned or unpinned.
66+
* @param matrixClient
67+
* @param mxEvent
68+
*/
69+
public static canPinOrUnpin(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
70+
if (!SettingsStore.getValue("feature_pinning")) return false;
71+
if (!isContentActionable(mxEvent)) return false;
72+
73+
const room = matrixClient.getRoom(mxEvent.getRoomId());
74+
if (!room) return false;
75+
76+
return Boolean(
77+
room
78+
.getLiveTimeline()
79+
.getState(EventTimeline.FORWARDS)
80+
?.mayClientSendStateEvent(EventType.RoomPinnedEvents, matrixClient) && canPinEvent(mxEvent),
81+
);
82+
}
83+
84+
/**
85+
* Pin or unpin the given event.
86+
* @param matrixClient
87+
* @param mxEvent
88+
*/
89+
public static async pinOrUnpinEvent(matrixClient: MatrixClient, mxEvent: MatrixEvent): Promise<void> {
90+
const room = matrixClient.getRoom(mxEvent.getRoomId());
91+
if (!room) return;
92+
93+
const eventId = mxEvent.getId();
94+
if (!eventId) return;
95+
96+
// Get the current pinned events of the room
97+
const pinnedIds: Array<string> =
98+
room
99+
.getLiveTimeline()
100+
.getState(EventTimeline.FORWARDS)
101+
?.getStateEvents(EventType.RoomPinnedEvents, "")
102+
?.getContent().pinned || [];
103+
104+
// If the event is already pinned, unpin it
105+
if (pinnedIds.includes(eventId)) {
106+
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
107+
} else {
108+
// Otherwise, pin it
109+
pinnedIds.push(eventId);
110+
await matrixClient.setRoomAccountData(room.roomId, ReadPinsEventId, {
111+
event_ids: [...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), eventId],
112+
});
113+
}
114+
await matrixClient.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned: pinnedIds }, "");
115+
}
41116
}

0 commit comments

Comments
 (0)