diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js new file mode 100644 index 00000000000..b1f114e8eff --- /dev/null +++ b/__mocks__/maplibre-gl.js @@ -0,0 +1,20 @@ +const EventEmitter = require("events"); +const { LngLat } = require('maplibre-gl'); + +class MockMap extends EventEmitter { + addControl = jest.fn(); + removeControl = jest.fn(); +} +class MockGeolocateControl extends EventEmitter { + +} +class MockMarker extends EventEmitter { + setLngLat = jest.fn().mockReturnValue(this); + addTo = jest.fn(); +} +module.exports = { + Map: MockMap, + GeolocateControl: MockGeolocateControl, + Marker: MockMarker, + LngLat, +}; diff --git a/res/css/_components.scss b/res/css/_components.scss index c602d26fb65..4ae93f07270 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -5,6 +5,7 @@ @import "./_font-weights.scss"; @import "./_spacing.scss"; @import "./components/views/location/_LocationShareMenu.scss"; +@import "./components/views/location/_ShareType.scss"; @import "./components/views/spaces/_QuickThemeSwitcher.scss"; @import "./structures/_AutoHideScrollbar.scss"; @import "./structures/_BackdropPanel.scss"; diff --git a/res/css/components/views/location/_ShareType.scss b/res/css/components/views/location/_ShareType.scss new file mode 100644 index 00000000000..ba21a7caa0d --- /dev/null +++ b/res/css/components/views/location/_ShareType.scss @@ -0,0 +1,108 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ShareType { + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + + padding: 60px $spacing-12 $spacing-32; + + color: $primary-content; +} + +.mx_ShareType_badge { + height: 60px; + width: 60px; + margin-bottom: $spacing-20; + background-color: $accent; + border-radius: 50%; + border: 14px solid $accent; + // colors icon + color: white; + box-sizing: border-box; +} + +.mx_ShareType_heading { + padding-bottom: $spacing-32; + text-align: center; +} + +.mx_ShareType_option { + @mixin ButtonResetDefault; + + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + padding: $spacing-8 $spacing-20; + margin-top: $spacing-12; + + color: $primary-content; + border: 1px solid $quinary-content; + border-radius: 8px; + + font-size: $font-15px; + + &:hover, &:focus { + border-color: $accent; + } + + // this style is only during active development + // when lab is enabled but feature not fully implemented + // pin drop option will be disabled + &.mx_AccessibleButton_disabled { + pointer-events: none; + opacity: 0.4; + } +} + +.mx_ShareType_option-icon { + height: 40px; + width: 40px; + box-sizing: border-box; + margin-right: $spacing-12; + flex: 0 0 40px; + border-width: 2px; + border-style: solid; + border-radius: 50%; + + &.Own { + // color is set by user color class + // generated from id + border-color: currentColor; + } + + &.Live { + background-color: $location-live-color; + // 20% brightness $location-live-color + border-color: #deddfd; + padding: 2px; + // colors icon + color: white; + } + + &.Pin { + border-color: $accent; + background-color: $accent; + padding: 7px; + // colors icon + color: white; + } +} diff --git a/res/img/element-icons/location.svg b/res/img/element-icons/location.svg index 436c0e637bf..fc8337a43ba 100644 --- a/res/img/element-icons/location.svg +++ b/res/img/element-icons/location.svg @@ -1,3 +1,3 @@ - + diff --git a/res/img/location/live-location.svg b/res/img/location/live-location.svg new file mode 100644 index 00000000000..09628709a71 --- /dev/null +++ b/res/img/location/live-location.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 885c515aab0..d71cd3524e2 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -182,6 +182,11 @@ $call-view-button-off-background: $primary-content; $video-feed-secondary-background: $system; // ******************** +// Location sharing +// ******************** +$location-live-color: #5c56f5; +// ******************** + // Location sharing // ******************** .maplibregl-ctrl-attrib-button { diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index bd17f4f03f7..83f0e71db57 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -188,6 +188,12 @@ $eventbubble-others-bg: $event-selected-color; $eventbubble-bg-hover: #1C2026; $eventbubble-reply-color: #C1C6CD; +// Location sharing +// ******************** +$location-marker-color: #ffffff; +$location-live-color: #5c56f5; +// ******************** + // ***** Mixins! ***** @define-mixin mx_DialogButton { diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 9dcb2540d3e..baee895d9f6 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -36,7 +36,6 @@ $accent-alt: #238cf5; $selection-fg-color: $primary-bg-color; $focus-brightness: 105%; -$location-marker-color: #ffffff; $other-user-pill-bg-color: rgba(0, 0, 0, 0.1); @@ -284,6 +283,12 @@ $pinned-color: $tertiary-content; $groupFilterPanel-divider-color: $tertiary-content; +// Location sharing +// ******************** +$location-marker-color: #ffffff; +$location-live-color: #5c56f5; +// ******************** + // ***** Mixins! ***** @define-mixin mx_DialogButton { diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index e54788e79e5..6d7921eb0b1 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -283,7 +283,6 @@ $pinned-color: $tertiary-content; $avatar-initial-color: $background; $primary-hairline-color: transparent; $focus-brightness: 105%; -$location-marker-color: #ffffff; // ******************** // blur amounts for left left panel (only for element theme) @@ -298,6 +297,12 @@ $location-marker-color: #ffffff; $copy-button-url: "$(res)/img/feather-customised/clipboard.svg"; // ******************** +// Location sharing +// ******************** +$location-marker-color: #ffffff; +$location-live-color: #5c56f5; +// ******************** + // Mixins // ******************** @define-mixin mx_DialogButton { diff --git a/src/components/views/location/LocationShareMenu.tsx b/src/components/views/location/LocationShareMenu.tsx index 0ef3efa1d2a..52cf32a217f 100644 --- a/src/components/views/location/LocationShareMenu.tsx +++ b/src/components/views/location/LocationShareMenu.tsx @@ -14,13 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { SyntheticEvent, useContext } from 'react'; +import React, { SyntheticEvent, useContext, useState } from 'react'; import { Room } from 'matrix-js-sdk/src/models/room'; import MatrixClientContext from '../../../contexts/MatrixClientContext'; import ContextMenu, { AboveLeftOf } from '../../structures/ContextMenu'; import LocationPicker, { ILocationPickerProps } from "./LocationPicker"; import { shareLocation } from './shareLocation'; +import SettingsStore from '../../../settings/SettingsStore'; +import ShareType, { LocationShareType } from './ShareType'; type Props = Omit & { onFinished: (ev?: SyntheticEvent) => void; @@ -29,10 +31,26 @@ type Props = Omit & { roomId: Room["roomId"]; }; +const getEnabledShareTypes = (): LocationShareType[] => { + const isPinDropLocationShareEnabled = SettingsStore.getValue("feature_location_share_pin_drop"); + + if (isPinDropLocationShareEnabled) { + return [LocationShareType.Own, LocationShareType.Pin]; + } + return [ + LocationShareType.Own, + ]; +}; + const LocationShareMenu: React.FC = ({ menuPosition, onFinished, sender, roomId, openMenu, }) => { const matrixClient = useContext(MatrixClientContext); + const enabledShareTypes = getEnabledShareTypes(); + + const [shareType, setShareType] = useState( + enabledShareTypes.length === 1 ? LocationShareType.Own : undefined, + ); return = ({ managed={false} >
- + : + }
; }; diff --git a/src/components/views/location/ShareType.tsx b/src/components/views/location/ShareType.tsx new file mode 100644 index 00000000000..8e16660dcb6 --- /dev/null +++ b/src/components/views/location/ShareType.tsx @@ -0,0 +1,103 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { HTMLAttributes, useContext } from 'react'; + +import MatrixClientContext from '../../../contexts/MatrixClientContext'; +import { _t } from '../../../languageHandler'; +import { OwnProfileStore } from '../../../stores/OwnProfileStore'; +import { getUserNameColorClass } from '../../../utils/FormattingUtils'; +import BaseAvatar from '../avatars/BaseAvatar'; +import AccessibleButton from '../elements/AccessibleButton'; +import Heading from '../typography/Heading'; +import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg'; +import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg'; + +const UserAvatar = () => { + const matrixClient = useContext(MatrixClientContext); + const userId = matrixClient.getUserId(); + const displayName = OwnProfileStore.instance.displayName; + // 40 - 2px border + const avatarSize = 36; + const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); + const colorClass = getUserNameColorClass(userId); + + return
+ +
; +}; + +// TODO this will be defined somewhere better +export enum LocationShareType { + Own = 'Own', + Pin = 'Pin', + Live = 'Live' +} +type ShareTypeOptionProps = HTMLAttributes & { label: string, shareType: LocationShareType }; +const ShareTypeOption: React.FC = ({ + onClick, label, shareType, ...rest +}) => + { shareType === LocationShareType.Own && } + { shareType === LocationShareType.Pin && + } + { shareType === LocationShareType.Live && + } + + { label } +; + +interface Props { + setShareType: (shareType: LocationShareType) => void; + enabledShareTypes: LocationShareType[]; +} +const ShareType: React.FC = ({ + setShareType, enabledShareTypes, +}) => { + const labels = { + [LocationShareType.Own]: _t('My current location'), + [LocationShareType.Live]: _t('My live location'), + [LocationShareType.Pin]: _t('Drop a Pin'), + }; + return
+ + { _t("What location type do you want to share?") } + { enabledShareTypes.map((type) => + setShareType(type)} + label={labels[type]} + shareType={type} + data-test-id={`share-location-option-${type}`} + />, + ) } +
; +}; + +export default ShareType; diff --git a/src/components/views/location/shareLocation.ts b/src/components/views/location/shareLocation.ts index e7261230d04..90733810b3c 100644 --- a/src/components/views/location/shareLocation.ts +++ b/src/components/views/location/shareLocation.ts @@ -1,4 +1,3 @@ - /* Copyright 2022 The Matrix.org Foundation C.I.C. diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8c0f80e6fea..246658d5c37 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2179,6 +2179,10 @@ "Unknown error fetching location. Please try again later.": "Unknown error fetching location. Please try again later.", "We couldn’t send your location": "We couldn’t send your location", "Element could not send your location. Please try again later.": "Element could not send your location. Please try again later.", + "My current location": "My current location", + "My live location": "My live location", + "Drop a Pin": "Drop a Pin", + "What location type do you want to share?": "What location type do you want to share?", "Failed to load group members": "Failed to load group members", "Filter community members": "Filter community members", "Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "Are you sure you want to remove '%(roomName)s' from %(groupId)s?", diff --git a/test/components/views/location/LocationShareMenu-test.tsx b/test/components/views/location/LocationShareMenu-test.tsx index fdf6b42158f..7607f6ef557 100644 --- a/test/components/views/location/LocationShareMenu-test.tsx +++ b/test/components/views/location/LocationShareMenu-test.tsx @@ -17,15 +17,46 @@ limitations under the License. import React from 'react'; import { mount } from 'enzyme'; 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 '../../../skinned-sdk'; import LocationShareMenu from '../../../../src/components/views/location/LocationShareMenu'; import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; 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/ShareType'; +import { findByTestId } from '../../../test-utils'; + +jest.mock('../../../../src/components/views/messages/MLocationBody', () => ({ + findMapStyleUrl: jest.fn().mockReturnValue('test'), +})); + +jest.mock('../../../../src/settings/SettingsStore', () => ({ + getValue: jest.fn(), + monitorSetting: jest.fn(), +})); + +jest.mock('../../../../src/stores/OwnProfileStore', () => ({ + OwnProfileStore: { + instance: { + displayName: 'Ernie', + getHttpAvatarUrl: jest.fn().mockReturnValue('image.com/img'), + }, + }, +})); describe('', () => { + const userId = '@ernie:server.org'; const mockClient = { on: jest.fn(), + removeListener: jest.fn(), + getUserId: jest.fn().mockReturnValue(userId), + getClientWellKnown: jest.fn().mockResolvedValue({ + map_style_url: 'maps.com', + }), }; const defaultProps = { @@ -36,7 +67,7 @@ describe('', () => { onFinished: jest.fn(), openMenu: jest.fn(), roomId: '!room:server.org', - sender: { id: '@ernie:server.org' } as unknown as RoomMember, + sender: new RoomMember('!room:server.org', userId), }; const getComponent = (props = {}) => mount(, { @@ -44,8 +75,43 @@ describe('', () => { wrappingComponentProps: { value: mockClient }, }); - it('renders', () => { + beforeEach(() => { + mocked(SettingsStore).getValue.mockImplementation( + (settingName) => settingName === "feature_location_share_pin_drop", + ); + + jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient as unknown as MatrixClient); + }); + + const getShareTypeOption = (component, shareType: LocationShareType) => + findByTestId(component, `share-location-option-${shareType}`); + + it('renders location picker when only Own share type is enabled', () => { + mocked(SettingsStore).getValue.mockReturnValue(false); const component = getComponent(); - expect(component).toMatchSnapshot(); + expect(component.find('ShareType').length).toBeFalsy(); + expect(component.find('LocationPicker').length).toBeTruthy(); + }); + + it('renders share type switch with own and pin drop options when enabled', () => { + // feature_location_share_pin_drop is set to enabled by default mocking + const component = getComponent(); + expect(component.find('LocationPicker').length).toBeFalsy(); + + expect(getShareTypeOption(component, LocationShareType.Own).length).toBeTruthy(); + expect(getShareTypeOption(component, LocationShareType.Pin).length).toBeTruthy(); + }); + + it('selecting own location share type advances to location picker', () => { + // feature_location_share_pin_drop is set to enabled by default mocking + const component = getComponent(); + + act(() => { + getShareTypeOption(component, LocationShareType.Own).at(0).simulate('click'); + }); + + component.setProps({}); + + expect(component.find('LocationPicker').length).toBeTruthy(); }); }); diff --git a/test/components/views/location/__snapshots__/LocationShareMenu-test.tsx.snap b/test/components/views/location/__snapshots__/LocationShareMenu-test.tsx.snap deleted file mode 100644 index 130496e6683..00000000000 --- a/test/components/views/location/__snapshots__/LocationShareMenu-test.tsx.snap +++ /dev/null @@ -1,289 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders 1`] = ` - - - -
-
-
-
-
-
-
-
- Failed to load map -
- -
-
- - -
-
-
-
-
-
-
-
- } - > - -
-
-
-
-
- -
-
-
- Failed to load map -
-
-
- -
- - -
-
-
-
-
-
- - - - - - -
-
-
-
- -
-
-
- - - - -`;