Live location sharing: beacon list view tiles (#8363)

* add basic sidebar container

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

* optionally show icon in beaconstatus

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

* add avatar and style list item

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

* formatted last update time

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

* test beacon list item

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

* move makeRoomWithState events to test utils

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

* move beacon test helpers into utils

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

* newline

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

* add copyable text to beacon list item

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

* add copyable geo uri to list item

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

* improve spacing

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

* overflow scroll on list

Signed-off-by: Kerry Archibald <kerrya@element.io>
pull/28788/head^2
Kerry 2022-04-20 13:57:50 +02:00 committed by GitHub
parent 2f6b76755c
commit 4a38cbd550
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 355 additions and 10 deletions

View File

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

View File

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

View File

@ -59,3 +59,7 @@ limitations under the License.
.mx_BeaconStatus_expiryTime {
color: $secondary-content;
}
.mx_BeaconStatus_label {
margin-bottom: 2px;
}

View File

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

View File

@ -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<Props> = ({ 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 <li className='mx_BeaconListItem'>
{ isSelfLocation ?
<MemberAvatar
className='mx_BeaconListItem_avatar'
member={beaconMember}
height={32}
width={32}
/> :
<StyledLiveBeaconIcon className='mx_BeaconListItem_avatarIcon' />
}
<div className='mx_BeaconListItem_info'>
<BeaconStatus
className='mx_BeaconListItem_status'
beacon={beacon}
label={beaconMember?.name || beacon.beaconInfo.description || beacon.beaconInfoOwner}
displayStatus={BeaconDisplayStatus.Active}
>
<CopyableText
border={false}
getTextToCopy={() => latestLocationState?.uri}
/>
</BeaconStatus>
<span className='mx_BeaconListItem_lastUpdated'>{ _t("Updated %(humanizedUpdateTime)s", { humanizedUpdateTime }) }</span>
</div>
</li>;
};
export default BeaconListItem;

View File

@ -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<Props & HTMLProps<HTMLDivElement>> =
label,
className,
children,
withIcon,
...rest
}) => {
const isIdle = displayStatus === BeaconDisplayStatus.Loading ||
@ -54,11 +56,11 @@ const BeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> =
{...rest}
className={classNames('mx_BeaconStatus', `mx_BeaconStatus_${displayStatus}`, className)}
>
<StyledLiveBeaconIcon
{ withIcon && <StyledLiveBeaconIcon
className='mx_BeaconStatus_icon'
withError={displayStatus === BeaconDisplayStatus.Error}
isIdle={isIdle}
/>
/> }
<div className='mx_BeaconStatus_description'>
{ displayStatus === BeaconDisplayStatus.Loading && <span>{ _t('Loading live location...') }</span> }
@ -68,7 +70,7 @@ const BeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> =
{ displayStatus === BeaconDisplayStatus.Active && beacon && <>
<>
{ label }
<span className='mx_BeaconStatus_label'>{ label }</span>
{ displayLiveTimeRemaining ?
<LiveTimeRemaining beacon={beacon} /> :
<BeaconExpiryTime beacon={beacon} />

View File

@ -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<Props> = ({ beacons, requestClose }) => {
</AccessibleButton>
</div>
<ol className='mx_DialogSidebar_list'>
{ /* TODO nice elements */ }
{ beacons.map((beacon, index) => <li key={beacon.identifier}>{ index }</li>) }
{ beacons.map((beacon) => <BeaconListItem key={beacon.identifier} beacon={beacon} />) }
</ol>
</div>;
};

View File

@ -54,6 +54,7 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
displayStatus={ownDisplayStatus}
label={_t('Live location enabled')}
displayLiveTimeRemaining
withIcon
{...rest}
>
{ ownDisplayStatus === BeaconDisplayStatus.Active && <AccessibleButton

View File

@ -24,7 +24,7 @@ import { ButtonEvent } from "./AccessibleButton";
import AccessibleTooltipButton from "./AccessibleTooltipButton";
interface IProps {
children: React.ReactNode;
children?: React.ReactNode;
getTextToCopy: () => string;
border?: boolean;
}

View File

@ -152,6 +152,7 @@ const MBeaconBody: React.FC<IBodyProps> = React.forwardRef(({ mxEvent }, ref) =>
beacon={beacon}
displayStatus={displayStatus}
label={_t('View live location')}
withIcon
/>
}
</div>

View File

@ -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",

View File

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

View File

@ -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('<BeaconListItem />', () => {
// 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(<BeaconListItem {...defaultProps} {...props} />, {
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');
});
});
});
});

View File

@ -26,6 +26,7 @@ describe('<BeaconStatus />', () => {
const defaultProps = {
displayStatus: BeaconDisplayStatus.Loading,
label: 'test label',
withIcon: true,
};
const getComponent = (props = {}) =>
mount(<BeaconStatus {...defaultProps} {...props} />);
@ -40,6 +41,11 @@ describe('<BeaconStatus />', () => {
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

View File

@ -41,7 +41,6 @@ describe('<DialogSidebar />', () => {
act(() => {
findByTestId(component, 'dialog-sidebar-close').at(0).simulate('click');
});
expect(requestClose).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<BeaconListItem /> when a beacon is live and has locations renders beacon info 1`] = `"<li class=\\"mx_BeaconListItem\\"><div class=\\"mx_StyledLiveBeaconIcon mx_BeaconListItem_avatarIcon\\"></div><div class=\\"mx_BeaconListItem_info\\"><div class=\\"mx_BeaconStatus mx_BeaconStatus_Active mx_BeaconListItem_status\\"><div class=\\"mx_BeaconStatus_description\\"><span class=\\"mx_BeaconStatus_label\\">Alice's car</span><span class=\\"mx_BeaconStatus_expiryTime\\">Live until 16:04</span></div><div class=\\"mx_CopyableText\\"><div aria-label=\\"Copy\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_CopyableText_copyButton\\"></div></div></div><span class=\\"mx_BeaconListItem_lastUpdated\\">Updated a few seconds ago</span></div></li>"`;

View File

@ -43,6 +43,7 @@ exports[`<BeaconStatus /> active state renders without children 1`] = `
}
displayStatus="Active"
label="test label"
withIcon={true}
>
<div
className="mx_BeaconStatus mx_BeaconStatus_Active"
@ -59,7 +60,11 @@ exports[`<BeaconStatus /> active state renders without children 1`] = `
<div
className="mx_BeaconStatus_description"
>
test label
<span
className="mx_BeaconStatus_label"
>
test label
</span>
<BeaconExpiryTime
beacon={
Beacon {
@ -114,6 +119,7 @@ exports[`<BeaconStatus /> renders loading state 1`] = `
<BeaconStatus
displayStatus="Loading"
label="test label"
withIcon={true}
>
<div
className="mx_BeaconStatus mx_BeaconStatus_Loading"
@ -142,6 +148,7 @@ exports[`<BeaconStatus /> renders stopped state 1`] = `
<BeaconStatus
displayStatus="Stopped"
label="test label"
withIcon={true}
>
<div
className="mx_BeaconStatus mx_BeaconStatus_Stopped"

View File

@ -9,6 +9,7 @@ exports[`<OwnBeaconStatus /> renders without a beacon instance 1`] = `
displayLiveTimeRemaining={true}
displayStatus="Loading"
label="Live location enabled"
withIcon={true}
>
<div
className="mx_BeaconStatus mx_BeaconStatus_Loading mx_MBeaconBody_chin"