diff --git a/res/css/components/views/beacon/_OwnBeaconStatus.scss b/res/css/components/views/beacon/_OwnBeaconStatus.scss index aa01b6269a4..cc22fd7a0cc 100644 --- a/res/css/components/views/beacon/_OwnBeaconStatus.scss +++ b/res/css/components/views/beacon/_OwnBeaconStatus.scss @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_OwnBeaconStatus_button { + margin-left: $spacing-8; +} + .mx_EventTile[data-layout="bubble"] .mx_OwnBeaconStatus_button { // align to top to make room for timestamp // in bubble view diff --git a/res/css/components/views/location/_MapError.scss b/res/css/components/views/location/_MapError.scss index 83d6316ec44..7dc8a684d11 100644 --- a/res/css/components/views/location/_MapError.scss +++ b/res/css/components/views/location/_MapError.scss @@ -18,9 +18,41 @@ limitations under the License. padding: 100px $spacing-32 0; text-align: center; - p { - margin: $spacing-16 0 $spacing-32; + --mx-map-error-icon-color: $secondary-content; + --mx-map-error-icon-size: 58px; +} + +.mx_MapError.mx_MapError_isMinimised { + height: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + padding: $spacing-24; + background-color: $panels; + font-size: $font-12px; + line-height: $font-16px; + + --mx-map-error-icon-color: $alert; + --mx-map-error-icon-size: 26px; + + .mx_MapError_message { + margin: 0; + max-width: 275px; } + + .mx_MapError_heading { + padding-top: $spacing-8; + // override h3 heading size + font-size: inherit !important; + font-weight: normal !important; + } +} + +.mx_MapError_message { + margin: $spacing-16 0 $spacing-32; } .mx_MapError_heading { @@ -28,9 +60,9 @@ limitations under the License. } .mx_MapError_icon { - height: 58px; + height: var(--mx-map-error-icon-size); path { - fill: $secondary-content; + fill: var(--mx-map-error-icon-color); } } diff --git a/res/css/components/views/messages/_MBeaconBody.scss b/res/css/components/views/messages/_MBeaconBody.scss index aed1cb44d3d..446b7e6e3fe 100644 --- a/res/css/components/views/messages/_MBeaconBody.scss +++ b/res/css/components/views/messages/_MBeaconBody.scss @@ -24,6 +24,29 @@ limitations under the License. overflow: hidden; } +.mx_MBeaconBody.mx_MBeaconBody_withoutMap { + height: auto; + + .mx_MBeaconBody_chin { + position: relative; + background-color: transparent; + } +} + +.mx_MBeaconBody_withoutMapContent { + background-color: $panels; + border-radius: 4px; +} + +.mx_MBeaconBody_withoutMapInfoLastUpdated { + // 48px lines up with icon in BeaconStatus + margin-top: -$spacing-8; + padding: 0 $spacing-8 $spacing-8 48px; + + color: $tertiary-content; + font-size: $font-10px; +} + .mx_MBeaconBody_map { height: 100%; width: 100%; @@ -32,11 +55,18 @@ limitations under the License. cursor: pointer; } -.mx_MBeaconBody_mapFallback { +.mx_MBeaconBody_mapFallback, +.mx_MBeaconBody_mapError { // pushes spinner/icon up // to appear more centered with the footer - padding-bottom: 50px; + padding-bottom: 50px !important; +} +.mx_MBeaconBody_mapErrorInteractive { + cursor: pointer; +} + +.mx_MBeaconBody_mapFallback { cursor: default; } diff --git a/src/components/views/beacon/BeaconViewDialog.tsx b/src/components/views/beacon/BeaconViewDialog.tsx index f3e2fd12a17..af21c64339d 100644 --- a/src/components/views/beacon/BeaconViewDialog.tsx +++ b/src/components/views/beacon/BeaconViewDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { Beacon, @@ -38,6 +38,8 @@ import DialogSidebar from './DialogSidebar'; import DialogOwnBeaconStatus from './DialogOwnBeaconStatus'; import BeaconStatusTooltip from './BeaconStatusTooltip'; import MapFallback from '../location/MapFallback'; +import { MapError } from '../location/MapError'; +import { LocationShareError } from '../../../utils/location'; interface IProps extends IDialogProps { roomId: Room['roomId']; @@ -83,6 +85,15 @@ const BeaconViewDialog: React.FC = ({ const { bounds, centerGeoUri } = useInitialMapPosition(liveBeacons, focusBeacon); + const [mapDisplayError, setMapDisplayError] = useState(); + + // automatically open the sidebar if there is no map to see + useEffect(() => { + if (mapDisplayError) { + setSidebarOpen(true); + } + }, [mapDisplayError]); + return ( = ({ fixedWidth={false} > - { !!liveBeacons?.length ? { @@ -109,7 +121,14 @@ const BeaconViewDialog: React.FC = ({ } - : + } + { mapDisplayError && + + } + { !liveBeacons?.length && !mapDisplayError && void; +export interface MapErrorProps { error: LocationShareError; + onFinished?: () => void; + isMinimised?: boolean; + className?: string; + onClick?: () => void; } -export const MapError: React.FC = ({ - onFinished, error, -}) => (
- - { _t("Unable to load map") } -

- { getLocationShareErrorMessage(error) } -

- { _t("OK") } -
); +export const MapError: React.FC = ({ + error, + isMinimised, + className, + onFinished, + onClick, +}) => ( +
+ + { _t('Unable to load map') } +

+ { getLocationShareErrorMessage(error) } +

+ { onFinished && + + { _t('OK') } + + } +
+); diff --git a/src/components/views/location/MapFallback.tsx b/src/components/views/location/MapFallback.tsx index 75545d5e0fd..702090d5dde 100644 --- a/src/components/views/location/MapFallback.tsx +++ b/src/components/views/location/MapFallback.tsx @@ -30,7 +30,6 @@ interface Props extends React.HTMLAttributes { const MapFallback: React.FC = ({ className, isLoading, children, ...rest }) => { return
- { /*
*/ } { isLoading ? : } { children }
; diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx index fda27633ec8..a5022317d27 100644 --- a/src/components/views/messages/MBeaconBody.tsx +++ b/src/components/views/messages/MBeaconBody.tsx @@ -26,17 +26,19 @@ import { import { BeaconLocationState } from 'matrix-js-sdk/src/content-helpers'; import { randomString } from 'matrix-js-sdk/src/randomstring'; import { M_BEACON } from 'matrix-js-sdk/src/@types/beacon'; +import classNames from 'classnames'; import MatrixClientContext from '../../../contexts/MatrixClientContext'; import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; import { isBeaconWaitingToStart, useBeacon } from '../../../utils/beacon'; -import { isSelfLocation } from '../../../utils/location'; +import { isSelfLocation, LocationShareError } from '../../../utils/location'; import { BeaconDisplayStatus, getBeaconDisplayStatus } from '../beacon/displayStatus'; import BeaconStatus from '../beacon/BeaconStatus'; import OwnBeaconStatus from '../beacon/OwnBeaconStatus'; import Map from '../location/Map'; +import { MapError } from '../location/MapError'; import MapFallback from '../location/MapFallback'; import SmartMarker from '../location/SmartMarker'; import { GetRelationsForEvent } from '../rooms/EventTile'; @@ -136,7 +138,16 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent, getRelati const matrixClient = useContext(MatrixClientContext); const [error, setError] = useState(); - const displayStatus = getBeaconDisplayStatus(isLive, latestLocationState, error, waitingToStart); + const isMapDisplayError = error?.message === LocationShareError.MapStyleUrlNotConfigured || + error?.message === LocationShareError.MapStyleUrlNotReachable; + const displayStatus = getBeaconDisplayStatus( + isLive, + latestLocationState, + // if we are unable to display maps because it is not configured for the server + // don't display an error + isMapDisplayError ? undefined : error, + waitingToStart, + ); const markerRoomMember = isSelfLocation(mxEvent.getContent()) ? mxEvent.sender : undefined; const isOwnBeacon = beacon?.beaconInfoOwner === matrixClient.getUserId(); @@ -152,6 +163,7 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent, getRelati roomId: mxEvent.getRoomId(), matrixClient, focusBeacon: beacon, + isMapDisplayError, }, "mx_BeaconViewDialog_wrapper", false, // isPriority @@ -160,8 +172,11 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent, getRelati }; return ( -
- { displayStatus === BeaconDisplayStatus.Active ? +
+ { (displayStatus === BeaconDisplayStatus.Active && !isMapDisplayError) ? = React.forwardRef(({ mxEvent, getRelati /> } - : + : isMapDisplayError ? + : + } { isOwnBeacon ? { wrapper.setProps({}); }); - expect(findByTestId(wrapper, 'location-picker-error').find('p').text()).toEqual( + expect(findByTestId(wrapper, 'map-rendering-error').find('p').text()).toEqual( "This homeserver is not configured correctly to display maps, " + "or the configured map server may be unreachable.", ); @@ -115,7 +115,7 @@ describe("LocationPicker", () => { const wrapper = getComponent(); wrapper.setProps({}); - expect(findByTestId(wrapper, 'location-picker-error').find('p').text()).toEqual( + expect(findByTestId(wrapper, 'map-rendering-error').find('p').text()).toEqual( "This homeserver is not configured to display maps.", ); }); @@ -130,7 +130,7 @@ describe("LocationPicker", () => { const wrapper = getComponent(); wrapper.setProps({}); - expect(findByTestId(wrapper, 'location-picker-error').find('p').text()).toEqual( + expect(findByTestId(wrapper, 'map-rendering-error').find('p').text()).toEqual( "This homeserver is not configured correctly to display maps, " + "or the configured map server may be unreachable.", ); diff --git a/test/components/views/location/MapError-test.tsx b/test/components/views/location/MapError-test.tsx index 2fa9cf3cd0d..27a42dd95a2 100644 --- a/test/components/views/location/MapError-test.tsx +++ b/test/components/views/location/MapError-test.tsx @@ -15,28 +15,45 @@ limitations under the License. */ import React from 'react'; -import { mount } from 'enzyme'; +import { render, RenderResult } from '@testing-library/react'; -import { MapError } from '../../../../src/components/views/location/MapError'; +import { MapError, MapErrorProps } from '../../../../src/components/views/location/MapError'; import { LocationShareError } from '../../../../src/utils/location'; describe('', () => { const defaultProps = { onFinished: jest.fn(), error: LocationShareError.MapStyleUrlNotConfigured, + className: 'test', }; - const getComponent = (props = {}) => - mount(); + const getComponent = (props: Partial = {}): RenderResult => + render(); it('renders correctly for MapStyleUrlNotConfigured', () => { - const component = getComponent(); - expect(component).toMatchSnapshot(); + const { container } = getComponent(); + expect(container).toMatchSnapshot(); }); it('renders correctly for MapStyleUrlNotReachable', () => { - const component = getComponent({ + const { container } = getComponent({ error: LocationShareError.MapStyleUrlNotReachable, }); - expect(component).toMatchSnapshot(); + expect(container).toMatchSnapshot(); + }); + + it('does not render button when onFinished falsy', () => { + const { queryByText } = getComponent({ + error: LocationShareError.MapStyleUrlNotReachable, + onFinished: undefined, + }); + // no button + expect(queryByText('OK')).toBeFalsy(); + }); + + it('applies class when isMinimised is truthy', () => { + const { container } = getComponent({ + isMinimised: true, + }); + expect(container).toMatchSnapshot(); }); }); diff --git a/test/components/views/location/__snapshots__/MapError-test.tsx.snap b/test/components/views/location/__snapshots__/MapError-test.tsx.snap index 726e9454938..4fdef3e5d3d 100644 --- a/test/components/views/location/__snapshots__/MapError-test.tsx.snap +++ b/test/components/views/location/__snapshots__/MapError-test.tsx.snap @@ -1,95 +1,91 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[` applies class when isMinimised is truthy 1`] = ` +
+
+
+

+ Unable to load map +

+

+ This homeserver is not configured to display maps. +

+ +
+
+`; + exports[` renders correctly for MapStyleUrlNotConfigured 1`] = ` - +
- + Unable to load map + +

-

- Unable to load map -

-
-

This homeserver is not configured to display maps.

- - - + OK +
- +
`; exports[` renders correctly for MapStyleUrlNotReachable 1`] = ` - +
- + Unable to load map + +

-

- Unable to load map -

-
-

This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.

- - - + OK +
- +
`; diff --git a/test/components/views/messages/MBeaconBody-test.tsx b/test/components/views/messages/MBeaconBody-test.tsx index c38e145a9f9..6a564dfdc4c 100644 --- a/test/components/views/messages/MBeaconBody-test.tsx +++ b/test/components/views/messages/MBeaconBody-test.tsx @@ -33,6 +33,7 @@ import { getMockClientWithEventEmitter, makeBeaconEvent, makeBeaconInfoEvent, + makeRoomWithBeacons, makeRoomWithStateEvents, } from '../../../test-utils'; import { RoomPermalinkCreator } from '../../../../src/utils/permalinks/Permalinks'; @@ -40,6 +41,9 @@ import { MediaEventHelper } from '../../../../src/utils/MediaEventHelper'; import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; import Modal from '../../../../src/Modal'; import { TILE_SERVER_WK_KEY } from '../../../../src/utils/WellKnownUtils'; +import { MapError } from '../../../../src/components/views/location/MapError'; +import * as mapUtilHooks from '../../../../src/utils/location/useMap'; +import { LocationShareError } from '../../../../src/utils/location'; describe('', () => { // 14.03.2022 16:15 @@ -94,112 +98,116 @@ describe('', () => { jest.clearAllMocks(); }); - it('renders stopped beacon UI for an explicitly stopped beacon', () => { - const beaconInfoEvent = makeBeaconInfoEvent(aliceId, - roomId, - { isLive: false }, - '$alice-room1-1', - ); - makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient }); - const component = getComponent({ mxEvent: beaconInfoEvent }); - expect(component.text()).toEqual("Live location ended"); - }); + const testBeaconStatuses = () => { + it('renders stopped beacon UI for an explicitly stopped beacon', () => { + const beaconInfoEvent = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: false }, + '$alice-room1-1', + ); + makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient }); + const component = getComponent({ mxEvent: beaconInfoEvent }); + expect(component.text()).toEqual("Live location ended"); + }); - it('renders stopped beacon UI for an expired beacon', () => { - const beaconInfoEvent = makeBeaconInfoEvent(aliceId, - roomId, - // puts this beacons live period in the past - { isLive: true, timestamp: now - 600000, timeout: 500 }, - '$alice-room1-1', - ); - makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient }); - const component = getComponent({ mxEvent: beaconInfoEvent }); - expect(component.text()).toEqual("Live location ended"); - }); + it('renders stopped beacon UI for an expired beacon', () => { + const beaconInfoEvent = makeBeaconInfoEvent(aliceId, + roomId, + // puts this beacons live period in the past + { isLive: true, timestamp: now - 600000, timeout: 500 }, + '$alice-room1-1', + ); + makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient }); + const component = getComponent({ mxEvent: beaconInfoEvent }); + expect(component.text()).toEqual("Live location ended"); + }); - it('renders loading beacon UI for a beacon that has not started yet', () => { - const beaconInfoEvent = makeBeaconInfoEvent( - aliceId, - roomId, - // puts this beacons start timestamp in the future - { isLive: true, timestamp: now + 60000, timeout: 500 }, - '$alice-room1-1', - ); - makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient }); - const component = getComponent({ mxEvent: beaconInfoEvent }); - expect(component.text()).toEqual("Loading live location..."); - }); + it('renders loading beacon UI for a beacon that has not started yet', () => { + const beaconInfoEvent = makeBeaconInfoEvent( + aliceId, + roomId, + // puts this beacons start timestamp in the future + { isLive: true, timestamp: now + 60000, timeout: 500 }, + '$alice-room1-1', + ); + makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient }); + const component = getComponent({ mxEvent: beaconInfoEvent }); + expect(component.text()).toEqual("Loading live location..."); + }); - it('does not open maximised map when on click when beacon is stopped', () => { - const beaconInfoEvent = makeBeaconInfoEvent(aliceId, - roomId, - // puts this beacons live period in the past - { isLive: true, timestamp: now - 600000, timeout: 500 }, - '$alice-room1-1', - ); - makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient }); - const component = getComponent({ mxEvent: beaconInfoEvent }); - act(() => { - component.find('.mx_MBeaconBody_map').at(0).simulate('click'); + it('does not open maximised map when on click when beacon is stopped', () => { + const beaconInfoEvent = makeBeaconInfoEvent(aliceId, + roomId, + // puts this beacons live period in the past + { isLive: true, timestamp: now - 600000, timeout: 500 }, + '$alice-room1-1', + ); + makeRoomWithStateEvents([beaconInfoEvent], { roomId, mockClient }); + const component = getComponent({ mxEvent: beaconInfoEvent }); + act(() => { + component.find('.mx_MBeaconBody_map').at(0).simulate('click'); + }); + + expect(modalSpy).not.toHaveBeenCalled(); }); - expect(modalSpy).not.toHaveBeenCalled(); - }); + it('renders stopped UI when a beacon event is not the latest beacon for a user', () => { + const aliceBeaconInfo1 = makeBeaconInfoEvent( + aliceId, + roomId, + // this one is a little older + { isLive: true, timestamp: now - 500 }, + '$alice-room1-1', + ); + aliceBeaconInfo1.event.origin_server_ts = now - 500; + const aliceBeaconInfo2 = makeBeaconInfoEvent( + aliceId, + roomId, + { isLive: true }, + '$alice-room1-2', + ); - it('renders stopped UI when a beacon event is not the latest beacon for a user', () => { - const aliceBeaconInfo1 = makeBeaconInfoEvent( - aliceId, - roomId, - // this one is a little older - { isLive: true, timestamp: now - 500 }, - '$alice-room1-1', - ); - aliceBeaconInfo1.event.origin_server_ts = now - 500; - const aliceBeaconInfo2 = makeBeaconInfoEvent( - aliceId, - roomId, - { isLive: true }, - '$alice-room1-2', - ); + makeRoomWithStateEvents([aliceBeaconInfo1, aliceBeaconInfo2], { roomId, mockClient }); - makeRoomWithStateEvents([aliceBeaconInfo1, aliceBeaconInfo2], { roomId, mockClient }); + const component = getComponent({ mxEvent: aliceBeaconInfo1 }); + // beacon1 has been superceded by beacon2 + expect(component.text()).toEqual("Live location ended"); + }); - const component = getComponent({ mxEvent: aliceBeaconInfo1 }); - // beacon1 has been superceded by beacon2 - expect(component.text()).toEqual("Live location ended"); - }); + it('renders stopped UI when a beacon event is replaced', () => { + const aliceBeaconInfo1 = makeBeaconInfoEvent( + aliceId, + roomId, + // this one is a little older + { isLive: true, timestamp: now - 500 }, + '$alice-room1-1', + ); + aliceBeaconInfo1.event.origin_server_ts = now - 500; + const aliceBeaconInfo2 = makeBeaconInfoEvent( + aliceId, + roomId, + { isLive: true }, + '$alice-room1-2', + ); - it('renders stopped UI when a beacon event is replaced', () => { - const aliceBeaconInfo1 = makeBeaconInfoEvent( - aliceId, - roomId, - // this one is a little older - { isLive: true, timestamp: now - 500 }, - '$alice-room1-1', - ); - aliceBeaconInfo1.event.origin_server_ts = now - 500; - const aliceBeaconInfo2 = makeBeaconInfoEvent( - aliceId, - roomId, - { isLive: true }, - '$alice-room1-2', - ); + const room = makeRoomWithStateEvents([aliceBeaconInfo1], { roomId, mockClient }); + const component = getComponent({ mxEvent: aliceBeaconInfo1 }); - const room = makeRoomWithStateEvents([aliceBeaconInfo1], { roomId, mockClient }); - const component = getComponent({ mxEvent: aliceBeaconInfo1 }); + const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo1)); + // update alice's beacon with a new edition + // beacon instance emits + act(() => { + beaconInstance.update(aliceBeaconInfo2); + }); - const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo1)); - // update alice's beacon with a new edition - // beacon instance emits - act(() => { - beaconInstance.update(aliceBeaconInfo2); - }); + component.setProps({}); - component.setProps({}); + // beacon1 has been superceded by beacon2 + expect(component.text()).toEqual("Live location ended"); + }); + }; - // beacon1 has been superceded by beacon2 - expect(component.text()).toEqual("Live location ended"); - }); + testBeaconStatuses(); describe('on liveness change', () => { it('renders stopped UI when a beacon stops being live', () => { @@ -458,4 +466,34 @@ describe('', () => { ); }); }); + + describe('when map display is not configured', () => { + beforeEach(() => { + // mock map utils to raise MapStyleUrlNotConfigured error + jest.spyOn(mapUtilHooks, 'useMap').mockImplementation( + ({ onError }) => { + onError(new Error(LocationShareError.MapStyleUrlNotConfigured)); + return mockMap; + }); + }); + + it('renders maps unavailable error for a live beacon with location', () => { + const beaconInfoEvent = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: true }, + '$alice-room1-1', + ); + const location1 = makeBeaconEvent( + aliceId, { beaconInfoId: beaconInfoEvent.getId(), geoUri: 'geo:51,41', timestamp: now + 1 }, + ); + + makeRoomWithBeacons(roomId, mockClient, [beaconInfoEvent], [location1]); + + const component = getComponent({ mxEvent: beaconInfoEvent }); + expect(component.find(MapError)).toMatchSnapshot(); + }); + + // test that statuses display as expected with a map display error + testBeaconStatuses(); + }); }); diff --git a/test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap new file mode 100644 index 00000000000..a9acf277c6d --- /dev/null +++ b/test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` when map display is not configured renders maps unavailable error for a live beacon with location 1`] = ` + +
+
+ +

+ Unable to load map +

+
+

+ This homeserver is not configured to display maps. +

+
+ +`;