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:
parent
4e4ce65f58
commit
cdcf6d0fd1
5 changed files with 152 additions and 36 deletions
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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[]) {
|
||||
|
|
Loading…
Reference in a new issue