diff --git a/res/css/components/views/messages/_MBeaconBody.scss b/res/css/components/views/messages/_MBeaconBody.scss new file mode 100644 index 0000000000..067359a4df --- /dev/null +++ b/res/css/components/views/messages/_MBeaconBody.scss @@ -0,0 +1,54 @@ +/* +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. +*/ + +.mx_MBeaconBody { + position: relative; + height: 220px; + width: 325px; + + border-radius: $timeline-image-border-radius; + overflow: hidden; +} + +.mx_MBeaconBody_map { + height: 100%; + width: 100%; + z-index: 0; // keeps the entire map under the message action bar +} + +.mx_MBeaconBody_mapFallback { + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + + // pushes spinner/icon up + // to appear more centered with the footer + padding-bottom: 50px; + + background: url('$(res)/img/location/map.svg'); + background-size: cover; +} + +.mx_MBeaconBody_mapFallbackIcon { + width: 65px; + color: $quaternary-content; +} + +.mx_EventTile[data-layout="bubble"] .mx_EventTile_line .mx_MBeaconBody { + max-width: 100%; + width: 450px; +} diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index a46e91a4ae..cf257c706c 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -130,7 +130,8 @@ limitations under the License. .mx_MImageBody::before, .mx_MVideoBody .mx_MVideoBody_container, .mx_MediaBody, - .mx_MLocationBody_map { + .mx_MLocationBody_map, + .mx_MBeaconBody { border-bottom-right-radius: var(--cornerRadius) !important; } } @@ -155,7 +156,8 @@ limitations under the License. .mx_MImageBody::before, .mx_MVideoBody .mx_MVideoBody_container, .mx_MediaBody, - .mx_MLocationBody_map { + .mx_MLocationBody_map, + .mx_MBeaconBody { border-bottom-left-radius: var(--cornerRadius) !important; } } @@ -300,7 +302,8 @@ limitations under the License. .mx_MVideoBody .mx_MVideoBody_container, .mx_MImageBody::before, .mx_MediaBody, - .mx_MLocationBody_map { + .mx_MLocationBody_map, + .mx_MBeaconBody { border-top-left-radius: 0; } } @@ -311,7 +314,8 @@ limitations under the License. .mx_MVideoBody .mx_MVideoBody_container, .mx_MImageBody::before, .mx_MediaBody, - .mx_MLocationBody_map { + .mx_MLocationBody_map, + .mx_MBeaconBody { border-bottom-left-radius: var(--cornerRadius); } } @@ -323,7 +327,8 @@ limitations under the License. .mx_MVideoBody .mx_MVideoBody_container, .mx_MImageBody::before, .mx_MediaBody, - .mx_MLocationBody_map { + .mx_MLocationBody_map, + .mx_MBeaconBody { border-top-right-radius: 0; } } @@ -334,7 +339,8 @@ limitations under the License. .mx_MVideoBody .mx_MVideoBody_container, .mx_MImageBody::before, .mx_MediaBody, - .mx_MLocationBody_map { + .mx_MLocationBody_map, + .mx_MBeaconBody { border-bottom-right-radius: var(--cornerRadius); } } diff --git a/res/img/location/map.svg b/res/img/location/map.svg new file mode 100644 index 0000000000..67be3a35ad --- /dev/null +++ b/res/img/location/map.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/views/beacon/displayStatus.ts b/src/components/views/beacon/displayStatus.ts new file mode 100644 index 0000000000..ee65991070 --- /dev/null +++ b/src/components/views/beacon/displayStatus.ts @@ -0,0 +1,42 @@ +/* +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 { BeaconLocationState } from "matrix-js-sdk/src/content-helpers"; + +export enum BeaconDisplayStatus { + Loading = 'Loading', + Error = 'Error', + Stopped = 'Stopped', + Active = 'Active', +} +export const getBeaconDisplayStatus = ( + isLive: boolean, + latestLocationState?: BeaconLocationState, + error?: Error): BeaconDisplayStatus => { + if (error) { + return BeaconDisplayStatus.Error; + } + if (!isLive) { + return BeaconDisplayStatus.Stopped; + } + + if (!latestLocationState) { + return BeaconDisplayStatus.Loading; + } + if (latestLocationState) { + return BeaconDisplayStatus.Active; + } +}; diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx index bbd7fb446f..9bbb627879 100644 --- a/src/components/views/messages/MBeaconBody.tsx +++ b/src/components/views/messages/MBeaconBody.tsx @@ -14,16 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import { BeaconEvent, MatrixEvent } from 'matrix-js-sdk/src/matrix'; +import React, { useEffect, useState } from 'react'; +import { Beacon, BeaconEvent, MatrixEvent } from 'matrix-js-sdk/src/matrix'; import { BeaconLocationState } from 'matrix-js-sdk/src/content-helpers'; +import { randomString } from 'matrix-js-sdk/src/randomstring'; -import { IBodyProps } from "./IBodyProps"; +import { Icon as LocationMarkerIcon } from '../../../../res/img/element-icons/location.svg'; import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { useBeacon } from '../../../utils/beacon'; +import { isSelfLocation } from '../../../utils/location'; +import { BeaconDisplayStatus, getBeaconDisplayStatus } from '../beacon/displayStatus'; +import Spinner from '../elements/Spinner'; +import Map from '../location/Map'; +import SmartMarker from '../location/SmartMarker'; +import { IBodyProps } from "./IBodyProps"; const useBeaconState = (beaconInfoEvent: MatrixEvent): { - hasBeacon: boolean; + beacon?: Beacon; description?: string; latestLocationState?: BeaconLocationState; isLive?: boolean; @@ -41,42 +48,71 @@ const useBeaconState = (beaconInfoEvent: MatrixEvent): { () => beacon?.latestLocationState); if (!beacon) { - return { - hasBeacon: false, - }; + return {}; } const { description } = beacon.beaconInfo; return { - hasBeacon: true, + beacon, description, isLive, latestLocationState, }; }; -const MBeaconBody: React.FC = React.forwardRef(({ mxEvent, ...rest }, ref) => { +// multiple instances of same map might be in document +// eg thread and main timeline, reply +// maplibregl needs a unique id to attach the map instance to +const useUniqueId = (eventId: string): string => { + const [id, setId] = useState(`${eventId}_${randomString(8)}`); + + useEffect(() => { + setId(`${eventId}_${randomString(8)}`); + }, [eventId]); + + return id; +}; + +const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) => { const { - hasBeacon, isLive, - description, latestLocationState, } = useBeaconState(mxEvent); + const mapId = useUniqueId(mxEvent.getId()); - if (!hasBeacon || !isLive) { - // TODO stopped, error states - return Beacon stopped or replaced; - } + const [error, setError] = useState(); + + const displayStatus = getBeaconDisplayStatus(isLive, latestLocationState, error); + + const markerRoomMember = isSelfLocation(mxEvent.getContent()) ? mxEvent.sender : undefined; return ( - // TODO nice map
- { mxEvent.getId() }  - Beacon "{ description }" - { latestLocationState ? - { `${latestLocationState.uri} at ${latestLocationState.timestamp}` } : - Waiting for location } + { displayStatus === BeaconDisplayStatus.Active ? + + { + ({ map }) => + + } + + :
+ { displayStatus === BeaconDisplayStatus.Loading ? + : + + } +
+ }
); }); diff --git a/src/utils/EventRenderingUtils.ts b/src/utils/EventRenderingUtils.ts index c6c7acc991..fd6e518c4f 100644 --- a/src/utils/EventRenderingUtils.ts +++ b/src/utils/EventRenderingUtils.ts @@ -81,6 +81,7 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent, hideEvent?: boolean): (eventType === EventType.RoomMessage && msgtype === MsgType.Emote) || M_POLL_START.matches(eventType) || M_LOCATION.matches(eventType) || + M_BEACON_INFO.matches(eventType) || ( eventType === EventType.RoomMessage && M_LOCATION.matches(msgtype) diff --git a/test/components/views/messages/MBeaconBody-test.tsx b/test/components/views/messages/MBeaconBody-test.tsx index dc8ec31903..103f003d82 100644 --- a/test/components/views/messages/MBeaconBody-test.tsx +++ b/test/components/views/messages/MBeaconBody-test.tsx @@ -17,6 +17,7 @@ limitations under the License. import React from 'react'; import { mount } from 'enzyme'; import { act } from 'react-dom/test-utils'; +import maplibregl from 'maplibre-gl'; import { BeaconEvent, Room, @@ -24,7 +25,7 @@ import { } from 'matrix-js-sdk/src/matrix'; import MBeaconBody from '../../../../src/components/views/messages/MBeaconBody'; -import { findByTestId, getMockClientWithEventEmitter, makeBeaconEvent, makeBeaconInfoEvent } from '../../../test-utils'; +import { getMockClientWithEventEmitter, makeBeaconEvent, makeBeaconInfoEvent } from '../../../test-utils'; import { RoomPermalinkCreator } from '../../../../src/utils/permalinks/Permalinks'; import { MediaEventHelper } from '../../../../src/utils/MediaEventHelper'; import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; @@ -37,7 +38,13 @@ describe('', () => { const roomId = '!room:server'; const aliceId = '@alice:server'; + const mockMap = new maplibregl.Map(); + const mockMarker = new maplibregl.Marker(); + const mockClient = getMockClientWithEventEmitter({ + getClientWellKnown: jest.fn().mockReturnValue({ + "m.tile_server": { map_style_url: 'maps.com' }, + }), getUserId: jest.fn().mockReturnValue(aliceId), getRoom: jest.fn(), }); @@ -58,6 +65,7 @@ describe('', () => { { isLive: true }, '$alice-room1-1', ); + const defaultProps = { mxEvent: defaultEvent, highlights: [], @@ -68,21 +76,15 @@ describe('', () => { permalinkCreator: {} as unknown as RoomPermalinkCreator, mediaEventHelper: {} as unknown as MediaEventHelper, }; + const getComponent = (props = {}) => mount(, { wrappingComponent: MatrixClientContext.Provider, wrappingComponentProps: { value: mockClient }, }); - it('renders a live beacon with basic stub', () => { - const beaconInfoEvent = makeBeaconInfoEvent(aliceId, - roomId, - { isLive: true }, - '$alice-room1-1', - ); - makeRoomWithStateEvents([beaconInfoEvent]); - const component = getComponent({ mxEvent: beaconInfoEvent }); - expect(component).toMatchSnapshot(); + beforeEach(() => { + jest.clearAllMocks(); }); it('renders stopped beacon UI for an explicitly stopped beacon', () => { @@ -93,7 +95,7 @@ describe('', () => { ); makeRoomWithStateEvents([beaconInfoEvent]); const component = getComponent({ mxEvent: beaconInfoEvent }); - expect(component.text()).toEqual("Beacon stopped or replaced"); + expect(component.find('Map').length).toBeFalsy(); }); it('renders stopped beacon UI for an expired beacon', () => { @@ -105,7 +107,7 @@ describe('', () => { ); makeRoomWithStateEvents([beaconInfoEvent]); const component = getComponent({ mxEvent: beaconInfoEvent }); - expect(component.text()).toEqual("Beacon stopped or replaced"); + expect(component.find('Map').length).toBeFalsy(); }); it('renders stopped UI when a beacon event is not the latest beacon for a user', () => { @@ -128,7 +130,7 @@ describe('', () => { const component = getComponent({ mxEvent: aliceBeaconInfo1 }); // beacon1 has been superceded by beacon2 - expect(component.text()).toEqual("Beacon stopped or replaced"); + expect(component.find('Map').length).toBeFalsy(); }); it('renders stopped UI when a beacon event is replaced', () => { @@ -160,7 +162,7 @@ describe('', () => { component.setProps({}); // beacon1 has been superceded by beacon2 - expect(component.text()).toEqual("Beacon stopped or replaced"); + expect(component.find('Map').length).toBeFalsy(); }); describe('on liveness change', () => { @@ -173,9 +175,9 @@ describe('', () => { ); const room = makeRoomWithStateEvents([aliceBeaconInfo]); + const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo)); const component = getComponent({ mxEvent: aliceBeaconInfo }); - const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo)); act(() => { // @ts-ignore cheat to force beacon to not live beaconInstance._isLive = false; @@ -185,7 +187,7 @@ describe('', () => { component.setProps({}); // stopped UI - expect(component.text()).toEqual("Beacon stopped or replaced"); + expect(component.find('Map').length).toBeFalsy(); }); }); @@ -198,18 +200,17 @@ describe('', () => { ); const location1 = makeBeaconEvent( - aliceId, { beaconInfoId: aliceBeaconInfo.getId(), geoUri: 'geo:foo', timestamp: now + 1 }, + aliceId, { beaconInfoId: aliceBeaconInfo.getId(), geoUri: 'geo:51,41', timestamp: now + 1 }, ); const location2 = makeBeaconEvent( - aliceId, { beaconInfoId: aliceBeaconInfo.getId(), geoUri: 'geo:bar', timestamp: now + 10000 }, + aliceId, { beaconInfoId: aliceBeaconInfo.getId(), geoUri: 'geo:52,42', timestamp: now + 10000 }, ); it('renders a live beacon without a location correctly', () => { makeRoomWithStateEvents([aliceBeaconInfo]); const component = getComponent({ mxEvent: aliceBeaconInfo }); - // loading map - expect(findByTestId(component, 'beacon-waiting-for-location').length).toBeTruthy(); + expect(component.find('Spinner').length).toBeTruthy(); }); it('updates latest location', () => { @@ -222,14 +223,16 @@ describe('', () => { component.setProps({}); }); - expect(component.text().includes('geo:foo')).toBeTruthy(); + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 41 }); + expect(mockMarker.setLngLat).toHaveBeenCalledWith({ lat: 51, lon: 41 }); act(() => { beaconInstance.addLocations([location2]); component.setProps({}); }); - expect(component.text().includes('geo:bar')).toBeTruthy(); + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 52, lon: 42 }); + expect(mockMarker.setLngLat).toHaveBeenCalledWith({ lat: 52, lon: 42 }); }); }); }); diff --git a/test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap deleted file mode 100644 index 81850608f6..0000000000 --- a/test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap +++ /dev/null @@ -1,49 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders a live beacon with basic stub 1`] = ` - -
- - $alice-room1-1 - -   - - Beacon " - " - - - Waiting for location - -
-
-`;