From 7571552836c42fa2024ffb0b48af76b907a50d4d Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 5 Apr 2022 18:12:37 +0100 Subject: [PATCH 01/34] Upgrade matrix-js-sdk to 16.0.2-rc.1 --- package.json | 2 +- yarn.lock | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index abba2bb75356..7f88cec033a0 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "maplibre-gl": "^1.15.2", "matrix-analytics-events": "github:matrix-org/matrix-analytics-events.git#daad3faed54f0b1f1e026a7498b4653e4d01cd90", "matrix-events-sdk": "^0.0.1-beta.7", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-js-sdk": "16.0.2-rc.1", "matrix-widget-api": "^0.1.0-beta.18", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/yarn.lock b/yarn.lock index 00e139cea36c..808ea04e44cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6285,9 +6285,10 @@ matrix-events-sdk@^0.0.1-beta.7: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934" integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA== -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "16.0.1" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/bdc3da1fac3cb454b409325b6a50cd869b3d5d57" +matrix-js-sdk@16.0.2-rc.1: + version "16.0.2-rc.1" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-16.0.2-rc.1.tgz#b5c748fc9bd95b97d3f13b1a63860c0033bee35d" + integrity sha512-dmrP+leQKLKGg0s/tOEHkWMVz9N3lrccgi3wI4XGiGi0hI8/xv/Y5est6hcpg5axOa1JyD1KqqV0o41whdb6PQ== dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" From f2368792393b7d3c6bb9b5e12e6f87c6dae0e430 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 5 Apr 2022 18:13:44 +0100 Subject: [PATCH 02/34] Prepare changelog for v3.42.2-rc.1 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba3cac6c70f0..1a32b21734b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +Changes in [3.42.2-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.42.2-rc.1) (2022-04-05) +=============================================================================================================== + Changes in [3.42.1-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.42.1-rc.1) (2022-03-22) =============================================================================================================== From e4df547c3127eb172556b9f0c59015e6e24da571 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 5 Apr 2022 18:13:45 +0100 Subject: [PATCH 03/34] v3.42.2-rc.1 --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 7f88cec033a0..6382d790fe79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.42.1", + "version": "3.42.2-rc.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -25,7 +25,7 @@ "bin": { "reskindex": "scripts/reskindex.js" }, - "main": "./src/index.ts", + "main": "./lib/index.ts", "matrix_src_main": "./src/index.ts", "matrix_lib_main": "./lib/index.ts", "matrix_lib_typings": "./lib/index.d.ts", @@ -237,5 +237,6 @@ "text", "json" ] - } + }, + "typings": "./lib/index.d.ts" } From 44d446be890222c8897cb7b06ed5292d297451b6 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 11 Apr 2022 10:28:43 +0200 Subject: [PATCH 04/34] Live location sharing - set replaces relation when stopping beacon (#8266) * set replaces relation on stopping beacon Signed-off-by: Kerry Archibald * update tests for stopBeacon Signed-off-by: Kerry Archibald --- src/stores/OwnBeaconStore.ts | 14 ++++++++++++-- test/stores/OwnBeaconStore-test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index cca428f83ead..0bb76afdd43f 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -20,6 +20,7 @@ import { BeaconIdentifier, BeaconEvent, MatrixEvent, + RelationType, Room, RoomMember, RoomState, @@ -447,11 +448,20 @@ export class OwnBeaconStore extends AsyncStoreWithClient { ...update, }; - const updateContent = makeBeaconInfoContent(timeout, + const newContent = makeBeaconInfoContent(timeout, live, description, assetType, - timestamp); + timestamp, + ); + const updateContent = { + ...newContent, + "m.new_content": newContent, + "m.relates_to": { + "rel_type": RelationType.Replace, + "event_id": beacon.beaconInfoId, + }, + }; await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, updateContent); }; diff --git a/test/stores/OwnBeaconStore-test.ts b/test/stores/OwnBeaconStore-test.ts index 6a95c82ad079..b5713f6348f2 100644 --- a/test/stores/OwnBeaconStore-test.ts +++ b/test/stores/OwnBeaconStore-test.ts @@ -20,6 +20,7 @@ import { BeaconEvent, getBeaconInfoIdentifier, MatrixEvent, + RelationType, RoomStateEvent, RoomMember, } from "matrix-js-sdk/src/matrix"; @@ -472,6 +473,14 @@ describe('OwnBeaconStore', () => { const expectedUpdateContent = { ...prevEventContent, live: false, + ["m.new_content"]: { + ...prevEventContent, + live: false, + }, + ["m.relates_to"]: { + event_id: alicesRoom1BeaconInfo.getId(), + rel_type: RelationType.Replace, + }, }; expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalledWith( room1Id, @@ -641,6 +650,14 @@ describe('OwnBeaconStore', () => { const expectedUpdateContent = { ...prevEventContent, live: false, + ["m.new_content"]: { + ...prevEventContent, + live: false, + }, + ["m.relates_to"]: { + event_id: alicesRoom1BeaconInfo.getId(), + rel_type: RelationType.Replace, + }, }; expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalledWith( room1Id, @@ -666,6 +683,14 @@ describe('OwnBeaconStore', () => { const expectedUpdateContent = { ...prevEventContent, live: false, + ["m.new_content"]: { + ...prevEventContent, + live: false, + }, + ["m.relates_to"]: { + event_id: alicesRoom1BeaconInfo.getId(), + rel_type: RelationType.Replace, + }, }; expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalledWith( room1Id, From df20821fd61cf3fbbbee26a4a6ede1f29114e623 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 11 Apr 2022 10:29:07 +0200 Subject: [PATCH 05/34] Live location sharing - extract zoom buttons into component (#8235) * extract out zoombuttons component * newline Signed-off-by: Kerry Archibald * stylelint Signed-off-by: Kerry Archibald * remove skinned sdk Signed-off-by: Kerry Archibald --- __mocks__/maplibre-gl.js | 2 + res/css/_components.scss | 1 + .../views/location/_ZoomButtons.scss | 45 ++++++++++++ res/img/element-icons/minus-button.svg | 2 +- res/img/element-icons/plus-button.svg | 2 +- src/components/views/location/ZoomButtons.tsx | 58 +++++++++++++++ .../views/location/ZoomButtons-test.tsx | 63 ++++++++++++++++ .../__snapshots__/ZoomButtons-test.tsx.snap | 71 +++++++++++++++++++ 8 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 res/css/components/views/location/_ZoomButtons.scss create mode 100644 src/components/views/location/ZoomButtons.tsx create mode 100644 test/components/views/location/ZoomButtons-test.tsx create mode 100644 test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index 8686089825d5..716db4b6651d 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -4,6 +4,8 @@ const { LngLat, NavigationControl } = require('maplibre-gl'); class MockMap extends EventEmitter { addControl = jest.fn(); removeControl = jest.fn(); + zoomIn = jest.fn(); + zoomOut = jest.fn(); } const MockMapInstance = new MockMap(); diff --git a/res/css/_components.scss b/res/css/_components.scss index 09a5fd6e148d..79efb3e89bc5 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -13,6 +13,7 @@ @import "./components/views/location/_Marker.scss"; @import "./components/views/location/_ShareDialogButtons.scss"; @import "./components/views/location/_ShareType.scss"; +@import "./components/views/location/_ZoomButtons.scss"; @import "./components/views/spaces/_QuickThemeSwitcher.scss"; @import "./structures/_AutoHideScrollbar.scss"; @import "./structures/_BackdropPanel.scss"; diff --git a/res/css/components/views/location/_ZoomButtons.scss b/res/css/components/views/location/_ZoomButtons.scss new file mode 100644 index 000000000000..59d52477f97f --- /dev/null +++ b/res/css/components/views/location/_ZoomButtons.scss @@ -0,0 +1,45 @@ +/* +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_ZoomButtons { + position: absolute; + bottom: $spacing-32; + right: $spacing-24; +} + +.mx_ZoomButtons_button { + @mixin ButtonResetDefault; + + margin-top: $spacing-8; + border-radius: 4px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + + height: 24px; + width: 24px; + + background: $background; + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.25); +} + +.mx_ZoomButtons_icon { + height: 10px; + width: 10px; + + color: $primary-content; +} diff --git a/res/img/element-icons/minus-button.svg b/res/img/element-icons/minus-button.svg index ca61c23b76b8..6e7ea87c0b1b 100644 --- a/res/img/element-icons/minus-button.svg +++ b/res/img/element-icons/minus-button.svg @@ -1,3 +1,3 @@ - + diff --git a/res/img/element-icons/plus-button.svg b/res/img/element-icons/plus-button.svg index cbc25c4553e6..9a14c85ee5e9 100644 --- a/res/img/element-icons/plus-button.svg +++ b/res/img/element-icons/plus-button.svg @@ -1,3 +1,3 @@ - + diff --git a/src/components/views/location/ZoomButtons.tsx b/src/components/views/location/ZoomButtons.tsx new file mode 100644 index 000000000000..80d2883ad6e2 --- /dev/null +++ b/src/components/views/location/ZoomButtons.tsx @@ -0,0 +1,58 @@ +/* +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 from 'react'; +import maplibregl from 'maplibre-gl'; + +import { _t } from '../../../languageHandler'; +import AccessibleButton from '../elements/AccessibleButton'; +import { Icon as PlusIcon } from '../../../../res/img/element-icons/plus-button.svg'; +import { Icon as MinusIcon } from '../../../../res/img/element-icons/minus-button.svg'; + +interface Props { + map: maplibregl.Map; +} + +const ZoomButtons: React.FC = ({ map }) => { + const onZoomIn = () => { + map.zoomIn(); + }; + + const onZoomOut = () => { + map.zoomOut(); + }; + + return
+ + + + + + +
; +}; + +export default ZoomButtons; diff --git a/test/components/views/location/ZoomButtons-test.tsx b/test/components/views/location/ZoomButtons-test.tsx new file mode 100644 index 000000000000..62f488f43cff --- /dev/null +++ b/test/components/views/location/ZoomButtons-test.tsx @@ -0,0 +1,63 @@ +/* +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 from 'react'; +import { mount } from 'enzyme'; +import maplibregl from 'maplibre-gl'; +import { act } from 'react-dom/test-utils'; + +import ZoomButtons from '../../../../src/components/views/location/ZoomButtons'; +import { findByTestId } from '../../../test-utils'; + +describe('', () => { + const mockMap = new maplibregl.Map(); + const defaultProps = { + map: mockMap, + }; + const getComponent = (props = {}) => + mount(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders buttons', () => { + const component = getComponent(); + expect(component).toMatchSnapshot(); + }); + + it('calls map zoom in on zoom in click', () => { + const component = getComponent(); + + act(() => { + findByTestId(component, 'map-zoom-in-button').at(0).simulate('click'); + }); + + expect(mockMap.zoomIn).toHaveBeenCalled(); + expect(component).toBeTruthy(); + }); + + it('calls map zoom out on zoom out click', () => { + const component = getComponent(); + + act(() => { + findByTestId(component, 'map-zoom-out-button').at(0).simulate('click'); + }); + + expect(mockMap.zoomOut).toHaveBeenCalled(); + expect(component).toBeTruthy(); + }); +}); diff --git a/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap b/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap new file mode 100644 index 000000000000..ba5a0b46992c --- /dev/null +++ b/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders buttons 1`] = ` + +
+ +
+
+
+ + +
+
+
+ +
+ +`; From 94385169f1470d4f7c022a22becda66f672a97d6 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 11 Apr 2022 10:29:24 +0200 Subject: [PATCH 06/34] Live location sharing - smart location marker (#8232) * extract location markers into generic Marker Signed-off-by: Kerry Archibald * wrap marker in smartmarker Signed-off-by: Kerry Archibald * test smartmarker Signed-off-by: Kerry Archibald * remove skinned-sdk Signed-off-by: Kerry Archibald * lint Signed-off-by: Kerry Archibald * better types for LocationBodyContent Signed-off-by: Kerry Archibald --- __mocks__/maplibre-gl.js | 3 +- .../views/location/LocationViewDialog.tsx | 4 +- src/components/views/location/Marker.tsx | 5 +- src/components/views/location/SmartMarker.tsx | 83 +++++++++++++++++++ .../views/messages/MLocationBody.tsx | 32 ++++--- src/utils/location/map.ts | 52 +++++++++--- .../views/location/SmartMarker-test.tsx | 80 ++++++++++++++++++ .../__snapshots__/Marker-test.tsx.snap | 4 +- .../__snapshots__/SmartMarker-test.tsx.snap | 61 ++++++++++++++ 9 files changed, 289 insertions(+), 35 deletions(-) create mode 100644 src/components/views/location/SmartMarker.tsx create mode 100644 test/components/views/location/SmartMarker-test.tsx create mode 100644 test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index 716db4b6651d..687f769a7052 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -16,10 +16,11 @@ const MockGeolocateInstance = new MockGeolocateControl(); const MockMarker = {} MockMarker.setLngLat = jest.fn().mockReturnValue(MockMarker); MockMarker.addTo = jest.fn().mockReturnValue(MockMarker); +MockMarker.remove = jest.fn().mockReturnValue(MockMarker); module.exports = { Map: jest.fn().mockReturnValue(MockMapInstance), GeolocateControl: jest.fn().mockReturnValue(MockGeolocateInstance), Marker: jest.fn().mockReturnValue(MockMarker), LngLat, - NavigationControl + NavigationControl, }; diff --git a/src/components/views/location/LocationViewDialog.tsx b/src/components/views/location/LocationViewDialog.tsx index 236bd754d926..a3363e8f22a0 100644 --- a/src/components/views/location/LocationViewDialog.tsx +++ b/src/components/views/location/LocationViewDialog.tsx @@ -22,7 +22,7 @@ import BaseDialog from "../dialogs/BaseDialog"; import { IDialogProps } from "../dialogs/IDialogProps"; import { LocationBodyContent } from '../messages/MLocationBody'; import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; -import { parseGeoUri, locationEventGeoUri, createMap } from '../../../utils/location'; +import { parseGeoUri, locationEventGeoUri, createMapWithCoords } from '../../../utils/location'; interface IProps extends IDialogProps { matrixClient: MatrixClient; @@ -54,7 +54,7 @@ export default class LocationViewDialog extends React.Component this.props.matrixClient.on(ClientEvent.ClientWellKnown, this.updateStyleUrl); - this.map = createMap( + this.map = createMapWithCoords( this.coords, true, this.getBodyId(), diff --git a/src/components/views/location/Marker.tsx b/src/components/views/location/Marker.tsx index 7978e0d53304..bacade71cf41 100644 --- a/src/components/views/location/Marker.tsx +++ b/src/components/views/location/Marker.tsx @@ -33,9 +33,10 @@ interface Props { /** * Generic location marker */ -const Marker: React.FC = ({ id, roomMember, useMemberColor }) => { +const Marker = React.forwardRef(({ id, roomMember, useMemberColor }, ref) => { const memberColorClass = useMemberColor && roomMember ? getUserNameColorClass(roomMember.userId) : ''; return
= ({ id, roomMember, useMemberColor }) => { }
; -}; +}); export default Marker; diff --git a/src/components/views/location/SmartMarker.tsx b/src/components/views/location/SmartMarker.tsx new file mode 100644 index 000000000000..29207eb6e357 --- /dev/null +++ b/src/components/views/location/SmartMarker.tsx @@ -0,0 +1,83 @@ +/* +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, { useCallback, useEffect, useState } from 'react'; +import maplibregl from 'maplibre-gl'; +import { RoomMember } from 'matrix-js-sdk/src/matrix'; + +import { createMarker, parseGeoUri } from '../../../utils/location'; +import Marker from './Marker'; + +const useMapMarker = ( + map: maplibregl.Map, + geoUri: string, +): { marker?: maplibregl.Marker, onElementRef: (el: HTMLDivElement) => void } => { + const [marker, setMarker] = useState(); + + const onElementRef = useCallback((element: HTMLDivElement) => { + if (marker || !element) { + return; + } + const coords = parseGeoUri(geoUri); + const newMarker = createMarker(coords, element); + newMarker.addTo(map); + setMarker(newMarker); + }, [marker, geoUri, map]); + + useEffect(() => { + if (marker) { + const coords = parseGeoUri(geoUri); + marker.setLngLat({ lon: coords.longitude, lat: coords.latitude }); + } + }, [marker, geoUri]); + + useEffect(() => () => { + if (marker) { + marker.remove(); + } + }, [marker]); + + return { + marker, + onElementRef, + }; +}; + +interface SmartMarkerProps { + map: maplibregl.Map; + geoUri: string; + id?: string; + // renders MemberAvatar when provided + roomMember?: RoomMember; + // use member text color as background + useMemberColor?: boolean; +} + +/** + * Generic location marker + */ +const SmartMarker: React.FC = ({ id, map, geoUri, roomMember, useMemberColor }) => { + const { onElementRef } = useMapMarker(map, geoUri); + + return ; +}; + +export default SmartMarker; diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index 3a447c7944b0..94e27def215a 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -30,7 +30,7 @@ import Modal from '../../../Modal'; import { parseGeoUri, locationEventGeoUri, - createMap, + createMapWithCoords, getLocationShareErrorMessage, LocationShareError, } from '../../../utils/location'; @@ -75,7 +75,7 @@ export default class MLocationBody extends React.Component { this.context.on(ClientEvent.ClientWellKnown, this.updateStyleUrl); - this.map = createMap( + this.map = createMapWithCoords( this.coords, false, this.bodyId, @@ -138,18 +138,6 @@ export function isSelfLocation(locationContent: ILocationContent): boolean { return assetType == LocationAssetType.Self; } -interface ILocationBodyContentProps { - mxEvent: MatrixEvent; - bodyId: string; - markerId: string; - error: Error; - tooltip?: string; - onClick?: (event: React.MouseEvent) => void; - zoomButtons?: boolean; - onZoomIn?: () => void; - onZoomOut?: () => void; -} - export const LocationBodyFallbackContent: React.FC<{ event: MatrixEvent, error: Error }> = ({ error, event }) => { const errorType = error?.message as LocationShareError; const message = `${_t('Unable to load map')}: ${getLocationShareErrorMessage(errorType)}`; @@ -167,8 +155,18 @@ export const LocationBodyFallbackContent: React.FC<{ event: MatrixEvent, error:
; }; -export function LocationBodyContent(props: ILocationBodyContentProps): - React.ReactElement { +interface LocationBodyContentProps { + mxEvent: MatrixEvent; + bodyId: string; + markerId: string; + error: Error; + tooltip?: string; + onClick?: (event: React.MouseEvent) => void; + zoomButtons?: boolean; + onZoomIn?: () => void; + onZoomOut?: () => void; +} +export const LocationBodyContent: React.FC = (props) => { const mapDiv =
; -} +}; interface IZoomButtonsProps { onZoomIn: () => void; diff --git a/src/utils/location/map.ts b/src/utils/location/map.ts index f1e87acc282c..5627bf5c0edb 100644 --- a/src/utils/location/map.ts +++ b/src/utils/location/map.ts @@ -24,31 +24,61 @@ import { findMapStyleUrl } from "./findMapStyleUrl"; import { LocationShareError } from "./LocationShareErrors"; export const createMap = ( - coords: GeolocationCoordinates, interactive: boolean, bodyId: string, - markerId: string, onError: (error: Error) => void, ): maplibregl.Map => { try { const styleUrl = findMapStyleUrl(); - const coordinates = new maplibregl.LngLat(coords.longitude, coords.latitude); const map = new maplibregl.Map({ container: bodyId, style: styleUrl, - center: coordinates, zoom: 15, interactive, }); - new maplibregl.Marker({ - element: document.getElementById(markerId), - anchor: 'bottom', - offset: [0, -1], - }) - .setLngLat(coordinates) - .addTo(map); + map.on('error', (e) => { + logger.error( + "Failed to load map: check map_style_url in config.json has a " + + "valid URL and API key", + e.error, + ); + onError(new Error(LocationShareError.MapStyleUrlNotReachable)); + }); + + return map; + } catch (e) { + logger.error("Failed to render map", e); + throw e; + } +}; + +export const createMarker = (coords: GeolocationCoordinates, element: HTMLElement): maplibregl.Marker => { + const marker = new maplibregl.Marker({ + element, + anchor: 'bottom', + offset: [0, -1], + }).setLngLat({ lon: coords.longitude, lat: coords.latitude }); + return marker; +}; + +export const createMapWithCoords = ( + coords: GeolocationCoordinates, + interactive: boolean, + bodyId: string, + markerId: string, + onError: (error: Error) => void, +): maplibregl.Map => { + try { + const map = createMap(interactive, bodyId, onError); + + const coordinates = new maplibregl.LngLat(coords.longitude, coords.latitude); + // center on coordinates + map.setCenter(coordinates); + + const marker = createMarker(coords, document.getElementById(markerId)); + marker.addTo(map); map.on('error', (e) => { logger.error( diff --git a/test/components/views/location/SmartMarker-test.tsx b/test/components/views/location/SmartMarker-test.tsx new file mode 100644 index 000000000000..5fc48085b4b5 --- /dev/null +++ b/test/components/views/location/SmartMarker-test.tsx @@ -0,0 +1,80 @@ +/* +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 from 'react'; +import { mount } from 'enzyme'; +import { mocked } from 'jest-mock'; +import maplibregl from 'maplibre-gl'; + +import SmartMarker from '../../../../src/components/views/location/SmartMarker'; + +jest.mock('../../../../src/utils/location/findMapStyleUrl', () => ({ + findMapStyleUrl: jest.fn().mockReturnValue('tileserver.com'), +})); + +describe('', () => { + const mockMap = new maplibregl.Map(); + const mockMarker = new maplibregl.Marker(); + + const defaultProps = { + map: mockMap, + geoUri: 'geo:43.2,54.6', + }; + const getComponent = (props = {}) => + mount(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a marker on mount', () => { + const component = getComponent(); + expect(component).toMatchSnapshot(); + + component.setProps({}); + + // marker added only once + expect(maplibregl.Marker).toHaveBeenCalledTimes(1); + // set to correct position + expect(mockMarker.setLngLat).toHaveBeenCalledWith({ lon: 54.6, lat: 43.2 }); + // added to map + expect(mockMarker.addTo).toHaveBeenCalledWith(mockMap); + }); + + it('updates marker position on change', () => { + const component = getComponent({ geoUri: 'geo:40,50' }); + + component.setProps({ geoUri: 'geo:41,51' }); + component.setProps({ geoUri: 'geo:42,52' }); + + // marker added only once + expect(maplibregl.Marker).toHaveBeenCalledTimes(1); + // set positions + expect(mocked(mockMarker.setLngLat)).toHaveBeenCalledWith({ lat: 40, lon: 50 }); + expect(mocked(mockMarker.setLngLat)).toHaveBeenCalledWith({ lat: 41, lon: 51 }); + expect(mocked(mockMarker.setLngLat)).toHaveBeenCalledWith({ lat: 42, lon: 52 }); + }); + + it('removes marker on unmount', () => { + const component = getComponent(); + expect(component).toMatchSnapshot(); + + component.setProps({}); + + component.unmount(); + expect(mockMarker.remove).toHaveBeenCalled(); + }); +}); diff --git a/test/components/views/location/__snapshots__/Marker-test.tsx.snap b/test/components/views/location/__snapshots__/Marker-test.tsx.snap index 8030f6448e11..b7596d1af8a8 100644 --- a/test/components/views/location/__snapshots__/Marker-test.tsx.snap +++ b/test/components/views/location/__snapshots__/Marker-test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` renders with location icon when no room member 1`] = ` -
renders with location icon when no room member 1`] = ` />
- + `; diff --git a/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap b/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap new file mode 100644 index 000000000000..5b6bcf6a950b --- /dev/null +++ b/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` creates a marker on mount 1`] = ` + + +
+
+
+
+
+ + +`; + +exports[` removes marker on unmount 1`] = ` + + +
+
+
+
+
+ + +`; From 7ba991cd8cf690125f68c6027aeccfbe8e175336 Mon Sep 17 00:00:00 2001 From: Robin Kouwenhoven <33722212+rkouwenhoven@users.noreply.github.com> Date: Mon, 11 Apr 2022 11:10:16 +0200 Subject: [PATCH 07/34] Fix export of redacted polls (#8118) * Move RequiresClient from MatrixCapabilities to ElementWidgetCapabilities Signed-off-by: Robin Kouwenhoven * Fix export of redacted polls * Fix ESLint error * Add test cases for poll events * Add test cases for message events * Fix lint errors * Fix i18n error * Revert "Move RequiresClient from MatrixCapabilities to ElementWidgetCapabilities" This reverts commit 920f80a2d4385656925987ccc9d37420255405c1. Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> Co-authored-by: Kerry --- src/TextForEvent.tsx | 46 +++++++++++++++++++--------- src/i18n/strings/en_EN.json | 4 +-- test/TextForEvent-test.ts | 60 +++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 998ed20b7496..bb6d9eab3c78 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -317,16 +317,7 @@ function textForMessageEvent(ev: MatrixEvent): () => string | null { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); let message = ev.getContent().body; if (ev.isRedacted()) { - message = _t("Message deleted"); - const unsigned = ev.getUnsigned(); - const redactedBecauseUserId = unsigned?.redacted_because?.sender; - if (redactedBecauseUserId && redactedBecauseUserId !== ev.getSender()) { - const room = MatrixClientPeg.get().getRoom(ev.getRoomId()); - const sender = room?.getMember(redactedBecauseUserId); - message = _t("Message deleted by %(name)s", { - name: sender?.name || redactedBecauseUserId, - }); - } + message = textForRedactedPollAndMessageEvent(ev); } if (SettingsStore.isEnabled("feature_extensible_events")) { @@ -727,11 +718,38 @@ export function textForLocationEvent(event: MatrixEvent): () => string | null { }); } +function textForRedactedPollAndMessageEvent(ev: MatrixEvent): string { + let message = _t("Message deleted"); + const unsigned = ev.getUnsigned(); + const redactedBecauseUserId = unsigned?.redacted_because?.sender; + if (redactedBecauseUserId && redactedBecauseUserId !== ev.getSender()) { + const room = MatrixClientPeg.get().getRoom(ev.getRoomId()); + const sender = room?.getMember(redactedBecauseUserId); + message = _t("Message deleted by %(name)s", { + name: sender?.name || redactedBecauseUserId, + }); + } + + return message; +} + function textForPollStartEvent(event: MatrixEvent): () => string | null { - return () => _t("%(senderName)s has started a poll - %(pollQuestion)s", { - senderName: getSenderName(event), - pollQuestion: (event.unstableExtensibleEvent as PollStartEvent)?.question?.text, - }); + return () => { + let message = ''; + + if (event.isRedacted()) { + message = textForRedactedPollAndMessageEvent(event); + const senderDisplayName = event.sender?.name ?? event.getSender(); + message = senderDisplayName + ': ' + message; + } else { + message = _t("%(senderName)s has started a poll - %(pollQuestion)s", { + senderName: getSenderName(event), + pollQuestion: (event.unstableExtensibleEvent as PollStartEvent)?.question?.text, + }); + } + + return message; + }; } function textForPollEndEvent(event: MatrixEvent): () => string | null { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 32e7b1affe9c..86c35a5cdd12 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -525,8 +525,6 @@ "%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s set the server ACLs for this room.", "%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s changed the server ACLs for this room.", "🎉 All servers are banned from participating! This room can no longer be used.": "🎉 All servers are banned from participating! This room can no longer be used.", - "Message deleted": "Message deleted", - "Message deleted by %(name)s": "Message deleted by %(name)s", "%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.", "%(senderDisplayName)s sent a sticker.": "%(senderDisplayName)s sent a sticker.", "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.", @@ -575,6 +573,8 @@ "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", "%(senderName)s has shared their location": "%(senderName)s has shared their location", + "Message deleted": "Message deleted", + "Message deleted by %(name)s": "Message deleted by %(name)s", "%(senderName)s has started a poll - %(pollQuestion)s": "%(senderName)s has started a poll - %(pollQuestion)s", "%(senderName)s has ended a poll": "%(senderName)s has ended a poll", "Light": "Light", diff --git a/test/TextForEvent-test.ts b/test/TextForEvent-test.ts index 11cb74928d59..f9e4eba3240d 100644 --- a/test/TextForEvent-test.ts +++ b/test/TextForEvent-test.ts @@ -379,4 +379,64 @@ describe('TextForEvent', () => { expect(textForEvent(event)).toEqual(result); }); }); + + describe("textForPollStartEvent()", () => { + let pollEvent; + + beforeEach(() => { + pollEvent = new MatrixEvent({ + type: 'org.matrix.msc3381.poll.start', + sender: '@a', + content: { + 'org.matrix.msc3381.poll.start': { + answers: [ + { 'org.matrix.msc1767.text': 'option1' }, + { 'org.matrix.msc1767.text': 'option2' }, + ], + question: { + 'body': 'Test poll name', + 'msgtype': 'm.text', + 'org.matrix.msc1767.text': 'Test poll name', + }, + }, + }, + }); + }); + + it("returns correct message for redacted poll start", () => { + pollEvent.makeRedacted(pollEvent); + + expect(textForEvent(pollEvent)).toEqual('@a: Message deleted'); + }); + + it("returns correct message for normal poll start", () => { + expect(textForEvent(pollEvent)).toEqual('@a has started a poll - '); + }); + }); + + describe("textForMessageEvent()", () => { + let messageEvent; + + beforeEach(() => { + messageEvent = new MatrixEvent({ + type: 'm.room.message', + sender: '@a', + content: { + 'body': 'test message', + 'msgtype': 'm.text', + 'org.matrix.msc1767.text': 'test message', + }, + }); + }); + + it("returns correct message for redacted message", () => { + messageEvent.makeRedacted(messageEvent); + + expect(textForEvent(messageEvent)).toEqual('@a: Message deleted'); + }); + + it("returns correct message for normal message", () => { + expect(textForEvent(messageEvent)).toEqual('@a: test message'); + }); + }); }); From aecd71ad15236b5bca1d00187c0d6081860126dc Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 11 Apr 2022 11:16:32 +0200 Subject: [PATCH 08/34] Live location sharing - update beacon tile with latest location (#8265) * add useBeacon hook Signed-off-by: Kerry Archibald * update message tile types to work with function comp with ref Signed-off-by: Kerry Archibald * use beacon hook in beacon body Signed-off-by: Kerry Archibald * update beacon body with (textual) latest locations, test Signed-off-by: Kerry Archibald * language in comment Signed-off-by: Kerry Archibald * comments Signed-off-by: Kerry Archibald * copyright Signed-off-by: Kerry Archibald --- src/components/views/messages/IBodyProps.ts | 3 + src/components/views/messages/MBeaconBody.tsx | 82 ++++-- .../views/messages/MessageEvent.tsx | 4 +- src/stores/OwnBeaconStore.ts | 3 +- src/utils/beacon/index.ts | 1 + src/utils/beacon/useBeacon.ts | 72 ++++++ .../__snapshots__/SmartMarker-test.tsx.snap | 4 + .../views/messages/MBeaconBody-test.tsx | 235 ++++++++++++++++++ .../__snapshots__/MBeaconBody-test.tsx.snap | 49 ++++ 9 files changed, 424 insertions(+), 29 deletions(-) create mode 100644 src/utils/beacon/useBeacon.ts create mode 100644 test/components/views/messages/MBeaconBody-test.tsx create mode 100644 test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap diff --git a/src/components/views/messages/IBodyProps.ts b/src/components/views/messages/IBodyProps.ts index 76c823b18511..2487f2c1a919 100644 --- a/src/components/views/messages/IBodyProps.ts +++ b/src/components/views/messages/IBodyProps.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import React, { LegacyRef } from "react"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Relations } from "matrix-js-sdk/src/models/relations"; @@ -52,4 +53,6 @@ export interface IBodyProps { // helper function to access relations for this event getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations; + + ref?: React.RefObject | LegacyRef; } diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx index ec601aa29877..bbd7fb446f57 100644 --- a/src/components/views/messages/MBeaconBody.tsx +++ b/src/components/views/messages/MBeaconBody.tsx @@ -15,41 +15,71 @@ limitations under the License. */ import React from 'react'; -import { Beacon, getBeaconInfoIdentifier } from 'matrix-js-sdk/src/matrix'; +import { BeaconEvent, MatrixEvent } from 'matrix-js-sdk/src/matrix'; +import { BeaconLocationState } from 'matrix-js-sdk/src/content-helpers'; -import MatrixClientContext from '../../../contexts/MatrixClientContext'; import { IBodyProps } from "./IBodyProps"; +import { useEventEmitterState } from '../../../hooks/useEventEmitter'; +import { useBeacon } from '../../../utils/beacon'; -export default class MLocationBody extends React.Component { - public static contextType = MatrixClientContext; - public context!: React.ContextType; - private beacon: Beacon | undefined; - private roomId: string; - private beaconIdentifier: string; +const useBeaconState = (beaconInfoEvent: MatrixEvent): { + hasBeacon: boolean; + description?: string; + latestLocationState?: BeaconLocationState; + isLive?: boolean; +} => { + const beacon = useBeacon(beaconInfoEvent); - constructor(props: IBodyProps) { - super(props); + const isLive = useEventEmitterState( + beacon, + BeaconEvent.LivenessChange, + () => beacon?.isLive); - this.roomId = props.mxEvent.getRoomId(); + const latestLocationState = useEventEmitterState( + beacon, + BeaconEvent.LocationUpdate, + () => beacon?.latestLocationState); - this.beaconIdentifier = getBeaconInfoIdentifier(props.mxEvent); + if (!beacon) { + return { + hasBeacon: false, + }; } - componentDidMount() { - const roomState = this.context.getRoom(this.roomId)?.currentState; + const { description } = beacon.beaconInfo; - const beacon = roomState?.beacons.get(this.beaconIdentifier); + return { + hasBeacon: true, + description, + isLive, + latestLocationState, + }; +}; - this.beacon = beacon; - } +const MBeaconBody: React.FC = React.forwardRef(({ mxEvent, ...rest }, ref) => { + const { + hasBeacon, + isLive, + description, + latestLocationState, + } = useBeaconState(mxEvent); - render(): React.ReactElement { - if (!this.beacon) { - // TODO loading and error states - return null; - } - // TODO everything else :~) - const description = this.beacon.beaconInfo.description; - return
{ description }
; + if (!hasBeacon || !isLive) { + // TODO stopped, error states + return Beacon stopped or replaced; } -} + + return ( + // TODO nice map +
+ { mxEvent.getId() }  + Beacon "{ description }" + { latestLocationState ? + { `${latestLocationState.uri} at ${latestLocationState.timestamp}` } : + Waiting for location } +
+ ); +}); + +export default MBeaconBody; + diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 319a04487314..2bafadf5178f 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -94,7 +94,7 @@ export default class MessageEvent extends React.Component implements IMe }; } - private get evTypes(): Record { + private get evTypes(): Record>> { return { [EventType.Sticker]: MStickerBody, [M_POLL_START.name]: MPollBody, @@ -122,7 +122,7 @@ export default class MessageEvent extends React.Component implements IMe const content = this.props.mxEvent.getContent(); const type = this.props.mxEvent.getType(); const msgtype = content.msgtype; - let BodyType: typeof React.Component | ReactAnyComponent = RedactedBody; + let BodyType: React.ComponentType> | ReactAnyComponent = RedactedBody; if (!this.props.mxEvent.isRedacted()) { // only resolve BodyType if event is not redacted if (type && this.evTypes[type]) { diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index 0bb76afdd43f..eabe9ea083bc 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -125,7 +125,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { protected async onReady(): Promise { this.matrixClient.on(BeaconEvent.LivenessChange, this.onBeaconLiveness); this.matrixClient.on(BeaconEvent.New, this.onNewBeacon); - this.matrixClient.removeListener(BeaconEvent.Update, this.onUpdateBeacon); + this.matrixClient.on(BeaconEvent.Update, this.onUpdateBeacon); this.matrixClient.on(RoomStateEvent.Members, this.onRoomStateMembers); this.initialiseBeaconState(); @@ -213,6 +213,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { } this.checkLiveness(); + beacon.monitorLiveness(); }; private onBeaconLiveness = (isLive: boolean, beacon: Beacon): void => { diff --git a/src/utils/beacon/index.ts b/src/utils/beacon/index.ts index 1308e0387824..62db92dc1a35 100644 --- a/src/utils/beacon/index.ts +++ b/src/utils/beacon/index.ts @@ -16,3 +16,4 @@ limitations under the License. export * from './duration'; export * from './geolocation'; +export * from './useBeacon'; diff --git a/src/utils/beacon/useBeacon.ts b/src/utils/beacon/useBeacon.ts new file mode 100644 index 000000000000..fbe5d2ff8a3a --- /dev/null +++ b/src/utils/beacon/useBeacon.ts @@ -0,0 +1,72 @@ +/* +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 { useContext, useEffect, useState } from "react"; +import { + Beacon, + BeaconEvent, + MatrixEvent, + getBeaconInfoIdentifier, +} from "matrix-js-sdk/src/matrix"; + +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { useEventEmitterState } from "../../hooks/useEventEmitter"; + +export const useBeacon = (beaconInfoEvent: MatrixEvent): Beacon | undefined => { + const matrixClient = useContext(MatrixClientContext); + const [beacon, setBeacon] = useState(); + + useEffect(() => { + const roomId = beaconInfoEvent.getRoomId(); + const beaconIdentifier = getBeaconInfoIdentifier(beaconInfoEvent); + + const room = matrixClient.getRoom(roomId); + const beaconInstance = room.currentState.beacons.get(beaconIdentifier); + + // TODO could this be less stupid? + + // Beacons are identified by their `state_key`, + // where `state_key` is always owner mxid for access control. + // Thus, only one beacon is allowed per-user per-room. + // See https://github.com/matrix-org/matrix-spec-proposals/pull/3672 + // When a user creates a new beacon any previous + // beacon is replaced and should assume a 'stopped' state + // Here we check that this event is the latest beacon for this user + // If it is not the beacon instance is set to undefined. + // Retired beacons don't get a beacon instance. + if (beaconInstance?.beaconInfoId === beaconInfoEvent.getId()) { + setBeacon(beaconInstance); + } else { + setBeacon(undefined); + } + }, [beaconInfoEvent, matrixClient]); + + // beacon update will fire when this beacon is superceded + // check the updated event id for equality to the matrix event + const beaconInstanceEventId = useEventEmitterState( + beacon, + BeaconEvent.Update, + () => beacon?.beaconInfoId, + ); + + useEffect(() => { + if (beaconInstanceEventId && beaconInstanceEventId !== beaconInfoEvent.getId()) { + setBeacon(undefined); + } + }, [beaconInstanceEventId, beaconInfoEvent]); + + return beacon; +}; diff --git a/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap b/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap index 5b6bcf6a950b..724e0b3fc881 100644 --- a/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap +++ b/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap @@ -10,6 +10,8 @@ exports[` creates a marker on mount 1`] = ` "_maxListeners": undefined, "addControl": [MockFunction], "removeControl": [MockFunction], + "zoomIn": [MockFunction], + "zoomOut": [MockFunction], Symbol(kCapture): false, } } @@ -40,6 +42,8 @@ exports[` removes marker on unmount 1`] = ` "_maxListeners": undefined, "addControl": [MockFunction], "removeControl": [MockFunction], + "zoomIn": [MockFunction], + "zoomOut": [MockFunction], Symbol(kCapture): false, } } diff --git a/test/components/views/messages/MBeaconBody-test.tsx b/test/components/views/messages/MBeaconBody-test.tsx new file mode 100644 index 000000000000..dc8ec3190317 --- /dev/null +++ b/test/components/views/messages/MBeaconBody-test.tsx @@ -0,0 +1,235 @@ +/* +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 from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { + BeaconEvent, + Room, + getBeaconInfoIdentifier, +} from 'matrix-js-sdk/src/matrix'; + +import MBeaconBody from '../../../../src/components/views/messages/MBeaconBody'; +import { findByTestId, getMockClientWithEventEmitter, makeBeaconEvent, makeBeaconInfoEvent } from '../../../test-utils'; +import { RoomPermalinkCreator } from '../../../../src/utils/permalinks/Permalinks'; +import { MediaEventHelper } from '../../../../src/utils/MediaEventHelper'; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; + +describe('', () => { + // 14.03.2022 16:15 + const now = 1647270879403; + // stable date for snapshots + jest.spyOn(global.Date, 'now').mockReturnValue(now); + const roomId = '!room:server'; + const aliceId = '@alice:server'; + + const mockClient = getMockClientWithEventEmitter({ + getUserId: jest.fn().mockReturnValue(aliceId), + getRoom: jest.fn(), + }); + + // make fresh rooms every time + // as we update room state + const makeRoomWithStateEvents = (stateEvents = []): Room => { + const room1 = new Room(roomId, mockClient, aliceId); + + room1.currentState.setStateEvents(stateEvents); + mockClient.getRoom.mockReturnValue(room1); + + return room1; + }; + + const defaultEvent = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: true }, + '$alice-room1-1', + ); + const defaultProps = { + mxEvent: defaultEvent, + highlights: [], + highlightLink: '', + onHeightChanged: jest.fn(), + onMessageAllowed: jest.fn(), + // we dont use these and they pollute the snapshots + permalinkCreator: {} as unknown as RoomPermalinkCreator, + mediaEventHelper: {} as unknown as MediaEventHelper, + }; + const getComponent = (props = {}) => + mount(, { + wrappingComponent: MatrixClientContext.Provider, + wrappingComponentProps: { value: mockClient }, + }); + + it('renders a live beacon with basic stub', () => { + const beaconInfoEvent = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: true }, + '$alice-room1-1', + ); + makeRoomWithStateEvents([beaconInfoEvent]); + const component = getComponent({ mxEvent: beaconInfoEvent }); + expect(component).toMatchSnapshot(); + }); + + it('renders stopped beacon UI for an explicitly stopped beacon', () => { + const beaconInfoEvent = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: false }, + '$alice-room1-1', + ); + makeRoomWithStateEvents([beaconInfoEvent]); + const component = getComponent({ mxEvent: beaconInfoEvent }); + expect(component.text()).toEqual("Beacon stopped or replaced"); + }); + + it('renders stopped beacon UI for an expired beacon', () => { + const beaconInfoEvent = makeBeaconInfoEvent(aliceId, + roomId, + // puts this beacons live period in the past + { isLive: true, timestamp: now - 600000, timeout: 500 }, + '$alice-room1-1', + ); + makeRoomWithStateEvents([beaconInfoEvent]); + const component = getComponent({ mxEvent: beaconInfoEvent }); + expect(component.text()).toEqual("Beacon stopped or replaced"); + }); + + it('renders stopped UI when a beacon event is not the latest beacon for a user', () => { + const aliceBeaconInfo1 = makeBeaconInfoEvent( + aliceId, + roomId, + // this one is a little older + { isLive: true, timestamp: now - 500 }, + '$alice-room1-1', + ); + aliceBeaconInfo1.event.origin_server_ts = now - 500; + const aliceBeaconInfo2 = makeBeaconInfoEvent( + aliceId, + roomId, + { isLive: true }, + '$alice-room1-2', + ); + + makeRoomWithStateEvents([aliceBeaconInfo1, aliceBeaconInfo2]); + + const component = getComponent({ mxEvent: aliceBeaconInfo1 }); + // beacon1 has been superceded by beacon2 + expect(component.text()).toEqual("Beacon stopped or replaced"); + }); + + it('renders stopped UI when a beacon event is replaced', () => { + const aliceBeaconInfo1 = makeBeaconInfoEvent( + aliceId, + roomId, + // this one is a little older + { isLive: true, timestamp: now - 500 }, + '$alice-room1-1', + ); + aliceBeaconInfo1.event.origin_server_ts = now - 500; + const aliceBeaconInfo2 = makeBeaconInfoEvent( + aliceId, + roomId, + { isLive: true }, + '$alice-room1-2', + ); + + const room = makeRoomWithStateEvents([aliceBeaconInfo1]); + const component = getComponent({ mxEvent: aliceBeaconInfo1 }); + + const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo1)); + // update alice's beacon with a new edition + // beacon instance emits + act(() => { + beaconInstance.update(aliceBeaconInfo2); + }); + + component.setProps({}); + + // beacon1 has been superceded by beacon2 + expect(component.text()).toEqual("Beacon stopped or replaced"); + }); + + describe('on liveness change', () => { + it('renders stopped UI when a beacon stops being live', () => { + const aliceBeaconInfo = makeBeaconInfoEvent( + aliceId, + roomId, + { isLive: true }, + '$alice-room1-1', + ); + + const room = makeRoomWithStateEvents([aliceBeaconInfo]); + const component = getComponent({ mxEvent: aliceBeaconInfo }); + + const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo)); + act(() => { + // @ts-ignore cheat to force beacon to not live + beaconInstance._isLive = false; + beaconInstance.emit(BeaconEvent.LivenessChange, false, beaconInstance); + }); + + component.setProps({}); + + // stopped UI + expect(component.text()).toEqual("Beacon stopped or replaced"); + }); + }); + + describe('latestLocationState', () => { + const aliceBeaconInfo = makeBeaconInfoEvent( + aliceId, + roomId, + { isLive: true }, + '$alice-room1-1', + ); + + const location1 = makeBeaconEvent( + aliceId, { beaconInfoId: aliceBeaconInfo.getId(), geoUri: 'geo:foo', timestamp: now + 1 }, + ); + const location2 = makeBeaconEvent( + aliceId, { beaconInfoId: aliceBeaconInfo.getId(), geoUri: 'geo:bar', timestamp: now + 10000 }, + ); + + it('renders a live beacon without a location correctly', () => { + makeRoomWithStateEvents([aliceBeaconInfo]); + const component = getComponent({ mxEvent: aliceBeaconInfo }); + + // loading map + expect(findByTestId(component, 'beacon-waiting-for-location').length).toBeTruthy(); + }); + + it('updates latest location', () => { + const room = makeRoomWithStateEvents([aliceBeaconInfo]); + const component = getComponent({ mxEvent: aliceBeaconInfo }); + + const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo)); + act(() => { + beaconInstance.addLocations([location1]); + component.setProps({}); + }); + + expect(component.text().includes('geo:foo')).toBeTruthy(); + + act(() => { + beaconInstance.addLocations([location2]); + component.setProps({}); + }); + + expect(component.text().includes('geo:bar')).toBeTruthy(); + }); + }); +}); diff --git a/test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap new file mode 100644 index 000000000000..81850608f6f1 --- /dev/null +++ b/test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders a live beacon with basic stub 1`] = ` + +
+ + $alice-room1-1 + +   + + Beacon " + " + + + Waiting for location + +
+
+`; From b760ec9392b6e25e012d0af6d39c9cc870b1bf35 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 11 Apr 2022 13:58:35 +0200 Subject: [PATCH 09/34] Location sharing - extract isSelfLocation util (#8279) * extract isSelfLocation into utils Signed-off-by: Kerry Archibald * replace use of isSelfLocation Signed-off-by: Kerry Archibald --- .../views/messages/MLocationBody.tsx | 12 +--- src/utils/location/index.ts | 1 + src/utils/location/isSelfLocation.ts | 23 ++++++ .../views/messages/MLocationBody-test.tsx | 59 +-------------- test/utils/location/isSelfLocation-test.ts | 72 +++++++++++++++++++ 5 files changed, 99 insertions(+), 68 deletions(-) create mode 100644 src/utils/location/isSelfLocation.ts create mode 100644 test/utils/location/isSelfLocation-test.ts diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index 94e27def215a..8ab065695c59 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -17,11 +17,6 @@ limitations under the License. import React from 'react'; import maplibregl from 'maplibre-gl'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { - M_ASSET, - LocationAssetType, - ILocationContent, -} from 'matrix-js-sdk/src/@types/location'; import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/client'; import { IBodyProps } from "./IBodyProps"; @@ -33,6 +28,7 @@ import { createMapWithCoords, getLocationShareErrorMessage, LocationShareError, + isSelfLocation, } from '../../../utils/location'; import LocationViewDialog from '../location/LocationViewDialog'; import TooltipTarget from '../elements/TooltipTarget'; @@ -132,12 +128,6 @@ export default class MLocationBody extends React.Component { } } -export function isSelfLocation(locationContent: ILocationContent): boolean { - const asset = M_ASSET.findIn(locationContent) as { type: string }; - const assetType = asset?.type ?? LocationAssetType.Self; - return assetType == LocationAssetType.Self; -} - export const LocationBodyFallbackContent: React.FC<{ event: MatrixEvent, error: Error }> = ({ error, event }) => { const errorType = error?.message as LocationShareError; const message = `${_t('Unable to load map')}: ${getLocationShareErrorMessage(errorType)}`; diff --git a/src/utils/location/index.ts b/src/utils/location/index.ts index 49c6d8112d8d..a6aeaa65d673 100644 --- a/src/utils/location/index.ts +++ b/src/utils/location/index.ts @@ -15,6 +15,7 @@ limitations under the License. */ export * from './findMapStyleUrl'; +export * from './isSelfLocation'; export * from './locationEventGeoUri'; export * from './LocationShareErrors'; export * from './map'; diff --git a/src/utils/location/isSelfLocation.ts b/src/utils/location/isSelfLocation.ts new file mode 100644 index 000000000000..d1e656e1a040 --- /dev/null +++ b/src/utils/location/isSelfLocation.ts @@ -0,0 +1,23 @@ +/* +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 { ILocationContent, LocationAssetType, M_ASSET } from "matrix-js-sdk/src/@types/location"; + +export const isSelfLocation = (locationContent: ILocationContent): boolean => { + const asset = M_ASSET.findIn(locationContent) as { type: string }; + const assetType = asset?.type ?? LocationAssetType.Self; + return assetType == LocationAssetType.Self; +}; diff --git a/test/components/views/messages/MLocationBody-test.tsx b/test/components/views/messages/MLocationBody-test.tsx index e80a73853477..11b8d707f2ff 100644 --- a/test/components/views/messages/MLocationBody-test.tsx +++ b/test/components/views/messages/MLocationBody-test.tsx @@ -17,21 +17,11 @@ limitations under the License. import React from 'react'; import { mount } from "enzyme"; import { mocked } from 'jest-mock'; -import { makeLocationContent } from "matrix-js-sdk/src/content-helpers"; -import { - M_ASSET, - LocationAssetType, - ILocationContent, - M_LOCATION, - M_TIMESTAMP, -} from "matrix-js-sdk/src/@types/location"; -import { TEXT_NODE_TYPE } from "matrix-js-sdk/src/@types/extensible_events"; +import { LocationAssetType } from "matrix-js-sdk/src/@types/location"; import maplibregl from 'maplibre-gl'; import { logger } from 'matrix-js-sdk/src/logger'; -import MLocationBody, { - isSelfLocation, -} from "../../../../src/components/views/messages/MLocationBody"; +import MLocationBody from "../../../../src/components/views/messages/MLocationBody"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper"; @@ -44,51 +34,6 @@ jest.mock("../../../../src/utils/WellKnownUtils", () => ({ })); describe("MLocationBody", () => { - describe("isSelfLocation", () => { - it("Returns true for a full m.asset event", () => { - const content = makeLocationContent("", '0'); - expect(isSelfLocation(content)).toBe(true); - }); - - it("Returns true for a missing m.asset", () => { - const content = { - body: "", - msgtype: "m.location", - geo_uri: "", - [M_LOCATION.name]: { uri: "" }, - [TEXT_NODE_TYPE.name]: "", - [M_TIMESTAMP.name]: 0, - // Note: no m.asset! - }; - expect(isSelfLocation(content as ILocationContent)).toBe(true); - }); - - it("Returns true for a missing m.asset type", () => { - const content = { - body: "", - msgtype: "m.location", - geo_uri: "", - [M_LOCATION.name]: { uri: "" }, - [TEXT_NODE_TYPE.name]: "", - [M_TIMESTAMP.name]: 0, - [M_ASSET.name]: { - // Note: no type! - }, - }; - expect(isSelfLocation(content as ILocationContent)).toBe(true); - }); - - it("Returns false for an unknown asset type", () => { - const content = makeLocationContent( - undefined, /* text */ - "geo:foo", - 0, - undefined, /* description */ - "org.example.unknown" as unknown as LocationAssetType); - expect(isSelfLocation(content)).toBe(false); - }); - }); - describe('', () => { describe('with error', () => { const mockClient = { diff --git a/test/utils/location/isSelfLocation-test.ts b/test/utils/location/isSelfLocation-test.ts new file mode 100644 index 000000000000..6fafc5e467b9 --- /dev/null +++ b/test/utils/location/isSelfLocation-test.ts @@ -0,0 +1,72 @@ +/* +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 { TEXT_NODE_TYPE } from "matrix-js-sdk/src/@types/extensible_events"; +import { + ILocationContent, + LocationAssetType, + M_ASSET, + M_LOCATION, + M_TIMESTAMP, +} from "matrix-js-sdk/src/@types/location"; +import { makeLocationContent } from "matrix-js-sdk/src/content-helpers"; + +import { isSelfLocation } from "../../../src/utils/location"; + +describe("isSelfLocation", () => { + it("Returns true for a full m.asset event", () => { + const content = makeLocationContent("", '0'); + expect(isSelfLocation(content)).toBe(true); + }); + + it("Returns true for a missing m.asset", () => { + const content = { + body: "", + msgtype: "m.location", + geo_uri: "", + [M_LOCATION.name]: { uri: "" }, + [TEXT_NODE_TYPE.name]: "", + [M_TIMESTAMP.name]: 0, + // Note: no m.asset! + }; + expect(isSelfLocation(content as ILocationContent)).toBe(true); + }); + + it("Returns true for a missing m.asset type", () => { + const content = { + body: "", + msgtype: "m.location", + geo_uri: "", + [M_LOCATION.name]: { uri: "" }, + [TEXT_NODE_TYPE.name]: "", + [M_TIMESTAMP.name]: 0, + [M_ASSET.name]: { + // Note: no type! + }, + }; + expect(isSelfLocation(content as ILocationContent)).toBe(true); + }); + + it("Returns false for an unknown asset type", () => { + const content = makeLocationContent( + undefined, /* text */ + "geo:foo", + 0, + undefined, /* description */ + "org.example.unknown" as unknown as LocationAssetType); + expect(isSelfLocation(content)).toBe(false); + }); +}); From f648140d0c727ca50846597b018e418728b3b6a7 Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 11 Apr 2022 12:39:47 +0000 Subject: [PATCH 10/34] Use a consistent alignment for all text items in a list (#8276) Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/messages/DisambiguatedProfile.tsx | 4 ++-- src/components/views/right_panel/UserInfo.tsx | 2 +- src/components/views/rooms/EntityTile.tsx | 6 +++--- src/components/views/rooms/RoomTile.tsx | 6 ++++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/views/messages/DisambiguatedProfile.tsx b/src/components/views/messages/DisambiguatedProfile.tsx index f376bbbd7a36..36850e916ea1 100644 --- a/src/components/views/messages/DisambiguatedProfile.tsx +++ b/src/components/views/messages/DisambiguatedProfile.tsx @@ -58,8 +58,8 @@ export default class DisambiguatedProfile extends React.Component { }); return ( -
- +
+ { rawDisplayName } { mxidElement } diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 021819683162..d36ba0f72896 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -1444,7 +1444,7 @@ const UserInfoHeader: React.FC<{

{ e2eIcon } - + { displayName }

diff --git a/src/components/views/rooms/EntityTile.tsx b/src/components/views/rooms/EntityTile.tsx index c06d47329995..dd8756f845fe 100644 --- a/src/components/views/rooms/EntityTile.tsx +++ b/src/components/views/rooms/EntityTile.tsx @@ -133,7 +133,7 @@ export default class EntityTile extends React.PureComponent { } nameEl = (
-
+
{ name }
{ presenceLabel } @@ -142,7 +142,7 @@ export default class EntityTile extends React.PureComponent { } else if (this.props.subtextLabel) { nameEl = (
-
+
{ name }
{ this.props.subtextLabel } @@ -150,7 +150,7 @@ export default class EntityTile extends React.PureComponent { ); } else { nameEl = ( -
{ name }
+
{ name }
); } diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 46aea8fa0df7..29476a55cf9e 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -683,8 +683,10 @@ export default class RoomTile extends React.PureComponent { const titleContainer = this.props.isMinimized ? null : (
-
- { name } +
+ + { name } +
{ subtitle }
From 944e11d7d6e4876f77b0b7f50c1580c0d25cf52f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 11 Apr 2022 16:51:04 +0100 Subject: [PATCH 11/34] Delete slate-formats.md (#8280) --- docs/slate-formats.md | 88 ------------------------------------------- 1 file changed, 88 deletions(-) delete mode 100644 docs/slate-formats.md diff --git a/docs/slate-formats.md b/docs/slate-formats.md deleted file mode 100644 index 7bb2fc9c5ff1..000000000000 --- a/docs/slate-formats.md +++ /dev/null @@ -1,88 +0,0 @@ -Guide to data types used by the Slate-based Rich Text Editor ------------------------------------------------------------- - -We always store the Slate editor state in its Value form. - -The schema for the Value is the same whether the editor is in MD or rich text mode, and is currently (rather arbitrarily) -dictated by the schema expected by slate-md-serializer, simply because it was the only bit of the pipeline which -has opinions on the schema. (slate-html-serializer lets you define how to serialize whatever schema you like). - -The BLOCK_TAGS and MARK_TAGS give the mapping from HTML tags to the schema's node types (for blocks, which describe -block content like divs, and marks, which describe inline formatted sections like spans). - -We use

as the parent tag for the message (XXX: although some tags are technically not allowed to be nested within p's) - -Various conversions are performed as content is moved between HTML, MD, and plaintext representations of HTML and MD. - -The primitives used are: - - * Markdown.js - models commonmark-formatted MD strings (as entered by the composer in MD mode) - * toHtml() - renders them to HTML suitable for sending on the wire - * isPlainText() - checks whether the parsed MD contains anything other than simple text. - * toPlainText() - renders MD to plain text in order to remove backslashes. Only works if the MD is already plaintext (otherwise it just emits HTML) - - * slate-html-serializer - * converts Values to HTML (serialising) using our schema rules - * converts HTML to Values (deserialising) using our schema rules - - * slate-md-serializer - * converts rich Values to MD strings (serialising) but using a non-commonmark generic MD dialect. - * This should use commonmark, but we use the serializer here for expedience rather than writing a commonmark one. - - * slate-plain-serializer - * converts Values to plain text strings (serialising them) by concatenating the strings together - * converts Values from plain text strings (deserialiasing them). - * Used to initialise the editor by deserializing "" into a Value. Apparently this is the idiomatic way to initialise a blank editor. - * Used (as a bodge) to turn a rich text editor into a MD editor, when deserialising the converted MD string of the editor into a value - - * PlainWithPillsSerializer - * A fork of slate-plain-serializer which is aware of Pills (hence the name) and Emoji. - * It can be configured to output Pills as: - * "plain": Pills are rendered via their 'completion' text - e.g. 'Matthew'; used for sending messages) - * "md": Pills are rendered as MD, e.g. [Matthew](https://matrix.to/#/@matthew:matrix.org) ) - * "id": Pills are rendered as IDs, e.g. '@matthew:matrix.org' (used for authoring / commands) - * Emoji nodes are converted to inline utf8 emoji. - -The actual conversion transitions are: - - * Quoting: - * The message being quoted is taken as HTML - * ...and deserialised into a Value - * ...and then serialised into MD via slate-md-serializer if the editor is in MD mode - - * Roundtripping between MD and rich text editor mode - * From MD to richtext (mdToRichEditorState): - * Serialise the MD-format Value to a MD string (converting pills to MD) with PlainWithPillsSerializer in 'md' mode - * Convert that MD string to HTML via Markdown.js - * Deserialise that Value to HTML via slate-html-serializer - * From richtext to MD (richToMdEditorState): - * Serialise the richtext-format Value to a MD string with slate-md-serializer (XXX: this should use commonmark) - * Deserialise that to a plain text value via slate-plain-serializer - - * Loading history in one format into an editor which is in the other format - * Uses the same functions as for roundtripping - - * Scanning the editor for a slash command - * If the editor is a single line node starting with /, then serialize it to a string with PlainWithPillsSerializer in 'id' mode - So that pills get converted to IDs suitable for commands being passed around - - * Sending messages - * In RT mode: - * If there is rich content, serialize the RT-format Value to HTML body via slate-html-serializer - * Serialize the RT-format Value to the plain text fallback via PlainWithPillsSerializer in 'plain' mode - * In MD mode: - * Serialize the MD-format Value into an MD string with PlainWithPillsSerializer in 'md' mode - * Parse the string with Markdown.js - * If it contains no formatting: - * Send as plaintext (as taken from Markdown.toPlainText()) - * Otherwise - * Send as HTML (as taken from Markdown.toHtml()) - * Serialize the RT-format Value to the plain text fallback via PlainWithPillsSerializer in 'plain' mode - - * Pasting HTML - * Deserialize HTML to a RT Value via slate-html-serializer - * In RT mode, insert it straight into the editor as a fragment - * In MD mode, serialise it to an MD string via slate-md-serializer and then insert the string into the editor as a fragment. - -The various scenarios and transitions could be drawn into a pretty diagram if one felt the urge, but hopefully the above -gives sufficient detail on how it's all meant to work. \ No newline at end of file From 9ba55d1d141088e8f2a9f4542840a76ac2824c20 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 11 Apr 2022 18:40:06 +0200 Subject: [PATCH 12/34] Live location sharing - consolidate maps (#8236) * extract location markers into generic Marker Signed-off-by: Kerry Archibald * wrap marker in smartmarker Signed-off-by: Kerry Archibald * test smartmarker Signed-off-by: Kerry Archibald * working map in location body Signed-off-by: Kerry Archibald * test Map Signed-off-by: Kerry Archibald * remove skinned sdk Signed-off-by: Kerry Archibald * update snaps with new mocks Signed-off-by: Kerry Archibald * use new ZoomButtons in MLocationBody Signed-off-by: Kerry Archibald * make LocationViewDialog map interactive Signed-off-by: Kerry Archibald * test MLocationBody Signed-off-by: Kerry Archibald * test LocationViewDialog Signed-off-by: Kerry Archibald * add copyrights, shrink snapshot Signed-off-by: Kerry Archibald * update comment Signed-off-by: Kerry Archibald * lint Signed-off-by: Kerry Archibald * lint Signed-off-by: Kerry Archibald --- __mocks__/maplibre-gl.js | 2 + .../views/dialogs/_LocationViewDialog.scss | 49 +----- .../views/location/LocationPicker.tsx | 1 + .../views/location/LocationViewDialog.tsx | 87 ++++----- src/components/views/location/Map.tsx | 101 +++++++++++ .../views/messages/MLocationBody.tsx | 156 ++++++----------- src/i18n/strings/en_EN.json | 4 +- src/utils/location/useMap.ts | 62 +++++++ .../views/location/LocationPicker-test.tsx | 2 +- .../location/LocationViewDialog-test.tsx | 56 ++++++ test/components/views/location/Map-test.tsx | 165 ++++++++++++++++++ .../LocationViewDialog-test.tsx.snap | 156 +++++++++++++++++ .../__snapshots__/SmartMarker-test.tsx.snap | 4 + .../__snapshots__/ZoomButtons-test.tsx.snap | 2 + .../views/messages/MLocationBody-test.tsx | 120 ++++++++++--- .../__snapshots__/MLocationBody-test.tsx.snap | 156 +++++++++++++++++ 16 files changed, 889 insertions(+), 234 deletions(-) create mode 100644 src/components/views/location/Map.tsx create mode 100644 src/utils/location/useMap.ts create mode 100644 test/components/views/location/LocationViewDialog-test.tsx create mode 100644 test/components/views/location/Map-test.tsx create mode 100644 test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index 687f769a7052..90a30968d9bd 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -6,6 +6,8 @@ class MockMap extends EventEmitter { removeControl = jest.fn(); zoomIn = jest.fn(); zoomOut = jest.fn(); + setCenter = jest.fn(); + setStyle = jest.fn(); } const MockMapInstance = new MockMap(); diff --git a/res/css/views/dialogs/_LocationViewDialog.scss b/res/css/views/dialogs/_LocationViewDialog.scss index e7cdaf88007e..600c3082657d 100644 --- a/res/css/views/dialogs/_LocationViewDialog.scss +++ b/res/css/views/dialogs/_LocationViewDialog.scss @@ -48,49 +48,10 @@ limitations under the License. background-color: $dialog-close-external-color; } } +} - .mx_MLocationBody { - position: absolute; - - .mx_MLocationBody_map { - width: 80vw; - height: 80vh; - } - - .mx_MLocationBody_zoomButtons { - position: absolute; - display: grid; - grid-template-columns: auto; - grid-row-gap: 8px; - - right: 24px; - bottom: 48px; - - .mx_AccessibleButton { - background-color: $background; - box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.25); - border-radius: 4px; - width: 24px; - height: 24px; - - .mx_MLocationBody_zoomButton { - background-color: $primary-content; - margin: 4px; - width: 16px; - height: 16px; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - } - - .mx_MLocationBody_plusButton { - mask-image: url('$(res)/img/element-icons/plus-button.svg'); - } - - .mx_MLocationBody_minusButton { - mask-image: url('$(res)/img/element-icons/minus-button.svg'); - } - } - } - } +.mx_LocationViewDialog_map { + width: 80vw; + height: 80vh; + border-radius: 8px; } diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index ffeb40774bd7..254ec335cbdc 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -225,6 +225,7 @@ class LocationPicker extends React.Component { return (

+ { this.props.shareType === LocationShareType.Pin &&
{ this.state.position ? _t("Click to move the pin") : _t("Click to drop a pin") } diff --git a/src/components/views/location/LocationViewDialog.tsx b/src/components/views/location/LocationViewDialog.tsx index a3363e8f22a0..9719013ee4cd 100644 --- a/src/components/views/location/LocationViewDialog.tsx +++ b/src/components/views/location/LocationViewDialog.tsx @@ -16,13 +16,14 @@ limitations under the License. import React from 'react'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { ClientEvent, IClientWellKnown, MatrixClient } from 'matrix-js-sdk/src/client'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; import BaseDialog from "../dialogs/BaseDialog"; import { IDialogProps } from "../dialogs/IDialogProps"; -import { LocationBodyContent } from '../messages/MLocationBody'; -import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; -import { parseGeoUri, locationEventGeoUri, createMapWithCoords } from '../../../utils/location'; +import { locationEventGeoUri, isSelfLocation } from '../../../utils/location'; +import Map from './Map'; +import SmartMarker from './SmartMarker'; +import ZoomButtons from './ZoomButtons'; interface IProps extends IDialogProps { matrixClient: MatrixClient; @@ -34,78 +35,54 @@ interface IState { } export default class LocationViewDialog extends React.Component { - private coords: GeolocationCoordinates; - private map?: maplibregl.Map; - constructor(props: IProps) { super(props); - this.coords = parseGeoUri(locationEventGeoUri(this.props.mxEvent)); - this.map = null; this.state = { error: undefined, }; } - componentDidMount() { - if (this.state.error) { - return; - } - - this.props.matrixClient.on(ClientEvent.ClientWellKnown, this.updateStyleUrl); - - this.map = createMapWithCoords( - this.coords, - true, - this.getBodyId(), - this.getMarkerId(), - (e: Error) => this.setState({ error: e }), - ); - } - - componentWillUnmount() { - this.props.matrixClient.off(ClientEvent.ClientWellKnown, this.updateStyleUrl); - } - - private updateStyleUrl = (clientWellKnown: IClientWellKnown) => { - const style = tileServerFromWellKnown(clientWellKnown)?.["map_style_url"]; - if (style) { - this.map?.setStyle(style); - } - }; - private getBodyId = () => { return `mx_LocationViewDialog_${this.props.mxEvent.getId()}`; }; - private getMarkerId = () => { - return `mx_MLocationViewDialog_marker_${this.props.mxEvent.getId()}`; - }; - - private onZoomIn = () => { - this.map?.zoomIn(); - }; - - private onZoomOut = () => { - this.map?.zoomOut(); + private onError = (error) => { + this.setState({ error }); }; render() { + const { mxEvent } = this.props; + + // only pass member to marker when should render avatar marker + const markerRoomMember = isSelfLocation(mxEvent.getContent()) ? mxEvent.sender : undefined; + const geoUri = locationEventGeoUri(mxEvent); return ( - + + { + ({ map }) => + <> + + + + } + ); } diff --git a/src/components/views/location/Map.tsx b/src/components/views/location/Map.tsx new file mode 100644 index 000000000000..8776e8e82644 --- /dev/null +++ b/src/components/views/location/Map.tsx @@ -0,0 +1,101 @@ +/* +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, { ReactNode, useContext, useEffect } from 'react'; +import classNames from 'classnames'; +import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/matrix'; +import { logger } from 'matrix-js-sdk/src/logger'; + +import MatrixClientContext from '../../../contexts/MatrixClientContext'; +import { useEventEmitterState } from '../../../hooks/useEventEmitter'; +import { parseGeoUri } from '../../../utils/location'; +import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; +import { useMap } from '../../../utils/location/useMap'; + +const useMapWithStyle = ({ id, centerGeoUri, onError, interactive }) => { + const bodyId = `mx_Map_${id}`; + + // style config + const context = useContext(MatrixClientContext); + const mapStyleUrl = useEventEmitterState( + context, + ClientEvent.ClientWellKnown, + (clientWellKnown: IClientWellKnown) => tileServerFromWellKnown(clientWellKnown)?.["map_style_url"], + ); + + const map = useMap({ interactive, bodyId, onError }); + + useEffect(() => { + if (mapStyleUrl && map) { + map.setStyle(mapStyleUrl); + } + }, [mapStyleUrl, map]); + + useEffect(() => { + if (map && centerGeoUri) { + try { + const coords = parseGeoUri(centerGeoUri); + map.setCenter({ lon: coords.longitude, lat: coords.latitude }); + } catch (error) { + logger.error('Could not set map center', centerGeoUri); + } + } + }, [map, centerGeoUri]); + + return { + map, + bodyId, + }; +}; + +interface MapProps { + id: string; + interactive?: boolean; + centerGeoUri?: string; + className?: string; + onClick?: () => void; + onError?: (error: Error) => void; + children?: (renderProps: { + map: maplibregl.Map; + }) => ReactNode; +} + +const Map: React.FC = ({ + centerGeoUri, className, id, onError, onClick, children, interactive, +}) => { + const { map, bodyId } = useMapWithStyle({ centerGeoUri, onError, id, interactive }); + + const onMapClick = ( + event: React.MouseEvent, + ) => { + // Eat click events when clicking the attribution button + const target = event.target as Element; + if (target.classList.contains("maplibregl-ctrl-attrib-button")) { + return; + } + + onClick && onClick(); + }; + + return
+ { !!children && !!map && children({ map }) } +
; +}; + +export default Map; diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index 8ab065695c59..94abc1c7a888 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -15,28 +15,23 @@ limitations under the License. */ import React from 'react'; -import maplibregl from 'maplibre-gl'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/client'; -import { IBodyProps } from "./IBodyProps"; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; import { - parseGeoUri, locationEventGeoUri, - createMapWithCoords, getLocationShareErrorMessage, LocationShareError, isSelfLocation, } from '../../../utils/location'; -import LocationViewDialog from '../location/LocationViewDialog'; +import MatrixClientContext from '../../../contexts/MatrixClientContext'; import TooltipTarget from '../elements/TooltipTarget'; import { Alignment } from '../elements/Tooltip'; -import AccessibleButton from '../elements/AccessibleButton'; -import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; -import MatrixClientContext from '../../../contexts/MatrixClientContext'; -import Marker from '../location/Marker'; +import LocationViewDialog from '../location/LocationViewDialog'; +import Map from '../location/Map'; +import SmartMarker from '../location/SmartMarker'; +import { IBodyProps } from "./IBodyProps"; interface IState { error: Error; @@ -45,61 +40,23 @@ interface IState { export default class MLocationBody extends React.Component { public static contextType = MatrixClientContext; public context!: React.ContextType; - private coords: GeolocationCoordinates; - private bodyId: string; - private markerId: string; - private map?: maplibregl.Map = null; + private mapId: string; constructor(props: IBodyProps) { super(props); const randomString = Math.random().toString(16).slice(2, 10); + // multiple instances of same map might be in document + // eg thread and main timeline, reply const idSuffix = `${props.mxEvent.getId()}_${randomString}`; - this.bodyId = `mx_MLocationBody_${idSuffix}`; - this.markerId = `mx_MLocationBody_marker_${idSuffix}`; - this.coords = parseGeoUri(locationEventGeoUri(this.props.mxEvent)); + this.mapId = `mx_MLocationBody_${idSuffix}`; this.state = { error: undefined, }; } - componentDidMount() { - if (this.state.error) { - return; - } - - this.context.on(ClientEvent.ClientWellKnown, this.updateStyleUrl); - - this.map = createMapWithCoords( - this.coords, - false, - this.bodyId, - this.markerId, - (e: Error) => this.setState({ error: e }), - ); - } - - componentWillUnmount() { - this.context.off(ClientEvent.ClientWellKnown, this.updateStyleUrl); - } - - private updateStyleUrl = (clientWellKnown: IClientWellKnown) => { - const style = tileServerFromWellKnown(clientWellKnown)?.["map_style_url"]; - if (style) { - this.map?.setStyle(style); - } - }; - - private onClick = ( - event: React.MouseEvent, - ) => { - // Don't open map if we clicked the attribution button - const target = event.target as Element; - if (target.classList.contains("maplibregl-ctrl-attrib-button")) { - return; - } - + private onClick = () => { Modal.createTrackedDialog( 'Location View', '', @@ -114,14 +71,17 @@ export default class MLocationBody extends React.Component { ); }; + private onError = (error) => { + this.setState({ error }); + }; + render(): React.ReactElement { return this.state.error ? : ; @@ -147,68 +107,52 @@ export const LocationBodyFallbackContent: React.FC<{ event: MatrixEvent, error: interface LocationBodyContentProps { mxEvent: MatrixEvent; - bodyId: string; - markerId: string; - error: Error; + mapId: string; tooltip?: string; - onClick?: (event: React.MouseEvent) => void; - zoomButtons?: boolean; - onZoomIn?: () => void; - onZoomOut?: () => void; + onError: (error: Error) => void; + onClick?: () => void; } -export const LocationBodyContent: React.FC = (props) => { - const mapDiv =
; - +export const LocationBodyContent: React.FC = ({ + mxEvent, + mapId, + tooltip, + onError, + onClick, +}) => { // only pass member to marker when should render avatar marker - const markerRoomMember = isSelfLocation(props.mxEvent.getContent()) ? props.mxEvent.sender : undefined; + const markerRoomMember = isSelfLocation(mxEvent.getContent()) ? mxEvent.sender : undefined; + const geoUri = locationEventGeoUri(mxEvent); + + const mapElement = ( + { + ({ map }) => + + } + ); return
{ - props.tooltip + tooltip ? - { mapDiv } + { mapElement } - : mapDiv - } - - { - props.zoomButtons - ? - : null + : mapElement }
; }; -interface IZoomButtonsProps { - onZoomIn: () => void; - onZoomOut: () => void; -} - -function ZoomButtons(props: IZoomButtonsProps): React.ReactElement { - return
- -
- - -
- -
; -} - diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 86c35a5cdd12..9afc3d58c395 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2118,8 +2118,6 @@ "Unable to load map": "Unable to load map", "Shared their location: ": "Shared their location: ", "Shared a location: ": "Shared a location: ", - "Zoom in": "Zoom in", - "Zoom out": "Zoom out", "Can't edit poll": "Can't edit poll", "Sorry, you can't edit a poll after votes have been cast.": "Sorry, you can't edit a poll after votes have been cast.", "Vote not registered": "Vote not registered", @@ -2173,6 +2171,8 @@ "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?", + "Zoom in": "Zoom in", + "Zoom out": "Zoom out", "Frequently Used": "Frequently Used", "Smileys & People": "Smileys & People", "Animals & Nature": "Animals & Nature", diff --git a/src/utils/location/useMap.ts b/src/utils/location/useMap.ts new file mode 100644 index 000000000000..d40836295063 --- /dev/null +++ b/src/utils/location/useMap.ts @@ -0,0 +1,62 @@ +/* +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 { useEffect, useState } from 'react'; +import { Map as MapLibreMap } from 'maplibre-gl'; + +import { createMap } from "./map"; + +interface UseMapProps { + bodyId: string; + onError: (error: Error) => void; + interactive?: boolean; +} + +/** + * Create a map instance + * Add listeners for errors + * Make sure `onError` has a stable reference + * As map is recreated on changes to it + */ +export const useMap = ({ + interactive, + bodyId, + onError, +}: UseMapProps): MapLibreMap => { + const [map, setMap] = useState(); + + useEffect( + () => { + try { + setMap(createMap(interactive, bodyId, onError)); + } catch (error) { + onError(error); + } + return () => { + if (map) { + map.remove(); + setMap(undefined); + } + }; + }, + // map is excluded as a dependency + // eslint-disable-next-line react-hooks/exhaustive-deps + [interactive, bodyId, onError], + ); + + return map; +}; + diff --git a/test/components/views/location/LocationPicker-test.tsx b/test/components/views/location/LocationPicker-test.tsx index 31b781d0561f..6dd25ae72d0f 100644 --- a/test/components/views/location/LocationPicker-test.tsx +++ b/test/components/views/location/LocationPicker-test.tsx @@ -283,7 +283,7 @@ describe("LocationPicker", () => { }); // marker not added - expect(wrapper.find('.mx_MLocationBody_markerBorder').length).toBeFalsy(); + expect(wrapper.find('Marker').length).toBeFalsy(); }); it('sets position on click event', () => { diff --git a/test/components/views/location/LocationViewDialog-test.tsx b/test/components/views/location/LocationViewDialog-test.tsx new file mode 100644 index 000000000000..432d7107fb28 --- /dev/null +++ b/test/components/views/location/LocationViewDialog-test.tsx @@ -0,0 +1,56 @@ +/* +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 from 'react'; +import { mount } from 'enzyme'; +import { RoomMember } from 'matrix-js-sdk/src/matrix'; +import { LocationAssetType } from 'matrix-js-sdk/src/@types/location'; + +import LocationViewDialog from '../../../../src/components/views/location/LocationViewDialog'; +import { getMockClientWithEventEmitter, makeLocationEvent } from '../../../test-utils'; + +describe('', () => { + const roomId = '!room:server'; + const userId = '@user:server'; + const mockClient = getMockClientWithEventEmitter({ + getClientWellKnown: jest.fn().mockReturnValue({ + "m.tile_server": { map_style_url: 'maps.com' }, + }), + isGuest: jest.fn().mockReturnValue(false), + }); + const defaultEvent = makeLocationEvent("geo:51.5076,-0.1276", LocationAssetType.Pin); + const defaultProps = { + matrixClient: mockClient, + mxEvent: defaultEvent, + onFinished: jest.fn(), + }; + const getComponent = (props = {}) => + mount(); + + it('renders map correctly', () => { + const component = getComponent(); + expect(component.find('Map')).toMatchSnapshot(); + }); + + it('renders marker correctly for self share', () => { + const selfShareEvent = makeLocationEvent("geo:51.5076,-0.1276", LocationAssetType.Self); + const member = new RoomMember(roomId, userId); + // @ts-ignore cheat assignment to property + selfShareEvent.sender = member; + const component = getComponent({ mxEvent: selfShareEvent }); + expect(component.find('SmartMarker').props()['roomMember']).toEqual(member); + }); +}); diff --git a/test/components/views/location/Map-test.tsx b/test/components/views/location/Map-test.tsx new file mode 100644 index 000000000000..b5fce12a88d6 --- /dev/null +++ b/test/components/views/location/Map-test.tsx @@ -0,0 +1,165 @@ +/* +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 from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import maplibregl from 'maplibre-gl'; +import { ClientEvent } from 'matrix-js-sdk/src/matrix'; +import { logger } from 'matrix-js-sdk/src/logger'; + +import Map from '../../../../src/components/views/location/Map'; +import { findByTestId, getMockClientWithEventEmitter } from '../../../test-utils'; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; + +describe('', () => { + const defaultProps = { + centerGeoUri: 'geo:52,41', + id: 'test-123', + onError: jest.fn(), + onClick: jest.fn(), + }; + const matrixClient = getMockClientWithEventEmitter({ + getClientWellKnown: jest.fn().mockReturnValue({ + "m.tile_server": { map_style_url: 'maps.com' }, + }), + }); + const getComponent = (props = {}) => + mount(, { + wrappingComponent: MatrixClientContext.Provider, + wrappingComponentProps: { value: matrixClient }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + matrixClient.getClientWellKnown.mockReturnValue({ + "m.tile_server": { map_style_url: 'maps.com' }, + }); + + jest.spyOn(logger, 'error').mockRestore(); + }); + + const mockMap = new maplibregl.Map(); + + it('renders', () => { + const component = getComponent(); + expect(component).toBeTruthy(); + }); + + describe('onClientWellKnown emits', () => { + it('updates map style when style url is truthy', () => { + getComponent(); + + act(() => { + matrixClient.emit(ClientEvent.ClientWellKnown, { + "m.tile_server": { map_style_url: 'new.maps.com' }, + }); + }); + + expect(mockMap.setStyle).toHaveBeenCalledWith('new.maps.com'); + }); + + it('does not update map style when style url is truthy', () => { + getComponent(); + + act(() => { + matrixClient.emit(ClientEvent.ClientWellKnown, { + "m.tile_server": { map_style_url: undefined }, + }); + }); + + expect(mockMap.setStyle).not.toHaveBeenCalledWith(); + }); + }); + + describe('map centering', () => { + it('does not try to center when no center uri provided', () => { + getComponent({ centerGeoUri: null }); + expect(mockMap.setCenter).not.toHaveBeenCalled(); + }); + + it('sets map center to centerGeoUri', () => { + getComponent({ centerGeoUri: 'geo:51,42' }); + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 42 }); + }); + + it('handles invalid centerGeoUri', () => { + const logSpy = jest.spyOn(logger, 'error').mockImplementation(); + getComponent({ centerGeoUri: '123 Sesame Street' }); + expect(mockMap.setCenter).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('Could not set map center', '123 Sesame Street'); + }); + + it('updates map center when centerGeoUri prop changes', () => { + const component = getComponent({ centerGeoUri: 'geo:51,42' }); + + component.setProps({ centerGeoUri: 'geo:53,45' }); + component.setProps({ centerGeoUri: 'geo:56,47' }); + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 42 }); + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 53, lon: 45 }); + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 56, lon: 47 }); + }); + }); + + describe('children', () => { + it('renders without children', () => { + const component = getComponent({ children: null }); + + component.setProps({}); + + // no error + expect(component).toBeTruthy(); + }); + + it('renders children with map renderProp', () => { + const children = ({ map }) =>
Hello, world
; + + const component = getComponent({ children }); + + // renders child with map instance + expect(findByTestId(component, 'test-child').props()['data-map']).toEqual(mockMap); + }); + }); + + describe('onClick', () => { + it('eats clicks to maplibre attribution button', () => { + const onClick = jest.fn(); + const component = getComponent({ onClick }); + + act(() => { + // this is added to the dom by maplibregl + // which is mocked + // just fake the target + const fakeEl = document.createElement('div'); + fakeEl.className = 'maplibregl-ctrl-attrib-button'; + component.simulate('click', { target: fakeEl }); + }); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('calls onClick', () => { + const onClick = jest.fn(); + const component = getComponent({ onClick }); + + act(() => { + component.simulate('click'); + }); + + expect(onClick).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap b/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap new file mode 100644 index 000000000000..99cd996a1028 --- /dev/null +++ b/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap @@ -0,0 +1,156 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders map correctly 1`] = ` + +
+ + +
+
+
+
+
+ + + +
+ +
+
+
+ + +
+
+
+ +
+ +
+ +`; diff --git a/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap b/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap index 724e0b3fc881..064b3ccff660 100644 --- a/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap +++ b/test/components/views/location/__snapshots__/SmartMarker-test.tsx.snap @@ -10,6 +10,8 @@ exports[` creates a marker on mount 1`] = ` "_maxListeners": undefined, "addControl": [MockFunction], "removeControl": [MockFunction], + "setCenter": [MockFunction], + "setStyle": [MockFunction], "zoomIn": [MockFunction], "zoomOut": [MockFunction], Symbol(kCapture): false, @@ -42,6 +44,8 @@ exports[` removes marker on unmount 1`] = ` "_maxListeners": undefined, "addControl": [MockFunction], "removeControl": [MockFunction], + "setCenter": [MockFunction], + "setStyle": [MockFunction], "zoomIn": [MockFunction], "zoomOut": [MockFunction], Symbol(kCapture): false, diff --git a/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap b/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap index ba5a0b46992c..7f18eccc82be 100644 --- a/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap +++ b/test/components/views/location/__snapshots__/ZoomButtons-test.tsx.snap @@ -9,6 +9,8 @@ exports[` renders buttons 1`] = ` "_maxListeners": undefined, "addControl": [MockFunction], "removeControl": [MockFunction], + "setCenter": [MockFunction], + "setStyle": [MockFunction], "zoomIn": [MockFunction], "zoomOut": [MockFunction], Symbol(kCapture): false, diff --git a/test/components/views/messages/MLocationBody-test.tsx b/test/components/views/messages/MLocationBody-test.tsx index 11b8d707f2ff..c1133e81c0a4 100644 --- a/test/components/views/messages/MLocationBody-test.tsx +++ b/test/components/views/messages/MLocationBody-test.tsx @@ -16,50 +16,52 @@ limitations under the License. import React from 'react'; import { mount } from "enzyme"; -import { mocked } from 'jest-mock'; import { LocationAssetType } from "matrix-js-sdk/src/@types/location"; +import { RoomMember } from 'matrix-js-sdk/src/matrix'; import maplibregl from 'maplibre-gl'; import { logger } from 'matrix-js-sdk/src/logger'; +import { act } from 'react-dom/test-utils'; import MLocationBody from "../../../../src/components/views/messages/MLocationBody"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper"; -import { getTileServerWellKnown } from "../../../../src/utils/WellKnownUtils"; +import Modal from '../../../../src/Modal'; import SdkConfig from "../../../../src/SdkConfig"; import { makeLocationEvent } from "../../../test-utils/location"; - -jest.mock("../../../../src/utils/WellKnownUtils", () => ({ - getTileServerWellKnown: jest.fn(), -})); +import { getMockClientWithEventEmitter } from '../../../test-utils'; describe("MLocationBody", () => { describe('', () => { + const roomId = '!room:server'; + const userId = '@user:server'; + const mockClient = getMockClientWithEventEmitter({ + getClientWellKnown: jest.fn().mockReturnValue({ + "m.tile_server": { map_style_url: 'maps.com' }, + }), + isGuest: jest.fn().mockReturnValue(false), + }); + const defaultEvent = makeLocationEvent("geo:51.5076,-0.1276", LocationAssetType.Pin); + const defaultProps = { + mxEvent: defaultEvent, + highlights: [], + highlightLink: '', + onHeightChanged: jest.fn(), + onMessageAllowed: jest.fn(), + permalinkCreator: {} as RoomPermalinkCreator, + mediaEventHelper: {} as MediaEventHelper, + }; + const getComponent = (props = {}) => mount(, { + wrappingComponent: MatrixClientContext.Provider, + wrappingComponentProps: { value: mockClient }, + }); describe('with error', () => { - const mockClient = { - on: jest.fn(), - off: jest.fn(), - }; - const defaultEvent = makeLocationEvent("geo:51.5076,-0.1276", LocationAssetType.Pin); - const defaultProps = { - mxEvent: defaultEvent, - highlights: [], - highlightLink: '', - onHeightChanged: jest.fn(), - onMessageAllowed: jest.fn(), - permalinkCreator: {} as RoomPermalinkCreator, - mediaEventHelper: {} as MediaEventHelper, - }; - const getComponent = (props = {}) => mount(, { - wrappingComponent: MatrixClientContext.Provider, - wrappingComponentProps: { value: mockClient }, - }); let sdkConfigSpy; beforeEach(() => { // eat expected errors to keep console clean jest.spyOn(logger, 'error').mockImplementation(() => { }); - mocked(getTileServerWellKnown).mockReturnValue({}); + mockClient.getClientWellKnown.mockReturnValue({}); sdkConfigSpy = jest.spyOn(SdkConfig, 'get').mockReturnValue({}); }); @@ -75,7 +77,9 @@ describe("MLocationBody", () => { it('displays correct fallback content when map_style_url is misconfigured', () => { const mockMap = new maplibregl.Map(); - mocked(getTileServerWellKnown).mockReturnValue({ map_style_url: 'bad-tile-server.com' }); + mockClient.getClientWellKnown.mockReturnValue({ + "m.tile_server": { map_style_url: 'bad-tile-server.com' }, + }); const component = getComponent(); // simulate error initialising map in maplibregl @@ -85,5 +89,69 @@ describe("MLocationBody", () => { expect(component.find(".mx_EventTile_body")).toMatchSnapshot(); }); }); + + describe('without error', () => { + beforeEach(() => { + mockClient.getClientWellKnown.mockReturnValue({ + "m.tile_server": { map_style_url: 'maps.com' }, + }); + + // MLocationBody uses random number for map id + // stabilise for test + jest.spyOn(global.Math, 'random').mockReturnValue(0.123456); + }); + + afterAll(() => { + jest.spyOn(global.Math, 'random').mockRestore(); + }); + + it('renders map correctly', () => { + const mockMap = new maplibregl.Map(); + const component = getComponent(); + + expect(component).toMatchSnapshot(); + // map was centered + expect(mockMap.setCenter).toHaveBeenCalledWith({ + lat: 51.5076, lon: -0.1276, + }); + }); + + it('opens map dialog on click', () => { + const modalSpy = jest.spyOn(Modal, 'createTrackedDialog').mockReturnValue(undefined); + const component = getComponent(); + + act(() => { + component.find('Map').at(0).simulate('click'); + }); + + expect(modalSpy).toHaveBeenCalled(); + }); + + it('renders marker correctly for a non-self share', () => { + const mockMap = new maplibregl.Map(); + const component = getComponent(); + + expect(component.find('SmartMarker').at(0).props()).toEqual( + expect.objectContaining({ + map: mockMap, + geoUri: 'geo:51.5076,-0.1276', + roomMember: undefined, + }), + ); + }); + + it('renders marker correctly for a self share', () => { + const selfShareEvent = makeLocationEvent("geo:51.5076,-0.1276", LocationAssetType.Self); + const member = new RoomMember(roomId, userId); + // @ts-ignore cheat assignment to property + selfShareEvent.sender = member; + const component = getComponent({ mxEvent: selfShareEvent }); + + // render self locations with user avatars + expect(component.find('SmartMarker').at(0).props()['roomMember']).toEqual( + member, + ); + }); + }); }); }); diff --git a/test/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap index 7db39cdfcb63..c422d64fcad2 100644 --- a/test/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap @@ -27,3 +27,159 @@ exports[`MLocationBody with error displays correct fallback cont Shared a location: Found at geo:51.5076,-0.1276 at 2021-12-21T12:22+0000
`; + +exports[`MLocationBody without error renders map correctly 1`] = ` + + +
+ +
+ +
+ + +
+
+
+
+
+ + +
+ +
+ +
+ + +`; From 70c10886c4ecb5cb0be94b67899ea421426cbe55 Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 6 Apr 2022 11:48:58 +0100 Subject: [PATCH 13/34] Port #8240 to release (#8241) * fix uneven gutter size in thread panel * Fix indicator positioning * fix gutter sizing * fix dropdown inner padding and spacing * lint fix --- res/css/structures/_RoomView.scss | 4 ++-- res/css/views/right_panel/_ThreadPanel.scss | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 7990db8cf1f1..84e6041ecd5c 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -326,8 +326,8 @@ hr.mx_RoomView_myReadMarker { .mx_Indicator { position: absolute; - right: 0; - top: 0; + right: -3px; + top: -3px; width: $dot-size; height: $dot-size; border-radius: 50%; diff --git a/res/css/views/right_panel/_ThreadPanel.scss b/res/css/views/right_panel/_ThreadPanel.scss index cb77f365b879..89b3f8aeb90b 100644 --- a/res/css/views/right_panel/_ThreadPanel.scss +++ b/res/css/views/right_panel/_ThreadPanel.scss @@ -20,8 +20,8 @@ limitations under the License. height: 100px; overflow: visible; - &.mx_BaseCard { - padding-right: 0; + &:not(.mx_ThreadView).mx_BaseCard { + padding-right: 2px; } .mx_BaseCard_header { @@ -48,7 +48,7 @@ limitations under the License. } .mx_ThreadPanel__header { - width: calc(100% - 30px); + width: calc(100% - 38px); height: 24px; display: flex; flex: 1; @@ -118,7 +118,7 @@ limitations under the License. &.mx_ThreadView .mx_ThreadView_timelinePanelWrapper { /* the scrollbar is 8px wide, and we want a 12px gap with the side of the panel. Hence the magic number, 8+4=12 */ - width: calc(100% - 4px); + width: calc(100% + 6px); padding-right: 4px; position: relative; min-height: 0; // don't displace the composer @@ -184,7 +184,7 @@ limitations under the License. } .mx_ThreadPanel_dropdown { - padding: 3px 8px; + padding: 3px 4px 3px 8px; border-radius: 4px; line-height: 1.5; user-select: none; @@ -294,7 +294,7 @@ limitations under the License. top: 0; bottom: 0; left: 0; - right: 0; + right: 6px; padding: 20px; h2 { From 6bd090c090bff8298bb138bb52102969c9d29194 Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Fri, 8 Apr 2022 08:11:39 +0000 Subject: [PATCH 14/34] Port #8249 to release (#8254) Signed-off-by: Suguru Hirahara --- res/css/views/rooms/_EventTile.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 4238dca93869..29f6242c4b1e 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -954,7 +954,6 @@ $threadInfoLineHeight: calc(2 * $font-12px); .mx_EventTile { display: flex; flex-direction: column; - padding-top: 14px; // due to layout differences, this odd number matches the 18px padding-top of main tl events .mx_EventTile_line { padding-left: 0; @@ -972,6 +971,10 @@ $threadInfoLineHeight: calc(2 * $font-12px); } } + .mx_EventTile:not([data-layout=bubble]) { + padding-top: 14px; // due to layout differences, this odd number matches the 18px padding-top of main tl events + } + .mx_EventTile[data-layout=bubble] { margin-left: 36px; margin-right: 36px; From 7b2f1e22e17d8008d60fca6ee804120c8557e281 Mon Sep 17 00:00:00 2001 From: olivialivia <94136530+olivialivia@users.noreply.github.com> Date: Mon, 11 Apr 2022 19:37:52 +0100 Subject: [PATCH 15/34] Add copy button to View Source screen (#8278) Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- res/css/structures/_ViewSource.scss | 5 +++ src/components/structures/ViewSource.tsx | 44 ++++++++++++++++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/res/css/structures/_ViewSource.scss b/res/css/structures/_ViewSource.scss index e3d6135ef30c..f1ada65786e2 100644 --- a/res/css/structures/_ViewSource.scss +++ b/res/css/structures/_ViewSource.scss @@ -34,8 +34,13 @@ limitations under the License. padding: 0.5em 1em 0.5em 1em; word-wrap: break-word; white-space: pre-wrap; + overflow-wrap: anywhere; } .mx_ViewSource_details { margin-top: 0.8em; } + +.mx_ViewSource_container { + max-width: calc(100% - 24px); +} diff --git a/src/components/structures/ViewSource.tsx b/src/components/structures/ViewSource.tsx index 477dd4ca9a2b..e84a26409e26 100644 --- a/src/components/structures/ViewSource.tsx +++ b/src/components/structures/ViewSource.tsx @@ -29,6 +29,7 @@ import BaseDialog from "../views/dialogs/BaseDialog"; import { DevtoolsContext } from "../views/dialogs/devtools/BaseTool"; import { StateEventEditor } from "../views/dialogs/devtools/RoomState"; import { stringify, TimelineEventEditor } from "../views/dialogs/devtools/Event"; +import CopyableText from "../views/elements/CopyableText"; interface IProps extends IDialogProps { mxEvent: MatrixEvent; // the MatrixEvent associated with the context menu @@ -63,29 +64,58 @@ export default class ViewSource extends React.Component { // @ts-ignore const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private const originalEventSource = mxEvent.event; - + const copyOriginalFunc = (): string => { + return stringify(originalEventSource); + }; if (isEncrypted) { + const copyDecryptedFunc = (): string => { + return stringify(decryptedEventSource); + }; return ( <>
- { _t("Decrypted event source") } + + { _t("Decrypted event source") } + - { stringify(decryptedEventSource) } +
+ + + { stringify(decryptedEventSource) } + + +
- { _t("Original event source") } + + { _t("Original event source") } + - { stringify(originalEventSource) } +
+ + + { stringify(originalEventSource) } + + +
); } else { return ( <> -
{ _t("Original event source") }
- { stringify(originalEventSource) } +
+ { _t("Original event source") } +
+
+ + + { stringify(originalEventSource) } + + +
); } From e90ee38e4bdcabf43e4ec0a7b44c9f01859aca93 Mon Sep 17 00:00:00 2001 From: Jume Brice <38510556+Jumeb@users.noreply.github.com> Date: Mon, 11 Apr 2022 20:52:01 +0100 Subject: [PATCH 16/34] fixed warning pop up when admin changes power to custome level 100 (#8248) --- src/components/views/right_panel/UserInfo.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index d36ba0f72896..a96a6c2961da 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -1024,7 +1024,7 @@ const PowerLevelEditor: React.FC<{ const myUserId = cli.getUserId(); const myPower = powerLevelEvent.getContent().users[myUserId]; - if (myPower && parseInt(myPower) === powerLevel) { + if (myPower && parseInt(myPower) <= powerLevel && myUserId !== target) { const { finished } = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, { title: _t("Warning!"), description: @@ -1038,7 +1038,7 @@ const PowerLevelEditor: React.FC<{ const [confirmed] = await finished; if (!confirmed) return; - } else if (myUserId === target) { + } else if (myUserId === target && myPower && parseInt(myPower) > powerLevel) { // If we are changing our own PL it can only ever be decreasing, which we cannot reverse. try { if (!(await warnSelfDemote(room?.isSpaceRoom()))) return; From 4b7840bf78e016308d0954e445009511ee9b43ef Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 12 Apr 2022 09:24:17 +0200 Subject: [PATCH 17/34] Live location sharing - extract live time UI for reuse (#8283) * extract livetimeremaining into own component Signed-off-by: Kerry Archibald * extract LiveTimeRemaining for reuse in beacon timeline Signed-off-by: Kerry Archibald --- .../views/beacon/_LiveTimeRemaining.scss | 20 +++++ .../views/beacon/_RoomLiveShareWarning.scss | 6 -- .../views/beacon/LiveTimeRemaining.tsx | 75 +++++++++++++++++++ .../views/beacon/RoomLiveShareWarning.tsx | 58 +------------- .../RoomLiveShareWarning-test.tsx.snap | 4 +- 5 files changed, 100 insertions(+), 63 deletions(-) create mode 100644 res/css/components/views/beacon/_LiveTimeRemaining.scss create mode 100644 src/components/views/beacon/LiveTimeRemaining.tsx diff --git a/res/css/components/views/beacon/_LiveTimeRemaining.scss b/res/css/components/views/beacon/_LiveTimeRemaining.scss new file mode 100644 index 000000000000..de13f7aab2ec --- /dev/null +++ b/res/css/components/views/beacon/_LiveTimeRemaining.scss @@ -0,0 +1,20 @@ +/* +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_LiveTimeRemaining { + color: $secondary-content; + font-size: $font-12px; +} diff --git a/res/css/components/views/beacon/_RoomLiveShareWarning.scss b/res/css/components/views/beacon/_RoomLiveShareWarning.scss index 7404f88aea37..1449193e7be3 100644 --- a/res/css/components/views/beacon/_RoomLiveShareWarning.scss +++ b/res/css/components/views/beacon/_RoomLiveShareWarning.scss @@ -39,12 +39,6 @@ limitations under the License. font-size: $font-15px; } -.mx_RoomLiveShareWarning_expiry { - color: $secondary-content; - font-size: $font-12px; - margin-right: $spacing-16; -} - .mx_RoomLiveShareWarning_spinner { margin-right: $spacing-16; } diff --git a/src/components/views/beacon/LiveTimeRemaining.tsx b/src/components/views/beacon/LiveTimeRemaining.tsx new file mode 100644 index 000000000000..1f25706a13f3 --- /dev/null +++ b/src/components/views/beacon/LiveTimeRemaining.tsx @@ -0,0 +1,75 @@ +/* +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, { useCallback, useEffect, useState } from 'react'; +import { BeaconEvent, Beacon } from 'matrix-js-sdk/src/matrix'; + +import { formatDuration } from '../../../DateUtils'; +import { useEventEmitterState } from '../../../hooks/useEventEmitter'; +import { useInterval } from '../../../hooks/useTimeout'; +import { _t } from '../../../languageHandler'; +import { getBeaconMsUntilExpiry } from '../../../utils/beacon'; + +const MINUTE_MS = 60000; +const HOUR_MS = MINUTE_MS * 60; +const getUpdateInterval = (ms: number) => { + // every 10 mins when more than an hour + if (ms > HOUR_MS) { + return MINUTE_MS * 10; + } + // every minute when more than a minute + if (ms > MINUTE_MS) { + return MINUTE_MS; + } + // otherwise every second + return 1000; +}; +const useMsRemaining = (beacon: Beacon): number => { + const beaconInfo = useEventEmitterState( + beacon, + BeaconEvent.Update, + () => beacon.beaconInfo, + ); + + const [msRemaining, setMsRemaining] = useState(() => getBeaconMsUntilExpiry(beaconInfo)); + + useEffect(() => { + setMsRemaining(getBeaconMsUntilExpiry(beaconInfo)); + }, [beaconInfo]); + + const updateMsRemaining = useCallback(() => { + const ms = getBeaconMsUntilExpiry(beaconInfo); + setMsRemaining(ms); + }, [beaconInfo]); + + useInterval(updateMsRemaining, getUpdateInterval(msRemaining)); + + return msRemaining; +}; + +const LiveTimeRemaining: React.FC<{ beacon: Beacon }> = ({ beacon }) => { + const msRemaining = useMsRemaining(beacon); + + const timeRemaining = formatDuration(msRemaining); + const liveTimeRemaining = _t(`%(timeRemaining)s left`, { timeRemaining }); + + return { liveTimeRemaining }; +}; + +export default LiveTimeRemaining; diff --git a/src/components/views/beacon/RoomLiveShareWarning.tsx b/src/components/views/beacon/RoomLiveShareWarning.tsx index 0c1b67dc100e..89fb1cfb46e3 100644 --- a/src/components/views/beacon/RoomLiveShareWarning.tsx +++ b/src/components/views/beacon/RoomLiveShareWarning.tsx @@ -14,63 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; import { Room, Beacon, - BeaconEvent, BeaconIdentifier, } from 'matrix-js-sdk/src/matrix'; -import { formatDuration } from '../../../DateUtils'; import { _t } from '../../../languageHandler'; import { useEventEmitterState } from '../../../hooks/useEventEmitter'; -import { useInterval } from '../../../hooks/useTimeout'; import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../stores/OwnBeaconStore'; -import { getBeaconMsUntilExpiry, sortBeaconsByLatestExpiry } from '../../../utils/beacon'; +import { sortBeaconsByLatestExpiry } from '../../../utils/beacon'; import AccessibleButton from '../elements/AccessibleButton'; import Spinner from '../elements/Spinner'; import StyledLiveBeaconIcon from './StyledLiveBeaconIcon'; import { Icon as CloseIcon } from '../../../../res/img/image-view/close.svg'; - -const MINUTE_MS = 60000; -const HOUR_MS = MINUTE_MS * 60; - -const getUpdateInterval = (ms: number) => { - // every 10 mins when more than an hour - if (ms > HOUR_MS) { - return MINUTE_MS * 10; - } - // every minute when more than a minute - if (ms > MINUTE_MS) { - return MINUTE_MS; - } - // otherwise every second - return 1000; -}; -const useMsRemaining = (beacon: Beacon): number => { - const beaconInfo = useEventEmitterState( - beacon, - BeaconEvent.Update, - () => beacon.beaconInfo, - ); - - const [msRemaining, setMsRemaining] = useState(() => getBeaconMsUntilExpiry(beaconInfo)); - - useEffect(() => { - setMsRemaining(getBeaconMsUntilExpiry(beaconInfo)); - }, [beaconInfo]); - - const updateMsRemaining = useCallback(() => { - const ms = getBeaconMsUntilExpiry(beaconInfo); - setMsRemaining(ms); - }, [beaconInfo]); - - useInterval(updateMsRemaining, getUpdateInterval(msRemaining)); - - return msRemaining; -}; +import LiveTimeRemaining from './LiveTimeRemaining'; /** * It's technically possible to have multiple live beacons in one room @@ -134,18 +94,6 @@ const useLiveBeacons = (liveBeaconIds: BeaconIdentifier[], roomId: string): Live }; }; -const LiveTimeRemaining: React.FC<{ beacon: Beacon }> = ({ beacon }) => { - const msRemaining = useMsRemaining(beacon); - - const timeRemaining = formatDuration(msRemaining); - const liveTimeRemaining = _t(`%(timeRemaining)s left`, { timeRemaining }); - - return { liveTimeRemaining }; -}; - const getLabel = (hasWireError: boolean, hasStopSharingError: boolean): string => { if (hasWireError) { return _t('An error occured whilst sharing your live location, please try again'); diff --git a/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap b/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap index 65fcd271378b..18efb2c8ab60 100644 --- a/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` when user has live beacons and geolocation is available renders correctly with one live beacon in room 1`] = `"
You are sharing your live location1h left
"`; +exports[` when user has live beacons and geolocation is available renders correctly with one live beacon in room 1`] = `"
You are sharing your live location1h left
"`; -exports[` when user has live beacons and geolocation is available renders correctly with two live beacons in room 1`] = `"
You are sharing your live location12h left
"`; +exports[` when user has live beacons and geolocation is available renders correctly with two live beacons in room 1`] = `"
You are sharing your live location12h left
"`; exports[` when user has live beacons and geolocation is available stopping beacons displays error when stop sharing fails 1`] = `"
An error occurred while stopping your live location, please try again
"`; From 661e2c2aa53fbd18669b115043ad03a850f435bd Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 12 Apr 2022 10:13:55 +0200 Subject: [PATCH 18/34] Live location sharing - beacon map in timeline (#8286) * add displaystatus util Signed-off-by: Kerry Archibald * map fallback svg Signed-off-by: Kerry Archibald * add Map to mbeaconbody Signed-off-by: Kerry Archibald * add bubble layout handling Signed-off-by: Kerry Archibald * test beaconbody Signed-off-by: Kerry Archibald * typo Signed-off-by: Kerry Archibald * use randomString from js-sdk Signed-off-by: Kerry Archibald --- .../views/messages/_MBeaconBody.scss | 54 +++++++++++++ res/css/views/rooms/_EventBubbleTile.scss | 18 +++-- res/img/location/map.svg | 9 +++ src/components/views/beacon/displayStatus.ts | 42 ++++++++++ src/components/views/messages/MBeaconBody.tsx | 78 ++++++++++++++----- src/utils/EventRenderingUtils.ts | 1 + .../views/messages/MBeaconBody-test.tsx | 47 +++++------ .../__snapshots__/MBeaconBody-test.tsx.snap | 49 ------------ 8 files changed, 200 insertions(+), 98 deletions(-) create mode 100644 res/css/components/views/messages/_MBeaconBody.scss create mode 100644 res/img/location/map.svg create mode 100644 src/components/views/beacon/displayStatus.ts delete mode 100644 test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap diff --git a/res/css/components/views/messages/_MBeaconBody.scss b/res/css/components/views/messages/_MBeaconBody.scss new file mode 100644 index 000000000000..067359a4df69 --- /dev/null +++ b/res/css/components/views/messages/_MBeaconBody.scss @@ -0,0 +1,54 @@ +/* +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_MBeaconBody { + position: relative; + height: 220px; + width: 325px; + + border-radius: $timeline-image-border-radius; + overflow: hidden; +} + +.mx_MBeaconBody_map { + height: 100%; + width: 100%; + z-index: 0; // keeps the entire map under the message action bar +} + +.mx_MBeaconBody_mapFallback { + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + + // pushes spinner/icon up + // to appear more centered with the footer + padding-bottom: 50px; + + background: url('$(res)/img/location/map.svg'); + background-size: cover; +} + +.mx_MBeaconBody_mapFallbackIcon { + width: 65px; + color: $quaternary-content; +} + +.mx_EventTile[data-layout="bubble"] .mx_EventTile_line .mx_MBeaconBody { + max-width: 100%; + width: 450px; +} diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index a46e91a4aec7..cf257c706ccb 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -130,7 +130,8 @@ limitations under the License. .mx_MImageBody::before, .mx_MVideoBody .mx_MVideoBody_container, .mx_MediaBody, - .mx_MLocationBody_map { + .mx_MLocationBody_map, + .mx_MBeaconBody { border-bottom-right-radius: var(--cornerRadius) !important; } } @@ -155,7 +156,8 @@ limitations under the License. .mx_MImageBody::before, .mx_MVideoBody .mx_MVideoBody_container, .mx_MediaBody, - .mx_MLocationBody_map { + .mx_MLocationBody_map, + .mx_MBeaconBody { border-bottom-left-radius: var(--cornerRadius) !important; } } @@ -300,7 +302,8 @@ limitations under the License. .mx_MVideoBody .mx_MVideoBody_container, .mx_MImageBody::before, .mx_MediaBody, - .mx_MLocationBody_map { + .mx_MLocationBody_map, + .mx_MBeaconBody { border-top-left-radius: 0; } } @@ -311,7 +314,8 @@ limitations under the License. .mx_MVideoBody .mx_MVideoBody_container, .mx_MImageBody::before, .mx_MediaBody, - .mx_MLocationBody_map { + .mx_MLocationBody_map, + .mx_MBeaconBody { border-bottom-left-radius: var(--cornerRadius); } } @@ -323,7 +327,8 @@ limitations under the License. .mx_MVideoBody .mx_MVideoBody_container, .mx_MImageBody::before, .mx_MediaBody, - .mx_MLocationBody_map { + .mx_MLocationBody_map, + .mx_MBeaconBody { border-top-right-radius: 0; } } @@ -334,7 +339,8 @@ limitations under the License. .mx_MVideoBody .mx_MVideoBody_container, .mx_MImageBody::before, .mx_MediaBody, - .mx_MLocationBody_map { + .mx_MLocationBody_map, + .mx_MBeaconBody { border-bottom-right-radius: var(--cornerRadius); } } diff --git a/res/img/location/map.svg b/res/img/location/map.svg new file mode 100644 index 000000000000..67be3a35ad4b --- /dev/null +++ b/res/img/location/map.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/views/beacon/displayStatus.ts b/src/components/views/beacon/displayStatus.ts new file mode 100644 index 000000000000..ee65991070fc --- /dev/null +++ b/src/components/views/beacon/displayStatus.ts @@ -0,0 +1,42 @@ +/* +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 { BeaconLocationState } from "matrix-js-sdk/src/content-helpers"; + +export enum BeaconDisplayStatus { + Loading = 'Loading', + Error = 'Error', + Stopped = 'Stopped', + Active = 'Active', +} +export const getBeaconDisplayStatus = ( + isLive: boolean, + latestLocationState?: BeaconLocationState, + error?: Error): BeaconDisplayStatus => { + if (error) { + return BeaconDisplayStatus.Error; + } + if (!isLive) { + return BeaconDisplayStatus.Stopped; + } + + if (!latestLocationState) { + return BeaconDisplayStatus.Loading; + } + if (latestLocationState) { + return BeaconDisplayStatus.Active; + } +}; diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx index bbd7fb446f57..9bbb627879ac 100644 --- a/src/components/views/messages/MBeaconBody.tsx +++ b/src/components/views/messages/MBeaconBody.tsx @@ -14,16 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import { BeaconEvent, MatrixEvent } from 'matrix-js-sdk/src/matrix'; +import React, { useEffect, useState } from 'react'; +import { Beacon, BeaconEvent, MatrixEvent } from 'matrix-js-sdk/src/matrix'; import { BeaconLocationState } from 'matrix-js-sdk/src/content-helpers'; +import { randomString } from 'matrix-js-sdk/src/randomstring'; -import { IBodyProps } from "./IBodyProps"; +import { Icon as LocationMarkerIcon } from '../../../../res/img/element-icons/location.svg'; import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { useBeacon } from '../../../utils/beacon'; +import { isSelfLocation } from '../../../utils/location'; +import { BeaconDisplayStatus, getBeaconDisplayStatus } from '../beacon/displayStatus'; +import Spinner from '../elements/Spinner'; +import Map from '../location/Map'; +import SmartMarker from '../location/SmartMarker'; +import { IBodyProps } from "./IBodyProps"; const useBeaconState = (beaconInfoEvent: MatrixEvent): { - hasBeacon: boolean; + beacon?: Beacon; description?: string; latestLocationState?: BeaconLocationState; isLive?: boolean; @@ -41,42 +48,71 @@ const useBeaconState = (beaconInfoEvent: MatrixEvent): { () => beacon?.latestLocationState); if (!beacon) { - return { - hasBeacon: false, - }; + return {}; } const { description } = beacon.beaconInfo; return { - hasBeacon: true, + beacon, description, isLive, latestLocationState, }; }; -const MBeaconBody: React.FC = React.forwardRef(({ mxEvent, ...rest }, ref) => { +// multiple instances of same map might be in document +// eg thread and main timeline, reply +// maplibregl needs a unique id to attach the map instance to +const useUniqueId = (eventId: string): string => { + const [id, setId] = useState(`${eventId}_${randomString(8)}`); + + useEffect(() => { + setId(`${eventId}_${randomString(8)}`); + }, [eventId]); + + return id; +}; + +const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) => { const { - hasBeacon, isLive, - description, latestLocationState, } = useBeaconState(mxEvent); + const mapId = useUniqueId(mxEvent.getId()); - if (!hasBeacon || !isLive) { - // TODO stopped, error states - return Beacon stopped or replaced; - } + const [error, setError] = useState(); + + const displayStatus = getBeaconDisplayStatus(isLive, latestLocationState, error); + + const markerRoomMember = isSelfLocation(mxEvent.getContent()) ? mxEvent.sender : undefined; return ( - // TODO nice map
- { mxEvent.getId() }  - Beacon "{ description }" - { latestLocationState ? - { `${latestLocationState.uri} at ${latestLocationState.timestamp}` } : - Waiting for location } + { displayStatus === BeaconDisplayStatus.Active ? + + { + ({ map }) => + + } + + :
+ { displayStatus === BeaconDisplayStatus.Loading ? + : + + } +
+ }
); }); diff --git a/src/utils/EventRenderingUtils.ts b/src/utils/EventRenderingUtils.ts index c6c7acc991a8..fd6e518c4f2e 100644 --- a/src/utils/EventRenderingUtils.ts +++ b/src/utils/EventRenderingUtils.ts @@ -81,6 +81,7 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent, hideEvent?: boolean): (eventType === EventType.RoomMessage && msgtype === MsgType.Emote) || M_POLL_START.matches(eventType) || M_LOCATION.matches(eventType) || + M_BEACON_INFO.matches(eventType) || ( eventType === EventType.RoomMessage && M_LOCATION.matches(msgtype) diff --git a/test/components/views/messages/MBeaconBody-test.tsx b/test/components/views/messages/MBeaconBody-test.tsx index dc8ec3190317..103f003d821d 100644 --- a/test/components/views/messages/MBeaconBody-test.tsx +++ b/test/components/views/messages/MBeaconBody-test.tsx @@ -17,6 +17,7 @@ limitations under the License. import React from 'react'; import { mount } from 'enzyme'; import { act } from 'react-dom/test-utils'; +import maplibregl from 'maplibre-gl'; import { BeaconEvent, Room, @@ -24,7 +25,7 @@ import { } from 'matrix-js-sdk/src/matrix'; import MBeaconBody from '../../../../src/components/views/messages/MBeaconBody'; -import { findByTestId, getMockClientWithEventEmitter, makeBeaconEvent, makeBeaconInfoEvent } from '../../../test-utils'; +import { getMockClientWithEventEmitter, makeBeaconEvent, makeBeaconInfoEvent } from '../../../test-utils'; import { RoomPermalinkCreator } from '../../../../src/utils/permalinks/Permalinks'; import { MediaEventHelper } from '../../../../src/utils/MediaEventHelper'; import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; @@ -37,7 +38,13 @@ describe('', () => { const roomId = '!room:server'; const aliceId = '@alice:server'; + const mockMap = new maplibregl.Map(); + const mockMarker = new maplibregl.Marker(); + const mockClient = getMockClientWithEventEmitter({ + getClientWellKnown: jest.fn().mockReturnValue({ + "m.tile_server": { map_style_url: 'maps.com' }, + }), getUserId: jest.fn().mockReturnValue(aliceId), getRoom: jest.fn(), }); @@ -58,6 +65,7 @@ describe('', () => { { isLive: true }, '$alice-room1-1', ); + const defaultProps = { mxEvent: defaultEvent, highlights: [], @@ -68,21 +76,15 @@ describe('', () => { permalinkCreator: {} as unknown as RoomPermalinkCreator, mediaEventHelper: {} as unknown as MediaEventHelper, }; + const getComponent = (props = {}) => mount(, { wrappingComponent: MatrixClientContext.Provider, wrappingComponentProps: { value: mockClient }, }); - it('renders a live beacon with basic stub', () => { - const beaconInfoEvent = makeBeaconInfoEvent(aliceId, - roomId, - { isLive: true }, - '$alice-room1-1', - ); - makeRoomWithStateEvents([beaconInfoEvent]); - const component = getComponent({ mxEvent: beaconInfoEvent }); - expect(component).toMatchSnapshot(); + beforeEach(() => { + jest.clearAllMocks(); }); it('renders stopped beacon UI for an explicitly stopped beacon', () => { @@ -93,7 +95,7 @@ describe('', () => { ); makeRoomWithStateEvents([beaconInfoEvent]); const component = getComponent({ mxEvent: beaconInfoEvent }); - expect(component.text()).toEqual("Beacon stopped or replaced"); + expect(component.find('Map').length).toBeFalsy(); }); it('renders stopped beacon UI for an expired beacon', () => { @@ -105,7 +107,7 @@ describe('', () => { ); makeRoomWithStateEvents([beaconInfoEvent]); const component = getComponent({ mxEvent: beaconInfoEvent }); - expect(component.text()).toEqual("Beacon stopped or replaced"); + expect(component.find('Map').length).toBeFalsy(); }); it('renders stopped UI when a beacon event is not the latest beacon for a user', () => { @@ -128,7 +130,7 @@ describe('', () => { const component = getComponent({ mxEvent: aliceBeaconInfo1 }); // beacon1 has been superceded by beacon2 - expect(component.text()).toEqual("Beacon stopped or replaced"); + expect(component.find('Map').length).toBeFalsy(); }); it('renders stopped UI when a beacon event is replaced', () => { @@ -160,7 +162,7 @@ describe('', () => { component.setProps({}); // beacon1 has been superceded by beacon2 - expect(component.text()).toEqual("Beacon stopped or replaced"); + expect(component.find('Map').length).toBeFalsy(); }); describe('on liveness change', () => { @@ -173,9 +175,9 @@ describe('', () => { ); const room = makeRoomWithStateEvents([aliceBeaconInfo]); + const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo)); const component = getComponent({ mxEvent: aliceBeaconInfo }); - const beaconInstance = room.currentState.beacons.get(getBeaconInfoIdentifier(aliceBeaconInfo)); act(() => { // @ts-ignore cheat to force beacon to not live beaconInstance._isLive = false; @@ -185,7 +187,7 @@ describe('', () => { component.setProps({}); // stopped UI - expect(component.text()).toEqual("Beacon stopped or replaced"); + expect(component.find('Map').length).toBeFalsy(); }); }); @@ -198,18 +200,17 @@ describe('', () => { ); const location1 = makeBeaconEvent( - aliceId, { beaconInfoId: aliceBeaconInfo.getId(), geoUri: 'geo:foo', timestamp: now + 1 }, + aliceId, { beaconInfoId: aliceBeaconInfo.getId(), geoUri: 'geo:51,41', timestamp: now + 1 }, ); const location2 = makeBeaconEvent( - aliceId, { beaconInfoId: aliceBeaconInfo.getId(), geoUri: 'geo:bar', timestamp: now + 10000 }, + aliceId, { beaconInfoId: aliceBeaconInfo.getId(), geoUri: 'geo:52,42', timestamp: now + 10000 }, ); it('renders a live beacon without a location correctly', () => { makeRoomWithStateEvents([aliceBeaconInfo]); const component = getComponent({ mxEvent: aliceBeaconInfo }); - // loading map - expect(findByTestId(component, 'beacon-waiting-for-location').length).toBeTruthy(); + expect(component.find('Spinner').length).toBeTruthy(); }); it('updates latest location', () => { @@ -222,14 +223,16 @@ describe('', () => { component.setProps({}); }); - expect(component.text().includes('geo:foo')).toBeTruthy(); + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 41 }); + expect(mockMarker.setLngLat).toHaveBeenCalledWith({ lat: 51, lon: 41 }); act(() => { beaconInstance.addLocations([location2]); component.setProps({}); }); - expect(component.text().includes('geo:bar')).toBeTruthy(); + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 52, lon: 42 }); + expect(mockMarker.setLngLat).toHaveBeenCalledWith({ lat: 52, lon: 42 }); }); }); }); diff --git a/test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap deleted file mode 100644 index 81850608f6f1..000000000000 --- a/test/components/views/messages/__snapshots__/MBeaconBody-test.tsx.snap +++ /dev/null @@ -1,49 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders a live beacon with basic stub 1`] = ` - -
- - $alice-room1-1 - -   - - Beacon " - " - - - Waiting for location - -
-
-`; From 7844c3ac8d4b7189d57e115bc3ba41be13ea294a Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 12 Apr 2022 10:31:47 +0100 Subject: [PATCH 19/34] Upgrade matrix-js-sdk to 17.0.0 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 6382d790fe79..ffc8dbd2827f 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "maplibre-gl": "^1.15.2", "matrix-analytics-events": "github:matrix-org/matrix-analytics-events.git#daad3faed54f0b1f1e026a7498b4653e4d01cd90", "matrix-events-sdk": "^0.0.1-beta.7", - "matrix-js-sdk": "16.0.2-rc.1", + "matrix-js-sdk": "17.0.0", "matrix-widget-api": "^0.1.0-beta.18", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/yarn.lock b/yarn.lock index 808ea04e44cd..07b02ce35db3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6285,10 +6285,10 @@ matrix-events-sdk@^0.0.1-beta.7: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934" integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA== -matrix-js-sdk@16.0.2-rc.1: - version "16.0.2-rc.1" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-16.0.2-rc.1.tgz#b5c748fc9bd95b97d3f13b1a63860c0033bee35d" - integrity sha512-dmrP+leQKLKGg0s/tOEHkWMVz9N3lrccgi3wI4XGiGi0hI8/xv/Y5est6hcpg5axOa1JyD1KqqV0o41whdb6PQ== +matrix-js-sdk@17.0.0: + version "17.0.0" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-17.0.0.tgz#6edf2f8d05da003e98a6cf5269a4717adfe4e406" + integrity sha512-8nv+a1e6n4x4DYKiBFRS6/CIpsE22+31K+9/4Y5MB8m3iraSVBtdZ5y/9ktQnjQuo9I85TvyqHL2obRWF7UD5Q== dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" From f7cb4af076fb4144cbd813c9d4e52ac52cd73329 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 12 Apr 2022 10:33:25 +0100 Subject: [PATCH 20/34] Prepare changelog for v3.42.3 --- CHANGELOG.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a32b21734b7..0126f1f369fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,53 @@ -Changes in [3.42.2-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.42.2-rc.1) (2022-04-05) -=============================================================================================================== +Changes in [3.42.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.42.3) (2022-04-12) +===================================================================================================== -Changes in [3.42.1-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.42.1-rc.1) (2022-03-22) -=============================================================================================================== +## ✨ Features + * Release threads as a beta feature ([\#8081](https://github.com/matrix-org/matrix-react-sdk/pull/8081)). Fixes vector-im/element-web#21351. + * More video rooms design updates ([\#8222](https://github.com/matrix-org/matrix-react-sdk/pull/8222)). + * Update video rooms to new design specs ([\#8207](https://github.com/matrix-org/matrix-react-sdk/pull/8207)). Fixes vector-im/element-web#21515, vector-im/element-web#21516 vector-im/element-web#21519 and vector-im/element-web#21526. + * Live Location Sharing - left panel warning with error ([\#8201](https://github.com/matrix-org/matrix-react-sdk/pull/8201)). + * Live location sharing - Stop publishing location to beacons with consecutive errors ([\#8194](https://github.com/matrix-org/matrix-react-sdk/pull/8194)). + * Live location sharing: allow retry when stop sharing fails ([\#8193](https://github.com/matrix-org/matrix-react-sdk/pull/8193)). + * Allow voice messages to be scrubbed in the timeline ([\#8079](https://github.com/matrix-org/matrix-react-sdk/pull/8079)). Fixes vector-im/element-web#18713. + * Live location sharing - stop sharing to beacons in rooms you left ([\#8187](https://github.com/matrix-org/matrix-react-sdk/pull/8187)). + * Allow sending and thumbnailing AVIF images ([\#8172](https://github.com/matrix-org/matrix-react-sdk/pull/8172)). + * Live location sharing - handle geolocation errors ([\#8179](https://github.com/matrix-org/matrix-react-sdk/pull/8179)). + * Show voice room participants when not connected ([\#8136](https://github.com/matrix-org/matrix-react-sdk/pull/8136)). Fixes vector-im/element-web#21513. + * Add margins between labs sections ([\#8169](https://github.com/matrix-org/matrix-react-sdk/pull/8169)). + * Live location sharing - send geolocation beacon events - happy path ([\#8127](https://github.com/matrix-org/matrix-react-sdk/pull/8127)). + * Add support for Animated (A)PNG ([\#8158](https://github.com/matrix-org/matrix-react-sdk/pull/8158)). Fixes vector-im/element-web#12967. + * Don't form continuations from thread roots ([\#8166](https://github.com/matrix-org/matrix-react-sdk/pull/8166)). Fixes vector-im/element-web#20908. + * Improve handling of animated GIF and WEBP images ([\#8153](https://github.com/matrix-org/matrix-react-sdk/pull/8153)). Fixes vector-im/element-web#16193 and vector-im/element-web#6684. + * Wire up file preview for video files ([\#8140](https://github.com/matrix-org/matrix-react-sdk/pull/8140)). Fixes vector-im/element-web#21539. + * When showing thread, always auto-focus its composer ([\#8115](https://github.com/matrix-org/matrix-react-sdk/pull/8115)). Fixes vector-im/element-web#21438. + * Live location sharing - refresh beacon expiry in room ([\#8116](https://github.com/matrix-org/matrix-react-sdk/pull/8116)). + * Use styled mxids in member list v2 ([\#8110](https://github.com/matrix-org/matrix-react-sdk/pull/8110)). Fixes vector-im/element-web#14825. Contributed by @SimonBrandner. + * Delete groups (legacy communities system) ([\#8027](https://github.com/matrix-org/matrix-react-sdk/pull/8027)). Fixes vector-im/element-web#17532. + * Add a prototype of voice rooms in labs ([\#8084](https://github.com/matrix-org/matrix-react-sdk/pull/8084)). Fixes vector-im/element-web#3546. + +## 🐛 Bug Fixes + * Fix editing `
    ` tags with a non-1 start attribute ([\#8211](https://github.com/matrix-org/matrix-react-sdk/pull/8211)). Fixes vector-im/element-web#21625. + * Fix URL previews being enabled when room first created ([\#8227](https://github.com/matrix-org/matrix-react-sdk/pull/8227)). Fixes vector-im/element-web#21659. + * Don't use m.call for Jitsi video rooms ([\#8223](https://github.com/matrix-org/matrix-react-sdk/pull/8223)). + * Scale emoji with size of surrounding text ([\#8224](https://github.com/matrix-org/matrix-react-sdk/pull/8224)). + * Make "Jump to date" translatable ([\#8218](https://github.com/matrix-org/matrix-react-sdk/pull/8218)). + * Normalize call buttons ([\#8129](https://github.com/matrix-org/matrix-react-sdk/pull/8129)). Fixes vector-im/element-web#21493. Contributed by @luixxiul. + * Show room preview bar with maximised widgets ([\#8180](https://github.com/matrix-org/matrix-react-sdk/pull/8180)). Fixes vector-im/element-web#21542. + * Update more strings to not wrongly mention room when it is/could be a space ([\#7722](https://github.com/matrix-org/matrix-react-sdk/pull/7722)). Fixes vector-im/element-web#20243 and vector-im/element-web#20910. + * Fix issue with redacting via edit composer flow causing stuck editStates ([\#8184](https://github.com/matrix-org/matrix-react-sdk/pull/8184)). + * Fix some image/video scroll jumps ([\#8182](https://github.com/matrix-org/matrix-react-sdk/pull/8182)). + * Fix "react error on share dialog" ([\#8170](https://github.com/matrix-org/matrix-react-sdk/pull/8170)). Contributed by @yaya-usman. + * Fix disambiguated profile in threads in bubble layout ([\#8168](https://github.com/matrix-org/matrix-react-sdk/pull/8168)). Fixes vector-im/element-web#21570. Contributed by @SimonBrandner. + * Responsive BetaCard on Labs ([\#8154](https://github.com/matrix-org/matrix-react-sdk/pull/8154)). Fixes vector-im/element-web#21554. Contributed by @luixxiul. + * Display button as inline in room directory dialog ([\#8164](https://github.com/matrix-org/matrix-react-sdk/pull/8164)). Fixes vector-im/element-web#21567. Contributed by @luixxiul. + * Null guard TimelinePanel unmount edge ([\#8171](https://github.com/matrix-org/matrix-react-sdk/pull/8171)). + * Fix beta pill label breaking ([\#8162](https://github.com/matrix-org/matrix-react-sdk/pull/8162)). Fixes vector-im/element-web#21566. Contributed by @luixxiul. + * Strip relations when forwarding ([\#7929](https://github.com/matrix-org/matrix-react-sdk/pull/7929)). Fixes vector-im/element-web#19769, vector-im/element-web#18067 vector-im/element-web#21015 and vector-im/element-web#10924. + * Don't try (and fail) to show replies for redacted events ([\#8141](https://github.com/matrix-org/matrix-react-sdk/pull/8141)). Fixes vector-im/element-web#21435. + * Fix 3pid member info for space member list ([\#8128](https://github.com/matrix-org/matrix-react-sdk/pull/8128)). Fixes vector-im/element-web#21534. + * Set max-width to user context menu ([\#8089](https://github.com/matrix-org/matrix-react-sdk/pull/8089)). Fixes vector-im/element-web#21486. Contributed by @luixxiul. + * Fix issue with falsey hrefs being sent in events ([\#8113](https://github.com/matrix-org/matrix-react-sdk/pull/8113)). Fixes vector-im/element-web#21417. + * Make video sizing consistent with images ([\#8102](https://github.com/matrix-org/matrix-react-sdk/pull/8102)). Fixes vector-im/element-web#20072. Changes in [3.42.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.42.0) (2022-03-15) ===================================================================================================== From 5f356093fdce2822256b9936b67962f66133f54c Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 12 Apr 2022 10:33:26 +0100 Subject: [PATCH 21/34] v3.42.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ffc8dbd2827f..8293d4198dbb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.42.2-rc.1", + "version": "3.42.3", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { From 28512f20d3677d6e1659ce833ae53eb90e60027c Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 12 Apr 2022 10:37:54 +0100 Subject: [PATCH 22/34] Resetting package fields for development --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 2165cebe9459..20125e29afa2 100644 --- a/package.json +++ b/package.json @@ -231,6 +231,5 @@ "text", "json" ] - }, - "typings": "./lib/index.d.ts" + } } From 42dbe14f36578931fc587484160defc21aa0c87d Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 12 Apr 2022 10:38:05 +0100 Subject: [PATCH 23/34] Reset matrix-js-sdk back to develop branch --- package.json | 2 +- release.sh | 48 ++++++++++++++++++++++++------------------------ yarn.lock | 5 ++--- 3 files changed, 27 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 20125e29afa2..dd1db92a7175 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "matrix-analytics-events": "github:matrix-org/matrix-analytics-events.git#daad3faed54f0b1f1e026a7498b4653e4d01cd90", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "^0.0.1-beta.7", - "matrix-js-sdk": "17.0.0", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-widget-api": "^0.1.0-beta.18", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/release.sh b/release.sh index 4742f00deab4..d8c8e59fe00e 100755 --- a/release.sh +++ b/release.sh @@ -12,30 +12,30 @@ cd `dirname $0` # This link seems to get eaten by the release process, so ensure it exists. yarn link matrix-js-sdk -for i in matrix-js-sdk -do - echo "Checking version of $i..." - depver=`cat package.json | jq -r .dependencies[\"$i\"]` - latestver=`yarn info -s $i dist-tags.next` - if [ "$depver" != "$latestver" ] - then - echo "The latest version of $i is $latestver but package.json depends on $depver." - echo -n "Type 'u' to auto-upgrade, 'c' to continue anyway, or 'a' to abort:" - read resp - if [ "$resp" != "u" ] && [ "$resp" != "c" ] - then - echo "Aborting." - exit 1 - fi - if [ "$resp" == "u" ] - then - echo "Upgrading $i to $latestver..." - yarn add -E $i@$latestver - git add -u - git commit -m "Upgrade $i to $latestver" - fi - fi -done +# for i in matrix-js-sdk +# do +# echo "Checking version of $i..." +# depver=`cat package.json | jq -r .dependencies[\"$i\"]` +# latestver=`yarn info -s $i dist-tags.next` +# if [ "$depver" != "$latestver" ] +# then +# echo "The latest version of $i is $latestver but package.json depends on $depver." +# echo -n "Type 'u' to auto-upgrade, 'c' to continue anyway, or 'a' to abort:" +# read resp +# if [ "$resp" != "u" ] && [ "$resp" != "c" ] +# then +# echo "Aborting." +# exit 1 +# fi +# if [ "$resp" == "u" ] +# then +# echo "Upgrading $i to $latestver..." +# yarn add -E $i@$latestver +# git add -u +# git commit -m "Upgrade $i to $latestver" +# fi +# fi +# done ./node_modules/matrix-js-sdk/release.sh -z "$@" diff --git a/yarn.lock b/yarn.lock index f74939c5569e..324fe711c71a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6244,10 +6244,9 @@ matrix-events-sdk@^0.0.1-beta.7: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934" integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA== -matrix-js-sdk@17.0.0: +"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": version "17.0.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-17.0.0.tgz#6edf2f8d05da003e98a6cf5269a4717adfe4e406" - integrity sha512-8nv+a1e6n4x4DYKiBFRS6/CIpsE22+31K+9/4Y5MB8m3iraSVBtdZ5y/9ktQnjQuo9I85TvyqHL2obRWF7UD5Q== + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b58d09aa9a7a3578c43185becb41ab0b17ce0f98" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" From 7600182a35a7742820c5ab17988ef1b545c74759 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 12 Apr 2022 10:39:13 +0100 Subject: [PATCH 24/34] uncomment first part of release.sh --- release.sh | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/release.sh b/release.sh index d8c8e59fe00e..4742f00deab4 100755 --- a/release.sh +++ b/release.sh @@ -12,30 +12,30 @@ cd `dirname $0` # This link seems to get eaten by the release process, so ensure it exists. yarn link matrix-js-sdk -# for i in matrix-js-sdk -# do -# echo "Checking version of $i..." -# depver=`cat package.json | jq -r .dependencies[\"$i\"]` -# latestver=`yarn info -s $i dist-tags.next` -# if [ "$depver" != "$latestver" ] -# then -# echo "The latest version of $i is $latestver but package.json depends on $depver." -# echo -n "Type 'u' to auto-upgrade, 'c' to continue anyway, or 'a' to abort:" -# read resp -# if [ "$resp" != "u" ] && [ "$resp" != "c" ] -# then -# echo "Aborting." -# exit 1 -# fi -# if [ "$resp" == "u" ] -# then -# echo "Upgrading $i to $latestver..." -# yarn add -E $i@$latestver -# git add -u -# git commit -m "Upgrade $i to $latestver" -# fi -# fi -# done +for i in matrix-js-sdk +do + echo "Checking version of $i..." + depver=`cat package.json | jq -r .dependencies[\"$i\"]` + latestver=`yarn info -s $i dist-tags.next` + if [ "$depver" != "$latestver" ] + then + echo "The latest version of $i is $latestver but package.json depends on $depver." + echo -n "Type 'u' to auto-upgrade, 'c' to continue anyway, or 'a' to abort:" + read resp + if [ "$resp" != "u" ] && [ "$resp" != "c" ] + then + echo "Aborting." + exit 1 + fi + if [ "$resp" == "u" ] + then + echo "Upgrading $i to $latestver..." + yarn add -E $i@$latestver + git add -u + git commit -m "Upgrade $i to $latestver" + fi + fi +done ./node_modules/matrix-js-sdk/release.sh -z "$@" From dbcb56f75e73a5438c9a1f3fd3bccbdcbdab6da3 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Tue, 12 Apr 2022 14:21:17 +0200 Subject: [PATCH 25/34] Fix: Avatar preview does not update when same file is selected repeatedly (#8288) * Fix: Avatar preview does not update when same file is selected repeatedly Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- .../security/AccessSecretStorageDialog.tsx | 2 ++ .../views/elements/MiniAvatarUploader.tsx | 2 ++ .../room_settings/RoomProfileSettings.tsx | 2 ++ .../views/rooms/MessageComposerButtons.tsx | 2 ++ .../views/settings/ChangeAvatar.tsx | 3 ++- .../views/settings/ProfileSettings.tsx | 2 ++ .../tabs/room/NotificationSettingsTab.tsx | 12 ++++++++-- .../views/spaces/SpaceBasicSettings.tsx | 2 ++ src/utils/BrowserWorkarounds.ts | 24 +++++++++++++++++++ 9 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 src/utils/BrowserWorkarounds.ts diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index a50ac2bb9441..ac7eea8e2b6a 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -30,6 +30,7 @@ import Modal from "../../../../Modal"; import InteractiveAuthDialog from "../InteractiveAuthDialog"; import DialogButtons from "../../elements/DialogButtons"; import BaseDialog from "../BaseDialog"; +import { chromeFileInputFix } from "../../../../utils/BrowserWorkarounds"; // Maximum acceptable size of a key file. It's 59 characters including the spaces we encode, // so this should be plenty and allow for people putting extra whitespace in the file because @@ -403,6 +404,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index a590427d232b..43e66db09c1d 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -25,6 +25,7 @@ import { useTimeout } from "../../../hooks/useTimeout"; import Analytics from "../../../Analytics"; import { TranslatedString } from '../../../languageHandler'; import RoomContext from "../../../contexts/RoomContext"; +import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; export const AVATAR_SIZE = 52; @@ -62,6 +63,7 @@ const MiniAvatarUploader: React.FC = ({ hasAvatar, hasAvatarLabel, noAva type="file" ref={uploadRef} className="mx_MiniAvatarUploader_input" + onClick={chromeFileInputFix} onChange={async (ev) => { if (!ev.target.files?.length) return; setBusy(true); diff --git a/src/components/views/room_settings/RoomProfileSettings.tsx b/src/components/views/room_settings/RoomProfileSettings.tsx index aff117681c67..85f5ab7600db 100644 --- a/src/components/views/room_settings/RoomProfileSettings.tsx +++ b/src/components/views/room_settings/RoomProfileSettings.tsx @@ -22,6 +22,7 @@ import Field from "../elements/Field"; import { mediaFromMxc } from "../../../customisations/Media"; import AccessibleButton from "../elements/AccessibleButton"; import AvatarSetting from "../settings/AvatarSetting"; +import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; interface IProps { roomId: string; @@ -252,6 +253,7 @@ export default class RoomProfileSettings extends React.Component type="file" ref={this.avatarUpload} className="mx_ProfileSettings_avatarUpload" + onClick={chromeFileInputFix} onChange={this.onAvatarChanged} accept="image/*" /> diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index fc7dd1ed845e..17c87b15ca29 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -37,6 +37,7 @@ import ContentMessages from '../../../ContentMessages'; import MatrixClientContext from '../../../contexts/MatrixClientContext'; import RoomContext from '../../../contexts/RoomContext'; import { useDispatcher } from "../../../hooks/useDispatcher"; +import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; interface IProps { addEmoji: (emoji: string) => boolean; @@ -236,6 +237,7 @@ const UploadButtonContextProvider: React.FC = ({ roomId, rel type="file" style={uploadInputStyle} multiple + onClick={chromeFileInputFix} onChange={onUploadFileInputChange} /> ; diff --git a/src/components/views/settings/ChangeAvatar.tsx b/src/components/views/settings/ChangeAvatar.tsx index e56de0b2042e..b0645ac51b4d 100644 --- a/src/components/views/settings/ChangeAvatar.tsx +++ b/src/components/views/settings/ChangeAvatar.tsx @@ -26,6 +26,7 @@ import Spinner from '../elements/Spinner'; import { mediaFromMxc } from "../../../customisations/Media"; import RoomAvatar from '../avatars/RoomAvatar'; import BaseAvatar from '../avatars/BaseAvatar'; +import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; interface IProps { initialAvatarUrl?: string; @@ -182,7 +183,7 @@ export default class ChangeAvatar extends React.Component { uploadSection = (
    { _t("Upload new:") } - + { this.state.errorText }
    ); diff --git a/src/components/views/settings/ProfileSettings.tsx b/src/components/views/settings/ProfileSettings.tsx index 9db1d49d836c..94d83439995d 100644 --- a/src/components/views/settings/ProfileSettings.tsx +++ b/src/components/views/settings/ProfileSettings.tsx @@ -29,6 +29,7 @@ import AccessibleButton from '../elements/AccessibleButton'; import AvatarSetting from './AvatarSetting'; import ExternalLink from '../elements/ExternalLink'; import UserIdentifierCustomisations from '../../../customisations/UserIdentifier'; +import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; interface IState { userId?: string; @@ -188,6 +189,7 @@ export default class ProfileSettings extends React.Component<{}, IState> { type="file" ref={this.avatarUpload} className="mx_ProfileSettings_avatarUpload" + onClick={chromeFileInputFix} onChange={this.onAvatarChanged} accept="image/*" /> diff --git a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx index b13277e74434..3467660ff787 100644 --- a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx @@ -31,6 +31,7 @@ import { RoomNotifState } from '../../../../../RoomNotifs'; import defaultDispatcher from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; import { UserTab } from "../../../dialogs/UserTab"; +import { chromeFileInputFix } from "../../../../../utils/BrowserWorkarounds"; interface IProps { roomId: string; @@ -77,7 +78,7 @@ export default class NotificationsSettingsTab extends React.Component): Promise => { + private onSoundUploadChanged = (e: React.ChangeEvent): void => { if (!e.target.files || !e.target.files.length) { this.setState({ uploadedFile: null, @@ -254,7 +255,14 @@ export default class NotificationsSettingsTab extends React.Component{ _t("Set a new custom sound") }
    - +
    { currentUploadedFile } diff --git a/src/components/views/spaces/SpaceBasicSettings.tsx b/src/components/views/spaces/SpaceBasicSettings.tsx index 4f305edd8ba5..14e4d5d5632a 100644 --- a/src/components/views/spaces/SpaceBasicSettings.tsx +++ b/src/components/views/spaces/SpaceBasicSettings.tsx @@ -19,6 +19,7 @@ import React, { useRef, useState } from "react"; import { _t } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; import Field from "../elements/Field"; +import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; interface IProps { avatarUrl?: string; @@ -89,6 +90,7 @@ export const SpaceAvatar = ({ { if (!e.target.files?.length) return; const file = e.target.files[0]; diff --git a/src/utils/BrowserWorkarounds.ts b/src/utils/BrowserWorkarounds.ts new file mode 100644 index 000000000000..ea8ea2a04f96 --- /dev/null +++ b/src/utils/BrowserWorkarounds.ts @@ -0,0 +1,24 @@ +/* +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 { MouseEvent } from "react"; + +export function chromeFileInputFix(event: MouseEvent): void { + // Workaround for Chromium Bug + // Chrome does not fire onChange events if the same file is selected twice + // Only required on Chromium-based browsers (Electron, Chrome, Edge, Opera, Vivaldi, etc) + event.currentTarget.value = ''; +} From 137c015d6cb49793e1b73d97b8ee45b496644c9f Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 12 Apr 2022 14:44:49 +0200 Subject: [PATCH 26/34] add excluded style to components.scss (#8293) Signed-off-by: Kerry Archibald --- res/css/_components.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/res/css/_components.scss b/res/css/_components.scss index 79efb3e89bc5..aefd86326e5c 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/beacon/_LeftPanelLiveShareWarning.scss"; +@import "./components/views/beacon/_LiveTimeRemaining.scss"; @import "./components/views/beacon/_RoomLiveShareWarning.scss"; @import "./components/views/beacon/_StyledLiveBeaconIcon.scss"; @import "./components/views/location/_LiveDurationDropdown.scss"; @@ -14,6 +15,7 @@ @import "./components/views/location/_ShareDialogButtons.scss"; @import "./components/views/location/_ShareType.scss"; @import "./components/views/location/_ZoomButtons.scss"; +@import "./components/views/messages/_MBeaconBody.scss"; @import "./components/views/spaces/_QuickThemeSwitcher.scss"; @import "./structures/_AutoHideScrollbar.scss"; @import "./structures/_BackdropPanel.scss"; From 1e442b2260aec69447eb263476b858d9ca781d80 Mon Sep 17 00:00:00 2001 From: Emmanuel <63562663+EECvision@users.noreply.github.com> Date: Tue, 12 Apr 2022 14:16:00 +0100 Subject: [PATCH 27/34] Fix inconsistent grammar in device sign out modal (#8253) --- src/components/views/settings/DevicesPanel.tsx | 4 +++- src/i18n/strings/en_EN.json | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/views/settings/DevicesPanel.tsx b/src/components/views/settings/DevicesPanel.tsx index 4779c013be5d..71f9b07e99be 100644 --- a/src/components/views/settings/DevicesPanel.tsx +++ b/src/components/views/settings/DevicesPanel.tsx @@ -205,7 +205,9 @@ export default class DevicesPanel extends React.Component { continueKind: "primary", }, [SSOAuthEntry.PHASE_POSTAUTH]: { - title: _t("Confirm signing out these devices"), + title: _t("Confirm signing out these devices", { + count: numDevices, + }), body: _t("Click the button below to confirm signing out these devices.", { count: numDevices, }), diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9afc3d58c395..6040b4baacbd 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1226,7 +1226,8 @@ "Unable to load device list": "Unable to load device list", "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.", "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Confirm logging out this device by using Single Sign On to prove your identity.", - "Confirm signing out these devices": "Confirm signing out these devices", + "Confirm signing out these devices|other": "Confirm signing out these devices", + "Confirm signing out these devices|one": "Confirm signing out this device", "Click the button below to confirm signing out these devices.|other": "Click the button below to confirm signing out these devices.", "Click the button below to confirm signing out these devices.|one": "Click the button below to confirm signing out this device.", "Sign out devices|other": "Sign out devices", From 391ec4c7e2ddeb34513c6e6e9254f803a7f5e2c4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 12 Apr 2022 14:42:35 +0100 Subject: [PATCH 28/34] Handle thread bundled relationships coming from the server via MSC3666 (#8292) --- src/components/structures/RoomView.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 46183cdadb20..1d363b649eae 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -23,7 +23,7 @@ limitations under the License. import React, { createRef } from 'react'; import classNames from 'classnames'; import { IRecommendedVersion, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; -import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; +import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import { EventSubscription } from "fbemitter"; import { ISearchResults } from 'matrix-js-sdk/src/@types/search'; import { logger } from "matrix-js-sdk/src/logger"; @@ -35,6 +35,7 @@ import { throttle } from "lodash"; import { MatrixError } from 'matrix-js-sdk/src/http-api'; import { ClientEvent } from "matrix-js-sdk/src/client"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; +import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread'; import shouldHideEvent from '../../shouldHideEvent'; import { _t } from '../../languageHandler'; @@ -1358,7 +1359,7 @@ export class RoomView extends React.Component { this.handleSearchResult(searchPromise); }; - private handleSearchResult(searchPromise: Promise): Promise { + private handleSearchResult(searchPromise: Promise): Promise { // keep a record of the current search id, so that if the search terms // change before we get a response, we can ignore the results. const localSearchId = this.searchId; @@ -1367,7 +1368,7 @@ export class RoomView extends React.Component { searchInProgress: true, }); - return searchPromise.then((results) => { + return searchPromise.then(async (results) => { debuglog("search complete"); if (this.unmounted || this.state.timelineRenderingType !== TimelineRenderingType.Search || @@ -1394,6 +1395,18 @@ export class RoomView extends React.Component { return b.length - a.length; }); + // Process all thread roots returned in this batch of search results + // XXX: This won't work for results coming from Seshat which won't include the bundled relationship + for (const result of results.results) { + for (const event of result.context.getTimeline()) { + const bundledRelationship = event + .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); + if (!bundledRelationship || event.getThread()) continue; + const room = this.context.getRoom(event.getRoomId()); + event.setThread(room.findThreadForEvent(event) ?? room.createThread(event, [], true)); + } + } + this.setState({ searchHighlights: highlights, searchResults: results, From 59fda5273fc467552fc97678676ad89c0e3f56b5 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 12 Apr 2022 14:42:47 +0100 Subject: [PATCH 29/34] When selecting reply in thread on a thread response open existing thread (#8291) --- src/components/views/messages/MessageActionBar.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 0b2e58f60c97..6af8625ab640 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -232,8 +232,19 @@ export default class MessageActionBar extends React.PureComponent Date: Tue, 12 Apr 2022 15:23:04 +0100 Subject: [PATCH 30/34] Prevent soft crash around room list header context menu when space changes (#8289) --- src/components/views/rooms/RoomListHeader.tsx | 13 +- .../views/rooms/RoomListHeader-test.tsx | 136 ++++++++++++++++++ test/test-utils/test-utils.ts | 1 + 3 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 test/components/views/rooms/RoomListHeader-test.tsx diff --git a/src/components/views/rooms/RoomListHeader.tsx b/src/components/views/rooms/RoomListHeader.tsx index 6b97e8a660aa..6a892df2386b 100644 --- a/src/components/views/rooms/RoomListHeader.tsx +++ b/src/components/views/rooms/RoomListHeader.tsx @@ -140,6 +140,15 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => { } }); + const canShowMainMenu = activeSpace || spaceKey === MetaSpace.Home; + + useEffect(() => { + if (mainMenuDisplayed && !canShowMainMenu) { + // Space changed under us and we no longer has a main menu to draw + closeMainMenu(); + } + }, [closeMainMenu, canShowMainMenu, mainMenuDisplayed]); + // we pass null for the queryLength to inhibit the metrics hook for when there is no filterCondition useWebSearchMetrics(count, filterCondition ? filterCondition.search.length : null, false); @@ -168,7 +177,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => { const canShowPlusMenu = canCreateRooms || canExploreRooms || activeSpace; let contextMenu: JSX.Element; - if (mainMenuDisplayed) { + if (mainMenuDisplayed && mainMenuHandle.current) { let ContextMenuComponent; if (activeSpace) { ContextMenuComponent = SpaceContextMenu; @@ -364,7 +373,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => { .join("\n"); let contextMenuButton: JSX.Element =
    { title }
    ; - if (activeSpace || spaceKey === MetaSpace.Home) { + if (canShowMainMenu) { contextMenuButton = { + let client: MatrixClient; + + beforeEach(() => { + client = createTestClient(); + }); + + it("renders a main menu for the home space", () => { + act(() => { + SpaceStore.instance.setActiveSpace(MetaSpace.Home); + }); + + const wrapper = mount( + + ); + + expect(wrapper.text()).toBe("Home"); + act(() => { + wrapper.find('[aria-label="Home options"]').hostNodes().simulate("click"); + }); + wrapper.update(); + + const menu = wrapper.find(".mx_IconizedContextMenu"); + const items = menu.find(".mx_IconizedContextMenu_item").hostNodes(); + expect(items).toHaveLength(1); + expect(items.at(0).text()).toBe("Show all rooms"); + }); + + it("renders a main menu for spaces", async () => { + const testSpace = mkSpace(client, "!space:server"); + testSpace.name = "Test Space"; + client.getRoom = () => testSpace; + + const getUserIdForRoomId = jest.fn(); + const getDMRoomsForUserId = jest.fn(); + // @ts-ignore + DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId }; + + await testUtils.setupAsyncStoreWithClient(SpaceStore.instance, client); + act(() => { + SpaceStore.instance.setActiveSpace(testSpace.roomId); + }); + + const wrapper = mount( + + ); + + expect(wrapper.text()).toBe("Test Space"); + act(() => { + wrapper.find('[aria-label="Test Space menu"]').hostNodes().simulate("click"); + }); + wrapper.update(); + + const menu = wrapper.find(".mx_IconizedContextMenu"); + const items = menu.find(".mx_IconizedContextMenu_item").hostNodes(); + expect(items).toHaveLength(6); + expect(items.at(0).text()).toBe("Space home"); + expect(items.at(1).text()).toBe("Manage & explore rooms"); + expect(items.at(2).text()).toBe("Preferences"); + expect(items.at(3).text()).toBe("Settings"); + expect(items.at(4).text()).toBe("Room"); + expect(items.at(4).text()).toBe("Room"); + }); + + it("closes menu if space changes from under it", async () => { + await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.DEVICE, { + [MetaSpace.Home]: true, + [MetaSpace.Favourites]: true, + }); + + const testSpace = mkSpace(client, "!space:server"); + testSpace.name = "Test Space"; + client.getRoom = () => testSpace; + + const getUserIdForRoomId = jest.fn(); + const getDMRoomsForUserId = jest.fn(); + // @ts-ignore + DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId }; + + await testUtils.setupAsyncStoreWithClient(SpaceStore.instance, client); + act(() => { + SpaceStore.instance.setActiveSpace(testSpace.roomId); + }); + + const wrapper = mount( + + ); + + expect(wrapper.text()).toBe("Test Space"); + act(() => { + wrapper.find('[aria-label="Test Space menu"]').hostNodes().simulate("click"); + }); + wrapper.update(); + + act(() => { + SpaceStore.instance.setActiveSpace(MetaSpace.Favourites); + }); + wrapper.update(); + + expect(wrapper.text()).toBe("Favourites"); + + const menu = wrapper.find(".mx_IconizedContextMenu"); + expect(menu).toHaveLength(0); + }); +}); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 14bd85bc5c23..fc85a825f31d 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -379,6 +379,7 @@ export function mkStubRoom(roomId: string = null, name: string, client: MatrixCl getJoinRule: jest.fn().mockReturnValue("invite"), loadMembersIfNeeded: jest.fn(), client, + canInvite: jest.fn(), } as unknown as Room; } From ceae8bb39aee2fc5b836d66804fd5e0364f4cfcc Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 12 Apr 2022 15:24:23 +0100 Subject: [PATCH 31/34] Hide the reply in thread button in deployments where beta is forcibly disabled (#8294) --- .../views/messages/MessageActionBar.tsx | 147 ++++++++++-------- src/i18n/strings/en_EN.json | 2 +- 2 files changed, 83 insertions(+), 66 deletions(-) diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 6af8625ab640..80f9b6daf866 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactElement, useEffect } from 'react'; +import React, { ReactElement, useContext, useEffect } from 'react'; import { EventStatus, MatrixEvent, MatrixEventEvent } from 'matrix-js-sdk/src/models/event'; import classNames from 'classnames'; import { MsgType, RelationType } from 'matrix-js-sdk/src/@types/event'; @@ -45,6 +45,7 @@ import { Key } from "../../../Keyboard"; import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts"; import { UserTab } from '../dialogs/UserTab'; import { Action } from '../../../dispatcher/actions'; +import SdkConfig from "../../../SdkConfig"; interface IOptionsButtonProps { mxEvent: MatrixEvent; @@ -154,6 +155,76 @@ const ReactButton: React.FC = ({ mxEvent, reactions, onFocusC ; }; +interface IReplyInThreadButton { + mxEvent: MatrixEvent; +} + +const ReplyInThreadButton = ({ mxEvent }: IReplyInThreadButton) => { + const context = useContext(CardContext); + + const relationType = mxEvent?.getRelation()?.rel_type; + const hasARelation = !!relationType && relationType !== RelationType.Thread; + const firstTimeSeeingThreads = localStorage.getItem("mx_seen_feature_thread") === null && + !SettingsStore.getValue("feature_thread"); + + const onClick = (): void => { + if (localStorage.getItem("mx_seen_feature_thread") === null) { + localStorage.setItem("mx_seen_feature_thread", "true"); + } + + if (!SettingsStore.getValue("feature_thread")) { + dis.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Labs, + }); + } else if (mxEvent.isThreadRelation) { + showThread({ + rootEvent: mxEvent.getThread().rootEvent, + initialEvent: mxEvent, + scroll_into_view: true, + highlighted: true, + push: context.isCard, + }); + } else { + showThread({ + rootEvent: mxEvent, + push: context.isCard, + }); + } + }; + + return +
    + { !hasARelation + ? _t("Reply in thread") + : _t("Can't create a thread from an event with an existing relation") } +
    + { !hasARelation && ( +
    + { SettingsStore.getValue("feature_thread") + ? _t("Beta feature") + : _t("Beta feature. Click to learn more.") + } +
    + ) } + } + + title={!hasARelation + ? _t("Reply in thread") + : _t("Can't create a thread from an event with an existing relation")} + + onClick={onClick} + > + { firstTimeSeeingThreads && ( +
    + ) } + ; +}; + interface IMessageActionBarProps { mxEvent: MatrixEvent; reactions?: Relations; @@ -222,32 +293,6 @@ export default class MessageActionBar extends React.PureComponent { - if (localStorage.getItem("mx_seen_feature_thread") === null) { - localStorage.setItem("mx_seen_feature_thread", "true"); - } - - if (!SettingsStore.getValue("feature_thread")) { - dis.dispatch({ - action: Action.ViewUserSettings, - initialTabId: UserTab.Labs, - }); - } else if (this.props.mxEvent.isThreadRelation) { - showThread({ - rootEvent: this.props.mxEvent.getThread().rootEvent, - initialEvent: this.props.mxEvent, - scroll_into_view: true, - highlighted: true, - push: isCard, - }); - } else { - showThread({ - rootEvent: this.props.mxEvent, - push: isCard, - }); - } - }; - private onEditClick = (): void => { editEvent(this.props.mxEvent, this.context.timelineRenderingType, this.props.getRelationsForEvent); }; @@ -257,6 +302,15 @@ export default class MessageActionBar extends React.PureComponent; - const relationType = this.props.mxEvent?.getRelation()?.rel_type; - const hasARelation = !!relationType && relationType !== RelationType.Thread; - const firstTimeSeeingThreads = localStorage.getItem("mx_seen_feature_thread") === null && - !SettingsStore.getValue("feature_thread"); - const threadTooltipButton = - { context => - -
    - { !hasARelation - ? _t("Reply in thread") - : _t("Can't create a thread from an event with an existing relation") } -
    - { !hasARelation && ( -
    - { SettingsStore.getValue("feature_thread") - ? _t("Beta feature") - : _t("Beta feature. Click to learn more.") - } -
    - ) } - } - - title={!hasARelation - ? _t("Reply in thread") - : _t("Can't create a thread from an event with an existing relation")} - - onClick={this.onThreadClick.bind(null, context.isCard)} - > - { firstTimeSeeingThreads && ( -
    - ) } - - } - ; + const threadTooltipButton = ; // We show a different toolbar for failed events, so detect that first. const mxEvent = this.props.mxEvent; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6040b4baacbd..c5769f9902a4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2080,11 +2080,11 @@ "Go": "Go", "Error processing audio message": "Error processing audio message", "React": "React", - "Edit": "Edit", "Reply in thread": "Reply in thread", "Can't create a thread from an event with an existing relation": "Can't create a thread from an event with an existing relation", "Beta feature": "Beta feature", "Beta feature. Click to learn more.": "Beta feature. Click to learn more.", + "Edit": "Edit", "Reply": "Reply", "Collapse quotes": "Collapse quotes", "Expand quotes": "Expand quotes", From ecdc11d3d5b7bd2db01cbecab3b3bf4e540ef71d Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Tue, 12 Apr 2022 17:52:46 +0200 Subject: [PATCH 32/34] Fix space panel width change on hovering over space item (#8299) * Fix space panel width change on hovering over space item Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- res/css/structures/_SpacePanel.scss | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 74d30bf59ab6..1cbc7cd73525 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -251,7 +251,8 @@ $activeBorderColor: $primary-content; margin-top: auto; margin-bottom: auto; display: none; - position: relative; + position: absolute; + right: 4px; &::before { top: 3px; @@ -327,6 +328,16 @@ $activeBorderColor: $primary-content; } } + .mx_SpaceItem:not(.mx_SpaceItem_new) { + .mx_SpaceButton:hover, + .mx_SpaceButton:focus-within, + .mx_SpaceButton_hasMenuOpen { + &:not(.mx_SpaceButton_narrow):not(.mx_SpaceButton_invite) .mx_SpaceButton_name { + max-width: calc(100% - 56px); + } + } + } + /* root space buttons are bigger and not indented */ & > .mx_AutoHideScrollbar { flex: 1; From 82981e4161dda0bb303855caf23ec706c74c5ec6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 13 Apr 2022 00:46:08 +0100 Subject: [PATCH 33/34] End to end tests for threads (#8267) --- test/end-to-end-tests/.gitignore | 1 + test/end-to-end-tests/src/rest/room.ts | 8 +- test/end-to-end-tests/src/scenario.ts | 9 ++ .../end-to-end-tests/src/scenarios/threads.ts | 83 ++++++++++ test/end-to-end-tests/src/session.ts | 7 +- .../src/usecases/rightpanel.ts | 22 +++ test/end-to-end-tests/src/usecases/signup.ts | 8 +- test/end-to-end-tests/src/usecases/threads.ts | 153 ++++++++++++++++++ 8 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 test/end-to-end-tests/src/scenarios/threads.ts create mode 100644 test/end-to-end-tests/src/usecases/threads.ts diff --git a/test/end-to-end-tests/.gitignore b/test/end-to-end-tests/.gitignore index 528c296f9311..a604178f5fdd 100644 --- a/test/end-to-end-tests/.gitignore +++ b/test/end-to-end-tests/.gitignore @@ -4,3 +4,4 @@ element/env performance-entries.json lib logs +homeserver.log diff --git a/test/end-to-end-tests/src/rest/room.ts b/test/end-to-end-tests/src/rest/room.ts index da5f91a60709..2261f959936c 100644 --- a/test/end-to-end-tests/src/rest/room.ts +++ b/test/end-to-end-tests/src/rest/room.ts @@ -20,19 +20,19 @@ import uuidv4 = require('uuid/v4'); import { RestSession } from "./session"; import { Logger } from "../logger"; -/* no pun intented */ +/* no pun intended */ export class RestRoom { constructor(readonly session: RestSession, readonly roomId: string, readonly log: Logger) {} - async talk(message: string): Promise { + async talk(message: string): Promise { this.log.step(`says "${message}" in ${this.roomId}`); const txId = uuidv4(); - await this.session.put(`/rooms/${this.roomId}/send/m.room.message/${txId}`, { + const { event_id: eventId } = await this.session.put(`/rooms/${this.roomId}/send/m.room.message/${txId}`, { "msgtype": "m.text", "body": message, }); this.log.done(); - return txId; + return eventId; } async leave(): Promise { diff --git a/test/end-to-end-tests/src/scenario.ts b/test/end-to-end-tests/src/scenario.ts index e6a588eac969..e843504c4869 100644 --- a/test/end-to-end-tests/src/scenario.ts +++ b/test/end-to-end-tests/src/scenario.ts @@ -30,6 +30,8 @@ import { stickerScenarios } from './scenarios/sticker'; import { userViewScenarios } from "./scenarios/user-view"; import { ssoCustomisationScenarios } from "./scenarios/sso-customisations"; import { updateScenarios } from "./scenarios/update"; +import { threadsScenarios } from "./scenarios/threads"; +import { enableThreads } from "./usecases/threads"; export async function scenario(createSession: (s: string) => Promise, restCreator: RestSessionCreator): Promise { @@ -48,6 +50,12 @@ export async function scenario(createSession: (s: string) => Promise Promise { + console.log(" threads tests:"); + + // Alice sends message + await sendMessage(alice, "Hey bob, what do you think about X?"); + + // Bob responds via a thread + await startThread(bob, "I think its Y!"); + + // Alice sees thread summary and opens thread panel + await assertTimelineThreadSummary(alice, "bob", "I think its Y!"); + await assertTimelineThreadSummary(bob, "bob", "I think its Y!"); + await clickTimelineThreadSummary(alice); + + // Bob closes right panel + await closeRoomRightPanel(bob); + + // Alice responds in thread + await sendThreadMessage(alice, "Great!"); + await assertTimelineThreadSummary(alice, "alice", "Great!"); + await assertTimelineThreadSummary(bob, "alice", "Great!"); + + // Alice reacts to Bob's message instead + await reactThreadMessage(alice, "😁"); + await assertTimelineThreadSummary(alice, "alice", "Great!"); + await assertTimelineThreadSummary(bob, "alice", "Great!"); + await redactThreadMessage(alice); + await assertTimelineThreadSummary(alice, "bob", "I think its Y!"); + await assertTimelineThreadSummary(bob, "bob", "I think its Y!"); + + // Bob sees notification dot on the thread header icon + await assertThreadListHasUnreadIndicator(bob); + + // Bob opens thread list and inspects it + await openThreadListPanel(bob); + + // Bob opens thread in right panel via thread list + await clickLatestThreadInThreadListPanel(bob); + + // Bob responds to thread + await sendThreadMessage(bob, "Testing threads s'more :)"); + await assertTimelineThreadSummary(alice, "bob", "Testing threads s'more :)"); + await assertTimelineThreadSummary(bob, "bob", "Testing threads s'more :)"); + + // Bob edits thread response + await editThreadMessage(bob, "Testing threads some more :)"); + await assertTimelineThreadSummary(alice, "bob", "Testing threads some more :)"); + await assertTimelineThreadSummary(bob, "bob", "Testing threads some more :)"); +} diff --git a/test/end-to-end-tests/src/session.ts b/test/end-to-end-tests/src/session.ts index 445cf1c477aa..86f612b0afaf 100644 --- a/test/end-to-end-tests/src/session.ts +++ b/test/end-to-end-tests/src/session.ts @@ -131,8 +131,11 @@ export class ElementSession { await input.type(text); } - public query(selector: string, timeout: number = DEFAULT_TIMEOUT, - hidden = false): Promise { + public query( + selector: string, + timeout: number = DEFAULT_TIMEOUT, + hidden = false, + ): Promise { return this.page.waitForSelector(selector, { visible: true, timeout, hidden }); } diff --git a/test/end-to-end-tests/src/usecases/rightpanel.ts b/test/end-to-end-tests/src/usecases/rightpanel.ts index c91e3fad5758..83417ccb1af4 100644 --- a/test/end-to-end-tests/src/usecases/rightpanel.ts +++ b/test/end-to-end-tests/src/usecases/rightpanel.ts @@ -16,6 +16,28 @@ limitations under the License. import { ElementSession } from "../session"; +export async function closeRoomRightPanel(session: ElementSession): Promise { + const button = await session.query(".mx_BaseCard_close"); + await button.click(); +} + +export async function openThreadListPanel(session: ElementSession): Promise { + await session.query('.mx_RoomHeader .mx_AccessibleButton[aria-label="Threads"]'); + const button = await session.queryWithoutWaiting('.mx_RoomHeader .mx_AccessibleButton[aria-label="Threads"]' + + ':not(.mx_RightPanel_headerButton_highlight)'); + await button?.click(); +} + +export async function assertThreadListHasUnreadIndicator(session: ElementSession): Promise { + await session.query('.mx_RoomHeader .mx_AccessibleButton[aria-label="Threads"] ' + + '.mx_RightPanel_headerButton_unreadIndicator'); +} + +export async function clickLatestThreadInThreadListPanel(session: ElementSession): Promise { + const threads = await session.queryAll(".mx_ThreadPanel .mx_EventTile"); + await threads[threads.length - 1].click(); +} + export async function openRoomRightPanel(session: ElementSession): Promise { // block until we have a roomSummaryButton const roomSummaryButton = await session.query('.mx_RoomHeader .mx_AccessibleButton[aria-label="Room Info"]'); diff --git a/test/end-to-end-tests/src/usecases/signup.ts b/test/end-to-end-tests/src/usecases/signup.ts index 55301c3108c4..86d272053569 100644 --- a/test/end-to-end-tests/src/usecases/signup.ts +++ b/test/end-to-end-tests/src/usecases/signup.ts @@ -19,8 +19,12 @@ import { strict as assert } from 'assert'; import { ElementSession } from "../session"; -export async function signup(session: ElementSession, username: string, password: string, - homeserver: string): Promise { +export async function signup( + session: ElementSession, + username: string, + password: string, + homeserver: string, +): Promise { session.log.step("signs up"); await session.goto(session.url('/#/register')); // change the homeserver by clicking the advanced section diff --git a/test/end-to-end-tests/src/usecases/threads.ts b/test/end-to-end-tests/src/usecases/threads.ts new file mode 100644 index 000000000000..9e51fe7eec4d --- /dev/null +++ b/test/end-to-end-tests/src/usecases/threads.ts @@ -0,0 +1,153 @@ +/* +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 { strict as assert } from "assert"; + +import { ElementSession } from "../session"; + +export async function enableThreads(session: ElementSession): Promise { + session.log.step(`enables threads`); + await session.page.evaluate(() => { + window.localStorage.setItem("mx_seen_feature_thread_experimental", "1"); // inhibit dialog + window["mxSettingsStore"].setValue("feature_thread", null, "device", true); + }); + session.log.done(); +} + +async function clickReplyInThread(session: ElementSession): Promise { + const events = await session.queryAll(".mx_EventTile_line"); + const event = events[events.length - 1]; + await event.hover(); + const button = await event.$(".mx_MessageActionBar_threadButton"); + await button.click(); +} + +export async function sendThreadMessage(session: ElementSession, message: string): Promise { + session.log.step(`sends thread response "${message}"`); + const composer = await session.query(".mx_ThreadView .mx_BasicMessageComposer_input"); + await composer.click(); + await composer.type(message); + + const text = await session.innerText(composer); + assert.equal(text.trim(), message.trim()); + await composer.press("Enter"); + // wait for the message to appear sent + await session.query(".mx_ThreadView .mx_EventTile_last:not(.mx_EventTile_sending)"); + session.log.done(); +} + +export async function editThreadMessage(session: ElementSession, message: string): Promise { + session.log.step(`edits thread response "${message}"`); + const events = await session.queryAll(".mx_EventTile_line"); + const event = events[events.length - 1]; + await event.hover(); + const button = await event.$(".mx_MessageActionBar_editButton"); + await button.click(); + + const composer = await session.query(".mx_ThreadView .mx_EditMessageComposer .mx_BasicMessageComposer_input"); + await composer.click({ clickCount: 3 }); + await composer.type(message); + + const text = await session.innerText(composer); + assert.equal(text.trim(), message.trim()); + await composer.press("Enter"); + // wait for the edit to appear sent + await session.query(".mx_ThreadView .mx_EventTile_last:not(.mx_EventTile_sending)"); + session.log.done(); +} + +export async function redactThreadMessage(session: ElementSession): Promise { + session.log.startGroup(`redacts latest thread response`); + + const events = await session.queryAll(".mx_ThreadView .mx_EventTile_line"); + const event = events[events.length - 1]; + await event.hover(); + + session.log.step(`clicks the ... button`); + let button = await event.$('.mx_MessageActionBar [aria-label="Options"]'); + await button.click(); + session.log.done(); + + session.log.step(`clicks the remove option`); + button = await session.query('.mx_IconizedContextMenu_item[aria-label="Remove"]'); + await button.click(); + session.log.done(); + + session.log.step(`confirms in the dialog`); + button = await session.query(".mx_Dialog_primary"); + await button.click(); + session.log.done(); + + await session.query(".mx_ThreadView .mx_RedactedBody"); + + session.log.endGroup(); +} + +export async function reactThreadMessage(session: ElementSession, reaction: string): Promise { + session.log.startGroup(`reacts to latest thread response`); + + const events = await session.queryAll(".mx_ThreadView .mx_EventTile_line"); + const event = events[events.length - 1]; + await event.hover(); + + session.log.step(`clicks the reaction button`); + let button = await event.$('.mx_MessageActionBar [aria-label="React"]'); + await button.click(); + session.log.done(); + + session.log.step(`selects reaction`); + button = await session.query(`.mx_EmojiPicker_item_wrapper[aria-label=${reaction}]`); + await button.click; + session.log.done(); + + session.log.step(`clicks away`); + button = await session.query(".mx_ContextualMenu_background"); + await button.click(); + session.log.done(); + + session.log.endGroup(); +} + +export async function startThread(session: ElementSession, response: string): Promise { + session.log.startGroup(`creates thread on latest message`); + + await clickReplyInThread(session); + await sendThreadMessage(session, response); + + session.log.endGroup(); +} + +export async function assertTimelineThreadSummary( + session: ElementSession, + sender: string, + content: string, +): Promise { + session.log.step("asserts the timeline thread summary is as expected"); + const summaries = await session.queryAll(".mx_MainSplit_timeline .mx_ThreadInfo"); + const summary = summaries[summaries.length - 1]; + assert.equal(await session.innerText(await summary.$(".mx_ThreadInfo_sender")), sender); + assert.equal(await session.innerText(await summary.$(".mx_ThreadInfo_content")), content); + session.log.done(); +} + +export async function clickTimelineThreadSummary(session: ElementSession): Promise { + session.log.step(`clicks the latest thread summary in the timeline`); + + const summaries = await session.queryAll(".mx_MainSplit_timeline .mx_ThreadInfo"); + await summaries[summaries.length - 1].click(); + + session.log.done(); +} From a4d3da78d70a29f448b17d92eca610b399c117a0 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 12 Apr 2022 20:22:34 -0400 Subject: [PATCH 34/34] Fix coverage diffs for PRs that aren't up to date, take 3 (#8301) --- .github/workflows/test_coverage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml index c915412e022b..4cd9f6d2f069 100644 --- a/.github/workflows/test_coverage.yml +++ b/.github/workflows/test_coverage.yml @@ -32,3 +32,4 @@ jobs: with: fail_ci_if_error: false verbose: true + override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}