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

* create beacon info event with defaulted duration

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

* add shareLiveLocation fn

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

* test share live location

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

* i18n

Signed-off-by: Kerry Archibald <kerrya@element.io>
This commit is contained in:
Kerry 2022-03-18 10:52:24 +01:00 committed by GitHub
parent 4e4ce65f58
commit cdcf6d0fd1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 152 additions and 36 deletions

View file

@ -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';
@ -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;
}
@ -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();
};

View file

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

View file

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

View file

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

View file

@ -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';
@ -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'),
@ -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 = {
@ -60,6 +67,7 @@ describe('<LocationShareMenu />', () => {
map_style_url: 'maps.com',
}),
sendMessage: jest.fn(),
unstable_createLiveBeacon: jest.fn().mockResolvedValue({}),
};
const defaultProps = {
@ -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) =>
@ -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[]) {