From b2548f05a825e3f2e38cbc6c04bfbe130105f7d4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 14 Dec 2021 15:15:27 +0000 Subject: [PATCH 001/145] Add missing react keys, fixing the warning (#7360) --- .../tabs/user/LabsUserSettingsTab.tsx | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx index 440ce2f7ed5..d8ecbc2815c 100644 --- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx @@ -89,25 +89,49 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> { }); groups.getOrCreate(LabGroup.Widgets, []).push( - , + , ); groups.getOrCreate(LabGroup.Experimental, []).push( - , + , ); groups.getOrCreate(LabGroup.Developer, []).push( - , - , + , + , ); groups.getOrCreate(LabGroup.Analytics, []).push( - , + , ); if (this.state.showHiddenReadReceipts) { groups.getOrCreate(LabGroup.Messaging, []).push( - , + , ); } From 7033f8696ae2a82a1fe23eefff6e3bf33ecc4e95 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 14 Dec 2021 15:34:54 +0000 Subject: [PATCH 002/145] Improve typing (#7349) --- src/@types/global.d.ts | 10 ++++++++++ src/DecryptionFailureTracker.ts | 4 ++-- src/components/structures/MatrixChat.tsx | 5 +++-- src/components/structures/RoomView.tsx | 3 ++- src/components/structures/auth/Login.tsx | 2 +- src/stores/CommunityPrototypeStore.ts | 3 ++- src/stores/RoomViewStore.tsx | 6 +++--- src/utils/ErrorUtils.tsx | 2 +- 8 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index e22dd89d137..c11e34c663d 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -163,6 +163,16 @@ declare global { interface HTMLAudioElement { type?: string; + // sinkId & setSinkId are experimental and typescript doesn't know about them + sinkId: string; + setSinkId(outputId: string): void; + } + + interface HTMLVideoElement { + type?: string; + // sinkId & setSinkId are experimental and typescript doesn't know about them + sinkId: string; + setSinkId(outputId: string): void; } interface HTMLStyleElement { diff --git a/src/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts index dc8fdc5739f..5db75fe0f37 100644 --- a/src/DecryptionFailureTracker.ts +++ b/src/DecryptionFailureTracker.ts @@ -94,9 +94,9 @@ export class DecryptionFailureTracker { // localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap)); // } - public eventDecrypted(e: MatrixEvent, err: MatrixError | Error): void { + public eventDecrypted(e: MatrixEvent, err: MatrixError): void { if (err) { - this.addDecryptionFailure(new DecryptionFailure(e.getId(), err.code)); + this.addDecryptionFailure(new DecryptionFailure(e.getId(), err.errcode)); } else { // Could be an event in the failures, remove it this.removeDecryptionFailuresForEvent(e); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index bfdbacef7a0..a3848af83a8 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -15,7 +15,8 @@ limitations under the License. */ import React, { ComponentType, createRef } from 'react'; -import { createClient } from "matrix-js-sdk/src/matrix"; +import { createClient } from 'matrix-js-sdk/src/matrix'; +import { MatrixError } from 'matrix-js-sdk/src/http-api'; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Error as ErrorEvent } from "matrix-analytics-events/types/typescript/Error"; @@ -233,7 +234,7 @@ interface IState { // When showing Modal dialogs we need to set aria-hidden on the root app element // and disable it when there are no dialogs hideToSRUsers: boolean; - syncError?: Error; + syncError?: MatrixError; resizeNotifier: ResizeNotifier; serverConfig?: ValidatedServerConfig; ready: boolean; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 2f1ee3400e9..65966a05e41 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -33,6 +33,7 @@ import { EventType } from 'matrix-js-sdk/src/@types/event'; import { RoomState } from 'matrix-js-sdk/src/models/room-state'; import { CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { throttle } from "lodash"; +import { MatrixError } from 'matrix-js-sdk/src/http-api'; import shouldHideEvent from '../../shouldHideEvent'; import { _t } from '../../languageHandler'; @@ -164,7 +165,7 @@ export interface IRoomState { // error object, as from the matrix client/server API // If we failed to load information about the room, // store the error here. - roomLoadError?: Error; + roomLoadError?: MatrixError; // Have we sent a request to join the room that we're waiting to complete? joining: boolean; // this is true if we are fully scrolled-down, and are looking at diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 4b849e1d03d..1d963a22295 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -454,7 +454,7 @@ export default class LoginComponent extends React.PureComponent let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " + "please try again later.") + (errCode ? " (" + errCode + ")" : ""); - if (err.cors === 'rejected') { + if (err["cors"] === 'rejected') { // browser-request specific error field if (window.location.protocol === 'https:' && (this.props.serverConfig.hsUrl.startsWith("http:") || !this.props.serverConfig.hsUrl.startsWith("http")) diff --git a/src/stores/CommunityPrototypeStore.ts b/src/stores/CommunityPrototypeStore.ts index 04f7eb29c43..83473298ba3 100644 --- a/src/stores/CommunityPrototypeStore.ts +++ b/src/stores/CommunityPrototypeStore.ts @@ -18,6 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import * as utils from "matrix-js-sdk/src/utils"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; +import { Method } from "matrix-js-sdk/src/http-api"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; @@ -131,7 +132,7 @@ export class CommunityPrototypeStore extends AsyncStoreWithClient { try { const path = utils.encodeUri("/rooms/$roomId/group_info", { $roomId: room.roomId }); const profile = await this.matrixClient.http.authedRequest( - undefined, "GET", path, + undefined, Method.Get, path, undefined, undefined, { prefix: "/_matrix/client/unstable/im.vector.custom" }); // we use global account data because per-room account data on invites is unreliable diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index f54f98fdb20..3f54435205c 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ReactNode } from "react"; import { Store } from 'flux/utils'; import { MatrixError } from "matrix-js-sdk/src/http-api"; import { logger } from "matrix-js-sdk/src/logger"; @@ -317,8 +317,8 @@ class RoomViewStore extends Store { } } - public showJoinRoomError(err: Error | MatrixError, roomId: string) { - let msg = err.message ? err.message : JSON.stringify(err); + public showJoinRoomError(err: MatrixError, roomId: string) { + let msg: ReactNode = err.message ? err.message : JSON.stringify(err); logger.log("Failed to join room:", msg); if (err.name === "ConnectionError") { diff --git a/src/utils/ErrorUtils.tsx b/src/utils/ErrorUtils.tsx index 4253564ffdf..52c9c470f85 100644 --- a/src/utils/ErrorUtils.tsx +++ b/src/utils/ErrorUtils.tsx @@ -57,7 +57,7 @@ export function messageForResourceLimitError( } } -export function messageForSyncError(err: MatrixError | Error): ReactNode { +export function messageForSyncError(err: MatrixError): ReactNode { if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const limitError = messageForResourceLimitError( err.data.limit_type, From cc689f95d8916f45bdfae7a30b5fc02994e8790f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 14 Dec 2021 16:06:45 +0000 Subject: [PATCH 003/145] Fix room search sometimes not opening spotlight (#7363) --- src/components/structures/RoomSearch.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 0ac762409c4..cd4f022341d 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -118,19 +118,17 @@ export default class RoomSearch extends React.PureComponent { this.setState({ query: this.inputRef.current.value }); }; - private onMouseDown = (ev: React.MouseEvent) => { + private onFocus = (ev: React.FocusEvent) => { if (SettingsStore.getValue("feature_spotlight")) { ev.preventDefault(); ev.stopPropagation(); this.openSpotlight(); + } else { + this.setState({ focused: true }); + ev.target.select(); } }; - private onFocus = (ev: React.FocusEvent) => { - this.setState({ focused: true }); - ev.target.select(); - }; - private onBlur = (ev: React.FocusEvent) => { this.setState({ focused: false }); }; @@ -156,7 +154,11 @@ export default class RoomSearch extends React.PureComponent { }; public focus = (): void => { - this.inputRef.current?.focus(); + if (SettingsStore.getValue("feature_spotlight")) { + this.openSpotlight(); + } else { + this.inputRef.current?.focus(); + } }; public render(): React.ReactNode { @@ -181,7 +183,6 @@ export default class RoomSearch extends React.PureComponent { ref={this.inputRef} className={inputClasses} value={this.state.query} - onMouseDown={this.onMouseDown} onFocus={this.onFocus} onBlur={this.onBlur} onChange={this.onChange} From 1d9906c3fa4e6235d44468b147606a15a2615e7e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 14 Dec 2021 16:07:00 +0000 Subject: [PATCH 004/145] Fix room join spinner in room list header (#7364) --- src/components/views/rooms/RoomListHeader.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/components/views/rooms/RoomListHeader.tsx b/src/components/views/rooms/RoomListHeader.tsx index e04e5fb165d..345307f54e7 100644 --- a/src/components/views/rooms/RoomListHeader.tsx +++ b/src/components/views/rooms/RoomListHeader.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useContext, useEffect, useState } from "react"; +import React, { ComponentProps, useContext, useEffect, useState } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import { EventType } from "matrix-js-sdk/src/@types/event"; @@ -41,7 +41,6 @@ import ErrorDialog from "../dialogs/ErrorDialog"; import { showCommunityInviteDialog } from "../../../RoomInvite"; import { useDispatcher } from "../../../hooks/useDispatcher"; import InlineSpinner from "../elements/InlineSpinner"; -import TooltipButton from "../elements/TooltipButton"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; import { @@ -51,6 +50,7 @@ import { UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE, } from "../../../stores/spaces"; +import TooltipTarget from "../elements/TooltipTarget"; const contextMenuBelow = (elementRect: DOMRect) => { // align the context menu's icons with the icon which opened the context menu @@ -60,7 +60,7 @@ const contextMenuBelow = (elementRect: DOMRect) => { return { left, top, chevronFace }; }; -const PrototypeCommunityContextMenu = (props) => { +const PrototypeCommunityContextMenu = (props: ComponentProps) => { const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId(); let settingsOption; @@ -327,14 +327,13 @@ const RoomListHeader = ({ spacePanelDisabled, onVisibilityChange }: IProps) => { title = getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome); } - let pendingRoomJoinSpinner; + let pendingRoomJoinSpinner: JSX.Element; if (joiningRooms.size) { - pendingRoomJoinSpinner = - - ; + pendingRoomJoinSpinner = + + ; } let contextMenuButton: JSX.Element =
{ title }
; From c1315bfa6cea395d7bd3f482ac605e1a6263e69a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 14 Dec 2021 16:07:34 +0000 Subject: [PATCH 005/145] Fix useUserStatusMessage exploding on unknown user (#7365) --- src/hooks/useUserStatusMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useUserStatusMessage.ts b/src/hooks/useUserStatusMessage.ts index ca2dbbf6e87..c3c99dc6685 100644 --- a/src/hooks/useUserStatusMessage.ts +++ b/src/hooks/useUserStatusMessage.ts @@ -25,7 +25,7 @@ import { useFeatureEnabled } from "./useSettings"; const getUser = (cli: MatrixClient, user: Member): User => cli.getUser(user?.userId); const getStatusMessage = (cli: MatrixClient, user: Member): string => { - return getUser(cli, user).unstable_statusMessage; + return getUser(cli, user)?.unstable_statusMessage; }; // Hook to simplify handling Matrix User status From 42b14bfcd7a48614e85e45e383329f77799b7b94 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 14 Dec 2021 16:07:54 +0000 Subject: [PATCH 006/145] Tweak FacePile tooltip to include whether or not you are included (#7367) --- src/components/views/elements/FacePile.tsx | 28 +++++++++++++++++----- src/i18n/strings/en_EN.json | 6 ++++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/components/views/elements/FacePile.tsx b/src/components/views/elements/FacePile.tsx index 0c19a7a63af..0ac43b06f37 100644 --- a/src/components/views/elements/FacePile.tsx +++ b/src/components/views/elements/FacePile.tsx @@ -38,7 +38,9 @@ const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsFor const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => { const cli = useContext(MatrixClientContext); + const isJoined = room.getMyMembership() === "join"; let members = useRoomMembers(room); + const count = members.length; // sort users with an explicit avatar first const iteratees = [member => !!member.getMxcAvatarUrl()]; @@ -59,19 +61,33 @@ const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, . let tooltip: ReactNode; if (props.onClick) { + let subText: string; + if (isJoined) { + subText = _t("Including you, %(commaSeparatedMembers)s", { commaSeparatedMembers }); + } else { + subText = _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers }); + } + tooltip =
- { _t("View all %(count)s members", { count: members.length }) } + { _t("View all %(count)s members", { count }) }
- { _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers }) } + { subText }
; } else { - tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", { - count: members.length, - commaSeparatedMembers, - }); + if (isJoined) { + tooltip = _t("%(count)s members including you, %(commaSeparatedMembers)s", { + count: count - 1, + commaSeparatedMembers, + }); + } else { + tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", { + count, + commaSeparatedMembers, + }); + } } return
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 36a061706ed..6406ce728e3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2166,9 +2166,13 @@ "Please create a new issue on GitHub so that we can investigate this bug.": "Please create a new issue on GitHub so that we can investigate this bug.", "collapse": "collapse", "expand": "expand", + "Including you, %(commaSeparatedMembers)s": "Including you, %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Including %(commaSeparatedMembers)s", "View all %(count)s members|other": "View all %(count)s members", "View all %(count)s members|one": "View 1 member", - "Including %(commaSeparatedMembers)s": "Including %(commaSeparatedMembers)s", + "%(count)s members including you, %(commaSeparatedMembers)s|other": "%(count)s members including you, %(commaSeparatedMembers)s", + "%(count)s members including you, %(commaSeparatedMembers)s|zero": "You", + "%(count)s members including you, %(commaSeparatedMembers)s|one": "%(count)s members including you and %(commaSeparatedMembers)s", "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s members including %(commaSeparatedMembers)s", "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", "%(count)s people you know have already joined|other": "%(count)s people you know have already joined", From 53081f52fb35c97944de91535409474ee536d76c Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 15 Dec 2021 08:34:52 +0000 Subject: [PATCH 007/145] Pass MatrixEvent to displayNotification method (#7355) --- src/BasePlatform.ts | 31 ++++++++++++++++++++++++++++++- src/Notifier.ts | 4 ++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index a4f91fc71bf..d9062f8ce43 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -170,7 +170,36 @@ export default abstract class BasePlatform { */ abstract requestNotificationPermission(): Promise; - abstract displayNotification(title: string, msg: string, avatarUrl: string, room: Room); + public displayNotification( + title: string, + msg: string, + avatarUrl: string, + room: Room, + ev?: MatrixEvent, + ): Notification { + const notifBody = { + body: msg, + silent: true, // we play our own sounds + }; + if (avatarUrl) notifBody['icon'] = avatarUrl; + const notification = new window.Notification(title, notifBody); + + notification.onclick = () => { + const payload: ActionPayload = { + action: Action.ViewRoom, + room_id: room.roomId, + }; + + if (ev.getThread()) { + payload.event_id = ev.getId(); + } + + dis.dispatch(payload); + window.focus(); + }; + + return notification; + } loudNotification(ev: MatrixEvent, room: Room) { } diff --git a/src/Notifier.ts b/src/Notifier.ts index 2fc2ee36437..35b3aee1652 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -122,7 +122,7 @@ export const Notifier = { avatarUrl = Avatar.avatarUrlForMember(ev.sender, 40, 40, 'crop'); } - const notif = plaf.displayNotification(title, msg, avatarUrl, room); + const notif = plaf.displayNotification(title, msg, avatarUrl, room, ev); // if displayNotification returns non-null, the platform supports // clearing notifications later, so keep track of this. @@ -381,7 +381,7 @@ export const Notifier = { _evaluateEvent: function(ev: MatrixEvent) { const room = MatrixClientPeg.get().getRoom(ev.getRoomId()); const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); - if (actions && actions.notify) { + if (actions?.notify) { if (RoomViewStore.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently() && !Modal.hasDialogs() From 5163ad216f36d4210e94c91c671d8f8f1b576d0f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Dec 2021 09:55:53 +0000 Subject: [PATCH 008/145] Use lazy rendering in the AddExistingToSpaceDialog (#7369) --- .../structures/AutoHideScrollbar.tsx | 2 +- .../dialogs/AddExistingToSpaceDialog.tsx | 189 ++++++++++-------- .../views/emojipicker/EmojiPicker.tsx | 19 +- 3 files changed, 114 insertions(+), 96 deletions(-) diff --git a/src/components/structures/AutoHideScrollbar.tsx b/src/components/structures/AutoHideScrollbar.tsx index a60df457704..31719a2cf14 100644 --- a/src/components/structures/AutoHideScrollbar.tsx +++ b/src/components/structures/AutoHideScrollbar.tsx @@ -27,7 +27,7 @@ interface IProps extends Omit, "onScroll"> { } export default class AutoHideScrollbar extends React.Component { - private containerRef: React.RefObject = React.createRef(); + public readonly containerRef: React.RefObject = React.createRef(); public componentDidMount() { if (this.containerRef.current && this.props.onScroll) { diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 298018798bb..0188fa4a066 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode, useContext, useMemo, useState } from "react"; +import React, { ReactNode, useContext, useMemo, useRef, useState } from "react"; import classNames from "classnames"; import { Room } from "matrix-js-sdk/src/models/room"; import { sleep } from "matrix-js-sdk/src/utils"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; -import { _t } from '../../../languageHandler'; +import { _t, _td } from '../../../languageHandler'; import BaseDialog from "./BaseDialog"; import Dropdown from "../elements/Dropdown"; import SearchBox from "../../structures/SearchBox"; @@ -38,9 +38,12 @@ import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/Rece import ProgressBar from "../elements/ProgressBar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import QueryMatcher from "../../../autocomplete/QueryMatcher"; -import TruncatedList from "../elements/TruncatedList"; -import EntityTile from "../rooms/EntityTile"; -import BaseAvatar from "../avatars/BaseAvatar"; +import LazyRenderList from "../elements/LazyRenderList"; + +// These values match CSS +const ROW_HEIGHT = 32 + 12; +const HEADER_HEIGHT = 15; +const GROUP_MARGIN = 24; interface IProps { space: Room; @@ -64,31 +67,56 @@ export const Entry = ({ room, checked, onChange }) => { ; }; +type OnChangeFn = (checked: boolean, room: Room) => void; + +type Renderer = ( + rooms: Room[], + selectedToAdd: Set, + scrollState: IScrollState, + onChange: undefined | OnChangeFn, +) => ReactNode; + interface IAddExistingToSpaceProps { space: Room; footerPrompt?: ReactNode; filterPlaceholder: string; emptySelectionButton?: ReactNode; onFinished(added: boolean): void; - roomsRenderer?( - rooms: Room[], - selectedToAdd: Set, - onChange: undefined | ((checked: boolean, room: Room) => void), - truncateAt: number, - overflowTile: (overflowCount: number, totalCount: number) => JSX.Element, - ): ReactNode; - spacesRenderer?( - spaces: Room[], - selectedToAdd: Set, - onChange?: (checked: boolean, room: Room) => void, - ): ReactNode; - dmsRenderer?( - dms: Room[], - selectedToAdd: Set, - onChange?: (checked: boolean, room: Room) => void, - ): ReactNode; + roomsRenderer?: Renderer; + spacesRenderer?: Renderer; + dmsRenderer?: Renderer; } +interface IScrollState { + scrollTop: number; + height: number; +} + +const getScrollState = ( + { scrollTop, height }: IScrollState, + numItems: number, + ...prevGroupSizes: number[] +): IScrollState => { + let heightBefore = 0; + prevGroupSizes.forEach(size => { + heightBefore += GROUP_MARGIN + HEADER_HEIGHT + (size * ROW_HEIGHT); + }); + + const viewportTop = scrollTop; + const viewportBottom = viewportTop + height; + const listTop = heightBefore + HEADER_HEIGHT; + const listBottom = listTop + (numItems * ROW_HEIGHT); + const top = Math.max(viewportTop, listTop); + const bottom = Math.min(viewportBottom, listBottom); + // the viewport height and scrollTop passed to the LazyRenderList + // is capped at the intersection with the real viewport, so lists + // out of view are passed height 0, so they won't render any items. + return { + scrollTop: Math.max(0, scrollTop - listTop), + height: Math.max(0, bottom - top), + }; +}; + export const AddExistingToSpace: React.FC = ({ space, footerPrompt, @@ -102,6 +130,13 @@ export const AddExistingToSpace: React.FC = ({ const cli = useContext(MatrixClientContext); const visibleRooms = useMemo(() => cli.getVisibleRooms().filter(r => r.getMyMembership() === "join"), [cli]); + const scrollRef = useRef(); + const [scrollState, setScrollState] = useState({ + // these are estimates which update as soon as it mounts + scrollTop: 0, + height: 600, + }); + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); const [progress, setProgress] = useState(null); const [error, setError] = useState(null); @@ -229,31 +264,33 @@ export const AddExistingToSpace: React.FC = ({ setSelectedToAdd(new Set(selectedToAdd)); } : null; - const [truncateAt, setTruncateAt] = useState(20); - function overflowTile(overflowCount: number, totalCount: number): JSX.Element { - const text = _t("and %(count)s others...", { count: overflowCount }); - return ( - - } - name={text} - presenceState="online" - suppressOnHover={true} - onClick={() => setTruncateAt(totalCount)} - /> - ); - } + // only count spaces when alone as they're shown on a separate modal all on their own + const numSpaces = (spacesRenderer && !dmsRenderer && !roomsRenderer) ? spaces.length : 0; let noResults = true; - if ((roomsRenderer && rooms.length > 0) || - (dmsRenderer && dms.length > 0) || - (!roomsRenderer && !dmsRenderer && spacesRenderer && spaces.length > 0) // only count spaces when alone - ) { + if ((roomsRenderer && rooms.length > 0) || (dmsRenderer && dms.length > 0) || (numSpaces > 0)) { noResults = false; } + const onScroll = () => { + const body = scrollRef.current?.containerRef.current; + setScrollState({ + scrollTop: body.scrollTop, + height: body.clientHeight, + }); + }; + + const wrappedRef = (body: HTMLDivElement) => { + setScrollState({ + scrollTop: body.scrollTop, + height: body.clientHeight, + }); + }; + + const roomsScrollState = getScrollState(scrollState, rooms.length); + const spacesScrollState = getScrollState(scrollState, numSpaces, rooms.length); + const dmsScrollState = getScrollState(scrollState, dms.length, numSpaces, rooms.length); + return
= ({ onSearch={setQuery} autoFocus={true} /> - + { rooms.length > 0 && roomsRenderer ? ( - roomsRenderer(rooms, selectedToAdd, onChange, truncateAt, overflowTile) + roomsRenderer(rooms, selectedToAdd, roomsScrollState, onChange) ) : undefined } { spaces.length > 0 && spacesRenderer ? ( - spacesRenderer(spaces, selectedToAdd, onChange) + spacesRenderer(spaces, selectedToAdd, spacesScrollState, onChange) ) : null } { dms.length > 0 && dmsRenderer ? ( - dmsRenderer(dms, selectedToAdd, onChange) + dmsRenderer(dms, selectedToAdd, dmsScrollState, onChange) ) : null } { noResults ? @@ -285,15 +327,20 @@ export const AddExistingToSpace: React.FC = ({
; }; -export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = ( - rooms, selectedToAdd, onChange, truncateAt, overflowTile, +const defaultRendererFactory = (title: string): Renderer => ( + rooms, + selectedToAdd, + { scrollTop, height }, + onChange, ) => (
-

{ _t("Rooms") }

- rooms.slice(start, end).map(room => +

{ _t(title) }

+ ( { onChange(checked, room); } : null} - />, + /> )} - getChildCount={() => rooms.length} />
); -export const defaultSpacesRenderer: IAddExistingToSpaceProps["spacesRenderer"] = (spaces, selectedToAdd, onChange) => ( -
- { spaces.map(space => { - return { - onChange(checked, space); - } : null} - />; - }) } -
-); - -export const defaultDmsRenderer: IAddExistingToSpaceProps["dmsRenderer"] = (dms, selectedToAdd, onChange) => ( -
-

{ _t("Direct Messages") }

- { dms.map(room => { - return { - onChange(checked, room); - } : null} - />; - }) } -
-); +export const defaultRoomsRenderer = defaultRendererFactory(_td("Rooms")); +export const defaultSpacesRenderer = defaultRendererFactory(_td("Spaces")); +export const defaultDmsRenderer = defaultRendererFactory(_td("Direct Messages")); interface ISubspaceSelectorProps { title: string; diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index e31317b9e55..d0c3b59d423 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -56,9 +56,9 @@ class EmojiPicker extends React.Component { private readonly memoizedDataByCategory: Record; private readonly categories: ICategory[]; - private bodyRef = React.createRef(); + private scrollRef = React.createRef(); - constructor(props) { + constructor(props: IProps) { super(props); this.state = { @@ -133,7 +133,7 @@ class EmojiPicker extends React.Component { } private onScroll = () => { - const body = this.bodyRef.current; + const body = this.scrollRef.current?.containerRef.current; this.setState({ scrollTop: body.scrollTop, viewportHeight: body.clientHeight, @@ -142,7 +142,7 @@ class EmojiPicker extends React.Component { }; private updateVisibility = () => { - const body = this.bodyRef.current; + const body = this.scrollRef.current?.containerRef.current; const rect = body.getBoundingClientRect(); for (const cat of this.categories) { const elem = body.querySelector(`[data-category-id="${cat.id}"]`); @@ -169,7 +169,8 @@ class EmojiPicker extends React.Component { }; private scrollToCategory = (category: string) => { - this.bodyRef.current.querySelector(`[data-category-id="${category}"]`).scrollIntoView(); + this.scrollRef.current?.containerRef.current + ?.querySelector(`[data-category-id="${category}"]`).scrollIntoView(); }; private onChangeFilter = (filter: string) => { @@ -202,7 +203,8 @@ class EmojiPicker extends React.Component { }; private onEnterFilter = () => { - const btn = this.bodyRef.current.querySelector(".mx_EmojiPicker_item"); + const btn = this.scrollRef.current?.containerRef.current + ?.querySelector(".mx_EmojiPicker_item"); if (btn) { btn.click(); } @@ -241,10 +243,7 @@ class EmojiPicker extends React.Component { { - // @ts-ignore - AutoHideScrollbar should accept a RefObject or fall back to its own instead - this.bodyRef.current = ref; - }} + ref={this.scrollRef} onScroll={this.onScroll} > { this.categories.map(category => { From 9436f3b58d46d49595a4856cbc716e5e87d523d6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Dec 2021 09:56:15 +0000 Subject: [PATCH 009/145] Update room context menu copy (#7361) --- src/components/views/context_menus/RoomContextMenu.tsx | 2 +- src/components/views/rooms/RoomTile.tsx | 6 +++--- src/i18n/strings/en_EN.json | 7 ++----- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/components/views/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx index f5081c4a7ad..0fe7726c621 100644 --- a/src/components/views/context_menus/RoomContextMenu.tsx +++ b/src/components/views/context_menus/RoomContextMenu.tsx @@ -213,7 +213,7 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => { }); onFinished(); }} - label={_t("Copy link")} + label={_t("Copy room link")} iconClassName="mx_RoomTile_iconCopyLink" />; } diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 25603c6e4c1..7cf89de4db9 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -511,13 +511,13 @@ export default class RoomTile extends React.PureComponent { { canInvite ? ( ) : null } { !isDm ? : null } { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6406ce728e3..bae4cc2d7cd 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1809,9 +1809,8 @@ "Favourited": "Favourited", "Favourite": "Favourite", "Low Priority": "Low Priority", - "Invite People": "Invite People", - "Copy Room Link": "Copy Room Link", - "Leave Room": "Leave Room", + "Copy room link": "Copy room link", + "Leave": "Leave", "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", "%(count)s unread messages including mentions.|one": "1 unread mention.", "%(count)s unread messages.|other": "%(count)s unread messages.", @@ -2820,9 +2819,7 @@ "Report": "Report", "View in room": "View in room", "Forget": "Forget", - "Leave": "Leave", "Mentions only": "Mentions only", - "Copy link": "Copy link", "See room timeline (devtools)": "See room timeline (devtools)", "Room": "Room", "Space": "Space", From 46b0865e89d603abed0b36f955ecdbdd90a1227c Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 15 Dec 2021 11:00:10 +0100 Subject: [PATCH 010/145] Add Heading components (#7362) * add Heading components Signed-off-by: Kerry Archibald * remove margins Signed-off-by: Kerry Archibald * snapshots Signed-off-by: Kerry Archibald * unset block and inline margins Signed-off-by: Kerry Archibald * copyright Signed-off-by: Kerry Archibald * fix werid quoting on heading test Signed-off-by: Kerry Archibald --- res/css/_components.scss | 1 + res/css/views/typography/_Heading.scss | 39 +++++++++++++++++++ src/components/views/typography/Heading.tsx | 31 +++++++++++++++ .../views/typography/Heading-test.tsx | 28 +++++++++++++ .../__snapshots__/Heading-test.tsx.snap | 34 ++++++++++++++++ 5 files changed, 133 insertions(+) create mode 100644 res/css/views/typography/_Heading.scss create mode 100644 src/components/views/typography/Heading.tsx create mode 100644 test/components/views/typography/Heading-test.tsx create mode 100644 test/components/views/typography/__snapshots__/Heading-test.tsx.snap diff --git a/res/css/_components.scss b/res/css/_components.scss index 01f8bbece33..674c6487784 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -298,6 +298,7 @@ @import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_IncomingCallToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; +@import "./views/typography/_Heading.scss"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voip/CallView/_CallViewButtons.scss"; @import "./views/voip/_CallContainer.scss"; diff --git a/res/css/views/typography/_Heading.scss b/res/css/views/typography/_Heading.scss new file mode 100644 index 00000000000..9b7ddeaef3f --- /dev/null +++ b/res/css/views/typography/_Heading.scss @@ -0,0 +1,39 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_Heading_h1 { + font-size: $font-32px; + font-weight: $font-semi-bold; + line-height: $font-39px; + margin-inline: unset; + margin-block: unset; +} + +.mx_Heading_h2 { + font-size: $font-24px; + font-weight: $font-semi-bold; + line-height: $font-29px; + margin-inline: unset; + margin-block: unset; +} + +.mx_Heading_h3 { + font-size: $font-18px; + font-weight: $font-semi-bold; + line-height: $font-22px; + margin-inline: unset; + margin-block: unset; +} diff --git a/src/components/views/typography/Heading.tsx b/src/components/views/typography/Heading.tsx new file mode 100644 index 00000000000..069ecb54df2 --- /dev/null +++ b/src/components/views/typography/Heading.tsx @@ -0,0 +1,31 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { HTMLAttributes } from 'react'; +import classNames from 'classnames'; + +type Size = 'h1' | 'h2' | 'h3'; +interface HeadingProps extends HTMLAttributes { + size: Size; +} + +const Heading: React.FC = ({ size, className, children, ...rest }) => React.createElement(size || 'h1', { + ...rest, + className: classNames(`mx_Heading_${size}`, className), + children, +}); + +export default Heading; diff --git a/test/components/views/typography/Heading-test.tsx b/test/components/views/typography/Heading-test.tsx new file mode 100644 index 00000000000..7f8561bfae3 --- /dev/null +++ b/test/components/views/typography/Heading-test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { renderIntoDocument } from 'react-dom/test-utils'; + +import Heading from "../../../../src/components/views/typography/Heading"; +describe('', () => { + const defaultProps = { + size: 'h1', + children:
test
, + ['data-test-id']: 'test', + className: 'test', + } as any; + const getComponent = (props = {}) => { + const wrapper = renderIntoDocument( +
, + ) as HTMLDivElement; + return wrapper.children[0]; + }; + + it('renders h1 with correct attributes', () => { + expect(getComponent({ size: 'h1' })).toMatchSnapshot(); + }); + it('renders h2 with correct attributes', () => { + expect(getComponent({ size: 'h2' })).toMatchSnapshot(); + }); + it('renders h3 with correct attributes', () => { + expect(getComponent({ size: 'h3' })).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/typography/__snapshots__/Heading-test.tsx.snap b/test/components/views/typography/__snapshots__/Heading-test.tsx.snap new file mode 100644 index 00000000000..592ee050e8e --- /dev/null +++ b/test/components/views/typography/__snapshots__/Heading-test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders h1 with correct attributes 1`] = ` +

+
+ test +
+

+`; + +exports[` renders h2 with correct attributes 1`] = ` +

+
+ test +
+

+`; + +exports[` renders h3 with correct attributes 1`] = ` +

+
+ test +
+

+`; From 48a37985f51858cc8de7b9c53dd3e75935134234 Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 15 Dec 2021 10:16:40 +0000 Subject: [PATCH 011/145] Allow slash commands description to wrap on a new line (#7370) --- res/css/views/rooms/_Autocomplete.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/res/css/views/rooms/_Autocomplete.scss b/res/css/views/rooms/_Autocomplete.scss index fcdab37f5a5..fe5d999fa60 100644 --- a/res/css/views/rooms/_Autocomplete.scss +++ b/res/css/views/rooms/_Autocomplete.scss @@ -22,6 +22,7 @@ .mx_Autocomplete_Completion_block { min-height: 34px; display: flex; + flex-wrap: wrap; padding: 0 12px; user-select: none; cursor: pointer; @@ -57,6 +58,7 @@ .mx_Autocomplete_Completion_description { color: gray; + min-width: 150px; } .mx_Autocomplete_Completion_container_pill { From 1e095994473d3a3424696bb5e5f43cb3ac3ae072 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 15 Dec 2021 06:34:47 -0600 Subject: [PATCH 012/145] Add `/jumptodate` slash command (#7372) Fix https://github.com/vector-im/element-web/issues/7677 Utilizing MSC3030: https://github.com/matrix-org/matrix-doc/pull/3030 Experimental Synapse implementation added in https://github.com/matrix-org/synapse/pull/9445 --- Jump to date headers are being worked on in https://github.com/matrix-org/matrix-react-sdk/pull/7339 --- src/SlashCommands.tsx | 45 +++++++++++++++++++ .../tabs/user/LabsUserSettingsTab.tsx | 16 +++++++ src/i18n/strings/en_EN.json | 3 ++ src/settings/Settings.tsx | 9 ++++ 4 files changed, 73 insertions(+) diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index f7435ddda81..0004f786280 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -19,6 +19,7 @@ limitations under the License. import * as React from 'react'; import { User } from "matrix-js-sdk/src/models/user"; +import { Direction } from 'matrix-js-sdk/src/models/event-timeline'; import { EventType } from "matrix-js-sdk/src/@types/event"; import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers'; import { parseFragment as parseHtml, Element as ChildElement } from "parse5"; @@ -286,6 +287,50 @@ export const Commands = [ category: CommandCategories.admin, renderingTypes: [TimelineRenderingType.Room], }), + new Command({ + command: 'jumptodate', + args: '', + description: _td('Jump to the given date in the timeline (YYYY-MM-DD)'), + isEnabled: () => SettingsStore.getValue("feature_jump_to_date"), + runFn: function(roomId, args) { + if (args) { + return success((async () => { + const unixTimestamp = Date.parse(args); + if (!unixTimestamp) { + throw new Error( + // FIXME: Use newTranslatableError here instead + // otherwise the rageshake error messages will be + // translated too + _t( + // eslint-disable-next-line max-len + 'We were unable to understand the given date (%(inputDate)s). Try using the format YYYY-MM-DD.', + { inputDate: args }, + ), + ); + } + + const cli = MatrixClientPeg.get(); + const { event_id: eventId, origin_server_ts: originServerTs } = await cli.timestampToEvent( + roomId, + unixTimestamp, + Direction.Forward, + ); + logger.log( + `/timestamp_to_event: found ${eventId} (${originServerTs}) for timestamp=${unixTimestamp}`, + ); + dis.dispatch({ + action: Action.ViewRoom, + eventId, + highlighted: true, + room_id: roomId, + }); + })()); + } + + return reject(this.getUsage()); + }, + category: CommandCategories.actions, + }), new Command({ command: 'nick', args: '', diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx index d8ecbc2815c..496f2fc1e69 100644 --- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx @@ -49,6 +49,7 @@ export class LabsSettingToggle extends React.Component interface IState { showHiddenReadReceipts: boolean; + showJumpToDate: boolean; } @replaceableComponent("views.settings.tabs.user.LabsUserSettingsTab") @@ -60,8 +61,13 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> { this.setState({ showHiddenReadReceipts }); }); + MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2716").then((showJumpToDate) => { + this.setState({ showJumpToDate }); + }); + this.state = { showHiddenReadReceipts: false, + showJumpToDate: false, }; } @@ -135,6 +141,16 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> { ); } + if (this.state.showJumpToDate) { + groups.getOrCreate(LabGroup.Messaging, []).push( + , + ); + } + labsSection =
{ sortBy(Array.from(groups.entries()), "0").map(([group, flags]) => (
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index bae4cc2d7cd..6cc5f8ca463 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -435,6 +435,8 @@ "Sends a message as html, without interpreting it as markdown": "Sends a message as html, without interpreting it as markdown", "Upgrades a room to a new version": "Upgrades a room to a new version", "You do not have the required permissions to use this command.": "You do not have the required permissions to use this command.", + "Jump to the given date in the timeline (YYYY-MM-DD)": "Jump to the given date in the timeline (YYYY-MM-DD)", + "We were unable to understand the given date (%(inputDate)s). Try using the format YYYY-MM-DD.": "We were unable to understand the given date (%(inputDate)s). Try using the format YYYY-MM-DD.", "Changes your display nickname": "Changes your display nickname", "Changes your display nickname in the current room only": "Changes your display nickname in the current room only", "Changes the avatar of the current room": "Changes the avatar of the current room", @@ -860,6 +862,7 @@ "Meta Spaces": "Meta Spaces", "Use new room breadcrumbs": "Use new room breadcrumbs", "New spotlight search experience": "New spotlight search experience", + "Jump to date (adds /jumptodate)": "Jump to date (adds /jumptodate)", "Don't send read receipts": "Don't send read receipts", "Font size": "Font size", "Use custom size": "Use custom size", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index a86f0535126..ff295484fb9 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -367,6 +367,15 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td("New spotlight search experience"), default: false, }, + "feature_jump_to_date": { + // We purposely leave out `isFeature: true` so it doesn't show in Labs + // by default. We will conditionally show it depending on whether we can + // detect MSC3030 support (see LabUserSettingsTab.tsx). + // labsGroup: LabGroup.Messaging, + displayName: _td("Jump to date (adds /jumptodate)"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "RoomList.backgroundImage": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: null, From 69c4a0cebc6b31e94cf4826860001d3a0854e5fc Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 15 Dec 2021 12:40:56 +0000 Subject: [PATCH 013/145] Fix main timeline pending events bleeding in thread's timeline (#7373) --- src/components/structures/TimelinePanel.tsx | 4 +++- src/components/views/rooms/EventTile.tsx | 2 +- src/stores/notifications/ThreadNotificationState.ts | 5 ++--- src/stores/notifications/ThreadsRoomNotificationState.ts | 9 ++++++++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index ea0492e1fc9..f9b5694fa53 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -1222,9 +1222,11 @@ class TimelinePanel extends React.Component { // should use this list, so that they don't advance into pending events. const liveEvents = [...events]; + const thread = events[0]?.getThread(); + // if we're at the end of the live timeline, append the pending events if (!this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - events.push(...this.props.timelineSet.getPendingEvents()); + events.push(...this.props.timelineSet.getPendingEvents(thread)); } return { diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 80d5e3bbbae..88a2682c905 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -513,7 +513,7 @@ export default class EventTile extends React.Component { const room = this.context.getRoom(this.props.mxEvent.getRoomId()); const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(room); - this.threadState = notifications.threadsState.get(thread); + this.threadState = notifications.getThreadRoomState(thread); this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate); this.onThreadStateUpdate(); diff --git a/src/stores/notifications/ThreadNotificationState.ts b/src/stores/notifications/ThreadNotificationState.ts index aac3153c738..b2f8e264068 100644 --- a/src/stores/notifications/ThreadNotificationState.ts +++ b/src/stores/notifications/ThreadNotificationState.ts @@ -16,7 +16,6 @@ limitations under the License. import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Thread, ThreadEvent } from "matrix-js-sdk/src/models/thread"; -import { Room } from "matrix-js-sdk/src/models/room"; import { NotificationColor } from "./NotificationColor"; import { IDestroyable } from "../../utils/IDestroyable"; @@ -28,7 +27,7 @@ export class ThreadNotificationState extends NotificationState implements IDestr protected _count = 0; protected _color = NotificationColor.None; - constructor(public readonly room: Room, public readonly thread: Thread) { + constructor(public readonly thread: Thread) { super(); this.thread.on(ThreadEvent.NewReply, this.handleNewThreadReply); this.thread.on(ThreadEvent.ViewThread, this.resetThreadNotification); @@ -46,7 +45,7 @@ export class ThreadNotificationState extends NotificationState implements IDestr const myUserId = client.getUserId(); const isOwn = myUserId === event.getSender(); - const readReceipt = this.room.getReadReceiptForUserId(myUserId); + const readReceipt = this.thread.room.getReadReceiptForUserId(myUserId); if (!isOwn && !readReceipt || event.getTs() >= readReceipt.data.ts) { const actions = client.getPushActionsForEvent(event, true); diff --git a/src/stores/notifications/ThreadsRoomNotificationState.ts b/src/stores/notifications/ThreadsRoomNotificationState.ts index bb3bbad716d..9c520873e00 100644 --- a/src/stores/notifications/ThreadsRoomNotificationState.ts +++ b/src/stores/notifications/ThreadsRoomNotificationState.ts @@ -47,8 +47,15 @@ export class ThreadsRoomNotificationState extends NotificationState implements I } } + public getThreadRoomState(thread: Thread): ThreadNotificationState { + if (!this.threadsState.has(thread)) { + this.threadsState.set(thread, new ThreadNotificationState(thread)); + } + return this.threadsState.get(thread); + } + private onNewThread = (thread: Thread): void => { - const notificationState = new ThreadNotificationState(this.room, thread); + const notificationState = new ThreadNotificationState(thread); this.threadsState.set( thread, notificationState, From 43839adec0e637b73b0b7d5232930b8171deaadf Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Wed, 15 Dec 2021 14:26:26 +0000 Subject: [PATCH 014/145] Use constants from js-sdk in polls code (#7381) --- .../context_menus/MessageContextMenu.tsx | 2 +- .../views/dialogs/EndPollDialog.tsx | 3 +- .../views/elements/PollCreateDialog.tsx | 2 +- src/components/views/messages/MPollBody.tsx | 16 ++-- .../views/messages/MessageEvent.tsx | 2 +- src/components/views/rooms/EventTile.tsx | 2 +- .../views/rooms/MessageComposer.tsx | 2 +- src/polls/consts.ts | 78 ------------------- src/stores/room-list/MessagePreviewStore.ts | 2 +- .../previews/PollStartEventPreview.ts | 3 +- src/utils/EventUtils.ts | 2 +- .../views/messages/MPollBody-test.tsx | 10 +-- .../previews/PollStartEventPreview-test.ts | 2 +- 13 files changed, 25 insertions(+), 101 deletions(-) delete mode 100644 src/polls/consts.ts diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 84c61a716de..019691e4c71 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -19,6 +19,7 @@ import React from 'react'; import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; import { Relations } from 'matrix-js-sdk/src/models/relations'; +import { POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import dis from '../../../dispatcher/dispatcher'; @@ -42,7 +43,6 @@ import { IPosition, ChevronFace } from '../../structures/ContextMenu'; import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext'; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore'; -import { POLL_START_EVENT_TYPE } from '../../../polls/consts'; import EndPollDialog from '../dialogs/EndPollDialog'; import { isPollEnded } from '../messages/MPollBody'; diff --git a/src/components/views/dialogs/EndPollDialog.tsx b/src/components/views/dialogs/EndPollDialog.tsx index cf501936393..f3403839b9c 100644 --- a/src/components/views/dialogs/EndPollDialog.tsx +++ b/src/components/views/dialogs/EndPollDialog.tsx @@ -18,11 +18,12 @@ import React from "react"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Relations } from "matrix-js-sdk/src/models/relations"; +import { IPollEndContent, POLL_END_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; +import { TEXT_NODE_TYPE } from "matrix-js-sdk/src/@types/extensible_events"; import { _t } from "../../../languageHandler"; import { IDialogProps } from "./IDialogProps"; import QuestionDialog from "./QuestionDialog"; -import { IPollEndContent, POLL_END_EVENT_TYPE, TEXT_NODE_TYPE } from "../../../polls/consts"; import { findTopAnswer } from "../messages/MPollBody"; import Modal from "../../../Modal"; import ErrorDialog from "./ErrorDialog"; diff --git a/src/components/views/elements/PollCreateDialog.tsx b/src/components/views/elements/PollCreateDialog.tsx index 4e1e76d23f9..1f442c93d80 100644 --- a/src/components/views/elements/PollCreateDialog.tsx +++ b/src/components/views/elements/PollCreateDialog.tsx @@ -16,6 +16,7 @@ limitations under the License. import React, { ChangeEvent, createRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; +import { makePollContent, POLL_KIND_DISCLOSED, POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; import ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal"; import { IDialogProps } from "../dialogs/IDialogProps"; @@ -25,7 +26,6 @@ import { _t } from "../../../languageHandler"; import { arrayFastClone, arraySeed } from "../../../utils/arrays"; import Field from "./Field"; import AccessibleButton from "./AccessibleButton"; -import { makePollContent, POLL_KIND_DISCLOSED, POLL_START_EVENT_TYPE } from "../../../polls/consts"; import Spinner from "./Spinner"; interface IProps extends IDialogProps { diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index 10f43938edf..51c895414ba 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -19,12 +19,7 @@ import classNames from 'classnames'; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Relations } from 'matrix-js-sdk/src/models/relations'; import { MatrixClient } from 'matrix-js-sdk/src/matrix'; - -import { _t } from '../../../languageHandler'; -import { replaceableComponent } from "../../../utils/replaceableComponent"; -import Modal from '../../../Modal'; -import { IBodyProps } from "./IBodyProps"; -import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; +import { TEXT_NODE_TYPE } from "matrix-js-sdk/src/@types/extensible_events"; import { IPollAnswer, IPollContent, @@ -32,8 +27,13 @@ import { POLL_END_EVENT_TYPE, POLL_RESPONSE_EVENT_TYPE, POLL_START_EVENT_TYPE, - TEXT_NODE_TYPE, -} from '../../../polls/consts'; +} from "matrix-js-sdk/src/@types/polls"; + +import { _t } from '../../../languageHandler'; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Modal from '../../../Modal'; +import { IBodyProps } from "./IBodyProps"; +import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import StyledRadioButton from '../elements/StyledRadioButton'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import ErrorDialog from '../dialogs/ErrorDialog'; diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 2cd23779d37..c40c9ff2a01 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -17,6 +17,7 @@ limitations under the License. import React, { createRef } from 'react'; import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; import { Relations } from 'matrix-js-sdk/src/models/relations'; +import { POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; import * as sdk from '../../../index'; import SettingsStore from "../../../settings/SettingsStore"; @@ -29,7 +30,6 @@ import { IOperableEventTile } from "../context_menus/MessageContextMenu"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { ReactAnyComponent } from "../../../@types/common"; import { IBodyProps } from "./IBodyProps"; -import { POLL_START_EVENT_TYPE } from '../../../polls/consts'; // onMessageAllowed is handled internally interface IProps extends Omit { diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 88a2682c905..2db9b311eae 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -24,6 +24,7 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; import { logger } from "matrix-js-sdk/src/logger"; import { NotificationCountType } from 'matrix-js-sdk/src/models/room'; +import { POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; import ReplyChain from "../elements/ReplyChain"; import { _t } from '../../../languageHandler'; @@ -65,7 +66,6 @@ import { MessagePreviewStore } from '../../../stores/room-list/MessagePreviewSto import { TimelineRenderingType } from "../../../contexts/RoomContext"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import Toolbar from '../../../accessibility/Toolbar'; -import { POLL_START_EVENT_TYPE } from '../../../polls/consts'; import { RovingAccessibleTooltipButton } from '../../../accessibility/roving/RovingAccessibleTooltipButton'; import { RovingThreadListContextMenu } from '../context_menus/ThreadListContextMenu'; import { ThreadNotificationState } from '../../../stores/notifications/ThreadNotificationState'; diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 7af913763b0..3698f87d2e9 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -21,6 +21,7 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RelationType } from 'matrix-js-sdk/src/@types/event'; import { MsgType } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; +import { POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -56,7 +57,6 @@ import LocationPicker from '../location/LocationPicker'; import UIStore, { UI_EVENTS } from '../../../stores/UIStore'; import Modal from "../../../Modal"; import RoomContext from '../../../contexts/RoomContext'; -import { POLL_START_EVENT_TYPE } from "../../../polls/consts"; import ErrorDialog from "../dialogs/ErrorDialog"; import PollCreateDialog from "../elements/PollCreateDialog"; import LocationShareType from "../location/LocationShareType"; diff --git a/src/polls/consts.ts b/src/polls/consts.ts deleted file mode 100644 index dfb56c0fe8f..00000000000 --- a/src/polls/consts.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* -Copyright 2021 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 { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; -import { IContent } from "matrix-js-sdk/src/models/event"; - -export const POLL_START_EVENT_TYPE = new UnstableValue("m.poll.start", "org.matrix.msc3381.poll.start"); -export const POLL_RESPONSE_EVENT_TYPE = new UnstableValue("m.poll.response", "org.matrix.msc3381.poll.response"); -export const POLL_END_EVENT_TYPE = new UnstableValue("m.poll.end", "org.matrix.msc3381.poll.end"); -export const POLL_KIND_DISCLOSED = new UnstableValue("m.poll.disclosed", "org.matrix.msc3381.poll.disclosed"); -export const POLL_KIND_UNDISCLOSED = new UnstableValue("m.poll.undisclosed", "org.matrix.msc3381.poll.undisclosed"); - -// TODO: [TravisR] Use extensible events library when ready -export const TEXT_NODE_TYPE = new UnstableValue("m.text", "org.matrix.msc1767.text"); - -export interface IPollAnswer extends IContent { - id: string; - [TEXT_NODE_TYPE.name]: string; -} - -export interface IPollContent extends IContent { - [POLL_START_EVENT_TYPE.name]: { - kind: string; // disclosed or undisclosed (untypeable for now) - question: { - [TEXT_NODE_TYPE.name]: string; - }; - answers: IPollAnswer[]; - }; - [TEXT_NODE_TYPE.name]: string; -} - -export interface IPollResponseContent extends IContent { - [POLL_RESPONSE_EVENT_TYPE.name]: { - answers: string[]; - }; - "m.relates_to": { - "event_id": string; - "rel_type": string; - }; -} - -export interface IPollEndContent extends IContent { - [POLL_END_EVENT_TYPE.name]: {}; - "m.relates_to": { - "event_id": string; - "rel_type": string; - }; -} - -export function makePollContent(question: string, answers: string[], kind: string): IPollContent { - question = question.trim(); - answers = answers.map(a => a.trim()).filter(a => !!a); - return { - [TEXT_NODE_TYPE.name]: `${question}\n${answers.map((a, i) => `${i + 1}. ${a}`).join('\n')}`, - [POLL_START_EVENT_TYPE.name]: { - kind: kind, - question: { - [TEXT_NODE_TYPE.name]: question, - }, - answers: answers.map( - (a, i) => ({ id: `${i}-${a}`, [TEXT_NODE_TYPE.name]: a }), - ), - }, - }; -} diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts index 45e98dc33f0..edf7b3cc317 100644 --- a/src/stores/room-list/MessagePreviewStore.ts +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -17,6 +17,7 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; import { ActionPayload } from "../../dispatcher/payloads"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; @@ -30,7 +31,6 @@ import { CallHangupEvent } from "./previews/CallHangupEvent"; import { StickerEventPreview } from "./previews/StickerEventPreview"; import { ReactionEventPreview } from "./previews/ReactionEventPreview"; import { UPDATE_EVENT } from "../AsyncStore"; -import { POLL_START_EVENT_TYPE } from "../../polls/consts"; import SettingsStore from "../../settings/SettingsStore"; // Emitted event for when a room's preview has changed. First argument will the room for which diff --git a/src/stores/room-list/previews/PollStartEventPreview.ts b/src/stores/room-list/previews/PollStartEventPreview.ts index ff78258d3b3..b473916bf83 100644 --- a/src/stores/room-list/previews/PollStartEventPreview.ts +++ b/src/stores/room-list/previews/PollStartEventPreview.ts @@ -15,12 +15,13 @@ limitations under the License. */ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; +import { TEXT_NODE_TYPE } from "matrix-js-sdk/src/@types/extensible_events"; import { IPreview } from "./IPreview"; import { TagID } from "../models"; import { _t, sanitizeForTranslation } from "../../../languageHandler"; import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; -import { POLL_START_EVENT_TYPE, TEXT_NODE_TYPE } from "../../../polls/consts"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; export class PollStartEventPreview implements IPreview { diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index a2773ab0443..5a7332301b9 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -19,12 +19,12 @@ import { EventType, MsgType, RelationType } from "matrix-js-sdk/src/@types/event import { MatrixClient } from 'matrix-js-sdk/src/client'; import { Thread } from 'matrix-js-sdk/src/models/thread'; import { logger } from 'matrix-js-sdk/src/logger'; +import { POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; import { MatrixClientPeg } from '../MatrixClientPeg'; import shouldHideEvent from "../shouldHideEvent"; import { getHandlerTile, haveTileForEvent } from "../components/views/rooms/EventTile"; import SettingsStore from "../settings/SettingsStore"; -import { POLL_START_EVENT_TYPE } from '../polls/consts'; /** * Returns whether an event should allow actions like reply, reactions, edit, etc. diff --git a/test/components/views/messages/MPollBody-test.tsx b/test/components/views/messages/MPollBody-test.tsx index ff62c26c714..fa8f1239306 100644 --- a/test/components/views/messages/MPollBody-test.tsx +++ b/test/components/views/messages/MPollBody-test.tsx @@ -19,16 +19,16 @@ import { mount, ReactWrapper } from "enzyme"; import { Callback, IContent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk"; import { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import { Relations } from "matrix-js-sdk/src/models/relations"; - -import * as TestUtils from "../../../test-utils"; -import sdk from "../../../skinned-sdk"; import { IPollAnswer, IPollContent, POLL_END_EVENT_TYPE, POLL_RESPONSE_EVENT_TYPE, - TEXT_NODE_TYPE, -} from "../../../../src/polls/consts"; +} from "matrix-js-sdk/src/@types/polls"; +import { TEXT_NODE_TYPE } from "matrix-js-sdk/src/@types/extensible_events"; + +import * as TestUtils from "../../../test-utils"; +import sdk from "../../../skinned-sdk"; import { UserVote, allVotes, diff --git a/test/stores/room-list/previews/PollStartEventPreview-test.ts b/test/stores/room-list/previews/PollStartEventPreview-test.ts index d1df55685aa..ed4e8cb8f17 100644 --- a/test/stores/room-list/previews/PollStartEventPreview-test.ts +++ b/test/stores/room-list/previews/PollStartEventPreview-test.ts @@ -15,8 +15,8 @@ limitations under the License. */ import { MatrixEvent } from "matrix-js-sdk"; +import { IPollAnswer } from "matrix-js-sdk/src/@types/polls"; -import { IPollAnswer } from "../../../../src/polls/consts"; import { PollStartEventPreview } from "../../../../src/stores/room-list/previews/PollStartEventPreview"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; From b174cc8963d1544d64b64c3244db579d46fd2dcd Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 15 Dec 2021 17:05:58 +0100 Subject: [PATCH 015/145] Use semantic heading on dialog component (#7383) * use semantic heading on dialog Signed-off-by: Kerry Archibald * tidy styles Signed-off-by: Kerry Archibald * un-flex text centering Signed-off-by: Kerry Archibald --- res/css/_common.scss | 12 +++++++----- res/css/views/dialogs/_SettingsDialog.scss | 4 ---- src/components/views/dialogs/BaseDialog.tsx | 5 +++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/res/css/_common.scss b/res/css/_common.scss index e4a5f8ddd06..b153d2c3cb8 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -355,6 +355,7 @@ legend { .mx_Dialog_header { position: relative; + padding: 3px 0; margin-bottom: 10px; } @@ -364,20 +365,21 @@ legend { height: 25px; margin-left: -2px; margin-right: 4px; + margin-bottom: 2px; } .mx_Dialog_title { - font-size: $font-22px; - font-weight: $font-semi-bold; - line-height: $font-36px; color: $dialog-title-fg-color; + display: inline-block; + width: 100%; + box-sizing: border-box; } .mx_Dialog_header.mx_Dialog_headerWithButton > .mx_Dialog_title { text-align: center; } -.mx_Dialog_header.mx_Dialog_headerWithCancel > .mx_Dialog_title { - margin-right: 20px; // leave space for the 'X' cancel button +.mx_Dialog_header.mx_Dialog_headerWithCancel { + padding-right: 20px; // leave space for the 'X' cancel button } .mx_Dialog_title.danger { diff --git a/res/css/views/dialogs/_SettingsDialog.scss b/res/css/views/dialogs/_SettingsDialog.scss index f9b373c30a1..59aed520fd7 100644 --- a/res/css/views/dialogs/_SettingsDialog.scss +++ b/res/css/views/dialogs/_SettingsDialog.scss @@ -36,8 +36,4 @@ limitations under the License. // colliding harshly with the dialog when scrolled down. padding-bottom: 100px; } - - .mx_Dialog_title { - margin-bottom: 24px; - } } diff --git a/src/components/views/dialogs/BaseDialog.tsx b/src/components/views/dialogs/BaseDialog.tsx index 15c9114ca2f..52773c13b93 100644 --- a/src/components/views/dialogs/BaseDialog.tsx +++ b/src/components/views/dialogs/BaseDialog.tsx @@ -27,6 +27,7 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from "../../../languageHandler"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Heading from '../typography/Heading'; import { IDialogProps } from "./IDialogProps"; interface IProps extends IDialogProps { @@ -141,10 +142,10 @@ export default class BaseDialog extends React.Component { 'mx_Dialog_headerWithButton': !!this.props.headerButton, 'mx_Dialog_headerWithCancel': !!cancelButton, })}> -
+ { headerImage } { this.props.title } -
+ { this.props.headerButton } { cancelButton }
From 3c9c82ee0d6df9a0a780ddc78230cfcd46e21241 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Dec 2021 16:15:09 +0000 Subject: [PATCH 016/145] Fix narrow mode composer buttons for polls labs (#7386) --- .../views/rooms/MessageComposer.tsx | 120 ++++++++++-------- src/i18n/strings/en_EN.json | 1 - 2 files changed, 67 insertions(+), 54 deletions(-) diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 3698f87d2e9..bf95f722e4d 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -13,7 +13,7 @@ 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, { createRef } from 'react'; +import React, { ComponentProps, createRef } from 'react'; import classNames from 'classnames'; import { MatrixEvent, IEventRelation } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; @@ -80,10 +80,22 @@ function SendButton(props: ISendButtonProps) { ); } -interface IEmojiButtonProps { +interface ICollapsibleButtonProps extends ComponentProps { + narrowMode: boolean; + title: string; +} + +const CollapsibleButton = ({ narrowMode, title, ...props }: ICollapsibleButtonProps) => { + return ; +}; + +interface IEmojiButtonProps extends Pick { addEmoji: (unicode: string) => boolean; menuPosition: AboveLeftOf; - narrowMode: boolean; } const EmojiButton: React.FC = ({ addEmoji, menuPosition, narrowMode }) => { @@ -108,18 +120,18 @@ const EmojiButton: React.FC = ({ addEmoji, menuPosition, narr // TODO: replace ContextMenuTooltipButton with a unified representation of // the header buttons and the right panel buttons return - { contextMenu } ; }; -interface ILocationButtonProps { +interface ILocationButtonProps extends Pick { room: Room; shareLocation: (uri: string, ts: number, type: LocationShareType, description: string) => boolean; menuPosition: AboveLeftOf; @@ -148,11 +160,11 @@ const LocationButton: React.FC = ({ shareLocation, menuPos // TODO: replace ContextMenuTooltipButton with a unified representation of // the header buttons and the right panel buttons return - { contextMenu } @@ -233,7 +245,7 @@ class UploadButton extends React.Component { } } -interface IPollButtonProps { +interface IPollButtonProps extends Pick { room: Room; } @@ -265,10 +277,11 @@ class PollButton extends React.PureComponent { render() { return ( - ); } @@ -567,16 +580,12 @@ export default class MessageComposer extends React.Component { if (!this.state.haveRecording) { if (this.state.showPollsButton) { buttons.push( - , + , ); } uploadButtonIndex = buttons.length; buttons.push( - , + , ); if (SettingsStore.getValue("feature_location_share")) { buttons.push( @@ -610,53 +619,58 @@ export default class MessageComposer extends React.Component { />, ); } + + // XXX: the recording UI does not work well in narrow mode, so we hide this button for now if (!this.state.haveRecording && !this.state.narrowMode) { buttons.push( - this.voiceRecordingButton.current?.onRecordStartEndClick()} title={_t("Send voice message")} + narrowMode={this.state.narrowMode} />, ); } if (!this.state.narrowMode) { return buttons; - } else { - const classnames = classNames({ - mx_MessageComposer_button: true, - mx_MessageComposer_buttonMenu: true, - mx_MessageComposer_closeButtonMenu: this.state.isMenuOpen, - }); - - return <> - { buttons[uploadButtonIndex] } - - { this.state.isMenuOpen && ( - - { buttons.slice(1).map((button, index) => ( - - { button } - - )) } - - ) } - ; } + + const classnames = classNames({ + mx_MessageComposer_button: true, + mx_MessageComposer_buttonMenu: true, + mx_MessageComposer_closeButtonMenu: this.state.isMenuOpen, + }); + + // we render the uploadButton at top level as it is a very common interaction, splice it out of the rest + const [uploadButton] = buttons.splice(uploadButtonIndex, 1); + return <> + { uploadButton } + + { this.state.isMenuOpen && ( + + { buttons.map((button, index) => ( + + { button } + + )) } + + ) } + ; } render() { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6cc5f8ca463..363ff13427b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1649,7 +1649,6 @@ "Filter room members": "Filter room members", "%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)", "Send message": "Send message", - "Emoji picker": "Emoji picker", "Add emoji": "Add emoji", "Share location": "Share location", "Upload file": "Upload file", From 7857bf2da1a14aa5d0f42b6b36cecab216d3dd68 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Dec 2021 16:21:32 +0000 Subject: [PATCH 017/145] Prevent escape to cancel edit from also scrolling to bottom (#7380) --- src/components/views/rooms/EditMessageComposer.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index f216a549fe2..36e2e5bf2d7 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -160,9 +160,11 @@ class EditMessageComposer extends React.Component Date: Wed, 15 Dec 2021 16:21:51 +0000 Subject: [PATCH 018/145] Add internationalisation to progress strings in room export dialog (#7385) --- src/components/views/dialogs/ExportDialog.tsx | 2 +- src/i18n/strings/en_EN.json | 17 +++++++++++++++ src/utils/exportUtils/Exporter.ts | 21 ++++++++++++------- src/utils/exportUtils/HtmlExport.tsx | 21 +++++++++++++------ src/utils/exportUtils/JSONExport.ts | 6 +++++- src/utils/exportUtils/PlainTextExport.ts | 11 ++++++---- 6 files changed, 59 insertions(+), 19 deletions(-) diff --git a/src/components/views/dialogs/ExportDialog.tsx b/src/components/views/dialogs/ExportDialog.tsx index e3d2b437cf2..4bbd68071ac 100644 --- a/src/components/views/dialogs/ExportDialog.tsx +++ b/src/components/views/dialogs/ExportDialog.tsx @@ -53,7 +53,7 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { const [sizeLimit, setSizeLimit] = useState(8); const sizeLimitRef = useRef(); const messageCountRef = useRef(); - const [exportProgressText, setExportProgressText] = useState("Processing..."); + const [exportProgressText, setExportProgressText] = useState(_t("Processing...")); const [displayCancel, setCancelWarning] = useState(false); const [exportCancelled, setExportCancelled] = useState(false); const [exportSuccessful, setExportSuccessful] = useState(false); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 363ff13427b..2851a8ce5f2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -740,6 +740,11 @@ "Share your public space": "Share your public space", "Unknown App": "Unknown App", "Are you sure you want to exit during this export?": "Are you sure you want to exit during this export?", + "Generating a ZIP": "Generating a ZIP", + "Fetched %(count)s events out of %(total)s|other": "Fetched %(count)s events out of %(total)s", + "Fetched %(count)s events out of %(total)s|one": "Fetched %(count)s event out of %(total)s", + "Fetched %(count)s events so far|other": "Fetched %(count)s events so far", + "Fetched %(count)s events so far|one": "Fetched %(count)s event so far", "HTML": "HTML", "JSON": "JSON", "Plain Text": "Plain Text", @@ -752,7 +757,18 @@ "This is the start of export of . Exported by at %(exportDate)s.": "This is the start of export of . Exported by at %(exportDate)s.", "Topic: %(topic)s": "Topic: %(topic)s", "Error fetching file": "Error fetching file", + "Processing event %(number)s out of %(total)s": "Processing event %(number)s out of %(total)s", + "Starting export...": "Starting export...", + "Fetched %(count)s events in %(seconds)ss|other": "Fetched %(count)s events in %(seconds)ss", + "Fetched %(count)s events in %(seconds)ss|one": "Fetched %(count)s event in %(seconds)ss", + "Creating HTML...": "Creating HTML...", + "Export successful!": "Export successful!", + "Exported %(count)s events in %(seconds)s seconds|other": "Exported %(count)s events in %(seconds)s seconds", + "Exported %(count)s events in %(seconds)s seconds|one": "Exported %(count)s event in %(seconds)s seconds", "File Attached": "File Attached", + "Starting export process...": "Starting export process...", + "Fetching events...": "Fetching events...", + "Creating output...": "Creating output...", "Enable": "Enable", "That's fine": "That's fine", "Stop": "Stop", @@ -2477,6 +2493,7 @@ "End Poll": "End Poll", "Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.": "Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.", "An error has occurred.": "An error has occurred.", + "Processing...": "Processing...", "Enter a number between %(min)s and %(max)s": "Enter a number between %(min)s and %(max)s", "Size can only be a number between %(min)s MB and %(max)s MB": "Size can only be a number between %(min)s MB and %(max)s MB", "Number of messages can only be a number between %(min)s and %(max)s": "Number of messages can only be a number between %(min)s and %(max)s", diff --git a/src/utils/exportUtils/Exporter.ts b/src/utils/exportUtils/Exporter.ts index 336304ceab7..3025be64928 100644 --- a/src/utils/exportUtils/Exporter.ts +++ b/src/utils/exportUtils/Exporter.ts @@ -22,7 +22,7 @@ import { saveAs } from "file-saver"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixClientPeg } from "../../MatrixClientPeg"; -import { IExportOptions, ExportType } from "./exportUtils"; +import { ExportType, IExportOptions } from "./exportUtils"; import { decryptFile } from "../DecryptFile"; import { mediaFromContent } from "../../customisations/Media"; import { formatFullDateNoDay } from "../../DateUtils"; @@ -83,7 +83,7 @@ export default abstract class Exporter { const zip = new JSZip(); // Create a writable stream to the directory - if (!this.cancelled) this.updateProgress("Generating a ZIP"); + if (!this.cancelled) this.updateProgress(_t("Generating a ZIP")); else return this.cleanUp(); for (const file of this.files) zip.file(filenameWithoutExt + "/" + file.name, file.blob); @@ -172,11 +172,18 @@ export default abstract class Exporter { // } events.push(mxEv); } - this.updateProgress( - ("Fetched " + events.length + " events ") + (this.exportType === ExportType.LastNMessages - ? `out of ${this.exportOptions.numberOfMessages}` - : "so far"), - ); + + if (this.exportType === ExportType.LastNMessages) { + this.updateProgress(_t("Fetched %(count)s events out of %(total)s", { + count: events.length, + total: this.exportOptions.numberOfMessages, + })); + } else { + this.updateProgress(_t("Fetched %(count)s events so far", { + count: events.length, + })); + } + prevToken = res.end; } // Reverse the events so that we preserve the order diff --git a/src/utils/exportUtils/HtmlExport.tsx b/src/utils/exportUtils/HtmlExport.tsx index a39bced5d63..09b546bf2f5 100644 --- a/src/utils/exportUtils/HtmlExport.tsx +++ b/src/utils/exportUtils/HtmlExport.tsx @@ -395,7 +395,10 @@ export default class HTMLExporter extends Exporter { let prevEvent = null; for (let i = start; i < Math.min(start + 1000, events.length); i++) { const event = events[i]; - this.updateProgress(`Processing event ${i + 1} out of ${events.length}`, false, true); + this.updateProgress(_t("Processing event %(number)s out of %(total)s", { + number: i + 1, + total: events.length, + }), false, true); if (this.cancelled) return this.cleanUp(); if (!haveTileForEvent(event)) continue; @@ -411,15 +414,18 @@ export default class HTMLExporter extends Exporter { } public async export() { - this.updateProgress("Starting export..."); + this.updateProgress(_t("Starting export...")); const fetchStart = performance.now(); const res = await this.getRequiredEvents(); const fetchEnd = performance.now(); - this.updateProgress(`Fetched ${res.length} events in ${(fetchEnd - fetchStart)/1000}s`, true, false); + this.updateProgress(_t("Fetched %(count)s events in %(seconds)ss", { + count: res.length, + seconds: (fetchEnd - fetchStart) / 1000, + }), true, false); - this.updateProgress("Creating HTML..."); + this.updateProgress(_t("Creating HTML...")); const usedClasses = new Set(); for (let page = 0; page < res.length / 1000; page++) { @@ -442,8 +448,11 @@ export default class HTMLExporter extends Exporter { if (this.cancelled) { logger.info("Export cancelled successfully"); } else { - this.updateProgress("Export successful!"); - this.updateProgress(`Exported ${res.length} events in ${(exportEnd - fetchStart)/1000} seconds`); + this.updateProgress(_t("Export successful!")); + this.updateProgress(_t("Exported %(count)s events in %(seconds)s seconds", { + count: res.length, + seconds: (exportEnd - fetchStart) / 1000, + })); } this.cleanUp(); diff --git a/src/utils/exportUtils/JSONExport.ts b/src/utils/exportUtils/JSONExport.ts index 96e2545827d..27fdb5a8478 100644 --- a/src/utils/exportUtils/JSONExport.ts +++ b/src/utils/exportUtils/JSONExport.ts @@ -23,6 +23,7 @@ import Exporter from "./Exporter"; import { formatFullDateNoDay, formatFullDateNoDayNoTime } from "../../DateUtils"; import { haveTileForEvent } from "../../components/views/rooms/EventTile"; import { ExportType, IExportOptions } from "./exportUtils"; +import { _t } from "../../languageHandler"; export default class JSONExporter extends Exporter { protected totalSize = 0; @@ -79,7 +80,10 @@ export default class JSONExporter extends Exporter { protected async createOutput(events: MatrixEvent[]) { for (let i = 0; i < events.length; i++) { const event = events[i]; - this.updateProgress(`Processing event ${i + 1} out of ${events.length}`, false, true); + this.updateProgress(_t("Processing event %(number)s out of %(total)s", { + number: i + 1, + total: events.length, + }), false, true); if (this.cancelled) return this.cleanUp(); if (!haveTileForEvent(event)) continue; this.messages.push(await this.getJSONString(event)); diff --git a/src/utils/exportUtils/PlainTextExport.ts b/src/utils/exportUtils/PlainTextExport.ts index 9eb4eca7b8c..edabb80fe02 100644 --- a/src/utils/exportUtils/PlainTextExport.ts +++ b/src/utils/exportUtils/PlainTextExport.ts @@ -107,7 +107,10 @@ export default class PlainTextExporter extends Exporter { let content = ""; for (let i = 0; i < events.length; i++) { const event = events[i]; - this.updateProgress(`Processing event ${i + 1} out of ${events.length}`, false, true); + this.updateProgress(_t("Processing event %(number)s out of %(total)s", { + number: i + 1, + total: events.length, + }), false, true); if (this.cancelled) return this.cleanUp(); if (!haveTileForEvent(event)) continue; const textForEvent = await this.plainTextForEvent(event); @@ -117,8 +120,8 @@ export default class PlainTextExporter extends Exporter { } public async export() { - this.updateProgress("Starting export process..."); - this.updateProgress("Fetching events..."); + this.updateProgress(_t("Starting export process...")); + this.updateProgress(_t("Fetching events...")); const fetchStart = performance.now(); const res = await this.getRequiredEvents(); @@ -126,7 +129,7 @@ export default class PlainTextExporter extends Exporter { logger.log(`Fetched ${res.length} events in ${(fetchEnd - fetchStart)/1000}s`); - this.updateProgress("Creating output..."); + this.updateProgress(_t("Creating output...")); const text = await this.createOutput(res); if (this.files.length) { From d9da2581b474f0e9cf962e88e12f5a6dc839f24b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Dec 2021 16:22:17 +0000 Subject: [PATCH 019/145] Pin qrcode to fix e2e verification bug (#7378) --- package.json | 2 +- yarn.lock | 72 +++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index e2fe0aefa26..f75f2871a16 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "png-chunks-extract": "^1.0.0", "posthog-js": "1.12.2", "prop-types": "^15.7.2", - "qrcode": "^1.4.4", + "qrcode": "1.4.4", "re-resizable": "^6.9.0", "react": "17.0.2", "react-beautiful-dnd": "^13.1.0", diff --git a/yarn.lock b/yarn.lock index b239b678baa..5b82e342255 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2461,6 +2461,11 @@ base-x@^3.0.2: dependencies: safe-buffer "^5.0.1" +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + base@^0.11.1: version "0.11.2" resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" @@ -2582,11 +2587,37 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -buffer-from@^1.0.0: +buffer-alloc-unsafe@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" + integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== + +buffer-alloc@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" + integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== + dependencies: + buffer-alloc-unsafe "^1.1.0" + buffer-fill "^1.0.0" + +buffer-fill@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" + integrity sha1-+PeLdniYiO858gXNY39o5wISKyw= + +buffer-from@^1.0.0, buffer-from@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buffer@^5.4.3: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -3371,11 +3402,6 @@ emojis-list@^3.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== -encode-utf8@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" - integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== - encoding@^0.1.11: version "0.1.13" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" @@ -4589,7 +4615,7 @@ iconv-lite@^0.6.2: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -ieee754@^1.1.12: +ieee754@^1.1.12, ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -5094,7 +5120,7 @@ isarray@1.0.0, isarray@~1.0.0: resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= -isarray@^2.0.5: +isarray@^2.0.1, isarray@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== @@ -6045,6 +6071,7 @@ mathml-tag-names@^2.1.3: browser-request "^0.3.3" bs58 "^4.0.1" content-type "^1.0.4" + eslint-plugin-import "^2.25.2" loglevel "^1.7.1" p-retry "^4.5.0" qs "^6.9.6" @@ -6837,10 +6864,10 @@ png-chunks-extract@^1.0.0: dependencies: crc-32 "^0.3.0" -pngjs@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" - integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== +pngjs@^3.3.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f" + integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w== posix-character-classes@^0.1.0: version "0.1.1" @@ -7039,15 +7066,18 @@ pvutils@latest: resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.0.17.tgz#ade3c74dfe7178944fe44806626bd2e249d996bf" integrity sha512-wLHYUQxWaXVQvKnwIDWFVKDJku9XDCvyhhxoq8dc5MFdIlRenyPI9eSfEtcvgHgD7FlvCyGAlWgOzRnZD99GZQ== -qrcode@^1.4.4: - version "1.5.0" - resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.0.tgz#95abb8a91fdafd86f8190f2836abbfc500c72d1b" - integrity sha512-9MgRpgVc+/+47dFvQeD6U2s0Z92EsKzcHogtum4QB+UNd025WOJSHvn/hjk9xmzj7Stj95CyUAs31mrjxliEsQ== +qrcode@1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.4.4.tgz#f0c43568a7e7510a55efc3b88d9602f71963ea83" + integrity sha512-oLzEC5+NKFou9P0bMj5+v6Z40evexeE29Z9cummZXZ9QXyMr3lphkURzxjXgPJC5azpxcshoDWV1xE46z+/c3Q== dependencies: + buffer "^5.4.3" + buffer-alloc "^1.2.0" + buffer-from "^1.1.1" dijkstrajs "^1.0.1" - encode-utf8 "^1.0.3" - pngjs "^5.0.0" - yargs "^15.3.1" + isarray "^2.0.1" + pngjs "^3.3.0" + yargs "^13.2.4" qs@^6.9.6: version "6.10.2" @@ -8944,7 +8974,7 @@ yargs-parser@^21.0.0: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.0.0.tgz#a485d3966be4317426dd56bdb6a30131b281dc55" integrity sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA== -yargs@^13.3.0: +yargs@^13.2.4, yargs@^13.3.0: version "13.3.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== @@ -8960,7 +8990,7 @@ yargs@^13.3.0: y18n "^4.0.0" yargs-parser "^13.1.2" -yargs@^15.3.1, yargs@^15.4.1: +yargs@^15.4.1: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== From 71b561d471637d015690a1a710be3cf400aa400b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Dec 2021 16:27:02 +0000 Subject: [PATCH 020/145] Make compact layout only apply to Modern layout (#7382) --- src/components/structures/LoggedInView.tsx | 7 +++++-- src/components/views/elements/Field.tsx | 4 ++-- .../views/settings/tabs/user/AppearanceUserSettingsTab.tsx | 7 +------ src/settings/Settings.tsx | 1 + src/settings/controllers/IncompatibleController.ts | 5 ++++- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 70c0346c498..e7fa353e51a 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -147,6 +147,7 @@ class LoggedInView extends React.Component { protected readonly _roomView: React.RefObject; protected readonly _resizeContainer: React.RefObject; protected readonly resizeHandler: React.RefObject; + protected layoutWatcherRef: string; protected compactLayoutWatcherRef: string; protected backgroundImageWatcherRef: string; protected resizer: Resizer; @@ -190,6 +191,7 @@ class LoggedInView extends React.Component { ); this._matrixClient.on("RoomState.events", this.onRoomStateEvents); + this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onCompactLayoutChanged); this.compactLayoutWatcherRef = SettingsStore.watchSetting( "useCompactLayout", null, this.onCompactLayoutChanged, ); @@ -212,6 +214,7 @@ class LoggedInView extends React.Component { this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); OwnProfileStore.instance.off(UPDATE_EVENT, this.refreshBackgroundImage); + SettingsStore.unwatchSetting(this.layoutWatcherRef); SettingsStore.unwatchSetting(this.compactLayoutWatcherRef); SettingsStore.unwatchSetting(this.backgroundImageWatcherRef); this.resizer.detach(); @@ -295,9 +298,9 @@ class LoggedInView extends React.Component { } }; - onCompactLayoutChanged = (setting, roomId, level, valueAtLevel, newValue) => { + private onCompactLayoutChanged = () => { this.setState({ - useCompactLayout: valueAtLevel, + useCompactLayout: SettingsStore.getValue("useCompactLayout"), }); }; diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 85f397834f2..1dedf77a8b9 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -230,7 +230,7 @@ export default class Field extends React.PureComponent { /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ const { element, prefixComponent, postfixComponent, className, onValidate, children, tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus, - usePlaceholderAsHint, + usePlaceholderAsHint, forceTooltipVisible, ...inputProps } = this.props; // Set some defaults for the element @@ -276,7 +276,7 @@ export default class Field extends React.PureComponent { if (tooltipContent || this.state.feedback) { fieldTooltip = ; diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index 8249f2f6152..aba8ef0340d 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -118,12 +118,7 @@ export default class AppearanceUserSettingsTab extends React.Component - + { !SettingsStore.getValue("feature_new_layout_switcher") ? v !== Layout.Group), }, "showRedactions": { supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM, diff --git a/src/settings/controllers/IncompatibleController.ts b/src/settings/controllers/IncompatibleController.ts index aa064219f94..3621a736a43 100644 --- a/src/settings/controllers/IncompatibleController.ts +++ b/src/settings/controllers/IncompatibleController.ts @@ -27,7 +27,7 @@ export default class IncompatibleController extends SettingController { public constructor( private settingName: string, private forcedValue: any = false, - private incompatibleValue: any = true, + private incompatibleValue: any | ((v: any) => boolean) = true, ) { super(); } @@ -49,6 +49,9 @@ export default class IncompatibleController extends SettingController { } public get incompatibleSetting(): boolean { + if (typeof this.incompatibleValue === "function") { + return this.incompatibleValue(SettingsStore.getValue(this.settingName)); + } return SettingsStore.getValue(this.settingName) === this.incompatibleValue; } } From fc58ce7ed299a353dd885d8ca102af27b7297b48 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Wed, 15 Dec 2021 16:54:01 +0000 Subject: [PATCH 021/145] Use the moved makePollContent function from js-sdk (#7387) --- src/components/views/elements/PollCreateDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/views/elements/PollCreateDialog.tsx b/src/components/views/elements/PollCreateDialog.tsx index 1f442c93d80..73e552f7725 100644 --- a/src/components/views/elements/PollCreateDialog.tsx +++ b/src/components/views/elements/PollCreateDialog.tsx @@ -16,7 +16,8 @@ limitations under the License. import React, { ChangeEvent, createRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; -import { makePollContent, POLL_KIND_DISCLOSED, POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; +import { makePollContent } from "matrix-js-sdk/src/content-helpers"; +import { POLL_KIND_DISCLOSED, POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; import ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal"; import { IDialogProps } from "../dialogs/IDialogProps"; From 1c6a7646faed63194d04036ee31bb3b33ea39d13 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Wed, 15 Dec 2021 16:54:11 +0000 Subject: [PATCH 022/145] Use UnstableValue for all location constants (#7388) --- .../views/messages/MLocationBody.tsx | 9 ++--- .../views/messages/MessageEvent.tsx | 7 +++- .../views/rooms/MessageComposer.tsx | 38 +++++++++++++------ 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index 28a122e60fe..36da9689632 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -17,6 +17,7 @@ limitations under the License. import React from 'react'; import maplibregl from 'maplibre-gl'; import { logger } from "matrix-js-sdk/src/logger"; +import { LOCATION_EVENT_TYPE } from 'matrix-js-sdk/src/@types/location'; import SdkConfig from '../../../SdkConfig'; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -41,17 +42,15 @@ export default class MLocationBody extends React.Component { // events - so folks can read their old chat history correctly. // https://github.com/matrix-org/matrix-doc/issues/3516 const content = this.props.mxEvent.getContent(); - const uri = content['org.matrix.msc3488.location'] ? - content['org.matrix.msc3488.location'].uri : - content['geo_uri']; + const loc = content[LOCATION_EVENT_TYPE.name]; + const uri = loc ? loc.uri : content['geo_uri']; this.coords = this.parseGeoUri(uri); this.state = { error: undefined, }; - this.description = - content['org.matrix.msc3488.location']?.description ?? content['body']; + this.description = loc?.description ?? content['body']; } private parseGeoUri = (uri: string): GeolocationCoordinates => { diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index c40c9ff2a01..a8ad1a98f94 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -18,6 +18,7 @@ import React, { createRef } from 'react'; import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; import { Relations } from 'matrix-js-sdk/src/models/relations'; import { POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; +import { LOCATION_EVENT_TYPE } from 'matrix-js-sdk/src/@types/location'; import * as sdk from '../../../index'; import SettingsStore from "../../../settings/SettingsStore"; @@ -127,8 +128,10 @@ export default class MessageEvent extends React.Component implements IMe } } - if ((type && type === "org.matrix.msc3488.location") || - (type && type === EventType.RoomMessage && msgtype && msgtype === MsgType.Location)) { + if ( + LOCATION_EVENT_TYPE.matches(type) || + (type === EventType.RoomMessage && msgtype === MsgType.Location) + ) { // TODO: tidy this up once location sharing is out of labs if (SettingsStore.getValue("feature_location_share")) { BodyType = sdk.getComponent('messages.MLocationBody'); diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index bf95f722e4d..dcfd7131f99 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -19,9 +19,9 @@ import { MatrixEvent, IEventRelation } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RelationType } from 'matrix-js-sdk/src/@types/event'; -import { MsgType } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; import { POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; +import { makeLocationContent } from "matrix-js-sdk/src/content-helpers"; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -505,19 +505,33 @@ export default class MessageComposer extends React.Component { return true; }; - private shareLocation = (uri: string, ts: number, type: LocationShareType, description: string): boolean => { + private textForLocation = ( + uri: string, + ts: number, + description: string | null, + ): string => { + const date = new Date(ts).toISOString(); + // TODO: translation, as soon as we've re-worded this better + if (description) { + return `${description} at ${uri} as of ${date}`; + } else { + return `Location at ${uri} as of ${date}`; + } + }; + + private shareLocation = ( + uri: string, + ts: number, + _type: LocationShareType, + description: string | null, + ): boolean => { if (!uri) return false; try { - const text = `${description ? description : 'Location'} at ${uri} as of ${new Date(ts).toISOString()}`; - // noinspection ES6MissingAwait - we don't care if it fails, it'll get queued. - MatrixClientPeg.get().sendMessage(this.props.room.roomId, { - "body": text, - "msgtype": MsgType.Location, - "geo_uri": uri, - "org.matrix.msc3488.location": { uri, description }, - "org.matrix.msc3488.ts": ts, - // TODO: MSC1767 fallbacks for text & thumbnail - }); + const text = this.textForLocation(uri, ts, description); + MatrixClientPeg.get().sendMessage( + this.props.room.roomId, + makeLocationContent(text, uri, ts, description), + ); } catch (e) { logger.error("Error sending location:", e); } From d31aa121567a377a3cca3028e2589342992b4d57 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Wed, 15 Dec 2021 18:06:37 +0100 Subject: [PATCH 023/145] Fix MaxListenersExceededWarning (#7389) --- src/components/structures/RoomView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 65966a05e41..73e5d4532cc 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -658,6 +658,7 @@ export class RoomView extends React.Component { callState: callState, }); + CallHandler.instance.on(CallHandlerEvent.CallState, this.onCallState); window.addEventListener('beforeunload', this.onPageUnload); } @@ -675,7 +676,6 @@ export class RoomView extends React.Component { } componentDidUpdate() { - CallHandler.instance.addListener(CallHandlerEvent.CallState, this.onCallState); if (this.roomView.current) { const roomView = this.roomView.current; if (!roomView.ondrop) { @@ -769,6 +769,8 @@ export class RoomView extends React.Component { ); } + CallHandler.instance.off(CallHandlerEvent.CallState, this.onCallState); + // cancel any pending calls to the throttled updated this.updateRoomMembers.cancel(); From 11aa6c74357dadadc5a954223112374271b2a7e6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Dec 2021 19:47:57 +0000 Subject: [PATCH 024/145] Don't show a message bubble around polls (#7374) * Don't show a message bubble around polls * Update res/css/views/rooms/_EventBubbleTile.scss --- res/css/views/rooms/_EventBubbleTile.scss | 4 ++++ src/components/views/rooms/EventTile.tsx | 2 ++ src/utils/EventUtils.ts | 9 +++++++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index 7e2a1ec45e7..d42339cf646 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -312,6 +312,10 @@ limitations under the License. } } +.mx_EventTile.mx_EventTile_noBubble[data-layout=bubble] { + --backgroundColor: transparent; +} + .mx_EventTile.mx_EventTile_bubbleContainer[data-layout=bubble], .mx_EventTile.mx_EventTile_leftAlignedBubble[data-layout=bubble], .mx_EventTile.mx_EventTile_info[data-layout=bubble], diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 2db9b311eae..e35c7286f6b 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -1067,6 +1067,7 @@ export default class EventTile extends React.Component { isBubbleMessage, isInfoMessage, isLeftAlignedBubbleMessage, + noBubbleEvent, } = getEventDisplayInfo(this.props.mxEvent); const { isQuoteExpanded } = this.state; @@ -1121,6 +1122,7 @@ export default class EventTile extends React.Component { mx_EventTile_emote: msgtype === 'm.emote', mx_EventTile_noSender: this.props.hideSender, mx_EventTile_clamp: this.props.tileShape === TileShape.ThreadPanel, + mx_EventTile_noBubble: noBubbleEvent, }); // If the tile is in the Sending state, don't speak the message. diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index 5a7332301b9..95ee1b14c69 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -119,6 +119,7 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent): { tileHandler: string; isBubbleMessage: boolean; isLeftAlignedBubbleMessage: boolean; + noBubbleEvent: boolean; } { const content = mxEvent.getContent(); const msgtype = content.msgtype; @@ -144,7 +145,11 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent): { eventType !== EventType.RoomMessage && eventType !== EventType.Sticker && eventType !== EventType.RoomCreate && - eventType !== POLL_START_EVENT_TYPE.name + !POLL_START_EVENT_TYPE.matches(eventType) + ); + // Some non-info messages want to be rendered in the appropriate bubble column but without the bubble background + const noBubbleEvent = ( + POLL_START_EVENT_TYPE.matches(eventType) ); // If we're showing hidden events in the timeline, we should use the @@ -158,7 +163,7 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent): { isInfoMessage = true; } - return { tileHandler, isInfoMessage, isBubbleMessage, isLeftAlignedBubbleMessage }; + return { tileHandler, isInfoMessage, isBubbleMessage, isLeftAlignedBubbleMessage, noBubbleEvent }; } export function isVoiceMessage(mxEvent: MatrixEvent): boolean { From b952fef1950b3f05895a723293d0c1700e7106d1 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Wed, 15 Dec 2021 21:06:40 +0000 Subject: [PATCH 025/145] Don't include the accuracy parameter in location events if accuracy could not be determined. (#7375) * Don't include accuracy on geouri if not defined * Use !== undefined * Add a ) --- src/components/views/location/LocationPicker.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index b5ed928dd35..f3f4d649860 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -188,9 +188,10 @@ class LocationPicker extends React.Component { private getGeoUri = (position) => { return (`geo:${ position.coords.latitude },` + position.coords.longitude + - ( position.coords.altitude != null ? + ( position.coords.altitude !== undefined ? `,${ position.coords.altitude }` : '' ) + - `;u=${ position.coords.accuracy }`); + ( position.coords.accuracy !== undefined ? + `;u=${ position.coords.accuracy }` : '' )); }; private onOk = () => { From a968b4ce537ba12c4ec9d8d9f4e89c4104abc9b3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 16 Dec 2021 09:57:10 +0000 Subject: [PATCH 026/145] Improve typing around LoggedInView (#7384) * Update SyncState imports * Improve typing around LoggedInView * Fix typing --- src/CallHandler.tsx | 2 +- src/components/structures/LoggedInView.tsx | 46 +++++--------------- src/components/structures/RoomStatusBar.tsx | 3 +- src/components/structures/TimelinePanel.tsx | 2 +- src/components/views/messages/MImageBody.tsx | 2 +- 5 files changed, 15 insertions(+), 40 deletions(-) diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index e7d6e991297..5d4973798d6 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -26,7 +26,7 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/ import EventEmitter from 'events'; import { RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules"; import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; -import { SyncState } from "matrix-js-sdk/src/sync.api"; +import { SyncState } from "matrix-js-sdk/src/sync"; import { MatrixClientPeg } from './MatrixClientPeg'; import Modal from './Modal'; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index e7fa353e51a..d180a327a9a 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -19,6 +19,8 @@ import { MatrixClient } from 'matrix-js-sdk/src/client'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import classNames from 'classnames'; +import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; +import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials'; import { Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; @@ -104,24 +106,8 @@ interface IProps { forceTimeline?: boolean; // see props on MatrixChat } -interface IUsageLimit { - // "hs_disabled" is NOT a specced string, but is used in Synapse - // This is tracked over at https://github.com/matrix-org/synapse/issues/9237 - // eslint-disable-next-line camelcase - limit_type: "monthly_active_user" | "hs_disabled" | string; - // eslint-disable-next-line camelcase - admin_contact?: string; -} - interface IState { - syncErrorData?: { - error: { - // This is not specced, but used in Synapse. See - // https://github.com/matrix-org/synapse/issues/9237#issuecomment-768238922 - data: IUsageLimit; - errcode: string; - }; - }; + syncErrorData?: ISyncStateData; usageLimitDismissed: boolean; usageLimitEventContent?: IUsageLimit; usageLimitEventTs?: number; @@ -304,33 +290,23 @@ class LoggedInView extends React.Component { }); }; - onSync = (syncState, oldSyncState, data) => { - const oldErrCode = ( - this.state.syncErrorData && - this.state.syncErrorData.error && - this.state.syncErrorData.error.errcode - ); + private onSync = (syncState: SyncState, oldSyncState?: SyncState, data?: ISyncStateData): void => { + const oldErrCode = this.state.syncErrorData?.error?.errcode; const newErrCode = data && data.error && data.error.errcode; if (syncState === oldSyncState && oldErrCode === newErrCode) return; - if (syncState === 'ERROR') { - this.setState({ - syncErrorData: data, - }); - } else { - this.setState({ - syncErrorData: null, - }); - } + this.setState({ + syncErrorData: syncState === SyncState.Error ? data : null, + }); - if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') { + if (oldSyncState === SyncState.Prepared && syncState === SyncState.Syncing) { this.updateServerNoticeEvents(); } else { this.calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent); } }; - onRoomStateEvents = (ev, state) => { + private onRoomStateEvents = (ev: MatrixEvent): void => { const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice]; if (serverNoticeList && serverNoticeList.some(r => r.roomId === ev.getRoomId())) { this.updateServerNoticeEvents(); @@ -346,7 +322,7 @@ class LoggedInView extends React.Component { private calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) { const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; if (error) { - usageLimitEventContent = syncError.error.data; + usageLimitEventContent = syncError.error.data as IUsageLimit; } // usageLimitDismissed is true when the user has explicitly hidden the toast diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index 76835d7c29d..4bc13c72413 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -16,8 +16,7 @@ limitations under the License. import React from 'react'; import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { SyncState } from "matrix-js-sdk/src/sync.api"; -import { ISyncStateData } from "matrix-js-sdk/src/sync"; +import { SyncState, ISyncStateData } from "matrix-js-sdk/src/sync"; import { Room } from "matrix-js-sdk/src/models/room"; import { _t, _td } from '../../languageHandler'; diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index f9b5694fa53..756a368e33a 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -22,7 +22,7 @@ import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; import { Direction, EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; import { TimelineWindow } from "matrix-js-sdk/src/timeline-window"; import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event'; -import { SyncState } from 'matrix-js-sdk/src/sync.api'; +import { SyncState } from 'matrix-js-sdk/src/sync'; import { debounce } from 'lodash'; import { logger } from "matrix-js-sdk/src/logger"; diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index e05aee6a250..86e895090d7 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -17,7 +17,7 @@ limitations under the License. import React, { ComponentProps, createRef } from 'react'; import { Blurhash } from "react-blurhash"; -import { SyncState } from 'matrix-js-sdk/src/sync.api'; +import { SyncState } from 'matrix-js-sdk/src/sync'; import classNames from 'classnames'; import { CSSTransition, SwitchTransition } from 'react-transition-group'; import { logger } from "matrix-js-sdk/src/logger"; From 8f3ea977293b4601dd36910d1ef0b220a2a79f4b Mon Sep 17 00:00:00 2001 From: Ingrid <36052282+twigleingrid@users.noreply.github.com> Date: Thu, 16 Dec 2021 13:28:21 +0100 Subject: [PATCH 027/145] Full page spinner has no padding (#7390) Description: Adds padding to full page spinner (e.g. when creating a room), which otherwise looks awkward on the grey background. Fixes https://github.com/vector-im/element-web/issues/20001 Signed-off-by: Ingrid Budau inigiri@posteo.jp --- res/css/_common.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/_common.scss b/res/css/_common.scss index b153d2c3cb8..c72e4362ce7 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -495,7 +495,7 @@ legend { .mx_Dialog_wrapper.mx_Dialog_spinner .mx_Dialog { width: auto; border-radius: 8px; - padding: 0px; + padding: 8px; box-shadow: none; /* Don't show scroll-bars on spinner dialogs */ From 9a8265429c16c3ed8509237f9af7cb3e75bcb356 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Thu, 16 Dec 2021 14:20:55 +0000 Subject: [PATCH 028/145] Unit tests for parseGeoUri (#7395) --- .../views/messages/MLocationBody.tsx | 44 ++--- .../views/messages/MLocationBody-test.tsx | 162 ++++++++++++++++++ 2 files changed, 184 insertions(+), 22 deletions(-) create mode 100644 test/components/views/messages/MLocationBody-test.tsx diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index 36da9689632..197e751e154 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -45,7 +45,7 @@ export default class MLocationBody extends React.Component { const loc = content[LOCATION_EVENT_TYPE.name]; const uri = loc ? loc.uri : content['geo_uri']; - this.coords = this.parseGeoUri(uri); + this.coords = parseGeoUri(uri); this.state = { error: undefined, }; @@ -53,27 +53,6 @@ export default class MLocationBody extends React.Component { this.description = loc?.description ?? content['body']; } - private parseGeoUri = (uri: string): GeolocationCoordinates => { - const m = uri.match(/^\s*geo:(.*?)\s*$/); - if (!m) return; - const parts = m[1].split(';'); - const coords = parts[0].split(','); - let uncertainty: number; - for (const param of parts.slice(1)) { - const m = param.match(/u=(.*)/); - if (m) uncertainty = parseFloat(m[1]); - } - return { - latitude: parseFloat(coords[0]), - longitude: parseFloat(coords[1]), - altitude: parseFloat(coords[2]), - accuracy: uncertainty, - altitudeAccuracy: undefined, - heading: undefined, - speed: undefined, - }; - }; - componentDidMount() { const config = SdkConfig.get(); const coordinates = new maplibregl.LngLat(this.coords.longitude, this.coords.latitude); @@ -116,3 +95,24 @@ export default class MLocationBody extends React.Component {
; } } + +export function parseGeoUri(uri: string): GeolocationCoordinates { + const m = uri.match(/^\s*geo:(.*?)\s*$/); + if (!m) return; + const parts = m[1].split(';'); + const coords = parts[0].split(','); + let uncertainty: number; + for (const param of parts.slice(1)) { + const m = param.match(/u=(.*)/); + if (m) uncertainty = parseFloat(m[1]); + } + return { + latitude: parseFloat(coords[0]), + longitude: parseFloat(coords[1]), + altitude: parseFloat(coords[2]), + accuracy: uncertainty, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }; +} diff --git a/test/components/views/messages/MLocationBody-test.tsx b/test/components/views/messages/MLocationBody-test.tsx new file mode 100644 index 00000000000..b38d3864b31 --- /dev/null +++ b/test/components/views/messages/MLocationBody-test.tsx @@ -0,0 +1,162 @@ +/* +Copyright 2021 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 sdk from "../../../skinned-sdk"; +import { parseGeoUri } from "../../../../src/components/views/messages/MLocationBody"; + +sdk.getComponent("views.messages.MLocationBody"); + +describe("MLocationBody", () => { + describe("parseGeoUri", () => { + it("fails if the supplied URI is empty", () => { + expect(parseGeoUri("")).toBeFalsy(); + }); + + // We use some examples from the spec, but don't check semantics + // like two textually-different URIs being equal, since we are + // just a humble parser. + + // Note: we do not understand geo URIs with percent-encoded coords + // or accuracy. It is RECOMMENDED in the spec never to percent-encode + // these, but it is permitted, and we will fail to parse in that case. + + it("rfc5870 6.1 Simple 3-dimensional", () => { + expect(parseGeoUri("geo:48.2010,16.3695,183")).toEqual( + { + latitude: 48.2010, + longitude: 16.3695, + altitude: 183, + accuracy: undefined, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + ); + }); + + it("rfc5870 6.2 Explicit CRS and accuracy", () => { + expect(parseGeoUri("geo:48.198634,16.371648;crs=wgs84;u=40")).toEqual( + { + latitude: 48.198634, + longitude: 16.371648, + altitude: NaN, // TODO: should be undefined + accuracy: 40, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + ); + }); + + it("rfc5870 6.4 Negative longitude and explicit CRS", () => { + expect(parseGeoUri("geo:90,-22.43;crs=WGS84")).toEqual( + { + latitude: 90, + longitude: -22.43, + altitude: NaN, // TODO: should be undefined + accuracy: undefined, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + ); + }); + + it("rfc5870 6.4 Integer lat and lon", () => { + expect(parseGeoUri("geo:90,46")).toEqual( + { + latitude: 90, + longitude: 46, + altitude: NaN, // TODO: should be undefined + accuracy: undefined, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + ); + }); + + it("rfc5870 6.4 Percent-encoded param value", () => { + expect(parseGeoUri("geo:66,30;u=6.500;FOo=this%2dthat")).toEqual( + { + latitude: 66, + longitude: 30, + altitude: NaN, // TODO: should be undefined + accuracy: 6.500, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + ); + }); + + it("rfc5870 6.4 Unknown param", () => { + expect(parseGeoUri("geo:66.0,30;u=6.5;foo=this-that>")).toEqual( + { + latitude: 66.0, + longitude: 30, + altitude: NaN, // TODO: should be undefined + accuracy: 6.5, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + ); + }); + + it("rfc5870 6.4 Multiple unknown params", () => { + expect(parseGeoUri("geo:70,20;foo=1.00;bar=white")).toEqual( + { + latitude: 70, + longitude: 20, + altitude: NaN, // TODO: should be undefined + accuracy: undefined, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + ); + }); + + it("Negative latitude", () => { + expect(parseGeoUri("geo:-7.5,20")).toEqual( + { + latitude: -7.5, + longitude: 20, + altitude: NaN, // TODO: should be undefined + accuracy: undefined, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + ); + }); + + it("Zero altitude is not unknown", () => { + expect(parseGeoUri("geo:-7.5,-20,0")).toEqual( + { + latitude: -7.5, + longitude: -20, + altitude: 0, + accuracy: undefined, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + ); + }); + }); +}); From 4a9173a7f1568ae55d26954a047d45c95840e8a6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 16 Dec 2021 18:34:59 +0000 Subject: [PATCH 029/145] Fix alignment between ELS and Events in bubble layout (#7392) * Fix alignment between ELS and Events in bubble layout * Improve ELS behaviour in bubble layout --- res/css/views/rooms/_EventBubbleTile.scss | 47 ++++++++++++----------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index d42339cf646..be90661442d 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -20,13 +20,13 @@ limitations under the License. --gutterSize: 11px; --cornerRadius: 12px; --maxWidth: 70%; + margin-right: 100px; } .mx_EventTile[data-layout=bubble] { position: relative; margin-top: var(--gutterSize); margin-left: 49px; - margin-right: 100px; font-size: $font-14px; .mx_ThreadInfo { @@ -356,14 +356,24 @@ limitations under the License. .mx_EventListSummary[data-layout=bubble] { --maxWidth: 70%; margin-left: calc(var(--avatarSize) + var(--gutterSize)); - margin-right: 94px; + .mx_EventListSummary_toggle { + margin: 0 55px 0 5px; float: none; - margin: 0; - order: 9; - margin-left: 5px; - margin-right: 55px; + + &[aria-expanded=false] { + order: 9; + } + &[aria-expanded=true] { + text-align: right; + margin-right: 100px; + } } + + .mx_EventListSummary_line { + display: none; + } + .mx_EventListSummary_avatars { padding-top: 0; } @@ -372,23 +382,6 @@ limitations under the License. content: ""; clear: both; } - - .mx_EventTile { - margin: 0 6px; - padding: 2px 0; - } - - .mx_EventTile_line { - margin: 0; - > a { - // Align timestamps with those of normal bubble tiles - left: -76px; - } - } - - .mx_MessageActionBar { - transform: translate3d(90%, 0, 0); - } } .mx_EventListSummary[data-expanded=false][data-layout=bubble] { @@ -396,6 +389,14 @@ limitations under the License. padding: 0 49px; } +.mx_EventListSummary[data-expanded=true][data-layout=bubble] { + display: contents; + + .mx_EventTile { + padding: 2px 0; + } +} + /* events that do not require bubble layout */ .mx_EventListSummary[data-layout=bubble], .mx_EventTile.mx_EventTile_bad[data-layout=bubble] { From 314475985c2631d85d79a5597e0a5350cdeedfef Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 16 Dec 2021 18:35:28 +0000 Subject: [PATCH 030/145] Fix bubble radius wrong when followed by a state event from same user (#7393) --- src/components/structures/MessagePanel.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 7db09c6720b..5cbbdf747eb 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -50,6 +50,7 @@ import TileErrorBoundary from '../views/messages/TileErrorBoundary'; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import EditorStateTransfer from "../../utils/EditorStateTransfer"; import { Action } from '../../dispatcher/actions'; +import { getEventDisplayInfo } from "../../utils/EventUtils"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; @@ -724,7 +725,8 @@ export default class MessagePanel extends React.Component { let lastInSection = true; if (nextEventWithTile) { willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEventWithTile.getDate() || new Date()); - lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEventWithTile.getSender(); + lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEventWithTile.getSender() || + getEventDisplayInfo(nextEventWithTile).isInfoMessage; } // is this a continuation of the previous message? From d009cebb0745f22350f98e7ea98e9e051c1a373d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 16 Dec 2021 18:35:51 +0000 Subject: [PATCH 031/145] Fix sizing of e2e shield in bubble layout (#7394) --- res/css/views/rooms/_EventBubbleTile.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index be90661442d..ba5c3595f2e 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -176,6 +176,10 @@ limitations under the License. border-top-left-radius: var(--cornerRadius); border-top-right-radius: var(--cornerRadius); } + + .mx_EventTile_e2eIcon { + flex-shrink: 0; // keep it at full size + } } .mx_EventTile_line:not(.mx_EventTile_mediaLine) { From feea80dfd5aeb97cc7e3588c564d48f5cd2f2f8a Mon Sep 17 00:00:00 2001 From: Germain Date: Fri, 17 Dec 2021 07:32:10 +0000 Subject: [PATCH 032/145] Do not assume unread state if read receipt comes from a thread (#7368) --- src/Unread.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Unread.ts b/src/Unread.ts index da5b883f925..905798eb038 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -67,6 +67,15 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean { return false; } + // if the read receipt relates to an event is that part of a thread + // we consider that there are no unread messages + // This might be a false negative, but probably the best we can do until + // the read receipts have evolved to cater for threads + const event = room.findEventById(readUpToId); + if (event?.getThread()) { + return false; + } + // this just looks at whatever history we have, which if we've only just started // up probably won't be very much, so if the last couple of events are ones that // don't count, we don't know if there are any events that do count between where From 9ed771ad7a28e0626a1a36794140e831c43857d5 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 17 Dec 2021 08:53:03 +0000 Subject: [PATCH 033/145] Have LocalEchoWrapper emit updates so the app can react faster (#7358) --- src/settings/SettingsStore.ts | 17 ++++++++--------- src/settings/handlers/AccountSettingsHandler.ts | 6 +++++- src/settings/handlers/DeviceSettingsHandler.ts | 2 +- src/settings/handlers/LocalEchoWrapper.ts | 11 +++++++++-- .../handlers/RoomAccountSettingsHandler.ts | 2 +- .../handlers/RoomDeviceSettingsHandler.ts | 2 +- src/settings/handlers/RoomSettingsHandler.ts | 2 +- src/settings/handlers/SettingsHandler.ts | 4 ++++ 8 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index 9d3151f68a1..ca9bfe3703e 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -28,7 +28,7 @@ import { _t } from '../languageHandler'; import dis from '../dispatcher/dispatcher'; import { IFeature, ISetting, LabGroup, SETTINGS } from "./Settings"; import LocalEchoWrapper from "./handlers/LocalEchoWrapper"; -import { WatchManager, CallbackFn as WatchCallbackFn } from "./WatchManager"; +import { CallbackFn as WatchCallbackFn, WatchManager } from "./WatchManager"; import { SettingLevel } from "./SettingLevel"; import SettingsHandler from "./handlers/SettingsHandler"; import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload"; @@ -50,21 +50,20 @@ for (const key of Object.keys(SETTINGS)) { } } +// Only wrap the handlers with async setters in a local echo wrapper const LEVEL_HANDLERS = { [SettingLevel.DEVICE]: new DeviceSettingsHandler(featureNames, defaultWatchManager), [SettingLevel.ROOM_DEVICE]: new RoomDeviceSettingsHandler(defaultWatchManager), - [SettingLevel.ROOM_ACCOUNT]: new RoomAccountSettingsHandler(defaultWatchManager), - [SettingLevel.ACCOUNT]: new AccountSettingsHandler(defaultWatchManager), - [SettingLevel.ROOM]: new RoomSettingsHandler(defaultWatchManager), + [SettingLevel.ROOM_ACCOUNT]: new LocalEchoWrapper( + new RoomAccountSettingsHandler(defaultWatchManager), + SettingLevel.ROOM_ACCOUNT, + ), + [SettingLevel.ACCOUNT]: new LocalEchoWrapper(new AccountSettingsHandler(defaultWatchManager), SettingLevel.ACCOUNT), + [SettingLevel.ROOM]: new LocalEchoWrapper(new RoomSettingsHandler(defaultWatchManager), SettingLevel.ROOM), [SettingLevel.CONFIG]: new ConfigSettingsHandler(featureNames), [SettingLevel.DEFAULT]: new DefaultSettingsHandler(defaultSettings, invertedDefaultSettings), }; -// Wrap all the handlers with local echo -for (const key of Object.keys(LEVEL_HANDLERS)) { - LEVEL_HANDLERS[key] = new LocalEchoWrapper(LEVEL_HANDLERS[key]); -} - export const LEVEL_ORDER = [ SettingLevel.DEVICE, SettingLevel.ROOM_DEVICE, diff --git a/src/settings/handlers/AccountSettingsHandler.ts b/src/settings/handlers/AccountSettingsHandler.ts index cf6653dc0c8..70fe915d696 100644 --- a/src/settings/handlers/AccountSettingsHandler.ts +++ b/src/settings/handlers/AccountSettingsHandler.ts @@ -36,10 +36,14 @@ const ANALYTICS_EVENT_TYPE = "im.vector.analytics"; * This handler does not make use of the roomId parameter. */ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHandler { - constructor(private watchers: WatchManager) { + constructor(public readonly watchers: WatchManager) { super(); } + public get level(): SettingLevel { + return SettingLevel.ACCOUNT; + } + public initMatrixClient(oldClient: MatrixClient, newClient: MatrixClient) { if (oldClient) { oldClient.removeListener("accountData", this.onAccountData); diff --git a/src/settings/handlers/DeviceSettingsHandler.ts b/src/settings/handlers/DeviceSettingsHandler.ts index f7a9fe9108d..03da2755b16 100644 --- a/src/settings/handlers/DeviceSettingsHandler.ts +++ b/src/settings/handlers/DeviceSettingsHandler.ts @@ -33,7 +33,7 @@ export default class DeviceSettingsHandler extends SettingsHandler { * @param {string[]} featureNames The names of known features. * @param {WatchManager} watchers The watch manager to notify updates to */ - constructor(private featureNames: string[], private watchers: WatchManager) { + constructor(private featureNames: string[], public readonly watchers: WatchManager) { super(); } diff --git a/src/settings/handlers/LocalEchoWrapper.ts b/src/settings/handlers/LocalEchoWrapper.ts index 5cfcd27aed4..0ce47ede2dc 100644 --- a/src/settings/handlers/LocalEchoWrapper.ts +++ b/src/settings/handlers/LocalEchoWrapper.ts @@ -16,6 +16,7 @@ limitations under the License. */ import SettingsHandler from "./SettingsHandler"; +import { SettingLevel } from "../SettingLevel"; /** * A wrapper for a SettingsHandler that performs local echo on @@ -32,8 +33,9 @@ export default class LocalEchoWrapper extends SettingsHandler { /** * Creates a new local echo wrapper * @param {SettingsHandler} handler The handler to wrap + * @param {SettingLevel} level The level to notify updates at */ - constructor(private handler: SettingsHandler) { + constructor(private readonly handler: SettingsHandler, private readonly level: SettingLevel) { super(); } @@ -54,8 +56,13 @@ export default class LocalEchoWrapper extends SettingsHandler { const cacheRoomId = roomId ? roomId : "UNDEFINED"; // avoid weird keys bySetting[cacheRoomId] = newValue; + const currentValue = this.handler.getValue(settingName, roomId); const handlerPromise = this.handler.setValue(settingName, roomId, newValue); - return Promise.resolve(handlerPromise).finally(() => { + this.handler.watchers?.notifyUpdate(settingName, roomId, this.level, newValue); + return Promise.resolve(handlerPromise).catch(() => { + // notify of a rollback + this.handler.watchers?.notifyUpdate(settingName, roomId, this.level, currentValue); + }).finally(() => { delete bySetting[cacheRoomId]; }); } diff --git a/src/settings/handlers/RoomAccountSettingsHandler.ts b/src/settings/handlers/RoomAccountSettingsHandler.ts index 05596ae1a48..14db4024bfe 100644 --- a/src/settings/handlers/RoomAccountSettingsHandler.ts +++ b/src/settings/handlers/RoomAccountSettingsHandler.ts @@ -31,7 +31,7 @@ const ALLOWED_WIDGETS_EVENT_TYPE = "im.vector.setting.allowed_widgets"; * Gets and sets settings at the "room-account" level for the current user. */ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettingsHandler { - constructor(private watchers: WatchManager) { + constructor(public readonly watchers: WatchManager) { super(); } diff --git a/src/settings/handlers/RoomDeviceSettingsHandler.ts b/src/settings/handlers/RoomDeviceSettingsHandler.ts index 2a068eb060b..47fcecdfacd 100644 --- a/src/settings/handlers/RoomDeviceSettingsHandler.ts +++ b/src/settings/handlers/RoomDeviceSettingsHandler.ts @@ -24,7 +24,7 @@ import { WatchManager } from "../WatchManager"; * room. */ export default class RoomDeviceSettingsHandler extends SettingsHandler { - constructor(private watchers: WatchManager) { + constructor(public readonly watchers: WatchManager) { super(); } diff --git a/src/settings/handlers/RoomSettingsHandler.ts b/src/settings/handlers/RoomSettingsHandler.ts index 13eab3c0c25..7adabc8f5cf 100644 --- a/src/settings/handlers/RoomSettingsHandler.ts +++ b/src/settings/handlers/RoomSettingsHandler.ts @@ -29,7 +29,7 @@ import { WatchManager } from "../WatchManager"; * Gets and sets settings at the "room" level. */ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandler { - constructor(private watchers: WatchManager) { + constructor(public readonly watchers: WatchManager) { super(); } diff --git a/src/settings/handlers/SettingsHandler.ts b/src/settings/handlers/SettingsHandler.ts index ca9a068fd34..c0070bc1efd 100644 --- a/src/settings/handlers/SettingsHandler.ts +++ b/src/settings/handlers/SettingsHandler.ts @@ -15,11 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { WatchManager } from "../WatchManager"; + /** * Represents the base class for all level handlers. This class performs no logic * and should be overridden. */ export default abstract class SettingsHandler { + public readonly watchers?: WatchManager; + /** * Gets the value for a particular setting at this level for a particular room. * If no room is applicable, the roomId may be null. The roomId may not be From 5ee356daaae0388eb5c6207ea02277739f8aae01 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 17 Dec 2021 08:53:26 +0000 Subject: [PATCH 034/145] Fix list of members in space preview (#7356) --- src/components/structures/SpaceRoomView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 7427e956f08..81ca9cbf5d4 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -128,9 +128,9 @@ const useMyRoomMembership = (room: Room) => { }; const SpaceInfo = ({ space }: { space: Room }) => { - // summary will begin as undefined whilst loading and go null if it fails to load. + // summary will begin as undefined whilst loading and go null if it fails to load or we are not invited. const summary = useAsyncMemo(async () => { - if (space.getMyMembership() !== "invite") return; + if (space.getMyMembership() !== "invite") return null; try { return space.client.getRoomSummary(space.roomId); } catch (e) { @@ -141,7 +141,7 @@ const SpaceInfo = ({ space }: { space: Room }) => { const membership = useMyRoomMembership(space); let visibilitySection; - if (joinRule === "public") { + if (joinRule === JoinRule.Public) { visibilitySection = { _t("Public space") } ; From 39c4b78371db075ad278f9308dcf23424d9a5f2b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 17 Dec 2021 09:26:32 +0000 Subject: [PATCH 035/145] Space preferences for whether or not you see DMs in a Space (#7250) --- res/css/_components.scss | 1 + res/css/structures/_SpacePanel.scss | 4 + res/css/views/dialogs/_SettingsDialog.scss | 5 +- .../dialogs/_SpacePreferencesDialog.scss | 34 + .../views/context_menus/SpaceContextMenu.tsx | 14 + .../views/dialogs/SpacePreferencesDialog.tsx | 99 +++ .../views/dialogs/UserSettingsDialog.tsx | 2 +- src/components/views/rooms/NewRoomIntro.tsx | 2 +- src/components/views/rooms/RoomList.tsx | 15 +- .../views/spaces/SpaceTreeLevel.tsx | 2 +- src/i18n/strings/en_EN.json | 2 + src/settings/Settings.tsx | 5 + src/settings/WatchManager.ts | 2 +- src/stores/room-list/SpaceWatcher.ts | 4 +- .../room-list/filters/SpaceFilterCondition.ts | 18 +- src/stores/spaces/SpaceStore.ts | 670 ++++++++++++------ src/stores/spaces/index.ts | 7 + src/utils/arrays.ts | 4 +- src/utils/sets.ts | 12 + src/utils/space.tsx | 8 + test/stores/SpaceStore-test.ts | 283 +++++--- test/test-utils.js | 8 +- 22 files changed, 881 insertions(+), 320 deletions(-) create mode 100644 res/css/views/dialogs/_SpacePreferencesDialog.scss create mode 100644 src/components/views/dialogs/SpacePreferencesDialog.tsx diff --git a/res/css/_components.scss b/res/css/_components.scss index 674c6487784..346a948abdb 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -112,6 +112,7 @@ @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_SlashCommandHelpDialog.scss"; +@import "./views/dialogs/_SpacePreferencesDialog.scss"; @import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_SpotlightDialog.scss"; @import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 1f2f5f4a58c..547a27ad78b 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -396,6 +396,10 @@ $activeBorderColor: $primary-content; mask-image: url('$(res)/img/element-icons/roomlist/search.svg'); } + .mx_SpacePanel_iconPreferences::before { + mask-image: url('$(res)/img/element-icons/settings/preference.svg'); + } + .mx_SpacePanel_noIcon { display: none; diff --git a/res/css/views/dialogs/_SettingsDialog.scss b/res/css/views/dialogs/_SettingsDialog.scss index 59aed520fd7..e982b6245fa 100644 --- a/res/css/views/dialogs/_SettingsDialog.scss +++ b/res/css/views/dialogs/_SettingsDialog.scss @@ -15,7 +15,10 @@ limitations under the License. */ // Not actually a component but things shared by settings components -.mx_UserSettingsDialog, .mx_RoomSettingsDialog, .mx_SpaceSettingsDialog { +.mx_UserSettingsDialog, +.mx_RoomSettingsDialog, +.mx_SpaceSettingsDialog, +.mx_SpacePreferencesDialog { width: 90vw; max-width: 1000px; // set the height too since tabbed view scrolls itself. diff --git a/res/css/views/dialogs/_SpacePreferencesDialog.scss b/res/css/views/dialogs/_SpacePreferencesDialog.scss new file mode 100644 index 00000000000..370c0f845b1 --- /dev/null +++ b/res/css/views/dialogs/_SpacePreferencesDialog.scss @@ -0,0 +1,34 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_SpacePreferencesDialog { + width: 700px; + height: 400px; + + .mx_TabbedView .mx_SettingsTab { + min-width: unset; + + .mx_SettingsTab_section { + font-size: $font-15px; + line-height: $font-24px; + + .mx_Checkbox + p { + color: $secondary-content; + margin: 0 20px 0 24px; + } + } + } +} diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx index d143ddfd98c..6c7d6f6597f 100644 --- a/src/components/views/context_menus/SpaceContextMenu.tsx +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -29,6 +29,7 @@ import { showCreateNewRoom, showCreateNewSubspace, showSpaceInvite, + showSpacePreferences, showSpaceSettings, } from "../../../utils/space"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; @@ -166,6 +167,14 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = ; } + const onPreferencesClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showSpacePreferences(space); + onFinished(); + }; + const onExploreRoomsClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); @@ -193,6 +202,11 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")} onClick={onExploreRoomsClick} /> + { settingsOption } { leaveOption } { devtoolsOption } diff --git a/src/components/views/dialogs/SpacePreferencesDialog.tsx b/src/components/views/dialogs/SpacePreferencesDialog.tsx new file mode 100644 index 00000000000..dc047245ac4 --- /dev/null +++ b/src/components/views/dialogs/SpacePreferencesDialog.tsx @@ -0,0 +1,99 @@ +/* +Copyright 2021 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, { ChangeEvent } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; + +import { _t, _td } from '../../../languageHandler'; +import BaseDialog from "../dialogs/BaseDialog"; +import { IDialogProps } from "./IDialogProps"; +import TabbedView, { Tab } from "../../structures/TabbedView"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import { useSettingValue } from "../../../hooks/useSettings"; +import SettingsStore from "../../../settings/SettingsStore"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import RoomName from "../elements/RoomName"; + +export enum SpacePreferenceTab { + Appearance = "SPACE_PREFERENCE_APPEARANCE_TAB", +} + +interface IProps extends IDialogProps { + space: Room; + initialTabId?: SpacePreferenceTab; +} + +const SpacePreferencesAppearanceTab = ({ space }: Pick) => { + const showPeople = useSettingValue("Spaces.showPeopleInSpace", space.roomId); + + return ( +
+
{ _t("Sections to show") }
+ +
+ ) => { + SettingsStore.setValue( + "Spaces.showPeopleInSpace", + space.roomId, + SettingLevel.ROOM_ACCOUNT, + !showPeople, + ); + }} + > + { _t("People") } + +

+ { _t("This groups your chats with members of this space. " + + "Turning this off will hide those chats from your view of %(spaceName)s.", { + spaceName: space.name, + }) } +

+
+
+ ); +}; + +const SpacePreferencesDialog: React.FC = ({ space, initialTabId, onFinished }) => { + const tabs = [ + new Tab( + SpacePreferenceTab.Appearance, + _td("Appearance"), + "mx_RoomSettingsDialog_notificationsIcon", + , + ), + ]; + + return ( + +

+ +

+
+ +
+
+ ); +}; + +export default SpacePreferencesDialog; diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx index 4cf08ca24e1..e50c12e6fbe 100644 --- a/src/components/views/dialogs/UserSettingsDialog.tsx +++ b/src/components/views/dialogs/UserSettingsDialog.tsx @@ -52,7 +52,7 @@ export enum UserTab { } interface IProps extends IDialogProps { - initialTabId?: string; + initialTabId?: UserTab; } interface IState { diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 100b1ca4350..6031fcca7e5 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -129,7 +129,7 @@ const NewRoomIntro = () => { let parentSpace: Room; if ( SpaceStore.instance.activeSpaceRoom?.canInvite(cli.getUserId()) && - SpaceStore.instance.getSpaceFilteredRoomIds(SpaceStore.instance.activeSpace).has(room.roomId) + SpaceStore.instance.isRoomInSpace(SpaceStore.instance.activeSpace, room.roomId) ) { parentSpace = SpaceStore.instance.activeSpaceRoom; } diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 8e29977786a..c36df0546d2 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -52,6 +52,7 @@ import { SpaceKey, UPDATE_SUGGESTED_ROOMS, UPDATE_SELECTED_SPACE, + isMetaSpace, } from "../../../stores/spaces"; import { shouldShowSpaceInvite, showAddExistingRooms, showCreateNewRoom, showSpaceInvite } from "../../../utils/space"; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -62,6 +63,7 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import SettingsStore from "../../../settings/SettingsStore"; interface IProps { onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void; @@ -493,10 +495,10 @@ export default class RoomList extends React.PureComponent { }; private onExplore = () => { - if (this.props.activeSpace[0] === "!") { + if (!isMetaSpace(this.props.activeSpace)) { defaultDispatcher.dispatch({ action: "view_room", - room_id: SpaceStore.instance.activeSpace, + room_id: this.props.activeSpace, }); } else { const initialText = RoomListStore.instance.getFirstNameFilterCondition()?.search; @@ -611,7 +613,12 @@ export default class RoomList extends React.PureComponent { if ( (this.props.activeSpace === MetaSpace.Favourites && orderedTagId !== DefaultTagID.Favourite) || (this.props.activeSpace === MetaSpace.People && orderedTagId !== DefaultTagID.DM) || - (this.props.activeSpace === MetaSpace.Orphans && orderedTagId === DefaultTagID.DM) + (this.props.activeSpace === MetaSpace.Orphans && orderedTagId === DefaultTagID.DM) || + ( + !isMetaSpace(this.props.activeSpace) && + orderedTagId === DefaultTagID.DM && + !SettingsStore.getValue("Spaces.showPeopleInSpace", this.props.activeSpace) + ) ) { alwaysVisible = false; } @@ -668,7 +675,7 @@ export default class RoomList extends React.PureComponent { kind="link" onClick={this.onExplore} > - { this.props.activeSpace[0] === "!" ? _t("Explore rooms") : _t("Explore all public rooms") } + { !isMetaSpace(this.props.activeSpace) ? _t("Explore rooms") : _t("Explore all public rooms") }
; } diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index e8d090644ac..7a0db1171d7 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -176,7 +176,7 @@ export class SpaceItem extends React.PureComponent { ); this.state = { - collapsed: collapsed, + collapsed, childSpaces: this.childSpaces, }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2851a8ce5f2..5d7d5f0787e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2727,6 +2727,8 @@ "Link to selected message": "Link to selected message", "Link to room": "Link to room", "Command Help": "Command Help", + "Sections to show": "Sections to show", + "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.": "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.", "Space settings": "Space settings", "Settings - %(spaceName)s": "Settings - %(spaceName)s", "Spaces you're in": "Spaces you're in", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index cf0776df062..4edc4884d5f 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -872,6 +872,11 @@ export const SETTINGS: {[setting: string]: ISetting} = { [MetaSpace.Home]: true, }, false), }, + "Spaces.showPeopleInSpace": { + supportedLevels: [SettingLevel.ROOM_ACCOUNT], + default: true, + controller: new IncompatibleController("showCommunitiesInsteadOfSpaces", null), + }, "showCommunitiesInsteadOfSpaces": { displayName: _td("Display Communities instead of Spaces"), description: _td("Temporarily show communities instead of Spaces for this session. " + diff --git a/src/settings/WatchManager.ts b/src/settings/WatchManager.ts index 744d75b136f..9eef8a5dbd9 100644 --- a/src/settings/WatchManager.ts +++ b/src/settings/WatchManager.ts @@ -18,7 +18,7 @@ import { SettingLevel } from "./SettingLevel"; export type CallbackFn = (changedInRoomId: string, atLevel: SettingLevel, newValAtLevel: any) => void; -const IRRELEVANT_ROOM = Symbol("irrelevant-room"); +const IRRELEVANT_ROOM: string = null; /** * Generalized management class for dealing with watchers on a per-handler (per-level) diff --git a/src/stores/room-list/SpaceWatcher.ts b/src/stores/room-list/SpaceWatcher.ts index e7d6e78206a..d9aa032c4b8 100644 --- a/src/stores/room-list/SpaceWatcher.ts +++ b/src/stores/room-list/SpaceWatcher.ts @@ -17,7 +17,7 @@ limitations under the License. import { RoomListStoreClass } from "./RoomListStore"; import { SpaceFilterCondition } from "./filters/SpaceFilterCondition"; import SpaceStore from "../spaces/SpaceStore"; -import { MetaSpace, SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces"; +import { isMetaSpace, MetaSpace, SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces"; /** * Watches for changes in spaces to manage the filter on the provided RoomListStore @@ -66,7 +66,7 @@ export class SpaceWatcher { }; private updateFilter = () => { - if (this.activeSpace[0] === "!") { + if (!isMetaSpace(this.activeSpace)) { SpaceStore.instance.traverseSpace(this.activeSpace, roomId => { this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded(); }); diff --git a/src/stores/room-list/filters/SpaceFilterCondition.ts b/src/stores/room-list/filters/SpaceFilterCondition.ts index fd815bf86fc..b2d86b459ac 100644 --- a/src/stores/room-list/filters/SpaceFilterCondition.ts +++ b/src/stores/room-list/filters/SpaceFilterCondition.ts @@ -22,6 +22,7 @@ import { IDestroyable } from "../../../utils/IDestroyable"; import SpaceStore from "../../spaces/SpaceStore"; import { MetaSpace, SpaceKey } from "../../spaces"; import { setHasDiff } from "../../../utils/sets"; +import SettingsStore from "../../../settings/SettingsStore"; /** * A filter condition for the room list which reveals rooms which @@ -31,6 +32,8 @@ import { setHasDiff } from "../../../utils/sets"; */ export class SpaceFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable { private roomIds = new Set(); + private userIds = new Set(); + private showPeopleInSpace = true; private space: SpaceKey = MetaSpace.Home; public get kind(): FilterKind { @@ -38,7 +41,7 @@ export class SpaceFilterCondition extends EventEmitter implements IFilterConditi } public isVisible(room: Room): boolean { - return this.roomIds.has(room.roomId); + return SpaceStore.instance.isRoomInSpace(this.space, room.roomId); } private onStoreUpdate = async (): Promise => { @@ -46,7 +49,18 @@ export class SpaceFilterCondition extends EventEmitter implements IFilterConditi // clone the set as it may be mutated by the space store internally this.roomIds = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(this.space)); - if (setHasDiff(beforeRoomIds, this.roomIds)) { + const beforeUserIds = this.userIds; + // clone the set as it may be mutated by the space store internally + this.userIds = new Set(SpaceStore.instance.getSpaceFilteredUserIds(this.space)); + + const beforeShowPeopleInSpace = this.showPeopleInSpace; + this.showPeopleInSpace = this.space[0] !== "!" || + SettingsStore.getValue("Spaces.showPeopleInSpace", this.space); + + if (beforeShowPeopleInSpace !== this.showPeopleInSpace || + setHasDiff(beforeRoomIds, this.roomIds) || + setHasDiff(beforeUserIds, this.userIds) + ) { this.emit(FILTER_CHANGED); // XXX: Room List Store has a bug where updates to the pre-filter during a local echo of a // tags transition seem to be ignored, so refire in the next tick to work around it diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index 13cd8b5b3a0..068d51bacef 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -20,6 +20,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { IRoomCapability } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -32,15 +33,15 @@ import { SpaceNotificationState } from "../notifications/SpaceNotificationState" import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore"; import { DefaultTagID } from "../room-list/models"; import { EnhancedMap, mapDiff } from "../../utils/maps"; -import { setHasDiff } from "../../utils/sets"; +import { setDiff, setHasDiff } from "../../utils/sets"; import RoomViewStore from "../RoomViewStore"; import { Action } from "../../dispatcher/actions"; import { arrayHasDiff, arrayHasOrderChange } from "../../utils/arrays"; -import { objectDiff } from "../../utils/objects"; import { reorderLexicographically } from "../../utils/stringOrderField"; import { TAG_ORDER } from "../../components/views/rooms/RoomList"; import { SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload"; import { + isMetaSpace, ISuggestedRoom, MetaSpace, SpaceKey, @@ -51,6 +52,7 @@ import { UPDATE_TOP_LEVEL_SPACES, } from "."; import { getCachedRoomIDForAlias } from "../../RoomAliasCache"; +import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; interface IState {} @@ -93,14 +95,14 @@ const getRoomFn: FetchRoomFn = (room: Room) => { export class SpaceStoreClass extends AsyncStoreWithClient { // The spaces representing the roots of the various tree-like hierarchies private rootSpaces: Room[] = []; - // The list of rooms not present in any currently joined spaces - private orphanedRooms = new Set(); // Map from room ID to set of spaces which list it as a child private parentMap = new EnhancedMap>(); // Map from SpaceKey to SpaceNotificationState instance representing that space private notificationStateMap = new Map(); // Map from space key to Set of room IDs that should be shown as part of that space's filter - private spaceFilteredRooms = new Map>(); + private spaceFilteredRooms = new Map>(); // won't contain MetaSpace.People + // Map from space ID to Set of user IDs that should be shown as part of that space's filter + private spaceFilteredUsers = new Map>(); // The space currently selected in the Space Panel private _activeSpace?: SpaceKey = MetaSpace.Home; // set properly by onReady private _suggestedRooms: ISuggestedRoom[] = []; @@ -115,6 +117,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { SettingsStore.monitorSetting("Spaces.allRoomsInHome", null); SettingsStore.monitorSetting("Spaces.enabledMetaSpaces", null); + SettingsStore.monitorSetting("Spaces.showPeopleInSpace", null); } public get invitedSpaces(): Room[] { @@ -134,7 +137,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public get activeSpaceRoom(): Room | null { - if (this._activeSpace[0] !== "!") return null; + if (isMetaSpace(this._activeSpace)) return null; return this.matrixClient?.getRoom(this._activeSpace); } @@ -147,7 +150,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public setActiveRoomInSpace(space: SpaceKey): void { - if (space[0] === "!" && !this.matrixClient?.getRoom(space)?.isSpaceRoom()) return; + if (!isMetaSpace(space) && !this.matrixClient?.getRoom(space)?.isSpaceRoom()) return; if (space !== this.activeSpace) this.setActiveSpace(space); if (space) { @@ -195,7 +198,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (!space || !this.matrixClient || space === this.activeSpace) return; let cliSpace: Room; - if (space[0] === "!") { + if (!isMetaSpace(space)) { cliSpace = this.matrixClient.getRoom(space); if (!cliSpace?.isSpaceRoom()) return; } else if (!this.enabledMetaSpaces.includes(space as MetaSpace)) { @@ -215,7 +218,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // else view space home or home depending on what is being clicked on if (cliSpace?.getMyMembership() !== "invite" && this.matrixClient.getRoom(roomId)?.getMyMembership() === "join" && - this.getSpaceFilteredRoomIds(space).has(roomId) + this.isRoomInSpace(space, roomId) ) { defaultDispatcher.dispatch({ action: "view_room", @@ -349,6 +352,36 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this.parentMap.get(roomId) || new Set(); } + public isRoomInSpace(space: SpaceKey, roomId: string): boolean { + if (space === MetaSpace.Home && this.allRoomsInHome) { + return true; + } + + if (this.spaceFilteredRooms.get(space)?.has(roomId)) { + return true; + } + + const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId); + if (!dmPartner) { + return false; + } + // beyond this point we know this is a DM + + if (space === MetaSpace.Home || space === MetaSpace.People) { + // these spaces contain all DMs + return true; + } + + if (!isMetaSpace(space) && + this.spaceFilteredUsers.get(space)?.has(dmPartner) && + SettingsStore.getValue("Spaces.showPeopleInSpace", space) + ) { + return true; + } + + return false; + } + public getSpaceFilteredRoomIds = (space: SpaceKey): Set => { if (space === MetaSpace.Home && this.allRoomsInHome) { return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); @@ -356,162 +389,147 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this.spaceFilteredRooms.get(space) || new Set(); }; - private rebuild = throttle(() => { - if (!this.matrixClient) return; + public getSpaceFilteredUserIds = (space: SpaceKey): Set => { + if (space === MetaSpace.Home && this.allRoomsInHome) { + return undefined; + } + if (isMetaSpace(space)) return undefined; + return this.spaceFilteredUsers.get(space) || new Set(); + }; - const [visibleSpaces, visibleRooms] = partitionSpacesAndRooms(this.matrixClient.getVisibleRooms()); - const [joinedSpaces, invitedSpaces] = visibleSpaces.reduce((arr, s) => { - if (s.getMyMembership() === "join") { - arr[0].push(s); - } else if (s.getMyMembership() === "invite") { - arr[1].push(s); - } - return arr; - }, [[], []]); + private markTreeChildren = (rootSpace: Room, unseen: Set): void => { + const stack = [rootSpace]; + while (stack.length) { + const space = stack.pop(); + unseen.delete(space); + this.getChildSpaces(space.roomId).forEach(space => { + if (unseen.has(space)) { + stack.push(space); + } + }); + } + }; + private findRootSpaces = (joinedSpaces: Room[]): Room[] => { // exclude invited spaces from unseenChildren as they will be forcibly shown at the top level of the treeview - const unseenChildren = new Set([...visibleRooms, ...joinedSpaces]); - const backrefs = new EnhancedMap>(); + const unseenSpaces = new Set(joinedSpaces); - // Sort spaces by room ID to force the cycle breaking to be deterministic - const spaces = sortBy(joinedSpaces, space => space.roomId); - - // TODO handle cleaning up links when a Space is removed - spaces.forEach(space => { - const children = this.getChildren(space.roomId); - children.forEach(child => { - unseenChildren.delete(child); - - backrefs.getOrCreate(child.roomId, new Set()).add(space.roomId); + joinedSpaces.forEach(space => { + this.getChildSpaces(space.roomId).forEach(subspace => { + unseenSpaces.delete(subspace); }); }); - const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren)); + // Consider any spaces remaining in unseenSpaces as root, + // given they are not children of any known spaces. + // The hierarchy from these roots may not yet be exhaustive due to the possibility of full-cycles. + const rootSpaces = Array.from(unseenSpaces); - // somewhat algorithm to handle full-cycles - const detachedNodes = new Set(spaces); - - const markTreeChildren = (rootSpace: Room, unseen: Set) => { - const stack = [rootSpace]; - while (stack.length) { - const op = stack.pop(); - unseen.delete(op); - this.getChildSpaces(op.roomId).forEach(space => { - if (unseen.has(space)) { - stack.push(space); - } - }); - } - }; + // Next we need to determine the roots of any remaining full-cycles. + // We sort spaces by room ID to force the cycle breaking to be deterministic. + const detachedNodes = new Set(sortBy(joinedSpaces, space => space.roomId)); + // Mark any nodes which are children of our existing root spaces as attached. rootSpaces.forEach(rootSpace => { - markTreeChildren(rootSpace, detachedNodes); + this.markTreeChildren(rootSpace, detachedNodes); }); // Handle spaces forming fully cyclical relationships. - // In order, assume each detachedNode is a root unless it has already + // In order, assume each remaining detachedNode is a root unless it has already // been claimed as the child of prior detached node. // Work from a copy of the detachedNodes set as it will be mutated as part of this operation. + // TODO consider sorting by number of in-refs to favour nodes with fewer parents. Array.from(detachedNodes).forEach(detachedNode => { - if (!detachedNodes.has(detachedNode)) return; + if (!detachedNodes.has(detachedNode)) return; // already claimed, skip // declare this detached node a new root, find its children, without ever looping back to it - detachedNodes.delete(detachedNode); - rootSpaces.push(detachedNode); - markTreeChildren(detachedNode, detachedNodes); - - // TODO only consider a detached node a root space if it has no *parents other than the ones forming cycles + rootSpaces.push(detachedNode); // consider this node a new root space + this.markTreeChildren(detachedNode, detachedNodes); // declare this node and its children attached }); - // TODO neither of these handle an A->B->C->A with an additional C->D - // detachedNodes.forEach(space => { - // rootSpaces.push(space); - // }); + return rootSpaces; + }; + + private rebuildSpaceHierarchy = () => { + const visibleSpaces = this.matrixClient.getVisibleRooms().filter(r => r.isSpaceRoom()); + const [joinedSpaces, invitedSpaces] = visibleSpaces.reduce(([joined, invited], s) => { + switch (getEffectiveMembership(s.getMyMembership())) { + case EffectiveMembership.Join: + joined.push(s); + break; + case EffectiveMembership.Invite: + invited.push(s); + break; + } + return [joined, invited]; + }, [[], []] as [Room[], Room[]]); - this.orphanedRooms = new Set(orphanedRooms.map(r => r.roomId)); + const rootSpaces = this.findRootSpaces(joinedSpaces); + const oldRootSpaces = this.rootSpaces; this.rootSpaces = this.sortRootSpaces(rootSpaces); - this.parentMap = backrefs; - // if the currently selected space no longer exists, remove its selection - if (this._activeSpace[0] === "!" && detachedNodes.has(this.matrixClient.getRoom(this._activeSpace))) { - this.goToFirstSpace(); - } + this.onRoomsUpdate(); - this.onRoomsUpdate(); // TODO only do this if a change has happened - this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces); + if (arrayHasOrderChange(oldRootSpaces, this.rootSpaces)) { + this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces); + } - // build initial state of invited spaces as we would have missed the emitted events about the room at launch + const oldInvitedSpaces = this._invitedSpaces; this._invitedSpaces = new Set(this.sortRootSpaces(invitedSpaces)); - this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); - }, 100, { trailing: true, leading: true }); - - private onSpaceUpdate = () => { - this.rebuild(); + if (setHasDiff(oldInvitedSpaces, this._invitedSpaces)) { + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } }; - private showInHomeSpace = (room: Room) => { - if (this.allRoomsInHome) return true; - if (room.isSpaceRoom()) return false; - return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space - || DMRoomMap.shared().getUserIdForRoomId(room.roomId); // put all DMs in the Home Space + private rebuildParentMap = () => { + const joinedSpaces = this.matrixClient.getVisibleRooms().filter(r => { + return r.isSpaceRoom() && r.getMyMembership() === "join"; + }); + + this.parentMap = new EnhancedMap>(); + joinedSpaces.forEach(space => { + const children = this.getChildren(space.roomId); + children.forEach(child => { + this.parentMap.getOrCreate(child.roomId, new Set()).add(space.roomId); + }); + }); }; - // Update a given room due to its tag changing (e.g DM-ness or Fav-ness) - // This can only change whether it shows up in the HOME_SPACE or not - private onRoomUpdate = (room: Room) => { - const enabledMetaSpaces = new Set(this.enabledMetaSpaces); - // TODO more metaspace stuffs - if (enabledMetaSpaces.has(MetaSpace.Home)) { - if (this.showInHomeSpace(room)) { - this.spaceFilteredRooms.get(MetaSpace.Home)?.add(room.roomId); - this.emit(MetaSpace.Home); - } else if (!this.orphanedRooms.has(room.roomId)) { - this.spaceFilteredRooms.get(MetaSpace.Home)?.delete(room.roomId); - this.emit(MetaSpace.Home); - } + private rebuildHomeSpace = () => { + if (this.allRoomsInHome) { + // this is a special-case to not have to maintain a set of all rooms + this.spaceFilteredRooms.delete(MetaSpace.Home); + } else { + const rooms = new Set(this.matrixClient.getVisibleRooms().filter(this.showInHomeSpace).map(r => r.roomId)); + this.spaceFilteredRooms.set(MetaSpace.Home, rooms); } - }; - private onSpaceMembersChange = (ev: MatrixEvent) => { - // skip this update if we do not have a DM with this user - if (DMRoomMap.shared().getDMRoomsForUserId(ev.getStateKey()).length < 1) return; - this.onRoomsUpdate(); + if (this.activeSpace === MetaSpace.Home) { + this.switchSpaceIfNeeded(); + } }; - private onRoomsUpdate = throttle(() => { - // TODO resolve some updates as deltas + private rebuildMetaSpaces = () => { + const enabledMetaSpaces = new Set(this.enabledMetaSpaces); const visibleRooms = this.matrixClient.getVisibleRooms(); - const oldFilteredRooms = this.spaceFilteredRooms; - this.spaceFilteredRooms = new Map(); - - const enabledMetaSpaces = new Set(this.enabledMetaSpaces); - // populate the Home metaspace if it is enabled and is not set to all rooms - if (enabledMetaSpaces.has(MetaSpace.Home) && !this.allRoomsInHome) { - // put all room invites in the Home Space - const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); - this.spaceFilteredRooms.set(MetaSpace.Home, new Set(invites.map(r => r.roomId))); - - visibleRooms.forEach(room => { - if (this.showInHomeSpace(room)) { - this.spaceFilteredRooms.get(MetaSpace.Home).add(room.roomId); - } - }); + if (enabledMetaSpaces.has(MetaSpace.Home)) { + this.rebuildHomeSpace(); + } else { + this.spaceFilteredRooms.delete(MetaSpace.Home); } - // populate the Favourites metaspace if it is enabled if (enabledMetaSpaces.has(MetaSpace.Favourites)) { const favourites = visibleRooms.filter(r => r.tags[DefaultTagID.Favourite]); this.spaceFilteredRooms.set(MetaSpace.Favourites, new Set(favourites.map(r => r.roomId))); + } else { + this.spaceFilteredRooms.delete(MetaSpace.Favourites); } - // populate the People metaspace if it is enabled - if (enabledMetaSpaces.has(MetaSpace.People)) { - const people = visibleRooms.filter(r => DMRoomMap.shared().getUserIdForRoomId(r.roomId)); - this.spaceFilteredRooms.set(MetaSpace.People, new Set(people.map(r => r.roomId))); - } + // The People metaspace doesn't need maintaining - // populate the Orphans metaspace if it is enabled - if (enabledMetaSpaces.has(MetaSpace.Orphans)) { + // Populate the orphans space if the Home space is enabled as it is a superset of it. + // Home is effectively a super set of People + Orphans with the addition of having all invites too. + if (enabledMetaSpaces.has(MetaSpace.Orphans) || enabledMetaSpaces.has(MetaSpace.Home)) { const orphans = visibleRooms.filter(r => { // filter out DMs and rooms with >0 parents return !this.parentMap.get(r.roomId)?.size && !DMRoomMap.shared().getUserIdForRoomId(r.roomId); @@ -519,6 +537,150 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.spaceFilteredRooms.set(MetaSpace.Orphans, new Set(orphans.map(r => r.roomId))); } + if (isMetaSpace(this.activeSpace)) { + this.switchSpaceIfNeeded(); + } + }; + + private updateNotificationStates = (spaces?: SpaceKey[]) => { + const enabledMetaSpaces = new Set(this.enabledMetaSpaces); + const visibleRooms = this.matrixClient.getVisibleRooms(); + + let dmBadgeSpace: MetaSpace; + // only show badges on dms on the most relevant space if such exists + if (enabledMetaSpaces.has(MetaSpace.People)) { + dmBadgeSpace = MetaSpace.People; + } else if (enabledMetaSpaces.has(MetaSpace.Home)) { + dmBadgeSpace = MetaSpace.Home; + } + + if (!spaces) { + spaces = [...this.spaceFilteredRooms.keys()]; + if (dmBadgeSpace === MetaSpace.People) { + spaces.push(MetaSpace.People); + } + if (enabledMetaSpaces.has(MetaSpace.Home) && !this.allRoomsInHome) { + spaces.push(MetaSpace.Home); + } + } + + spaces.forEach((s) => { + if (this.allRoomsInHome && s === MetaSpace.Home) return; // we'll be using the global notification state, skip + + // Update NotificationStates + this.getNotificationState(s).setRooms(visibleRooms.filter(room => { + if (s === MetaSpace.People) { + return this.isRoomInSpace(MetaSpace.People, room.roomId); + } + + if (room.isSpaceRoom() || !this.spaceFilteredRooms.get(s).has(room.roomId)) return false; + + if (dmBadgeSpace && DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { + return s === dmBadgeSpace; + } + + return true; + })); + }); + + if (dmBadgeSpace !== MetaSpace.People) { + this.notificationStateMap.delete(MetaSpace.People); + } + }; + + private showInHomeSpace = (room: Room): boolean => { + if (this.allRoomsInHome) return true; + if (room.isSpaceRoom()) return false; + return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space + || !!DMRoomMap.shared().getUserIdForRoomId(room.roomId) || // put all DMs in the Home Space + room.getMyMembership() === "invite"; // put all invites in the Home Space + }; + + private static isInSpace(member: RoomMember): boolean { + return member.membership === "join" || member.membership === "invite"; + } + + private static getSpaceMembers(space: Room): string[] { + return space.getMembers().filter(SpaceStoreClass.isInSpace).map(m => m.userId); + } + + // Method for resolving the impact of a single user's membership change in the given Space and its hierarchy + private onMemberUpdate = (space: Room, userId: string) => { + const inSpace = SpaceStoreClass.isInSpace(space.getMember(userId)); + + if (this.spaceFilteredUsers.get(space.roomId).has(userId)) { + if (inSpace) return; // nothing to do, user was already joined to subspace + if (this.getChildSpaces(space.roomId).some(s => this.spaceFilteredUsers.get(s.roomId).has(userId))) { + return; // nothing to do, this user leaving will have no effect as they are in a subspace + } + } else if (!inSpace) { + return; // nothing to do, user already not in the list + } + + const seen = new Set(); + const stack = [space.roomId]; + while (stack.length) { + const spaceId = stack.pop(); + seen.add(spaceId); + + if (inSpace) { + // add to our list and to that of all of our parents + this.spaceFilteredUsers.get(spaceId).add(userId); + } else { + // remove from our list and that of all of our parents until we hit a parent with this user + this.spaceFilteredUsers.get(spaceId).delete(userId); + } + + this.getKnownParents(spaceId).forEach(parentId => { + if (seen.has(parentId)) return; + const parent = this.matrixClient.getRoom(parentId); + // because spaceFilteredUsers is cumulative, if we are removing from lower in the hierarchy, + // but the member is present higher in the hierarchy we must take care not to wrongly over-remove them. + if (inSpace || !SpaceStoreClass.isInSpace(parent.getMember(userId))) { + stack.push(parentId); + } + }); + } + + this.switchSpaceIfNeeded(); + }; + + private onMembersUpdate = (space: Room, seen = new Set()) => { + // Update this space's membership list + const userIds = new Set(SpaceStoreClass.getSpaceMembers(space)); + // We only need to look one level with children + // as any further descendants will already be in their parent's superset + this.getChildSpaces(space.roomId).forEach(subspace => { + SpaceStoreClass.getSpaceMembers(subspace).forEach(userId => { + userIds.add(userId); + }); + }); + this.spaceFilteredUsers.set(space.roomId, userIds); + this.emit(space.roomId); + + // Traverse all parents and update them too + this.getKnownParents(space.roomId).forEach(parentId => { + if (seen.has(parentId)) return; + const parent = this.matrixClient.getRoom(parentId); + if (parent) { + const newSeen = new Set(seen); + newSeen.add(parentId); + this.onMembersUpdate(parent, newSeen); + } + }); + }; + + private onRoomsUpdate = () => { + const visibleRooms = this.matrixClient.getVisibleRooms(); + + const oldFilteredRooms = this.spaceFilteredRooms; + const oldFilteredUsers = this.spaceFilteredUsers; + this.spaceFilteredRooms = new Map(); + this.spaceFilteredUsers = new Map(); + + this.rebuildParentMap(); + this.rebuildMetaSpaces(); + const hiddenChildren = new EnhancedMap>(); visibleRooms.forEach(room => { if (room.getMyMembership() !== "join") return; @@ -530,31 +692,26 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.rootSpaces.forEach(s => { // traverse each space tree in DFS to build up the supersets as you go up, // reusing results from like subtrees. - const fn = (spaceId: string, parentPath: Set): Set => { + const fn = (spaceId: string, parentPath: Set): [Set, Set] => { if (parentPath.has(spaceId)) return; // prevent cycles // reuse existing results if multiple similar branches exist - if (this.spaceFilteredRooms.has(spaceId)) { - return this.spaceFilteredRooms.get(spaceId); + if (this.spaceFilteredRooms.has(spaceId) && this.spaceFilteredUsers.has(spaceId)) { + return [this.spaceFilteredRooms.get(spaceId), this.spaceFilteredUsers.get(spaceId)]; } const [childSpaces, childRooms] = partitionSpacesAndRooms(this.getChildren(spaceId)); const roomIds = new Set(childRooms.map(r => r.roomId)); const space = this.matrixClient?.getRoom(spaceId); - - // Add relevant DMs - space?.getMembers().forEach(member => { - if (member.membership !== "join" && member.membership !== "invite") return; - DMRoomMap.shared().getDMRoomsForUserId(member.userId).forEach(roomId => { - roomIds.add(roomId); - }); - }); + const userIds = new Set(space?.getMembers().filter(m => { + return m.membership === "join" || m.membership === "invite"; + }).map(m => m.userId)); const newPath = new Set(parentPath).add(spaceId); childSpaces.forEach(childSpace => { - fn(childSpace.roomId, newPath)?.forEach(roomId => { - roomIds.add(roomId); - }); + const [rooms, users] = fn(childSpace.roomId, newPath) ?? []; + rooms?.forEach(roomId => roomIds.add(roomId)); + users?.forEach(userId => userIds.add(userId)); }); hiddenChildren.get(spaceId)?.forEach(roomId => { roomIds.add(roomId); @@ -565,42 +722,59 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this.matrixClient.getRoomUpgradeHistory(roomId, true).map(r => r.roomId); })); this.spaceFilteredRooms.set(spaceId, expandedRoomIds); - return expandedRoomIds; + this.spaceFilteredUsers.set(spaceId, userIds); + return [expandedRoomIds, userIds]; }; fn(s.roomId, new Set()); }); - const diff = mapDiff(oldFilteredRooms, this.spaceFilteredRooms); + const roomDiff = mapDiff(oldFilteredRooms, this.spaceFilteredRooms); + const userDiff = mapDiff(oldFilteredUsers, this.spaceFilteredUsers); // filter out keys which changed by reference only by checking whether the sets differ - const changed = diff.changed.filter(k => setHasDiff(oldFilteredRooms.get(k), this.spaceFilteredRooms.get(k))); - [...diff.added, ...diff.removed, ...changed].forEach(k => { + const roomsChanged = roomDiff.changed.filter(k => { + return setHasDiff(oldFilteredRooms.get(k), this.spaceFilteredRooms.get(k)); + }); + const usersChanged = userDiff.changed.filter(k => { + return setHasDiff(oldFilteredUsers.get(k), this.spaceFilteredUsers.get(k)); + }); + + const changeSet = new Set([ + ...roomDiff.added, + ...userDiff.added, + ...roomDiff.removed, + ...userDiff.removed, + ...roomsChanged, + ...usersChanged, + ]); + + changeSet.forEach(k => { this.emit(k); }); - let dmBadgeSpace: MetaSpace; - // only show badges on dms on the most relevant space if such exists - if (enabledMetaSpaces.has(MetaSpace.People)) { - dmBadgeSpace = MetaSpace.People; - } else if (enabledMetaSpaces.has(MetaSpace.Home)) { - dmBadgeSpace = MetaSpace.Home; + if (changeSet.has(this.activeSpace)) { + this.switchSpaceIfNeeded(); } - this.spaceFilteredRooms.forEach((roomIds, s) => { - if (this.allRoomsInHome && s === MetaSpace.Home) return; // we'll be using the global notification state, skip + const notificationStatesToUpdate = [...changeSet]; + if (this.enabledMetaSpaces.includes(MetaSpace.People) && + userDiff.added.length + userDiff.removed.length + usersChanged.length > 0 + ) { + notificationStatesToUpdate.push(MetaSpace.People); + } + this.updateNotificationStates(notificationStatesToUpdate); + }; - // Update NotificationStates - this.getNotificationState(s).setRooms(visibleRooms.filter(room => { - if (!roomIds.has(room.roomId) || room.isSpaceRoom()) return false; + private switchSpaceIfNeeded = throttle(() => { + const roomId = RoomViewStore.getRoomId(); + if (this.isRoomInSpace(this.activeSpace, roomId)) return; - if (dmBadgeSpace && DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { - return s === dmBadgeSpace; - } - - return true; - })); - }); - }, 100, { trailing: true, leading: true }); + if (this.matrixClient.getRoom(roomId)?.isSpaceRoom()) { + this.goToFirstSpace(true); + } else { + this.switchToRelatedSpace(roomId); + } + }, 100, { leading: true, trailing: true }); private switchToRelatedSpace = (roomId: string) => { if (this.suggestedRooms.find(r => r.room_id === roomId)) return; @@ -616,11 +790,15 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // otherwise, try to find a metaspace which contains this room if (!parent) { // search meta spaces in reverse as Home is the first and least specific one - parent = [...this.enabledMetaSpaces].reverse().find(s => this.getSpaceFilteredRoomIds(s).has(roomId)); + parent = [...this.enabledMetaSpaces].reverse().find(s => this.isRoomInSpace(s, roomId)); } // don't trigger a context switch when we are switching a space to match the chosen room - this.setActiveSpace(parent ?? MetaSpace.Home, false); // TODO + if (parent) { + this.setActiveSpace(parent, false); + } else { + this.goToFirstSpace(); + } }; private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => { @@ -632,10 +810,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const membership = newMembership || roomMembership; if (!room.isSpaceRoom()) { - // this.onRoomUpdate(room); - // this.onRoomsUpdate(); - // ideally we only need onRoomsUpdate here but it doesn't rebuild parentMap so always adds new rooms to Home - this.rebuild(); + this.onRoomsUpdate(); if (membership === "join") { // the user just joined a room, remove it from the suggested list if it was there @@ -655,13 +830,21 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // Space if (membership === "invite") { + const len = this._invitedSpaces.size; this._invitedSpaces.add(room); - this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + if (len !== this._invitedSpaces.size) { + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } } else if (oldMembership === "invite" && membership !== "join") { - this._invitedSpaces.delete(room); - this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + if (this._invitedSpaces.delete(room)) { + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } } else { - this.onSpaceUpdate(); + this.rebuildSpaceHierarchy(); + // fire off updates to all parent listeners + this.parentMap.get(room.roomId)?.forEach((parentId) => { + this.emit(parentId); + }); this.emit(room.roomId); } @@ -687,28 +870,36 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (!room) return; switch (ev.getType()) { - case EventType.SpaceChild: + case EventType.SpaceChild: { + const target = this.matrixClient.getRoom(ev.getStateKey()); + if (room.isSpaceRoom()) { - this.onSpaceUpdate(); + if (target?.isSpaceRoom()) { + this.rebuildSpaceHierarchy(); + this.emit(target.roomId); + } else { + this.onRoomsUpdate(); + } this.emit(room.roomId); } if (room.roomId === this.activeSpace && // current space - this.matrixClient.getRoom(ev.getStateKey())?.getMyMembership() !== "join" && // target not joined + target?.getMyMembership() !== "join" && // target not joined ev.getPrevContent().suggested !== ev.getContent().suggested // suggested flag changed ) { this.loadSuggestedRooms(room); } break; + } case EventType.SpaceParent: // TODO rebuild the space parent and not the room - check permissions? // TODO confirm this after implementing parenting behaviour if (room.isSpaceRoom()) { - this.onSpaceUpdate(); - } else if (!this.allRoomsInHome) { - this.onRoomUpdate(room); + this.rebuildSpaceHierarchy(); + } else { + this.onRoomsUpdate(); } this.emit(room.roomId); break; @@ -724,8 +915,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // listening for m.room.member events in onRoomState above doesn't work as the Member object isn't updated by then private onRoomStateMembers = (ev: MatrixEvent) => { const room = this.matrixClient.getRoom(ev.getRoomId()); - if (room?.isSpaceRoom()) { - this.onSpaceMembersChange(ev); + const userId = ev.getStateKey(); + if (room?.isSpaceRoom() && // only consider space rooms + DMRoomMap.shared().getDMRoomsForUserId(userId).length > 0 && // only consider members we have a DM with + ev.getPrevContent().membership !== ev.getContent().membership // only consider when membership changes + ) { + this.onMemberUpdate(room, userId); } }; @@ -744,35 +939,73 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const oldTags = lastEv?.getContent()?.tags || {}; const newTags = ev.getContent()?.tags || {}; if (!!oldTags[DefaultTagID.Favourite] !== !!newTags[DefaultTagID.Favourite]) { - this.onRoomUpdate(room); + this.onRoomFavouriteChange(room); } } }; - private onAccountData = (ev: MatrixEvent, prevEvent?: MatrixEvent) => { - if (!this.allRoomsInHome && ev.getType() === EventType.Direct) { - const lastContent = prevEvent?.getContent() ?? {}; - const content = ev.getContent(); + private onRoomFavouriteChange(room: Room) { + if (this.enabledMetaSpaces.includes(MetaSpace.Favourites)) { + if (room.tags[DefaultTagID.Favourite]) { + this.spaceFilteredRooms.get(MetaSpace.Favourites).add(room.roomId); + } else { + this.spaceFilteredRooms.get(MetaSpace.Favourites).delete(room.roomId); + } + this.emit(MetaSpace.Favourites); + } + } + + private onRoomDmChange(room: Room, isDm: boolean): void { + const enabledMetaSpaces = new Set(this.enabledMetaSpaces); - const diff = objectDiff>(lastContent, content); - // filter out keys which changed by reference only by checking whether the sets differ - const changed = diff.changed.filter(k => arrayHasDiff(lastContent[k], content[k])); - // DM tag changes, refresh relevant rooms - new Set([...diff.added, ...diff.removed, ...changed]).forEach(roomId => { + if (!this.allRoomsInHome && enabledMetaSpaces.has(MetaSpace.Home)) { + const homeRooms = this.spaceFilteredRooms.get(MetaSpace.Home); + if (this.showInHomeSpace(room)) { + homeRooms?.add(room.roomId); + } else if (!this.spaceFilteredRooms.get(MetaSpace.Orphans).has(room.roomId)) { + this.spaceFilteredRooms.get(MetaSpace.Home)?.delete(room.roomId); + } + + this.emit(MetaSpace.Home); + } + + if (enabledMetaSpaces.has(MetaSpace.People)) { + this.emit(MetaSpace.People); + } + + if (enabledMetaSpaces.has(MetaSpace.Orphans) || enabledMetaSpaces.has(MetaSpace.Home)) { + if (isDm && this.spaceFilteredRooms.get(MetaSpace.Orphans).delete(room.roomId)) { + this.emit(MetaSpace.Orphans); + this.emit(MetaSpace.Home); + } + } + } + + private onAccountData = (ev: MatrixEvent, prevEv?: MatrixEvent) => { + if (ev.getType() === EventType.Direct) { + const previousRooms = new Set(Object.values(prevEv?.getContent>() ?? {}).flat()); + const currentRooms = new Set(Object.values(ev.getContent>()).flat()); + + const diff = setDiff(previousRooms, currentRooms); + [...diff.added, ...diff.removed].forEach(roomId => { const room = this.matrixClient?.getRoom(roomId); if (room) { - this.onRoomUpdate(room); + this.onRoomDmChange(room, currentRooms.has(roomId)); } }); + + if (diff.removed.length > 0) { + this.switchSpaceIfNeeded(); + } } }; protected async reset() { this.rootSpaces = []; - this.orphanedRooms = new Set(); this.parentMap = new EnhancedMap(); this.notificationStateMap = new Map(); this.spaceFilteredRooms = new Map(); + this.spaceFilteredUsers = new Map(); this._activeSpace = MetaSpace.Home; // set properly by onReady this._suggestedRooms = []; this._invitedSpaces = new Set(); @@ -809,17 +1042,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const enabledMetaSpaces = SettingsStore.getValue("Spaces.enabledMetaSpaces"); this._enabledMetaSpaces = metaSpaceOrder.filter(k => enabledMetaSpaces[k]) as MetaSpace[]; - await this.onSpaceUpdate(); // trigger an initial update + this.rebuildSpaceHierarchy(); // trigger an initial update // restore selected state from last session if any and still valid const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY); - if (lastSpaceId && ( - lastSpaceId[0] === "!" ? this.matrixClient.getRoom(lastSpaceId) : enabledMetaSpaces[lastSpaceId] - )) { + const valid = (lastSpaceId && !isMetaSpace(lastSpaceId)) + ? this.matrixClient.getRoom(lastSpaceId) + : enabledMetaSpaces[lastSpaceId]; + if (valid) { // don't context switch here as it may break permalinks this.setActiveSpace(lastSpaceId, false); } else { - this.goToFirstSpace(); + this.switchSpaceIfNeeded(); } } @@ -828,7 +1062,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } protected async onAction(payload: ActionPayload) { - if (!spacesEnabled) return; + if (!spacesEnabled || !this.matrixClient) return; + switch (payload.action) { case "view_room": { // Don't auto-switch rooms when reacting to a context-switch @@ -842,12 +1077,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (!roomId) return; // we'll get re-fired with the room ID shortly - const room = this.matrixClient?.getRoom(roomId); + const room = this.matrixClient.getRoom(roomId); if (room?.isSpaceRoom()) { // Don't context switch when navigating to the space room // as it will cause you to end up in the wrong room this.setActiveSpace(room.roomId, false); - } else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) { + } else if (!this.isRoomInSpace(this.activeSpace, roomId)) { this.switchToRelatedSpace(roomId); } @@ -866,9 +1101,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { break; case "after_leave_room": - if (this._activeSpace[0] === "!" && payload.room_id === this._activeSpace) { + if (!isMetaSpace(this._activeSpace) && payload.room_id === this._activeSpace) { // User has left the current space, go to first space - this.goToFirstSpace(); + this.goToFirstSpace(true); } break; @@ -892,7 +1127,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (this.allRoomsInHome !== newValue) { this._allRoomsInHome = newValue; this.emit(UPDATE_HOME_BEHAVIOUR, this.allRoomsInHome); - this.rebuild(); // rebuild everything + if (this.enabledMetaSpaces.includes(MetaSpace.Home)) { + this.rebuildHomeSpace(); + } } break; } @@ -901,18 +1138,39 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const newValue = SettingsStore.getValue("Spaces.enabledMetaSpaces"); const enabledMetaSpaces = metaSpaceOrder.filter(k => newValue[k]) as MetaSpace[]; if (arrayHasDiff(this._enabledMetaSpaces, enabledMetaSpaces)) { + const hadPeopleOrHomeEnabled = this.enabledMetaSpaces.some(s => { + return s === MetaSpace.Home || s === MetaSpace.People; + }); this._enabledMetaSpaces = enabledMetaSpaces; - // if a metaspace currently being viewed was remove, go to another one - if (this.activeSpace[0] !== "!" && - !enabledMetaSpaces.includes(this.activeSpace as MetaSpace) - ) { - this.goToFirstSpace(); + const hasPeopleOrHomeEnabled = this.enabledMetaSpaces.some(s => { + return s === MetaSpace.Home || s === MetaSpace.People; + }); + + // if a metaspace currently being viewed was removed, go to another one + if (isMetaSpace(this.activeSpace) && !newValue[this.activeSpace]) { + this.switchSpaceIfNeeded(); } + this.rebuildMetaSpaces(); + + if (hadPeopleOrHomeEnabled !== hasPeopleOrHomeEnabled) { + // in this case we have to rebuild everything as DM badges will move to/from real spaces + this.updateNotificationStates(); + } else { + this.updateNotificationStates(enabledMetaSpaces); + } + this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces, this.enabledMetaSpaces); - this.rebuild(); // rebuild everything } break; } + + case "Spaces.showPeopleInSpace": + // getSpaceFilteredUserIds will return the appropriate value + this.emit(settingUpdatedPayload.roomId); + if (!this.enabledMetaSpaces.some(s => s === MetaSpace.Home || s === MetaSpace.People)) { + this.updateNotificationStates([settingUpdatedPayload.roomId]); + } + break; } } } diff --git a/src/stores/spaces/index.ts b/src/stores/spaces/index.ts index 7272cd6095b..f4bba0621b6 100644 --- a/src/stores/spaces/index.ts +++ b/src/stores/spaces/index.ts @@ -53,3 +53,10 @@ export type SpaceKey = MetaSpace | Room["roomId"]; export interface ISuggestedRoom extends IHierarchyRoom { viaServers: string[]; } + +export function isMetaSpace(spaceKey: SpaceKey): boolean { + return spaceKey === MetaSpace.Home || + spaceKey === MetaSpace.Favourites || + spaceKey === MetaSpace.People || + spaceKey === MetaSpace.Orphans; +} diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 6181d4e875b..a0fddde45cd 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -184,6 +184,8 @@ export function arrayHasDiff(a: any[], b: any[]): boolean { } } +export type Diff = { added: T[], removed: T[] }; + /** * Performs a diff on two arrays. The result is what is different with the * first array (`added` in the returned object means objects in B that aren't @@ -192,7 +194,7 @@ export function arrayHasDiff(a: any[], b: any[]): boolean { * @param b The second array. Must be defined. * @returns The diff between the arrays. */ -export function arrayDiff(a: T[], b: T[]): { added: T[], removed: T[] } { +export function arrayDiff(a: T[], b: T[]): Diff { return { added: b.filter(i => !a.includes(i)), removed: a.filter(i => !b.includes(i)), diff --git a/src/utils/sets.ts b/src/utils/sets.ts index e5427b2e94b..da856af2b5f 100644 --- a/src/utils/sets.ts +++ b/src/utils/sets.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { arrayDiff, Diff } from "./arrays"; + /** * Determines if two sets are different through a shallow comparison. * @param a The first set. Must be defined. @@ -32,3 +34,13 @@ export function setHasDiff(a: Set, b: Set): boolean { return true; // different lengths means they are naturally diverged } } + +/** + * Determines the values added and removed between two sets. + * @param a The first set. Must be defined. + * @param b The second set. Must be defined. + * @returns The difference between the values in each set. + */ +export function setDiff(a: Set, b: Set): Diff { + return arrayDiff(Array.from(a), Array.from(b)); +} diff --git a/src/utils/space.tsx b/src/utils/space.tsx index 8642d423209..a88d621bc60 100644 --- a/src/utils/space.tsx +++ b/src/utils/space.tsx @@ -40,6 +40,7 @@ import Spinner from "../components/views/elements/Spinner"; import dis from "../dispatcher/dispatcher"; import LeaveSpaceDialog from "../components/views/dialogs/LeaveSpaceDialog"; import CreateSpaceFromCommunityDialog from "../components/views/dialogs/CreateSpaceFromCommunityDialog"; +import SpacePreferencesDialog, { SpacePreferenceTab } from "../components/views/dialogs/SpacePreferencesDialog"; export const shouldShowSpaceSettings = (space: Room) => { const userId = space.client.getUserId(); @@ -197,3 +198,10 @@ export const createSpaceFromCommunity = (cli: MatrixClient, groupId: string): Pr groupId, }, "mx_CreateSpaceFromCommunityDialog_wrapper").finished as Promise<[string?]>; }; + +export const showSpacePreferences = (space: Room, initialTabId?: SpacePreferenceTab): Promise => { + return Modal.createTrackedDialog("Space preferences", "", SpacePreferencesDialog, { + initialTabId, + space, + }, null, false, true).finished; +}; diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index 228d7da0597..52aff5b3815 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "events"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; @@ -40,11 +39,6 @@ jest.useFakeTimers(); const testUserId = "@test:user"; -const getUserIdForRoomId = jest.fn(); -const getDMRoomsForUserId = jest.fn(); -// @ts-ignore -DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId }; - const fav1 = "!fav1:server"; const fav2 = "!fav2:server"; const fav3 = "!fav3:server"; @@ -68,6 +62,28 @@ const space1 = "!space1:server"; const space2 = "!space2:server"; const space3 = "!space3:server"; +const getUserIdForRoomId = jest.fn(roomId => { + return { + [dm1]: dm1Partner.userId, + [dm2]: dm2Partner.userId, + [dm3]: dm3Partner.userId, + }[roomId]; +}); +const getDMRoomsForUserId = jest.fn(userId => { + switch (userId) { + case dm1Partner.userId: + return [dm1]; + case dm2Partner.userId: + return [dm2]; + case dm3Partner.userId: + return [dm3]; + default: + return []; + } +}); +// @ts-ignore +DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId }; + describe("SpaceStore", () => { stubClient(); const store = SpaceStore.instance; @@ -306,26 +322,6 @@ describe("SpaceStore", () => { client.getRoom(roomId).getMyMembership.mockReturnValue("invite"); }); - getUserIdForRoomId.mockImplementation(roomId => { - return { - [dm1]: dm1Partner.userId, - [dm2]: dm2Partner.userId, - [dm3]: dm3Partner.userId, - }[roomId]; - }); - getDMRoomsForUserId.mockImplementation(userId => { - switch (userId) { - case dm1Partner.userId: - return [dm1]; - case dm2Partner.userId: - return [dm2]; - case dm3Partner.userId: - return [dm3]; - default: - return []; - } - }); - // have dmPartner1 be in space1 with you const mySpace1Member = new RoomMember(space1, testUserId); mySpace1Member.membership = "join"; @@ -388,103 +384,104 @@ describe("SpaceStore", () => { }); it("home space contains orphaned rooms", () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(orphan1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(orphan2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, orphan1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, orphan2)).toBeTruthy(); }); it("home space does not contain all favourites", () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(fav1)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(fav2)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(fav3)).toBeFalsy(); + expect(store.isRoomInSpace(MetaSpace.Home, fav1)).toBeFalsy(); + expect(store.isRoomInSpace(MetaSpace.Home, fav2)).toBeFalsy(); + expect(store.isRoomInSpace(MetaSpace.Home, fav3)).toBeFalsy(); }); it("home space contains dm rooms", () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(dm1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(dm2)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(dm3)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, dm1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, dm2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, dm3)).toBeTruthy(); }); it("home space contains invites", () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(invite1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, invite1)).toBeTruthy(); }); it("home space contains invites even if they are also shown in a space", () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(invite2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, invite2)).toBeTruthy(); }); it("all rooms space does contain rooms/low priority even if they are also shown in a space", async () => { await setShowAllRooms(true); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(room1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, room1)).toBeTruthy(); }); it("favourites space does contain favourites even if they are also shown in a space", async () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.Favourites).has(fav1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Favourites).has(fav2)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Favourites).has(fav3)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Favourites, fav1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Favourites, fav2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Favourites, fav3)).toBeTruthy(); }); it("people space does contain people even if they are also shown in a space", async () => { - expect(store.getSpaceFilteredRoomIds(MetaSpace.People).has(dm1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.People).has(dm2)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.People).has(dm3)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.People, dm1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.People, dm2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.People, dm3)).toBeTruthy(); }); it("orphans space does contain orphans even if they are also shown in all rooms", async () => { await setShowAllRooms(true); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Orphans).has(orphan1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Orphans).has(orphan2)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Orphans, orphan1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Orphans, orphan2)).toBeTruthy(); }); it("home space doesn't contain rooms/low priority if they are also shown in a space", async () => { await setShowAllRooms(false); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(room1)).toBeFalsy(); + expect(store.isRoomInSpace(MetaSpace.Home, room1)).toBeFalsy(); }); it("space contains child rooms", () => { - expect(store.getSpaceFilteredRoomIds(space1).has(fav1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(space1).has(room1)).toBeTruthy(); + expect(store.isRoomInSpace(space1, fav1)).toBeTruthy(); + expect(store.isRoomInSpace(space1, room1)).toBeTruthy(); }); it("space contains child favourites", () => { - expect(store.getSpaceFilteredRoomIds(space2).has(fav1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(space2).has(fav2)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(space2).has(fav3)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(space2).has(room1)).toBeTruthy(); + expect(store.isRoomInSpace(space2, fav1)).toBeTruthy(); + expect(store.isRoomInSpace(space2, fav2)).toBeTruthy(); + expect(store.isRoomInSpace(space2, fav3)).toBeTruthy(); + expect(store.isRoomInSpace(space2, room1)).toBeTruthy(); }); it("space contains child invites", () => { - expect(store.getSpaceFilteredRoomIds(space3).has(invite2)).toBeTruthy(); + expect(store.isRoomInSpace(space3, invite2)).toBeTruthy(); }); it("spaces contain dms which you have with members of that space", () => { - expect(store.getSpaceFilteredRoomIds(space1).has(dm1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(space2).has(dm1)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(space3).has(dm1)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(space1).has(dm2)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(space2).has(dm2)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(space3).has(dm2)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(space1).has(dm3)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(space2).has(dm3)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(space3).has(dm3)).toBeFalsy(); + expect(store.isRoomInSpace(space1, dm1)).toBeTruthy(); + expect(store.isRoomInSpace(space2, dm1)).toBeFalsy(); + expect(store.isRoomInSpace(space3, dm1)).toBeFalsy(); + expect(store.isRoomInSpace(space1, dm2)).toBeFalsy(); + expect(store.isRoomInSpace(space2, dm2)).toBeTruthy(); + expect(store.isRoomInSpace(space3, dm2)).toBeFalsy(); + expect(store.isRoomInSpace(space1, dm3)).toBeFalsy(); + expect(store.isRoomInSpace(space2, dm3)).toBeFalsy(); + expect(store.isRoomInSpace(space3, dm3)).toBeFalsy(); }); - it("dms are only added to Notification States for only the Home Space", () => { - // XXX: All rooms space is forcibly enabled, as part of a future PR test Home space better - // [dm1, dm2, dm3].forEach(d => { - // expect(store.getNotificationState(HOME_SPACE).rooms.map(r => r.roomId).includes(d)).toBeTruthy(); - // }); - [space1, space2, space3].forEach(s => { + it("dms are only added to Notification States for only the People Space", async () => { + [dm1, dm2, dm3].forEach(d => { + expect(store.getNotificationState(MetaSpace.People) + .rooms.map(r => r.roomId).includes(d)).toBeTruthy(); + }); + [space1, space2, space3, MetaSpace.Home, MetaSpace.Orphans, MetaSpace.Favourites].forEach(s => { [dm1, dm2, dm3].forEach(d => { expect(store.getNotificationState(s).rooms.map(r => r.roomId).includes(d)).toBeFalsy(); }); }); }); - it("orphan rooms are added to Notification States for only the Home Space", () => { - // XXX: All rooms space is forcibly enabled, as part of a future PR test Home space better - // [orphan1, orphan2].forEach(d => { - // expect(store.getNotificationState(HOME_SPACE).rooms.map(r => r.roomId).includes(d)).toBeTruthy(); - // }); + it("orphan rooms are added to Notification States for only the Home Space", async () => { + await setShowAllRooms(false); + [orphan1, orphan2].forEach(d => { + expect(store.getNotificationState(MetaSpace.Home) + .rooms.map(r => r.roomId).includes(d)).toBeTruthy(); + }); [space1, space2, space3].forEach(s => { [orphan1, orphan2].forEach(d => { expect(store.getNotificationState(s).rooms.map(r => r.roomId).includes(d)).toBeFalsy(); @@ -517,33 +514,22 @@ describe("SpaceStore", () => { }); it("honours m.space.parent if sender has permission in parent space", () => { - expect(store.getSpaceFilteredRoomIds(space2).has(room2)).toBeTruthy(); + expect(store.isRoomInSpace(space2, room2)).toBeTruthy(); }); it("does not honour m.space.parent if sender does not have permission in parent space", () => { - expect(store.getSpaceFilteredRoomIds(space3).has(room3)).toBeFalsy(); + expect(store.isRoomInSpace(space3, room3)).toBeFalsy(); }); }); }); describe("hierarchy resolution update tests", () => { - let emitter: EventEmitter; - beforeEach(async () => { - emitter = new EventEmitter(); - client.on.mockImplementation(emitter.on.bind(emitter)); - client.removeListener.mockImplementation(emitter.removeListener.bind(emitter)); - }); - afterEach(() => { - client.on.mockReset(); - client.removeListener.mockReset(); - }); - it("updates state when spaces are joined", async () => { await run(); expect(store.spacePanelSpaces).toStrictEqual([]); const space = mkSpace(space1); const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES); - emitter.emit("Room", space); + client.emit("Room", space); await prom; expect(store.spacePanelSpaces).toStrictEqual([space]); expect(store.invitedSpaces).toStrictEqual([]); @@ -556,7 +542,7 @@ describe("SpaceStore", () => { expect(store.spacePanelSpaces).toStrictEqual([space]); space.getMyMembership.mockReturnValue("leave"); const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES); - emitter.emit("Room.myMembership", space, "leave", "join"); + client.emit("Room.myMembership", space, "leave", "join"); await prom; expect(store.spacePanelSpaces).toStrictEqual([]); }); @@ -568,7 +554,7 @@ describe("SpaceStore", () => { const space = mkSpace(space1); space.getMyMembership.mockReturnValue("invite"); const prom = testUtils.emitPromise(store, UPDATE_INVITED_SPACES); - emitter.emit("Room", space); + client.emit("Room", space); await prom; expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.invitedSpaces).toStrictEqual([space]); @@ -583,7 +569,7 @@ describe("SpaceStore", () => { expect(store.invitedSpaces).toStrictEqual([space]); space.getMyMembership.mockReturnValue("join"); const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES); - emitter.emit("Room.myMembership", space, "join", "invite"); + client.emit("Room.myMembership", space, "join", "invite"); await prom; expect(store.spacePanelSpaces).toStrictEqual([space]); expect(store.invitedSpaces).toStrictEqual([]); @@ -598,7 +584,7 @@ describe("SpaceStore", () => { expect(store.invitedSpaces).toStrictEqual([space]); space.getMyMembership.mockReturnValue("leave"); const prom = testUtils.emitPromise(store, UPDATE_INVITED_SPACES); - emitter.emit("Room.myMembership", space, "leave", "invite"); + client.emit("Room.myMembership", space, "leave", "invite"); await prom; expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.invitedSpaces).toStrictEqual([]); @@ -612,21 +598,21 @@ describe("SpaceStore", () => { expect(store.invitedSpaces).toStrictEqual([]); expect(store.getChildSpaces(space1)).toStrictEqual([]); expect(store.getChildRooms(space1)).toStrictEqual([]); - expect(store.getSpaceFilteredRoomIds(space1).has(invite1)).toBeFalsy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(invite1)).toBeFalsy(); + expect(store.isRoomInSpace(space1, invite1)).toBeFalsy(); + expect(store.isRoomInSpace(MetaSpace.Home, invite1)).toBeFalsy(); const invite = mkRoom(invite1); invite.getMyMembership.mockReturnValue("invite"); const prom = testUtils.emitPromise(store, space1); - emitter.emit("Room", space); + client.emit("Room", space); await prom; expect(store.spacePanelSpaces).toStrictEqual([space]); expect(store.invitedSpaces).toStrictEqual([]); expect(store.getChildSpaces(space1)).toStrictEqual([]); expect(store.getChildRooms(space1)).toStrictEqual([invite]); - expect(store.getSpaceFilteredRoomIds(space1).has(invite1)).toBeTruthy(); - expect(store.getSpaceFilteredRoomIds(MetaSpace.Home).has(invite1)).toBeTruthy(); + expect(store.isRoomInSpace(space1, invite1)).toBeTruthy(); + expect(store.isRoomInSpace(MetaSpace.Home, invite1)).toBeTruthy(); }); }); @@ -817,7 +803,7 @@ describe("SpaceStore", () => { expect(store.activeSpace).toBe(MetaSpace.Orphans); }); - it("switch to first space when selected metaspace is disabled", async () => { + it("switch to first valid space when selected metaspace is disabled", async () => { store.setActiveSpace(MetaSpace.People, false); expect(store.activeSpace).toBe(MetaSpace.People); await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.DEVICE, { @@ -827,7 +813,7 @@ describe("SpaceStore", () => { [MetaSpace.Orphans]: true, }); jest.runAllTimers(); - expect(store.activeSpace).toBe(MetaSpace.Favourites); + expect(store.activeSpace).toBe(MetaSpace.Orphans); }); it("when switching rooms in the all rooms home space don't switch to related space", async () => { @@ -889,4 +875,105 @@ describe("SpaceStore", () => { expect(fn).toBeCalledWith("!c:server"); }); }); + + it("test user flow", async () => { + // init the store + await run(); + await setShowAllRooms(false); + + // receive invite to space + const rootSpace = mkSpace(space1, [room1, room2, space2]); + rootSpace.getMyMembership.mockReturnValue("invite"); + client.emit("Room", rootSpace); + jest.runAllTimers(); + expect(SpaceStore.instance.invitedSpaces).toStrictEqual([rootSpace]); + expect(SpaceStore.instance.spacePanelSpaces).toStrictEqual([]); + + // accept invite to space + rootSpace.getMyMembership.mockReturnValue("join"); + client.emit("Room.myMembership", rootSpace, "join", "invite"); + jest.runAllTimers(); + expect(SpaceStore.instance.invitedSpaces).toStrictEqual([]); + expect(SpaceStore.instance.spacePanelSpaces).toStrictEqual([rootSpace]); + + // join room in space + expect(SpaceStore.instance.isRoomInSpace(space1, room1)).toBeFalsy(); + const rootSpaceRoom1 = mkRoom(room1); + rootSpaceRoom1.getMyMembership.mockReturnValue("join"); + client.emit("Room", rootSpaceRoom1); + jest.runAllTimers(); + expect(SpaceStore.instance.invitedSpaces).toStrictEqual([]); + expect(SpaceStore.instance.spacePanelSpaces).toStrictEqual([rootSpace]); + expect(SpaceStore.instance.isRoomInSpace(space1, room1)).toBeTruthy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Home, room1)).toBeFalsy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Favourites, room1)).toBeFalsy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.People, room1)).toBeFalsy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Orphans, room1)).toBeFalsy(); + + // receive room invite + expect(SpaceStore.instance.isRoomInSpace(space1, room2)).toBeFalsy(); + const rootSpaceRoom2 = mkRoom(room2); + rootSpaceRoom2.getMyMembership.mockReturnValue("invite"); + client.emit("Room", rootSpaceRoom2); + jest.runAllTimers(); + expect(SpaceStore.instance.invitedSpaces).toStrictEqual([]); + expect(SpaceStore.instance.spacePanelSpaces).toStrictEqual([rootSpace]); + expect(SpaceStore.instance.isRoomInSpace(space1, room2)).toBeTruthy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Home, room2)).toBeTruthy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Favourites, room2)).toBeFalsy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.People, room2)).toBeFalsy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Orphans, room2)).toBeFalsy(); + + // start DM in space + const myRootSpaceMember = new RoomMember(space1, testUserId); + myRootSpaceMember.membership = "join"; + const rootSpaceFriend = new RoomMember(space1, dm1Partner.userId); + rootSpaceFriend.membership = "join"; + rootSpace.getMembers.mockReturnValue([ + myRootSpaceMember, + rootSpaceFriend, + ]); + rootSpace.getMember.mockImplementation(userId => { + switch (userId) { + case testUserId: + return myRootSpaceMember; + case dm1Partner.userId: + return rootSpaceFriend; + } + }); + expect(SpaceStore.instance.getSpaceFilteredUserIds(space1).has(dm1Partner.userId)).toBeFalsy(); + client.emit("RoomState.members", mkEvent({ + event: true, + type: EventType.RoomMember, + content: { + membership: "join", + }, + skey: dm1Partner.userId, + user: dm1Partner.userId, + room: space1, + })); + jest.runAllTimers(); + expect(SpaceStore.instance.getSpaceFilteredUserIds(space1).has(dm1Partner.userId)).toBeTruthy(); + const dm1Room = mkRoom(dm1); + dm1Room.getMyMembership.mockReturnValue("join"); + client.emit("Room", dm1Room); + jest.runAllTimers(); + expect(SpaceStore.instance.invitedSpaces).toStrictEqual([]); + expect(SpaceStore.instance.spacePanelSpaces).toStrictEqual([rootSpace]); + expect(SpaceStore.instance.isRoomInSpace(space1, dm1)).toBeTruthy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Home, dm1)).toBeTruthy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Favourites, dm1)).toBeFalsy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.People, dm1)).toBeTruthy(); + expect(SpaceStore.instance.isRoomInSpace(MetaSpace.Orphans, dm1)).toBeFalsy(); + + // join subspace + const subspace = mkSpace(space2); + subspace.getMyMembership.mockReturnValue("join"); + const prom = testUtils.emitPromise(SpaceStore.instance, space1); + client.emit("Room", subspace); + jest.runAllTimers(); + expect(SpaceStore.instance.invitedSpaces).toStrictEqual([]); + expect(SpaceStore.instance.spacePanelSpaces.map(r => r.roomId)).toStrictEqual([rootSpace.roomId]); + await prom; + }); }); diff --git a/test/test-utils.js b/test/test-utils.js index d34385c7de9..0b9bbd642c8 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -1,4 +1,5 @@ import React from 'react'; +import EventEmitter from "events"; import ShallowRenderer from 'react-test-renderer/shallow'; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; @@ -43,6 +44,8 @@ export function stubClient() { * @returns {object} MatrixClient stub */ export function createTestClient() { + const eventEmitter = new EventEmitter(); + return { getHomeserverUrl: jest.fn(), getIdentityServerUrl: jest.fn(), @@ -57,8 +60,9 @@ export function createTestClient() { getVisibleRooms: jest.fn().mockReturnValue([]), getGroups: jest.fn().mockReturnValue([]), loginFlows: jest.fn(), - on: jest.fn(), - removeListener: jest.fn(), + on: eventEmitter.on.bind(eventEmitter), + emit: eventEmitter.emit.bind(eventEmitter), + removeListener: eventEmitter.removeListener.bind(eventEmitter), isRoomEncrypted: jest.fn().mockReturnValue(false), peekInRoom: jest.fn().mockResolvedValue(mkStubRoom()), From 818465a4d671522ba511c6ae346796b0b89cdee4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 17 Dec 2021 09:28:10 +0000 Subject: [PATCH 036/145] Increase gap between ELS and the subsequent event to prevent overlap (#7391) --- res/css/views/rooms/_EventBubbleTile.scss | 5 +++++ src/components/views/elements/MemberEventListSummary.tsx | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index ba5c3595f2e..92daa223f73 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -401,6 +401,11 @@ limitations under the License. } } +// increase margin between ELS and the next Event to not have our user avatar overlap the expand/collapse button +.mx_EventListSummary[data-layout=bubble][data-expanded=false] + .mx_EventTile[data-layout=bubble][data-self=true] { + margin-top: 20px; +} + /* events that do not require bubble layout */ .mx_EventListSummary[data-layout=bubble], .mx_EventTile.mx_EventTile_bad[data-layout=bubble] { diff --git a/src/components/views/elements/MemberEventListSummary.tsx b/src/components/views/elements/MemberEventListSummary.tsx index be39ceb6e56..69ab4a60f08 100644 --- a/src/components/views/elements/MemberEventListSummary.tsx +++ b/src/components/views/elements/MemberEventListSummary.tsx @@ -90,14 +90,15 @@ export default class MemberEventListSummary extends React.Component { layout: Layout.Group, }; - shouldComponentUpdate(nextProps) { + shouldComponentUpdate(nextProps: IProps): boolean { // Update if // - The number of summarised events has changed // - or if the summary is about to toggle to become collapsed // - or if there are fewEvents, meaning the child eventTiles are shown as-is return ( nextProps.events.length !== this.props.events.length || - nextProps.events.length < this.props.threshold + nextProps.events.length < this.props.threshold || + nextProps.layout !== this.props.layout ); } From 144e4c61fcd0d0482b4314436fa8b9416a9bd757 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 17 Dec 2021 09:50:27 +0000 Subject: [PATCH 037/145] Avoid NaNs in parseGeoUri return (#7396) --- src/components/views/messages/MLocationBody.tsx | 17 +++++++++++++---- .../views/messages/MLocationBody-test.tsx | 14 +++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index 197e751e154..d9131245d1f 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -97,6 +97,15 @@ export default class MLocationBody extends React.Component { } export function parseGeoUri(uri: string): GeolocationCoordinates { + function parse(s: string): number { + const ret = parseFloat(s); + if (Number.isNaN(ret)) { + return undefined; + } else { + return ret; + } + } + const m = uri.match(/^\s*geo:(.*?)\s*$/); if (!m) return; const parts = m[1].split(';'); @@ -104,12 +113,12 @@ export function parseGeoUri(uri: string): GeolocationCoordinates { let uncertainty: number; for (const param of parts.slice(1)) { const m = param.match(/u=(.*)/); - if (m) uncertainty = parseFloat(m[1]); + if (m) uncertainty = parse(m[1]); } return { - latitude: parseFloat(coords[0]), - longitude: parseFloat(coords[1]), - altitude: parseFloat(coords[2]), + latitude: parse(coords[0]), + longitude: parse(coords[1]), + altitude: parse(coords[2]), accuracy: uncertainty, altitudeAccuracy: undefined, heading: undefined, diff --git a/test/components/views/messages/MLocationBody-test.tsx b/test/components/views/messages/MLocationBody-test.tsx index b38d3864b31..31281bec6ad 100644 --- a/test/components/views/messages/MLocationBody-test.tsx +++ b/test/components/views/messages/MLocationBody-test.tsx @@ -52,7 +52,7 @@ describe("MLocationBody", () => { { latitude: 48.198634, longitude: 16.371648, - altitude: NaN, // TODO: should be undefined + altitude: undefined, accuracy: 40, altitudeAccuracy: undefined, heading: undefined, @@ -66,7 +66,7 @@ describe("MLocationBody", () => { { latitude: 90, longitude: -22.43, - altitude: NaN, // TODO: should be undefined + altitude: undefined, accuracy: undefined, altitudeAccuracy: undefined, heading: undefined, @@ -80,7 +80,7 @@ describe("MLocationBody", () => { { latitude: 90, longitude: 46, - altitude: NaN, // TODO: should be undefined + altitude: undefined, accuracy: undefined, altitudeAccuracy: undefined, heading: undefined, @@ -94,7 +94,7 @@ describe("MLocationBody", () => { { latitude: 66, longitude: 30, - altitude: NaN, // TODO: should be undefined + altitude: undefined, accuracy: 6.500, altitudeAccuracy: undefined, heading: undefined, @@ -108,7 +108,7 @@ describe("MLocationBody", () => { { latitude: 66.0, longitude: 30, - altitude: NaN, // TODO: should be undefined + altitude: undefined, accuracy: 6.5, altitudeAccuracy: undefined, heading: undefined, @@ -122,7 +122,7 @@ describe("MLocationBody", () => { { latitude: 70, longitude: 20, - altitude: NaN, // TODO: should be undefined + altitude: undefined, accuracy: undefined, altitudeAccuracy: undefined, heading: undefined, @@ -136,7 +136,7 @@ describe("MLocationBody", () => { { latitude: -7.5, longitude: 20, - altitude: NaN, // TODO: should be undefined + altitude: undefined, accuracy: undefined, altitudeAccuracy: undefined, heading: undefined, From f3893245716f360302aca80ae475411211a8438c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 17 Dec 2021 10:36:52 +0000 Subject: [PATCH 038/145] Show error if could not load space hierarchy (#7399) --- src/components/structures/SpaceHierarchy.tsx | 20 +++++++++++++------- src/i18n/strings/en_EN.json | 1 + 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index f684c28ff0a..9e7f1df272c 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -477,17 +477,19 @@ export const useRoomHierarchy = (space: Room): { loading: boolean; rooms: IHierarchyRoom[]; hierarchy: RoomHierarchy; - loadMore(pageSize?: number): Promise ; + error: Error; + loadMore(pageSize?: number): Promise; } => { const [rooms, setRooms] = useState([]); const [hierarchy, setHierarchy] = useState(); + const [error, setError] = useState(); const resetHierarchy = useCallback(() => { const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE); hierarchy.load().then(() => { if (space !== hierarchy.root) return; // discard stale results setRooms(hierarchy.rooms); - }); + }, setError); setHierarchy(hierarchy); }, [space]); useEffect(resetHierarchy, [resetHierarchy]); @@ -501,12 +503,12 @@ export const useRoomHierarchy = (space: Room): { const loadMore = useCallback(async (pageSize?: number) => { if (hierarchy.loading || !hierarchy.canLoadMore || hierarchy.noSupport) return; - await hierarchy.load(pageSize); + await hierarchy.load(pageSize).catch(setError); setRooms(hierarchy.rooms); }, [hierarchy]); const loading = hierarchy?.loading ?? true; - return { loading, rooms, hierarchy, loadMore }; + return { loading, rooms, hierarchy, loadMore, error }; }; const useIntersectionObserver = (callback: () => void) => { @@ -649,7 +651,7 @@ const SpaceHierarchy = ({ const [selected, setSelected] = useState(new Map>()); // Map> - const { loading, rooms, hierarchy, loadMore } = useRoomHierarchy(space); + const { loading, rooms, hierarchy, loadMore, error: hierarchyError } = useRoomHierarchy(space); const filteredRoomSet = useMemo>(() => { if (!rooms?.length) return new Set(); @@ -677,6 +679,10 @@ const SpaceHierarchy = ({ }, [rooms, hierarchy, query]); const [error, setError] = useState(""); + let errorText = error; + if (!error && hierarchyError) { + errorText = _t("Failed to load list of rooms."); + } const loaderRef = useIntersectionObserver(loadMore); @@ -759,8 +765,8 @@ const SpaceHierarchy = ({ ) } - { error &&
- { error } + { errorText &&
+ { errorText }
}
    Date: Fri, 17 Dec 2021 10:58:24 +0000 Subject: [PATCH 039/145] Tests for getGeoUri (#7400) Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- .../views/location/LocationPicker.tsx | 20 ++--- .../views/location/LocationPicker-test.tsx | 86 +++++++++++++++++++ 2 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 test/components/views/location/LocationPicker-test.tsx diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index f3f4d649860..5571f64d866 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -185,21 +185,12 @@ class LocationPicker extends React.Component { this.setState({ description: ev.target.value }); }; - private getGeoUri = (position) => { - return (`geo:${ position.coords.latitude },` + - position.coords.longitude + - ( position.coords.altitude !== undefined ? - `,${ position.coords.altitude }` : '' ) + - ( position.coords.accuracy !== undefined ? - `;u=${ position.coords.accuracy }` : '' )); - }; - private onOk = () => { const position = (this.state.type == LocationShareType.Custom) ? this.state.manualPosition : this.state.position; this.props.onChoose( - position ? this.getGeoUri(position) : undefined, + position ? getGeoUri(position) : undefined, position ? position.timestamp : undefined, this.state.type, this.state.description, @@ -263,4 +254,13 @@ class LocationPicker extends React.Component { } } +export function getGeoUri(position: GeolocationPosition): string { + return (`geo:${ position.coords.latitude },` + + position.coords.longitude + + ( position.coords.altitude !== undefined ? + `,${ position.coords.altitude }` : '' ) + + ( position.coords.accuracy !== undefined ? + `;u=${ position.coords.accuracy }` : '' )); +} + export default LocationPicker; diff --git a/test/components/views/location/LocationPicker-test.tsx b/test/components/views/location/LocationPicker-test.tsx new file mode 100644 index 00000000000..65c76e113ad --- /dev/null +++ b/test/components/views/location/LocationPicker-test.tsx @@ -0,0 +1,86 @@ +/* +Copyright 2021 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 "../../../skinned-sdk"; +import { getGeoUri } from "../../../../src/components/views/location/LocationPicker"; + +describe("LocationPicker", () => { + describe("getGeoUri", () => { + it("Renders a URI with only lat and lon", () => { + const pos: GeolocationPosition = { + coords: { + latitude: 43.2, + longitude: 12.4, + altitude: undefined, + accuracy: undefined, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + timestamp: 12334, + }; + expect(getGeoUri(pos)).toEqual("geo:43.2,12.4"); + }); + + it("Renders a URI with 3 coords", () => { + const pos: GeolocationPosition = { + coords: { + latitude: 43.2, + longitude: 12.4, + altitude: 332.54, + accuracy: undefined, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + timestamp: 12334, + }; + expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,332.54"); + }); + + it("Renders a URI with accuracy", () => { + const pos: GeolocationPosition = { + coords: { + latitude: 43.2, + longitude: 12.4, + altitude: undefined, + accuracy: 21, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + timestamp: 12334, + }; + expect(getGeoUri(pos)).toEqual("geo:43.2,12.4;u=21"); + }); + + it("Renders a URI with accuracy and altitude", () => { + const pos: GeolocationPosition = { + coords: { + latitude: 43.2, + longitude: 12.4, + altitude: 12.3, + accuracy: 21, + altitudeAccuracy: undefined, + heading: undefined, + speed: undefined, + }, + timestamp: 12334, + }; + expect(getGeoUri(pos)).toEqual("geo:43.2,12.4,12.3;u=21"); + }); + }); +}); From fb494a5098e6db9cd64ceb9aecfab9009346cab2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 17 Dec 2021 11:02:06 +0000 Subject: [PATCH 040/145] Fix notification badge for All Rooms space (#7401) --- src/components/structures/MatrixChat.tsx | 94 ++++++++++--------- src/components/views/spaces/SpacePanel.tsx | 25 ++++- .../RoomNotificationStateStore.ts | 43 ++++++--- 3 files changed, 98 insertions(+), 64 deletions(-) diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index a3848af83a8..275400e0671 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -15,7 +15,8 @@ limitations under the License. */ import React, { ComponentType, createRef } from 'react'; -import { createClient } from 'matrix-js-sdk/src/matrix'; +import { createClient, MatrixClient } from 'matrix-js-sdk/src/matrix'; +import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; import { MatrixError } from 'matrix-js-sdk/src/http-api'; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; @@ -32,7 +33,7 @@ import 'what-input'; import Analytics from "../../Analytics"; import CountlyAnalytics from "../../CountlyAnalytics"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; -import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg"; +import { IMatrixClientCreds, MatrixClientPeg } from "../../MatrixClientPeg"; import PlatformPeg from "../../PlatformPeg"; import SdkConfig from "../../SdkConfig"; import dis from "../../dispatcher/dispatcher"; @@ -59,6 +60,7 @@ import { storeRoomAliasInCache } from '../../RoomAliasCache'; import ToastStore from "../../stores/ToastStore"; import * as StorageManager from "../../utils/StorageManager"; import type LoggedInViewType from "./LoggedInView"; +import LoggedInView from './LoggedInView'; import { Action } from "../../dispatcher/actions"; import { hideToast as hideAnalyticsToast, @@ -68,7 +70,10 @@ import { import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import ErrorDialog from "../views/dialogs/ErrorDialog"; -import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; +import { + RoomNotificationStateStore, + UPDATE_STATUS_INDICATOR, +} from "../../stores/notifications/RoomNotificationStateStore"; import { SettingLevel } from "../../settings/SettingLevel"; import { leaveRoomBehaviour } from "../../utils/membership"; import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityPrototypeDialog"; @@ -92,7 +97,6 @@ import RoomDirectory from './RoomDirectory'; import KeySignatureUploadFailedDialog from "../views/dialogs/KeySignatureUploadFailedDialog"; import IncomingSasDialog from "../views/dialogs/IncomingSasDialog"; import CompleteSecurity from "./auth/CompleteSecurity"; -import LoggedInView from './LoggedInView'; import Welcome from "../views/auth/Welcome"; import ForgotPassword from "./auth/ForgotPassword"; import E2eSetup from "./auth/E2eSetup"; @@ -114,6 +118,7 @@ import InfoDialog from "../views/dialogs/InfoDialog"; import FeedbackDialog from "../views/dialogs/FeedbackDialog"; import AccessibleButton from "../views/elements/AccessibleButton"; import { ActionPayload } from "../../dispatcher/payloads"; +import { SummarizedNotificationState } from "../../stores/notifications/SummarizedNotificationState"; /** constants for MatrixChat.state.view */ export enum Views { @@ -257,8 +262,8 @@ export default class MatrixChat extends React.PureComponent { onTokenLoginCompleted: () => {}, }; - firstSyncComplete: boolean; - firstSyncPromise: IDeferred; + private firstSyncComplete = false; + private firstSyncPromise: IDeferred; private screenAfterLogin?: IScreen; private pageChanging: boolean; @@ -270,12 +275,12 @@ export default class MatrixChat extends React.PureComponent { private prevWindowWidth: number; private readonly loggedInView: React.RefObject; - private readonly dispatcherRef: any; + private readonly dispatcherRef: string; private readonly themeWatcher: ThemeWatcher; private readonly fontWatcher: FontWatcher; - constructor(props, context) { - super(props, context); + constructor(props: IProps) { + super(props); this.state = { view: Views.LOADING, @@ -321,6 +326,8 @@ export default class MatrixChat extends React.PureComponent { // For PersistentElement this.state.resizeNotifier.on("middlePanelResized", this.dispatchTimelineResize); + RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatusIndicator); + // Force users to go through the soft logout page if they're soft logged out if (Lifecycle.isSoftLogout()) { // When the session loads it'll be detected as soft logged out and a dispatch @@ -494,15 +501,15 @@ export default class MatrixChat extends React.PureComponent { }); } - getFallbackHsUrl() { - if (this.props.serverConfig && this.props.serverConfig.isDefault) { + private getFallbackHsUrl(): string { + if (this.props.serverConfig?.isDefault) { return this.props.config.fallback_hs_url; } else { return null; } } - getServerProperties() { + private getServerProperties() { let props = this.state.serverConfig; if (!props) props = this.props.serverConfig; // for unit tests if (!props) props = SdkConfig.get()["validated_server_config"]; @@ -535,11 +542,11 @@ export default class MatrixChat extends React.PureComponent { // to try logging out. } - startPageChangeTimer() { + private startPageChangeTimer() { PerformanceMonitor.instance.start(PerformanceEntryNames.PAGE_CHANGE); } - stopPageChangeTimer() { + private stopPageChangeTimer() { const perfMonitor = PerformanceMonitor.instance; perfMonitor.stop(PerformanceEntryNames.PAGE_CHANGE); @@ -554,13 +561,13 @@ export default class MatrixChat extends React.PureComponent { : null; } - shouldTrackPageChange(prevState: IState, state: IState) { + private shouldTrackPageChange(prevState: IState, state: IState): boolean { return prevState.currentRoomId !== state.currentRoomId || prevState.view !== state.view || prevState.page_type !== state.page_type; } - setStateForNewView(state: Partial) { + private setStateForNewView(state: Partial): void { if (state.view === undefined) { throw new Error("setStateForNewView with no view!"); } @@ -572,7 +579,7 @@ export default class MatrixChat extends React.PureComponent { this.setState(newState); } - private onAction = (payload: ActionPayload) => { + private onAction = (payload: ActionPayload): void => { // console.log(`MatrixClientPeg.onAction: ${payload.action}`); // Start the onboarding process for certain actions @@ -1486,7 +1493,7 @@ export default class MatrixChat extends React.PureComponent { return this.loggedInView.current.canResetTimelineInRoom(roomId); }); - cli.on('sync', (state, prevState, data) => { + cli.on('sync', (state: SyncState, prevState?: SyncState, data?: ISyncStateData) => { // LifecycleStore and others cannot directly subscribe to matrix client for // events because flux only allows store state changes during flux dispatches. // So dispatch directly from here. Ideally we'd use a SyncStateStore that @@ -1494,21 +1501,20 @@ export default class MatrixChat extends React.PureComponent { // its own dispatch). dis.dispatch({ action: 'sync_state', prevState, state }); - if (state === "ERROR" || state === "RECONNECTING") { + if (state === SyncState.Error || state === SyncState.Reconnecting) { if (data.error instanceof InvalidStoreError) { Lifecycle.handleInvalidStoreError(data.error); } - this.setState({ syncError: data.error || true }); + this.setState({ syncError: data.error || {} as MatrixError }); } else if (this.state.syncError) { this.setState({ syncError: null }); } - this.updateStatusIndicator(state, prevState); - if (state === "SYNCING" && prevState === "SYNCING") { + if (state === SyncState.Syncing && prevState === SyncState.Syncing) { return; } logger.info("MatrixClient sync state => %s", state); - if (state !== "PREPARED") { return; } + if (state !== SyncState.Prepared) { return; } this.firstSyncComplete = true; this.firstSyncPromise.resolve(); @@ -1766,7 +1772,7 @@ export default class MatrixChat extends React.PureComponent { } } - showScreen(screen: string, params?: {[key: string]: any}) { + public showScreen(screen: string, params?: {[key: string]: any}) { const cli = MatrixClientPeg.get(); const isLoggedOutOrGuest = !cli || cli.isGuest(); if (!isLoggedOutOrGuest && AUTH_SCREENS.includes(screen)) { @@ -1941,13 +1947,14 @@ export default class MatrixChat extends React.PureComponent { } } - notifyNewScreen(screen: string, replaceLast = false) { + private notifyNewScreen(screen: string, replaceLast = false) { if (this.props.onNewScreen) { this.props.onNewScreen(screen, replaceLast); } this.setPageSubtitle(); } - onLogoutClick(event: React.MouseEvent) { + + private onLogoutClick(event: React.MouseEvent) { dis.dispatch({ action: 'logout', }); @@ -1955,7 +1962,7 @@ export default class MatrixChat extends React.PureComponent { event.preventDefault(); } - handleResize = () => { + private handleResize = () => { const LHS_THRESHOLD = 1000; const width = UIStore.instance.windowWidth; @@ -1975,28 +1982,28 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: 'timeline_resize' }); } - onRegisterClick = () => { + private onRegisterClick = () => { this.showScreen("register"); }; - onLoginClick = () => { + private onLoginClick = () => { this.showScreen("login"); }; - onForgotPasswordClick = () => { + private onForgotPasswordClick = () => { this.showScreen("forgot_password"); }; - onRegisterFlowComplete = (credentials: IMatrixClientCreds, password: string) => { + private onRegisterFlowComplete = (credentials: IMatrixClientCreds, password: string): Promise => { return this.onUserCompletedLoginFlow(credentials, password); }; // returns a promise which resolves to the new MatrixClient - onRegistered(credentials: IMatrixClientCreds) { + private onRegistered(credentials: IMatrixClientCreds): Promise { return Lifecycle.setLoggedIn(credentials); } - onSendEvent(roomId: string, event: MatrixEvent) { + private onSendEvent(roomId: string, event: MatrixEvent): void { const cli = MatrixClientPeg.get(); if (!cli) return; @@ -2023,17 +2030,16 @@ export default class MatrixChat extends React.PureComponent { } } - updateStatusIndicator(state: string, prevState: string) { - const notificationState = RoomNotificationStateStore.instance.globalState; + private onUpdateStatusIndicator = (notificationState: SummarizedNotificationState, state: SyncState): void => { const numUnreadRooms = notificationState.numUnreadStates; // we know that states === rooms here if (PlatformPeg.get()) { - PlatformPeg.get().setErrorStatus(state === 'ERROR'); + PlatformPeg.get().setErrorStatus(state === SyncState.Error); PlatformPeg.get().setNotificationCount(numUnreadRooms); } this.subTitleStatus = ''; - if (state === "ERROR") { + if (state === SyncState.Error) { this.subTitleStatus += `[${_t("Offline")}] `; } if (numUnreadRooms > 0) { @@ -2041,13 +2047,9 @@ export default class MatrixChat extends React.PureComponent { } this.setPageSubtitle(); - } - - onCloseAllSettings() { - dis.dispatch({ action: 'close_settings' }); - } + }; - onServerConfigChange = (serverConfig: ValidatedServerConfig) => { + private onServerConfigChange = (serverConfig: ValidatedServerConfig) => { this.setState({ serverConfig }); }; @@ -2065,7 +2067,7 @@ export default class MatrixChat extends React.PureComponent { * Note: SSO users (and any others using token login) currently do not pass through * this, as they instead jump straight into the app after `attemptTokenLogin`. */ - onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds, password: string) => { + private onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds, password: string): Promise => { this.accountPassword = password; // self-destruct the password after 5mins if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer); @@ -2083,11 +2085,11 @@ export default class MatrixChat extends React.PureComponent { }; // complete security / e2e setup has finished - onCompleteSecurityE2eSetupFinished = () => { + private onCompleteSecurityE2eSetupFinished = (): void => { this.onLoggedIn(); }; - getFragmentAfterLogin() { + private getFragmentAfterLogin(): string { let fragmentAfterLogin = ""; const initialScreenAfterLogin = this.props.initialScreenAfterLogin; if (initialScreenAfterLogin && diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 9d20782a7e3..d68f665ddc6 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -19,6 +19,7 @@ import React, { Dispatch, ReactNode, SetStateAction, + useCallback, useEffect, useLayoutEffect, useRef, @@ -33,7 +34,7 @@ import { useContextMenu } from "../../structures/ContextMenu"; import SpaceCreateMenu from "./SpaceCreateMenu"; import { SpaceButton, SpaceItem } from "./SpaceTreeLevel"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -import { useEventEmitterState } from "../../../hooks/useEventEmitter"; +import { useEventEmitter, useEventEmitterState } from "../../../hooks/useEventEmitter"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import { getMetaSpaceName, @@ -45,7 +46,10 @@ import { UPDATE_TOP_LEVEL_SPACES, } from "../../../stores/spaces"; import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; -import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; +import { + RoomNotificationStateStore, + UPDATE_STATUS_INDICATOR, +} from "../../../stores/notifications/RoomNotificationStateStore"; import SpaceContextMenu from "../context_menus/SpaceContextMenu"; import IconizedContextMenu, { IconizedContextMenuCheckbox, @@ -63,6 +67,7 @@ import { useDispatcher } from "../../../hooks/useDispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { ActionPayload } from "../../../dispatcher/payloads"; import { Action } from "../../../dispatcher/actions"; +import { NotificationState } from "../../../stores/notifications/NotificationState"; const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => { const invites = useEventEmitterState(SpaceStore.instance, UPDATE_INVITED_SPACES, () => { @@ -136,10 +141,22 @@ const MetaSpaceButton = ({ selected, isPanelCollapsed, ...props }: IMetaSpaceBut ; }; +const getHomeNotificationState = (): NotificationState => { + return SpaceStore.instance.allRoomsInHome + ? RoomNotificationStateStore.instance.globalState + : SpaceStore.instance.getNotificationState(MetaSpace.Home); +}; + const HomeButton = ({ selected, isPanelCollapsed }: MetaSpaceButtonProps) => { const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => { return SpaceStore.instance.allRoomsInHome; }); + const [notificationState, setNotificationState] = useState(getHomeNotificationState()); + const updateNotificationState = useCallback(() => { + setNotificationState(getHomeNotificationState()); + }, []); + useEffect(updateNotificationState, [updateNotificationState, allRoomsInHome]); + useEventEmitter(RoomNotificationStateStore.instance, UPDATE_STATUS_INDICATOR, updateNotificationState); return { selected={selected} isPanelCollapsed={isPanelCollapsed} label={getMetaSpaceName(MetaSpace.Home, allRoomsInHome)} - notificationState={allRoomsInHome - ? RoomNotificationStateStore.instance.globalState - : SpaceStore.instance.getNotificationState(MetaSpace.Home)} + notificationState={notificationState} ContextMenuComponent={HomeButtonContextMenu} contextMenuTooltip={_t("Options")} />; diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index a15efd87ab8..b4a699ab540 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { Room } from "matrix-js-sdk/src/models/room"; +import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; import { ActionPayload } from "../../dispatcher/payloads"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; @@ -23,17 +24,20 @@ import { DefaultTagID, TagID } from "../room-list/models"; import { FetchRoomFn, ListNotificationState } from "./ListNotificationState"; import { RoomNotificationState } from "./RoomNotificationState"; import { SummarizedNotificationState } from "./SummarizedNotificationState"; -import { VisibilityProvider } from "../room-list/filters/VisibilityProvider"; import { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState"; +import { VisibilityProvider } from "../room-list/filters/VisibilityProvider"; interface IState {} +export const UPDATE_STATUS_INDICATOR = Symbol("update-status-indicator"); + export class RoomNotificationStateStore extends AsyncStoreWithClient { private static internalInstance = new RoomNotificationStateStore(); private roomMap = new Map(); private roomThreadsMap = new Map(); private listMap = new Map(); + private _globalState = new SummarizedNotificationState(); private constructor() { super(defaultDispatcher, {}); @@ -44,18 +48,7 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { * on the SummarizedNotificationState is equivalent to rooms. */ public get globalState(): SummarizedNotificationState { - // If we're not ready yet, just return an empty state - if (!this.matrixClient) return new SummarizedNotificationState(); - - // Only count visible rooms to not torment the user with notification counts in rooms they can't see. - // This will include highlights from the previous version of the room internally - const globalState = new SummarizedNotificationState(); - for (const room of this.matrixClient.getVisibleRooms()) { - if (VisibilityProvider.instance.isRoomVisible(room)) { - globalState.add(this.getRoomState(room)); - } - } - return globalState; + return this._globalState; } /** @@ -108,6 +101,30 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { return RoomNotificationStateStore.internalInstance; } + private onSync = (state: SyncState, prevState?: SyncState, data?: ISyncStateData) => { + // Only count visible rooms to not torment the user with notification counts in rooms they can't see. + // This will include highlights from the previous version of the room internally + const globalState = new SummarizedNotificationState(); + for (const room of this.matrixClient.getVisibleRooms()) { + if (VisibilityProvider.instance.isRoomVisible(room)) { + globalState.add(this.getRoomState(room)); + } + } + + if (this.globalState.symbol !== globalState.symbol || + this.globalState.count !== globalState.count || + this.globalState.color !== globalState.color || + this.globalState.numUnreadStates !== globalState.numUnreadStates + ) { + this._globalState = globalState; + this.emit(UPDATE_STATUS_INDICATOR, globalState, state, prevState, data); + } + }; + + protected async onReady() { + this.matrixClient.on("sync", this.onSync); + } + protected async onNotReady(): Promise { for (const roomState of this.roomMap.values()) { roomState.destroy(); From 670d7824ec433b4ec6702682af54804f0781e421 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 17 Dec 2021 12:26:02 +0000 Subject: [PATCH 041/145] Tidy location code (#7402) --- .../views/location/LocationPicker.tsx | 84 ++++++++++++++----- .../views/messages/MLocationBody.tsx | 9 +- .../views/rooms/MessageComposer.tsx | 18 ++-- .../views/location/LocationPicker-test.tsx | 2 +- 4 files changed, 82 insertions(+), 31 deletions(-) diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index 5571f64d866..dc383d67a49 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { SyntheticEvent } from 'react'; import maplibregl from 'maplibre-gl'; import { logger } from "matrix-js-sdk/src/logger"; @@ -40,16 +40,36 @@ const LocationShareTypeDropdown = ({ onChange, }: IDropdownProps) => { const options = [ -
    { _t("Share custom location") }
    , -
    { _t("Share my current location as a once off") }
    , - //
    { _t("Share my current location for one minute") }
    , - //
    { _t("Share my current location for five minutes") }
    , - //
    { _t("Share my current location for thirty minutes") }
    , - //
    { _t("Share my current location for one hour") }
    , - //
    { _t("Share my current location for three hours") }
    , - //
    { _t("Share my current location for six hours") }
    , - //
    { _t("Share my current location for one day") }
    , - //
    { _t("Share my current location until I disable it") }
    , +
    { + _t("Share custom location") + }
    , +
    { + _t("Share my current location as a once off") + }
    , + //
    { + // _t("Share my current location for one minute") + // }
    , + //
    { + // _t("Share my current location for five minutes") + // }
    , + //
    { + // _t("Share my current location for thirty minutes") + // }
    , + //
    { + // _t("Share my current location for one hour") + // }
    , + //
    { + // _t("Share my current location for three hours") + // }
    , + //
    { + // _t("Share my current location for six hours") + // }
    , + //
    { + // _t("Share my current location for one day") + // }
    , + //
    { + // _t("Share my current location until I disable it") + // }
    , ]; return { private marker: maplibregl.Marker; private geolocate: maplibregl.GeolocateControl; - constructor(props) { + constructor(props: IProps) { super(props); this.state = { @@ -117,8 +142,11 @@ class LocationPicker extends React.Component { this.map.addControl(this.geolocate); this.map.on('error', (e) => { - logger.error("Failed to load map: check map_style_url in config.json has a valid URL and API key", - e.error); + logger.error( + "Failed to load map: check map_style_url in config.json " + + "has a valid URL and API key", + e.error, + ); this.setState({ error: e.error }); }); @@ -246,7 +274,10 @@ class LocationPicker extends React.Component { + primaryDisabled={ + !this.state.position && + !this.state.manualPosition + } />
@@ -255,12 +286,19 @@ class LocationPicker extends React.Component { } export function getGeoUri(position: GeolocationPosition): string { - return (`geo:${ position.coords.latitude },` + - position.coords.longitude + - ( position.coords.altitude !== undefined ? - `,${ position.coords.altitude }` : '' ) + - ( position.coords.accuracy !== undefined ? - `;u=${ position.coords.accuracy }` : '' )); + const lat = position.coords.latitude; + const lon = position.coords.longitude; + const alt = ( + position.coords.altitude !== undefined + ? `,${position.coords.altitude}` + : "" + ); + const acc = ( + position.coords.accuracy !== undefined + ? `;u=${ position.coords.accuracy }` + : "" + ); + return `geo:${lat},${lon}${alt}${acc}`; } export default LocationPicker; diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index d9131245d1f..a09e9929b2b 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -55,7 +55,8 @@ export default class MLocationBody extends React.Component { componentDidMount() { const config = SdkConfig.get(); - const coordinates = new maplibregl.LngLat(this.coords.longitude, this.coords.latitude); + const coordinates = new maplibregl.LngLat( + this.coords.longitude, this.coords.latitude); this.map = new maplibregl.Map({ container: this.getBodyId(), @@ -74,7 +75,11 @@ export default class MLocationBody extends React.Component { .addTo(this.map); this.map.on('error', (e)=>{ - logger.error("Failed to load map: check map_style_url in config.json has a valid URL and API key", e.error); + logger.error( + "Failed to load map: check map_style_url in config.json has a " + + "valid URL and API key", + e.error, + ); this.setState({ error: e.error }); }); } diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index dcfd7131f99..9fca185d8eb 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -13,7 +13,7 @@ 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, { ComponentProps, createRef } from 'react'; +import React, { ComponentProps, createRef, ReactElement } from 'react'; import classNames from 'classnames'; import { MatrixEvent, IEventRelation } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; @@ -138,13 +138,21 @@ interface ILocationButtonProps extends Pick = ({ shareLocation, menuPosition, narrowMode }) => { +const LocationButton: React.FC = ( + { shareLocation, menuPosition, narrowMode }, +) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - let contextMenu; + let contextMenu: ReactElement; if (menuDisplayed) { - const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect()); - contextMenu = + const position = menuPosition ?? aboveLeftOf( + button.current.getBoundingClientRect()); + + contextMenu = ; } diff --git a/test/components/views/location/LocationPicker-test.tsx b/test/components/views/location/LocationPicker-test.tsx index 65c76e113ad..0d58612da68 100644 --- a/test/components/views/location/LocationPicker-test.tsx +++ b/test/components/views/location/LocationPicker-test.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import "../../../skinned-sdk"; +import "../../../skinned-sdk"; // Must be first for skinning to work import { getGeoUri } from "../../../../src/components/views/location/LocationPicker"; describe("LocationPicker", () => { From 63e69d9fa8f68ab1b8300646300c9756af0cc21e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 17 Dec 2021 16:41:01 +0000 Subject: [PATCH 042/145] Fix inline code block nowrap issue (#7406) --- res/css/views/rooms/_EventTile.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index b7ad40d0e71..f8f49114191 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -489,7 +489,7 @@ $left-gutter: 64px; } code { - white-space: pre; // don't collapse spaces in inline code blocks + white-space: pre-wrap; // don't collapse spaces in inline code blocks } } From 6761ef954098de8afd68d648a7968f281054a69e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 17 Dec 2021 16:41:39 +0000 Subject: [PATCH 043/145] Space Panel use SettingsStore instead of SpaceStore as source of truth (#7404) --- src/components/views/spaces/SpacePanel.tsx | 4 +--- src/stores/spaces/SpaceStore.ts | 7 +++++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index d68f665ddc6..c120d7fd6ac 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -97,9 +97,7 @@ export const HomeButtonContextMenu = ({ hideHeader, ...props }: ComponentProps) => { - const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => { - return SpaceStore.instance.allRoomsInHome; - }); + const allRoomsInHome = useSettingValue("Spaces.allRoomsInHome"); return { private _invitedSpaces = new Set(); private spaceOrderLocalEchoMap = new Map(); private _restrictedJoinRuleSupport?: IRoomCapability; - private _allRoomsInHome: boolean = SettingsStore.getValue("Spaces.allRoomsInHome"); - private _enabledMetaSpaces: MetaSpace[] = []; // set by onReady + // The following properties are set by onReady as they live in account_data + private _allRoomsInHome = false; + private _enabledMetaSpaces: MetaSpace[] = []; constructor() { super(defaultDispatcher, {}); @@ -1042,6 +1043,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const enabledMetaSpaces = SettingsStore.getValue("Spaces.enabledMetaSpaces"); this._enabledMetaSpaces = metaSpaceOrder.filter(k => enabledMetaSpaces[k]) as MetaSpace[]; + this._allRoomsInHome = SettingsStore.getValue("Spaces.allRoomsInHome"); + this.rebuildSpaceHierarchy(); // trigger an initial update // restore selected state from last session if any and still valid From 9289c0c90fc6877aa6ea751a1ac3c92b16611ef9 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 17 Dec 2021 11:08:56 -0600 Subject: [PATCH 044/145] Refactor `ContextMenu` to use `RovingTabIndex` (more consistent keyboard navigation accessibility) (#7353) Split off from https://github.com/matrix-org/matrix-react-sdk/pull/7339 --- res/css/structures/_UserMenu.scss | 41 ++++- src/accessibility/RovingTabIndex.tsx | 32 +++- src/accessibility/context_menu/MenuItem.tsx | 14 +- .../context_menu/MenuItemCheckbox.tsx | 9 +- .../context_menu/MenuItemRadio.tsx | 9 +- .../context_menu/StyledMenuItemCheckbox.tsx | 7 +- .../context_menu/StyledMenuItemRadio.tsx | 7 +- src/components/structures/ContextMenu.tsx | 145 +++++------------- src/components/structures/LoggedInView.tsx | 5 +- src/components/structures/UserMenu.tsx | 35 +++-- .../views/elements/StyledCheckbox.tsx | 12 +- .../views/elements/StyledRadioButton.tsx | 11 +- src/components/views/rooms/RoomSublist.tsx | 1 + .../__snapshots__/ThreadPanel-test.tsx.snap | 56 +++++-- 14 files changed, 224 insertions(+), 160 deletions(-) diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 5e0d4f6f156..a8b488c1b55 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -208,14 +208,51 @@ limitations under the License. .mx_UserMenu_CustomStatusSection { margin: 0 12px 8px; - .mx_UserMenu_CustomStatusSection_input { + .mx_UserMenu_CustomStatusSection_field { position: relative; display: flex; - > input { + &.mx_UserMenu_CustomStatusSection_field_hasQuery { + .mx_UserMenu_CustomStatusSection_clear { + display: block; + } + } + + > .mx_UserMenu_CustomStatusSection_input { border: 1px solid $accent; border-radius: 8px; width: 100%; + + &:focus + .mx_UserMenu_CustomStatusSection_clear { + display: block; + } + } + + > .mx_UserMenu_CustomStatusSection_clear { + display: none; + + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + + width: 16px; + height: 16px; + margin-right: 8px; + background-color: $quinary-content; + border-radius: 50%; + + &::before { + content: ""; + position: absolute; + width: inherit; + height: inherit; + mask-image: url('$(res)/img/feather-customised/x.svg'); + mask-position: center; + mask-size: 12px; + mask-repeat: no-repeat; + background-color: $secondary-content; + } } } diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 65494a210d8..842b4edce03 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -43,6 +43,17 @@ import { FocusHandler, Ref } from "./roving/types"; * https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex */ +// Check for form elements which utilize the arrow keys for native functions +// like many of the text input varieties. +// +// i.e. it's ok to press the down arrow on a radio button to move to the next +// radio. But it's not ok to press the down arrow on a to +// move away because the down arrow should move the cursor to the end of the +// input. +export function checkInputableElement(el: HTMLElement): boolean { + return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]'); +} + export interface IState { activeRef: Ref; refs: Ref[]; @@ -187,7 +198,7 @@ export const RovingTabIndexProvider: React.FC = ({ const context = useMemo(() => ({ state, dispatch }), [state]); - const onKeyDownHandler = useCallback((ev) => { + const onKeyDownHandler = useCallback((ev: React.KeyboardEvent) => { if (onKeyDown) { onKeyDown(ev, context.state); if (ev.defaultPrevented) { @@ -198,7 +209,18 @@ export const RovingTabIndexProvider: React.FC = ({ let handled = false; let focusRef: RefObject; // Don't interfere with input default keydown behaviour - if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { + // but allow people to move focus from it with Tab. + if (checkInputableElement(ev.target as HTMLElement)) { + switch (ev.key) { + case Key.TAB: + handled = true; + if (context.state.refs.length > 0) { + const idx = context.state.refs.indexOf(context.state.activeRef); + focusRef = findSiblingElement(context.state.refs, idx + (ev.shiftKey ? -1 : 1), ev.shiftKey); + } + break; + } + } else { // check if we actually have any items switch (ev.key) { case Key.HOME: @@ -270,9 +292,11 @@ export const RovingTabIndexProvider: React.FC = ({ // onFocus should be called when the index gained focus in any manner // isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}` // ref should be passed to a DOM node which will be used for DOM compareDocumentPosition -export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => { +export const useRovingTabIndex = ( + inputRef?: RefObject, +): [FocusHandler, boolean, RefObject] => { const context = useContext(RovingTabIndexContext); - let ref = useRef(null); + let ref = useRef(null); if (inputRef) { // if we are given a ref, use it instead of ours diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx index 9c0b2482740..7f231c7bc0f 100644 --- a/src/accessibility/context_menu/MenuItem.tsx +++ b/src/accessibility/context_menu/MenuItem.tsx @@ -18,10 +18,9 @@ limitations under the License. import React from "react"; -import AccessibleButton from "../../components/views/elements/AccessibleButton"; -import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton"; +import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../RovingTabIndex"; -interface IProps extends React.ComponentProps { +interface IProps extends React.ComponentProps { label?: string; tooltip?: string; } @@ -31,15 +30,14 @@ export const MenuItem: React.FC = ({ children, label, tooltip, ...props const ariaLabel = props["aria-label"] || label; if (tooltip) { - return + return { children } - ; + ; } return ( - + { children } - + ); }; - diff --git a/src/accessibility/context_menu/MenuItemCheckbox.tsx b/src/accessibility/context_menu/MenuItemCheckbox.tsx index 67da4cc85a3..6eb66102f3a 100644 --- a/src/accessibility/context_menu/MenuItemCheckbox.tsx +++ b/src/accessibility/context_menu/MenuItemCheckbox.tsx @@ -18,9 +18,9 @@ limitations under the License. import React from "react"; -import AccessibleButton from "../../components/views/elements/AccessibleButton"; +import { RovingAccessibleButton } from "../RovingTabIndex"; -interface IProps extends React.ComponentProps { +interface IProps extends React.ComponentProps { label?: string; active: boolean; } @@ -28,16 +28,15 @@ interface IProps extends React.ComponentProps { // Semantic component for representing a role=menuitemcheckbox export const MenuItemCheckbox: React.FC = ({ children, label, active, disabled, ...props }) => { return ( - { children } - + ); }; diff --git a/src/accessibility/context_menu/MenuItemRadio.tsx b/src/accessibility/context_menu/MenuItemRadio.tsx index eb50d458365..f8c85dd8f9a 100644 --- a/src/accessibility/context_menu/MenuItemRadio.tsx +++ b/src/accessibility/context_menu/MenuItemRadio.tsx @@ -18,9 +18,9 @@ limitations under the License. import React from "react"; -import AccessibleButton from "../../components/views/elements/AccessibleButton"; +import { RovingAccessibleButton } from "../RovingTabIndex"; -interface IProps extends React.ComponentProps { +interface IProps extends React.ComponentProps { label?: string; active: boolean; } @@ -28,16 +28,15 @@ interface IProps extends React.ComponentProps { // Semantic component for representing a role=menuitemradio export const MenuItemRadio: React.FC = ({ children, label, active, disabled, ...props }) => { return ( - { children } - + ); }; diff --git a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx index 66846cc4849..7349646f2ba 100644 --- a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx +++ b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx @@ -19,6 +19,7 @@ limitations under the License. import React from "react"; import { Key } from "../../Keyboard"; +import { useRovingTabIndex } from "../RovingTabIndex"; import StyledCheckbox from "../../components/views/elements/StyledCheckbox"; interface IProps extends React.ComponentProps { @@ -29,6 +30,8 @@ interface IProps extends React.ComponentProps { // Semantic component for representing a styled role=menuitemcheckbox export const StyledMenuItemCheckbox: React.FC = ({ children, label, onChange, onClose, ...props }) => { + const [onFocus, isActive, ref] = useRovingTabIndex(); + const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === Key.ENTER || e.key === Key.SPACE) { e.stopPropagation(); @@ -52,11 +55,13 @@ export const StyledMenuItemCheckbox: React.FC = ({ children, label, onCh { children } diff --git a/src/accessibility/context_menu/StyledMenuItemRadio.tsx b/src/accessibility/context_menu/StyledMenuItemRadio.tsx index e3d340ef3e8..0ce7f3d6f6f 100644 --- a/src/accessibility/context_menu/StyledMenuItemRadio.tsx +++ b/src/accessibility/context_menu/StyledMenuItemRadio.tsx @@ -19,6 +19,7 @@ limitations under the License. import React from "react"; import { Key } from "../../Keyboard"; +import { useRovingTabIndex } from "../RovingTabIndex"; import StyledRadioButton from "../../components/views/elements/StyledRadioButton"; interface IProps extends React.ComponentProps { @@ -29,6 +30,8 @@ interface IProps extends React.ComponentProps { // Semantic component for representing a styled role=menuitemradio export const StyledMenuItemRadio: React.FC = ({ children, label, onChange, onClose, ...props }) => { + const [onFocus, isActive, ref] = useRovingTabIndex(); + const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === Key.ENTER || e.key === Key.SPACE) { e.stopPropagation(); @@ -52,11 +55,13 @@ export const StyledMenuItemRadio: React.FC = ({ children, label, onChang { children } diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index e1aa014bdcd..95a414e1a1b 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -25,7 +25,7 @@ import { Key } from "../../Keyboard"; import { Writeable } from "../../@types/common"; import { replaceableComponent } from "../../utils/replaceableComponent"; import UIStore from "../../stores/UIStore"; -import { getInputableElement } from "./LoggedInView"; +import { checkInputableElement, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex"; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -180,108 +180,39 @@ export default class ContextMenu extends React.PureComponent { if (this.props.onFinished) this.props.onFinished(); }; - private onMoveFocus = (element: Element, up: boolean) => { - let descending = false; // are we currently descending or ascending through the DOM tree? - - do { - const child = up ? element.lastElementChild : element.firstElementChild; - const sibling = up ? element.previousElementSibling : element.nextElementSibling; - - if (descending) { - if (child) { - element = child; - } else if (sibling) { - element = sibling; - } else { - descending = false; - element = element.parentElement; - } - } else { - if (sibling) { - element = sibling; - descending = true; - } else { - element = element.parentElement; - } - } - - if (element) { - if (element.classList.contains("mx_ContextualMenu")) { // we hit the top - element = up ? element.lastElementChild : element.firstElementChild; - descending = true; - } - } - } while (element && !element.getAttribute("role")?.startsWith("menuitem")); - - if (element) { - (element as HTMLElement).focus(); - } - }; - - private onMoveFocusHomeEnd = (element: Element, up: boolean) => { - let results = element.querySelectorAll('[role^="menuitem"]'); - if (!results) { - results = element.querySelectorAll('[tab-index]'); - } - if (results && results.length) { - if (up) { - (results[0] as HTMLElement).focus(); - } else { - (results[results.length - 1] as HTMLElement).focus(); - } - } - }; - private onClick = (ev: React.MouseEvent) => { // Don't allow clicks to escape the context menu wrapper ev.stopPropagation(); }; + // We now only handle closing the ContextMenu in this keyDown handler. + // All of the item/option navigation is delegated to RovingTabIndex. private onKeyDown = (ev: React.KeyboardEvent) => { - // don't let keyboard handling escape the context menu - ev.stopPropagation(); - + // If someone is managing their own focus, we will only exit for them with Escape. + // They are probably using props.focusLock along with this option as well. if (!this.props.managed) { if (ev.key === Key.ESCAPE) { this.props.onFinished(); - ev.preventDefault(); } return; } - // only handle escape when in an input field - if (ev.key !== Key.ESCAPE && getInputableElement(ev.target as HTMLElement)) return; - - let handled = true; - - switch (ev.key) { - // XXX: this is imitating roving behaviour, it should really use the RovingTabIndex utils - // to inherit proper handling of unmount edge cases - case Key.TAB: - case Key.ESCAPE: - case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a - case Key.ARROW_RIGHT: - this.props.onFinished(); - break; - case Key.ARROW_UP: - this.onMoveFocus(ev.target as Element, true); - break; - case Key.ARROW_DOWN: - this.onMoveFocus(ev.target as Element, false); - break; - case Key.HOME: - this.onMoveFocusHomeEnd(this.state.contextMenuElem, true); - break; - case Key.END: - this.onMoveFocusHomeEnd(this.state.contextMenuElem, false); - break; - default: - handled = false; + // When an is focused, only handle the Escape key + if (checkInputableElement(ev.target as HTMLElement) && ev.key !== Key.ESCAPE) { + return; } - if (handled) { - // consume all other keys in context menu - ev.preventDefault(); + if ( + ev.key === Key.ESCAPE || + // You can only navigate the ContextMenu by arrow keys and Home/End (see RovingTabIndex). + // Tabbing to the next section of the page, will close the ContextMenu. + ev.key === Key.TAB || + // When someone moves left or right along a (like the + // MessageActionBar), we should close any ContextMenu that is open. + ev.key === Key.ARROW_LEFT || + ev.key === Key.ARROW_RIGHT + ) { + this.props.onFinished(); } }; @@ -408,23 +339,27 @@ export default class ContextMenu extends React.PureComponent { } return ( -
- { background } -
- { body } -
-
+ + { ({ onKeyDownHandler }) => ( +
+ { background } +
+ { body } +
+
+ ) } +
); } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index d180a327a9a..83cde3d04b9 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -74,7 +74,10 @@ import LegacyCommunityPreview from "./LegacyCommunityPreview"; // NB. this is just for server notices rather than pinned messages in general. const MAX_PINNED_NOTICES_PER_ROOM = 2; -export function getInputableElement(el: HTMLElement): HTMLElement | null { +// Used to find the closest inputable thing. Because of how our composer works, +// your caret might be within a paragraph/font/div/whatever within the +// contenteditable rather than directly in something inputable. +function getInputableElement(el: HTMLElement): HTMLElement | null { return el.closest("input, textarea, select, [contenteditable=true]"); } diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 4c7e01bcdd8..00233d70e5c 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef, useContext, useState } from "react"; +import React, { createRef, useContext, useRef, useState } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import * as fbEmitter from "fbemitter"; import classNames from "classnames"; @@ -33,13 +33,17 @@ import Modal from "../../Modal"; import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme"; +import { + RovingAccessibleButton, + RovingAccessibleTooltipButton, + useRovingTabIndex, +} from "../../accessibility/RovingTabIndex"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import { getHomePageUrl } from "../../utils/pages"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import BaseAvatar from '../views/avatars/BaseAvatar'; -import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { SettingLevel } from "../../settings/SettingLevel"; import IconizedContextMenu, { IconizedContextMenuCheckbox, @@ -61,30 +65,43 @@ const CustomStatusSection = () => { const setStatus = cli.getUser(cli.getUserId()).unstable_statusMessage || ""; const [value, setValue] = useState(setStatus); + const ref = useRef(null); + const [onFocus, isActive] = useRovingTabIndex(ref); + + const classes = classNames({ + 'mx_UserMenu_CustomStatusSection_field': true, + 'mx_UserMenu_CustomStatusSection_field_hasQuery': value, + }); + let details: JSX.Element; if (value !== setStatus) { details = <>

{ _t("Your status will be shown to people you have a DM with.") }

- cli._unstable_setStatusMessage(value)} kind="primary_outline" > { value ? _t("Set status") : _t("Clear status") } - + ; } - return
-
+ return
+
setValue(e.target.value)} placeholder={_t("Set a new status")} autoComplete="off" + onFocus={onFocus} + ref={ref} + tabIndex={isActive ? 0 : -1} /> {
{ details } -
; + ; }; interface IProps { @@ -486,7 +503,7 @@ export default class UserMenu extends React.Component {
- { alt={_t("Switch theme")} width={16} /> - + { customStatusSection } { topSection } diff --git a/src/components/views/elements/StyledCheckbox.tsx b/src/components/views/elements/StyledCheckbox.tsx index 868791151b7..814ed6b38f5 100644 --- a/src/components/views/elements/StyledCheckbox.tsx +++ b/src/components/views/elements/StyledCheckbox.tsx @@ -26,6 +26,7 @@ export enum CheckboxStyle { } interface IProps extends React.InputHTMLAttributes { + inputRef?: React.RefObject; kind?: CheckboxStyle; } @@ -48,7 +49,8 @@ export default class StyledCheckbox extends React.PureComponent public render() { /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ - const { children, className, kind = CheckboxStyle.Solid, ...otherProps } = this.props; + const { children, className, kind = CheckboxStyle.Solid, inputRef, ...otherProps } = this.props; + const newClassName = classnames( "mx_Checkbox", className, @@ -58,7 +60,13 @@ export default class StyledCheckbox extends React.PureComponent }, ); return - +