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 ErrorDialog from '../dialogs/ErrorDialog';
import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils';
import { findMapStyleUrl } from './findMapStyleUrl'; 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 { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg';
import { LocationShareError } from './LocationShareErrors'; import { LocationShareError } from './LocationShareErrors';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
@ -38,7 +38,7 @@ import { getUserNameColorClass } from '../../../utils/FormattingUtils';
export interface ILocationPickerProps { export interface ILocationPickerProps {
sender: RoomMember; sender: RoomMember;
shareType: LocationShareType; shareType: LocationShareType;
onChoose(uri: string, ts: number): unknown; onChoose: ShareLocationFn;
onFinished(ev?: SyntheticEvent): void; onFinished(ev?: SyntheticEvent): void;
} }
@ -209,7 +209,7 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
private onOk = () => { private onOk = () => {
const position = this.state.position; 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(); this.props.onFinished();
}; };

View file

@ -21,11 +21,12 @@ import { IEventRelation } from 'matrix-js-sdk/src/models/event';
import MatrixClientContext from '../../../contexts/MatrixClientContext'; import MatrixClientContext from '../../../contexts/MatrixClientContext';
import ContextMenu, { AboveLeftOf } from '../../structures/ContextMenu'; import ContextMenu, { AboveLeftOf } from '../../structures/ContextMenu';
import LocationPicker, { ILocationPickerProps } from "./LocationPicker"; import LocationPicker, { ILocationPickerProps } from "./LocationPicker";
import { shareLocation } from './shareLocation'; import { shareLiveLocation, shareLocation } from './shareLocation';
import SettingsStore from '../../../settings/SettingsStore'; import SettingsStore from '../../../settings/SettingsStore';
import ShareDialogButtons from './ShareDialogButtons'; import ShareDialogButtons from './ShareDialogButtons';
import ShareType from './ShareType'; import ShareType from './ShareType';
import { LocationShareType } from './shareLocation'; import { LocationShareType } from './shareLocation';
import { OwnProfileStore } from '../../../stores/OwnProfileStore';
type Props = Omit<ILocationPickerProps, 'onChoose' | 'shareType'> & { type Props = Omit<ILocationPickerProps, 'onChoose' | 'shareType'> & {
onFinished: (ev?: SyntheticEvent) => void; onFinished: (ev?: SyntheticEvent) => void;
@ -66,20 +67,27 @@ const LocationShareMenu: React.FC<Props> = ({
multipleShareTypesEnabled ? undefined : LocationShareType.Own, 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 return <ContextMenu
{...menuPosition} {...menuPosition}
onFinished={onFinished} onFinished={onFinished}
managed={false} managed={false}
> >
<div className="mx_LocationShareMenu"> <div className="mx_LocationShareMenu">
{ shareType ? <LocationPicker { shareType ?
sender={sender} <LocationPicker
shareType={shareType} sender={sender}
onChoose={shareLocation(matrixClient, roomId, shareType, relation, openMenu)} shareType={shareType}
onFinished={onFinished} onChoose={onLocationSubmit}
/> onFinished={onFinished}
: /> :
<ShareType setShareType={setShareType} enabledShareTypes={enabledShareTypes} /> } <ShareType setShareType={setShareType} enabledShareTypes={enabledShareTypes} />
}
<ShareDialogButtons displayBack={!!shareType && multipleShareTypesEnabled} onBack={() => setShareType(undefined)} onCancel={onFinished} /> <ShareDialogButtons displayBack={!!shareType && multipleShareTypesEnabled} onBack={() => setShareType(undefined)} onCancel={onFinished} />
</div> </div>
</ContextMenu>; </ContextMenu>;

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import { MatrixClient } from "matrix-js-sdk/src/client"; 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 { logger } from "matrix-js-sdk/src/logger";
import { IEventRelation } from "matrix-js-sdk/src/models/event"; import { IEventRelation } from "matrix-js-sdk/src/models/event";
import { LocationAssetType } from "matrix-js-sdk/src/@types/location"; import { LocationAssetType } from "matrix-js-sdk/src/@types/location";
@ -32,38 +32,78 @@ export enum LocationShareType {
Live = 'Live' 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 = ( export const shareLocation = (
client: MatrixClient, client: MatrixClient,
roomId: string, roomId: string,
shareType: LocationShareType, shareType: LocationShareType,
relation: IEventRelation | undefined, relation: IEventRelation | undefined,
openMenu: () => void, openMenu: () => void,
) => async (uri: string, ts: number) => { ): ShareLocationFn => async ({ uri, timestamp }) => {
if (!uri) return false; if (!uri) return;
try { try {
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null; const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
const assetType = shareType === LocationShareType.Pin ? LocationAssetType.Pin : LocationAssetType.Self; const assetType = shareType === LocationShareType.Pin ? LocationAssetType.Pin : LocationAssetType.Self;
await client.sendMessage(roomId, threadId, makeLocationContent(undefined, uri, ts, undefined, assetType)); await client.sendMessage(
} catch (e) { roomId,
logger.error("We couldn't send your location", e); threadId,
makeLocationContent(undefined, uri, timestamp, undefined, assetType),
const analyticsAction = "We couldn't send your location"; );
const params = { } catch (error) {
title: _t("We couldn't send your location"), handleShareError(error, openMenu, shareType);
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);
} }
return true;
}; };
export function textForLocation( 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.", "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", "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.", "%(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 current location": "My current location",
"My live location": "My live location", "My live location": "My live location",
"Drop a Pin": "Drop a Pin", "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 { MatrixClient } from 'matrix-js-sdk/src/client';
import { mocked } from 'jest-mock'; import { mocked } from 'jest-mock';
import { act } from 'react-dom/test-utils'; 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 { M_ASSET, LocationAssetType } from 'matrix-js-sdk/src/@types/location';
import { logger } from 'matrix-js-sdk/src/logger';
import '../../../skinned-sdk'; import '../../../skinned-sdk';
import LocationShareMenu from '../../../../src/components/views/location/LocationShareMenu'; 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 SettingsStore from '../../../../src/settings/SettingsStore';
import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; import { MatrixClientPeg } from '../../../../src/MatrixClientPeg';
import { LocationShareType } from '../../../../src/components/views/location/shareLocation'; 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', () => ({ jest.mock('../../../../src/components/views/location/findMapStyleUrl', () => ({
findMapStyleUrl: jest.fn().mockReturnValue('test'), findMapStyleUrl: jest.fn().mockReturnValue('test'),
@ -49,6 +52,10 @@ jest.mock('../../../../src/stores/OwnProfileStore', () => ({
}, },
})); }));
jest.mock('../../../../src/Modal', () => ({
createTrackedDialog: jest.fn(),
}));
describe('<LocationShareMenu />', () => { describe('<LocationShareMenu />', () => {
const userId = '@ernie:server.org'; const userId = '@ernie:server.org';
const mockClient = { const mockClient = {
@ -60,6 +67,7 @@ describe('<LocationShareMenu />', () => {
map_style_url: 'maps.com', map_style_url: 'maps.com',
}), }),
sendMessage: jest.fn(), sendMessage: jest.fn(),
unstable_createLiveBeacon: jest.fn().mockResolvedValue({}),
}; };
const defaultProps = { const defaultProps = {
@ -90,9 +98,12 @@ describe('<LocationShareMenu />', () => {
}); });
beforeEach(() => { beforeEach(() => {
jest.spyOn(logger, 'error').mockRestore();
mocked(SettingsStore).getValue.mockReturnValue(false); mocked(SettingsStore).getValue.mockReturnValue(false);
mockClient.sendMessage.mockClear(); mockClient.sendMessage.mockClear();
mockClient.unstable_createLiveBeacon.mockClear().mockResolvedValue(undefined);
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient as unknown as MatrixClient); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient as unknown as MatrixClient);
mocked(Modal).createTrackedDialog.mockClear();
}); });
const getShareTypeOption = (component: ReactWrapper, shareType: LocationShareType) => const getShareTypeOption = (component: ReactWrapper, shareType: LocationShareType) =>
@ -281,6 +292,62 @@ describe('<LocationShareMenu />', () => {
expect(liveButton.hasClass("mx_AccessibleButton_disabled")).toBeFalsy(); 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[]) { function enableSettings(settings: string[]) {