From b51ef246ab93d2811c3f27be5086c8a5b6e042f1 Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 17 Jun 2022 15:27:08 +0200 Subject: [PATCH] Live location share - forward latest location (PSF-1044) (#8860) * handle beacon location events in ForwardDialog * add transformer for forwarded events in MessageContextMenu * remove canForward * update snapshots for beacon model change * add comments * fix bad copy pasted test * add test for beacon locations --- __mocks__/maplibre-gl.js | 19 ++- .../context_menus/MessageContextMenu.tsx | 12 +- .../views/dialogs/ForwardDialog.tsx | 37 +++-- src/events/forward/getForwardableBeacon.ts | 34 +++++ src/events/forward/getForwardableEvent.ts | 36 +++++ src/events/forward/types.ts | 19 +++ src/utils/EventUtils.ts | 8 -- .../__snapshots__/BeaconMarker-test.tsx.snap | 18 ++- .../__snapshots__/BeaconStatus-test.tsx.snap | 4 +- .../context_menus/MessageContextMenu-test.tsx | 131 +++++++++++++++--- .../views/dialogs/ForwardDialog-test.tsx | 28 ++++ test/utils/EventUtils-test.ts | 27 ---- 12 files changed, 292 insertions(+), 81 deletions(-) create mode 100644 src/events/forward/getForwardableBeacon.ts create mode 100644 src/events/forward/getForwardableEvent.ts create mode 100644 src/events/forward/types.ts diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index 599cacde13d..fe6ec9139e6 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -1,5 +1,21 @@ +/* +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. +*/ + const EventEmitter = require("events"); -const { LngLat, NavigationControl, LngLatBounds } = require('maplibre-gl'); +const { LngLat, NavigationControl, LngLatBounds, AttributionControl } = require('maplibre-gl'); class MockMap extends EventEmitter { addControl = jest.fn(); @@ -27,4 +43,5 @@ module.exports = { LngLat, LngLatBounds, NavigationControl, + AttributionControl, }; diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index f54bee8c0ed..836df2d4de8 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -30,7 +30,7 @@ import Modal from '../../../Modal'; import Resend from '../../../Resend'; import SettingsStore from '../../../settings/SettingsStore'; import { isUrlPermitted } from '../../../HtmlUtils'; -import { canEditContent, canForward, editEvent, isContentActionable, isLocationEvent } from '../../../utils/EventUtils'; +import { canEditContent, editEvent, isContentActionable, isLocationEvent } from '../../../utils/EventUtils'; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu'; import { ReadPinsEventId } from "../right_panel/types"; import { Action } from "../../../dispatcher/actions"; @@ -51,6 +51,7 @@ import { GetRelationsForEvent, IEventTileOps } from "../rooms/EventTile"; import { OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload"; import { OpenReportEventDialogPayload } from "../../../dispatcher/payloads/OpenReportEventDialogPayload"; import { createMapSiteLinkFromEvent } from '../../../utils/location'; +import { getForwardableEvent } from '../../../events/forward/getForwardableEvent'; interface IProps extends IPosition { chevronFace: ChevronFace; @@ -188,10 +189,10 @@ export default class MessageContextMenu extends React.Component this.closeMenu(); }; - private onForwardClick = (): void => { + private onForwardClick = (forwardableEvent: MatrixEvent) => (): void => { dis.dispatch({ action: Action.OpenForwardDialog, - event: this.props.mxEvent, + event: forwardableEvent, permalinkCreator: this.props.permalinkCreator, }); this.closeMenu(); @@ -379,12 +380,13 @@ export default class MessageContextMenu extends React.Component } let forwardButton: JSX.Element; - if (contentActionable && canForward(mxEvent)) { + const forwardableEvent = getForwardableEvent(mxEvent, cli); + if (contentActionable && forwardableEvent) { forwardButton = ( ); } diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx index e6de73e635d..c375a23b815 100644 --- a/src/components/views/dialogs/ForwardDialog.tsx +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -23,6 +23,7 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { ILocationContent, LocationAssetType, M_TIMESTAMP } from "matrix-js-sdk/src/@types/location"; import { makeLocationContent } from "matrix-js-sdk/src/content-helpers"; +import { M_BEACON } from "matrix-js-sdk/src/@types/beacon"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; @@ -158,7 +159,7 @@ const Entry: React.FC = ({ room, type, content, matrixClient: cli, ; }; -const getStrippedEventContent = (event: MatrixEvent): IContent => { +const transformEvent = (event: MatrixEvent): {type: string, content: IContent } => { const { // eslint-disable-next-line @typescript-eslint/no-unused-vars "m.relates_to": _, // strip relations - in future we will attach a relation pointing at the original event @@ -166,24 +167,34 @@ const getStrippedEventContent = (event: MatrixEvent): IContent => { ...content } = event.getContent(); + // beacon pulses get transformed into static locations on forward + const type = M_BEACON.matches(event.getType()) ? EventType.RoomMessage : event.getType(); + // self location shares should have their description removed // and become 'pin' share type - if (isLocationEvent(event) && isSelfLocation(content as ILocationContent)) { + if ( + (isLocationEvent(event) && isSelfLocation(content as ILocationContent)) || + // beacon pulses get transformed into static locations on forward + M_BEACON.matches(event.getType()) + ) { const timestamp = M_TIMESTAMP.findIn(content); const geoUri = locationEventGeoUri(event); return { - ...content, - ...makeLocationContent( - undefined, // text - geoUri, - timestamp || Date.now(), - undefined, // description - LocationAssetType.Pin, - ), + type, + content: { + ...content, + ...makeLocationContent( + undefined, // text + geoUri, + timestamp || Date.now(), + undefined, // description + LocationAssetType.Pin, + ), + }, }; } - return content; + return { type, content }; }; const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCreator, onFinished }) => { @@ -193,7 +204,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr cli.getProfileInfo(userId).then(info => setProfileInfo(info)); }, [cli, userId]); - const content = getStrippedEventContent(event); + const { type, content } = transformEvent(event); // For the message preview we fake the sender as ourselves const mockEvent = new MatrixEvent({ @@ -293,7 +304,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr { + const room = cli.getRoom(event.getRoomId()); + const beacon = room.currentState.beacons?.get(getBeaconInfoIdentifier(event)); + const latestLocationEvent = beacon.latestLocationEvent; + + if (beacon.isLive && latestLocationEvent) { + return latestLocationEvent; + } + return null; +}; diff --git a/src/events/forward/getForwardableEvent.ts b/src/events/forward/getForwardableEvent.ts new file mode 100644 index 00000000000..d1d78a469ce --- /dev/null +++ b/src/events/forward/getForwardableEvent.ts @@ -0,0 +1,36 @@ +/* +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 { M_POLL_START } from "matrix-events-sdk"; +import { M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; +import { MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { getForwardableBeaconEvent } from "./getForwardableBeacon"; + +/** + * Get forwardable event for a given event + * If an event is not forwardable return null + */ +export const getForwardableEvent = (event: MatrixEvent, cli: MatrixClient): MatrixEvent | null => { + if (M_POLL_START.matches(event.getType())) { + return null; + } + if (M_BEACON_INFO.matches(event.getType())) { + return getForwardableBeaconEvent(event, cli); + } + return event; +}; + diff --git a/src/events/forward/types.ts b/src/events/forward/types.ts new file mode 100644 index 00000000000..531253c725a --- /dev/null +++ b/src/events/forward/types.ts @@ -0,0 +1,19 @@ +/* +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 { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; + +export type ForwardableEventTransformFunction = (event: MatrixEvent, cli: MatrixClient) => MatrixEvent | null; diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index 7bd4ed84471..7e7d97d5614 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -281,14 +281,6 @@ export const isLocationEvent = (event: MatrixEvent): boolean => { ); }; -export function canForward(event: MatrixEvent): boolean { - return !( - M_POLL_START.matches(event.getType()) || - // disallow forwarding until psf-1044 - M_BEACON_INFO.matches(event.getType()) - ); -} - export function hasThreadSummary(event: MatrixEvent): boolean { return event.isThreadRoot && event.getThread()?.length && !!event.getThread().replyToEvent; } diff --git a/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap index b82f98dc4e5..6a763ddc53c 100644 --- a/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap @@ -26,10 +26,20 @@ exports[` renders marker when beacon has location 1`] = ` }, "_eventsCount": 5, "_isLive": true, - "_latestLocationState": Object { - "description": undefined, - "timestamp": 1647270879404, - "uri": "geo:51,41", + "_latestLocationEvent": Object { + "content": Object { + "m.relates_to": Object { + "event_id": "$alice-room1-1", + "rel_type": "m.reference", + }, + "org.matrix.msc3488.location": Object { + "description": undefined, + "uri": "geo:51,41", + }, + "org.matrix.msc3488.ts": 1647270879404, + }, + "sender": "@alice:server", + "type": "org.matrix.msc3672.beacon", }, "_maxListeners": undefined, "clearLatestLocation": [Function], diff --git a/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap index 4e565b3fb9b..0f2db924ecf 100644 --- a/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap @@ -16,7 +16,7 @@ exports[` active state renders without children 1`] = ` "_events": Object {}, "_eventsCount": 0, "_isLive": undefined, - "_latestLocationState": undefined, + "_latestLocationEvent": undefined, "_maxListeners": undefined, "clearLatestLocation": [Function], "livenessWatchTimeout": undefined, @@ -78,7 +78,7 @@ exports[` active state renders without children 1`] = ` "_events": Object {}, "_eventsCount": 0, "_isLive": undefined, - "_latestLocationState": undefined, + "_latestLocationEvent": undefined, "_maxListeners": undefined, "clearLatestLocation": [Function], "livenessWatchTimeout": undefined, diff --git a/test/components/views/context_menus/MessageContextMenu-test.tsx b/test/components/views/context_menus/MessageContextMenu-test.tsx index e449fad52a4..2f50a5ef6de 100644 --- a/test/components/views/context_menus/MessageContextMenu-test.tsx +++ b/test/components/views/context_menus/MessageContextMenu-test.tsx @@ -18,18 +18,26 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { Room } from 'matrix-js-sdk/src/models/room'; -import { PendingEventOrdering } from 'matrix-js-sdk/src/matrix'; +import { + PendingEventOrdering, + BeaconIdentifier, + Beacon, + getBeaconInfoIdentifier, +} from 'matrix-js-sdk/src/matrix'; import { ExtensibleEvent, MessageEvent, M_POLL_KIND_DISCLOSED, PollStartEvent } from 'matrix-events-sdk'; import { Thread } from "matrix-js-sdk/src/models/thread"; import { mocked } from "jest-mock"; +import { act } from '@testing-library/react'; import * as TestUtils from '../../../test-utils'; import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; import { IRoomState } from "../../../../src/components/structures/RoomView"; -import { canEditContent, canForward, isContentActionable } from "../../../../src/utils/EventUtils"; +import { canEditContent, isContentActionable } from "../../../../src/utils/EventUtils"; import { copyPlaintext, getSelectedText } from "../../../../src/utils/strings"; import MessageContextMenu from "../../../../src/components/views/context_menus/MessageContextMenu"; +import { makeBeaconEvent, makeBeaconInfoEvent } from '../../../test-utils'; +import dispatcher from '../../../../src/dispatcher/dispatcher'; jest.mock("../../../../src/utils/strings", () => ({ copyPlaintext: jest.fn(), @@ -37,33 +45,17 @@ jest.mock("../../../../src/utils/strings", () => ({ })); jest.mock("../../../../src/utils/EventUtils", () => ({ canEditContent: jest.fn(), - canForward: jest.fn(), isContentActionable: jest.fn(), isLocationEvent: jest.fn(), })); +const roomId = 'roomid'; + describe('MessageContextMenu', () => { beforeEach(() => { jest.resetAllMocks(); }); - it('allows forwarding a room message', () => { - mocked(canForward).mockReturnValue(true); - mocked(isContentActionable).mockReturnValue(true); - - const eventContent = MessageEvent.from("hello"); - const menu = createMenuWithContent(eventContent); - expect(menu.find('div[aria-label="Forward"]')).toHaveLength(1); - }); - - it('does not allow forwarding a poll', () => { - mocked(canForward).mockReturnValue(false); - - const eventContent = PollStartEvent.from("why?", ["42"], M_POLL_KIND_DISCLOSED); - const menu = createMenuWithContent(eventContent); - expect(menu.find('div[aria-label="Forward"]')).toHaveLength(0); - }); - it('does show copy link button when supplied a link', () => { const eventContent = MessageEvent.from("hello"); const props = { @@ -82,6 +74,99 @@ describe('MessageContextMenu', () => { expect(copyLinkButton).toHaveLength(0); }); + describe('message forwarding', () => { + it('allows forwarding a room message', () => { + mocked(isContentActionable).mockReturnValue(true); + + const eventContent = MessageEvent.from("hello"); + const menu = createMenuWithContent(eventContent); + expect(menu.find('div[aria-label="Forward"]')).toHaveLength(1); + }); + + it('does not allow forwarding a poll', () => { + const eventContent = PollStartEvent.from("why?", ["42"], M_POLL_KIND_DISCLOSED); + const menu = createMenuWithContent(eventContent); + expect(menu.find('div[aria-label="Forward"]')).toHaveLength(0); + }); + + describe('forwarding beacons', () => { + const aliceId = "@alice:server.org"; + beforeEach(() => { + mocked(isContentActionable).mockReturnValue(true); + }); + + it('does not allow forwarding a beacon that is not live', () => { + const deadBeaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: false }); + const beacon = new Beacon(deadBeaconEvent); + const beacons = new Map(); + beacons.set(getBeaconInfoIdentifier(deadBeaconEvent), beacon); + const menu = createMenu(deadBeaconEvent, {}, {}, beacons); + expect(menu.find('div[aria-label="Forward"]')).toHaveLength(0); + }); + + it('does not allow forwarding a beacon that is not live but has a latestLocation', () => { + const deadBeaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: false }); + const beaconLocation = makeBeaconEvent( + aliceId, { beaconInfoId: deadBeaconEvent.getId(), geoUri: 'geo:51,41' }, + ); + const beacon = new Beacon(deadBeaconEvent); + // @ts-ignore illegally set private prop + beacon._latestLocationEvent = beaconLocation; + const beacons = new Map(); + beacons.set(getBeaconInfoIdentifier(deadBeaconEvent), beacon); + const menu = createMenu(deadBeaconEvent, {}, {}, beacons); + expect(menu.find('div[aria-label="Forward"]')).toHaveLength(0); + }); + + it('does not allow forwarding a live beacon that does not have a latestLocation', () => { + const beaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: true }); + + const beacon = new Beacon(beaconEvent); + const beacons = new Map(); + beacons.set(getBeaconInfoIdentifier(beaconEvent), beacon); + const menu = createMenu(beaconEvent, {}, {}, beacons); + expect(menu.find('div[aria-label="Forward"]')).toHaveLength(0); + }); + + it('allows forwarding a live beacon that has a location', () => { + const liveBeaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: true }); + const beaconLocation = makeBeaconEvent( + aliceId, { beaconInfoId: liveBeaconEvent.getId(), geoUri: 'geo:51,41' }, + ); + const beacon = new Beacon(liveBeaconEvent); + // @ts-ignore illegally set private prop + beacon._latestLocationEvent = beaconLocation; + const beacons = new Map(); + beacons.set(getBeaconInfoIdentifier(liveBeaconEvent), beacon); + const menu = createMenu(liveBeaconEvent, {}, {}, beacons); + expect(menu.find('div[aria-label="Forward"]')).toHaveLength(1); + }); + + it('opens forward dialog with correct event', () => { + const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); + const liveBeaconEvent = makeBeaconInfoEvent(aliceId, roomId, { isLive: true }); + const beaconLocation = makeBeaconEvent( + aliceId, { beaconInfoId: liveBeaconEvent.getId(), geoUri: 'geo:51,41' }, + ); + const beacon = new Beacon(liveBeaconEvent); + // @ts-ignore illegally set private prop + beacon._latestLocationEvent = beaconLocation; + const beacons = new Map(); + beacons.set(getBeaconInfoIdentifier(liveBeaconEvent), beacon); + const menu = createMenu(liveBeaconEvent, {}, {}, beacons); + + act(() => { + menu.find('div[aria-label="Forward"]').simulate('click'); + }); + + // called with forwardableEvent, not beaconInfo event + expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ + event: beaconLocation, + })); + }); + }); + }); + describe("right click", () => { it('copy button does work as expected', () => { const text = "hello"; @@ -215,12 +300,13 @@ function createMenu( mxEvent: MatrixEvent, props?: Partial>, context: Partial = {}, + beacons: Map = new Map(), ): ReactWrapper { TestUtils.stubClient(); const client = MatrixClientPeg.get(); const room = new Room( - "roomid", + roomId, client, "@user:example.com", { @@ -228,6 +314,9 @@ function createMenu( }, ); + // @ts-ignore illegally set private prop + room.currentState.beacons = beacons; + mxEvent.setStatus(EventStatus.SENT); client.getUserId = jest.fn().mockReturnValue("@user:example.com"); diff --git a/test/components/views/dialogs/ForwardDialog-test.tsx b/test/components/views/dialogs/ForwardDialog-test.tsx index aedd1cfc16f..fbb7f0cd848 100644 --- a/test/components/views/dialogs/ForwardDialog-test.tsx +++ b/test/components/views/dialogs/ForwardDialog-test.tsx @@ -28,6 +28,7 @@ import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { getMockClientWithEventEmitter, + makeBeaconEvent, makeLegacyLocationEvent, makeLocationEvent, mkEvent, @@ -285,6 +286,33 @@ describe("ForwardDialog", () => { ); }); + it('forwards beacon location as a pin drop event', async () => { + const timestamp = 123456; + const beaconEvent = makeBeaconEvent('@alice:server.org', { geoUri, timestamp }); + const text = `Location ${geoUri} at ${new Date(timestamp).toISOString()}`; + const expectedContent = { + msgtype: "m.location", + body: text, + [TEXT_NODE_TYPE.name]: text, + [M_ASSET.name]: { type: LocationAssetType.Pin }, + [M_LOCATION.name]: { + uri: geoUri, + description: undefined, + }, + geo_uri: geoUri, + [M_TIMESTAMP.name]: timestamp, + }; + const wrapper = await mountForwardDialog(beaconEvent); + + expect(wrapper.find('MLocationBody').length).toBeTruthy(); + + sendToFirstRoom(wrapper); + + expect(mockClient.sendEvent).toHaveBeenCalledWith( + roomId, EventType.RoomMessage, expectedContent, + ); + }); + it('forwards pin drop event', async () => { const wrapper = await mountForwardDialog(pinDropLocationEvent); diff --git a/test/utils/EventUtils-test.ts b/test/utils/EventUtils-test.ts index 49bc26b4efc..120d47aa1d4 100644 --- a/test/utils/EventUtils-test.ts +++ b/test/utils/EventUtils-test.ts @@ -28,7 +28,6 @@ import { canCancel, canEditContent, canEditOwnEvent, - canForward, isContentActionable, isLocationEvent, isVoiceMessage, @@ -319,32 +318,6 @@ describe('EventUtils', () => { }); }); - describe('canForward()', () => { - it('returns true for a location event', () => { - const event = new MatrixEvent({ - type: M_LOCATION.name, - }); - expect(canForward(event)).toBe(true); - }); - it('returns false for a poll event', () => { - const event = makePollStartEvent('Who?', userId); - expect(canForward(event)).toBe(false); - }); - it('returns false for a beacon_info event', () => { - const event = makeBeaconInfoEvent(userId, roomId); - expect(canForward(event)).toBe(false); - }); - it('returns true for a room message event', () => { - const event = new MatrixEvent({ - type: EventType.RoomMessage, - content: { - body: 'Hello', - }, - }); - expect(canForward(event)).toBe(true); - }); - }); - describe('canCancel()', () => { it.each([ [EventStatus.QUEUED],