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

Live location sharing: create beacon info event from location picker #8072

Merged
merged 5 commits into from
Mar 18, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 3 additions & 3 deletions src/components/views/location/LocationPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import Modal from '../../../Modal';
import ErrorDialog from '../dialogs/ErrorDialog';
import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils';
import { findMapStyleUrl } from './findMapStyleUrl';
import { LocationShareType } from './shareLocation';
import { LocationShareType, ShareLocationFn } from './shareLocation';
import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg';
import { LocationShareError } from './LocationShareErrors';
import AccessibleButton from '../elements/AccessibleButton';
Expand All @@ -38,7 +38,7 @@ import { getUserNameColorClass } from '../../../utils/FormattingUtils';
export interface ILocationPickerProps {
sender: RoomMember;
shareType: LocationShareType;
onChoose(uri: string, ts: number): unknown;
onChoose: ShareLocationFn;
onFinished(ev?: SyntheticEvent): void;
}

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

this.props.onChoose(position ? getGeoUri(position) : undefined, position?.timestamp);
this.props.onChoose(position ? { uri: getGeoUri(position), timestamp: position.timestamp } : {});
this.props.onFinished();
};

Expand Down
26 changes: 17 additions & 9 deletions src/components/views/location/LocationShareMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ import { IEventRelation } from 'matrix-js-sdk/src/models/event';
import MatrixClientContext from '../../../contexts/MatrixClientContext';
import ContextMenu, { AboveLeftOf } from '../../structures/ContextMenu';
import LocationPicker, { ILocationPickerProps } from "./LocationPicker";
import { shareLocation } from './shareLocation';
import { shareLiveLocation, shareLocation } from './shareLocation';
import SettingsStore from '../../../settings/SettingsStore';
import ShareDialogButtons from './ShareDialogButtons';
import ShareType from './ShareType';
import { LocationShareType } from './shareLocation';
import { OwnProfileStore } from '../../../stores/OwnProfileStore';

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

const displayName = OwnProfileStore.instance.displayName;

const onLocationSubmit = shareType === LocationShareType.Live ?
shareLiveLocation(matrixClient, roomId, displayName, openMenu) :
shareLocation(matrixClient, roomId, shareType, relation, openMenu);

return <ContextMenu
{...menuPosition}
onFinished={onFinished}
managed={false}
>
<div className="mx_LocationShareMenu">
{ shareType ? <LocationPicker
sender={sender}
shareType={shareType}
onChoose={shareLocation(matrixClient, roomId, shareType, relation, openMenu)}
onFinished={onFinished}
/>
:
<ShareType setShareType={setShareType} enabledShareTypes={enabledShareTypes} /> }
{ shareType ?
<LocationPicker
sender={sender}
shareType={shareType}
onChoose={onLocationSubmit}
onFinished={onFinished}
/> :
<ShareType setShareType={setShareType} enabledShareTypes={enabledShareTypes} />
}
<ShareDialogButtons displayBack={!!shareType && multipleShareTypesEnabled} onBack={() => setShareType(undefined)} onCancel={onFinished} />
</div>
</ContextMenu>;
Expand Down
86 changes: 63 additions & 23 deletions src/components/views/location/shareLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ limitations under the License.
*/

import { MatrixClient } from "matrix-js-sdk/src/client";
import { makeLocationContent } from "matrix-js-sdk/src/content-helpers";
import { makeLocationContent, makeBeaconInfoContent } from "matrix-js-sdk/src/content-helpers";
import { logger } from "matrix-js-sdk/src/logger";
import { IEventRelation } from "matrix-js-sdk/src/models/event";
import { LocationAssetType } from "matrix-js-sdk/src/@types/location";
Expand All @@ -32,38 +32,78 @@ export enum LocationShareType {
Live = 'Live'
}

export type LocationShareProps = {
timeout?: number;
uri?: string;
timestamp?: number;
};

// default duration to 5min for now
const DEFAULT_LIVE_DURATION = 300000;

export type ShareLocationFn = (props: LocationShareProps) => Promise<void>;

const handleShareError = (error: Error, openMenu: () => void, shareType: LocationShareType) => {
const errorMessage = shareType === LocationShareType.Live ?
"We couldn't start sharing your live location" :
"We couldn't send your location";
logger.error(errorMessage, error);
const analyticsAction = errorMessage;
const params = {
title: _t("We couldn't send your location"),
description: _t("%(brand)s could not send your location. Please try again later.", {
brand: SdkConfig.get().brand,
}),
button: _t('Try again'),
cancelButton: _t('Cancel'),
onFinished: (tryAgain: boolean) => {
if (tryAgain) {
openMenu();
}
},
};
Modal.createTrackedDialog(analyticsAction, '', QuestionDialog, params);
};

export const shareLiveLocation = (
client: MatrixClient, roomId: string, displayName: string, openMenu: () => void,
): ShareLocationFn => async ({ timeout }) => {
const description = _t(`%(displayName)s's live location`, { displayName });
try {
await client.unstable_createLiveBeacon(
roomId,
makeBeaconInfoContent(
timeout ?? DEFAULT_LIVE_DURATION,
true, /* isLive */
description,
LocationAssetType.Self,
),
// use timestamp as unique suffix in interim
`${Date.now()}`);
} catch (error) {
handleShareError(error, openMenu, LocationShareType.Live);
}
};

export const shareLocation = (
client: MatrixClient,
roomId: string,
shareType: LocationShareType,
relation: IEventRelation | undefined,
openMenu: () => void,
) => async (uri: string, ts: number) => {
if (!uri) return false;
): ShareLocationFn => async ({ uri, timestamp }) => {
if (!uri) return;
try {
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
const assetType = shareType === LocationShareType.Pin ? LocationAssetType.Pin : LocationAssetType.Self;
await client.sendMessage(roomId, threadId, makeLocationContent(undefined, uri, ts, undefined, assetType));
} catch (e) {
logger.error("We couldn't send your location", e);

const analyticsAction = "We couldn't send your location";
const params = {
title: _t("We couldn't send your location"),
description: _t("%(brand)s could not send your location. Please try again later.", {
brand: SdkConfig.get().brand,
}),
button: _t('Try again'),
cancelButton: _t('Cancel'),
onFinished: (tryAgain: boolean) => {
if (tryAgain) {
openMenu();
}
},
};
Modal.createTrackedDialog(analyticsAction, '', QuestionDialog, params);
await client.sendMessage(
roomId,
threadId,
makeLocationContent(undefined, uri, timestamp, undefined, assetType),
);
} catch (error) {
handleShareError(error, openMenu, shareType);
}
return true;
};

export function textForLocation(
Expand Down
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2191,6 +2191,7 @@
"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.",
"We couldn't send your location": "We couldn't send your location",
"%(brand)s could not send your location. Please try again later.": "%(brand)s could not send your location. Please try again later.",
"%(displayName)s's live location": "%(displayName)s's live location",
"My current location": "My current location",
"My live location": "My live location",
"Drop a Pin": "Drop a Pin",
Expand Down
69 changes: 68 additions & 1 deletion test/components/views/location/LocationShareMenu-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { mocked } from 'jest-mock';
import { act } from 'react-dom/test-utils';
import { M_BEACON_INFO } from 'matrix-js-sdk/src/@types/beacon';
import { M_ASSET, LocationAssetType } from 'matrix-js-sdk/src/@types/location';
import { logger } from 'matrix-js-sdk/src/logger';

import '../../../skinned-sdk';
import LocationShareMenu from '../../../../src/components/views/location/LocationShareMenu';
Expand All @@ -29,7 +31,8 @@ import { ChevronFace } from '../../../../src/components/structures/ContextMenu';
import SettingsStore from '../../../../src/settings/SettingsStore';
import { MatrixClientPeg } from '../../../../src/MatrixClientPeg';
import { LocationShareType } from '../../../../src/components/views/location/shareLocation';
import { findByTagAndTestId } from '../../../test-utils';
import { findByTagAndTestId, flushPromises } from '../../../test-utils';
import Modal from '../../../../src/Modal';

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

jest.mock('../../../../src/Modal', () => ({
createTrackedDialog: jest.fn(),
}));

describe('<LocationShareMenu />', () => {
const userId = '@ernie:server.org';
const mockClient = {
Expand All @@ -60,6 +67,7 @@ describe('<LocationShareMenu />', () => {
map_style_url: 'maps.com',
}),
sendMessage: jest.fn(),
unstable_createLiveBeacon: jest.fn().mockResolvedValue({}),
};

const defaultProps = {
Expand Down Expand Up @@ -90,9 +98,12 @@ describe('<LocationShareMenu />', () => {
});

beforeEach(() => {
jest.spyOn(logger, 'error').mockRestore();
mocked(SettingsStore).getValue.mockReturnValue(false);
mockClient.sendMessage.mockClear();
mockClient.unstable_createLiveBeacon.mockClear().mockResolvedValue(undefined);
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient as unknown as MatrixClient);
mocked(Modal).createTrackedDialog.mockClear();
});

const getShareTypeOption = (component: ReactWrapper, shareType: LocationShareType) =>
Expand Down Expand Up @@ -281,6 +292,62 @@ describe('<LocationShareMenu />', () => {
expect(liveButton.hasClass("mx_AccessibleButton_disabled")).toBeFalsy();
});
});

describe('Live location share', () => {
beforeEach(() => enableSettings(["feature_location_share_live"]));

it('creates beacon info event on submission', () => {
const onFinished = jest.fn();
const component = getComponent({ onFinished });

// advance to location picker
setShareType(component, LocationShareType.Live);
setLocation(component);

act(() => {
getSubmitButton(component).at(0).simulate('click');
component.setProps({});
});

expect(onFinished).toHaveBeenCalled();
const [eventRoomId, eventContent, eventTypeSuffix] = mockClient.unstable_createLiveBeacon.mock.calls[0];
expect(eventRoomId).toEqual(defaultProps.roomId);
expect(eventTypeSuffix).toBeTruthy();
expect(eventContent).toEqual(expect.objectContaining({
[M_BEACON_INFO.name]: {
// default timeout
timeout: 300000,
description: `Ernie's live location`,
live: true,
},
[M_ASSET.name]: {
type: LocationAssetType.Self,
},
}));
});

it('opens error dialog when beacon creation fails ', async () => {
// stub logger to keep console clean from expected error
const logSpy = jest.spyOn(logger, 'error').mockReturnValue(undefined);
const error = new Error('oh no');
mockClient.unstable_createLiveBeacon.mockRejectedValue(error);
const component = getComponent();

// advance to location picker
setShareType(component, LocationShareType.Live);
setLocation(component);

act(() => {
getSubmitButton(component).at(0).simulate('click');
component.setProps({});
});

await flushPromises();

expect(logSpy).toHaveBeenCalledWith("We couldn't start sharing your live location", error);
expect(mocked(Modal).createTrackedDialog).toHaveBeenCalled();
});
});
});

function enableSettings(settings: string[]) {
Expand Down