Improve spotlight accessibility by adding context menus (#8907)

* Extract room general context menu from roomtile
* Create hook to access and change a room’s notification state
* Extract room notification context menu from roomtile
* Add room context menus to rooms in spotlight
* Make arrow movement apply to the whole dialog, not just the input box
This commit is contained in:
Janne Mareike Koschinski 2022-07-12 15:03:08 +02:00 committed by GitHub
parent 6a125d5a1d
commit 780a903e2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 632 additions and 283 deletions

View file

@ -89,6 +89,8 @@
@import "./views/context_menus/_DeviceContextMenu.scss"; @import "./views/context_menus/_DeviceContextMenu.scss";
@import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss";
@import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss";
@import "./views/context_menus/_RoomGeneralContextMenu.scss";
@import "./views/context_menus/_RoomNotificationContextMenu.scss";
@import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddExistingToSpaceDialog.scss";
@import "./views/dialogs/_AnalyticsLearnMoreDialog.scss"; @import "./views/dialogs/_AnalyticsLearnMoreDialog.scss";
@import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_BugReportDialog.scss";

View file

@ -0,0 +1,63 @@
.mx_RoomGeneralContextMenu_iconStar::before {
mask-image: url('$(res)/img/element-icons/roomlist/favorite.svg');
}
.mx_RoomGeneralContextMenu_iconArrowDown::before {
mask-image: url('$(res)/img/element-icons/roomlist/low-priority.svg');
}
.mx_RoomGeneralContextMenu_iconNotificationsDefault::before {
mask-image: url('$(res)/img/element-icons/notifications.svg');
}
.mx_RoomGeneralContextMenu_iconNotificationsAllMessages::before {
mask-image: url('$(res)/img/element-icons/roomlist/notifications-default.svg');
}
.mx_RoomGeneralContextMenu_iconNotificationsMentionsKeywords::before {
mask-image: url('$(res)/img/element-icons/roomlist/notifications-dm.svg');
}
.mx_RoomGeneralContextMenu_iconNotificationsNone::before {
mask-image: url('$(res)/img/element-icons/roomlist/notifications-off.svg');
}
.mx_RoomGeneralContextMenu_iconPeople::before {
mask-image: url('$(res)/img/element-icons/room/members.svg');
}
.mx_RoomGeneralContextMenu_iconFiles::before {
mask-image: url('$(res)/img/element-icons/room/files.svg');
}
.mx_RoomGeneralContextMenu_iconPins::before {
mask-image: url('$(res)/img/element-icons/room/pin-upright.svg');
}
.mx_RoomGeneralContextMenu_iconWidgets::before {
mask-image: url('$(res)/img/element-icons/room/apps.svg');
}
.mx_RoomGeneralContextMenu_iconSettings::before {
mask-image: url('$(res)/img/element-icons/settings.svg');
}
.mx_RoomGeneralContextMenu_iconExport::before {
mask-image: url('$(res)/img/element-icons/export.svg');
}
.mx_RoomGeneralContextMenu_iconDeveloperTools::before {
mask-image: url('$(res)/img/element-icons/settings/flask.svg');
}
.mx_RoomGeneralContextMenu_iconCopyLink::before {
mask-image: url('$(res)/img/element-icons/link.svg');
}
.mx_RoomGeneralContextMenu_iconInvite::before {
mask-image: url('$(res)/img/element-icons/room/invite.svg');
}
.mx_RoomGeneralContextMenu_iconSignOut::before {
mask-image: url('$(res)/img/element-icons/leave.svg');
}

View file

@ -0,0 +1,12 @@
.mx_RoomNotificationContextMenu_iconBell::before {
mask-image: url('$(res)/img/element-icons/notifications.svg');
}
.mx_RoomNotificationContextMenu_iconBellDot::before {
mask-image: url('$(res)/img/element-icons/roomlist/notifications-default.svg');
}
.mx_RoomNotificationContextMenu_iconBellMentions::before {
mask-image: url('$(res)/img/element-icons/roomlist/notifications-dm.svg');
}
.mx_RoomNotificationContextMenu_iconBellCrossed::before {
mask-image: url('$(res)/img/element-icons/roomlist/notifications-off.svg');
}

View file

@ -239,6 +239,13 @@ limitations under the License.
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
.mx_SpotlightDialog_option--endAdornment {
display: inline-flex;
flex-direction: row;
margin-left: auto;
align-items: start;
}
&.mx_SpotlightDialog_result_multiline { &.mx_SpotlightDialog_result_multiline {
align-items: start; align-items: start;
@ -309,8 +316,45 @@ limitations under the License.
margin-left: $spacing-8; margin-left: $spacing-8;
} }
.mx_SpotlightDialog_option--menu,
.mx_SpotlightDialog_option--notifications {
width: 20px;
min-width: 20px;
height: 20px;
margin-top: auto;
margin-bottom: auto;
position: relative;
display: none;
&::before {
top: 2px;
left: 2px;
content: '';
width: 16px;
height: 16px;
position: absolute;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: $tertiary-content;
}
&:hover::before, &[aria-selected=true]::before {
background-color: $secondary-content;
}
}
.mx_SpotlightDialog_option--menu::before {
mask-image: url('$(res)/img/element-icons/context-menu.svg');
}
&:hover, &[aria-selected=true] { &:hover, &[aria-selected=true] {
background-color: $system; background-color: $system;
.mx_SpotlightDialog_option--menu,
.mx_SpotlightDialog_option--notifications {
display: block;
}
} }
&[aria-selected=true] .mx_SpotlightDialog_enterPrompt { &[aria-selected=true] .mx_SpotlightDialog_enterPrompt {
@ -436,7 +480,7 @@ limitations under the License.
color: $tertiary-content; color: $tertiary-content;
border-radius: 6px; border-radius: 6px;
background-color: $quinary-content; background-color: $quinary-content;
margin: 0 $spacing-4 0 auto; margin-right: $spacing-4;
display: none; display: none;
} }

View file

@ -0,0 +1,186 @@
/*
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 { logger } from "matrix-js-sdk/src/logger";
import { Room } from "matrix-js-sdk/src/models/room";
import React, { useContext } from "react";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import RoomListActions from "../../../actions/RoomListActions";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import dis from "../../../dispatcher/dispatcher";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { _t } from "../../../languageHandler";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import DMRoomMap from "../../../utils/DMRoomMap";
import { IProps as IContextMenuProps } from "../../structures/ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuCheckbox,
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import { ButtonEvent } from "../elements/AccessibleButton";
interface IProps extends IContextMenuProps {
room: Room;
onPostFavoriteClick?: (event: ButtonEvent) => void;
onPostLowPriorityClick?: (event: ButtonEvent) => void;
onPostInviteClick?: (event: ButtonEvent) => void;
onPostCopyLinkClick?: (event: ButtonEvent) => void;
onPostSettingsClick?: (event: ButtonEvent) => void;
onPostForgetClick?: (event: ButtonEvent) => void;
onPostLeaveClick?: (event: ButtonEvent) => void;
}
export const RoomGeneralContextMenu = ({
room, onFinished,
onPostFavoriteClick, onPostLowPriorityClick, onPostInviteClick, onPostCopyLinkClick, onPostSettingsClick,
onPostLeaveClick, onPostForgetClick, ...props
}: IProps) => {
const cli = useContext(MatrixClientContext);
const roomTags = useEventEmitterState(
RoomListStore.instance,
LISTS_UPDATE_EVENT,
() => RoomListStore.instance.getTagsForRoom(room),
);
const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
const wrapHandler = (
handler: (ev: ButtonEvent) => void,
postHandler?: (ev: ButtonEvent) => void,
persistent = false,
): (ev: ButtonEvent) => void => {
return (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
handler(ev);
const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent);
if (!persistent || action === KeyBindingAction.Enter) {
onFinished();
}
postHandler?.(ev);
};
};
const onTagRoom = (ev: ButtonEvent, tagId: TagID) => {
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
const isApplied = RoomListStore.instance.getTagsForRoom(room).includes(tagId);
const removeTag = isApplied ? tagId : inverseTag;
const addTag = isApplied ? null : tagId;
dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, undefined, 0));
} else {
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
}
};
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
const favoriteOption: JSX.Element = <IconizedContextMenuCheckbox
onClick={wrapHandler((ev) =>
onTagRoom(ev, DefaultTagID.Favourite), onPostFavoriteClick, true)}
active={isFavorite}
label={isFavorite ? _t("Favourited") : _t("Favourite")}
iconClassName="mx_RoomGeneralContextMenu_iconStar"
/>;
const isLowPriority = roomTags.includes(DefaultTagID.LowPriority);
const lowPriorityOption: JSX.Element = <IconizedContextMenuCheckbox
onClick={wrapHandler((ev) =>
onTagRoom(ev, DefaultTagID.LowPriority), onPostLowPriorityClick, true)}
active={isLowPriority}
label={_t("Low Priority")}
iconClassName="mx_RoomGeneralContextMenu_iconArrowDown"
/>;
let inviteOption: JSX.Element;
if (room.canInvite(cli.getUserId()) && !isDm) {
inviteOption = <IconizedContextMenuOption
onClick={wrapHandler(() => dis.dispatch({
action: "view_invite",
roomId: room.roomId,
}), onPostInviteClick)}
label={_t("Invite")}
iconClassName="mx_RoomGeneralContextMenu_iconInvite"
/>;
}
let copyLinkOption: JSX.Element;
if (!isDm) {
copyLinkOption = <IconizedContextMenuOption
onClick={wrapHandler(() => dis.dispatch({
action: "copy_room",
room_id: room.roomId,
}), onPostCopyLinkClick)}
label={_t("Copy room link")}
iconClassName="mx_RoomGeneralContextMenu_iconCopyLink"
/>;
}
const settingsOption: JSX.Element = <IconizedContextMenuOption
onClick={wrapHandler(() => dis.dispatch({
action: "open_room_settings",
room_id: room.roomId,
}), onPostSettingsClick)}
label={_t("Settings")}
iconClassName="mx_RoomGeneralContextMenu_iconSettings"
/>;
let leaveOption: JSX.Element;
if (roomTags.includes(DefaultTagID.Archived)) {
leaveOption = <IconizedContextMenuOption
iconClassName="mx_RoomGeneralContextMenu_iconSignOut"
label={_t("Forget Room")}
className="mx_IconizedContextMenu_option_red"
onClick={wrapHandler(() => dis.dispatch({
action: "forget_room",
room_id: room.roomId,
}), onPostForgetClick)}
/>;
} else {
leaveOption = <IconizedContextMenuOption
onClick={wrapHandler(() => dis.dispatch({
action: "leave_room",
room_id: room.roomId,
}), onPostLeaveClick)}
label={_t("Leave")}
className="mx_IconizedContextMenu_option_red"
iconClassName="mx_RoomGeneralContextMenu_iconSignOut"
/>;
}
return <IconizedContextMenu
{...props}
onFinished={onFinished}
className="mx_RoomGeneralContextMenu"
compact
>
{ !roomTags.includes(DefaultTagID.Archived) && (
<IconizedContextMenuOptionList>
{ favoriteOption }
{ lowPriorityOption }
{ inviteOption }
{ copyLinkOption }
{ settingsOption }
</IconizedContextMenuOptionList>
) }
<IconizedContextMenuOptionList red>
{ leaveOption }
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
};

View file

@ -0,0 +1,97 @@
/*
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 { Room } from "matrix-js-sdk/src/models/room";
import React from "react";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { useNotificationState } from "../../../hooks/useRoomNotificationState";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { _t } from "../../../languageHandler";
import { RoomNotifState } from "../../../RoomNotifs";
import { IProps as IContextMenuProps } from "../../structures/ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOptionList,
IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu";
import { ButtonEvent } from "../elements/AccessibleButton";
interface IProps extends IContextMenuProps {
room: Room;
}
export const RoomNotificationContextMenu = ({ room, onFinished, ...props }: IProps) => {
const [notificationState, setNotificationState] = useNotificationState(room);
const wrapHandler = (
handler: (ev: ButtonEvent) => void,
persistent = false,
): (ev: ButtonEvent) => void => {
return (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
handler(ev);
const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent);
if (!persistent || action === KeyBindingAction.Enter) {
onFinished();
}
};
};
const defaultOption: JSX.Element = <IconizedContextMenuRadio
label={_t("Use default")}
active={notificationState === RoomNotifState.AllMessages}
iconClassName="mx_RoomNotificationContextMenu_iconBell"
onClick={wrapHandler(() => setNotificationState(RoomNotifState.AllMessages))}
/>;
const allMessagesOption: JSX.Element = <IconizedContextMenuRadio
label={_t("All messages")}
active={notificationState === RoomNotifState.AllMessagesLoud}
iconClassName="mx_RoomNotificationContextMenu_iconBellDot"
onClick={wrapHandler(() => setNotificationState(RoomNotifState.AllMessagesLoud))}
/>;
const mentionsOption: JSX.Element = <IconizedContextMenuRadio
label={_t("Mentions & Keywords")}
active={notificationState === RoomNotifState.MentionsOnly}
iconClassName="mx_RoomNotificationContextMenu_iconBellMentions"
onClick={wrapHandler(() => setNotificationState(RoomNotifState.MentionsOnly))}
/>;
const muteOption: JSX.Element = <IconizedContextMenuRadio
label={_t("None")}
active={notificationState === RoomNotifState.Mute}
iconClassName="mx_RoomNotificationContextMenu_iconBellCrossed"
onClick={wrapHandler(() => setNotificationState(RoomNotifState.Mute))}
/>;
return <IconizedContextMenu
{...props}
onFinished={onFinished}
className="mx_RoomNotificationContextMenu"
compact
>
<IconizedContextMenuOptionList first>
{ defaultOption }
{ allMessagesOption }
{ mentionsOption }
{ muteOption }
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
};

View file

@ -37,7 +37,9 @@ export const Option: React.FC<OptionProps> = ({ inputRef, children, endAdornment
role="option" role="option"
> >
{ children } { children }
<div className="mx_SpotlightDialog_option--endAdornment">
<kbd className="mx_SpotlightDialog_enterPrompt" aria-hidden></kbd> <kbd className="mx_SpotlightDialog_enterPrompt" aria-hidden></kbd>
{ endAdornment } { endAdornment }
</div>
</AccessibleButton>; </AccessibleButton>;
}; };

View file

@ -0,0 +1,107 @@
/*
Copyright 2021-2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/matrix";
import React, { Fragment, useState } from "react";
import { ContextMenuTooltipButton } from "../../../../accessibility/context_menu/ContextMenuTooltipButton";
import { useNotificationState } from "../../../../hooks/useRoomNotificationState";
import { _t } from "../../../../languageHandler";
import { RoomNotifState } from "../../../../RoomNotifs";
import { RoomGeneralContextMenu } from "../../context_menus/RoomGeneralContextMenu";
import { RoomNotificationContextMenu } from "../../context_menus/RoomNotificationContextMenu";
import SpaceContextMenu from "../../context_menus/SpaceContextMenu";
import { ButtonEvent } from "../../elements/AccessibleButton";
import { contextMenuBelow } from "../../rooms/RoomTile";
interface Props {
room: Room;
}
export function RoomResultContextMenus({ room }: Props) {
const [notificationState] = useNotificationState(room);
const [generalMenuPosition, setGeneralMenuPosition] = useState<DOMRect | null>(null);
const [notificationMenuPosition, setNotificationMenuPosition] = useState<DOMRect | null>(null);
let generalMenu: JSX.Element;
if (generalMenuPosition !== null) {
if (room.isSpaceRoom()) {
generalMenu = <SpaceContextMenu
{...contextMenuBelow(generalMenuPosition)}
space={room}
onFinished={() => setGeneralMenuPosition(null)}
/>;
} else {
generalMenu = <RoomGeneralContextMenu
{...contextMenuBelow(generalMenuPosition)}
room={room}
onFinished={() => setGeneralMenuPosition(null)}
/>;
}
}
let notificationMenu: JSX.Element;
if (notificationMenuPosition !== null) {
notificationMenu = <RoomNotificationContextMenu
{...contextMenuBelow(notificationMenuPosition)}
room={room}
onFinished={() => setNotificationMenuPosition(null)}
/>;
}
const notificationMenuClasses = classNames("mx_SpotlightDialog_option--notifications", {
// Show bell icon for the default case too.
mx_RoomNotificationContextMenu_iconBell: notificationState === RoomNotifState.AllMessages,
mx_RoomNotificationContextMenu_iconBellDot: notificationState === RoomNotifState.AllMessagesLoud,
mx_RoomNotificationContextMenu_iconBellMentions: notificationState === RoomNotifState.MentionsOnly,
mx_RoomNotificationContextMenu_iconBellCrossed: notificationState === RoomNotifState.Mute,
});
return (
<Fragment>
<ContextMenuTooltipButton
className="mx_SpotlightDialog_option--menu"
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLElement;
setGeneralMenuPosition(target.getBoundingClientRect());
}}
title={room.isSpaceRoom() ? _t("Space options") : _t("Room options")}
isExpanded={generalMenuPosition !== null}
/>
{ !room.isSpaceRoom() && (
<ContextMenuTooltipButton
className={notificationMenuClasses}
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLElement;
setNotificationMenuPosition(target.getBoundingClientRect());
}}
title={_t("Notification options")}
isExpanded={notificationMenuPosition !== null}
/>
) }
{ generalMenu }
{ notificationMenu }
</Fragment>
);
}

View file

@ -88,6 +88,7 @@ import FeedbackDialog from "../FeedbackDialog";
import { IDialogProps } from "../IDialogProps"; import { IDialogProps } from "../IDialogProps";
import { Option } from "./Option"; import { Option } from "./Option";
import { PublicRoomResultDetails } from "./PublicRoomResultDetails"; import { PublicRoomResultDetails } from "./PublicRoomResultDetails";
import { RoomResultContextMenus } from "./RoomResultContextMenus";
import { RoomContextDetails } from "../../rooms/RoomContextDetails"; import { RoomContextDetails } from "../../rooms/RoomContextDetails";
import { TooltipOption } from "./TooltipOption"; import { TooltipOption } from "./TooltipOption";
@ -506,8 +507,11 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
let otherSearchesSection: JSX.Element; let otherSearchesSection: JSX.Element;
if (trimmedQuery || filter !== Filter.PublicRooms) { if (trimmedQuery || filter !== Filter.PublicRooms) {
otherSearchesSection = ( otherSearchesSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group"> <div
<h4> className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches"
role="group"
aria-labelledby="mx_SpotlightDialog_section_otherSearches">
<h4 id="mx_SpotlightDialog_section_otherSearches">
{ trimmedQuery { trimmedQuery
? _t('Use "%(query)s" to search', { query }) ? _t('Use "%(query)s" to search', { query })
: _t("Search for") } : _t("Search for") }
@ -544,7 +548,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
const unreadLabel = roomAriaUnreadLabel(result.room, notification); const unreadLabel = roomAriaUnreadLabel(result.room, notification);
const ariaProperties = { const ariaProperties = {
"aria-label": unreadLabel ? `${result.room.name} ${unreadLabel}` : result.room.name, "aria-label": unreadLabel ? `${result.room.name} ${unreadLabel}` : result.room.name,
"aria-details": `mx_SpotlightDialog_button_result_${result.room.roomId}_details`, "aria-describedby": `mx_SpotlightDialog_button_result_${result.room.roomId}_details`,
}; };
return ( return (
<Option <Option
@ -553,6 +557,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
onClick={(ev) => { onClick={(ev) => {
viewRoom(result.room.roomId, true, ev?.type !== "click"); viewRoom(result.room.roomId, true, ev?.type !== "click");
}} }}
endAdornment={<RoomResultContextMenus room={result.room} />}
{...ariaProperties} {...ariaProperties}
> >
<DecoratedRoomAvatar <DecoratedRoomAvatar
@ -948,8 +953,10 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
tabIndex={-1} tabIndex={-1}
aria-labelledby="mx_SpotlightDialog_section_recentSearches" aria-labelledby="mx_SpotlightDialog_section_recentSearches"
> >
<h4 id="mx_SpotlightDialog_section_recentSearches"> <h4>
<span id="mx_SpotlightDialog_section_recentSearches">
{ _t("Recent searches") } { _t("Recent searches") }
</span>
<AccessibleButton kind="link" onClick={clearRecentSearches}> <AccessibleButton kind="link" onClick={clearRecentSearches}>
{ _t("Clear") } { _t("Clear") }
</AccessibleButton> </AccessibleButton>
@ -960,7 +967,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
const unreadLabel = roomAriaUnreadLabel(room, notification); const unreadLabel = roomAriaUnreadLabel(room, notification);
const ariaProperties = { const ariaProperties = {
"aria-label": unreadLabel ? `${room.name} ${unreadLabel}` : room.name, "aria-label": unreadLabel ? `${room.name} ${unreadLabel}` : room.name,
"aria-details": `mx_SpotlightDialog_button_recentSearch_${room.roomId}_details`, "aria-describedby": `mx_SpotlightDialog_button_recentSearch_${room.roomId}_details`,
}; };
return ( return (
<Option <Option
@ -969,6 +976,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
onClick={(ev) => { onClick={(ev) => {
viewRoom(room.roomId, true, ev?.type !== "click"); viewRoom(room.roomId, true, ev?.type !== "click");
}} }}
endAdornment={<RoomResultContextMenus room={room} />}
{...ariaProperties} {...ariaProperties}
> >
<DecoratedRoomAvatar <DecoratedRoomAvatar
@ -1034,6 +1042,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
break; break;
} }
let ref: RefObject<HTMLElement>;
const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev); const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev);
switch (accessibilityAction) { switch (accessibilityAction) {
case KeyBindingAction.Escape: case KeyBindingAction.Escape:
@ -1041,22 +1050,6 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
ev.preventDefault(); ev.preventDefault();
onFinished(); onFinished();
break; break;
}
};
const onKeyDown = (ev: KeyboardEvent) => {
let ref: RefObject<HTMLElement>;
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
case KeyBindingAction.Backspace:
if (!query && filter !== null) {
ev.stopPropagation();
ev.preventDefault();
setFilter(null);
}
break;
case KeyBindingAction.ArrowUp: case KeyBindingAction.ArrowUp:
case KeyBindingAction.ArrowDown: case KeyBindingAction.ArrowDown:
ev.stopPropagation(); ev.stopPropagation();
@ -1075,7 +1068,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
} }
const idx = refs.indexOf(rovingContext.state.activeRef); const idx = refs.indexOf(rovingContext.state.activeRef);
ref = findSiblingElement(refs, idx + (action === KeyBindingAction.ArrowUp ? -1 : 1)); ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
} }
break; break;
@ -1092,14 +1085,9 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed); const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed);
const idx = refs.indexOf(rovingContext.state.activeRef); const idx = refs.indexOf(rovingContext.state.activeRef);
ref = findSiblingElement(refs, idx + (action === KeyBindingAction.ArrowLeft ? -1 : 1)); ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1));
} }
break; break;
case KeyBindingAction.Enter:
ev.stopPropagation();
ev.preventDefault();
rovingContext.state.activeRef?.current?.click();
break;
} }
if (ref) { if (ref) {
@ -1113,6 +1101,25 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
} }
}; };
const onKeyDown = (ev: KeyboardEvent) => {
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
case KeyBindingAction.Backspace:
if (!query && filter !== null) {
ev.stopPropagation();
ev.preventDefault();
setFilter(null);
}
break;
case KeyBindingAction.Enter:
ev.stopPropagation();
ev.preventDefault();
rovingContext.state.activeRef?.current?.click();
break;
}
};
const openFeedback = SdkConfig.get().bug_report_endpoint_url ? () => { const openFeedback = SdkConfig.get().bug_report_endpoint_url ? () => {
Modal.createDialog(FeedbackDialog, { Modal.createDialog(FeedbackDialog, {
feature: "spotlight", feature: "spotlight",

View file

@ -18,7 +18,6 @@ limitations under the License.
import React, { createRef } from "react"; import React, { createRef } from "react";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import classNames from "classnames"; import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
@ -32,9 +31,8 @@ import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewSto
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { RoomNotifState } from "../../../RoomNotifs"; import { RoomNotifState } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { RoomNotificationContextMenu } from "../context_menus/RoomNotificationContextMenu";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import RoomListStore from "../../../stores/room-list/RoomListStore";
import RoomListActions from "../../../actions/RoomListActions";
import { ActionPayload } from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState";
@ -42,18 +40,13 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { EchoChamber } from "../../../stores/local-echo/EchoChamber"; import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
import { CachedRoomKey, RoomEchoChamber } from "../../../stores/local-echo/RoomEchoChamber"; import { CachedRoomKey, RoomEchoChamber } from "../../../stores/local-echo/RoomEchoChamber";
import { PROPERTY_UPDATED } from "../../../stores/local-echo/GenericEchoChamber"; import { PROPERTY_UPDATED } from "../../../stores/local-echo/GenericEchoChamber";
import IconizedContextMenu, {
IconizedContextMenuCheckbox,
IconizedContextMenuOption,
IconizedContextMenuOptionList,
IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu";
import PosthogTrackers from "../../../PosthogTrackers"; import PosthogTrackers from "../../../PosthogTrackers";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { RoomViewStore } from "../../../stores/RoomViewStore"; import { RoomViewStore } from "../../../stores/RoomViewStore";
import VideoRoomSummary from "./VideoRoomSummary"; import VideoRoomSummary from "./VideoRoomSummary";
import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu";
interface IProps { interface IProps {
room: Room; room: Room;
@ -267,118 +260,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.setState({ generalMenuPosition: null }); this.setState({ generalMenuPosition: null });
}; };
private onTagRoom = (ev: ButtonEvent, tagId: TagID) => {
ev.preventDefault();
ev.stopPropagation();
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
const isApplied = RoomListStore.instance.getTagsForRoom(this.props.room).includes(tagId);
const removeTag = isApplied ? tagId : inverseTag;
const addTag = isApplied ? null : tagId;
defaultDispatcher.dispatch(RoomListActions.tagRoom(
MatrixClientPeg.get(),
this.props.room,
removeTag,
addTag,
undefined,
0,
));
} else {
logger.warn(`Unexpected tag ${tagId} applied to ${this.props.room.roomId}`);
}
const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent);
switch (action) {
case KeyBindingAction.Enter:
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
this.setState({ generalMenuPosition: null }); // hide the menu
break;
}
};
private onLeaveRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: 'leave_room',
room_id: this.props.room.roomId,
});
this.setState({ generalMenuPosition: null }); // hide the menu
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", ev);
};
private onForgetRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: 'forget_room',
room_id: this.props.room.roomId,
});
this.setState({ generalMenuPosition: null }); // hide the menu
};
private onOpenRoomSettings = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: 'open_room_settings',
room_id: this.props.room.roomId,
});
this.setState({ generalMenuPosition: null }); // hide the menu
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuSettingsItem", ev);
};
private onCopyRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: 'copy_room',
room_id: this.props.room.roomId,
});
this.setState({ generalMenuPosition: null }); // hide the menu
};
private onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: 'view_invite',
roomId: this.props.room.roomId,
});
this.setState({ generalMenuPosition: null }); // hide the menu
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", ev);
};
private async saveNotifState(ev: ButtonEvent, newState: RoomNotifState) {
ev.preventDefault();
ev.stopPropagation();
if (MatrixClientPeg.get().isGuest()) return;
this.roomProps.notificationVolume = newState;
const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent);
switch (action) {
case KeyBindingAction.Enter:
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
this.setState({ notificationsMenuPosition: null }); // hide the menu
break;
}
}
private onClickAllNotifs = ev => this.saveNotifState(ev, RoomNotifState.AllMessages);
private onClickAlertMe = ev => this.saveNotifState(ev, RoomNotifState.AllMessagesLoud);
private onClickMentions = ev => this.saveNotifState(ev, RoomNotifState.MentionsOnly);
private onClickMute = ev => this.saveNotifState(ev, RoomNotifState.Mute);
private renderNotificationsMenu(isActive: boolean): React.ReactElement { private renderNotificationsMenu(isActive: boolean): React.ReactElement {
if (MatrixClientPeg.get().isGuest() || this.props.tag === DefaultTagID.Archived || if (MatrixClientPeg.get().isGuest() || this.props.tag === DefaultTagID.Archived ||
!this.showContextMenu || this.props.isMinimized !this.showContextMenu || this.props.isMinimized
@ -389,49 +270,12 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
const state = this.roomProps.notificationVolume; const state = this.roomProps.notificationVolume;
let contextMenu = null;
if (this.state.notificationsMenuPosition) {
contextMenu = <IconizedContextMenu
{...contextMenuBelow(this.state.notificationsMenuPosition)}
onFinished={this.onCloseNotificationsMenu}
className="mx_RoomTile_contextMenu"
compact
>
<IconizedContextMenuOptionList first>
<IconizedContextMenuRadio
label={_t("Use default")}
active={state === RoomNotifState.AllMessages}
iconClassName="mx_RoomTile_iconBell"
onClick={this.onClickAllNotifs}
/>
<IconizedContextMenuRadio
label={_t("All messages")}
active={state === RoomNotifState.AllMessagesLoud}
iconClassName="mx_RoomTile_iconBellDot"
onClick={this.onClickAlertMe}
/>
<IconizedContextMenuRadio
label={_t("Mentions & Keywords")}
active={state === RoomNotifState.MentionsOnly}
iconClassName="mx_RoomTile_iconBellMentions"
onClick={this.onClickMentions}
/>
<IconizedContextMenuRadio
label={_t("None")}
active={state === RoomNotifState.Mute}
iconClassName="mx_RoomTile_iconBellCrossed"
onClick={this.onClickMute}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
const classes = classNames("mx_RoomTile_notificationsButton", { const classes = classNames("mx_RoomTile_notificationsButton", {
// Show bell icon for the default case too. // Show bell icon for the default case too.
mx_RoomTile_iconBell: state === RoomNotifState.AllMessages, mx_RoomNotificationContextMenu_iconBell: state === RoomNotifState.AllMessages,
mx_RoomTile_iconBellDot: state === RoomNotifState.AllMessagesLoud, mx_RoomNotificationContextMenu_iconBellDot: state === RoomNotifState.AllMessagesLoud,
mx_RoomTile_iconBellMentions: state === RoomNotifState.MentionsOnly, mx_RoomNotificationContextMenu_iconBellMentions: state === RoomNotifState.MentionsOnly,
mx_RoomTile_iconBellCrossed: state === RoomNotifState.Mute, mx_RoomNotificationContextMenu_iconBellCrossed: state === RoomNotifState.Mute,
// Only show the icon by default if the room is overridden to muted. // Only show the icon by default if the room is overridden to muted.
// TODO: [FTUE Notifications] Probably need to detect global mute state // TODO: [FTUE Notifications] Probably need to detect global mute state
@ -447,93 +291,19 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
isExpanded={!!this.state.notificationsMenuPosition} isExpanded={!!this.state.notificationsMenuPosition}
tabIndex={isActive ? 0 : -1} tabIndex={isActive ? 0 : -1}
/> />
{ contextMenu } { this.state.notificationsMenuPosition && (
<RoomNotificationContextMenu
{...contextMenuBelow(this.state.notificationsMenuPosition)}
onFinished={this.onCloseNotificationsMenu}
room={this.props.room}
/>
) }
</React.Fragment> </React.Fragment>
); );
} }
private renderGeneralMenu(): React.ReactElement { private renderGeneralMenu(): React.ReactElement {
if (!this.showContextMenu) return null; // no menu to show if (!this.showContextMenu) return null; // no menu to show
let contextMenu = null;
if (this.state.generalMenuPosition && this.props.tag === DefaultTagID.Archived) {
contextMenu = <IconizedContextMenu
{...contextMenuBelow(this.state.generalMenuPosition)}
onFinished={this.onCloseGeneralMenu}
className="mx_RoomTile_contextMenu"
compact
>
<IconizedContextMenuOptionList red>
<IconizedContextMenuOption
iconClassName="mx_RoomTile_iconSignOut"
label={_t("Forget Room")}
onClick={this.onForgetRoomClick}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
} else if (this.state.generalMenuPosition) {
const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room);
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
const favouriteLabel = isFavorite ? _t("Favourited") : _t("Favourite");
const isLowPriority = roomTags.includes(DefaultTagID.LowPriority);
const lowPriorityLabel = _t("Low Priority");
const isDm = roomTags.includes(DefaultTagID.DM);
const userId = MatrixClientPeg.get().getUserId();
const canInvite = this.props.room.canInvite(userId) && !isDm; // hide invite in DMs from this quick menu
contextMenu = <IconizedContextMenu
{...contextMenuBelow(this.state.generalMenuPosition)}
onFinished={this.onCloseGeneralMenu}
className="mx_RoomTile_contextMenu"
compact
>
<IconizedContextMenuOptionList>
<IconizedContextMenuCheckbox
onClick={(e) => {
this.onTagRoom(e, DefaultTagID.Favourite);
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", e);
}}
active={isFavorite}
label={favouriteLabel}
iconClassName="mx_RoomTile_iconStar"
/>
<IconizedContextMenuCheckbox
onClick={(e) => this.onTagRoom(e, DefaultTagID.LowPriority)}
active={isLowPriority}
label={lowPriorityLabel}
iconClassName="mx_RoomTile_iconArrowDown"
/>
{ canInvite ? (
<IconizedContextMenuOption
onClick={this.onInviteClick}
label={_t("Invite")}
iconClassName="mx_RoomTile_iconInvite"
/>
) : null }
{ !isDm ? <IconizedContextMenuOption
onClick={this.onCopyRoomClick}
label={_t("Copy room link")}
iconClassName="mx_RoomTile_iconCopyLink"
/> : null }
<IconizedContextMenuOption
onClick={this.onOpenRoomSettings}
label={_t("Settings")}
iconClassName="mx_RoomTile_iconSettings"
/>
</IconizedContextMenuOptionList>
<IconizedContextMenuOptionList red>
<IconizedContextMenuOption
onClick={this.onLeaveRoomClick}
label={_t("Leave")}
iconClassName="mx_RoomTile_iconSignOut"
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
return ( return (
<React.Fragment> <React.Fragment>
<ContextMenuTooltipButton <ContextMenuTooltipButton
@ -542,7 +312,25 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
title={_t("Room options")} title={_t("Room options")}
isExpanded={!!this.state.generalMenuPosition} isExpanded={!!this.state.generalMenuPosition}
/> />
{ contextMenu } { this.state.generalMenuPosition && (
<RoomGeneralContextMenu
{...contextMenuBelow(this.state.generalMenuPosition)}
onFinished={this.onCloseGeneralMenu}
room={this.props.room}
onPostFavoriteClick={(ev: ButtonEvent) => PosthogTrackers.trackInteraction(
"WebRoomListRoomTileContextMenuFavouriteToggle", ev,
)}
onPostInviteClick={(ev: ButtonEvent) => PosthogTrackers.trackInteraction(
"WebRoomListRoomTileContextMenuInviteItem", ev,
)}
onPostSettingsClick={(ev: ButtonEvent) => PosthogTrackers.trackInteraction(
"WebRoomListRoomTileContextMenuSettingsItem", ev,
)}
onPostLeaveClick={(ev: ButtonEvent) => PosthogTrackers.trackInteraction(
"WebRoomListRoomTileContextMenuLeaveItem", ev,
)}
/>
) }
</React.Fragment> </React.Fragment>
); );
} }

View file

@ -0,0 +1,41 @@
/*
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 { useCallback, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { RoomNotifState } from "../RoomNotifs";
import { EchoChamber } from "../stores/local-echo/EchoChamber";
import { PROPERTY_UPDATED } from "../stores/local-echo/GenericEchoChamber";
import { CachedRoomKey } from "../stores/local-echo/RoomEchoChamber";
import { useEventEmitter } from "./useEventEmitter";
export const useNotificationState = (room: Room): [RoomNotifState, (state: RoomNotifState) => void] => {
const echoChamber = useMemo(() => EchoChamber.forRoom(room), [room]);
const [notificationState, setNotificationState] = useState<RoomNotifState>(
echoChamber.notificationVolume,
);
useEventEmitter(echoChamber, PROPERTY_UPDATED, (key: CachedRoomKey) => {
if (key === CachedRoomKey.NotificationVolume) {
setNotificationState(echoChamber.notificationVolume);
}
});
const setter = useCallback(
(state: RoomNotifState) => echoChamber.notificationVolume = state,
[echoChamber],
);
return [notificationState, setter];
};

View file

@ -1880,14 +1880,7 @@
"Show %(count)s more|other": "Show %(count)s more", "Show %(count)s more|other": "Show %(count)s more",
"Show %(count)s more|one": "Show %(count)s more", "Show %(count)s more|one": "Show %(count)s more",
"Show less": "Show less", "Show less": "Show less",
"Use default": "Use default",
"Mentions & Keywords": "Mentions & Keywords",
"Notification options": "Notification options", "Notification options": "Notification options",
"Forget Room": "Forget Room",
"Favourited": "Favourited",
"Favourite": "Favourite",
"Low Priority": "Low Priority",
"Copy room link": "Copy room link",
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", "%(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 including mentions.|one": "1 unread mention.",
"%(count)s unread messages.|other": "%(count)s unread messages.", "%(count)s unread messages.|other": "%(count)s unread messages.",
@ -2938,7 +2931,14 @@
"Report": "Report", "Report": "Report",
"Copy link": "Copy link", "Copy link": "Copy link",
"Forget": "Forget", "Forget": "Forget",
"Favourited": "Favourited",
"Favourite": "Favourite",
"Mentions only": "Mentions only", "Mentions only": "Mentions only",
"Copy room link": "Copy room link",
"Low Priority": "Low Priority",
"Forget Room": "Forget Room",
"Use default": "Use default",
"Mentions & Keywords": "Mentions & Keywords",
"See room timeline (devtools)": "See room timeline (devtools)", "See room timeline (devtools)": "See room timeline (devtools)",
"Space": "Space", "Space": "Space",
"Space home": "Space home", "Space home": "Space home",