diff --git a/res/css/_components.scss b/res/css/_components.scss index aa92057e63..7032c35f39 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -4,6 +4,7 @@ @import "./_font-sizes.scss"; @import "./_font-weights.scss"; @import "./_spacing.scss"; +@import "./components/views/beacon/_BeaconListItem.scss"; @import "./components/views/beacon/_BeaconStatus.scss"; @import "./components/views/beacon/_BeaconViewDialog.scss"; @import "./components/views/beacon/_DialogSidebar.scss"; diff --git a/res/css/components/views/beacon/_BeaconListItem.scss b/res/css/components/views/beacon/_BeaconListItem.scss new file mode 100644 index 0000000000..60311a4466 --- /dev/null +++ b/res/css/components/views/beacon/_BeaconListItem.scss @@ -0,0 +1,61 @@ +/* +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_BeaconListItem { + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: flex-start; + padding: $spacing-12 0; + + border-bottom: 1px solid $system; +} + +.mx_BeaconListItem_avatarIcon { + flex: 0 0; + height: 32px; + width: 32px; +} + +.mx_BeaconListItem_avatar { + flex: 0 0; + box-sizing: border-box; + + margin-right: $spacing-8; + border: 2px solid $location-live-color; +} + +.mx_BeaconListItem_info { + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.mx_BeaconListItem_status { + // override beacon status padding + padding: 0 !important; + margin-bottom: $spacing-8; + + .mx_BeaconStatus_label { + font-weight: $font-semi-bold; + } +} + +.mx_BeaconListItem_lastUpdated { + color: $tertiary-content; + font-size: $font-10px; +} diff --git a/res/css/components/views/beacon/_BeaconStatus.scss b/res/css/components/views/beacon/_BeaconStatus.scss index 8ac873604d..4dd3d32547 100644 --- a/res/css/components/views/beacon/_BeaconStatus.scss +++ b/res/css/components/views/beacon/_BeaconStatus.scss @@ -59,3 +59,7 @@ limitations under the License. .mx_BeaconStatus_expiryTime { color: $secondary-content; } + +.mx_BeaconStatus_label { + margin-bottom: 2px; +} diff --git a/res/css/components/views/beacon/_DialogSidebar.scss b/res/css/components/views/beacon/_DialogSidebar.scss index 02d0e82cc3..1989b57c30 100644 --- a/res/css/components/views/beacon/_DialogSidebar.scss +++ b/res/css/components/views/beacon/_DialogSidebar.scss @@ -21,6 +21,9 @@ limitations under the License. height: 100%; width: 265px; + display: flex; + flex-direction: column; + box-sizing: border-box; padding: $spacing-16; @@ -34,7 +37,7 @@ limitations under the License. align-items: center; justify-content: space-between; - flex: 0; + flex: 0 0; margin-bottom: $spacing-16; color: $primary-content; diff --git a/src/components/views/beacon/BeaconListItem.tsx b/src/components/views/beacon/BeaconListItem.tsx new file mode 100644 index 0000000000..eda1580700 --- /dev/null +++ b/src/components/views/beacon/BeaconListItem.tsx @@ -0,0 +1,82 @@ +/* +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, { useContext } from 'react'; +import { Beacon, BeaconEvent } from 'matrix-js-sdk/src/matrix'; +import { LocationAssetType } from 'matrix-js-sdk/src/@types/location'; + +import MatrixClientContext from '../../../contexts/MatrixClientContext'; +import { useEventEmitterState } from '../../../hooks/useEventEmitter'; +import { humanizeTime } from '../../../utils/humanize'; +import { _t } from '../../../languageHandler'; +import MemberAvatar from '../avatars/MemberAvatar'; +import CopyableText from '../elements/CopyableText'; +import BeaconStatus from './BeaconStatus'; +import { BeaconDisplayStatus } from './displayStatus'; +import StyledLiveBeaconIcon from './StyledLiveBeaconIcon'; + +interface Props { + beacon: Beacon; +} + +const BeaconListItem: React.FC = ({ beacon }) => { + const latestLocationState = useEventEmitterState( + beacon, + BeaconEvent.LocationUpdate, + () => beacon.latestLocationState, + ); + const matrixClient = useContext(MatrixClientContext); + const room = matrixClient.getRoom(beacon.roomId); + + if (!latestLocationState || !beacon.isLive) { + return null; + } + + const isSelfLocation = beacon.beaconInfo.assetType === LocationAssetType.Self; + const beaconMember = isSelfLocation ? + room.getMember(beacon.beaconInfoOwner) : + undefined; + + const humanizedUpdateTime = humanizeTime(latestLocationState.timestamp); + + return
  • + { isSelfLocation ? + : + + } +
    + + latestLocationState?.uri} + /> + + { _t("Updated %(humanizedUpdateTime)s", { humanizedUpdateTime }) } +
    +
  • ; +}; + +export default BeaconListItem; diff --git a/src/components/views/beacon/BeaconStatus.tsx b/src/components/views/beacon/BeaconStatus.tsx index c9d7bd3762..935e22f4f0 100644 --- a/src/components/views/beacon/BeaconStatus.tsx +++ b/src/components/views/beacon/BeaconStatus.tsx @@ -28,6 +28,7 @@ import { formatTime } from '../../../DateUtils'; interface Props { displayStatus: BeaconDisplayStatus; displayLiveTimeRemaining?: boolean; + withIcon?: boolean; beacon?: Beacon; label?: string; } @@ -45,6 +46,7 @@ const BeaconStatus: React.FC> = label, className, children, + withIcon, ...rest }) => { const isIdle = displayStatus === BeaconDisplayStatus.Loading || @@ -54,11 +56,11 @@ const BeaconStatus: React.FC> = {...rest} className={classNames('mx_BeaconStatus', `mx_BeaconStatus_${displayStatus}`, className)} > - + /> }
    { displayStatus === BeaconDisplayStatus.Loading && { _t('Loading live location...') } } @@ -68,7 +70,7 @@ const BeaconStatus: React.FC> = { displayStatus === BeaconDisplayStatus.Active && beacon && <> <> - { label } + { label } { displayLiveTimeRemaining ? : diff --git a/src/components/views/beacon/DialogSidebar.tsx b/src/components/views/beacon/DialogSidebar.tsx index fac91c77cb..4365b5fa8b 100644 --- a/src/components/views/beacon/DialogSidebar.tsx +++ b/src/components/views/beacon/DialogSidebar.tsx @@ -21,6 +21,7 @@ import { Icon as CloseIcon } from '../../../../res/img/image-view/close.svg'; import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; import Heading from '../typography/Heading'; +import BeaconListItem from './BeaconListItem'; interface Props { beacons: Beacon[]; @@ -41,8 +42,7 @@ const DialogSidebar: React.FC = ({ beacons, requestClose }) => {
      - { /* TODO nice elements */ } - { beacons.map((beacon, index) =>
    1. { index }
    2. ) } + { beacons.map((beacon) => ) }
    ; }; diff --git a/src/components/views/beacon/OwnBeaconStatus.tsx b/src/components/views/beacon/OwnBeaconStatus.tsx index 204e296829..0a682b1164 100644 --- a/src/components/views/beacon/OwnBeaconStatus.tsx +++ b/src/components/views/beacon/OwnBeaconStatus.tsx @@ -54,6 +54,7 @@ const OwnBeaconStatus: React.FC> = ({ displayStatus={ownDisplayStatus} label={_t('Live location enabled')} displayLiveTimeRemaining + withIcon {...rest} > { ownDisplayStatus === BeaconDisplayStatus.Active && string; border?: boolean; } diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx index 4beac79101..bd7e10f044 100644 --- a/src/components/views/messages/MBeaconBody.tsx +++ b/src/components/views/messages/MBeaconBody.tsx @@ -152,6 +152,7 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) => beacon={beacon} displayStatus={displayStatus} label={_t('View live location')} + withIcon /> } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2467440879..c6a66c7967 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2913,6 +2913,7 @@ "Click for more info": "Click for more info", "Beta": "Beta", "Join the beta": "Join the beta", + "Updated %(humanizedUpdateTime)s": "Updated %(humanizedUpdateTime)s", "Live until %(expiryTime)s": "Live until %(expiryTime)s", "Loading live location...": "Loading live location...", "Live location ended": "Live location ended", diff --git a/src/utils/humanize.ts b/src/utils/humanize.ts index 978d17424b..47e2d83e8a 100644 --- a/src/utils/humanize.ts +++ b/src/utils/humanize.ts @@ -30,7 +30,7 @@ const HOURS_1_DAY = 26; * @returns {string} The humanized time. */ export function humanizeTime(timeMillis: number): string { - const now = (new Date()).getTime(); + const now = Date.now(); let msAgo = now - timeMillis; const minutes = Math.abs(Math.ceil(msAgo / 60000)); const hours = Math.ceil(minutes / 60); diff --git a/test/components/views/beacon/BeaconListItem-test.tsx b/test/components/views/beacon/BeaconListItem-test.tsx new file mode 100644 index 0000000000..e7e9fbb726 --- /dev/null +++ b/test/components/views/beacon/BeaconListItem-test.tsx @@ -0,0 +1,173 @@ +/* +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 { + Beacon, + RoomMember, + MatrixEvent, +} from 'matrix-js-sdk/src/matrix'; +import { LocationAssetType } from 'matrix-js-sdk/src/@types/location'; +import { act } from 'react-dom/test-utils'; + +import BeaconListItem from '../../../../src/components/views/beacon/BeaconListItem'; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; +import { + getMockClientWithEventEmitter, + makeBeaconEvent, + makeBeaconInfoEvent, + makeRoomWithBeacons, +} from '../../../test-utils'; + +describe('', () => { + // 14.03.2022 16:15 + const now = 1647270879403; + // go back in time to create beacons and locations in the past + jest.spyOn(global.Date, 'now').mockReturnValue(now - 600000); + const roomId = '!room:server'; + const aliceId = '@alice:server'; + + const mockClient = getMockClientWithEventEmitter({ + getUserId: jest.fn().mockReturnValue(aliceId), + getRoom: jest.fn(), + isGuest: jest.fn().mockReturnValue(false), + }); + + const aliceBeaconEvent = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: true }, + '$alice-room1-1', + ); + const alicePinBeaconEvent = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: true, assetType: LocationAssetType.Pin, description: "Alice's car" }, + '$alice-room1-1', + ); + const pinBeaconWithoutDescription = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: true, assetType: LocationAssetType.Pin }, + '$alice-room1-1', + ); + + const aliceLocation1 = makeBeaconEvent( + aliceId, { beaconInfoId: aliceBeaconEvent.getId(), geoUri: 'geo:51,41', timestamp: now - 1 }, + ); + const aliceLocation2 = makeBeaconEvent( + aliceId, { beaconInfoId: aliceBeaconEvent.getId(), geoUri: 'geo:52,42', timestamp: now - 500000 }, + ); + + const defaultProps = { + beacon: new Beacon(aliceBeaconEvent), + }; + + const getComponent = (props = {}) => + mount(, { + wrappingComponent: MatrixClientContext.Provider, + wrappingComponentProps: { value: mockClient }, + }); + + const setupRoomWithBeacons = (beaconInfoEvents: MatrixEvent[], locationEvents?: MatrixEvent[]): Beacon[] => { + const beacons = makeRoomWithBeacons(roomId, mockClient, beaconInfoEvents, locationEvents); + + const member = new RoomMember(roomId, aliceId); + member.name = `Alice`; + const room = mockClient.getRoom(roomId); + jest.spyOn(room, 'getMember').mockReturnValue(member); + + return beacons; + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Date, 'now').mockReturnValue(now); + }); + + it('renders null when beacon is not live', () => { + const notLiveBeacon = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: false }, + ); + const [beacon] = setupRoomWithBeacons([notLiveBeacon]); + const component = getComponent({ beacon }); + expect(component.html()).toBeNull(); + }); + + it('renders null when beacon has no location', () => { + const [beacon] = setupRoomWithBeacons([aliceBeaconEvent]); + const component = getComponent({ beacon }); + expect(component.html()).toBeNull(); + }); + + describe('when a beacon is live and has locations', () => { + it('renders beacon info', () => { + const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); + const component = getComponent({ beacon }); + expect(component.html()).toMatchSnapshot(); + }); + + describe('non-self beacons', () => { + it('uses beacon description as beacon name', () => { + const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); + const component = getComponent({ beacon }); + expect(component.find('BeaconStatus').props().label).toEqual("Alice's car"); + }); + + it('uses beacon owner mxid as beacon name for a beacon without description', () => { + const [beacon] = setupRoomWithBeacons([pinBeaconWithoutDescription], [aliceLocation1]); + const component = getComponent({ beacon }); + expect(component.find('BeaconStatus').props().label).toEqual(aliceId); + }); + + it('renders location icon', () => { + const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); + const component = getComponent({ beacon }); + expect(component.find('StyledLiveBeaconIcon').length).toBeTruthy(); + }); + }); + + describe('self locations', () => { + it('renders beacon owner avatar', () => { + const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation1]); + const component = getComponent({ beacon }); + expect(component.find('MemberAvatar').length).toBeTruthy(); + }); + + it('uses beacon owner name as beacon name', () => { + const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation1]); + const component = getComponent({ beacon }); + expect(component.find('BeaconStatus').props().label).toEqual('Alice'); + }); + }); + + describe('on location updates', () => { + it('updates last updated time on location updated', () => { + const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation2]); + const component = getComponent({ beacon }); + + expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated 9 minutes ago'); + + // update to a newer location + act(() => { + beacon.addLocations([aliceLocation1]); + component.setProps({}); + }); + + expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated a few seconds ago'); + }); + }); + }); +}); diff --git a/test/components/views/beacon/BeaconStatus-test.tsx b/test/components/views/beacon/BeaconStatus-test.tsx index db4153defa..68a6a34a30 100644 --- a/test/components/views/beacon/BeaconStatus-test.tsx +++ b/test/components/views/beacon/BeaconStatus-test.tsx @@ -26,6 +26,7 @@ describe('', () => { const defaultProps = { displayStatus: BeaconDisplayStatus.Loading, label: 'test label', + withIcon: true, }; const getComponent = (props = {}) => mount(); @@ -40,6 +41,11 @@ describe('', () => { expect(component).toMatchSnapshot(); }); + it('renders without icon', () => { + const component = getComponent({ withIcon: false, displayStatus: BeaconDisplayStatus.Stopped }); + expect(component.find('StyledLiveBeaconIcon').length).toBeFalsy(); + }); + describe('active state', () => { it('renders without children', () => { // mock for stable snapshot diff --git a/test/components/views/beacon/DialogSidebar-test.tsx b/test/components/views/beacon/DialogSidebar-test.tsx index 4c6fe8ad05..a5a1f0e5e7 100644 --- a/test/components/views/beacon/DialogSidebar-test.tsx +++ b/test/components/views/beacon/DialogSidebar-test.tsx @@ -41,7 +41,6 @@ describe('', () => { act(() => { findByTestId(component, 'dialog-sidebar-close').at(0).simulate('click'); }); - expect(requestClose).toHaveBeenCalled(); }); }); diff --git a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap new file mode 100644 index 0000000000..1518a60dba --- /dev/null +++ b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` when a beacon is live and has locations renders beacon info 1`] = `"
  • Alice's carLive until 16:04
    Updated a few seconds ago
  • "`; diff --git a/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap index 5e2b6673da..b3366336a1 100644 --- a/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap @@ -43,6 +43,7 @@ exports[` active state renders without children 1`] = ` } displayStatus="Active" label="test label" + withIcon={true} >
    active state renders without children 1`] = `
    - test label + + test label + renders loading state 1`] = `
    renders stopped state 1`] = `
    renders without a beacon instance 1`] = ` displayLiveTimeRemaining={true} displayStatus="Loading" label="Live location enabled" + withIcon={true} >