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

Commit cdcf6d0

Browse files
author
Kerry
authored
Live location sharing: create beacon info event from location picker (#8072)
* create beacon info event with defaulted duration Signed-off-by: Kerry Archibald <[email protected]> * add shareLiveLocation fn Signed-off-by: Kerry Archibald <[email protected]> * test share live location Signed-off-by: Kerry Archibald <[email protected]> * i18n Signed-off-by: Kerry Archibald <[email protected]>
1 parent 4e4ce65 commit cdcf6d0

File tree

5 files changed

+152
-36
lines changed

5 files changed

+152
-36
lines changed

src/components/views/location/LocationPicker.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import Modal from '../../../Modal';
2929
import ErrorDialog from '../dialogs/ErrorDialog';
3030
import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils';
3131
import { findMapStyleUrl } from './findMapStyleUrl';
32-
import { LocationShareType } from './shareLocation';
32+
import { LocationShareType, ShareLocationFn } from './shareLocation';
3333
import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg';
3434
import { LocationShareError } from './LocationShareErrors';
3535
import AccessibleButton from '../elements/AccessibleButton';
@@ -38,7 +38,7 @@ import { getUserNameColorClass } from '../../../utils/FormattingUtils';
3838
export interface ILocationPickerProps {
3939
sender: RoomMember;
4040
shareType: LocationShareType;
41-
onChoose(uri: string, ts: number): unknown;
41+
onChoose: ShareLocationFn;
4242
onFinished(ev?: SyntheticEvent): void;
4343
}
4444

@@ -209,7 +209,7 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
209209
private onOk = () => {
210210
const position = this.state.position;
211211

212-
this.props.onChoose(position ? getGeoUri(position) : undefined, position?.timestamp);
212+
this.props.onChoose(position ? { uri: getGeoUri(position), timestamp: position.timestamp } : {});
213213
this.props.onFinished();
214214
};
215215

src/components/views/location/LocationShareMenu.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ import { IEventRelation } from 'matrix-js-sdk/src/models/event';
2121
import MatrixClientContext from '../../../contexts/MatrixClientContext';
2222
import ContextMenu, { AboveLeftOf } from '../../structures/ContextMenu';
2323
import LocationPicker, { ILocationPickerProps } from "./LocationPicker";
24-
import { shareLocation } from './shareLocation';
24+
import { shareLiveLocation, shareLocation } from './shareLocation';
2525
import SettingsStore from '../../../settings/SettingsStore';
2626
import ShareDialogButtons from './ShareDialogButtons';
2727
import ShareType from './ShareType';
2828
import { LocationShareType } from './shareLocation';
29+
import { OwnProfileStore } from '../../../stores/OwnProfileStore';
2930

3031
type Props = Omit<ILocationPickerProps, 'onChoose' | 'shareType'> & {
3132
onFinished: (ev?: SyntheticEvent) => void;
@@ -66,20 +67,27 @@ const LocationShareMenu: React.FC<Props> = ({
6667
multipleShareTypesEnabled ? undefined : LocationShareType.Own,
6768
);
6869

70+
const displayName = OwnProfileStore.instance.displayName;
71+
72+
const onLocationSubmit = shareType === LocationShareType.Live ?
73+
shareLiveLocation(matrixClient, roomId, displayName, openMenu) :
74+
shareLocation(matrixClient, roomId, shareType, relation, openMenu);
75+
6976
return <ContextMenu
7077
{...menuPosition}
7178
onFinished={onFinished}
7279
managed={false}
7380
>
7481
<div className="mx_LocationShareMenu">
75-
{ shareType ? <LocationPicker
76-
sender={sender}
77-
shareType={shareType}
78-
onChoose={shareLocation(matrixClient, roomId, shareType, relation, openMenu)}
79-
onFinished={onFinished}
80-
/>
81-
:
82-
<ShareType setShareType={setShareType} enabledShareTypes={enabledShareTypes} /> }
82+
{ shareType ?
83+
<LocationPicker
84+
sender={sender}
85+
shareType={shareType}
86+
onChoose={onLocationSubmit}
87+
onFinished={onFinished}
88+
/> :
89+
<ShareType setShareType={setShareType} enabledShareTypes={enabledShareTypes} />
90+
}
8391
<ShareDialogButtons displayBack={!!shareType && multipleShareTypesEnabled} onBack={() => setShareType(undefined)} onCancel={onFinished} />
8492
</div>
8593
</ContextMenu>;

src/components/views/location/shareLocation.ts

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ limitations under the License.
1515
*/
1616

1717
import { MatrixClient } from "matrix-js-sdk/src/client";
18-
import { makeLocationContent } from "matrix-js-sdk/src/content-helpers";
18+
import { makeLocationContent, makeBeaconInfoContent } from "matrix-js-sdk/src/content-helpers";
1919
import { logger } from "matrix-js-sdk/src/logger";
2020
import { IEventRelation } from "matrix-js-sdk/src/models/event";
2121
import { LocationAssetType } from "matrix-js-sdk/src/@types/location";
@@ -32,38 +32,78 @@ export enum LocationShareType {
3232
Live = 'Live'
3333
}
3434

35+
export type LocationShareProps = {
36+
timeout?: number;
37+
uri?: string;
38+
timestamp?: number;
39+
};
40+
41+
// default duration to 5min for now
42+
const DEFAULT_LIVE_DURATION = 300000;
43+
44+
export type ShareLocationFn = (props: LocationShareProps) => Promise<void>;
45+
46+
const handleShareError = (error: Error, openMenu: () => void, shareType: LocationShareType) => {
47+
const errorMessage = shareType === LocationShareType.Live ?
48+
"We couldn't start sharing your live location" :
49+
"We couldn't send your location";
50+
logger.error(errorMessage, error);
51+
const analyticsAction = errorMessage;
52+
const params = {
53+
title: _t("We couldn't send your location"),
54+
description: _t("%(brand)s could not send your location. Please try again later.", {
55+
brand: SdkConfig.get().brand,
56+
}),
57+
button: _t('Try again'),
58+
cancelButton: _t('Cancel'),
59+
onFinished: (tryAgain: boolean) => {
60+
if (tryAgain) {
61+
openMenu();
62+
}
63+
},
64+
};
65+
Modal.createTrackedDialog(analyticsAction, '', QuestionDialog, params);
66+
};
67+
68+
export const shareLiveLocation = (
69+
client: MatrixClient, roomId: string, displayName: string, openMenu: () => void,
70+
): ShareLocationFn => async ({ timeout }) => {
71+
const description = _t(`%(displayName)s's live location`, { displayName });
72+
try {
73+
await client.unstable_createLiveBeacon(
74+
roomId,
75+
makeBeaconInfoContent(
76+
timeout ?? DEFAULT_LIVE_DURATION,
77+
true, /* isLive */
78+
description,
79+
LocationAssetType.Self,
80+
),
81+
// use timestamp as unique suffix in interim
82+
`${Date.now()}`);
83+
} catch (error) {
84+
handleShareError(error, openMenu, LocationShareType.Live);
85+
}
86+
};
87+
3588
export const shareLocation = (
3689
client: MatrixClient,
3790
roomId: string,
3891
shareType: LocationShareType,
3992
relation: IEventRelation | undefined,
4093
openMenu: () => void,
41-
) => async (uri: string, ts: number) => {
42-
if (!uri) return false;
94+
): ShareLocationFn => async ({ uri, timestamp }) => {
95+
if (!uri) return;
4396
try {
4497
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
4598
const assetType = shareType === LocationShareType.Pin ? LocationAssetType.Pin : LocationAssetType.Self;
46-
await client.sendMessage(roomId, threadId, makeLocationContent(undefined, uri, ts, undefined, assetType));
47-
} catch (e) {
48-
logger.error("We couldn't send your location", e);
49-
50-
const analyticsAction = "We couldn't send your location";
51-
const params = {
52-
title: _t("We couldn't send your location"),
53-
description: _t("%(brand)s could not send your location. Please try again later.", {
54-
brand: SdkConfig.get().brand,
55-
}),
56-
button: _t('Try again'),
57-
cancelButton: _t('Cancel'),
58-
onFinished: (tryAgain: boolean) => {
59-
if (tryAgain) {
60-
openMenu();
61-
}
62-
},
63-
};
64-
Modal.createTrackedDialog(analyticsAction, '', QuestionDialog, params);
99+
await client.sendMessage(
100+
roomId,
101+
threadId,
102+
makeLocationContent(undefined, uri, timestamp, undefined, assetType),
103+
);
104+
} catch (error) {
105+
handleShareError(error, openMenu, shareType);
65106
}
66-
return true;
67107
};
68108

69109
export function textForLocation(

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2191,6 +2191,7 @@
21912191
"This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.": "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.",
21922192
"We couldn't send your location": "We couldn't send your location",
21932193
"%(brand)s could not send your location. Please try again later.": "%(brand)s could not send your location. Please try again later.",
2194+
"%(displayName)s's live location": "%(displayName)s's live location",
21942195
"My current location": "My current location",
21952196
"My live location": "My live location",
21962197
"Drop a Pin": "Drop a Pin",

test/components/views/location/LocationShareMenu-test.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
2020
import { MatrixClient } from 'matrix-js-sdk/src/client';
2121
import { mocked } from 'jest-mock';
2222
import { act } from 'react-dom/test-utils';
23+
import { M_BEACON_INFO } from 'matrix-js-sdk/src/@types/beacon';
2324
import { M_ASSET, LocationAssetType } from 'matrix-js-sdk/src/@types/location';
25+
import { logger } from 'matrix-js-sdk/src/logger';
2426

2527
import '../../../skinned-sdk';
2628
import LocationShareMenu from '../../../../src/components/views/location/LocationShareMenu';
@@ -29,7 +31,8 @@ import { ChevronFace } from '../../../../src/components/structures/ContextMenu';
2931
import SettingsStore from '../../../../src/settings/SettingsStore';
3032
import { MatrixClientPeg } from '../../../../src/MatrixClientPeg';
3133
import { LocationShareType } from '../../../../src/components/views/location/shareLocation';
32-
import { findByTagAndTestId } from '../../../test-utils';
34+
import { findByTagAndTestId, flushPromises } from '../../../test-utils';
35+
import Modal from '../../../../src/Modal';
3336

3437
jest.mock('../../../../src/components/views/location/findMapStyleUrl', () => ({
3538
findMapStyleUrl: jest.fn().mockReturnValue('test'),
@@ -49,6 +52,10 @@ jest.mock('../../../../src/stores/OwnProfileStore', () => ({
4952
},
5053
}));
5154

55+
jest.mock('../../../../src/Modal', () => ({
56+
createTrackedDialog: jest.fn(),
57+
}));
58+
5259
describe('<LocationShareMenu />', () => {
5360
const userId = '@ernie:server.org';
5461
const mockClient = {
@@ -60,6 +67,7 @@ describe('<LocationShareMenu />', () => {
6067
map_style_url: 'maps.com',
6168
}),
6269
sendMessage: jest.fn(),
70+
unstable_createLiveBeacon: jest.fn().mockResolvedValue({}),
6371
};
6472

6573
const defaultProps = {
@@ -90,9 +98,12 @@ describe('<LocationShareMenu />', () => {
9098
});
9199

92100
beforeEach(() => {
101+
jest.spyOn(logger, 'error').mockRestore();
93102
mocked(SettingsStore).getValue.mockReturnValue(false);
94103
mockClient.sendMessage.mockClear();
104+
mockClient.unstable_createLiveBeacon.mockClear().mockResolvedValue(undefined);
95105
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient as unknown as MatrixClient);
106+
mocked(Modal).createTrackedDialog.mockClear();
96107
});
97108

98109
const getShareTypeOption = (component: ReactWrapper, shareType: LocationShareType) =>
@@ -281,6 +292,62 @@ describe('<LocationShareMenu />', () => {
281292
expect(liveButton.hasClass("mx_AccessibleButton_disabled")).toBeFalsy();
282293
});
283294
});
295+
296+
describe('Live location share', () => {
297+
beforeEach(() => enableSettings(["feature_location_share_live"]));
298+
299+
it('creates beacon info event on submission', () => {
300+
const onFinished = jest.fn();
301+
const component = getComponent({ onFinished });
302+
303+
// advance to location picker
304+
setShareType(component, LocationShareType.Live);
305+
setLocation(component);
306+
307+
act(() => {
308+
getSubmitButton(component).at(0).simulate('click');
309+
component.setProps({});
310+
});
311+
312+
expect(onFinished).toHaveBeenCalled();
313+
const [eventRoomId, eventContent, eventTypeSuffix] = mockClient.unstable_createLiveBeacon.mock.calls[0];
314+
expect(eventRoomId).toEqual(defaultProps.roomId);
315+
expect(eventTypeSuffix).toBeTruthy();
316+
expect(eventContent).toEqual(expect.objectContaining({
317+
[M_BEACON_INFO.name]: {
318+
// default timeout
319+
timeout: 300000,
320+
description: `Ernie's live location`,
321+
live: true,
322+
},
323+
[M_ASSET.name]: {
324+
type: LocationAssetType.Self,
325+
},
326+
}));
327+
});
328+
329+
it('opens error dialog when beacon creation fails ', async () => {
330+
// stub logger to keep console clean from expected error
331+
const logSpy = jest.spyOn(logger, 'error').mockReturnValue(undefined);
332+
const error = new Error('oh no');
333+
mockClient.unstable_createLiveBeacon.mockRejectedValue(error);
334+
const component = getComponent();
335+
336+
// advance to location picker
337+
setShareType(component, LocationShareType.Live);
338+
setLocation(component);
339+
340+
act(() => {
341+
getSubmitButton(component).at(0).simulate('click');
342+
component.setProps({});
343+
});
344+
345+
await flushPromises();
346+
347+
expect(logSpy).toHaveBeenCalledWith("We couldn't start sharing your live location", error);
348+
expect(mocked(Modal).createTrackedDialog).toHaveBeenCalled();
349+
});
350+
});
284351
});
285352

286353
function enableSettings(settings: string[]) {

0 commit comments

Comments
 (0)