Live location sharing - beacon map in timeline (#8286)

* add displaystatus util

Signed-off-by: Kerry Archibald <kerrya@element.io>

* map fallback svg

Signed-off-by: Kerry Archibald <kerrya@element.io>

* add Map to mbeaconbody

Signed-off-by: Kerry Archibald <kerrya@element.io>

* add bubble layout handling

Signed-off-by: Kerry Archibald <kerrya@element.io>

* test beaconbody

Signed-off-by: Kerry Archibald <kerrya@element.io>

* typo

Signed-off-by: Kerry Archibald <kerrya@element.io>

* use randomString from js-sdk

Signed-off-by: Kerry Archibald <kerrya@element.io>
This commit is contained in:
Kerry 2022-04-12 10:13:55 +02:00 committed by GitHub
parent 4b7840bf78
commit 661e2c2aa5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 200 additions and 98 deletions

View file

@ -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;
}

View file

@ -130,7 +130,8 @@ limitations under the License.
.mx_MImageBody::before, .mx_MImageBody::before,
.mx_MVideoBody .mx_MVideoBody_container, .mx_MVideoBody .mx_MVideoBody_container,
.mx_MediaBody, .mx_MediaBody,
.mx_MLocationBody_map { .mx_MLocationBody_map,
.mx_MBeaconBody {
border-bottom-right-radius: var(--cornerRadius) !important; border-bottom-right-radius: var(--cornerRadius) !important;
} }
} }
@ -155,7 +156,8 @@ limitations under the License.
.mx_MImageBody::before, .mx_MImageBody::before,
.mx_MVideoBody .mx_MVideoBody_container, .mx_MVideoBody .mx_MVideoBody_container,
.mx_MediaBody, .mx_MediaBody,
.mx_MLocationBody_map { .mx_MLocationBody_map,
.mx_MBeaconBody {
border-bottom-left-radius: var(--cornerRadius) !important; border-bottom-left-radius: var(--cornerRadius) !important;
} }
} }
@ -300,7 +302,8 @@ limitations under the License.
.mx_MVideoBody .mx_MVideoBody_container, .mx_MVideoBody .mx_MVideoBody_container,
.mx_MImageBody::before, .mx_MImageBody::before,
.mx_MediaBody, .mx_MediaBody,
.mx_MLocationBody_map { .mx_MLocationBody_map,
.mx_MBeaconBody {
border-top-left-radius: 0; border-top-left-radius: 0;
} }
} }
@ -311,7 +314,8 @@ limitations under the License.
.mx_MVideoBody .mx_MVideoBody_container, .mx_MVideoBody .mx_MVideoBody_container,
.mx_MImageBody::before, .mx_MImageBody::before,
.mx_MediaBody, .mx_MediaBody,
.mx_MLocationBody_map { .mx_MLocationBody_map,
.mx_MBeaconBody {
border-bottom-left-radius: var(--cornerRadius); border-bottom-left-radius: var(--cornerRadius);
} }
} }
@ -323,7 +327,8 @@ limitations under the License.
.mx_MVideoBody .mx_MVideoBody_container, .mx_MVideoBody .mx_MVideoBody_container,
.mx_MImageBody::before, .mx_MImageBody::before,
.mx_MediaBody, .mx_MediaBody,
.mx_MLocationBody_map { .mx_MLocationBody_map,
.mx_MBeaconBody {
border-top-right-radius: 0; border-top-right-radius: 0;
} }
} }
@ -334,7 +339,8 @@ limitations under the License.
.mx_MVideoBody .mx_MVideoBody_container, .mx_MVideoBody .mx_MVideoBody_container,
.mx_MImageBody::before, .mx_MImageBody::before,
.mx_MediaBody, .mx_MediaBody,
.mx_MLocationBody_map { .mx_MLocationBody_map,
.mx_MBeaconBody {
border-bottom-right-radius: var(--cornerRadius); border-bottom-right-radius: var(--cornerRadius);
} }
} }

9
res/img/location/map.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -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;
}
};

View file

@ -14,16 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { useEffect, useState } from 'react';
import { BeaconEvent, MatrixEvent } from 'matrix-js-sdk/src/matrix'; import { Beacon, BeaconEvent, MatrixEvent } from 'matrix-js-sdk/src/matrix';
import { BeaconLocationState } from 'matrix-js-sdk/src/content-helpers'; 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 { useEventEmitterState } from '../../../hooks/useEventEmitter';
import { useBeacon } from '../../../utils/beacon'; 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): { const useBeaconState = (beaconInfoEvent: MatrixEvent): {
hasBeacon: boolean; beacon?: Beacon;
description?: string; description?: string;
latestLocationState?: BeaconLocationState; latestLocationState?: BeaconLocationState;
isLive?: boolean; isLive?: boolean;
@ -41,42 +48,71 @@ const useBeaconState = (beaconInfoEvent: MatrixEvent): {
() => beacon?.latestLocationState); () => beacon?.latestLocationState);
if (!beacon) { if (!beacon) {
return { return {};
hasBeacon: false,
};
} }
const { description } = beacon.beaconInfo; const { description } = beacon.beaconInfo;
return { return {
hasBeacon: true, beacon,
description, description,
isLive, isLive,
latestLocationState, latestLocationState,
}; };
}; };
const MBeaconBody: React.FC<IBodyProps> = 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<IBodyProps> = React.forwardRef(({ mxEvent }, ref) => {
const { const {
hasBeacon,
isLive, isLive,
description,
latestLocationState, latestLocationState,
} = useBeaconState(mxEvent); } = useBeaconState(mxEvent);
const mapId = useUniqueId(mxEvent.getId());
if (!hasBeacon || !isLive) { const [error, setError] = useState<Error>();
// TODO stopped, error states
return <span ref={ref}>Beacon stopped or replaced</span>; const displayStatus = getBeaconDisplayStatus(isLive, latestLocationState, error);
}
const markerRoomMember = isSelfLocation(mxEvent.getContent()) ? mxEvent.sender : undefined;
return ( return (
// TODO nice map
<div className='mx_MBeaconBody' ref={ref}> <div className='mx_MBeaconBody' ref={ref}>
<code>{ mxEvent.getId() }</code>&nbsp; { displayStatus === BeaconDisplayStatus.Active ?
<span>Beacon "{ description }" </span> <Map
{ latestLocationState ? id={mapId}
<span>{ `${latestLocationState.uri} at ${latestLocationState.timestamp}` }</span> : centerGeoUri={latestLocationState.uri}
<span data-test-id='beacon-waiting-for-location'>Waiting for location</span> } onError={setError}
className="mx_MBeaconBody_map"
>
{
({ map }) =>
<SmartMarker
map={map}
id={`${mapId}-marker`}
geoUri={latestLocationState.uri}
roomMember={markerRoomMember}
/>
}
</Map>
: <div className='mx_MBeaconBody_map mx_MBeaconBody_mapFallback'>
{ displayStatus === BeaconDisplayStatus.Loading ?
<Spinner h={32} w={32} /> :
<LocationMarkerIcon className='mx_MBeaconBody_mapFallbackIcon' />
}
</div>
}
</div> </div>
); );
}); });

View file

@ -81,6 +81,7 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent, hideEvent?: boolean):
(eventType === EventType.RoomMessage && msgtype === MsgType.Emote) || (eventType === EventType.RoomMessage && msgtype === MsgType.Emote) ||
M_POLL_START.matches(eventType) || M_POLL_START.matches(eventType) ||
M_LOCATION.matches(eventType) || M_LOCATION.matches(eventType) ||
M_BEACON_INFO.matches(eventType) ||
( (
eventType === EventType.RoomMessage && eventType === EventType.RoomMessage &&
M_LOCATION.matches(msgtype) M_LOCATION.matches(msgtype)

View file

@ -17,6 +17,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import maplibregl from 'maplibre-gl';
import { import {
BeaconEvent, BeaconEvent,
Room, Room,
@ -24,7 +25,7 @@ import {
} from 'matrix-js-sdk/src/matrix'; } from 'matrix-js-sdk/src/matrix';
import MBeaconBody from '../../../../src/components/views/messages/MBeaconBody'; 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 { RoomPermalinkCreator } from '../../../../src/utils/permalinks/Permalinks';
import { MediaEventHelper } from '../../../../src/utils/MediaEventHelper'; import { MediaEventHelper } from '../../../../src/utils/MediaEventHelper';
import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; import MatrixClientContext from '../../../../src/contexts/MatrixClientContext';
@ -37,7 +38,13 @@ describe('<MBeaconBody />', () => {
const roomId = '!room:server'; const roomId = '!room:server';
const aliceId = '@alice:server'; const aliceId = '@alice:server';
const mockMap = new maplibregl.Map();
const mockMarker = new maplibregl.Marker();
const mockClient = getMockClientWithEventEmitter({ const mockClient = getMockClientWithEventEmitter({
getClientWellKnown: jest.fn().mockReturnValue({
"m.tile_server": { map_style_url: 'maps.com' },
}),
getUserId: jest.fn().mockReturnValue(aliceId), getUserId: jest.fn().mockReturnValue(aliceId),
getRoom: jest.fn(), getRoom: jest.fn(),
}); });
@ -58,6 +65,7 @@ describe('<MBeaconBody />', () => {
{ isLive: true }, { isLive: true },
'$alice-room1-1', '$alice-room1-1',
); );
const defaultProps = { const defaultProps = {
mxEvent: defaultEvent, mxEvent: defaultEvent,
highlights: [], highlights: [],
@ -68,21 +76,15 @@ describe('<MBeaconBody />', () => {
permalinkCreator: {} as unknown as RoomPermalinkCreator, permalinkCreator: {} as unknown as RoomPermalinkCreator,
mediaEventHelper: {} as unknown as MediaEventHelper, mediaEventHelper: {} as unknown as MediaEventHelper,
}; };
const getComponent = (props = {}) => const getComponent = (props = {}) =>
mount(<MBeaconBody {...defaultProps} {...props} />, { mount(<MBeaconBody {...defaultProps} {...props} />, {
wrappingComponent: MatrixClientContext.Provider, wrappingComponent: MatrixClientContext.Provider,
wrappingComponentProps: { value: mockClient }, wrappingComponentProps: { value: mockClient },
}); });
it('renders a live beacon with basic stub', () => { beforeEach(() => {
const beaconInfoEvent = makeBeaconInfoEvent(aliceId, jest.clearAllMocks();
roomId,
{ isLive: true },
'$alice-room1-1',
);
makeRoomWithStateEvents([beaconInfoEvent]);
const component = getComponent({ mxEvent: beaconInfoEvent });
expect(component).toMatchSnapshot();
}); });
it('renders stopped beacon UI for an explicitly stopped beacon', () => { it('renders stopped beacon UI for an explicitly stopped beacon', () => {
@ -93,7 +95,7 @@ describe('<MBeaconBody />', () => {
); );
makeRoomWithStateEvents([beaconInfoEvent]); makeRoomWithStateEvents([beaconInfoEvent]);
const component = getComponent({ mxEvent: 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', () => { it('renders stopped beacon UI for an expired beacon', () => {
@ -105,7 +107,7 @@ describe('<MBeaconBody />', () => {
); );
makeRoomWithStateEvents([beaconInfoEvent]); makeRoomWithStateEvents([beaconInfoEvent]);
const component = getComponent({ mxEvent: 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', () => { it('renders stopped UI when a beacon event is not the latest beacon for a user', () => {
@ -128,7 +130,7 @@ describe('<MBeaconBody />', () => {
const component = getComponent({ mxEvent: aliceBeaconInfo1 }); const component = getComponent({ mxEvent: aliceBeaconInfo1 });
// beacon1 has been superceded by beacon2 // 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', () => { it('renders stopped UI when a beacon event is replaced', () => {
@ -160,7 +162,7 @@ describe('<MBeaconBody />', () => {
component.setProps({}); component.setProps({});
// beacon1 has been superceded by beacon2 // beacon1 has been superceded by beacon2
expect(component.text()).toEqual("Beacon stopped or replaced"); expect(component.find('Map').length).toBeFalsy();
}); });
describe('on liveness change', () => { describe('on liveness change', () => {
@ -173,9 +175,9 @@ describe('<MBeaconBody />', () => {
); );
const room = makeRoomWithStateEvents([aliceBeaconInfo]); const room = makeRoomWithStateEvents([aliceBeaconInfo]);
const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo));
const component = getComponent({ mxEvent: aliceBeaconInfo }); const component = getComponent({ mxEvent: aliceBeaconInfo });
const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo));
act(() => { act(() => {
// @ts-ignore cheat to force beacon to not live // @ts-ignore cheat to force beacon to not live
beaconInstance._isLive = false; beaconInstance._isLive = false;
@ -185,7 +187,7 @@ describe('<MBeaconBody />', () => {
component.setProps({}); component.setProps({});
// stopped UI // stopped UI
expect(component.text()).toEqual("Beacon stopped or replaced"); expect(component.find('Map').length).toBeFalsy();
}); });
}); });
@ -198,18 +200,17 @@ describe('<MBeaconBody />', () => {
); );
const location1 = makeBeaconEvent( 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( 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', () => { it('renders a live beacon without a location correctly', () => {
makeRoomWithStateEvents([aliceBeaconInfo]); makeRoomWithStateEvents([aliceBeaconInfo]);
const component = getComponent({ mxEvent: aliceBeaconInfo }); const component = getComponent({ mxEvent: aliceBeaconInfo });
// loading map expect(component.find('Spinner').length).toBeTruthy();
expect(findByTestId(component, 'beacon-waiting-for-location').length).toBeTruthy();
}); });
it('updates latest location', () => { it('updates latest location', () => {
@ -222,14 +223,16 @@ describe('<MBeaconBody />', () => {
component.setProps({}); 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(() => { act(() => {
beaconInstance.addLocations([location2]); beaconInstance.addLocations([location2]);
component.setProps({}); 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 });
}); });
}); });
}); });

View file

@ -1,49 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MBeaconBody /> renders a live beacon with basic stub 1`] = `
<ForwardRef
highlightLink=""
highlights={Array []}
mediaEventHelper={Object {}}
mxEvent={
Object {
"content": Object {
"description": undefined,
"live": true,
"org.matrix.msc3488.asset": Object {
"type": "m.self",
},
"org.matrix.msc3488.ts": 1647270879403,
"timeout": 3600000,
},
"event_id": "$alice-room1-1",
"origin_server_ts": 1647270879403,
"room_id": "!room:server",
"sender": "@alice:server",
"state_key": "@alice:server",
"type": "org.matrix.msc3672.beacon_info",
}
}
onHeightChanged={[MockFunction]}
onMessageAllowed={[MockFunction]}
permalinkCreator={Object {}}
>
<div
className="mx_MBeaconBody"
>
<code>
$alice-room1-1
</code>
 
<span>
Beacon "
"
</span>
<span
data-test-id="beacon-waiting-for-location"
>
Waiting for location
</span>
</div>
</ForwardRef>
`;