Merge branches 'develop' and 't3chguy/nad/rampage' of github.com:matrix-org/matrix-react-sdk into t3chguy/nad/rampage

 Conflicts:
	src/components/views/elements/AccessibleTooltipButton.tsx
This commit is contained in:
Michael Telatynski 2020-07-16 15:47:12 +01:00
commit a704eefdd5
22 changed files with 259 additions and 110 deletions

View file

@ -26,23 +26,3 @@ limitations under the License.
margin: 0 -10px 0 0;
padding: 0 10px 0 0;
}
.mx_MainSplit > .mx_ResizeHandle_horizontal:hover {
position: relative;
&::before {
position: absolute;
left: 4px;
top: 50%;
transform: translate(0, -50%);
height: 30%;
width: 4px;
border-radius: 4px;
content: ' ';
background-color: $primary-fg-color;
opacity: 0.8;
}
}

View file

@ -78,23 +78,3 @@ limitations under the License.
*/
height: 100%;
}
.mx_MatrixChat > .mx_ResizeHandle_horizontal:hover {
position: relative;
&::before {
position: absolute;
left: -2px;
top: 50%;
transform: translate(0, -50%);
height: 30%;
width: 4px;
border-radius: 4px;
content: ' ';
background-color: $primary-fg-color;
opacity: 0.8;
}
}

View file

@ -97,7 +97,7 @@ $irc-line-height: $font-18px;
}
> .mx_EventTile_e2eIcon {
position: relative;
position: absolute;
right: unset;
left: unset;
top: 0;

View file

@ -43,6 +43,7 @@ import SdkConfig from "./SdkConfig";
import { ensureDMExists } from "./createRoom";
import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
import { Action } from "./dispatcher/actions";
import { EffectiveMembership, getEffectiveMembership } from "./utils/membership";
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
interface HTMLInputEvent extends Event {
@ -730,9 +731,11 @@ export const Commands = [
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) return reject(_t("Command failed"));
const member = room.getMember(args);
if (!member || getEffectiveMembership(member.membership) === EffectiveMembership.Leave) {
return reject(_t("Could not find user in room"));
}
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
if (!powerLevelEvent.getContent().users[args]) return reject(_t("Could not find user in room"));
return success(cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent));
}
}

View file

@ -23,12 +23,11 @@ import React, {
useRef,
useReducer,
Reducer,
RefObject,
Dispatch,
} from "react";
import {Key} from "../Keyboard";
import AccessibleButton from "../components/views/elements/AccessibleButton";
import {FocusHandler, Ref} from "./roving/types";
/**
* Module to simplify implementing the Roving TabIndex accessibility technique
@ -45,9 +44,7 @@ import AccessibleButton from "../components/views/elements/AccessibleButton";
const DOCUMENT_POSITION_PRECEDING = 2;
type Ref = RefObject<HTMLElement>;
interface IState {
export interface IState {
activeRef: Ref;
refs: Ref[];
}
@ -156,7 +153,7 @@ interface IProps {
children(renderProps: {
onKeyDownHandler(ev: React.KeyboardEvent);
});
onKeyDown?(ev: React.KeyboardEvent);
onKeyDown?(ev: React.KeyboardEvent, state: IState);
}
export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEnd, onKeyDown}) => {
@ -193,7 +190,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
ev.preventDefault();
ev.stopPropagation();
} else if (onKeyDown) {
return onKeyDown(ev);
return onKeyDown(ev, state);
}
}, [context.state, onKeyDown, handleHomeEnd]);
@ -202,8 +199,6 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
</RovingTabIndexContext.Provider>;
};
type FocusHandler = () => void;
// Hook to register a roving tab index
// inputRef parameter specifies the ref to use
// onFocus should be called when the index gained focus in any manner
@ -244,28 +239,7 @@ export const useRovingTabIndex = (inputRef: Ref): [FocusHandler, boolean, Ref] =
return [onFocus, isActive, ref];
};
interface IRovingTabIndexWrapperProps {
inputRef?: Ref;
children(renderProps: {
onFocus: FocusHandler;
isActive: boolean;
ref: Ref;
});
}
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
export const RovingTabIndexWrapper: React.FC<IRovingTabIndexWrapperProps> = ({children, inputRef}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({onFocus, isActive, ref});
};
interface IRovingAccessibleButtonProps extends React.ComponentProps<typeof AccessibleButton> {
inputRef?: Ref;
}
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleButton: React.FC<IRovingAccessibleButtonProps> = ({inputRef, ...props}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
};
// re-export the semantic helper components for simplicity
export {RovingTabIndexWrapper} from "./roving/RovingTabIndexWrapper";
export {RovingAccessibleButton} from "./roving/RovingAccessibleButton";
export {RovingAccessibleTooltipButton} from "./roving/RovingAccessibleTooltipButton";

View file

@ -0,0 +1,69 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import {IState, RovingTabIndexProvider} from "./RovingTabIndex";
import {Key} from "../Keyboard";
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
}
// This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines.
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
const Toolbar: React.FC<IProps> = ({children, ...props}) => {
const onKeyDown = (ev: React.KeyboardEvent, state: IState) => {
const target = ev.target as HTMLElement;
let handled = true;
switch (ev.key) {
case Key.ARROW_UP:
case Key.ARROW_DOWN:
if (target.hasAttribute('aria-haspopup')) {
target.click();
}
break;
case Key.ARROW_LEFT:
case Key.ARROW_RIGHT:
if (state.refs.length > 0) {
const i = state.refs.findIndex(r => r === state.activeRef);
const delta = ev.key === Key.ARROW_RIGHT ? 1 : -1;
state.refs.slice((i + delta) % state.refs.length)[0].current.focus();
}
break;
// HOME and END are handled by RovingTabIndexProvider
default:
handled = false;
}
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
};
return <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
{({onKeyDownHandler}) => <div {...props} onKeyDown={onKeyDownHandler} role="toolbar">
{ children }
</div>}
</RovingTabIndexProvider>;
};
export default Toolbar;

View file

@ -0,0 +1,32 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import {useRovingTabIndex} from "../RovingTabIndex";
import {Ref} from "./types";
interface IProps extends Omit<React.ComponentProps<typeof AccessibleButton>, "onFocus" | "inputRef" | "tabIndex"> {
inputRef?: Ref;
}
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleButton: React.FC<IProps> = ({inputRef, ...props}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
};

View file

@ -0,0 +1,32 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
import {useRovingTabIndex} from "../RovingTabIndex";
import {Ref} from "./types";
interface IProps extends Omit<React.ComponentProps<typeof AccessibleTooltipButton>, "onFocus" | "inputRef" | "tabIndex"> {
inputRef?: Ref;
}
// Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components.
export const RovingAccessibleTooltipButton: React.FC<IProps> = ({inputRef, ...props}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleTooltipButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
};

View file

@ -0,0 +1,36 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import {useRovingTabIndex} from "../RovingTabIndex";
import {FocusHandler, Ref} from "./types";
interface IProps {
inputRef?: Ref;
children(renderProps: {
onFocus: FocusHandler;
isActive: boolean;
ref: Ref;
});
}
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
export const RovingTabIndexWrapper: React.FC<IProps> = ({children, inputRef}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({onFocus, isActive, ref});
};

View file

@ -0,0 +1,21 @@
/*
Copyright 2020 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 {RefObject} from "react";
export type Ref = RefObject<HTMLElement>;
export type FocusHandler = () => void;

View file

@ -233,6 +233,9 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
switch (ev.key) {
case Key.TAB:
case Key.ESCAPE:
// close on left and right arrows too for when it is a context menu on a <Toolbar />
case Key.ARROW_LEFT:
case Key.ARROW_RIGHT:
this.props.onFinished();
break;
case Key.ARROW_UP:

View file

@ -269,7 +269,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
const firstRoom = this.listContainerRef.current.querySelector<HTMLDivElement>(".mx_RoomTile2");
if (firstRoom) {
firstRoom.click();
this.onSearch(""); // clear the search field
return true; // to get the field to clear
}
};

View file

@ -675,12 +675,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case 'hide_left_panel':
this.setState({
collapseLhs: true,
}, () => {
this.state.resizeNotifier.notifyLeftHandleResized();
});
break;
case 'focus_room_filter': // for CtrlOrCmd+K to work by expanding the left panel first
case 'show_left_panel':
this.setState({
collapseLhs: false,
}, () => {
this.state.resizeNotifier.notifyLeftHandleResized();
});
break;
case 'panel_disable': {

View file

@ -28,8 +28,8 @@ import { Action } from "../../dispatcher/actions";
interface IProps {
onQueryUpdate: (newQuery: string) => void;
isMinimized: boolean;
onVerticalArrow(ev: React.KeyboardEvent);
onEnter(ev: React.KeyboardEvent);
onVerticalArrow(ev: React.KeyboardEvent): void;
onEnter(ev: React.KeyboardEvent): boolean;
}
interface IState {
@ -107,7 +107,13 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
} else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) {
this.props.onVerticalArrow(ev);
} else if (ev.key === Key.ENTER) {
this.props.onEnter(ev);
const shouldClear = this.props.onEnter(ev);
if (shouldClear) {
// wrap in set immediate to delay it so that we don't clear the filter & then change room
setImmediate(() => {
this.clearInput();
});
}
}
};

View file

@ -66,7 +66,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
oobData={this.props.oobData}
viewAvatarOnClick={this.props.viewAvatarOnClick}
/>
<RoomTileIcon room={this.props.room} tag={this.props.tag} />
<RoomTileIcon room={this.props.room} />
{badge}
</div>;
}

View file

@ -16,7 +16,7 @@ limitations under the License.
*/
import React from 'react';
import classnames from 'classnames';
import classNames from 'classnames';
import AccessibleButton from "./AccessibleButton";
import {IProps} from "./AccessibleButton";
@ -53,15 +53,11 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
};
render() {
const {title, tooltip, children, ...props} = this.props;
const tooltipClassName = classnames(
"mx_AccessibleTooltipButton_tooltip",
this.props.tooltipClassName,
);
const {title, tooltip, children, tooltipClassName, ...props} = this.props;
const tip = this.state.hover ? <Tooltip
className="mx_AccessibleTooltipButton_container"
tooltipClassName={tooltipClassName}
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
label={tooltip || title}
/> : <div />;
return (

View file

@ -25,9 +25,12 @@ import dis from '../../../dispatcher/dispatcher';
import {aboveLeftOf, ContextMenu, ContextMenuButton, useContextMenu} from '../../structures/ContextMenu';
import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
import RoomContext from "../../../contexts/RoomContext";
import Toolbar from "../../../accessibility/Toolbar";
import {RovingAccessibleButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex";
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
const [onFocus, isActive, ref] = useRovingTabIndex(button);
useEffect(() => {
onFocusChange(menuDisplayed);
}, [onFocusChange, menuDisplayed]);
@ -57,7 +60,9 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo
label={_t("Options")}
onClick={openMenu}
isExpanded={menuDisplayed}
inputRef={button}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
/>
{ contextMenu }
@ -66,6 +71,7 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo
const ReactButton = ({mxEvent, reactions, onFocusChange}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
const [onFocus, isActive, ref] = useRovingTabIndex(button);
useEffect(() => {
onFocusChange(menuDisplayed);
}, [onFocusChange, menuDisplayed]);
@ -85,7 +91,9 @@ const ReactButton = ({mxEvent, reactions, onFocusChange}) => {
label={_t("React")}
onClick={openMenu}
isExpanded={menuDisplayed}
inputRef={button}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
/>
{ contextMenu }
@ -148,8 +156,6 @@ export default class MessageActionBar extends React.PureComponent {
};
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let reactButton;
let replyButton;
let editButton;
@ -161,7 +167,7 @@ export default class MessageActionBar extends React.PureComponent {
);
}
if (this.context.canReply) {
replyButton = <AccessibleButton
replyButton = <RovingAccessibleButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
@ -169,7 +175,7 @@ export default class MessageActionBar extends React.PureComponent {
}
}
if (canEditContent(this.props.mxEvent)) {
editButton = <AccessibleButton
editButton = <RovingAccessibleButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
title={_t("Edit")}
onClick={this.onEditClick}
@ -177,7 +183,7 @@ export default class MessageActionBar extends React.PureComponent {
}
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
return <div className="mx_MessageActionBar" role="toolbar" aria-label={_t("Message Actions")} aria-live="off">
return <Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
{reactButton}
{replyButton}
{editButton}
@ -188,6 +194,6 @@ export default class MessageActionBar extends React.PureComponent {
permalinkCreator={this.props.permalinkCreator}
onFocusChange={this.onFocusChange}
/>
</div>;
</Toolbar>;
}
}

View file

@ -25,7 +25,8 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { CSSTransition } from "react-transition-group";
import RoomListStore from "../../../stores/room-list/RoomListStore2";
import { DefaultTagID } from "../../../stores/room-list/models";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
import Toolbar from "../../../accessibility/Toolbar";
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
@ -86,7 +87,7 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
const roomTags = RoomListStore.instance.getTagsForRoom(r);
const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0];
return (
<AccessibleTooltipButton
<RovingAccessibleTooltipButton
className="mx_RoomBreadcrumbs2_crumb"
key={r.roomId}
onClick={() => this.viewRoom(r, i)}
@ -101,7 +102,7 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
displayBadge={true}
forceCount={true}
/>
</AccessibleTooltipButton>
</RovingAccessibleTooltipButton>
);
});
@ -112,9 +113,9 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
appear={true} in={this.state.doAnimation} timeout={640}
classNames='mx_RoomBreadcrumbs2'
>
<div className='mx_RoomBreadcrumbs2'>
<Toolbar className='mx_RoomBreadcrumbs2'>
{tiles.slice(this.state.skipFirst ? 1 : 0)}
</div>
</Toolbar>
</CSSTransition>
);
} else {

View file

@ -49,7 +49,6 @@ function tooltipText(variant: Icon) {
interface IProps {
room: Room;
tag: TagID;
}
interface IState {
@ -137,10 +136,11 @@ export default class RoomTileIcon extends React.Component<IProps, IState> {
private calculateIcon(): Icon {
let icon = Icon.None;
if (this.props.tag === DefaultTagID.DM && this.props.room.getJoinedMemberCount() === 2) {
// We look at the DMRoomMap and not the tag here so that we don't exclude DMs in Favourites
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
if (otherUserId && this.props.room.getJoinedMemberCount() === 2) {
// Track presence, if available
if (isPresenceEnabled()) {
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
if (otherUserId) {
this.dmUser = MatrixClientPeg.get().getUser(otherUserId);
icon = this.getPresenceIcon();

View file

@ -33,7 +33,6 @@ import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import RoomListLayoutStore from "./RoomListLayoutStore";
import { MarkedExecution } from "../../utils/MarkedExecution";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import { MatrixClientPeg } from "../../MatrixClientPeg";
interface IState {
tagsEnabled?: boolean;

View file

@ -158,6 +158,7 @@ export class Algorithm extends EventEmitter {
filterCondition.off(FILTER_CHANGED, this.handleFilterChange.bind(this));
if (this.allowedByFilter.has(filterCondition)) {
this.allowedByFilter.delete(filterCondition);
this.recalculateFilteredRooms();
// If we removed the last filter, tell consumers that we've "updated" our filtered
// view. This will trick them into getting the complete room list.

View file

@ -21,6 +21,7 @@ import { EventEmitter } from "events";
import GroupStore from "../../GroupStore";
import { arrayHasDiff } from "../../../utils/arrays";
import { IDestroyable } from "../../../utils/IDestroyable";
import DMRoomMap from "../../../utils/DMRoomMap";
/**
* A filter condition for the room list which reveals rooms which
@ -28,6 +29,7 @@ import { IDestroyable } from "../../../utils/IDestroyable";
*/
export class CommunityFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable {
private roomIds: string[] = [];
private userIds: string[] = [];
constructor(private community: Group) {
super();
@ -43,15 +45,19 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
}
public isVisible(room: Room): boolean {
return this.roomIds.includes(room.roomId);
return this.roomIds.includes(room.roomId) ||
this.userIds.includes(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
}
private onStoreUpdate = async (): Promise<any> => {
// We don't actually know if the room list changed for the community, so just
// check it again.
// We don't actually know if the room list changed for the community, so just check it again.
const beforeRoomIds = this.roomIds;
this.roomIds = (await GroupStore.getGroupRooms(this.community.groupId)).map(r => r.roomId);
if (arrayHasDiff(beforeRoomIds, this.roomIds)) {
const beforeUserIds = this.userIds;
this.userIds = (await GroupStore.getGroupMembers(this.community.groupId)).map(u => u.userId);
if (arrayHasDiff(beforeRoomIds, this.roomIds) || arrayHasDiff(beforeUserIds, this.userIds)) {
this.emit(FILTER_CHANGED);
}
};