From 03540a3f017b1597ec031189c5ced03b6b93696a Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Wed, 16 Mar 2022 17:50:29 +0100 Subject: [PATCH 1/4] create beacon info event with defaulted duration Signed-off-by: Kerry Archibald --- .../views/location/LocationShareMenu.tsx | 5 +++- .../views/location/shareLocation.ts | 26 ++++++++++++++++++- src/i18n/strings/en_EN.json | 1 + 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/components/views/location/LocationShareMenu.tsx b/src/components/views/location/LocationShareMenu.tsx index 414bcad9a48..ac9c684247a 100644 --- a/src/components/views/location/LocationShareMenu.tsx +++ b/src/components/views/location/LocationShareMenu.tsx @@ -26,6 +26,7 @@ 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,6 +67,8 @@ const LocationShareMenu: React.FC = ({ multipleShareTypesEnabled ? undefined : LocationShareType.Own, ); + const displayName = OwnProfileStore.instance.displayName; + return = ({ { shareType ? : diff --git a/src/components/views/location/shareLocation.ts b/src/components/views/location/shareLocation.ts index b2632fa0f8a..644201826cf 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"; @@ -25,6 +25,7 @@ import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; import QuestionDialog from "../dialogs/QuestionDialog"; import SdkConfig from "../../../SdkConfig"; +import { OwnProfileStore } from "../../../stores/OwnProfileStore"; export enum LocationShareType { Own = 'Own', @@ -32,15 +33,38 @@ export enum LocationShareType { Live = 'Live' } +// default duration to 5min for now +const DEFAULT_LIVE_DURATION = 300000; +export const shareLiveLocation = async (client, roomId, displayName): Promise => { + const description = _t(`%(displayName)s's live location`, { displayName }); + await client.unstable_createLiveBeacon( + roomId, + makeBeaconInfoContent( + DEFAULT_LIVE_DURATION, + true, /* isLive */ + description, + LocationAssetType.Self + ), + // use timestamp as unique suffix in interim + `${Date.now()}`); + return; +} + export const shareLocation = ( client: MatrixClient, roomId: string, shareType: LocationShareType, relation: IEventRelation | undefined, openMenu: () => void, + displayName: string, ) => async (uri: string, ts: number) => { if (!uri) return false; try { + + if (shareType === LocationShareType.Live) { + return await shareLiveLocation(client, roomId, displayName); + } + 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)); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 26b1a012b3c..b285b87d181 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2189,6 +2189,7 @@ "Unknown error fetching location. Please try again later.": "Unknown error fetching location. Please try again later.", "This homeserver is not configured to display maps.": "This homeserver is not configured to display maps.", "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.", + "%(displayName)s's live location": "%(displayName)s's live 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.", "My current location": "My current location", From 433c3842e876ca14fee0830c5f1e383315ff401b Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Thu, 17 Mar 2022 12:33:52 +0100 Subject: [PATCH 2/4] add shareLiveLocation fn Signed-off-by: Kerry Archibald --- .../views/location/LocationPicker.tsx | 6 ++-- .../views/location/LocationShareMenu.tsx | 13 +++++++-- .../views/location/shareLocation.ts | 28 +++++++++---------- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index f9c1c0eb6f2..9959ae52ac6 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 ac9c684247a..ef0dcd14153 100644 --- a/src/components/views/location/LocationShareMenu.tsx +++ b/src/components/views/location/LocationShareMenu.tsx @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { SyntheticEvent, useContext, useState } from 'react'; +import React, { SyntheticEvent, useContext, useMemo, useState } from 'react'; import { Room } from 'matrix-js-sdk/src/models/room'; 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, ShareLocationFn } from './shareLocation'; import SettingsStore from '../../../settings/SettingsStore'; import ShareDialogButtons from './ShareDialogButtons'; import ShareType from './ShareType'; @@ -69,6 +69,13 @@ const LocationShareMenu: React.FC = ({ const displayName = OwnProfileStore.instance.displayName; + const onLocationSubmit = useMemo(() => + (shareType === LocationShareType.Live ? + shareLiveLocation(matrixClient, roomId, displayName) : + shareLocation(matrixClient, roomId, shareType, relation, openMenu)), + [shareType, shareLiveLocation, shareLocation, matrixClient, roomId, relation, openMenu] + ); + return = ({ { shareType ? : diff --git a/src/components/views/location/shareLocation.ts b/src/components/views/location/shareLocation.ts index 644201826cf..9c0f527c37a 100644 --- a/src/components/views/location/shareLocation.ts +++ b/src/components/views/location/shareLocation.ts @@ -25,7 +25,6 @@ import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; import QuestionDialog from "../dialogs/QuestionDialog"; import SdkConfig from "../../../SdkConfig"; -import { OwnProfileStore } from "../../../stores/OwnProfileStore"; export enum LocationShareType { Own = 'Own', @@ -33,21 +32,29 @@ 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 const shareLiveLocation = async (client, roomId, displayName): Promise => { + +export type ShareLocationFn = (props: LocationShareProps) => Promise; + +export const shareLiveLocation = (client, roomId, displayName): ShareLocationFn => async ({ timeout }) => { const description = _t(`%(displayName)s's live location`, { displayName }); await client.unstable_createLiveBeacon( roomId, makeBeaconInfoContent( - DEFAULT_LIVE_DURATION, + timeout ?? DEFAULT_LIVE_DURATION, true, /* isLive */ description, LocationAssetType.Self ), // use timestamp as unique suffix in interim `${Date.now()}`); - return; } export const shareLocation = ( @@ -56,18 +63,12 @@ export const shareLocation = ( shareType: LocationShareType, relation: IEventRelation | undefined, openMenu: () => void, - displayName: string, -) => async (uri: string, ts: number) => { - if (!uri) return false; +): ShareLocationFn => async ({ uri, timestamp }) => { + if (!uri) return; try { - - if (shareType === LocationShareType.Live) { - return await shareLiveLocation(client, roomId, displayName); - } - 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)); + await client.sendMessage(roomId, threadId, makeLocationContent(undefined, uri, timestamp, undefined, assetType)); } catch (e) { logger.error("We couldn't send your location", e); @@ -87,7 +88,6 @@ export const shareLocation = ( }; Modal.createTrackedDialog(analyticsAction, '', QuestionDialog, params); } - return true; }; export function textForLocation( From 70ba5df5e2c978fb461a35403b02f3d5a2db7b5c Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Thu, 17 Mar 2022 13:04:53 +0100 Subject: [PATCH 3/4] test share live location Signed-off-by: Kerry Archibald --- .../views/location/LocationPicker.tsx | 2 +- .../views/location/LocationShareMenu.tsx | 30 ++++--- .../views/location/shareLocation.ts | 82 +++++++++++-------- .../views/location/LocationShareMenu-test.tsx | 69 +++++++++++++++- 4 files changed, 132 insertions(+), 51 deletions(-) diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index 9959ae52ac6..40030637950 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -38,7 +38,7 @@ import { getUserNameColorClass } from '../../../utils/FormattingUtils'; export interface ILocationPickerProps { sender: RoomMember; shareType: LocationShareType; - onChoose: ShareLocationFn + onChoose: ShareLocationFn; onFinished(ev?: SyntheticEvent): void; } diff --git a/src/components/views/location/LocationShareMenu.tsx b/src/components/views/location/LocationShareMenu.tsx index ef0dcd14153..7b102f4b0f0 100644 --- a/src/components/views/location/LocationShareMenu.tsx +++ b/src/components/views/location/LocationShareMenu.tsx @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { SyntheticEvent, useContext, useMemo, useState } from 'react'; +import React, { SyntheticEvent, useContext, useState } from 'react'; import { Room } from 'matrix-js-sdk/src/models/room'; 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 { shareLiveLocation, shareLocation, ShareLocationFn } from './shareLocation'; +import { shareLiveLocation, shareLocation } from './shareLocation'; import SettingsStore from '../../../settings/SettingsStore'; import ShareDialogButtons from './ShareDialogButtons'; import ShareType from './ShareType'; @@ -69,12 +69,9 @@ const LocationShareMenu: React.FC = ({ const displayName = OwnProfileStore.instance.displayName; - const onLocationSubmit = useMemo(() => - (shareType === LocationShareType.Live ? - shareLiveLocation(matrixClient, roomId, displayName) : - shareLocation(matrixClient, roomId, shareType, relation, openMenu)), - [shareType, shareLiveLocation, shareLocation, matrixClient, roomId, relation, openMenu] - ); + const onLocationSubmit = shareType === LocationShareType.Live ? + shareLiveLocation(matrixClient, roomId, displayName, openMenu) : + shareLocation(matrixClient, roomId, shareType, relation, openMenu); return = ({ managed={false} >
- { shareType ? - : - } + { shareType ? + : + + } setShareType(undefined)} onCancel={onFinished} />
; diff --git a/src/components/views/location/shareLocation.ts b/src/components/views/location/shareLocation.ts index 9c0f527c37a..8fa801e13a2 100644 --- a/src/components/views/location/shareLocation.ts +++ b/src/components/views/location/shareLocation.ts @@ -34,28 +34,56 @@ export enum LocationShareType { export type LocationShareProps = { timeout?: number; - uri?: string, + uri?: string; timestamp?: number; -} +}; // default duration to 5min for now const DEFAULT_LIVE_DURATION = 300000; export type ShareLocationFn = (props: LocationShareProps) => Promise; -export const shareLiveLocation = (client, roomId, displayName): ShareLocationFn => async ({ timeout }) => { +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 }); - await client.unstable_createLiveBeacon( - roomId, - makeBeaconInfoContent( - timeout ?? DEFAULT_LIVE_DURATION, - true, /* isLive */ - description, - LocationAssetType.Self - ), - // use timestamp as unique suffix in interim - `${Date.now()}`); -} + 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, @@ -68,25 +96,13 @@ export const shareLocation = ( 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, timestamp, 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); } }; diff --git a/test/components/views/location/LocationShareMenu-test.tsx b/test/components/views/location/LocationShareMenu-test.tsx index 61e0dd67365..1afb28f1833 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[]) { From d5358406c07f1681a85613a4f338f719bcdfbfe5 Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Thu, 17 Mar 2022 13:14:08 +0100 Subject: [PATCH 4/4] i18n Signed-off-by: Kerry Archibald --- src/i18n/strings/en_EN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index da7c38ff154..c4c20f647ef 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2189,9 +2189,9 @@ "Unknown error fetching location. Please try again later.": "Unknown error fetching location. Please try again later.", "This homeserver is not configured to display maps.": "This homeserver is not configured to display maps.", "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.", - "%(displayName)s's live location": "%(displayName)s's live 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.", + "%(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",