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 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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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[]) {
|
||||||
|
|
Loading…
Reference in a new issue