Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Live location share - focus on user location on list item click (PSG-609) #9051

Merged
merged 9 commits into from
Jul 18, 2022
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions res/css/components/views/beacon/_BeaconListItem.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ limitations under the License.
padding: $spacing-12 0;

border-bottom: 1px solid $system;

cursor: pointer;
}

.mx_BeaconListItem_avatarIcon {
Expand Down Expand Up @@ -61,3 +63,8 @@ limitations under the License.
color: $tertiary-content;
font-size: $font-10px;
}

.mx_BeaconListItem_interactions {
display: flex;
flex-direction: row;
}
13 changes: 9 additions & 4 deletions src/components/views/beacon/BeaconListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import React, { useContext } from 'react';
import React, { HTMLProps, 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 { preventDefaultWrapper } from '../../../utils/NativeEventUtils';
import { _t } from '../../../languageHandler';
import MemberAvatar from '../avatars/MemberAvatar';
import BeaconStatus from './BeaconStatus';
Expand All @@ -32,7 +33,7 @@ interface Props {
beacon: Beacon;
}

const BeaconListItem: React.FC<Props> = ({ beacon }) => {
const BeaconListItem: React.FC<Props & HTMLProps<HTMLLIElement>> = ({ beacon, ...rest }) => {
const latestLocationState = useEventEmitterState(
beacon,
BeaconEvent.LocationUpdate,
Expand All @@ -52,7 +53,7 @@ const BeaconListItem: React.FC<Props> = ({ beacon }) => {

const humanizedUpdateTime = humanizeTime(latestLocationState.timestamp);

return <li className='mx_BeaconListItem'>
return <li className='mx_BeaconListItem' {...rest}>
{ isSelfLocation ?
<MemberAvatar
className='mx_BeaconListItem_avatar'
Expand All @@ -69,7 +70,11 @@ const BeaconListItem: React.FC<Props> = ({ beacon }) => {
label={beaconMember?.name || beacon.beaconInfo.description || beacon.beaconInfoOwner}
displayStatus={BeaconDisplayStatus.Active}
>
<ShareLatestLocation latestLocationState={latestLocationState} />
{ /* eat events from interactive share buttons
so parent click handlers are not triggered */ }
<div className='mx_BeaconListItem_interactions' onClick={preventDefaultWrapper(() => {})}>
<ShareLatestLocation latestLocationState={latestLocationState} />
</div>
</BeaconStatus>
<span className='mx_BeaconListItem_lastUpdated'>{ _t("Updated %(humanizedUpdateTime)s", { humanizedUpdateTime }) }</span>
</div>
Expand Down
56 changes: 45 additions & 11 deletions src/components/views/beacon/BeaconViewDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import {
Beacon,
Expand Down Expand Up @@ -45,7 +45,16 @@ interface IProps extends IDialogProps {
roomId: Room['roomId'];
matrixClient: MatrixClient;
// open the map centered on this beacon's location
focusBeacon?: Beacon;
initialFocusedBeacon?: Beacon;
}

// track the 'focused time' as ts
// to make it possible to refocus the same beacon
// as the beacon location may change
// or the map may move around
interface FocusedBeaconState {
ts: number;
beacon?: Beacon;
}

const getBoundsCenter = (bounds: Bounds): string | undefined => {
Expand All @@ -59,31 +68,52 @@ const getBoundsCenter = (bounds: Bounds): string | undefined => {
});
};

const useInitialMapPosition = (liveBeacons: Beacon[], focusBeacon?: Beacon): {
const useInitialMapPosition = (liveBeacons: Beacon[], { beacon, ts }: FocusedBeaconState): {
bounds?: Bounds; centerGeoUri: string;
} => {
const bounds = useRef<Bounds | undefined>(getBeaconBounds(liveBeacons));
const centerGeoUri = useRef<string>(
focusBeacon?.latestLocationState?.uri ||
getBoundsCenter(bounds.current),
const [bounds, setBounds] = useState<Bounds | undefined>(getBeaconBounds(liveBeacons));
const [centerGeoUri, setCenterGeoUri] = useState<string>(
beacon?.latestLocationState?.uri ||
getBoundsCenter(bounds),
);
return { bounds: bounds.current, centerGeoUri: centerGeoUri.current };

useEffect(() => {
if (
// this check ignores the first initial focused beacon state
// as centering logic on map zooms to show everything
// instead of focusing down
ts !== 0 &&
// only set focus to a known location
beacon?.latestLocationState?.uri
) {
// append custom `mxTs` parameter to geoUri
// so map is triggered to refocus on this uri
// event if it was previously the center geouri
// but the map have moved/zoomed
setCenterGeoUri(`${beacon?.latestLocationState?.uri};mxTs=${Date.now()}`);
setBounds(getBeaconBounds([beacon]));
}
}, [beacon, ts]);

return { bounds, centerGeoUri };
};

/**
* Dialog to view live beacons maximised
*/
const BeaconViewDialog: React.FC<IProps> = ({
focusBeacon,
initialFocusedBeacon,
roomId,
matrixClient,
onFinished,
}) => {
const liveBeacons = useLiveBeacons(roomId, matrixClient);
const [focusedBeaconState, setFocusedBeaconState] =
useState<FocusedBeaconState>({ beacon: initialFocusedBeacon, ts: 0 });

const [isSidebarOpen, setSidebarOpen] = useState(false);

const { bounds, centerGeoUri } = useInitialMapPosition(liveBeacons, focusBeacon);
const { bounds, centerGeoUri } = useInitialMapPosition(liveBeacons, focusedBeaconState);

const [mapDisplayError, setMapDisplayError] = useState<Error>();

Expand All @@ -94,6 +124,10 @@ const BeaconViewDialog: React.FC<IProps> = ({
}
}, [mapDisplayError]);

const onBeaconListItemClick = (beacon: Beacon) => {
setFocusedBeaconState({ beacon, ts: Date.now() });
};

return (
<BaseDialog
className='mx_BeaconViewDialog'
Expand Down Expand Up @@ -144,7 +178,7 @@ const BeaconViewDialog: React.FC<IProps> = ({
</MapFallback>
}
{ isSidebarOpen ?
<DialogSidebar beacons={liveBeacons} requestClose={() => setSidebarOpen(false)} /> :
<DialogSidebar beacons={liveBeacons} onBeaconClick={onBeaconListItemClick} requestClose={() => setSidebarOpen(false)} /> :
<AccessibleButton
kind='primary'
onClick={() => setSidebarOpen(true)}
Expand Down
15 changes: 12 additions & 3 deletions src/components/views/beacon/DialogSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,32 @@ import BeaconListItem from './BeaconListItem';
interface Props {
beacons: Beacon[];
requestClose: () => void;
onBeaconClick: (beacon: Beacon) => void;
}

const DialogSidebar: React.FC<Props> = ({ beacons, requestClose }) => {
const DialogSidebar: React.FC<Props> = ({
beacons,
onBeaconClick,
requestClose,
}) => {
return <div className='mx_DialogSidebar'>
<div className='mx_DialogSidebar_header'>
<Heading size='h4'>{ _t('View List') }</Heading>
<AccessibleButton
className='mx_DialogSidebar_closeButton'
onClick={requestClose}
title={_t('Close sidebar')}
data-test-id='dialog-sidebar-close'
data-testid='dialog-sidebar-close'
>
<CloseIcon className='mx_DialogSidebar_closeButtonIcon' />
</AccessibleButton>
</div>
<ol className='mx_DialogSidebar_list'>
{ beacons.map((beacon) => <BeaconListItem key={beacon.identifier} beacon={beacon} />) }
{ beacons.map((beacon) => <BeaconListItem
key={beacon.identifier}
beacon={beacon}
onClick={() => onBeaconClick(beacon)}
/>) }
</ol>
</div>;
};
Expand Down
17 changes: 8 additions & 9 deletions src/components/views/beacon/OwnBeaconStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import React, { HTMLProps } from 'react';

import { _t } from '../../../languageHandler';
import { useOwnLiveBeacons } from '../../../utils/beacon';
import { preventDefaultWrapper } from '../../../utils/NativeEventUtils';
import BeaconStatus from './BeaconStatus';
import { BeaconDisplayStatus } from './displayStatus';
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
Expand All @@ -45,14 +46,6 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
onResetLocationPublishError,
} = useOwnLiveBeacons([beacon?.identifier]);

// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
const preventDefaultWrapper = (callback: () => void) => (e?: ButtonEvent) => {
e?.stopPropagation();
e?.preventDefault();
callback();
};

// combine display status with errors that only occur for user's own beacons
const ownDisplayStatus = hasLocationPublishError || hasStopSharingError ?
BeaconDisplayStatus.Error :
Expand All @@ -68,7 +61,9 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
{ ownDisplayStatus === BeaconDisplayStatus.Active && <AccessibleButton
data-test-id='beacon-status-stop-beacon'
kind='link'
onClick={preventDefaultWrapper(onStopSharing)}
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper<ButtonEvent>(onStopSharing)}
className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton'
disabled={stoppingInProgress}
>
Expand All @@ -78,6 +73,8 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
{ hasLocationPublishError && <AccessibleButton
data-test-id='beacon-status-reset-wire-error'
kind='link'
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper(onResetLocationPublishError)}
className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton'
>
Expand All @@ -87,6 +84,8 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
{ hasStopSharingError && <AccessibleButton
data-test-id='beacon-status-stop-beacon-retry'
kind='link'
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper(onStopSharing)}
className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton'
>
Expand Down
7 changes: 7 additions & 0 deletions src/components/views/location/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ const useMapWithStyle = ({ id, centerGeoUri, onError, interactive, bounds }) =>
interface MapProps {
id: string;
interactive?: boolean;
/**
* set map center to geoUri coords
* Center will only be set to valid geoUri
* this prop is only simply diffed by useEffect, so to trigger *recentering* of the same geoUri
* append the uri with a var not used by the geoUri spec
* eg a timestamp: `geo:54,42;mxTs=123`
*/
centerGeoUri?: string;
bounds?: Bounds;
className?: string;
Expand Down
2 changes: 1 addition & 1 deletion src/components/views/messages/MBeaconBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ const MBeaconBody: React.FC<IBodyProps> = React.forwardRef(({ mxEvent, getRelati
{
roomId: mxEvent.getRoomId(),
matrixClient,
focusBeacon: beacon,
initialFocusedBeacon: beacon,
isMapDisplayError,
},
"mx_BeaconViewDialog_wrapper",
Expand Down
25 changes: 25 additions & 0 deletions src/utils/NativeEventUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
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";

// Wrap DOM event handlers with stopPropagation and preventDefault
export const preventDefaultWrapper =
<T extends React.BaseSyntheticEvent = React.BaseSyntheticEvent>(callback: () => void) => (e?: T) => {
e?.stopPropagation();
e?.preventDefault();
callback();
};
2 changes: 1 addition & 1 deletion src/utils/location/useMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const useMap = ({
interactive,
bodyId,
onError,
}: UseMapProps): MapLibreMap => {
}: UseMapProps): MapLibreMap | undefined => {
const [map, setMap] = useState<MapLibreMap>();

useEffect(
Expand Down
26 changes: 26 additions & 0 deletions test/components/views/beacon/BeaconListItem-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { act } from 'react-dom/test-utils';
import BeaconListItem from '../../../../src/components/views/beacon/BeaconListItem';
import MatrixClientContext from '../../../../src/contexts/MatrixClientContext';
import {
findByTestId,
getMockClientWithEventEmitter,
makeBeaconEvent,
makeBeaconInfoEvent,
Expand Down Expand Up @@ -169,5 +170,30 @@ describe('<BeaconListItem />', () => {
expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated a few seconds ago');
});
});

describe('interactions', () => {
it('does not call onClick handler when clicking share button', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
const onClick = jest.fn();
const component = getComponent({ beacon, onClick });

act(() => {
findByTestId(component, 'open-location-in-osm').at(0).simulate('click');
});
expect(onClick).not.toHaveBeenCalled();
});

it('calls onClick handler when clicking outside of share buttons', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
const onClick = jest.fn();
const component = getComponent({ beacon, onClick });

act(() => {
// click the beacon name
component.find('.mx_BeaconStatus_description').simulate('click');
});
expect(onClick).toHaveBeenCalled();
});
});
});
});
Loading