From cdcf6d0fd182b6199059073a8def89e576e952fc Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 18 Mar 2022 10:52:24 +0100 Subject: [PATCH] Live location sharing: create beacon info event from location picker (#8072) * create beacon info event with defaulted duration Signed-off-by: Kerry Archibald * add shareLiveLocation fn Signed-off-by: Kerry Archibald * test share live location Signed-off-by: Kerry Archibald * i18n Signed-off-by: Kerry Archibald --- .../views/location/LocationPicker.tsx | 6 +- .../views/location/LocationShareMenu.tsx | 26 ++++-- .../views/location/shareLocation.ts | 86 ++++++++++++++----- src/i18n/strings/en_EN.json | 1 + .../views/location/LocationShareMenu-test.tsx | 69 ++++++++++++++- 5 files changed, 152 insertions(+), 36 deletions(-) diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index f9c1c0eb6f..4003063795 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -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 { 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(); }; diff --git a/src/components/views/location/LocationShareMenu.tsx b/src/components/views/location/LocationShareMenu.tsx index 414bcad9a4..7b102f4b0f 100644 --- a/src/components/views/location/LocationShareMenu.tsx +++ b/src/components/views/location/LocationShareMenu.tsx @@ -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 & { onFinished: (ev?: SyntheticEvent) => void; @@ -66,20 +67,27 @@ const LocationShareMenu: React.FC = ({ 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
- { shareType ? - : - } + { shareType ? + : + + } setShareType(undefined)} onCancel={onFinished} />
; diff --git a/src/components/views/location/shareLocation.ts b/src/components/views/location/shareLocation.ts index b2632fa0f8..8fa801e13a 100644 --- a/src/components/views/location/shareLocation.ts +++ b/src/components/views/location/shareLocation.ts @@ -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; + +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( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 28e98bba00..c4c20f647e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/test/components/views/location/LocationShareMenu-test.tsx b/test/components/views/location/LocationShareMenu-test.tsx index 61e0dd6736..1afb28f183 100644 --- a/test/components/views/location/LocationShareMenu-test.tsx +++ b/test/components/views/location/LocationShareMenu-test.tsx @@ -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('', () => { const userId = '@ernie:server.org'; const mockClient = { @@ -60,6 +67,7 @@ describe('', () => { map_style_url: 'maps.com', }), sendMessage: jest.fn(), + unstable_createLiveBeacon: jest.fn().mockResolvedValue({}), }; const defaultProps = { @@ -90,9 +98,12 @@ describe('', () => { }); 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('', () => { 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[]) {