diff --git a/res/css/_components.scss b/res/css/_components.scss index c5aafe282c..4a712d33b4 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -89,6 +89,8 @@ @import "./views/context_menus/_DeviceContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.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/_AnalyticsLearnMoreDialog.scss"; @import "./views/dialogs/_BugReportDialog.scss"; diff --git a/res/css/views/context_menus/_RoomGeneralContextMenu.scss b/res/css/views/context_menus/_RoomGeneralContextMenu.scss new file mode 100644 index 0000000000..cd106ceeef --- /dev/null +++ b/res/css/views/context_menus/_RoomGeneralContextMenu.scss @@ -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'); +} diff --git a/res/css/views/context_menus/_RoomNotificationContextMenu.scss b/res/css/views/context_menus/_RoomNotificationContextMenu.scss new file mode 100644 index 0000000000..43bd5a2dbe --- /dev/null +++ b/res/css/views/context_menus/_RoomNotificationContextMenu.scss @@ -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'); +} diff --git a/res/css/views/dialogs/_SpotlightDialog.scss b/res/css/views/dialogs/_SpotlightDialog.scss index 93385f2957..70e7617ff7 100644 --- a/res/css/views/dialogs/_SpotlightDialog.scss +++ b/res/css/views/dialogs/_SpotlightDialog.scss @@ -239,6 +239,13 @@ limitations under the License. text-overflow: ellipsis; overflow: hidden; + .mx_SpotlightDialog_option--endAdornment { + display: inline-flex; + flex-direction: row; + margin-left: auto; + align-items: start; + } + &.mx_SpotlightDialog_result_multiline { align-items: start; @@ -309,8 +316,45 @@ limitations under the License. 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] { background-color: $system; + + .mx_SpotlightDialog_option--menu, + .mx_SpotlightDialog_option--notifications { + display: block; + } } &[aria-selected=true] .mx_SpotlightDialog_enterPrompt { @@ -436,7 +480,7 @@ limitations under the License. color: $tertiary-content; border-radius: 6px; background-color: $quinary-content; - margin: 0 $spacing-4 0 auto; + margin-right: $spacing-4; display: none; } diff --git a/src/components/views/context_menus/RoomGeneralContextMenu.tsx b/src/components/views/context_menus/RoomGeneralContextMenu.tsx new file mode 100644 index 0000000000..2abb497235 --- /dev/null +++ b/src/components/views/context_menus/RoomGeneralContextMenu.tsx @@ -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 = + 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 = + 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 = dis.dispatch({ + action: "view_invite", + roomId: room.roomId, + }), onPostInviteClick)} + label={_t("Invite")} + iconClassName="mx_RoomGeneralContextMenu_iconInvite" + />; + } + + let copyLinkOption: JSX.Element; + if (!isDm) { + copyLinkOption = dis.dispatch({ + action: "copy_room", + room_id: room.roomId, + }), onPostCopyLinkClick)} + label={_t("Copy room link")} + iconClassName="mx_RoomGeneralContextMenu_iconCopyLink" + />; + } + + const settingsOption: JSX.Element = 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 = dis.dispatch({ + action: "forget_room", + room_id: room.roomId, + }), onPostForgetClick)} + />; + } else { + leaveOption = dis.dispatch({ + action: "leave_room", + room_id: room.roomId, + }), onPostLeaveClick)} + label={_t("Leave")} + className="mx_IconizedContextMenu_option_red" + iconClassName="mx_RoomGeneralContextMenu_iconSignOut" + />; + } + + return + { !roomTags.includes(DefaultTagID.Archived) && ( + + { favoriteOption } + { lowPriorityOption } + { inviteOption } + { copyLinkOption } + { settingsOption } + + ) } + + { leaveOption } + + ; +}; diff --git a/src/components/views/context_menus/RoomNotificationContextMenu.tsx b/src/components/views/context_menus/RoomNotificationContextMenu.tsx new file mode 100644 index 0000000000..2e3f18a8b0 --- /dev/null +++ b/src/components/views/context_menus/RoomNotificationContextMenu.tsx @@ -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 = setNotificationState(RoomNotifState.AllMessages))} + />; + + const allMessagesOption: JSX.Element = setNotificationState(RoomNotifState.AllMessagesLoud))} + />; + + const mentionsOption: JSX.Element = setNotificationState(RoomNotifState.MentionsOnly))} + />; + + const muteOption: JSX.Element = setNotificationState(RoomNotifState.Mute))} + />; + + return + + { defaultOption } + { allMessagesOption } + { mentionsOption } + { muteOption } + + ; +}; diff --git a/src/components/views/dialogs/spotlight/Option.tsx b/src/components/views/dialogs/spotlight/Option.tsx index 6e082f6e1b..121b2d73ef 100644 --- a/src/components/views/dialogs/spotlight/Option.tsx +++ b/src/components/views/dialogs/spotlight/Option.tsx @@ -37,7 +37,9 @@ export const Option: React.FC = ({ inputRef, children, endAdornment role="option" > { children } - - { endAdornment } +
+ + { endAdornment } +
; }; diff --git a/src/components/views/dialogs/spotlight/RoomResultContextMenus.tsx b/src/components/views/dialogs/spotlight/RoomResultContextMenus.tsx new file mode 100644 index 0000000000..dcfb81a83f --- /dev/null +++ b/src/components/views/dialogs/spotlight/RoomResultContextMenus.tsx @@ -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(null); + const [notificationMenuPosition, setNotificationMenuPosition] = useState(null); + + let generalMenu: JSX.Element; + if (generalMenuPosition !== null) { + if (room.isSpaceRoom()) { + generalMenu = setGeneralMenuPosition(null)} + />; + } else { + generalMenu = setGeneralMenuPosition(null)} + />; + } + } + + let notificationMenu: JSX.Element; + if (notificationMenuPosition !== null) { + notificationMenu = 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 ( + + { + 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() && ( + { + ev.preventDefault(); + ev.stopPropagation(); + + const target = ev.target as HTMLElement; + setNotificationMenuPosition(target.getBoundingClientRect()); + }} + title={_t("Notification options")} + isExpanded={notificationMenuPosition !== null} + /> + ) } + { generalMenu } + { notificationMenu } + + ); +} diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index f14d29d8ca..51e65b4248 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -88,6 +88,7 @@ import FeedbackDialog from "../FeedbackDialog"; import { IDialogProps } from "../IDialogProps"; import { Option } from "./Option"; import { PublicRoomResultDetails } from "./PublicRoomResultDetails"; +import { RoomResultContextMenus } from "./RoomResultContextMenus"; import { RoomContextDetails } from "../../rooms/RoomContextDetails"; import { TooltipOption } from "./TooltipOption"; @@ -506,8 +507,11 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n let otherSearchesSection: JSX.Element; if (trimmedQuery || filter !== Filter.PublicRooms) { otherSearchesSection = ( -
-

+
+

{ trimmedQuery ? _t('Use "%(query)s" to search', { query }) : _t("Search for") } @@ -544,7 +548,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n const unreadLabel = roomAriaUnreadLabel(result.room, notification); const ariaProperties = { "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 (