From dc6ceb1d1c6831f96a35ff2cb2bc29cb2f1267f3 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 18 Jul 2022 10:34:39 +0200 Subject: [PATCH 01/13] Live location share - focus on user location on list item click (PSG-609) (#9051) * extract preventDefaultWrapper into utils * add click handling to beacon list item * add click handling to dialog sidebar * focus in on beacons when clicked in list * stylelint * fussy import ordering * test beacon focusing in beaocnviewdialog --- .../views/beacon/_BeaconListItem.pcss | 7 + .../views/beacon/BeaconListItem.tsx | 13 +- .../views/beacon/BeaconViewDialog.tsx | 56 ++++-- src/components/views/beacon/DialogSidebar.tsx | 15 +- .../views/beacon/OwnBeaconStatus.tsx | 17 +- src/components/views/location/Map.tsx | 7 + src/components/views/messages/MBeaconBody.tsx | 2 +- src/utils/NativeEventUtils.ts | 25 +++ src/utils/location/useMap.ts | 2 +- .../views/beacon/BeaconListItem-test.tsx | 26 +++ .../views/beacon/BeaconViewDialog-test.tsx | 141 ++++++++++++++-- .../views/beacon/DialogSidebar-test.tsx | 75 ++++++++- .../BeaconListItem-test.tsx.snap | 2 +- .../__snapshots__/DialogSidebar-test.tsx.snap | 159 ++++++++++++++---- test/test-utils/beacon.ts | 6 +- test/test-utils/utilities.ts | 3 +- 16 files changed, 470 insertions(+), 86 deletions(-) create mode 100644 src/utils/NativeEventUtils.ts diff --git a/res/css/components/views/beacon/_BeaconListItem.pcss b/res/css/components/views/beacon/_BeaconListItem.pcss index 00f8bcbe5b6..42032b14c1b 100644 --- a/res/css/components/views/beacon/_BeaconListItem.pcss +++ b/res/css/components/views/beacon/_BeaconListItem.pcss @@ -22,6 +22,8 @@ limitations under the License. padding: $spacing-12 0; border-bottom: 1px solid $system; + + cursor: pointer; } .mx_BeaconListItem_avatarIcon { @@ -61,3 +63,8 @@ limitations under the License. color: $tertiary-content; font-size: $font-10px; } + +.mx_BeaconListItem_interactions { + display: flex; + flex-direction: row; +} diff --git a/src/components/views/beacon/BeaconListItem.tsx b/src/components/views/beacon/BeaconListItem.tsx index bcfb4971766..414b45a7f75 100644 --- a/src/components/views/beacon/BeaconListItem.tsx +++ b/src/components/views/beacon/BeaconListItem.tsx @@ -14,13 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useContext } from 'react'; +import React, { HTMLProps, useContext } from 'react'; import { Beacon, BeaconEvent } from 'matrix-js-sdk/src/matrix'; import { LocationAssetType } from 'matrix-js-sdk/src/@types/location'; import MatrixClientContext from '../../../contexts/MatrixClientContext'; import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { humanizeTime } from '../../../utils/humanize'; +import { preventDefaultWrapper } from '../../../utils/NativeEventUtils'; import { _t } from '../../../languageHandler'; import MemberAvatar from '../avatars/MemberAvatar'; import BeaconStatus from './BeaconStatus'; @@ -32,7 +33,7 @@ interface Props { beacon: Beacon; } -const BeaconListItem: React.FC = ({ beacon }) => { +const BeaconListItem: React.FC> = ({ beacon, ...rest }) => { const latestLocationState = useEventEmitterState( beacon, BeaconEvent.LocationUpdate, @@ -52,7 +53,7 @@ const BeaconListItem: React.FC = ({ beacon }) => { const humanizedUpdateTime = humanizeTime(latestLocationState.timestamp); - return
  • + return
  • { isSelfLocation ? = ({ beacon }) => { label={beaconMember?.name || beacon.beaconInfo.description || beacon.beaconInfoOwner} displayStatus={BeaconDisplayStatus.Active} > - + { /* eat events from interactive share buttons + so parent click handlers are not triggered */ } +
    {})}> + +
    { _t("Updated %(humanizedUpdateTime)s", { humanizedUpdateTime }) } diff --git a/src/components/views/beacon/BeaconViewDialog.tsx b/src/components/views/beacon/BeaconViewDialog.tsx index af21c64339d..50a880c1b3f 100644 --- a/src/components/views/beacon/BeaconViewDialog.tsx +++ b/src/components/views/beacon/BeaconViewDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { Beacon, @@ -45,7 +45,16 @@ interface IProps extends IDialogProps { roomId: Room['roomId']; matrixClient: MatrixClient; // open the map centered on this beacon's location - focusBeacon?: Beacon; + initialFocusedBeacon?: Beacon; +} + +// track the 'focused time' as ts +// to make it possible to refocus the same beacon +// as the beacon location may change +// or the map may move around +interface FocusedBeaconState { + ts: number; + beacon?: Beacon; } const getBoundsCenter = (bounds: Bounds): string | undefined => { @@ -59,31 +68,52 @@ const getBoundsCenter = (bounds: Bounds): string | undefined => { }); }; -const useInitialMapPosition = (liveBeacons: Beacon[], focusBeacon?: Beacon): { +const useInitialMapPosition = (liveBeacons: Beacon[], { beacon, ts }: FocusedBeaconState): { bounds?: Bounds; centerGeoUri: string; } => { - const bounds = useRef(getBeaconBounds(liveBeacons)); - const centerGeoUri = useRef( - focusBeacon?.latestLocationState?.uri || - getBoundsCenter(bounds.current), + const [bounds, setBounds] = useState(getBeaconBounds(liveBeacons)); + const [centerGeoUri, setCenterGeoUri] = useState( + beacon?.latestLocationState?.uri || + getBoundsCenter(bounds), ); - return { bounds: bounds.current, centerGeoUri: centerGeoUri.current }; + + useEffect(() => { + if ( + // this check ignores the first initial focused beacon state + // as centering logic on map zooms to show everything + // instead of focusing down + ts !== 0 && + // only set focus to a known location + beacon?.latestLocationState?.uri + ) { + // append custom `mxTs` parameter to geoUri + // so map is triggered to refocus on this uri + // event if it was previously the center geouri + // but the map have moved/zoomed + setCenterGeoUri(`${beacon?.latestLocationState?.uri};mxTs=${Date.now()}`); + setBounds(getBeaconBounds([beacon])); + } + }, [beacon, ts]); + + return { bounds, centerGeoUri }; }; /** * Dialog to view live beacons maximised */ const BeaconViewDialog: React.FC = ({ - focusBeacon, + initialFocusedBeacon, roomId, matrixClient, onFinished, }) => { const liveBeacons = useLiveBeacons(roomId, matrixClient); + const [focusedBeaconState, setFocusedBeaconState] = + useState({ beacon: initialFocusedBeacon, ts: 0 }); const [isSidebarOpen, setSidebarOpen] = useState(false); - const { bounds, centerGeoUri } = useInitialMapPosition(liveBeacons, focusBeacon); + const { bounds, centerGeoUri } = useInitialMapPosition(liveBeacons, focusedBeaconState); const [mapDisplayError, setMapDisplayError] = useState(); @@ -94,6 +124,10 @@ const BeaconViewDialog: React.FC = ({ } }, [mapDisplayError]); + const onBeaconListItemClick = (beacon: Beacon) => { + setFocusedBeaconState({ beacon, ts: Date.now() }); + }; + return ( = ({ } { isSidebarOpen ? - setSidebarOpen(false)} /> : + setSidebarOpen(false)} /> : setSidebarOpen(true)} diff --git a/src/components/views/beacon/DialogSidebar.tsx b/src/components/views/beacon/DialogSidebar.tsx index 4365b5fa8b6..0b5442cade6 100644 --- a/src/components/views/beacon/DialogSidebar.tsx +++ b/src/components/views/beacon/DialogSidebar.tsx @@ -26,9 +26,14 @@ import BeaconListItem from './BeaconListItem'; interface Props { beacons: Beacon[]; requestClose: () => void; + onBeaconClick: (beacon: Beacon) => void; } -const DialogSidebar: React.FC = ({ beacons, requestClose }) => { +const DialogSidebar: React.FC = ({ + beacons, + onBeaconClick, + requestClose, +}) => { return
    { _t('View List') } @@ -36,13 +41,17 @@ const DialogSidebar: React.FC = ({ beacons, requestClose }) => { className='mx_DialogSidebar_closeButton' onClick={requestClose} title={_t('Close sidebar')} - data-test-id='dialog-sidebar-close' + data-testid='dialog-sidebar-close' >
      - { beacons.map((beacon) => ) } + { beacons.map((beacon) => onBeaconClick(beacon)} + />) }
    ; }; diff --git a/src/components/views/beacon/OwnBeaconStatus.tsx b/src/components/views/beacon/OwnBeaconStatus.tsx index 87603761317..9a6126c0b7b 100644 --- a/src/components/views/beacon/OwnBeaconStatus.tsx +++ b/src/components/views/beacon/OwnBeaconStatus.tsx @@ -19,6 +19,7 @@ import React, { HTMLProps } from 'react'; import { _t } from '../../../languageHandler'; import { useOwnLiveBeacons } from '../../../utils/beacon'; +import { preventDefaultWrapper } from '../../../utils/NativeEventUtils'; import BeaconStatus from './BeaconStatus'; import { BeaconDisplayStatus } from './displayStatus'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; @@ -45,14 +46,6 @@ const OwnBeaconStatus: React.FC> = ({ onResetLocationPublishError, } = useOwnLiveBeacons([beacon?.identifier]); - // eat events here to avoid 1) the map and 2) reply or thread tiles - // moving under the beacon status on stop/retry click - const preventDefaultWrapper = (callback: () => void) => (e?: ButtonEvent) => { - e?.stopPropagation(); - e?.preventDefault(); - callback(); - }; - // combine display status with errors that only occur for user's own beacons const ownDisplayStatus = hasLocationPublishError || hasStopSharingError ? BeaconDisplayStatus.Error : @@ -68,7 +61,9 @@ const OwnBeaconStatus: React.FC> = ({ { ownDisplayStatus === BeaconDisplayStatus.Active && (onStopSharing)} className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton' disabled={stoppingInProgress} > @@ -78,6 +73,8 @@ const OwnBeaconStatus: React.FC> = ({ { hasLocationPublishError && @@ -87,6 +84,8 @@ const OwnBeaconStatus: React.FC> = ({ { hasStopSharingError && diff --git a/src/components/views/location/Map.tsx b/src/components/views/location/Map.tsx index 023ff2d5ccb..6cd75cfafce 100644 --- a/src/components/views/location/Map.tsx +++ b/src/components/views/location/Map.tsx @@ -80,6 +80,13 @@ const useMapWithStyle = ({ id, centerGeoUri, onError, interactive, bounds }) => interface MapProps { id: string; interactive?: boolean; + /** + * set map center to geoUri coords + * Center will only be set to valid geoUri + * this prop is only simply diffed by useEffect, so to trigger *recentering* of the same geoUri + * append the uri with a var not used by the geoUri spec + * eg a timestamp: `geo:54,42;mxTs=123` + */ centerGeoUri?: string; bounds?: Bounds; className?: string; diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx index a5022317d27..8c75de01ba9 100644 --- a/src/components/views/messages/MBeaconBody.tsx +++ b/src/components/views/messages/MBeaconBody.tsx @@ -162,7 +162,7 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent, getRelati { roomId: mxEvent.getRoomId(), matrixClient, - focusBeacon: beacon, + initialFocusedBeacon: beacon, isMapDisplayError, }, "mx_BeaconViewDialog_wrapper", diff --git a/src/utils/NativeEventUtils.ts b/src/utils/NativeEventUtils.ts new file mode 100644 index 00000000000..3094b57bd42 --- /dev/null +++ b/src/utils/NativeEventUtils.ts @@ -0,0 +1,25 @@ +/* +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"; + +// Wrap DOM event handlers with stopPropagation and preventDefault +export const preventDefaultWrapper = + (callback: () => void) => (e?: T) => { + e?.stopPropagation(); + e?.preventDefault(); + callback(); + }; diff --git a/src/utils/location/useMap.ts b/src/utils/location/useMap.ts index d4083629506..55770cc5e28 100644 --- a/src/utils/location/useMap.ts +++ b/src/utils/location/useMap.ts @@ -35,7 +35,7 @@ export const useMap = ({ interactive, bodyId, onError, -}: UseMapProps): MapLibreMap => { +}: UseMapProps): MapLibreMap | undefined => { const [map, setMap] = useState(); useEffect( diff --git a/test/components/views/beacon/BeaconListItem-test.tsx b/test/components/views/beacon/BeaconListItem-test.tsx index e7e9fbb7265..238a1dd041a 100644 --- a/test/components/views/beacon/BeaconListItem-test.tsx +++ b/test/components/views/beacon/BeaconListItem-test.tsx @@ -27,6 +27,7 @@ import { act } from 'react-dom/test-utils'; import BeaconListItem from '../../../../src/components/views/beacon/BeaconListItem'; import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; import { + findByTestId, getMockClientWithEventEmitter, makeBeaconEvent, makeBeaconInfoEvent, @@ -169,5 +170,30 @@ describe('', () => { expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated a few seconds ago'); }); }); + + describe('interactions', () => { + it('does not call onClick handler when clicking share button', () => { + const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); + const onClick = jest.fn(); + const component = getComponent({ beacon, onClick }); + + act(() => { + findByTestId(component, 'open-location-in-osm').at(0).simulate('click'); + }); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('calls onClick handler when clicking outside of share buttons', () => { + const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); + const onClick = jest.fn(); + const component = getComponent({ beacon, onClick }); + + act(() => { + // click the beacon name + component.find('.mx_BeaconStatus_description').simulate('click'); + }); + expect(onClick).toHaveBeenCalled(); + }); + }); }); }); diff --git a/test/components/views/beacon/BeaconViewDialog-test.tsx b/test/components/views/beacon/BeaconViewDialog-test.tsx index 12b40939392..12c82968a55 100644 --- a/test/components/views/beacon/BeaconViewDialog-test.tsx +++ b/test/components/views/beacon/BeaconViewDialog-test.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from 'react'; -import { mount } from 'enzyme'; +import { mount, ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { MatrixClient, @@ -28,15 +28,18 @@ import maplibregl from 'maplibre-gl'; import BeaconViewDialog from '../../../../src/components/views/beacon/BeaconViewDialog'; import { + findByAttr, findByTestId, getMockClientWithEventEmitter, makeBeaconEvent, makeBeaconInfoEvent, + makeRoomWithBeacons, makeRoomWithStateEvents, } from '../../../test-utils'; import { TILE_SERVER_WK_KEY } from '../../../../src/utils/WellKnownUtils'; import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore'; import { BeaconDisplayStatus } from '../../../../src/components/views/beacon/displayStatus'; +import BeaconListItem from '../../../../src/components/views/beacon/BeaconListItem'; describe('', () => { // 14.03.2022 16:15 @@ -89,13 +92,18 @@ describe('', () => { const getComponent = (props = {}) => mount(); + const openSidebar = (component: ReactWrapper) => act(() => { + findByTestId(component, 'beacon-view-dialog-open-sidebar').at(0).simulate('click'); + component.setProps({}); + }); + beforeAll(() => { maplibregl.AttributionControl = jest.fn(); }); beforeEach(() => { jest.spyOn(OwnBeaconStore.instance, 'getLiveBeaconIds').mockRestore(); - + jest.spyOn(global.Date, 'now').mockReturnValue(now); jest.clearAllMocks(); }); @@ -225,10 +233,7 @@ describe('', () => { beacon.addLocations([location1]); const component = getComponent(); - act(() => { - findByTestId(component, 'beacon-view-dialog-open-sidebar').at(0).simulate('click'); - component.setProps({}); - }); + openSidebar(component); expect(component.find('DialogSidebar').length).toBeTruthy(); }); @@ -240,20 +245,134 @@ describe('', () => { const component = getComponent(); // open the sidebar - act(() => { - findByTestId(component, 'beacon-view-dialog-open-sidebar').at(0).simulate('click'); - component.setProps({}); - }); + openSidebar(component); expect(component.find('DialogSidebar').length).toBeTruthy(); // now close it act(() => { - findByTestId(component, 'dialog-sidebar-close').at(0).simulate('click'); + findByAttr('data-testid')(component, 'dialog-sidebar-close').at(0).simulate('click'); component.setProps({}); }); expect(component.find('DialogSidebar').length).toBeFalsy(); }); }); + + describe('focused beacons', () => { + const beacon2Event = makeBeaconInfoEvent(bobId, + roomId, + { isLive: true }, + '$bob-room1-2', + ); + + const location2 = makeBeaconEvent( + bobId, { beaconInfoId: beacon2Event.getId(), geoUri: 'geo:33,22', timestamp: now + 1 }, + ); + + const fitBoundsOptions = { maxZoom: 15, padding: 100 }; + + it('opens map with both beacons in view on first load without initialFocusedBeacon', () => { + const [beacon1, beacon2] = makeRoomWithBeacons( + roomId, mockClient, [defaultEvent, beacon2Event], [location1, location2], + ); + + getComponent({ beacons: [beacon1, beacon2] }); + + // start centered on mid point between both beacons + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 42, lon: 31.5 }); + // only called once + expect(mockMap.setCenter).toHaveBeenCalledTimes(1); + // bounds fit both beacons, only called once + expect(mockMap.fitBounds).toHaveBeenCalledWith(new maplibregl.LngLatBounds( + [22, 33], [41, 51], + ), fitBoundsOptions); + expect(mockMap.fitBounds).toHaveBeenCalledTimes(1); + }); + + it('opens map with both beacons in view on first load with an initially focused beacon', () => { + const [beacon1, beacon2] = makeRoomWithBeacons( + roomId, mockClient, [defaultEvent, beacon2Event], [location1, location2], + ); + + getComponent({ beacons: [beacon1, beacon2], initialFocusedBeacon: beacon1 }); + + // start centered on initialFocusedBeacon + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 41 }); + // only called once + expect(mockMap.setCenter).toHaveBeenCalledTimes(1); + // bounds fit both beacons, only called once + expect(mockMap.fitBounds).toHaveBeenCalledWith(new maplibregl.LngLatBounds( + [22, 33], [41, 51], + ), fitBoundsOptions); + expect(mockMap.fitBounds).toHaveBeenCalledTimes(1); + }); + + it('focuses on beacon location on sidebar list item click', () => { + const [beacon1, beacon2] = makeRoomWithBeacons( + roomId, mockClient, [defaultEvent, beacon2Event], [location1, location2], + ); + + const component = getComponent({ beacons: [beacon1, beacon2] }); + + // reset call counts on map mocks after initial render + jest.clearAllMocks(); + + openSidebar(component); + + act(() => { + // click on the first beacon in the list + component.find(BeaconListItem).at(0).simulate('click'); + }); + + // centered on clicked beacon + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 41 }); + // only called once + expect(mockMap.setCenter).toHaveBeenCalledTimes(1); + // bounds fitted just to clicked beacon + expect(mockMap.fitBounds).toHaveBeenCalledWith(new maplibregl.LngLatBounds( + [41, 51], [41, 51], + ), fitBoundsOptions); + expect(mockMap.fitBounds).toHaveBeenCalledTimes(1); + }); + + it('refocuses on same beacon when clicking list item again', () => { + // test the map responds to refocusing the same beacon + const [beacon1, beacon2] = makeRoomWithBeacons( + roomId, mockClient, [defaultEvent, beacon2Event], [location1, location2], + ); + + const component = getComponent({ beacons: [beacon1, beacon2] }); + + // reset call counts on map mocks after initial render + jest.clearAllMocks(); + + openSidebar(component); + + act(() => { + // click on the second beacon in the list + component.find(BeaconListItem).at(1).simulate('click'); + }); + + const expectedBounds = new maplibregl.LngLatBounds( + [22, 33], [22, 33], + ); + + // date is mocked but this relies on timestamp, manually mock a tick + jest.spyOn(global.Date, 'now').mockReturnValue(now + 1); + + act(() => { + // click on the second beacon in the list + component.find(BeaconListItem).at(1).simulate('click'); + }); + + // centered on clicked beacon + expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 33, lon: 22 }); + // bounds fitted just to clicked beacon + expect(mockMap.fitBounds).toHaveBeenCalledWith(expectedBounds, fitBoundsOptions); + // each called once per click + expect(mockMap.setCenter).toHaveBeenCalledTimes(2); + expect(mockMap.fitBounds).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/test/components/views/beacon/DialogSidebar-test.tsx b/test/components/views/beacon/DialogSidebar-test.tsx index a5a1f0e5e79..8249ec37123 100644 --- a/test/components/views/beacon/DialogSidebar-test.tsx +++ b/test/components/views/beacon/DialogSidebar-test.tsx @@ -15,31 +15,88 @@ limitations under the License. */ import React from 'react'; -import { mount } from 'enzyme'; +import { fireEvent, render } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; import DialogSidebar from '../../../../src/components/views/beacon/DialogSidebar'; -import { findByTestId } from '../../../test-utils'; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; +import { + getMockClientWithEventEmitter, + makeBeaconEvent, + makeBeaconInfoEvent, + makeRoomWithBeacons, + mockClientMethodsUser, +} from '../../../test-utils'; describe('', () => { const defaultProps = { beacons: [], requestClose: jest.fn(), + onBeaconClick: jest.fn(), }; - const getComponent = (props = {}) => - mount(); - it('renders sidebar correctly', () => { - const component = getComponent(); - expect(component).toMatchSnapshot(); + const now = 1647270879403; + + const roomId = '!room:server.org'; + const aliceId = '@alice:server.org'; + const client = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(aliceId), + getRoom: jest.fn(), + }); + + const beaconEvent = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: true, timestamp: now }, + '$alice-room1-1', + ); + const location1 = makeBeaconEvent( + aliceId, { beaconInfoId: beaconEvent.getId(), geoUri: 'geo:51,41', timestamp: now }, + ); + + const getComponent = (props = {}) => ( + + ); + ); + + beforeEach(() => { + // mock now so time based text in snapshots is stable + jest.spyOn(Date, 'now').mockReturnValue(now); + }); + + afterAll(() => { + jest.spyOn(Date, 'now').mockRestore(); + }); + + it('renders sidebar correctly without beacons', () => { + const { container } = render(getComponent()); + expect(container).toMatchSnapshot(); + }); + + it('renders sidebar correctly with beacons', () => { + const [beacon] = makeRoomWithBeacons(roomId, client, [beaconEvent], [location1]); + const { container } = render(getComponent({ beacons: [beacon] })); + expect(container).toMatchSnapshot(); + }); + + it('calls on beacon click', () => { + const onBeaconClick = jest.fn(); + const [beacon] = makeRoomWithBeacons(roomId, client, [beaconEvent], [location1]); + const { container } = render(getComponent({ beacons: [beacon], onBeaconClick })); + + act(() => { + const [listItem] = container.getElementsByClassName('mx_BeaconListItem'); + fireEvent.click(listItem); + }); + + expect(onBeaconClick).toHaveBeenCalled(); }); it('closes on close button click', () => { const requestClose = jest.fn(); - const component = getComponent({ requestClose }); + const { getByTestId } = render(getComponent({ requestClose })); act(() => { - findByTestId(component, 'dialog-sidebar-close').at(0).simulate('click'); + fireEvent.click(getByTestId('dialog-sidebar-close')); }); expect(requestClose).toHaveBeenCalled(); }); diff --git a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap index 221d534c029..9ddc5dd44c1 100644 --- a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` when a beacon is live and has locations renders beacon info 1`] = `"
  • Alice's carLive until 16:04
    Updated a few seconds ago
  • "`; +exports[` when a beacon is live and has locations renders beacon info 1`] = `"
  • Alice's carLive until 16:04
    Updated a few seconds ago
  • "`; diff --git a/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap b/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap index e3b6f104907..6da5cc27c22 100644 --- a/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap @@ -1,53 +1,144 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` renders sidebar correctly 1`] = ` - +exports[` renders sidebar correctly with beacons 1`] = ` +
    - -

    - View List -

    -
    - +
    +
    +
    +
      +
    1. + -
    2. +
    +
    + ); +
    +`; + +exports[` renders sidebar correctly without beacons 1`] = ` +
    +
    +
    +

    + View List +

    +
    +
    +
    - + ); +
    `; diff --git a/test/test-utils/beacon.ts b/test/test-utils/beacon.ts index 9501cd2cb3c..a58be78151e 100644 --- a/test/test-utils/beacon.ts +++ b/test/test-utils/beacon.ts @@ -205,7 +205,11 @@ export const makeRoomWithBeacons = ( const room = makeRoomWithStateEvents(beaconInfoEvents, { roomId, mockClient }); const beacons = beaconInfoEvents.map(event => room.currentState.beacons.get(getBeaconInfoIdentifier(event))); if (locationEvents) { - beacons.forEach(beacon => beacon.addLocations(locationEvents)); + beacons.forEach(beacon => { + // this filtering happens in roomState, which is bypassed here + const validLocationEvents = locationEvents?.filter(event => event.getSender() === beacon.beaconInfoOwner); + beacon.addLocations(validLocationEvents); + }); } return beacons; }; diff --git a/test/test-utils/utilities.ts b/test/test-utils/utilities.ts index 666f7c68ea6..5011a555934 100644 --- a/test/test-utils/utilities.ts +++ b/test/test-utils/utilities.ts @@ -35,7 +35,8 @@ export function untilDispatch(waitForAction: DispatcherAction): Promise (component: ReactWrapper, value: string) => component.find(`[${attr}="${value}"]`); +export const findByAttr = (attr: string) => (component: ReactWrapper, value: string) => + component.find(`[${attr}="${value}"]`); export const findByTestId = findByAttr('data-test-id'); export const findById = findByAttr('id'); export const findByAriaLabel = findByAttr('aria-label'); From 77d8a242afc98a7c55967f78a4943924b8d80613 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 18 Jul 2022 14:00:56 +0200 Subject: [PATCH 02/13] unit test eventTriggersUnreadCount (#9070) * unit test eventTriggersUnreadCount * Update test/Unread-test.ts Co-authored-by: Michael Weimann Co-authored-by: Michael Weimann --- test/Unread-test.ts | 113 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 test/Unread-test.ts diff --git a/test/Unread-test.ts b/test/Unread-test.ts new file mode 100644 index 00000000000..836a3432351 --- /dev/null +++ b/test/Unread-test.ts @@ -0,0 +1,113 @@ +/* +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 { mocked } from "jest-mock"; +import { + MatrixEvent, + EventType, + MsgType, +} from "matrix-js-sdk/src/matrix"; + +import { haveRendererForEvent } from "../src/events/EventTileFactory"; +import { getMockClientWithEventEmitter, mockClientMethodsUser } from "./test-utils"; +import { eventTriggersUnreadCount } from "../src/Unread"; + +jest.mock("../src/events/EventTileFactory", () => ({ + haveRendererForEvent: jest.fn(), +})); + +describe('eventTriggersUnreadCount()', () => { + const aliceId = '@alice:server.org'; + const bobId = '@bob:server.org'; + + // mock user credentials + getMockClientWithEventEmitter({ + ...mockClientMethodsUser(bobId), + }); + + // setup events + const alicesMessage = new MatrixEvent({ + type: EventType.RoomMessage, + sender: aliceId, + content: { + msgtype: MsgType.Text, + body: 'Hello from Alice', + }, + }); + + const bobsMessage = new MatrixEvent({ + type: EventType.RoomMessage, + sender: bobId, + content: { + msgtype: MsgType.Text, + body: 'Hello from Bob', + }, + }); + + const redactedEvent = new MatrixEvent({ + type: EventType.RoomMessage, + sender: aliceId, + }); + redactedEvent.makeRedacted(redactedEvent); + + beforeEach(() => { + jest.clearAllMocks(); + mocked(haveRendererForEvent).mockClear().mockReturnValue(false); + }); + + it('returns false when the event was sent by the current user', () => { + expect(eventTriggersUnreadCount(bobsMessage)).toBe(false); + // returned early before checking renderer + expect(haveRendererForEvent).not.toHaveBeenCalled(); + }); + + it('returns false for a redacted event', () => { + expect(eventTriggersUnreadCount(redactedEvent)).toBe(false); + // returned early before checking renderer + expect(haveRendererForEvent).not.toHaveBeenCalled(); + }); + + it('returns false for an event without a renderer', () => { + mocked(haveRendererForEvent).mockReturnValue(false); + expect(eventTriggersUnreadCount(alicesMessage)).toBe(false); + expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, false); + }); + + it('returns true for an event with a renderer', () => { + mocked(haveRendererForEvent).mockReturnValue(true); + expect(eventTriggersUnreadCount(alicesMessage)).toBe(true); + expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, false); + }); + + const noUnreadEventTypes = [ + EventType.RoomMember, + EventType.RoomThirdPartyInvite, + EventType.CallAnswer, + EventType.CallHangup, + EventType.RoomAliases, + EventType.RoomCanonicalAlias, + EventType.RoomServerAcl, + ]; + + it.each(noUnreadEventTypes)('returns false without checking for renderer for events with type %s', (eventType) => { + const event = new MatrixEvent({ + type: eventType, + sender: aliceId, + }); + expect(eventTriggersUnreadCount(event)).toBe(false); + expect(haveRendererForEvent).not.toHaveBeenCalled(); + }); +}); From 42ff9d6dc830f313ff16e78ef92e3e2c5a6b4445 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 18 Jul 2022 13:16:44 +0100 Subject: [PATCH 03/13] Move Lazy Loading tests from Puppeteer to Cypress (#8982) * Remove Puppeteer Lazy Loading tests * Remove Puppeteer Lazy Loading tests * Remove Puppeteer Lazy Loading tests * Stash lazy loading cypress tests * Stash lazy loading cypress tests * Update cypress-real-events * Stash offline-less test * Add offline/online'ing --- cypress/e2e/lazy-loading/lazy-loading.spec.ts | 174 ++++++++++++++++++ cypress/support/bot.ts | 29 ++- cypress/support/client.ts | 9 + cypress/support/e2e.ts | 2 + cypress/support/network.ts | 62 +++++++ cypress/support/timeline.ts | 68 +++++++ package.json | 2 +- test/end-to-end-tests/src/rest/consent.ts | 31 ---- test/end-to-end-tests/src/rest/creator.ts | 90 --------- test/end-to-end-tests/src/rest/multi.ts | 93 ---------- test/end-to-end-tests/src/rest/room.ts | 43 ----- test/end-to-end-tests/src/rest/session.ts | 138 -------------- test/end-to-end-tests/src/scenario.ts | 18 +- .../src/scenarios/lazy-loading.ts | 127 ------------- test/end-to-end-tests/src/session.ts | 25 --- .../src/usecases/memberlist.ts | 41 ----- .../end-to-end-tests/src/usecases/timeline.ts | 65 ------- test/end-to-end-tests/src/util.ts | 8 - test/end-to-end-tests/start.ts | 10 +- yarn.lock | 2 +- 20 files changed, 348 insertions(+), 689 deletions(-) create mode 100644 cypress/e2e/lazy-loading/lazy-loading.spec.ts create mode 100644 cypress/support/network.ts create mode 100644 cypress/support/timeline.ts delete mode 100644 test/end-to-end-tests/src/rest/consent.ts delete mode 100644 test/end-to-end-tests/src/rest/creator.ts delete mode 100644 test/end-to-end-tests/src/rest/multi.ts delete mode 100644 test/end-to-end-tests/src/rest/room.ts delete mode 100644 test/end-to-end-tests/src/rest/session.ts delete mode 100644 test/end-to-end-tests/src/scenarios/lazy-loading.ts diff --git a/cypress/e2e/lazy-loading/lazy-loading.spec.ts b/cypress/e2e/lazy-loading/lazy-loading.spec.ts new file mode 100644 index 00000000000..af4d7ef6aec --- /dev/null +++ b/cypress/e2e/lazy-loading/lazy-loading.spec.ts @@ -0,0 +1,174 @@ +/* +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 { SynapseInstance } from "../../plugins/synapsedocker"; +import { MatrixClient } from "../../global"; +import Chainable = Cypress.Chainable; + +interface Charly { + client: MatrixClient; + displayName: string; +} + +describe("Lazy Loading", () => { + let synapse: SynapseInstance; + let bob: MatrixClient; + const charlies: Charly[] = []; + + beforeEach(() => { + cy.window().then(win => { + win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests + }); + + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Alice"); + + cy.getBot(synapse, { + displayName: "Bob", + startClient: false, + autoAcceptInvites: false, + }).then(_bob => { + bob = _bob; + }); + + for (let i = 1; i <= 10; i++) { + const displayName = `Charly #${i}`; + cy.getBot(synapse, { + displayName, + startClient: false, + autoAcceptInvites: false, + }).then(client => { + charlies[i - 1] = { displayName, client }; + }); + } + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + const name = "Lazy Loading Test"; + const alias = "#lltest:localhost"; + const charlyMsg1 = "hi bob!"; + const charlyMsg2 = "how's it going??"; + + function setupRoomWithBobAliceAndCharlies(charlies: Charly[]) { + cy.window({ log: false }).then(win => { + return cy.wrap(bob.createRoom({ + name, + room_alias_name: "lltest", + visibility: win.matrixcs.Visibility.Public, + }).then(r => r.room_id), { log: false }).as("roomId"); + }); + + cy.get("@roomId").then(async roomId => { + for (const charly of charlies) { + await charly.client.joinRoom(alias); + } + + for (const charly of charlies) { + cy.botSendMessage(charly.client, roomId, charlyMsg1); + } + for (const charly of charlies) { + cy.botSendMessage(charly.client, roomId, charlyMsg2); + } + + for (let i = 20; i >= 1; --i) { + cy.botSendMessage(bob, roomId, `I will only say this ${i} time(s)!`); + } + }); + + cy.joinRoom(alias); + cy.viewRoomByName(name); + } + + function checkPaginatedDisplayNames(charlies: Charly[]) { + cy.scrollToTop(); + for (const charly of charlies) { + cy.findEventTile(charly.displayName, charlyMsg1).should("exist"); + cy.findEventTile(charly.displayName, charlyMsg2).should("exist"); + } + } + + function openMemberlist(): void { + cy.get('.mx_HeaderButtons [aria-label="Room Info"]').click(); + cy.get(".mx_RoomSummaryCard").within(() => { + cy.get(".mx_RoomSummaryCard_icon_people").click(); + }); + } + + function getMembersInMemberlist(): Chainable { + return cy.get(".mx_MemberList .mx_EntityTile_name"); + } + + function checkMemberList(charlies: Charly[]) { + getMembersInMemberlist().contains("Alice").should("exist"); + getMembersInMemberlist().contains("Bob").should("exist"); + charlies.forEach(charly => { + getMembersInMemberlist().contains(charly.displayName).should("exist"); + }); + } + + function checkMemberListLacksCharlies(charlies: Charly[]) { + charlies.forEach(charly => { + getMembersInMemberlist().contains(charly.displayName).should("not.exist"); + }); + } + + function joinCharliesWhileAliceIsOffline(charlies: Charly[]) { + cy.goOffline(); + + cy.get("@roomId").then(async roomId => { + for (const charly of charlies) { + await charly.client.joinRoom(alias); + } + for (let i = 20; i >= 1; --i) { + cy.botSendMessage(charlies[0].client, roomId, "where is charly?"); + } + }); + + cy.goOnline(); + cy.wait(1000); // Ideally we'd await a /sync here but intercepts step on each other from going offline/online + } + + it("should handle lazy loading properly even when offline", () => { + const charly1to5 = charlies.slice(0, 5); + const charly6to10 = charlies.slice(5); + + // Set up room with alice, bob & charlies 1-5 + setupRoomWithBobAliceAndCharlies(charly1to5); + // Alice should see 2 messages from every charly with the correct display name + checkPaginatedDisplayNames(charly1to5); + + openMemberlist(); + checkMemberList(charly1to5); + joinCharliesWhileAliceIsOffline(charly6to10); + checkMemberList(charly6to10); + + cy.get("@roomId").then(async roomId => { + for (const charly of charlies) { + await charly.client.leave(roomId); + } + }); + + checkMemberListLacksCharlies(charlies); + }); +}); diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index 91efca9ea0d..f724d6b3d36 100644 --- a/cypress/support/bot.ts +++ b/cypress/support/bot.ts @@ -18,7 +18,7 @@ limitations under the License. import request from "browser-request"; -import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { SynapseInstance } from "../plugins/synapsedocker"; import Chainable = Cypress.Chainable; @@ -31,10 +31,15 @@ interface CreateBotOpts { * The display name to give to that bot user */ displayName?: string; + /** + * Whether or not to start the syncing client. + */ + startClient?: boolean; } const defaultCreateBotOptions = { autoAcceptInvites: true, + startClient: true, } as CreateBotOpts; declare global { @@ -59,6 +64,13 @@ declare global { * @param roomName Name of the room to join */ botJoinRoomByName(cli: MatrixClient, roomName: string): Chainable; + /** + * Send a message as a bot into a room + * @param cli The bot's MatrixClient + * @param roomId ID of the room to join + * @param message the message body to send + */ + botSendMessage(cli: MatrixClient, roomId: string, message: string): Chainable; } } } @@ -88,6 +100,10 @@ Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): }); } + if (!opts.startClient) { + return cy.wrap(cli); + } + return cy.wrap( cli.initCrypto() .then(() => cli.setGlobalErrorOnUnknownDevices(false)) @@ -114,3 +130,14 @@ Cypress.Commands.add("botJoinRoomByName", (cli: MatrixClient, roomName: string): return cy.wrap(Promise.reject()); }); + +Cypress.Commands.add("botSendMessage", ( + cli: MatrixClient, + roomId: string, + message: string, +): Chainable => { + return cy.wrap(cli.sendMessage(roomId, { + msgtype: "m.text", + body: message, + }), { log: false }); +}); diff --git a/cypress/support/client.ts b/cypress/support/client.ts index 8f9b14e851e..c3f3aab0eb6 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -124,6 +124,11 @@ declare global { * Boostraps cross-signing. */ bootstrapCrossSigning(): Chainable; + /** + * Joins the given room by alias or ID + * @param roomIdOrAlias the id or alias of the room to join + */ + joinRoom(roomIdOrAlias: string): Chainable; } } } @@ -217,3 +222,7 @@ Cypress.Commands.add("bootstrapCrossSigning", () => { }); }); }); + +Cypress.Commands.add("joinRoom", (roomIdOrAlias: string): Chainable => { + return cy.getClient().then(cli => cli.joinRoom(roomIdOrAlias)); +}); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 4a0852c64a6..18445d8d04b 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -33,3 +33,5 @@ import "./percy"; import "./webserver"; import "./views"; import "./iframes"; +import "./timeline"; +import "./network"; diff --git a/cypress/support/network.ts b/cypress/support/network.ts new file mode 100644 index 00000000000..73df049c6c4 --- /dev/null +++ b/cypress/support/network.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. +*/ + +/// + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + // Intercept all /_matrix/ networking requests for the logged in user and fail them + goOffline(): void; + // Remove intercept on all /_matrix/ networking requests + goOnline(): void; + } + } +} + +// We manage intercepting Matrix APIs here, as fully disabling networking will disconnect +// the browser under test from the Cypress runner, so can cause issues. + +Cypress.Commands.add("goOffline", (): void => { + cy.log("Going offline"); + cy.window({ log: false }).then(win => { + cy.intercept("**/_matrix/**", { + headers: { + "Authorization": "Bearer " + win.mxMatrixClientPeg.matrixClient.getAccessToken(), + }, + }, req => { + req.destroy(); + }); + }); +}); + +Cypress.Commands.add("goOnline", (): void => { + cy.log("Going online"); + cy.window({ log: false }).then(win => { + cy.intercept("**/_matrix/**", { + headers: { + "Authorization": "Bearer " + win.mxMatrixClientPeg.matrixClient.getAccessToken(), + }, + }, req => { + req.continue(); + }); + win.dispatchEvent(new Event("online")); + }); +}); + +// Needed to make this file a module +export { }; diff --git a/cypress/support/timeline.ts b/cypress/support/timeline.ts new file mode 100644 index 00000000000..28a9705fdb3 --- /dev/null +++ b/cypress/support/timeline.ts @@ -0,0 +1,68 @@ +/* +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 Chainable = Cypress.Chainable; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + // Scroll to the top of the timeline + scrollToTop(): void; + // Find the event tile matching the given sender & body + findEventTile(sender: string, body: string): Chainable; + } + } +} + +export interface Message { + sender: string; + body: string; + encrypted: boolean; + continuation: boolean; +} + +Cypress.Commands.add("scrollToTop", (): void => { + cy.get(".mx_RoomView_timeline .mx_ScrollPanel").scrollTo("top", { duration: 100 }).then(ref => { + if (ref.scrollTop() > 0) { + return cy.scrollToTop(); + } + }); +}); + +Cypress.Commands.add("findEventTile", (sender: string, body: string): Chainable => { + // We can't just use a bunch of `.contains` here due to continuations meaning that the events don't + // have their own rendered sender displayname so we have to walk the list to keep track of the sender. + return cy.get(".mx_RoomView_MessageList .mx_EventTile").then(refs => { + let latestSender: string; + for (let i = 0; i < refs.length; i++) { + const ref = refs.eq(i); + const displayName = ref.find(".mx_DisambiguatedProfile_displayName"); + if (displayName) { + latestSender = displayName.text(); + } + + if (latestSender === sender && ref.find(".mx_EventTile_body").text() === body) { + return ref; + } + } + }); +}); + +// Needed to make this file a module +export { }; diff --git a/package.json b/package.json index e40d2d672c4..be5976ffd5f 100644 --- a/package.json +++ b/package.json @@ -172,7 +172,7 @@ "blob-polyfill": "^6.0.20211015", "chokidar": "^3.5.1", "cypress": "^10.3.0", - "cypress-real-events": "^1.7.0", + "cypress-real-events": "^1.7.1", "enzyme": "^3.11.0", "enzyme-to-json": "^3.6.2", "eslint": "8.9.0", diff --git a/test/end-to-end-tests/src/rest/consent.ts b/test/end-to-end-tests/src/rest/consent.ts deleted file mode 100644 index 8b2e19821cd..00000000000 --- a/test/end-to-end-tests/src/rest/consent.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 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 request = require('request-promise-native'); -import * as cheerio from 'cheerio'; -import * as url from "url"; - -export const approveConsent = async function(consentUrl: string): Promise { - const body = await request.get(consentUrl); - const doc = cheerio.load(body); - const v = doc("input[name=v]").val(); - const u = doc("input[name=u]").val(); - const h = doc("input[name=h]").val(); - const formAction = doc("form").attr("action"); - const absAction = url.resolve(consentUrl, formAction); - await request.post(absAction).form({ v, u, h }); -}; diff --git a/test/end-to-end-tests/src/rest/creator.ts b/test/end-to-end-tests/src/rest/creator.ts deleted file mode 100644 index 33eea675d9a..00000000000 --- a/test/end-to-end-tests/src/rest/creator.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 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 request = require('request-promise-native'); -import * as crypto from 'crypto'; - -import { RestSession } from './session'; -import { RestMultiSession } from './multi'; - -export interface Credentials { - accessToken: string; - homeServer: string; - userId: string; - deviceId: string; - hsUrl: string; -} - -export class RestSessionCreator { - constructor(private readonly hsUrl: string, private readonly regSecret: string) {} - - public async createSessionRange(usernames: string[], password: string, - groupName: string): Promise { - const sessionPromises = usernames.map((username) => this.createSession(username, password)); - const sessions = await Promise.all(sessionPromises); - return new RestMultiSession(sessions, groupName); - } - - public async createSession(username: string, password: string): Promise { - await this.register(username, password); - console.log(` * created REST user ${username} ... done`); - const authResult = await this.authenticate(username, password); - return new RestSession(authResult); - } - - private async register(username: string, password: string): Promise { - // get a nonce - const regUrl = `${this.hsUrl}/_synapse/admin/v1/register`; - const nonceResp = await request.get({ uri: regUrl, json: true }); - - const mac = crypto.createHmac('sha1', this.regSecret).update( - `${nonceResp.nonce}\0${username}\0${password}\0notadmin`, - ).digest('hex'); - - await request.post({ - uri: regUrl, - json: true, - body: { - nonce: nonceResp.nonce, - username, - password, - mac, - admin: false, - }, - }); - } - - private async authenticate(username: string, password: string): Promise { - const requestBody = { - "type": "m.login.password", - "identifier": { - "type": "m.id.user", - "user": username, - }, - "password": password, - }; - const url = `${this.hsUrl}/_matrix/client/r0/login`; - const responseBody = await request.post({ url, json: true, body: requestBody }); - return { - accessToken: responseBody.access_token, - homeServer: responseBody.home_server, - userId: responseBody.user_id, - deviceId: responseBody.device_id, - hsUrl: this.hsUrl, - }; - } -} diff --git a/test/end-to-end-tests/src/rest/multi.ts b/test/end-to-end-tests/src/rest/multi.ts deleted file mode 100644 index 00f127567fa..00000000000 --- a/test/end-to-end-tests/src/rest/multi.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 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 { Logger } from '../logger'; -import { RestSession } from "./session"; -import { RestRoom } from "./room"; - -export class RestMultiSession { - readonly log: Logger; - - constructor(public readonly sessions: RestSession[], groupName: string) { - this.log = new Logger(groupName); - } - - public slice(groupName: string, start: number, end?: number): RestMultiSession { - return new RestMultiSession(this.sessions.slice(start, end), groupName); - } - - public pop(userName: string): RestSession { - const idx = this.sessions.findIndex((s) => s.userName() === userName); - if (idx === -1) { - throw new Error(`user ${userName} not found`); - } - const session = this.sessions.splice(idx, 1)[0]; - return session; - } - - public async setDisplayName(fn: (s: RestSession) => string): Promise { - this.log.step("set their display name"); - await Promise.all(this.sessions.map(async (s: RestSession) => { - s.log.mute(); - await s.setDisplayName(fn(s)); - s.log.unmute(); - })); - this.log.done(); - } - - public async join(roomIdOrAlias: string): Promise { - this.log.step(`join ${roomIdOrAlias}`); - const rooms = await Promise.all(this.sessions.map(async (s) => { - s.log.mute(); - const room = await s.join(roomIdOrAlias); - s.log.unmute(); - return room; - })); - this.log.done(); - return new RestMultiRoom(rooms, roomIdOrAlias, this.log); - } - - public room(roomIdOrAlias: string): RestMultiRoom { - const rooms = this.sessions.map(s => s.room(roomIdOrAlias)); - return new RestMultiRoom(rooms, roomIdOrAlias, this.log); - } -} - -class RestMultiRoom { - constructor(public readonly rooms: RestRoom[], private readonly roomIdOrAlias: string, - private readonly log: Logger) {} - - public async talk(message: string): Promise { - this.log.step(`say "${message}" in ${this.roomIdOrAlias}`); - await Promise.all(this.rooms.map(async (r: RestRoom) => { - r.log.mute(); - await r.talk(message); - r.log.unmute(); - })); - this.log.done(); - } - - public async leave() { - this.log.step(`leave ${this.roomIdOrAlias}`); - await Promise.all(this.rooms.map(async (r) => { - r.log.mute(); - await r.leave(); - r.log.unmute(); - })); - this.log.done(); - } -} diff --git a/test/end-to-end-tests/src/rest/room.ts b/test/end-to-end-tests/src/rest/room.ts deleted file mode 100644 index 2261f959936..00000000000 --- a/test/end-to-end-tests/src/rest/room.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 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 uuidv4 = require('uuid/v4'); - -import { RestSession } from "./session"; -import { Logger } from "../logger"; - -/* no pun intended */ -export class RestRoom { - constructor(readonly session: RestSession, readonly roomId: string, readonly log: Logger) {} - - async talk(message: string): Promise { - this.log.step(`says "${message}" in ${this.roomId}`); - const txId = uuidv4(); - 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 eventId; - } - - async leave(): Promise { - this.log.step(`leaves ${this.roomId}`); - await this.session.post(`/rooms/${this.roomId}/leave`); - this.log.done(); - } -} diff --git a/test/end-to-end-tests/src/rest/session.ts b/test/end-to-end-tests/src/rest/session.ts deleted file mode 100644 index a6536ac3a6e..00000000000 --- a/test/end-to-end-tests/src/rest/session.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -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 request = require('request-promise-native'); - -import { Logger } from '../logger'; -import { RestRoom } from './room'; -import { approveConsent } from './consent'; -import { Credentials } from "./creator"; - -interface RoomOptions { - invite?: string; - public?: boolean; - topic?: string; - dm?: boolean; -} - -export class RestSession { - private _displayName: string = null; - private readonly rooms: Record = {}; - readonly log: Logger; - - constructor(private readonly credentials: Credentials) { - this.log = new Logger(credentials.userId); - } - - userId(): string { - return this.credentials.userId; - } - - userName(): string { - return this.credentials.userId.split(":")[0].slice(1); - } - - displayName(): string { - return this._displayName; - } - - async setDisplayName(displayName: string): Promise { - this.log.step(`sets their display name to ${displayName}`); - this._displayName = displayName; - await this.put(`/profile/${this.credentials.userId}/displayname`, { - displayname: displayName, - }); - this.log.done(); - } - - async join(roomIdOrAlias: string): Promise { - this.log.step(`joins ${roomIdOrAlias}`); - const roomId = (await this.post(`/join/${encodeURIComponent(roomIdOrAlias)}`)).room_id; - this.log.done(); - const room = new RestRoom(this, roomId, this.log); - this.rooms[roomId] = room; - this.rooms[roomIdOrAlias] = room; - return room; - } - - room(roomIdOrAlias: string): RestRoom { - if (this.rooms.hasOwnProperty(roomIdOrAlias)) { - return this.rooms[roomIdOrAlias]; - } else { - throw new Error(`${this.credentials.userId} is not in ${roomIdOrAlias}`); - } - } - - async createRoom(name: string, options: RoomOptions): Promise { - this.log.step(`creates room ${name}`); - const body = { - name, - }; - if (options.invite) { - body['invite'] = options.invite; - } - if (options.public) { - body['visibility'] = "public"; - } else { - body['visibility'] = "private"; - } - if (options.dm) { - body['is_direct'] = true; - } - if (options.topic) { - body['topic'] = options.topic; - } - - const roomId = (await this.post(`/createRoom`, body)).room_id; - this.log.done(); - return new RestRoom(this, roomId, this.log); - } - - post(csApiPath: string, body?: any): Promise { - return this.request("POST", csApiPath, body); - } - - put(csApiPath: string, body?: any): Promise { - return this.request("PUT", csApiPath, body); - } - - async request(method: string, csApiPath: string, body?: any): Promise { - try { - return await request({ - url: `${this.credentials.hsUrl}/_matrix/client/r0${csApiPath}`, - method, - headers: { - "Authorization": `Bearer ${this.credentials.accessToken}`, - }, - json: true, - body, - }); - } catch (err) { - if (!err.response) { - throw err; - } - const responseBody = err.response.body; - if (responseBody.errcode === 'M_CONSENT_NOT_GIVEN') { - await approveConsent(responseBody.consent_uri); - return this.request(method, csApiPath, body); - } else if (responseBody && responseBody.error) { - throw new Error(`${method} ${csApiPath}: ${responseBody.error}`); - } else { - throw new Error(`${method} ${csApiPath}: ${err.response.statusCode}`); - } - } - } -} diff --git a/test/end-to-end-tests/src/scenario.ts b/test/end-to-end-tests/src/scenario.ts index 1c81205e27a..6c6495fef1b 100644 --- a/test/end-to-end-tests/src/scenario.ts +++ b/test/end-to-end-tests/src/scenario.ts @@ -15,18 +15,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { range } from './util'; import { signup } from './usecases/signup'; import { toastScenarios } from './scenarios/toast'; -import { lazyLoadingScenarios } from './scenarios/lazy-loading'; import { e2eEncryptionScenarios } from './scenarios/e2e-encryption'; import { ElementSession } from "./session"; -import { RestSessionCreator } from "./rest/creator"; -import { RestMultiSession } from "./rest/multi"; -import { RestSession } from "./rest/session"; -export async function scenario(createSession: (s: string) => Promise, - restCreator: RestSessionCreator): Promise { +export async function scenario(createSession: (s: string) => Promise): Promise { let firstUser = true; async function createUser(username: string) { const session = await createSession(username); @@ -45,14 +39,4 @@ export async function scenario(createSession: (s: string) => Promise { - const usernames = range(1, 10).map((i) => `charly-${i}`); - const charlies = await restCreator.createSessionRange(usernames, "testtest", "charly-1..10"); - await charlies.setDisplayName((s: RestSession) => `Charly #${s.userName().split('-')[1]}`); - return charlies; } diff --git a/test/end-to-end-tests/src/scenarios/lazy-loading.ts b/test/end-to-end-tests/src/scenarios/lazy-loading.ts deleted file mode 100644 index 3cbfdafdaba..00000000000 --- a/test/end-to-end-tests/src/scenarios/lazy-loading.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 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 { delay } from '../util'; -import { join } from '../usecases/join'; -import { sendMessage } from '../usecases/send-message'; -import { - checkTimelineContains, - scrollToTimelineTop, -} from '../usecases/timeline'; -import { createRoom } from '../usecases/create-room'; -import { getMembersInMemberlist } from '../usecases/memberlist'; -import { changeRoomSettings } from '../usecases/room-settings'; -import { RestMultiSession } from "../rest/multi"; -import { ElementSession } from "../session"; - -export async function lazyLoadingScenarios(alice: ElementSession, - bob: ElementSession, charlies: RestMultiSession): Promise { - console.log(" creating a room for lazy loading member scenarios:"); - const charly1to5 = charlies.slice("charly-1..5", 0, 5); - const charly6to10 = charlies.slice("charly-6..10", 5); - assert(charly1to5.sessions.length == 5); - assert(charly6to10.sessions.length == 5); - await setupRoomWithBobAliceAndCharlies(alice, bob, charly1to5); - await checkPaginatedDisplayNames(alice, charly1to5); - await checkMemberList(alice, charly1to5); - await joinCharliesWhileAliceIsOffline(alice, charly6to10); - await checkMemberList(alice, charly6to10); - await charlies.room(alias).leave(); - await delay(1000); - await checkMemberListLacksCharlies(alice, charlies); - await checkMemberListLacksCharlies(bob, charlies); -} - -const room = "Lazy Loading Test"; -const alias = "#lltest:localhost"; -const charlyMsg1 = "hi bob!"; -const charlyMsg2 = "how's it going??"; - -async function setupRoomWithBobAliceAndCharlies(alice: ElementSession, bob: ElementSession, - charlies: RestMultiSession): Promise { - await createRoom(bob, room); - await changeRoomSettings(bob, { directory: true, visibility: "public", alias }); - // wait for alias to be set by server after clicking "save" - // so the charlies can join it. - await bob.delay(500); - const charlyMembers = await charlies.join(alias); - await charlyMembers.talk(charlyMsg1); - await charlyMembers.talk(charlyMsg2); - bob.log.step("sends 20 messages").mute(); - for (let i = 20; i >= 1; --i) { - await sendMessage(bob, `I will only say this ${i} time(s)!`); - } - bob.log.unmute().done(); - await join(alice, alias); -} - -async function checkPaginatedDisplayNames(alice: ElementSession, charlies: RestMultiSession): Promise { - await scrollToTimelineTop(alice); - //alice should see 2 messages from every charly with - //the correct display name - const expectedMessages = [charlyMsg1, charlyMsg2].reduce((messages, msgText) => { - return charlies.sessions.reduce((messages, charly) => { - return messages.concat({ - sender: charly.displayName(), - body: msgText, - }); - }, messages); - }, []); - await checkTimelineContains(alice, expectedMessages, charlies.log.username); -} - -async function checkMemberList(alice: ElementSession, charlies: RestMultiSession): Promise { - alice.log.step(`checks the memberlist contains herself, bob and ${charlies.log.username}`); - const displayNames = (await getMembersInMemberlist(alice)).map((m) => m.displayName); - assert(displayNames.includes("alice")); - assert(displayNames.includes("bob")); - charlies.sessions.forEach((charly) => { - assert(displayNames.includes(charly.displayName()), - `${charly.displayName()} should be in the member list, ` + - `only have ${displayNames}`); - }); - alice.log.done(); -} - -async function checkMemberListLacksCharlies(session: ElementSession, charlies: RestMultiSession): Promise { - session.log.step(`checks the memberlist doesn't contain ${charlies.log.username}`); - const displayNames = (await getMembersInMemberlist(session)).map((m) => m.displayName); - charlies.sessions.forEach((charly) => { - assert(!displayNames.includes(charly.displayName()), - `${charly.displayName()} should not be in the member list, ` + - `only have ${displayNames}`); - }); - session.log.done(); -} - -async function joinCharliesWhileAliceIsOffline(alice: ElementSession, charly6to10: RestMultiSession) { - await alice.setOffline(true); - await delay(1000); - const members6to10 = await charly6to10.join(alias); - const member6 = members6to10.rooms[0]; - member6.log.step("sends 20 messages").mute(); - for (let i = 20; i >= 1; --i) { - await member6.talk("where is charly?"); - } - member6.log.unmute().done(); - const catchupPromise = alice.waitForNextSuccessfulSync(); - await alice.setOffline(false); - await catchupPromise; - await delay(2000); -} diff --git a/test/end-to-end-tests/src/session.ts b/test/end-to-end-tests/src/session.ts index c3f19db532d..9f8d67bae94 100644 --- a/test/end-to-end-tests/src/session.ts +++ b/test/end-to-end-tests/src/session.ts @@ -118,24 +118,6 @@ export class ElementSession { return await this.page.$$(selector); } - /** wait for a /sync request started after this call that gets a 200 response */ - public async waitForNextSuccessfulSync(): Promise { - const syncUrls = []; - function onRequest(request) { - if (request.url().indexOf("/sync") !== -1) { - syncUrls.push(request.url()); - } - } - - this.page.on('request', onRequest); - - await this.page.waitForResponse((response) => { - return syncUrls.includes(response.request().url()) && response.status() === 200; - }); - - this.page.off('request', onRequest); - } - public async waitNoSpinner(): Promise { await this.page.waitForSelector(".mx_Spinner", { hidden: true }); } @@ -152,13 +134,6 @@ export class ElementSession { return delay(ms); } - public async setOffline(enabled: boolean): Promise { - const description = enabled ? "offline" : "back online"; - this.log.step(`goes ${description}`); - await this.page.setOfflineMode(enabled); - this.log.done(); - } - public async close(): Promise { return this.browser.close(); } diff --git a/test/end-to-end-tests/src/usecases/memberlist.ts b/test/end-to-end-tests/src/usecases/memberlist.ts index 2aa61313a34..9daea8b1cb5 100644 --- a/test/end-to-end-tests/src/usecases/memberlist.ts +++ b/test/end-to-end-tests/src/usecases/memberlist.ts @@ -15,7 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { strict as assert } from 'assert'; import { ElementHandle } from "puppeteer"; import { openRoomSummaryCard } from "./rightpanel"; @@ -29,46 +28,6 @@ export async function openMemberInfo(session: ElementSession, name: String): Pro await matchingLabel.click(); } -interface Device { - id: string; - key: string; -} - -export async function verifyDeviceForUser(session: ElementSession, name: string, - expectedDevice: Device): Promise { - session.log.step(`verifies e2e device for ${name}`); - const membersAndNames = await getMembersInMemberlist(session); - const matchingLabel = membersAndNames.filter((m) => { - return m.displayName === name; - }).map((m) => m.label)[0]; - await matchingLabel.click(); - // click verify in member info - const firstVerifyButton = await session.query(".mx_MemberDeviceInfo_verify"); - await firstVerifyButton.click(); - // expect "Verify device" dialog and click "Begin Verification" - const dialogHeader = await session.innerText(await session.query(".mx_Dialog .mx_Dialog_title")); - assert(dialogHeader, "Verify device"); - const beginVerificationButton = await session.query(".mx_Dialog .mx_Dialog_primary"); - await beginVerificationButton.click(); - // get emoji SAS labels - const sasLabelElements = await session.queryAll( - ".mx_VerificationShowSas .mx_VerificationShowSas_emojiSas .mx_VerificationShowSas_emojiSas_label"); - const sasLabels = await Promise.all(sasLabelElements.map(e => session.innerText(e))); - console.log("my sas labels", sasLabels); - - const dialogCodeFields = await session.queryAll(".mx_QuestionDialog code"); - assert.strictEqual(dialogCodeFields.length, 2); - const deviceId = await session.innerText(dialogCodeFields[0]); - const deviceKey = await session.innerText(dialogCodeFields[1]); - assert.strictEqual(expectedDevice.id, deviceId); - assert.strictEqual(expectedDevice.key, deviceKey); - const confirmButton = await session.query(".mx_Dialog_primary"); - await confirmButton.click(); - const closeMemberInfo = await session.query(".mx_MemberInfo_cancel"); - await closeMemberInfo.click(); - session.log.done(); -} - interface MemberName { label: ElementHandle; displayName: string; diff --git a/test/end-to-end-tests/src/usecases/timeline.ts b/test/end-to-end-tests/src/usecases/timeline.ts index d12ccf9e19d..ff88d634527 100644 --- a/test/end-to-end-tests/src/usecases/timeline.ts +++ b/test/end-to-end-tests/src/usecases/timeline.ts @@ -20,32 +20,6 @@ import { ElementHandle } from "puppeteer"; import { ElementSession } from "../session"; -export async function scrollToTimelineTop(session: ElementSession): Promise { - session.log.step(`scrolls to the top of the timeline`); - await session.page.evaluate(() => { - return Promise.resolve().then(async () => { - let timedOut = false; - let timeoutHandle = null; - // set scrollTop to 0 in a loop and check every 50ms - // if content became available (scrollTop not being 0 anymore), - // assume everything is loaded after 3s - do { - const timelineScrollView = document.querySelector(".mx_RoomView_timeline .mx_ScrollPanel"); - if (timelineScrollView && timelineScrollView.scrollTop !== 0) { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } - timeoutHandle = setTimeout(() => timedOut = true, 3000); - timelineScrollView.scrollTop = 0; - } else { - await new Promise((resolve) => setTimeout(resolve, 50)); - } - } while (!timedOut); - }); - }); - session.log.done(); -} - interface Message { sender: string; encrypted?: boolean; @@ -79,41 +53,6 @@ export async function receiveMessage(session: ElementSession, expectedMessage: M session.log.done(); } -export async function checkTimelineContains(session: ElementSession, expectedMessages: Message[], - sendersDescription: string): Promise { - session.log.step(`checks timeline contains ${expectedMessages.length} ` + - `given messages${sendersDescription ? ` from ${sendersDescription}`:""}`); - const eventTiles = await getAllEventTiles(session); - let timelineMessages: Message[] = await Promise.all(eventTiles.map((eventTile) => { - return getMessageFromEventTile(eventTile); - })); - //filter out tiles that were not messages - timelineMessages = timelineMessages.filter((m) => !!m); - timelineMessages.reduce((prevSender: string, m) => { - if (m.continuation) { - m.sender = prevSender; - return prevSender; - } else { - return m.sender; - } - }, ""); - - expectedMessages.forEach((expectedMessage) => { - const foundMessage = timelineMessages.find((message) => { - return message.sender === expectedMessage.sender && - message.body === expectedMessage.body; - }); - try { - assertMessage(foundMessage, expectedMessage); - } catch (err) { - console.log("timelineMessages", timelineMessages); - throw err; - } - }); - - session.log.done(); -} - function assertMessage(foundMessage: Message, expectedMessage: Message): void { assert(foundMessage, `message ${JSON.stringify(expectedMessage)} not found in timeline`); assert.equal(foundMessage.body, expectedMessage.body); @@ -127,10 +66,6 @@ function getLastEventTile(session: ElementSession): Promise { return session.query(".mx_EventTile_last"); } -function getAllEventTiles(session: ElementSession): Promise { - return session.queryAll(".mx_RoomView_MessageList .mx_EventTile"); -} - async function getMessageFromEventTile(eventTile: ElementHandle): Promise { const senderElement = await eventTile.$(".mx_DisambiguatedProfile_displayName"); const className: string = await (await eventTile.getProperty("className")).jsonValue(); diff --git a/test/end-to-end-tests/src/util.ts b/test/end-to-end-tests/src/util.ts index 298e708007e..130519f396d 100644 --- a/test/end-to-end-tests/src/util.ts +++ b/test/end-to-end-tests/src/util.ts @@ -20,14 +20,6 @@ import { padEnd } from "lodash"; import { ElementSession } from "./session"; -export const range = function(start: number, amount: number, step = 1): Array { - const r = []; - for (let i = 0; i < amount; ++i) { - r.push(start + (i * step)); - } - return r; -}; - export const delay = function(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }; diff --git a/test/end-to-end-tests/start.ts b/test/end-to-end-tests/start.ts index 4660ee01b04..378d24d2a0f 100644 --- a/test/end-to-end-tests/start.ts +++ b/test/end-to-end-tests/start.ts @@ -19,7 +19,6 @@ import { Command } from "commander"; import { ElementSession } from './src/session'; import { scenario } from './src/scenario'; -import { RestSessionCreator } from './src/rest/creator'; const program = new Command(); @@ -54,12 +53,7 @@ async function runTests() { options['executablePath'] = path; } - const restCreator = new RestSessionCreator( - hsUrl, - program.opts().registrationSharedSecret, - ); - - async function createSession(username) { + async function createSession(username: string) { const session = await ElementSession.create( username, options, program.opts().appUrl, hsUrl, program.opts().throttleCpu, ); @@ -69,7 +63,7 @@ async function runTests() { let failure = false; try { - await scenario(createSession, restCreator); + await scenario(createSession); } catch (err) { failure = true; console.log('failure: ', err); diff --git a/yarn.lock b/yarn.lock index cf119eca8ad..67e9ab090d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3539,7 +3539,7 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2" integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA== -cypress-real-events@^1.7.0: +cypress-real-events@^1.7.1: version "1.7.1" resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.7.1.tgz#8f430d67c29ea4f05b9c5b0311780120cbc9b935" integrity sha512-/Bg15RgJ0SYsuXc6lPqH08x19z6j2vmhWN4wXfJqm3z8BTAFiK2MvipZPzxT8Z0jJP0q7kuniWrLIvz/i/8lCQ== From 43773e134a42de27d3c02841bb288daea5b966a6 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 18 Jul 2022 16:23:24 +0200 Subject: [PATCH 04/13] dont trigger unread counts for beacon location events (#9071) --- src/Unread.ts | 3 +++ test/Unread-test.ts | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Unread.ts b/src/Unread.ts index 7deafef9678..19dc07f4141 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -17,6 +17,7 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType } from "matrix-js-sdk/src/@types/event"; +import { M_BEACON } from "matrix-js-sdk/src/@types/beacon"; import { MatrixClientPeg } from "./MatrixClientPeg"; import shouldHideEvent from './shouldHideEvent'; @@ -44,6 +45,8 @@ export function eventTriggersUnreadCount(ev: MatrixEvent): boolean { case EventType.RoomAliases: case EventType.RoomCanonicalAlias: case EventType.RoomServerAcl: + case M_BEACON.name: + case M_BEACON.altName: return false; } diff --git a/test/Unread-test.ts b/test/Unread-test.ts index 836a3432351..1c1c469cba1 100644 --- a/test/Unread-test.ts +++ b/test/Unread-test.ts @@ -22,7 +22,7 @@ import { } from "matrix-js-sdk/src/matrix"; import { haveRendererForEvent } from "../src/events/EventTileFactory"; -import { getMockClientWithEventEmitter, mockClientMethodsUser } from "./test-utils"; +import { getMockClientWithEventEmitter, makeBeaconEvent, mockClientMethodsUser } from "./test-utils"; import { eventTriggersUnreadCount } from "../src/Unread"; jest.mock("../src/events/EventTileFactory", () => ({ @@ -92,6 +92,12 @@ describe('eventTriggersUnreadCount()', () => { expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, false); }); + it('returns false for beacon locations', () => { + const beaconLocationEvent = makeBeaconEvent(aliceId); + expect(eventTriggersUnreadCount(beaconLocationEvent)).toBe(false); + expect(haveRendererForEvent).not.toHaveBeenCalled(); + }); + const noUnreadEventTypes = [ EventType.RoomMember, EventType.RoomThirdPartyInvite, From 0053f21286e87a7a8c233ef4af72e2b9d4e8c09e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 18 Jul 2022 23:58:04 -0600 Subject: [PATCH 05/13] Update lockfile per yarn's decision (#9074) --- yarn.lock | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/yarn.lock b/yarn.lock index 67e9ab090d2..259f1f7310a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2853,10 +2853,12 @@ balanced-match@^2.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9" integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA== -base-x@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/base-x/-/base-x-4.0.0.tgz#d0e3b7753450c73f8ad2389b5c018a4af7b2224a" - integrity sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw== +base-x@^3.0.2: + version "3.0.9" + resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.9.tgz#6349aaabb58526332de9f60995e548a53fe21320" + integrity sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ== + dependencies: + safe-buffer "^5.0.1" base64-js@^1.3.1: version "1.5.1" @@ -2974,12 +2976,12 @@ browserslist@^4.20.2, browserslist@^4.21.1: node-releases "^2.0.5" update-browserslist-db "^1.0.4" -bs58@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/bs58/-/bs58-5.0.0.tgz#865575b4d13c09ea2a84622df6c8cbeb54ffc279" - integrity sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ== +bs58@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a" + integrity sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw== dependencies: - base-x "^4.0.0" + base-x "^3.0.2" bser@2.1.1: version "2.1.1" @@ -7284,7 +7286,7 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" -p-retry@4: +p-retry@^4.5.0: version "4.6.2" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== From 2179be299a530c0c2439260fa3dfbc7e2513270f Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 19 Jul 2022 17:05:02 +0200 Subject: [PATCH 06/13] Update pr-details to 1.2 (#9076) --- .github/workflows/cypress.yaml | 2 +- .github/workflows/netlify.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cypress.yaml b/.github/workflows/cypress.yaml index 8b344f52744..b0d39fe46b8 100644 --- a/.github/workflows/cypress.yaml +++ b/.github/workflows/cypress.yaml @@ -22,7 +22,7 @@ jobs: - id: prdetails if: github.event.workflow_run.event == 'pull_request' - uses: matrix-org/pr-details-action@v1.1 + uses: matrix-org/pr-details-action@v1.2 with: owner: ${{ github.event.workflow_run.head_repository.owner.login }} branch: ${{ github.event.workflow_run.head_branch }} diff --git a/.github/workflows/netlify.yaml b/.github/workflows/netlify.yaml index 6fa0f9df552..f6ade6f3bfa 100644 --- a/.github/workflows/netlify.yaml +++ b/.github/workflows/netlify.yaml @@ -25,7 +25,7 @@ jobs: Exercise caution. Use test accounts. - id: prdetails - uses: matrix-org/pr-details-action@v1.1 + uses: matrix-org/pr-details-action@v1.2 with: owner: ${{ github.event.workflow_run.head_repository.owner.login }} branch: ${{ github.event.workflow_run.head_branch }} From 66f7c9f564a723ed9f9efb75821a2217d9ea761b Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 19 Jul 2022 18:02:38 +0200 Subject: [PATCH 07/13] Remove obsolete crypto e2e test scenario (#9077) It has been replaced by cypress tests --- test/end-to-end-tests/src/scenario.ts | 2 - .../src/scenarios/e2e-encryption.ts | 53 ------------------- 2 files changed, 55 deletions(-) delete mode 100644 test/end-to-end-tests/src/scenarios/e2e-encryption.ts diff --git a/test/end-to-end-tests/src/scenario.ts b/test/end-to-end-tests/src/scenario.ts index 6c6495fef1b..f7479d17cb1 100644 --- a/test/end-to-end-tests/src/scenario.ts +++ b/test/end-to-end-tests/src/scenario.ts @@ -17,7 +17,6 @@ limitations under the License. import { signup } from './usecases/signup'; import { toastScenarios } from './scenarios/toast'; -import { e2eEncryptionScenarios } from './scenarios/e2e-encryption'; import { ElementSession } from "./session"; export async function scenario(createSession: (s: string) => Promise): Promise { @@ -38,5 +37,4 @@ export async function scenario(createSession: (s: string) => Promise Date: Wed, 20 Jul 2022 09:26:25 +0200 Subject: [PATCH 08/13] Wire local room logic (#9078) * Wire local room logic * Migrate to testling-lib; update test descriptions --- src/Avatar.ts | 8 +- src/components/structures/MatrixChat.tsx | 8 +- .../dialogs/spotlight/SpotlightDialog.tsx | 6 +- .../views/messages/EncryptionEvent.tsx | 6 +- src/i18n/strings/en_EN.json | 1 + src/stores/TypingStore.ts | 4 + .../room-list/filters/VisibilityProvider.ts | 4 +- src/utils/localRoom/isLocalRoom.ts | 26 ++++ test/Avatar-test.ts | 102 ++++++++++++++ .../views/dialogs/SpotlightDialog-test.tsx | 66 ++++++++- .../views/messages/EncryptionEvent-test.tsx | 131 ++++++++++++++++++ test/stores/TypingStore-test.ts | 88 ++++++++++++ test/test-utils/test-utils.ts | 2 + test/utils/localRoom/isLocalRoom-test.ts | 52 +++++++ 14 files changed, 493 insertions(+), 11 deletions(-) create mode 100644 src/utils/localRoom/isLocalRoom.ts create mode 100644 test/Avatar-test.ts create mode 100644 test/components/views/messages/EncryptionEvent-test.tsx create mode 100644 test/stores/TypingStore-test.ts create mode 100644 test/utils/localRoom/isLocalRoom-test.ts diff --git a/src/Avatar.ts b/src/Avatar.ts index 86560713aed..0472e00b0d1 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -22,6 +22,7 @@ import { split } from "lodash"; import DMRoomMap from './utils/DMRoomMap'; import { mediaFromMxc } from "./customisations/Media"; +import { isLocalRoom } from "./utils/localRoom/isLocalRoom"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already export function avatarUrlForMember( @@ -142,7 +143,12 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi if (room.isSpaceRoom()) return null; // If the room is not a DM don't fallback to a member avatar - if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) return null; + if ( + !DMRoomMap.shared().getUserIdForRoomId(room.roomId) + && !(isLocalRoom(room)) + ) { + return null; + } // If there are only two members in the DM use the avatar of the other member const otherMember = room.getAvatarFallbackMember(); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index eb979c5798d..534d82036cc 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -132,6 +132,7 @@ import VideoChannelStore from "../../stores/VideoChannelStore"; import { IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators"; import { UseCaseSelection } from '../views/elements/UseCaseSelection'; import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig'; +import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; // legacy export export { default as Views } from "../../Views"; @@ -890,7 +891,12 @@ export default class MatrixChat extends React.PureComponent { } // If we are redirecting to a Room Alias and it is for the room we already showing then replace history item - const replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId; + let replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId; + + if (isLocalRoom(this.state.currentRoomId)) { + // Replace local room history items + replaceLast = true; + } if (roomInfo.room_id === this.state.currentRoomId) { // if we are re-viewing the same room then copy any state we already know diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 4bf7b572392..dbe55972c54 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -91,6 +91,7 @@ import { PublicRoomResultDetails } from "./PublicRoomResultDetails"; import { RoomResultContextMenus } from "./RoomResultContextMenus"; import { RoomContextDetails } from "../../rooms/RoomContextDetails"; import { TooltipOption } from "./TooltipOption"; +import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons @@ -243,6 +244,9 @@ export const useWebSearchMetrics = (numResults: number, queryLength: number, via const findVisibleRooms = (cli: MatrixClient) => { return cli.getVisibleRooms().filter(room => { + // Do not show local rooms + if (isLocalRoom(room)) return false; + // TODO we may want to put invites in their own list return room.getMyMembership() === "join" || room.getMyMembership() == "invite"; }); @@ -395,7 +399,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n possibleResults.forEach(entry => { if (isRoomResult(entry)) { - if (!entry.room.normalizedName.includes(normalizedQuery) && + if (!entry.room.normalizedName?.includes(normalizedQuery) && !entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) && !entry.query?.some(q => q.includes(lcQuery)) ) return; // bail, does not match query diff --git a/src/components/views/messages/EncryptionEvent.tsx b/src/components/views/messages/EncryptionEvent.tsx index 809cf75f760..dc4daaaf7f6 100644 --- a/src/components/views/messages/EncryptionEvent.tsx +++ b/src/components/views/messages/EncryptionEvent.tsx @@ -24,6 +24,7 @@ import EventTileBubble from "./EventTileBubble"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import DMRoomMap from "../../../utils/DMRoomMap"; import { objectHasDiff } from "../../../utils/objects"; +import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; interface IProps { mxEvent: MatrixEvent; @@ -46,12 +47,15 @@ const EncryptionEvent = forwardRef(({ mxEvent, timestamp if (content.algorithm === ALGORITHM && isRoomEncrypted) { let subtitle: string; const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId); + const room = cli?.getRoom(roomId); if (prevContent.algorithm === ALGORITHM) { subtitle = _t("Some encryption parameters have been changed."); } else if (dmPartner) { - const displayName = cli?.getRoom(roomId)?.getMember(dmPartner)?.rawDisplayName || dmPartner; + const displayName = room.getMember(dmPartner)?.rawDisplayName || dmPartner; subtitle = _t("Messages here are end-to-end encrypted. " + "Verify %(displayName)s in their profile - tap on their avatar.", { displayName }); + } else if (isLocalRoom(room)) { + subtitle = _t("Messages in this chat will be end-to-end encrypted."); } else { subtitle = _t("Messages in this room are end-to-end encrypted. " + "When people join, you can verify them in their profile, just tap on their avatar."); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 875516e0bb4..a2568949d9f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2101,6 +2101,7 @@ "View Source": "View Source", "Some encryption parameters have been changed.": "Some encryption parameters have been changed.", "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.", + "Messages in this chat will be end-to-end encrypted.": "Messages in this chat will be end-to-end encrypted.", "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.", "Encryption enabled": "Encryption enabled", "Ignored attempt to disable encryption": "Ignored attempt to disable encryption", diff --git a/src/stores/TypingStore.ts b/src/stores/TypingStore.ts index cae8529bbd0..d642f3fea7f 100644 --- a/src/stores/TypingStore.ts +++ b/src/stores/TypingStore.ts @@ -16,6 +16,7 @@ limitations under the License. import { MatrixClientPeg } from "../MatrixClientPeg"; import SettingsStore from "../settings/SettingsStore"; +import { isLocalRoom } from "../utils/localRoom/isLocalRoom"; import Timer from "../utils/Timer"; const TYPING_USER_TIMEOUT = 10000; @@ -64,6 +65,9 @@ export default class TypingStore { * @param {boolean} isTyping Whether the user is typing or not. */ public setSelfTyping(roomId: string, threadId: string | null, isTyping: boolean): void { + // No typing notifications for local rooms + if (isLocalRoom(roomId)) return; + if (!SettingsStore.getValue('sendTypingNotifications')) return; if (SettingsStore.getValue('lowBandwidth')) return; // Disable typing notification for threads for the initial launch diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts index 26bfcd78ea9..ca377331065 100644 --- a/src/stores/room-list/filters/VisibilityProvider.ts +++ b/src/stores/room-list/filters/VisibilityProvider.ts @@ -18,7 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import CallHandler from "../../../CallHandler"; import { RoomListCustomisations } from "../../../customisations/RoomList"; -import { LocalRoom } from "../../../models/LocalRoom"; +import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import VoipUserMapper from "../../../VoipUserMapper"; export class VisibilityProvider { @@ -55,7 +55,7 @@ export class VisibilityProvider { return false; } - if (room instanceof LocalRoom) { + if (isLocalRoom(room)) { // local rooms shouldn't show up anywhere return false; } diff --git a/src/utils/localRoom/isLocalRoom.ts b/src/utils/localRoom/isLocalRoom.ts new file mode 100644 index 00000000000..a31774ea5e4 --- /dev/null +++ b/src/utils/localRoom/isLocalRoom.ts @@ -0,0 +1,26 @@ +/* +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 { Room } from "matrix-js-sdk/src/matrix"; + +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../models/LocalRoom"; + +export function isLocalRoom(roomOrID: Room|string): boolean { + if (typeof roomOrID === "string") { + return roomOrID.startsWith(LOCAL_ROOM_ID_PREFIX); + } + return roomOrID instanceof LocalRoom; +} diff --git a/test/Avatar-test.ts b/test/Avatar-test.ts new file mode 100644 index 00000000000..214ada9486a --- /dev/null +++ b/test/Avatar-test.ts @@ -0,0 +1,102 @@ +/* +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 { mocked } from "jest-mock"; +import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; + +import { avatarUrlForRoom } from "../src/Avatar"; +import { Media, mediaFromMxc } from "../src/customisations/Media"; +import DMRoomMap from "../src/utils/DMRoomMap"; + +jest.mock("../src/customisations/Media", () => ({ + mediaFromMxc: jest.fn(), +})); + +const roomId = "!room:example.com"; +const avatarUrl1 = "https://example.com/avatar1"; +const avatarUrl2 = "https://example.com/avatar2"; + +describe("avatarUrlForRoom", () => { + let getThumbnailOfSourceHttp: jest.Mock; + let room: Room; + let roomMember: RoomMember; + let dmRoomMap: DMRoomMap; + + beforeEach(() => { + getThumbnailOfSourceHttp = jest.fn(); + mocked(mediaFromMxc).mockImplementation((): Media => { + return { + getThumbnailOfSourceHttp, + } as unknown as Media; + }); + room = { + roomId, + getMxcAvatarUrl: jest.fn(), + isSpaceRoom: jest.fn(), + getAvatarFallbackMember: jest.fn(), + } as unknown as Room; + dmRoomMap = { + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap; + DMRoomMap.setShared(dmRoomMap); + roomMember = { + getMxcAvatarUrl: jest.fn(), + } as unknown as RoomMember; + }); + + it("should return null for a null room", () => { + expect(avatarUrlForRoom(null, 128, 128)).toBeNull(); + }); + + it("should return the HTTP source if the room provides a MXC url", () => { + mocked(room.getMxcAvatarUrl).mockReturnValue(avatarUrl1); + getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2); + expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2); + expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop"); + }); + + it("should return null for a space room", () => { + mocked(room.isSpaceRoom).mockReturnValue(true); + expect(avatarUrlForRoom(room, 128, 128)).toBeNull(); + }); + + it("should return null if the room is not a DM", () => { + mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue(null); + expect(avatarUrlForRoom(room, 128, 128)).toBeNull(); + expect(dmRoomMap.getUserIdForRoomId).toHaveBeenCalledWith(roomId); + }); + + it("should return null if there is no other member in the room", () => { + mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com"); + mocked(room.getAvatarFallbackMember).mockReturnValue(null); + expect(avatarUrlForRoom(room, 128, 128)).toBeNull(); + }); + + it("should return null if the other member has no avatar URL", () => { + mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com"); + mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember); + expect(avatarUrlForRoom(room, 128, 128)).toBeNull(); + }); + + it("should return the other member's avatar URL", () => { + mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com"); + mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember); + mocked(roomMember.getMxcAvatarUrl).mockReturnValue(avatarUrl2); + getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2); + expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2); + expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop"); + }); +}); diff --git a/test/components/views/dialogs/SpotlightDialog-test.tsx b/test/components/views/dialogs/SpotlightDialog-test.tsx index 7c8bbc6bc73..c2445139a78 100644 --- a/test/components/views/dialogs/SpotlightDialog-test.tsx +++ b/test/components/views/dialogs/SpotlightDialog-test.tsx @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { mount } from "enzyme"; -import { IProtocol, IPublicRoomsChunkRoom, MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix"; +import { mount, ReactWrapper } from "enzyme"; +import { mocked } from "jest-mock"; +import { IProtocol, IPublicRoomsChunkRoom, MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { sleep } from "matrix-js-sdk/src/utils"; import React from "react"; import { act } from "react-dom/test-utils"; @@ -23,7 +24,15 @@ import sanitizeHtml from "sanitize-html"; import SpotlightDialog, { Filter } from "../../../../src/components/views/dialogs/spotlight/SpotlightDialog"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { stubClient } from "../../../test-utils"; +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../src/models/LocalRoom"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import { mkRoom, stubClient } from "../../../test-utils"; + +jest.mock("../../../../src/utils/direct-messages", () => ({ + // @ts-ignore + ...jest.requireActual("../../../../src/utils/direct-messages"), + startDmOnFirstMessage: jest.fn(), +})); interface IUserChunkMember { user_id: string; @@ -110,10 +119,23 @@ describe("Spotlight Dialog", () => { guest_can_join: false, }; + let testRoom: Room; + let testLocalRoom: LocalRoom; + + let mockedClient: MatrixClient; + beforeEach(() => { - mockClient({ rooms: [testPublicRoom], users: [testPerson] }); + mockedClient = mockClient({ rooms: [testPublicRoom], users: [testPerson] }); + testRoom = mkRoom(mockedClient, "!test23:example.com"); + mocked(testRoom.getMyMembership).mockReturnValue("join"); + testLocalRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test23", mockedClient, mockedClient.getUserId()); + testLocalRoom.updateMyMembership("join"); + mocked(mockedClient.getVisibleRooms).mockReturnValue([testRoom, testLocalRoom]); + + jest.spyOn(DMRoomMap, "shared").mockReturnValue({ + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap); }); - describe("should apply filters supplied via props", () => { it("without filter", async () => { const wrapper = mount( @@ -289,4 +311,38 @@ describe("Spotlight Dialog", () => { wrapper.unmount(); }); }); + + describe("searching for rooms", () => { + let wrapper: ReactWrapper; + let options: ReactWrapper; + + beforeAll(async () => { + wrapper = mount( + null} />, + ); + await act(async () => { + await sleep(200); + }); + wrapper.update(); + + const content = wrapper.find("#mx_SpotlightDialog_content"); + options = content.find("div.mx_SpotlightDialog_option"); + }); + + afterAll(() => { + wrapper.unmount(); + }); + + it("should find Rooms", () => { + expect(options.length).toBe(3); + expect(options.first().text()).toContain(testRoom.name); + }); + + it("should not find LocalRooms", () => { + expect(options.length).toBe(3); + expect(options.first().text()).not.toContain(testLocalRoom.name); + }); + }); }); diff --git a/test/components/views/messages/EncryptionEvent-test.tsx b/test/components/views/messages/EncryptionEvent-test.tsx new file mode 100644 index 00000000000..7b70fa4f6fc --- /dev/null +++ b/test/components/views/messages/EncryptionEvent-test.tsx @@ -0,0 +1,131 @@ +/* +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 { mocked } from "jest-mock"; +import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { render, screen } from '@testing-library/react'; + +import EncryptionEvent from "../../../../src/components/views/messages/EncryptionEvent"; +import { createTestClient, mkMessage } from "../../../test-utils"; +import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; +import { LocalRoom } from '../../../../src/models/LocalRoom'; +import DMRoomMap from '../../../../src/utils/DMRoomMap'; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; + +const renderEncryptionEvent = (client: MatrixClient, event: MatrixEvent) => { + render( + + ); +}; + +const checkTexts = (title: string, subTitle: string) => { + screen.getByText(title); + screen.getByText(subTitle); +}; + +describe("EncryptionEvent", () => { + const roomId = "!room:example.com"; + const algorithm = "m.megolm.v1.aes-sha2"; + let client: MatrixClient; + let event: MatrixEvent; + + beforeEach(() => { + jest.clearAllMocks(); + client = createTestClient(); + jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client); + event = mkMessage({ + event: true, + room: roomId, + user: client.getUserId(), + }); + jest.spyOn(DMRoomMap, "shared").mockReturnValue({ + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap); + }); + + describe("for an encrypted room", () => { + beforeEach(() => { + event.event.content.algorithm = algorithm; + mocked(client.isRoomEncrypted).mockReturnValue(true); + const room = new Room(roomId, client, client.getUserId()); + mocked(client.getRoom).mockReturnValue(room); + }); + + it("should show the expected texts", () => { + renderEncryptionEvent(client, event); + checkTexts( + "Encryption enabled", + "Messages in this room are end-to-end encrypted. " + + "When people join, you can verify them in their profile, just tap on their avatar.", + ); + }); + + describe("with same previous algorithm", () => { + beforeEach(() => { + jest.spyOn(event, "getPrevContent").mockReturnValue({ + algorithm: algorithm, + }); + }); + + it("should show the expected texts", () => { + renderEncryptionEvent(client, event); + checkTexts( + "Encryption enabled", + "Some encryption parameters have been changed.", + ); + }); + }); + + describe("with unknown algorithm", () => { + beforeEach(() => { + event.event.content.algorithm = "unknown"; + }); + + it("should show the expected texts", () => { + renderEncryptionEvent(client, event); + checkTexts("Encryption enabled", "Ignored attempt to disable encryption"); + }); + }); + }); + + describe("for an unencrypted room", () => { + beforeEach(() => { + mocked(client.isRoomEncrypted).mockReturnValue(false); + renderEncryptionEvent(client, event); + }); + + it("should show the expected texts", () => { + expect(client.isRoomEncrypted).toHaveBeenCalledWith(roomId); + checkTexts("Encryption not enabled", "The encryption used by this room isn't supported."); + }); + }); + + describe("for an encrypted local room", () => { + beforeEach(() => { + event.event.content.algorithm = algorithm; + mocked(client.isRoomEncrypted).mockReturnValue(true); + const localRoom = new LocalRoom(roomId, client, client.getUserId()); + mocked(client.getRoom).mockReturnValue(localRoom); + renderEncryptionEvent(client, event); + }); + + it("should show the expected texts", () => { + expect(client.isRoomEncrypted).toHaveBeenCalledWith(roomId); + checkTexts("Encryption enabled", "Messages in this chat will be end-to-end encrypted."); + }); + }); +}); diff --git a/test/stores/TypingStore-test.ts b/test/stores/TypingStore-test.ts new file mode 100644 index 00000000000..98ddfca3c40 --- /dev/null +++ b/test/stores/TypingStore-test.ts @@ -0,0 +1,88 @@ +/* +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 { mocked } from "jest-mock"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +import TypingStore from "../../src/stores/TypingStore"; +import { LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom"; +import SettingsStore from "../../src/settings/SettingsStore"; + +jest.mock("../../src/settings/SettingsStore", () => ({ + getValue: jest.fn(), +})); + +describe("TypingStore", () => { + let typingStore: TypingStore; + let mockClient: MatrixClient; + const settings = { + "sendTypingNotifications": true, + "feature_thread": false, + }; + const roomId = "!test:example.com"; + const localRoomId = LOCAL_ROOM_ID_PREFIX + "test"; + + beforeEach(() => { + typingStore = new TypingStore(); + mockClient = { + sendTyping: jest.fn(), + } as unknown as MatrixClient; + MatrixClientPeg.get = () => mockClient; + mocked(SettingsStore.getValue).mockImplementation((setting: string) => { + return settings[setting]; + }); + }); + + describe("setSelfTyping", () => { + it("shouldn't do anything for a local room", () => { + typingStore.setSelfTyping(localRoomId, null, true); + expect(mockClient.sendTyping).not.toHaveBeenCalled(); + }); + + describe("in typing state true", () => { + beforeEach(() => { + typingStore.setSelfTyping(roomId, null, true); + }); + + it("should change to false when setting false", () => { + typingStore.setSelfTyping(roomId, null, false); + expect(mockClient.sendTyping).toHaveBeenCalledWith(roomId, false, 30000); + }); + + it("should change to true when setting true", () => { + typingStore.setSelfTyping(roomId, null, true); + expect(mockClient.sendTyping).toHaveBeenCalledWith(roomId, true, 30000); + }); + }); + + describe("in typing state false", () => { + beforeEach(() => { + typingStore.setSelfTyping(roomId, null, false); + }); + + it("shouldn't change when setting false", () => { + typingStore.setSelfTyping(roomId, null, false); + expect(mockClient.sendTyping).not.toHaveBeenCalled(); + }); + + it("should change to true when setting true", () => { + typingStore.setSelfTyping(roomId, null, true); + expect(mockClient.sendTyping).toHaveBeenCalledWith(roomId, true, 30000); + }); + }); + }); +}); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 62ef22f92f5..aea6e591cb4 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -31,6 +31,7 @@ import { IEventRelation, IUnsigned, } from 'matrix-js-sdk/src/matrix'; +import { normalize } from "matrix-js-sdk/src/utils"; import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg'; import dis from '../../src/dispatcher/dispatcher'; @@ -389,6 +390,7 @@ export function mkStubRoom(roomId: string = null, name: string, client: MatrixCl removeListener: jest.fn(), getDMInviter: jest.fn(), name, + normalizedName: normalize(name || ""), getAvatarUrl: () => 'mxc://avatar.url/room.png', getMxcAvatarUrl: () => 'mxc://avatar.url/room.png', isSpaceRoom: jest.fn().mockReturnValue(false), diff --git a/test/utils/localRoom/isLocalRoom-test.ts b/test/utils/localRoom/isLocalRoom-test.ts new file mode 100644 index 00000000000..c94fd0608aa --- /dev/null +++ b/test/utils/localRoom/isLocalRoom-test.ts @@ -0,0 +1,52 @@ +/* +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 { Room } from "matrix-js-sdk/src/matrix"; + +import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../src/models/LocalRoom"; +import { isLocalRoom } from "../../../src/utils/localRoom/isLocalRoom"; +import { createTestClient } from "../../test-utils"; + +describe("isLocalRoom", () => { + let room: Room; + let localRoom: LocalRoom; + + beforeEach(() => { + const client = createTestClient(); + room = new Room("!room:example.com", client, client.getUserId()); + localRoom = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "test", client, client.getUserId()); + }); + + it("should return false for null", () => { + expect(isLocalRoom(null)).toBe(false); + }); + + it("should return false for a Room", () => { + expect(isLocalRoom(room)).toBe(false); + }); + + it("should return false for a non-local room ID", () => { + expect(isLocalRoom(room.roomId)).toBe(false); + }); + + it("should return true for LocalRoom", () => { + expect(isLocalRoom(localRoom)).toBe(true); + }); + + it("should return true for local room ID", () => { + expect(isLocalRoom(LOCAL_ROOM_ID_PREFIX + "test")).toBe(true); + }); +}); From be0f4a1fe52f4c11b9c040549a2bc01cd551bd2d Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 20 Jul 2022 09:56:52 +0200 Subject: [PATCH 09/13] add cypress test case for simple poll flow (#9073) * add cypress test case for simple poll flow * tidy comments * actually correct comments * add polls x thread test * tweak comments * pr improvements --- cypress/e2e/15-polls/polls.spec.ts | 249 +++++++++++++++++++++++++++++ cypress/support/composer.ts | 48 ++++++ cypress/support/e2e.ts | 1 + 3 files changed, 298 insertions(+) create mode 100644 cypress/e2e/15-polls/polls.spec.ts create mode 100644 cypress/support/composer.ts diff --git a/cypress/e2e/15-polls/polls.spec.ts b/cypress/e2e/15-polls/polls.spec.ts new file mode 100644 index 00000000000..ecfd4af90e6 --- /dev/null +++ b/cypress/e2e/15-polls/polls.spec.ts @@ -0,0 +1,249 @@ +/* +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 { PollResponseEvent } from "matrix-events-sdk"; + +import { SynapseInstance } from "../../plugins/synapsedocker"; +import { MatrixClient } from "../../global"; +import Chainable = Cypress.Chainable; + +const hideTimestampCSS = ".mx_MessageTimestamp { visibility: hidden !important; }"; + +describe("Polls", () => { + let synapse: SynapseInstance; + + type CreatePollOptions = { + title: string; + options: string[]; + }; + const createPoll = ({ title, options }: CreatePollOptions) => { + if (options.length < 2) { + throw new Error('Poll must have at least two options'); + } + cy.get('.mx_PollCreateDialog').within((pollCreateDialog) => { + cy.get('#poll-topic-input').type(title); + + options.forEach((option, index) => { + const optionId = `#pollcreate_option_${index}`; + + // click 'add option' button if needed + if (pollCreateDialog.find(optionId).length === 0) { + cy.get('.mx_PollCreateDialog_addOption').scrollIntoView().click(); + } + cy.get(optionId).scrollIntoView().type(option); + }); + }); + cy.get('.mx_Dialog button[type="submit"]').click(); + }; + + const getPollTile = (pollId: string): Chainable => { + return cy.get(`.mx_EventTile[data-scroll-tokens="${pollId}"]`); + }; + + const getPollOption = (pollId: string, optionText: string): Chainable => { + return getPollTile(pollId).contains('.mx_MPollBody_option .mx_StyledRadioButton', optionText); + }; + + const expectPollOptionVoteCount = (pollId: string, optionText: string, votes: number): void => { + getPollOption(pollId, optionText).within(() => { + cy.get('.mx_MPollBody_optionVoteCount').should('contain', `${votes} vote`); + }); + }; + + const botVoteForOption = (bot: MatrixClient, roomId: string, pollId: string, optionText: string): void => { + getPollOption(pollId, optionText).within(ref => { + cy.get('input[type="radio"]').invoke('attr', 'value').then(optionId => { + const pollVote = PollResponseEvent.from([optionId], pollId).serialize(); + bot.sendEvent( + roomId, + pollVote.type, + pollVote.content, + ); + }); + }); + }; + + beforeEach(() => { + cy.enableLabsFeature("feature_thread"); + cy.window().then(win => { + win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests + }); + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Tom"); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("Open polls can be created and voted in", () => { + let bot: MatrixClient; + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + bot = _bot; + }); + + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, bot.getUserId()); + cy.visit('/#/room/' + roomId); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + cy.get('.mx_CompoundDialog').percySnapshotElement('Polls Composer'); + + const pollParams = { + title: 'Does the polls feature work?', + options: ['Yes', 'No', 'Maybe'], + }; + createPoll(pollParams); + + // Wait for message to send, get its ID and save as @pollId + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + .invoke("attr", "data-scroll-tokens").as("pollId"); + + cy.get("@pollId").then(pollId => { + getPollTile(pollId).percySnapshotElement('Polls Timeline tile - no votes', { percyCSS: hideTimestampCSS }); + + // Bot votes 'Maybe' in the poll + botVoteForOption(bot, roomId, pollId, pollParams.options[2]); + + // no votes shown until I vote, check bots vote has arrived + cy.get('.mx_MPollBody_totalVotes').should('contain', '1 vote cast'); + + // vote 'Maybe' + getPollOption(pollId, pollParams.options[2]).click('topLeft'); + // both me and bot have voted Maybe + expectPollOptionVoteCount(pollId, pollParams.options[2], 2); + + // change my vote to 'Yes' + getPollOption(pollId, pollParams.options[0]).click('topLeft'); + + // 1 vote for yes + expectPollOptionVoteCount(pollId, pollParams.options[0], 1); + // 1 vote for maybe + expectPollOptionVoteCount(pollId, pollParams.options[2], 1); + + // Bot updates vote to 'No' + botVoteForOption(bot, roomId, pollId, pollParams.options[1]); + + // 1 vote for yes + expectPollOptionVoteCount(pollId, pollParams.options[0], 1); + // 1 vote for no + expectPollOptionVoteCount(pollId, pollParams.options[0], 1); + // 0 for maybe + expectPollOptionVoteCount(pollId, pollParams.options[2], 0); + }); + }); + + it("displays polls correctly in thread panel", () => { + let botBob: MatrixClient; + let botCharlie: MatrixClient; + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + botBob = _bot; + }); + cy.getBot(synapse, { displayName: "BotCharlie" }).then(_bot => { + botCharlie = _bot; + }); + + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, botBob.getUserId()); + cy.inviteUser(roomId, botCharlie.getUserId()); + cy.visit('/#/room/' + roomId); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + const pollParams = { + title: 'Does the polls feature work?', + options: ['Yes', 'No', 'Maybe'], + }; + createPoll(pollParams); + + // Wait for message to send, get its ID and save as @pollId + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + .invoke("attr", "data-scroll-tokens").as("pollId"); + + cy.get("@pollId").then(pollId => { + // Bob starts thread on the poll + botBob.sendMessage(roomId, pollId, { + body: "Hello there", + msgtype: "m.text", + }); + + // open the thread summary + cy.get(".mx_RoomView_body .mx_ThreadSummary").click(); + + // Bob votes 'Maybe' in the poll + botVoteForOption(botBob, roomId, pollId, pollParams.options[2]); + // Charlie votes 'No' + botVoteForOption(botCharlie, roomId, pollId, pollParams.options[1]); + + // no votes shown until I vote, check votes have arrived in main tl + cy.get('.mx_RoomView_body .mx_MPollBody_totalVotes').should('contain', '2 votes cast'); + // and thread view + cy.get('.mx_ThreadView .mx_MPollBody_totalVotes').should('contain', '2 votes cast'); + + cy.get('.mx_RoomView_body').within(() => { + // vote 'Maybe' in the main timeline poll + getPollOption(pollId, pollParams.options[2]).click('topLeft'); + // both me and bob have voted Maybe + expectPollOptionVoteCount(pollId, pollParams.options[2], 2); + }); + + cy.get('.mx_ThreadView').within(() => { + // votes updated in thread view too + expectPollOptionVoteCount(pollId, pollParams.options[2], 2); + // change my vote to 'Yes' + getPollOption(pollId, pollParams.options[0]).click('topLeft'); + }); + + // Bob updates vote to 'No' + botVoteForOption(botBob, roomId, pollId, pollParams.options[1]); + + // me: yes, bob: no, charlie: no + const expectVoteCounts = () => { + // I voted yes + expectPollOptionVoteCount(pollId, pollParams.options[0], 1); + // Bob and Charlie voted no + expectPollOptionVoteCount(pollId, pollParams.options[1], 2); + // 0 for maybe + expectPollOptionVoteCount(pollId, pollParams.options[2], 0); + }; + + // check counts are correct in main timeline tile + cy.get('.mx_RoomView_body').within(() => { + expectVoteCounts(); + }); + // and in thread view tile + cy.get('.mx_ThreadView').within(() => { + expectVoteCounts(); + }); + }); + }); +}); diff --git a/cypress/support/composer.ts b/cypress/support/composer.ts new file mode 100644 index 00000000000..ae6c8bef871 --- /dev/null +++ b/cypress/support/composer.ts @@ -0,0 +1,48 @@ +/* +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 Chainable = Cypress.Chainable; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + // Get the composer element + // selects main timeline composer by default + // set `isRightPanel` true to select right panel composer + getComposer(isRightPanel?: boolean): Chainable; + // Open the message composer kebab menu + openMessageComposerOptions(isRightPanel?: boolean): Chainable; + } + } +} + +Cypress.Commands.add("getComposer", (isRightPanel?: boolean): Chainable => { + const panelClass = isRightPanel ? '.mx_RightPanel' : '.mx_RoomView_body'; + return cy.get(`${panelClass} .mx_MessageComposer`); +}); + +Cypress.Commands.add("openMessageComposerOptions", (isRightPanel?: boolean): Chainable => { + cy.getComposer(isRightPanel).within(() => { + cy.get('[aria-label="More options"]').click(); + }); + return cy.get('.mx_MessageComposer_Menu'); +}); + +// Needed to make this file a module +export { }; diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 18445d8d04b..faff9a83637 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -35,3 +35,4 @@ import "./views"; import "./iframes"; import "./timeline"; import "./network"; +import "./composer"; From dca4b8b2914a2abbeea16809f755533fbeeafa91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Wed, 20 Jul 2022 12:48:31 +0200 Subject: [PATCH 10/13] Fix wrong buttons being used when exploring public rooms (#9062) --- cypress/e2e/12-spotlight/spotlight.spec.ts | 33 +++++++++++++++++++ .../dialogs/spotlight/SpotlightDialog.tsx | 14 ++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/12-spotlight/spotlight.spec.ts b/cypress/e2e/12-spotlight/spotlight.spec.ts index 7b5ca76fe15..a99bb43f6ab 100644 --- a/cypress/e2e/12-spotlight/spotlight.spec.ts +++ b/cypress/e2e/12-spotlight/spotlight.spec.ts @@ -139,6 +139,9 @@ describe("Spotlight", () => { const room2Name = "Lounge"; let room2Id: string; + const room3Name = "Public"; + let room3Id: string; + beforeEach(() => { cy.startSynapse("default").then(data => { synapse = data; @@ -163,6 +166,19 @@ describe("Spotlight", () => { room2Id = _room2Id; bot2.invite(room2Id, bot1.getUserId()); }); + bot2.createRoom({ + name: room3Name, + visibility: Visibility.Public, initial_state: [{ + type: "m.room.history_visibility", + state_key: "", + content: { + history_visibility: "world_readable", + }, + }], + }).then(({ room_id: _room3Id }) => { + room3Id = _room3Id; + bot2.invite(room3Id, bot1.getUserId()); + }); }), ).then(() => cy.get('.mx_RoomSublist_skeletonUI').should('not.exist'), @@ -212,6 +228,7 @@ describe("Spotlight", () => { cy.spotlightSearch().clear().type(room1Name); cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room1Name); + cy.spotlightResults().eq(0).should("contain", "View"); cy.spotlightResults().eq(0).click(); cy.url().should("contain", room1Id); }).then(() => { @@ -225,6 +242,7 @@ describe("Spotlight", () => { cy.spotlightSearch().clear().type(room2Name); cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room2Name); + cy.spotlightResults().eq(0).should("contain", "Join"); cy.spotlightResults().eq(0).click(); cy.url().should("contain", room2Id); }).then(() => { @@ -233,6 +251,21 @@ describe("Spotlight", () => { }); }); + it("should find unknown public world readable rooms", () => { + cy.openSpotlightDialog().within(() => { + cy.spotlightFilter(Filter.PublicRooms); + cy.spotlightSearch().clear().type(room3Name); + cy.spotlightResults().should("have.length", 1); + cy.spotlightResults().eq(0).should("contain", room3Name); + cy.spotlightResults().eq(0).should("contain", "View"); + cy.spotlightResults().eq(0).click(); + cy.url().should("contain", room3Id); + }).then(() => { + cy.get(".mx_RoomPreviewBar_actions .mx_AccessibleButton").click(); + cy.roomHeaderName().should("contain", room3Name); + }); + }); + // TODO: We currently canโ€™t test finding rooms on other homeservers/other protocols // We obviously donโ€™t have federation or bridges in cypress tests /* diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index dbe55972c54..fa2c5c48b5f 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -607,6 +607,16 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n } if (isPublicRoomResult(result)) { const clientRoom = cli.getRoom(result.publicRoom.room_id); + // Element Web currently does not allow guests to join rooms, so we + // instead show them view buttons for all rooms. If the room is not + // world readable, a modal will appear asking you to register first. If + // it is readable, the preview appears as normal. + const showViewButton = ( + clientRoom?.getMyMembership() === "join" || + result.publicRoom.world_readable || + cli.isGuest() + ); + const listener = (ev) => { const { publicRoom } = result; viewRoom({ @@ -622,11 +632,11 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n onClick={listener} endAdornment={ - { _t(clientRoom ? "View" : "Join") } + { showViewButton ? _t("View") : _t("Join") } } aria-labelledby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_name`} aria-describedby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_alias`} From c980885d6b6cb8306250a772300b2522349df5e9 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 20 Jul 2022 14:41:43 +0200 Subject: [PATCH 11/13] Extract RoomStatusBarUnsentMessages (#9080) --- res/css/structures/_RoomStatusBar.pcss | 2 +- src/components/structures/RoomStatusBar.tsx | 32 +++-------- .../RoomStatusBarUnsentMessages.tsx | 55 +++++++++++++++++++ .../RoomStatusBarUnsentMessages-test.tsx | 47 ++++++++++++++++ 4 files changed, 111 insertions(+), 25 deletions(-) create mode 100644 src/components/structures/RoomStatusBarUnsentMessages.tsx create mode 100644 test/components/structures/RoomStatusBarUnsentMessages-test.tsx diff --git a/res/css/structures/_RoomStatusBar.pcss b/res/css/structures/_RoomStatusBar.pcss index a54ceae49e9..51fea7a49a0 100644 --- a/res/css/structures/_RoomStatusBar.pcss +++ b/res/css/structures/_RoomStatusBar.pcss @@ -138,7 +138,7 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/trashcan.svg'); } - &.mx_RoomStatusBar_unsentResendAllBtn { + &.mx_RoomStatusBar_unsentRetry { padding-left: 34px; // 28px from above, but +6px to account for the wider icon &::before { diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index a89f205a88e..d46ad12b50d 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -24,11 +24,11 @@ import Resend from '../../Resend'; import dis from '../../dispatcher/dispatcher'; import { messageForResourceLimitError } from '../../utils/ErrorUtils'; import { Action } from "../../dispatcher/actions"; -import NotificationBadge from "../views/rooms/NotificationBadge"; import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState"; import AccessibleButton from "../views/elements/AccessibleButton"; import InlineSpinner from "../views/elements/InlineSpinner"; import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages'; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -240,7 +240,7 @@ export default class RoomStatusBar extends React.PureComponent { { _t("Delete all") } - + { _t("Retry all") } ; @@ -252,28 +252,12 @@ export default class RoomStatusBar extends React.PureComponent { ; } - return <> -
    -
    -
    - -
    -
    -
    - { title } -
    -
    - { _t("You can select all or individual messages to retry or delete") } -
    -
    -
    - { buttonRow } -
    -
    -
    - ; + return ; } public render(): JSX.Element { diff --git a/src/components/structures/RoomStatusBarUnsentMessages.tsx b/src/components/structures/RoomStatusBarUnsentMessages.tsx new file mode 100644 index 00000000000..4c8f9fe35b5 --- /dev/null +++ b/src/components/structures/RoomStatusBarUnsentMessages.tsx @@ -0,0 +1,55 @@ +/* +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, { ReactElement } from "react"; + +import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState"; +import NotificationBadge from "../views/rooms/NotificationBadge"; + +interface RoomStatusBarUnsentMessagesProps { + title: string; + description?: string; + notificationState: StaticNotificationState; + buttons: ReactElement; +} + +export const RoomStatusBarUnsentMessages = (props: RoomStatusBarUnsentMessagesProps): ReactElement => { + return ( +
    +
    +
    + +
    +
    +
    + { props.title } +
    + { + props.description && +
    + { props.description } +
    + } +
    +
    + { props.buttons } +
    +
    +
    + ); +}; diff --git a/test/components/structures/RoomStatusBarUnsentMessages-test.tsx b/test/components/structures/RoomStatusBarUnsentMessages-test.tsx new file mode 100644 index 00000000000..432687218e2 --- /dev/null +++ b/test/components/structures/RoomStatusBarUnsentMessages-test.tsx @@ -0,0 +1,47 @@ +/* +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 { render, screen } from "@testing-library/react"; + +import { RoomStatusBarUnsentMessages } from "../../../src/components/structures/RoomStatusBarUnsentMessages"; +import { StaticNotificationState } from "../../../src/stores/notifications/StaticNotificationState"; + +describe("RoomStatusBarUnsentMessages", () => { + const title = "test title"; + const description = "test description"; + const buttonsText = "test buttons"; + const buttons =
    { buttonsText }
    ; + + beforeEach(() => { + render( + , + ); + }); + + it("should render the values passed as props", () => { + screen.getByText(title); + screen.getByText(description); + screen.getByText(buttonsText); + // notification state + screen.getByText("!"); + }); +}); From 9edd49818c4a8e23656b800e5daea7b361f28f4b Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 20 Jul 2022 15:07:06 +0200 Subject: [PATCH 12/13] Prepare room components for local rooms (#9082) --- res/css/views/rooms/_RoomHeader.pcss | 4 +- src/components/views/rooms/EventTile.tsx | 4 + src/components/views/rooms/NewRoomIntro.tsx | 17 +- src/components/views/rooms/RoomHeader.tsx | 184 ++++++++++-------- src/i18n/strings/en_EN.json | 11 +- .../views/rooms/NewRoomIntro-test.tsx | 78 ++++++++ .../views/rooms/RoomHeader-test.tsx | 63 ++++-- 7 files changed, 252 insertions(+), 109 deletions(-) create mode 100644 test/components/views/rooms/NewRoomIntro-test.tsx diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index 1ae9aae8b82..dc2817ee684 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -100,7 +100,7 @@ limitations under the License. display: flex; user-select: none; - &:hover { + &:not(.mx_RoomHeader_name--textonly):hover { background-color: $quinary-content; } @@ -139,7 +139,7 @@ limitations under the License. opacity: 0.6; } -.mx_RoomHeader_name, +.mx_RoomHeader_name:not(.mx_RoomHeader_name--textonly), .mx_RoomHeader_avatar { cursor: pointer; } diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 3c5e419d94a..8e7038a255f 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -80,6 +80,7 @@ import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../event import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary"; import { ReadReceiptGroup } from './ReadReceiptGroup'; import { useTooltip } from "../../../utils/useTooltip"; +import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; @@ -766,6 +767,9 @@ export class UnwrappedEventTile extends React.Component { private renderE2EPadlock() { const ev = this.props.mxEvent; + // no icon for local rooms + if (isLocalRoom(ev.getRoomId())) return; + // event could not be decrypted if (ev.getContent().msgtype === 'm.bad.encrypted') { return ; diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 40d3d4ce0c9..7cd43aded0f 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -38,6 +38,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; import { privateShouldBeEncrypted } from "../../../utils/rooms"; +import { LocalRoom } from "../../../models/LocalRoom"; function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean { const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId); @@ -49,11 +50,19 @@ const NewRoomIntro = () => { const cli = useContext(MatrixClientContext); const { room, roomId } = useContext(RoomContext); - const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId); + const isLocalRoom = room instanceof LocalRoom; + const dmPartner = isLocalRoom + ? room.targets[0]?.userId + : DMRoomMap.shared().getUserIdForRoomId(roomId); + let body; if (dmPartner) { + let introMessage = _t("This is the beginning of your direct message history with ."); let caption; - if ((room.getJoinedMemberCount() + room.getInvitedMemberCount()) === 2) { + + if (isLocalRoom) { + introMessage = _t("Send your first message to invite to chat"); + } else if ((room.getJoinedMemberCount() + room.getInvitedMemberCount()) === 2) { caption = _t("Only the two of you are in this conversation, unless either of you invites anyone to join."); } @@ -75,7 +84,7 @@ const NewRoomIntro = () => {

    { room.name }

    -

    { _t("This is the beginning of your direct message history with .", {}, { +

    { _t(introMessage, {}, { displayName: () => { displayName }, }) }

    { caption &&

    { caption }

    } @@ -200,7 +209,7 @@ const NewRoomIntro = () => { ); let subButton; - if (room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, MatrixClientPeg.get())) { + if (room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, MatrixClientPeg.get()) && !isLocalRoom) { subButton = ( { _t("Enable encryption in settings.") } ); diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 5e7962b48ff..81ed22e35bb 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -65,6 +65,8 @@ interface IProps { appsShown: boolean; searchInfo: ISearchInfo; excludedRightPanelPhaseButtons?: Array; + showButtons?: boolean; + enableRoomOptionsMenu?: boolean; } interface IState { @@ -76,6 +78,8 @@ export default class RoomHeader extends React.Component { editing: false, inRoom: false, excludedRightPanelPhaseButtons: [], + showButtons: true, + enableRoomOptionsMenu: true, }; static contextType = RoomContext; @@ -130,81 +134,7 @@ export default class RoomHeader extends React.Component { this.setState({ contextMenuPosition: null }); }; - public render() { - let searchStatus = null; - - // don't display the search count until the search completes and - // gives us a valid (possibly zero) searchCount. - if (this.props.searchInfo && - this.props.searchInfo.searchCount !== undefined && - this.props.searchInfo.searchCount !== null) { - searchStatus =
      - { _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) } -
    ; - } - - // XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'... - let settingsHint = false; - const members = this.props.room ? this.props.room.getJoinedMembers() : undefined; - if (members) { - if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) { - const nameEvent = this.props.room.currentState.getStateEvents('m.room.name', ''); - if (!nameEvent || !nameEvent.getContent().name) { - settingsHint = true; - } - } - } - - let oobName = _t("Join Room"); - if (this.props.oobData && this.props.oobData.name) { - oobName = this.props.oobData.name; - } - - let contextMenu: JSX.Element; - if (this.state.contextMenuPosition && this.props.room) { - contextMenu = ( - - ); - } - - const textClasses = classNames('mx_RoomHeader_nametext', { mx_RoomHeader_settingsHint: settingsHint }); - const name = ( - - - { (name) => { - const roomName = name || oobName; - return
    { roomName }
    ; - } } -
    - { this.props.room &&
    } - { contextMenu } - - ); - - const topicElement = ; - - let roomAvatar; - if (this.props.room) { - roomAvatar = ; - } - + private renderButtons(): JSX.Element[] { const buttons: JSX.Element[] = []; if (this.props.inRoom && @@ -269,10 +199,105 @@ export default class RoomHeader extends React.Component { buttons.push(inviteButton); } - const rightRow = -
    - { buttons } + return buttons; + } + + private renderName(oobName) { + let contextMenu: JSX.Element; + if (this.state.contextMenuPosition && this.props.room) { + contextMenu = ( + + ); + } + + // XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'... + let settingsHint = false; + const members = this.props.room ? this.props.room.getJoinedMembers() : undefined; + if (members) { + if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) { + const nameEvent = this.props.room.currentState.getStateEvents('m.room.name', ''); + if (!nameEvent || !nameEvent.getContent().name) { + settingsHint = true; + } + } + } + + const textClasses = classNames('mx_RoomHeader_nametext', { mx_RoomHeader_settingsHint: settingsHint }); + const roomName = + { (name) => { + const roomName = name || oobName; + return
    { roomName }
    ; + } } +
    ; + + if (this.props.enableRoomOptionsMenu) { + return ( + + { roomName } + { this.props.room &&
    } + { contextMenu } + + ); + } + + return
    + { roomName } +
    ; + } + + public render() { + let searchStatus = null; + + // don't display the search count until the search completes and + // gives us a valid (possibly zero) searchCount. + if (this.props.searchInfo && + this.props.searchInfo.searchCount !== undefined && + this.props.searchInfo.searchCount !== null) { + searchStatus =
      + { _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) }
    ; + } + + let oobName = _t("Join Room"); + if (this.props.oobData && this.props.oobData.name) { + oobName = this.props.oobData.name; + } + + const name = this.renderName(oobName); + + const topicElement = ; + + let roomAvatar; + if (this.props.room) { + roomAvatar = ; + } + + let buttons; + if (this.props.showButtons) { + buttons = +
    + { this.renderButtons() } +
    + +
    ; + } const e2eIcon = this.props.e2eStatus ? : undefined; @@ -294,8 +319,7 @@ export default class RoomHeader extends React.Component { { searchStatus } { topicElement } { betaPill } - { rightRow } - + { buttons }
    diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a2568949d9f..597f4735ebd 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1730,8 +1730,9 @@ "Code block": "Code block", "Quote": "Quote", "Insert link": "Insert link", - "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Only the two of you are in this conversation, unless either of you invites anyone to join.", "This is the beginning of your direct message history with .": "This is the beginning of your direct message history with .", + "Send your first message to invite to chat": "Send your first message to invite to chat", + "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Only the two of you are in this conversation, unless either of you invites anyone to join.", "Topic: %(topic)s (edit)": "Topic: %(topic)s (edit)", "Topic: %(topic)s ": "Topic: %(topic)s ", "Add a topic to help people know what it is about.": "Add a topic to help people know what it is about.", @@ -1771,15 +1772,15 @@ "Room %(name)s": "Room %(name)s", "Recently visited rooms": "Recently visited rooms", "No recently visited rooms": "No recently visited rooms", - "(~%(count)s results)|other": "(~%(count)s results)", - "(~%(count)s results)|one": "(~%(count)s result)", - "Join Room": "Join Room", - "Room options": "Room options", "Forget room": "Forget room", "Hide Widgets": "Hide Widgets", "Show Widgets": "Show Widgets", "Search": "Search", "Invite": "Invite", + "Room options": "Room options", + "(~%(count)s results)|other": "(~%(count)s results)", + "(~%(count)s results)|one": "(~%(count)s result)", + "Join Room": "Join Room", "Video rooms are a beta feature": "Video rooms are a beta feature", "Video room": "Video room", "Public space": "Public space", diff --git a/test/components/views/rooms/NewRoomIntro-test.tsx b/test/components/views/rooms/NewRoomIntro-test.tsx new file mode 100644 index 00000000000..988a749f509 --- /dev/null +++ b/test/components/views/rooms/NewRoomIntro-test.tsx @@ -0,0 +1,78 @@ +/* +Copyright 2015 - 2022 The Matrix.org Foundation C.I.C. +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> + +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 { render, screen } from "@testing-library/react"; +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { LocalRoom } from "../../../../src/models/LocalRoom"; +import { createTestClient } from "../../../test-utils"; +import RoomContext from "../../../../src/contexts/RoomContext"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import NewRoomIntro from "../../../../src/components/views/rooms/NewRoomIntro"; +import { IRoomState } from "../../../../src/components/structures/RoomView"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { DirectoryMember } from "../../../../src/utils/direct-messages"; + +const renderNewRoomIntro = (client: MatrixClient, room: Room|LocalRoom) => { + render( + + + + + , + ); +}; + +describe("NewRoomIntro", () => { + let client: MatrixClient; + const roomId = "!room:example.com"; + const userId = "@user:example.com"; + + beforeEach(() => { + client = createTestClient(); + jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client); + DMRoomMap.makeShared(); + }); + + describe("for a DM Room", () => { + beforeEach(() => { + jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(userId); + renderNewRoomIntro(client, new Room(roomId, client, client.getUserId())); + }); + + it("should render the expected intro", () => { + const expected = `This is the beginning of your direct message history with ${userId}.`; + screen.getByText((id, element) => element.tagName === "SPAN" && element.textContent === expected); + }); + }); + + describe("for a DM LocalRoom", () => { + beforeEach(() => { + jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(userId); + const localRoom = new LocalRoom(roomId, client, client.getUserId()); + localRoom.targets.push(new DirectoryMember({ user_id: userId })); + renderNewRoomIntro(client, localRoom); + }); + + it("should render the expected intro", () => { + const expected = `Send your first message to invite ${userId} to chat`; + screen.getByText((id, element) => element.tagName === "SPAN" && element.textContent === expected); + }); + }); +}); diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index b7c07f320d8..31b6e818f37 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -127,7 +127,7 @@ describe('RoomHeader', () => { it("hides call buttons when the room is tombstoned", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room, { + const wrapper = render(room, {}, { tombstone: mkEvent({ event: true, type: "m.room.tombstone", @@ -142,6 +142,30 @@ describe('RoomHeader', () => { expect(wrapper.find('[aria-label="Voice call"]').hostNodes()).toHaveLength(0); expect(wrapper.find('[aria-label="Video call"]').hostNodes()).toHaveLength(0); }); + + it("should render buttons if not passing showButtons (default true)", () => { + const room = createRoom({ name: "Room", isDm: false, userIds: [] }); + const wrapper = render(room); + expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(1); + }); + + it("should not render buttons if passing showButtons = false", () => { + const room = createRoom({ name: "Room", isDm: false, userIds: [] }); + const wrapper = render(room, { showButtons: false }); + expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(0); + }); + + it("should render the room options context menu if not passing enableRoomOptionsMenu (default true)", () => { + const room = createRoom({ name: "Room", isDm: false, userIds: [] }); + const wrapper = render(room); + expect(wrapper.find(".mx_RoomHeader_name.mx_AccessibleButton")).toHaveLength(1); + }); + + it("should not render the room options context menu if passing enableRoomOptionsMenu = false", () => { + const room = createRoom({ name: "Room", isDm: false, userIds: [] }); + const wrapper = render(room, { enableRoomOptionsMenu: false }); + expect(wrapper.find(".mx_RoomHeader_name.mx_AccessibleButton")).toHaveLength(0); + }); }); interface IRoomCreationInfo { @@ -185,25 +209,28 @@ function createRoom(info: IRoomCreationInfo) { return room; } -function render(room: Room, roomContext?: Partial): ReactWrapper { +function render(room: Room, propsOverride = {}, roomContext?: Partial): ReactWrapper { + const props = { + room, + inRoom: true, + onSearchClick: () => {}, + onInviteClick: null, + onForgetClick: () => {}, + onCallPlaced: (_type) => { }, + onAppsClick: () => {}, + e2eStatus: E2EStatus.Normal, + appsShown: true, + searchInfo: { + searchTerm: "", + searchScope: SearchScope.Room, + searchCount: 0, + }, + ...propsOverride, + }; + return mount(( - {}} - onInviteClick={null} - onForgetClick={() => {}} - onCallPlaced={(_type) => { }} - onAppsClick={() => {}} - e2eStatus={E2EStatus.Normal} - appsShown={true} - searchInfo={{ - searchTerm: "", - searchScope: SearchScope.Room, - searchCount: 0, - }} - /> + )); } From 35ba389d61e97e7570a7644c4bd20eadf93ac8da Mon Sep 17 00:00:00 2001 From: grimhilt <107760093+grimhilt@users.noreply.github.com> Date: Thu, 21 Jul 2022 13:27:11 +0000 Subject: [PATCH 13/13] Use "frequently used emojis" for autocompletion in composer (#8998) Co-authored-by: grimhilt --- src/autocomplete/EmojiProvider.tsx | 19 ++++++++++++++++--- test/autocomplete/EmojiProvider-test.ts | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 4c051a71269..4a2c37988ae 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -29,8 +29,9 @@ import QueryMatcher from './QueryMatcher'; import { PillCompletion } from './Components'; import { ICompletion, ISelectionRange } from './Autocompleter'; import SettingsStore from "../settings/SettingsStore"; -import { EMOJI, IEmoji } from '../emoji'; +import { EMOJI, IEmoji, getEmojiFromUnicode } from '../emoji'; import { TimelineRenderingType } from '../contexts/RoomContext'; +import * as recent from '../emojipicker/recent'; const LIMIT = 20; @@ -73,6 +74,7 @@ function colonsTrimmed(str: string): string { export default class EmojiProvider extends AutocompleteProvider { matcher: QueryMatcher; nameMatcher: QueryMatcher; + private readonly recentlyUsed: IEmoji[]; constructor(room: Room, renderingType?: TimelineRenderingType) { super({ commandRegex: EMOJI_REGEX, renderingType }); @@ -87,6 +89,8 @@ export default class EmojiProvider extends AutocompleteProvider { // For removing punctuation shouldMatchWordsOnly: true, }); + + this.recentlyUsed = Array.from(new Set(recent.get().map(getEmojiFromUnicode).filter(Boolean))); } async getCompletions( @@ -109,7 +113,7 @@ export default class EmojiProvider extends AutocompleteProvider { // Do second match with shouldMatchWordsOnly in order to match against 'name' completions = completions.concat(this.nameMatcher.match(matchedString)); - const sorters = []; + let sorters = []; // make sure that emoticons come first sorters.push(c => score(matchedString, c.emoji.emoticon || "")); @@ -130,6 +134,15 @@ export default class EmojiProvider extends AutocompleteProvider { sorters.push(c => c._orderBy); completions = sortBy(uniq(completions), sorters); + completions = completions.slice(0, LIMIT); + + // Do a second sort to place emoji matching with frequently used one on top + sorters = []; + this.recentlyUsed.forEach(emoji => { + sorters.push(c => score(emoji.shortcodes[0], c.emoji.shortcodes[0])); + }); + completions = sortBy(uniq(completions), sorters); + completions = completions.map(c => ({ completion: c.emoji.unicode, component: ( @@ -138,7 +151,7 @@ export default class EmojiProvider extends AutocompleteProvider { ), range, - })).slice(0, LIMIT); + })); } return completions; } diff --git a/test/autocomplete/EmojiProvider-test.ts b/test/autocomplete/EmojiProvider-test.ts index a64312fa297..914ca429016 100644 --- a/test/autocomplete/EmojiProvider-test.ts +++ b/test/autocomplete/EmojiProvider-test.ts @@ -16,6 +16,9 @@ limitations under the License. import EmojiProvider from '../../src/autocomplete/EmojiProvider'; import { mkStubRoom } from '../test-utils/test-utils'; +import { add } from "../../src/emojipicker/recent"; +import { stubClient } from "../test-utils"; +import { MatrixClientPeg } from '../../src/MatrixClientPeg'; const EMOJI_SHORTCODES = [ ":+1", @@ -42,6 +45,8 @@ const TOO_SHORT_EMOJI_SHORTCODE = [ describe('EmojiProvider', function() { const testRoom = mkStubRoom(undefined, undefined, undefined); + stubClient(); + MatrixClientPeg.get(); it.each(EMOJI_SHORTCODES)('Returns consistent results after final colon %s', async function(emojiShortcode) { const ep = new EmojiProvider(testRoom); @@ -64,4 +69,21 @@ describe('EmojiProvider', function() { expect(completions[0].completion).toEqual(expectedEmoji); }); + + it('Returns correct autocompletion based on recently used emoji', async function() { + add("๐Ÿ˜˜"); //kissing_heart + add("๐Ÿ˜˜"); + add("๐Ÿ˜š"); //kissing_closed_eyes + const emojiProvider = new EmojiProvider(null); + + let completionsList = await emojiProvider.getCompletions(":kis", { beginning: true, end: 3, start: 3 }); + expect(completionsList[0].component.props.title).toEqual(":kissing_heart:"); + expect(completionsList[1].component.props.title).toEqual(":kissing_closed_eyes:"); + + completionsList = await emojiProvider.getCompletions(":kissing_c", { beginning: true, end: 3, start: 3 }); + expect(completionsList[0].component.props.title).toEqual(":kissing_closed_eyes:"); + + completionsList = await emojiProvider.getCompletions(":so", { beginning: true, end: 2, start: 2 }); + expect(completionsList[0].component.props.title).toEqual(":sob:"); + }); });