diff --git a/res/css/_components.scss b/res/css/_components.scss index 38d539248ca..f0aff95be0d 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -7,6 +7,7 @@ @import "./components/views/beacon/_BeaconStatus.scss"; @import "./components/views/beacon/_LeftPanelLiveShareWarning.scss"; @import "./components/views/beacon/_LiveTimeRemaining.scss"; +@import "./components/views/beacon/_OwnBeaconStatus.scss"; @import "./components/views/beacon/_RoomLiveShareWarning.scss"; @import "./components/views/beacon/_StyledLiveBeaconIcon.scss"; @import "./components/views/location/_LiveDurationDropdown.scss"; diff --git a/res/css/components/views/beacon/_BeaconStatus.scss b/res/css/components/views/beacon/_BeaconStatus.scss index fa971ffd0fd..8ac873604d2 100644 --- a/res/css/components/views/beacon/_BeaconStatus.scss +++ b/res/css/components/views/beacon/_BeaconStatus.scss @@ -45,16 +45,17 @@ limitations under the License. margin-right: $spacing-8; } -.mx_BeaconStatus_activeDescription { +.mx_BeaconStatus_description { flex: 1; display: flex; flex-direction: column; line-height: $font-14px; + + padding-right: $spacing-8; + + // TODO handle text-overflow } -.mx_BeaconStatus_stopButton { - // override button link_inline styles - color: $alert !important; - font-weight: $font-semi-bold !important; - text-transform: uppercase; +.mx_BeaconStatus_expiryTime { + color: $secondary-content; } diff --git a/res/css/components/views/beacon/_OwnBeaconStatus.scss b/res/css/components/views/beacon/_OwnBeaconStatus.scss new file mode 100644 index 00000000000..aa01b6269a4 --- /dev/null +++ b/res/css/components/views/beacon/_OwnBeaconStatus.scss @@ -0,0 +1,27 @@ +/* +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_EventTile[data-layout="bubble"] .mx_OwnBeaconStatus_button { + // align to top to make room for timestamp + // in bubble view + align-self: start; +} + +.mx_OwnBeaconStatus_destructiveButton { + // override button link_inline styles + color: $alert !important; + font-weight: $font-semi-bold !important; +} diff --git a/src/components/views/beacon/BeaconStatus.tsx b/src/components/views/beacon/BeaconStatus.tsx index 15c94732fc7..c9d7bd3762d 100644 --- a/src/components/views/beacon/BeaconStatus.tsx +++ b/src/components/views/beacon/BeaconStatus.tsx @@ -20,20 +20,33 @@ import { Beacon } from 'matrix-js-sdk/src/matrix'; import StyledLiveBeaconIcon from './StyledLiveBeaconIcon'; import { _t } from '../../../languageHandler'; -import AccessibleButton from '../elements/AccessibleButton'; import LiveTimeRemaining from './LiveTimeRemaining'; import { BeaconDisplayStatus } from './displayStatus'; +import { getBeaconExpiryTimestamp } from '../../../utils/beacon'; +import { formatTime } from '../../../DateUtils'; interface Props { displayStatus: BeaconDisplayStatus; + displayLiveTimeRemaining?: boolean; beacon?: Beacon; label?: string; - // assumes permission to stop was checked by parent - stopBeacon?: () => void; } +const BeaconExpiryTime: React.FC<{ beacon: Beacon }> = ({ beacon }) => { + const expiryTime = formatTime(new Date(getBeaconExpiryTimestamp(beacon))); + return { _t('Live until %(expiryTime)s', { expiryTime }) }; +}; + const BeaconStatus: React.FC> = - ({ beacon, displayStatus, label, stopBeacon, className, ...rest }) => { + ({ + beacon, + displayStatus, + displayLiveTimeRemaining, + label, + className, + children, + ...rest + }) => { const isIdle = displayStatus === BeaconDisplayStatus.Loading || displayStatus === BeaconDisplayStatus.Stopped; @@ -46,25 +59,25 @@ const BeaconStatus: React.FC> = withError={displayStatus === BeaconDisplayStatus.Error} isIdle={isIdle} /> - { displayStatus === BeaconDisplayStatus.Loading && { _t('Loading live location...') } } - { displayStatus === BeaconDisplayStatus.Stopped && { _t('Live location ended') } } +
+ + { displayStatus === BeaconDisplayStatus.Loading && { _t('Loading live location...') } } + { displayStatus === BeaconDisplayStatus.Stopped && { _t('Live location ended') } } - { /* TODO error */ } + { displayStatus === BeaconDisplayStatus.Error && { _t('Live location error') } } - { displayStatus === BeaconDisplayStatus.Active && beacon && <> -
- { label } - -
- { stopBeacon && { _t('Stop') } + { displayStatus === BeaconDisplayStatus.Active && beacon && <> + <> + { label } + { displayLiveTimeRemaining ? + : + + } + + } - - } +
+ { children } ; }; diff --git a/src/components/views/beacon/OwnBeaconStatus.tsx b/src/components/views/beacon/OwnBeaconStatus.tsx new file mode 100644 index 00000000000..204e2968293 --- /dev/null +++ b/src/components/views/beacon/OwnBeaconStatus.tsx @@ -0,0 +1,89 @@ +/* +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 { Beacon } from 'matrix-js-sdk/src/matrix'; +import React, { HTMLProps } from 'react'; + +import { _t } from '../../../languageHandler'; +import { useOwnLiveBeacons } from '../../../utils/beacon'; +import BeaconStatus from './BeaconStatus'; +import { BeaconDisplayStatus } from './displayStatus'; +import AccessibleButton from '../elements/AccessibleButton'; + +interface Props { + displayStatus: BeaconDisplayStatus; + beacon?: Beacon; +} + +/** + * Wraps BeaconStatus with more capabilities + * for errors and actions available for users own live beacons + */ +const OwnBeaconStatus: React.FC> = ({ + beacon, displayStatus, className, ...rest +}) => { + const { + hasWireError, + hasStopSharingError, + stoppingInProgress, + onStopSharing, + onResetWireError, + } = useOwnLiveBeacons([beacon?.identifier]); + + // combine display status with errors that only occur for user's own beacons + const ownDisplayStatus = hasWireError || hasStopSharingError ? + BeaconDisplayStatus.Error : + displayStatus; + + return + { ownDisplayStatus === BeaconDisplayStatus.Active && + { _t('Stop') } + + } + { hasWireError && + { _t('Retry') } + + } + { hasStopSharingError && + { _t('Retry') } + } + ; +}; + +export default OwnBeaconStatus; diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx index ed40bfdda75..a2142e7b0bb 100644 --- a/src/components/views/messages/MBeaconBody.tsx +++ b/src/components/views/messages/MBeaconBody.tsx @@ -14,22 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useEffect, useState } from 'react'; +import React, { useContext, 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 { Icon as LocationMarkerIcon } from '../../../../res/img/element-icons/location.svg'; +import MatrixClientContext from '../../../contexts/MatrixClientContext'; import { useEventEmitterState } from '../../../hooks/useEventEmitter'; +import { _t } from '../../../languageHandler'; import { useBeacon } from '../../../utils/beacon'; import { isSelfLocation } from '../../../utils/location'; import { BeaconDisplayStatus, getBeaconDisplayStatus } from '../beacon/displayStatus'; +import BeaconStatus from '../beacon/BeaconStatus'; import Spinner from '../elements/Spinner'; import Map from '../location/Map'; import SmartMarker from '../location/SmartMarker'; -import BeaconStatus from '../beacon/BeaconStatus'; +import OwnBeaconStatus from '../beacon/OwnBeaconStatus'; import { IBodyProps } from "./IBodyProps"; -import { _t } from '../../../languageHandler'; const useBeaconState = (beaconInfoEvent: MatrixEvent): { beacon?: Beacon; @@ -83,13 +85,13 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) => latestLocationState, } = useBeaconState(mxEvent); const mapId = useUniqueId(mxEvent.getId()); - const [error, setError] = useState(); - + const matrixClient = useContext(MatrixClientContext); const displayStatus = getBeaconDisplayStatus(isLive, latestLocationState, error); - const markerRoomMember = isSelfLocation(mxEvent.getContent()) ? mxEvent.sender : undefined; + const isOwnBeacon = beacon?.beaconInfoOwner === matrixClient.getUserId(); + return (
{ displayStatus === BeaconDisplayStatus.Active ? @@ -106,6 +108,7 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) => id={`${mapId}-marker`} geoUri={latestLocationState.uri} roomMember={markerRoomMember} + useMemberColor /> } @@ -116,12 +119,19 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) => }
} - + { isOwnBeacon ? + : + + } ); }); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a3ab40d3449..62b3031ce54 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2901,11 +2901,14 @@ "Beta": "Beta", "Leave the beta": "Leave the beta", "Join the beta": "Join the beta", + "Live until %(expiryTime)s": "Live until %(expiryTime)s", "Loading live location...": "Loading live location...", "Live location ended": "Live location ended", + "Live location error": "Live location error", "An error occured whilst sharing your live location": "An error occured whilst sharing your live location", "You are sharing your live location": "You are sharing your live location", "%(timeRemaining)s left": "%(timeRemaining)s left", + "Live location enabled": "Live location enabled", "An error occured whilst sharing your live location, please try again": "An error occured whilst sharing your live location, please try again", "An error occurred while stopping your live location, please try again": "An error occurred while stopping your live location, please try again", "Stop sharing": "Stop sharing", diff --git a/test/components/views/beacon/BeaconStatus-test.tsx b/test/components/views/beacon/BeaconStatus-test.tsx index 5be5cfe08d5..db4153defac 100644 --- a/test/components/views/beacon/BeaconStatus-test.tsx +++ b/test/components/views/beacon/BeaconStatus-test.tsx @@ -17,7 +17,6 @@ limitations under the License. import React from 'react'; import { mount } from 'enzyme'; import { Beacon } from 'matrix-js-sdk/src/matrix'; -import { act } from 'react-dom/test-utils'; import BeaconStatus from '../../../../src/components/views/beacon/BeaconStatus'; import { BeaconDisplayStatus } from '../../../../src/components/views/beacon/displayStatus'; @@ -26,6 +25,7 @@ import { findByTestId, makeBeaconInfoEvent } from '../../../test-utils'; describe('', () => { const defaultProps = { displayStatus: BeaconDisplayStatus.Loading, + label: 'test label', }; const getComponent = (props = {}) => mount(); @@ -40,28 +40,42 @@ describe('', () => { expect(component).toMatchSnapshot(); }); - it('renders active state without stop buttons', () => { - // mock for stable snapshot - jest.spyOn(Date, 'now').mockReturnValue(123456789); - const beacon = new Beacon(makeBeaconInfoEvent('@user:server', '!room:server', {}, '$1')); - const component = getComponent({ beacon, displayStatus: BeaconDisplayStatus.Active }); - expect(component).toMatchSnapshot(); - }); + describe('active state', () => { + it('renders without children', () => { + // mock for stable snapshot + jest.spyOn(Date, 'now').mockReturnValue(123456789); + const beacon = new Beacon(makeBeaconInfoEvent('@user:server', '!room:server', {}, '$1')); + const component = getComponent({ beacon, displayStatus: BeaconDisplayStatus.Active }); + expect(component).toMatchSnapshot(); + }); - it('renders active state with stop button', () => { - const stopBeacon = jest.fn(); - const beacon = new Beacon(makeBeaconInfoEvent('@user:server', '!room:sever')); - const component = getComponent({ - beacon, - stopBeacon, - displayStatus: BeaconDisplayStatus.Active, + it('renders with children', () => { + const beacon = new Beacon(makeBeaconInfoEvent('@user:server', '!room:sever')); + const component = getComponent({ + beacon, + children: test, + displayStatus: BeaconDisplayStatus.Active, + }); + expect(findByTestId(component, 'test-child')).toMatchSnapshot(); }); - expect(findByTestId(component, 'beacon-status-stop-beacon')).toMatchSnapshot(); - act(() => { - findByTestId(component, 'beacon-status-stop-beacon').at(0).simulate('click'); + it('renders static remaining time when displayLiveTimeRemaining is falsy', () => { + // mock for stable snapshot + jest.spyOn(Date, 'now').mockReturnValue(123456789); + const beacon = new Beacon(makeBeaconInfoEvent('@user:server', '!room:server', {}, '$1')); + const component = getComponent({ beacon, displayStatus: BeaconDisplayStatus.Active }); + expect(component.text().includes('Live until 11:17')).toBeTruthy(); }); - expect(stopBeacon).toHaveBeenCalled(); + it('renders live time remaining when displayLiveTimeRemaining is truthy', () => { + // mock for stable snapshot + jest.spyOn(Date, 'now').mockReturnValue(123456789); + const beacon = new Beacon(makeBeaconInfoEvent('@user:server', '!room:server', {}, '$1')); + const component = getComponent({ + beacon, displayStatus: BeaconDisplayStatus.Active, + displayLiveTimeRemaining: true, + }); + expect(component.text().includes('1h left')).toBeTruthy(); + }); }); }); diff --git a/test/components/views/beacon/OwnBeaconStatus-test.tsx b/test/components/views/beacon/OwnBeaconStatus-test.tsx new file mode 100644 index 00000000000..a76e514538b --- /dev/null +++ b/test/components/views/beacon/OwnBeaconStatus-test.tsx @@ -0,0 +1,161 @@ +/* +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 React from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { mocked } from 'jest-mock'; +import { Beacon } from 'matrix-js-sdk/src/matrix'; + +import OwnBeaconStatus from '../../../../src/components/views/beacon/OwnBeaconStatus'; +import { BeaconDisplayStatus } from '../../../../src/components/views/beacon/displayStatus'; +import { useOwnLiveBeacons } from '../../../../src/utils/beacon'; +import { findByTestId, makeBeaconInfoEvent } from '../../../test-utils'; + +jest.mock('../../../../src/utils/beacon/useOwnLiveBeacons', () => ({ + useOwnLiveBeacons: jest.fn(), +})); + +describe('', () => { + const defaultProps = { + displayStatus: BeaconDisplayStatus.Loading, + }; + const userId = '@user:server'; + const roomId = '!room:server'; + let defaultBeacon; + const getComponent = (props = {}) => + mount(); + + beforeEach(() => { + jest.spyOn(global.Date, 'now').mockReturnValue(123456789); + mocked(useOwnLiveBeacons).mockClear().mockReturnValue({}); + + defaultBeacon = new Beacon(makeBeaconInfoEvent(userId, roomId)); + }); + + it('renders without a beacon instance', () => { + const component = getComponent(); + expect(component).toMatchSnapshot(); + }); + + it('renders loading state correctly', () => { + const component = getComponent(); + expect(component.find('BeaconStatus').props()).toBeTruthy(); + }); + + describe('Active state', () => { + it('renders stop button', () => { + const displayStatus = BeaconDisplayStatus.Active; + mocked(useOwnLiveBeacons).mockReturnValue({ + onStopSharing: jest.fn(), + }); + const component = getComponent({ displayStatus, beacon: defaultBeacon }); + expect(component.text()).toContain('Live location enabled'); + + expect(findByTestId(component, 'beacon-status-stop-beacon').length).toBeTruthy(); + }); + + it('stops sharing on stop button click', () => { + const displayStatus = BeaconDisplayStatus.Active; + const onStopSharing = jest.fn(); + mocked(useOwnLiveBeacons).mockReturnValue({ + onStopSharing, + }); + const component = getComponent({ displayStatus, beacon: defaultBeacon }); + + act(() => { + findByTestId(component, 'beacon-status-stop-beacon').at(0).simulate('click'); + }); + + expect(onStopSharing).toHaveBeenCalled(); + }); + }); + + describe('errors', () => { + it('renders in error mode when displayStatus is error', () => { + const displayStatus = BeaconDisplayStatus.Error; + const component = getComponent({ displayStatus }); + expect(component.text()).toEqual('Live location error'); + + // no actions for plain error + expect(component.find('AccessibleButton').length).toBeFalsy(); + }); + + describe('with wire error', () => { + it('renders in error mode', () => { + const displayStatus = BeaconDisplayStatus.Active; + mocked(useOwnLiveBeacons).mockReturnValue({ + hasWireError: true, + onResetWireError: jest.fn(), + }); + const component = getComponent({ displayStatus, beacon: defaultBeacon }); + expect(component.text()).toContain('Live location error'); + // retry button + expect(findByTestId(component, 'beacon-status-reset-wire-error').length).toBeTruthy(); + }); + + it('retry button resets wire error', () => { + const displayStatus = BeaconDisplayStatus.Active; + const onResetWireError = jest.fn(); + mocked(useOwnLiveBeacons).mockReturnValue({ + hasWireError: true, + onResetWireError, + }); + const component = getComponent({ displayStatus, beacon: defaultBeacon }); + act(() => { + findByTestId(component, 'beacon-status-reset-wire-error').at(0).simulate('click'); + }); + + expect(onResetWireError).toHaveBeenCalled(); + }); + }); + + describe('with stopping error', () => { + it('renders in error mode', () => { + const displayStatus = BeaconDisplayStatus.Active; + mocked(useOwnLiveBeacons).mockReturnValue({ + hasWireError: false, + hasStopSharingError: true, + onStopSharing: jest.fn(), + }); + const component = getComponent({ displayStatus, beacon: defaultBeacon }); + expect(component.text()).toContain('Live location error'); + // retry button + expect(findByTestId(component, 'beacon-status-stop-beacon-retry').length).toBeTruthy(); + }); + + it('retry button retries stop sharing', () => { + const displayStatus = BeaconDisplayStatus.Active; + const onStopSharing = jest.fn(); + mocked(useOwnLiveBeacons).mockReturnValue({ + hasStopSharingError: true, + onStopSharing, + }); + const component = getComponent({ displayStatus, beacon: defaultBeacon }); + act(() => { + findByTestId(component, 'beacon-status-stop-beacon-retry').at(0).simulate('click'); + }); + + expect(onStopSharing).toHaveBeenCalled(); + }); + }); + }); + + it('renders loading state correctly', () => { + const component = getComponent(); + expect(component).toBeTruthy(); + }); +}); diff --git a/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap index cf0ef327540..5e2b6673daa 100644 --- a/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap @@ -1,43 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` renders active state with stop button 1`] = ` -Array [ - -
- Stop -
-
, -
- Stop -
, -] -`; +exports[` active state renders with children 1`] = `null`; -exports[` renders active state without stop buttons 1`] = ` +exports[` active state renders without children 1`] = ` renders active state without stop buttons 1`] = ` "timeout": 3600000, "timestamp": 123456789, }, - "_events": Object { - "Beacon.update": [Function], - }, - "_eventsCount": 1, + "_events": Object {}, + "_eventsCount": 0, "_isLive": undefined, "_latestLocationState": undefined, "_maxListeners": undefined, @@ -79,6 +42,7 @@ exports[` renders active state without stop buttons 1`] = ` } } displayStatus="Active" + label="test label" >
renders active state without stop buttons 1`] = ` />
- renders active state without stop buttons 1`] = ` "timeout": 3600000, "timestamp": 123456789, }, - "_events": Object { - "Beacon.update": [Function], - }, - "_eventsCount": 1, + "_events": Object {}, + "_eventsCount": 0, "_isLive": undefined, "_latestLocationState": undefined, "_maxListeners": undefined, @@ -137,12 +100,11 @@ exports[` renders active state without stop buttons 1`] = ` } > - 1h left + Live until 11:17 - +
@@ -151,6 +113,7 @@ exports[` renders active state without stop buttons 1`] = ` exports[` renders loading state 1`] = `
renders loading state 1`] = ` className="mx_StyledLiveBeaconIcon mx_BeaconStatus_icon mx_StyledLiveBeaconIcon_idle" /> - - Loading live location... - +
+ + Loading live location... + +
`; @@ -174,6 +141,7 @@ exports[` renders loading state 1`] = ` exports[` renders stopped state 1`] = `
renders stopped state 1`] = ` className="mx_StyledLiveBeaconIcon mx_BeaconStatus_icon mx_StyledLiveBeaconIcon_idle" /> - - Live location ended - +
+ + Live location ended + +
`; diff --git a/test/components/views/beacon/__snapshots__/OwnBeaconStatus-test.tsx.snap b/test/components/views/beacon/__snapshots__/OwnBeaconStatus-test.tsx.snap new file mode 100644 index 00000000000..4d8b4e76605 --- /dev/null +++ b/test/components/views/beacon/__snapshots__/OwnBeaconStatus-test.tsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders without a beacon instance 1`] = ` + + +
+ +
+ +
+ + Loading live location... + +
+
+ + +`;