diff --git a/package.json b/package.json index 93867da9677..9c7ad5f0089 100644 --- a/package.json +++ b/package.json @@ -154,7 +154,7 @@ "@typescript-eslint/eslint-plugin": "^4.17.0", "@typescript-eslint/parser": "^4.17.0", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", - "allchange": "^1.0.5", + "allchange": "^1.0.6", "babel-jest": "^26.6.3", "chokidar": "^3.5.1", "concurrently": "^5.3.0", diff --git a/res/css/_common.scss b/res/css/_common.scss index bb5c74d5b90..ae8a475aef2 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -482,6 +482,15 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { overflow-y: hidden; } +.mx_DialogDesignChanges_wrapper .mx_Dialog_fixedWidth { + max-width: 636px; // match splash image width + + .mx_AccessibleButton_kind_link { + font-size: inherit; + padding: 0; + } +} + // TODO: Review mx_GeneralButton usage to see if it can use a different class // These classes were brought in from the old UserSettings and are included here to avoid // breaking the app. diff --git a/res/css/_components.scss b/res/css/_components.scss index 6029a26080a..adfd98925a5 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -60,13 +60,11 @@ @import "./views/auth/_Welcome.scss"; @import "./views/avatars/_BaseAvatar.scss"; @import "./views/avatars/_DecoratedRoomAvatar.scss"; -@import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/avatars/_WidgetAvatar.scss"; @import "./views/beta/_BetaCard.scss"; @import "./views/context_menus/_CallContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; -@import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; @import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @@ -145,6 +143,7 @@ @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InfoTooltip.scss"; @import "./views/elements/_InlineSpinner.scss"; +@import "./views/elements/_InteractiveTooltip.scss"; @import "./views/elements/_InviteReason.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_MiniAvatarUploader.scss"; @@ -232,11 +231,13 @@ @import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PresenceLabel.scss"; +@import "./views/rooms/_RecentlyViewedButton.scss"; @import "./views/rooms/_ReplyPreview.scss"; @import "./views/rooms/_ReplyTile.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomList.scss"; +@import "./views/rooms/_RoomListHeader.scss"; @import "./views/rooms/_RoomPreviewBar.scss"; @import "./views/rooms/_RoomSublist.scss"; @import "./views/rooms/_RoomTile.scss"; diff --git a/res/css/structures/_ContextualMenu.scss b/res/css/structures/_ContextualMenu.scss index 42e8b403d19..18873978d3b 100644 --- a/res/css/structures/_ContextualMenu.scss +++ b/res/css/structures/_ContextualMenu.scss @@ -27,7 +27,6 @@ limitations under the License. width: 100%; height: 100%; opacity: 1.0; - z-index: 5000; } .mx_ContextualMenu { @@ -38,6 +37,7 @@ limitations under the License. position: absolute; font-size: $font-14px; z-index: 5001; + width: max-content; } .mx_ContextualMenu_right { diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 0bfa0c993af..f8ca34a0c71 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -101,8 +101,9 @@ $roomListCollapsedWidth: 68px; } .mx_LeftPanel_filterContainer { - margin-left: 12px; - margin-right: 12px; + margin: 0 12px; + padding: 12px 0 8px; + border-bottom: 1px solid $quinary-content; flex-shrink: 0; // to convince safari's layout engine the flexbox is fine @@ -110,8 +111,30 @@ $roomListCollapsedWidth: 68px; display: flex; align-items: center; + & + .mx_RoomListHeader { + margin-top: 12px; + } + + .mx_RoomSearch_shortcutPrompt { + border-radius: 6px; + background-color: $panel-actions; + padding: 2px 4px; + user-select: none; + pointer-events: none; + font-size: $font-12px; + line-height: $font-15px; + font-weight: $font-semi-bold; + color: $light-fg-color; + margin-right: 6px; + } + .mx_RoomSearch_focused, .mx_RoomSearch_hasQuery { - & + .mx_LeftPanel_exploreButton { + .mx_RoomSearch_shortcutPrompt { + display: none; + } + + & + .mx_LeftPanel_exploreButton, + & + .mx_LeftPanel_recentsButton { // Cheaty way to return the occupied space to the filter input flex-basis: 0; margin: 0; @@ -144,11 +167,12 @@ $roomListCollapsedWidth: 68px; mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $secondary-content; + background-color: $secondary-content; } } - .mx_LeftPanel_exploreButton { + .mx_LeftPanel_exploreButton, + .mx_LeftPanel_recentsButton { width: 32px; height: 32px; border-radius: 8px; @@ -163,17 +187,28 @@ $roomListCollapsedWidth: 68px; left: 8px; width: 16px; height: 16px; - mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $secondary-content; + background-color: $secondary-content; } - &.mx_LeftPanel_exploreButton_space::before { - mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); + &:hover { + background-color: $tertiary-content; + + &::before { + background-color: $background; + } } } + + .mx_LeftPanel_exploreButton::before { + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); + } + + .mx_LeftPanel_recentsButton::before { + mask-image: url('$(res)/img/element-icons/clock.svg'); + } } .mx_LeftPanel_roomListFilterCount { @@ -231,10 +266,10 @@ $roomListCollapsedWidth: 68px; background-color: transparent; } - .mx_LeftPanel_exploreButton { + .mx_LeftPanel_exploreButton, + .mx_LeftPanel_recentsButton { margin-left: 0; margin-top: 8px; - background-color: transparent; } } } diff --git a/res/css/structures/_QuickSettingsButton.scss b/res/css/structures/_QuickSettingsButton.scss index 24883478bdc..45f7447162e 100644 --- a/res/css/structures/_QuickSettingsButton.scss +++ b/res/css/structures/_QuickSettingsButton.scss @@ -16,17 +16,26 @@ limitations under the License. .mx_QuickSettingsButton { flex: 0 0 auto; - width: 32px; - height: 32px; border-radius: 8px; position: relative; margin: 12px auto; + color: $secondary-content; + min-width: 32px; + min-height: 32px; + line-height: 32px; + + &.expanded { + margin-left: 20px; + padding-left: 44px; // align with toggle collapse button text + padding-right: 8px; + } &::before { content: ""; position: absolute; - width: inherit; - height: inherit; + width: 32px; + height: 32px; + left: 0; mask-image: url('$(res)/img/element-icons/settings.svg'); mask-repeat: no-repeat; mask-position: center; @@ -34,7 +43,7 @@ limitations under the License. background: $secondary-content; } - &:hover { + &:not(.expanded):hover { background-color: $quaternary-content; &::before { diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss index ac727dab39c..86628a5d9e4 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -103,7 +103,7 @@ limitations under the License. mask-position: center; } -$dot-size: 8px; +$dot-size: 7px; $pulse-color: $alert; .mx_RightPanel_pinnedMessagesButton { @@ -111,36 +111,53 @@ $pulse-color: $alert; mask-image: url('$(res)/img/element-icons/room/pin.svg'); mask-position: center; } +} +.mx_RightPanel_headerButton_unreadIndicator_bg { + position: absolute; + right: 0; + top: 0; + margin: 4px; + width: $dot-size; + height: $dot-size; + border-radius: 50%; + transform: scale(1.6); + transform-origin: center center; + background: rgba($background, 1); +} - .mx_RightPanel_pinnedMessagesButton_unreadIndicator { +.mx_RightPanel_headerButton_unreadIndicator { + position: absolute; + right: 0; + top: 0; + margin: 4px; + width: $dot-size; + height: $dot-size; + border-radius: 50%; + transform: scale(1); + background: rgba($pulse-color, 1); + box-shadow: 0 0 0 0 rgba($pulse-color, 1); + animation: mx_RightPanel_indicator_pulse 2s infinite; + animation-iteration-count: 1; + + &.mx_Indicator_gray { + background: rgba($input-darker-fg-color, 1); + box-shadow: rgba($input-darker-fg-color, 1); + } + + &::after { + content: ""; position: absolute; - right: 0; + width: inherit; + height: inherit; top: 0; - margin: 4px; - width: $dot-size; - height: $dot-size; - border-radius: 50%; + left: 0; transform: scale(1); - background: rgba($pulse-color, 1); - box-shadow: 0 0 0 0 rgba($pulse-color, 1); - animation: mx_RightPanel_indicator_pulse 2s infinite; - animation-iteration-count: 1; - - &::after { - content: ""; - position: absolute; - width: inherit; - height: inherit; - top: 0; - left: 0; - transform: scale(1); - transform-origin: center center; - animation-name: mx_RightPanel_indicator_pulse_shadow; - animation-duration: inherit; - animation-iteration-count: inherit; - border-radius: 50%; - background: rgba($pulse-color, 1); - } + transform-origin: center center; + animation-name: mx_RightPanel_indicator_pulse_shadow; + animation-duration: inherit; + animation-iteration-count: inherit; + border-radius: 50%; + background: inherit; } } diff --git a/res/css/structures/_RoomSearch.scss b/res/css/structures/_RoomSearch.scss index 89e9a9d0aea..a35b7e35390 100644 --- a/res/css/structures/_RoomSearch.scss +++ b/res/css/structures/_RoomSearch.scss @@ -67,12 +67,22 @@ limitations under the License. .mx_RoomSearch_clearButton { width: 16px; height: 16px; - mask-image: url('$(res)/img/element-icons/roomlist/search-clear.svg'); - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background-color: $secondary-content; margin-right: 8px; + background-color: $quinary-content; + border-radius: 50%; + position: relative; + + &::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; + } } } @@ -82,13 +92,20 @@ limitations under the License. } &.mx_RoomSearch_minimized { - border-radius: 32px; height: auto; width: auto; - padding: 8px; + padding: 5px; .mx_RoomSearch_icon { margin-left: 0; } + + &:hover { + background-color: $tertiary-content; + + .mx_RoomSearch_icon { + background-color: $background; + } + } } } diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 42fb72464a5..6749134a2c5 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -20,7 +20,7 @@ $gutterSize: 16px; $activeBorderTransparentGap: 1px; $activeBackgroundColor: $panel-actions; -$activeBorderColor: $secondary-content; +$activeBorderColor: $primary-content; .mx_SpacePanel { background-color: $groupFilterPanel-bg-color; @@ -35,24 +35,35 @@ $activeBorderColor: $secondary-content; display: flex; flex-direction: column; - .mx_SpacePanel_spaceTreeWrapper { - flex: 1; - padding: 8px 8px 16px 0; - } - .mx_SpacePanel_toggleCollapse { - flex: 0 0 auto; - width: 40px; - height: 40px; - mask-position: center; - mask-size: 32px; - mask-repeat: no-repeat; - margin-left: $gutterSize; - background-color: $tertiary-content; - mask-image: url('$(res)/img/element-icons/expand-space-panel.svg'); + margin: 0 auto; + position: relative; + min-width: 32px; + min-height: 32px; + line-height: 32px; + color: $secondary-content; + + &::before { + content: ""; + position: absolute; + width: 32px; + height: 32px; + left: 0; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: $secondary-content; + mask-image: url('$(res)/img/element-icons/expand-space-panel.svg'); + } &.expanded { - transform: scaleX(-1); + padding-left: 48px; + padding-right: 8px; + margin-left: $gutterSize; + + &::before { + transform: scaleX(-1); + } } } @@ -76,13 +87,6 @@ $activeBorderColor: $secondary-content; } } - .mx_SpaceTreeLevel { - display: flex; - flex-direction: column; - max-width: 250px; - flex-grow: 1; - } - .mx_SpaceItem { display: inline-flex; flex-flow: wrap; @@ -193,10 +197,10 @@ $activeBorderColor: $secondary-content; &.mx_SpaceButton_people, &.mx_SpaceButton_orphans { .mx_SpaceButton_icon { - background-color: #ffffff; + background-color: $panel-actions; &::before { - background-color: #3f3d3d; + background-color: $secondary-content; } } } @@ -218,8 +222,6 @@ $activeBorderColor: $secondary-content; } &.mx_SpaceButton_new .mx_SpaceButton_icon { - background-color: $panel-actions; - &::before { background-color: $primary-content; mask-image: url('$(res)/img/element-icons/plus.svg'); @@ -245,7 +247,7 @@ $activeBorderColor: $secondary-content; position: relative; &::before { - top: 2px; + top: 3px; left: 2px; content: ''; width: 16px; @@ -260,6 +262,26 @@ $activeBorderColor: $secondary-content; } } + .mx_SpaceTreeLevel { + display: flex; + flex-direction: column; + max-width: 250px; + flex-grow: 1; + + .mx_BaseAvatar:not(.mx_UserMenu_userAvatar_BaseAvatar) .mx_BaseAvatar_initial { + color: $secondary-content; + border-radius: 8px; + background-color: $panel-actions; + font-size: $font-15px !important; // override inline style + font-weight: $font-semi-bold; + line-height: $font-18px; + + & + .mx_BaseAvatar_image { + visibility: hidden; + } + } + } + .mx_SpaceButton_avatarWrapper { position: relative; } @@ -300,6 +322,9 @@ $activeBorderColor: $secondary-content; /* root space buttons are bigger and not indented */ & > .mx_AutoHideScrollbar { + flex: 1; + padding: 0 8px 16px 0; + & > .mx_SpaceButton { height: $topLevelHeight; @@ -311,6 +336,18 @@ $activeBorderColor: $secondary-content; & > ul { padding-left: 0; } + + &.mx_IndicatorScrollbar_topOverflow { + mask-image: linear-gradient(180deg, transparent, black 5%); + } + + &.mx_IndicatorScrollbar_bottomOverflow { + mask-image: linear-gradient(180deg, black, black 95%, transparent); + } + + &.mx_IndicatorScrollbar_topOverflow.mx_IndicatorScrollbar_bottomOverflow { + mask-image: linear-gradient(180deg, transparent, black 5%, black 95%, transparent); + } } } @@ -322,13 +359,8 @@ $activeBorderColor: $secondary-content; line-height: $font-18px; } - .mx_IconizedContextMenu_optionList .mx_AccessibleButton.mx_SpacePanel_contextMenu_inviteButton { - color: $accent; - - .mx_SpacePanel_iconInvite::before { - background-color: $accent; - mask-image: url('$(res)/img/element-icons/room/invite.svg'); - } + .mx_SpacePanel_iconInvite::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); } .mx_SpacePanel_iconSettings::before { @@ -344,15 +376,11 @@ $activeBorderColor: $secondary-content; } .mx_SpacePanel_iconPlus::before { - mask-image: url('$(res)/img/element-icons/roomlist/plus-circle.svg'); - } - - .mx_SpacePanel_iconHash::before { - mask-image: url('$(res)/img/element-icons/roomlist/hash-circle.svg'); + mask-image: url('$(res)/img/element-icons/plus.svg'); } .mx_SpacePanel_iconExplore::before { - mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); + mask-image: url('$(res)/img/element-icons/roomlist/search.svg'); } .mx_SpacePanel_noIcon { @@ -362,6 +390,14 @@ $activeBorderColor: $secondary-content; padding-left: 5px !important; // override default iconized label style to align with header } } + + .mx_SpacePanel_contextMenu_separatorLabel { + color: $tertiary-content; + font-size: $font-10px; + line-height: $font-12px; + font-weight: $font-semi-bold; + //margin-left: 8px; + } } .mx_SpacePanel_sharePublicSpace { diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index d2b3babc3ab..e4fe3c1e664 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -85,6 +85,11 @@ limitations under the License. background-color: $primary-content; } + &.mx_Toast_icon_labs::after { + mask-image: url('$(res)/img/element-icons/flask.svg'); + background-color: $secondary-content; + } + .mx_Toast_title, .mx_Toast_body { grid-column: 2; } diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index c10e7f60df2..4545ecc0e60 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -15,141 +15,49 @@ limitations under the License. */ .mx_UserMenu { - // to make the menu button sort of aligned with the explore button below - padding-right: 6px; - - &.mx_UserMenu_prototype { - // The margin & padding combination between here and the ::after is to - // align the border line with the tag panel. - margin-bottom: 6px; - - padding-right: 0; // make the right edge line up with the explore button + padding: 0 2px 8px; + border-bottom: 1px solid $quinary-content; + margin: 12px 14px 4px 18px; + box-sizing: border-box; + display: flex; + align-items: center; + + .mx_UserMenu_userAvatar { + position: relative; + } - .mx_UserMenu_headerButtons { - // considering we've eliminated right padding on the menu itself, we need to - // push the chevron in slightly (roughly lining up with the center of the - // plus buttons) - margin-right: 2px; - } + .mx_UserMenu_name { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-24px; + margin-left: 10px; + } - // we cheat opacity on the theme colour with an after selector here - &::after { - content: ''; - border-bottom: 1px solid $primary-content; - opacity: 0.2; - display: block; - padding-top: 8px; - } + &.mx_UserMenu_cutout .mx_BaseAvatar { + mask-image: url('$(res)/img/element-icons/roomlist/dnd-avatar-mask.svg'); + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; } - .mx_UserMenu_headerButtons { + .mx_UserMenu_dndBadge { + position: absolute; + bottom: -2px; + right: -7px; width: 16px; height: 16px; - position: relative; - display: block; + border-radius: 50%; &::before { content: ''; width: 16px; height: 16px; position: absolute; - top: 0; - left: 0; mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $tertiary-content; - mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); - } - } - - .mx_UserMenu_row { - // Create a row-based flexbox to ensure items stay aligned correctly. - display: flex; - align-items: center; - - .mx_UserMenu_userAvatarContainer { - position: relative; // to make default avatars work - margin-right: 8px; - height: 32px; // to remove the unknown 4px gap the browser puts below it - padding: 3px 0; // to align with and without using doubleName - - .mx_UserMenu_userAvatar { - border-radius: 32px; // should match avatar size - object-fit: cover; - } - } - - .mx_UserMenu_doubleName { - flex: 1; - min-width: 0; // make flexbox aware that it can crush this to a tiny width - - .mx_UserMenu_userName, - .mx_UserMenu_subUserName { - display: block; - } - - .mx_UserMenu_subUserName { - color: $muted-fg-color; - font-size: $font-13px; - line-height: $font-18px; - flex: 1; - - // Ellipsize any text overflow - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - } - - .mx_UserMenu_userName { - font-weight: 600; - font-size: $font-15px; - line-height: $font-20px; - flex: 1; - - // Ellipsize any text overflow - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - - .mx_UserMenu_headerButtons { - // No special styles: the rest of the layout happens to make it work. - } - - .mx_UserMenu_dnd { - width: 24px; - height: 24px; - margin-right: 8px; - position: relative; - - &::before { - content: ''; - position: absolute; - width: 24px; - height: 24px; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background: $muted-fg-color; - } - - &.mx_UserMenu_dnd_noisy::before { - mask-image: url('$(res)/img/element-icons/notifications.svg'); - } - - &.mx_UserMenu_dnd_muted::before { - mask-image: url('$(res)/img/element-icons/roomlist/notifications-off.svg'); - } - } - } - - &.mx_UserMenu_minimized { - padding-right: 0px; - - .mx_UserMenu_userAvatarContainer { - margin-right: 0px; + background-color: $alert; + mask-image: url('$(res)/img/element-icons/roomlist/dnd.svg'); } } } @@ -157,44 +65,6 @@ limitations under the License. .mx_UserMenu_contextMenu { width: 258px; - // These override the styles already present on the user menu rather than try to - // define a new menu. They are specifically for the stacked menu when a community - // is being represented as a prototype. - &.mx_UserMenu_contextMenu_prototype { - padding-bottom: 16px; - - .mx_UserMenu_contextMenu_header { - padding-bottom: 0; - padding-top: 16px; - - &:nth-child(n + 2) { - padding-top: 8px; - } - } - - hr { - width: 85%; - opacity: 0.2; - border: none; - border-bottom: 1px solid $primary-content; - } - - &.mx_IconizedContextMenu { - > .mx_IconizedContextMenu_optionList { - margin-top: 4px; - - &::before { - border: none; - } - - > .mx_AccessibleButton { - padding-top: 2px; - padding-bottom: 2px; - } - } - } - } - &.mx_IconizedContextMenu .mx_IconizedContextMenu_optionList_red { .mx_AccessibleButton { padding-top: 16px; @@ -299,6 +169,15 @@ limitations under the License. .mx_UserMenu_iconHome::before { mask-image: url('$(res)/img/element-icons/roomlist/home.svg'); } + + .mx_UserMenu_iconDnd::before { + mask-image: url('$(res)/img/element-icons/roomlist/dnd.svg'); + } + + .mx_UserMenu_iconDndOff::before { + mask-image: url('$(res)/img/element-icons/roomlist/dnd-cross.svg'); + } + .mx_UserMenu_iconHosting::before { mask-image: url('$(res)/img/element-icons/brands/element.svg'); } @@ -315,23 +194,37 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/settings.svg'); } - .mx_UserMenu_iconArchive::before { - mask-image: url('$(res)/img/element-icons/roomlist/archived.svg'); - } - .mx_UserMenu_iconMessage::before { - mask-image: url('$(res)/img/element-icons/roomlist/feedback.svg'); + mask-image: url('$(res)/img/element-icons/feedback.svg'); } .mx_UserMenu_iconSignOut::before { mask-image: url('$(res)/img/element-icons/leave.svg'); } +} + +.mx_UserMenu_CustomStatusSection { + margin: 0 12px 8px; + + .mx_UserMenu_CustomStatusSection_input { + position: relative; + display: flex; + + > input { + border: 1px solid $accent; + border-radius: 8px; + width: 100%; + } + } - .mx_UserMenu_iconMembers::before { - mask-image: url('$(res)/img/element-icons/room/members.svg'); + > p { + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + margin: 4px 0; } - .mx_UserMenu_iconInvite::before { - mask-image: url('$(res)/img/element-icons/room/invite.svg'); + .mx_AccessibleButton_kind_primary_outline { + display: block; } } diff --git a/res/css/views/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index 486958887e2..964e8156261 100644 --- a/res/css/views/avatars/_BaseAvatar.scss +++ b/res/css/views/avatars/_BaseAvatar.scss @@ -27,11 +27,17 @@ limitations under the License. // https://bugzilla.mozilla.org/show_bug.cgi?id=255139 display: inline-block; user-select: none; + + &.mx_RoomAvatar_isSpaceRoom { + &.mx_BaseAvatar_image, .mx_BaseAvatar_image { + border-radius: 8px; + } + } } .mx_BaseAvatar_initial { position: absolute; - left: 0px; + left: 0; color: $avatar-initial-color; text-align: center; speak: none; diff --git a/res/css/views/avatars/_MemberStatusMessageAvatar.scss b/res/css/views/avatars/_MemberStatusMessageAvatar.scss deleted file mode 100644 index 7f2f53668f7..00000000000 --- a/res/css/views/avatars/_MemberStatusMessageAvatar.scss +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_MessageComposer_avatar .mx_BaseAvatar { - padding: 2px; - border: 1px solid transparent; - border-radius: 100%; -} - -.mx_MessageComposer_avatar .mx_BaseAvatar_initial { - left: 2px; -} - -.mx_MemberStatusMessageAvatar_hasStatus .mx_BaseAvatar { - border-color: $accent; -} diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index ef7a547650b..56e98fa50ec 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -59,6 +59,11 @@ limitations under the License. border-radius: 0 0 8px 8px; // radius matches .mx_ContextualMenu } + // round all corners of the only button for the hover effect to be bounded + &:first-child:last-child .mx_AccessibleButton:first-child:last-child { + border-radius: 8px; // radius matches .mx_ContextualMenu + } + .mx_AccessibleButton { // pad the inside of the button so that the hover background is padded too padding-top: 12px; diff --git a/res/css/views/context_menus/_StatusMessageContextMenu.scss b/res/css/views/context_menus/_StatusMessageContextMenu.scss deleted file mode 100644 index d313936c091..00000000000 --- a/res/css/views/context_menus/_StatusMessageContextMenu.scss +++ /dev/null @@ -1,65 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_StatusMessageContextMenu { - padding: 10px; -} - -.mx_StatusMessageContextMenu_form { - display: flex; - flex-direction: column; -} - -input.mx_StatusMessageContextMenu_message { - border-radius: 4px; - border: 1px solid $input-border-color; - padding: 6.5px 11px; - background-color: $background; - font-weight: normal; - margin: 0 0 10px; -} - -.mx_StatusMessageContextMenu_message::placeholder { - color: $tertiary-content; -} - -.mx_StatusMessageContextMenu_actionContainer { - display: flex; -} - -.mx_StatusMessageContextMenu_submit, -.mx_StatusMessageContextMenu_clear { - @mixin mx_DialogButton; - align-self: start; - font-size: $font-12px; - padding: 6px 1em; - border: 1px solid transparent; - margin-right: 10px; -} - -.mx_StatusMessageContextMenu_submit[disabled] { - opacity: 0.49; -} - -.mx_StatusMessageContextMenu_clear { - color: $alert; - background-color: transparent; - border: 1px solid $alert; -} - -.mx_StatusMessageContextMenu_actionContainer .mx_Spinner { - justify-content: flex-start; -} diff --git a/res/css/views/dialogs/_FeedbackDialog.scss b/res/css/views/dialogs/_FeedbackDialog.scss index 8674f2bab64..9e4cf5e8aae 100644 --- a/res/css/views/dialogs/_FeedbackDialog.scss +++ b/res/css/views/dialogs/_FeedbackDialog.scss @@ -30,10 +30,32 @@ limitations under the License. .mx_FeedbackDialog_section { position: relative; - padding-left: 52px; + padding: 12px 52px 12px 68px; + border-radius: 8px; + border: 1px solid $quinary-content; + + & + .mx_FeedbackDialog_section { + margin-top: 16px; + } + + > h3 { + margin-top: 0; + margin-bottom: 8px; + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + } > p { - color: $tertiary-content; + color: $secondary-content; + font-size: $font-15px; + line-height: $font-24px; + margin: 8px 0 0; + } + + .mx_FeedbackDialog_section_microcopy { + font-size: $font-12px; + line-height: $font-15px; } .mx_AccessibleButton_kind_link { @@ -51,13 +73,13 @@ limitations under the License. position: absolute; width: 40px; height: 40px; - left: 0; - top: 0; + left: 16px; + top: 12px; } &::before { background-color: $icon-button-color; - border-radius: 20px; + border-radius: 8px; } &::after { diff --git a/res/css/views/elements/_InteractiveTooltip.scss b/res/css/views/elements/_InteractiveTooltip.scss new file mode 100644 index 00000000000..8196441b6d7 --- /dev/null +++ b/res/css/views/elements/_InteractiveTooltip.scss @@ -0,0 +1,97 @@ +/* +Copyright 2019 - 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_InteractiveTooltip_wrapper { + position: fixed; + z-index: 5000; +} + +.mx_InteractiveTooltip { + border-radius: 8px; + background-color: $background; + color: $primary-content; + position: absolute; + z-index: 5001; + box-shadow: 0 24px 8px rgb(17 17 26 / 4%), 0 8px 32px rgb(17 17 26 / 4%); +} + +.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_top { + top: 10px; // 8px chevron + 2px spacing +} + +.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_left { + left: 10px; // 8px chevron + 2px spacing +} + +.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_right { + right: 10px; // 8px chevron + 2px spacing +} + +.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_bottom { + bottom: 10px; // 8px chevron + 2px spacing +} + +.mx_InteractiveTooltip_chevron_top { + position: absolute; + left: calc(50% - 8px); + top: -8px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-bottom: 8px solid $background; + border-right: 8px solid transparent; +} + +// Adapted from https://codyhouse.co/blog/post/css-rounded-triangles-with-clip-path +// by Sebastiano Guerriero (@guerriero_se) +@supports (clip-path: polygon(0% 0%, 100% 100%, 0% 100%)) { + .mx_InteractiveTooltip_chevron_top { + height: 16px; + width: 16px; + background-color: inherit; + border: none; + clip-path: polygon(0% 0%, 100% 100%, 0% 100%); + transform: rotate(135deg); + border-radius: 0 0 0 3px; + top: calc(-8px / 1.414); // sqrt(2) because of rotation + } +} + +.mx_InteractiveTooltip_chevron_bottom { + position: absolute; + left: calc(50% - 8px); + bottom: -8px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-top: 8px solid $background; + border-right: 8px solid transparent; +} + +// Adapted from https://codyhouse.co/blog/post/css-rounded-triangles-with-clip-path +// by Sebastiano Guerriero (@guerriero_se) +@supports (clip-path: polygon(0% 0%, 100% 100%, 0% 100%)) { + .mx_InteractiveTooltip_chevron_bottom { + height: 16px; + width: 16px; + background-color: inherit; + border: none; + clip-path: polygon(0% 0%, 100% 100%, 0% 100%); + transform: rotate(-45deg); + border-radius: 0 0 0 3px; + bottom: calc(-8px / 1.414); // sqrt(2) because of rotation + } +} diff --git a/res/css/views/elements/_ResizeHandle.scss b/res/css/views/elements/_ResizeHandle.scss index 2af2880654e..3c6ba254fa5 100644 --- a/res/css/views/elements/_ResizeHandle.scss +++ b/res/css/views/elements/_ResizeHandle.scss @@ -20,6 +20,15 @@ limitations under the License. z-index: 100; } +// We'd like to remove this, but this makes matrixchat's resizehandle's +// negative margin & greater than its positive padding. If it's the same +// or less, Safari gets confused about overflows somehow and +// https://github.com/vector-im/element-web/issues/19863 happens. +.mx_MatrixChat > .mx_ResizeHandle.mx_ResizeHandle_horizontal { + margin: 0 -10px 0 0; + padding: 0 8px 0 0; +} + .mx_ResizeHandle.mx_ResizeHandle_horizontal { margin: 0 -5px; padding: 0 5px; diff --git a/res/css/views/right_panel/_PinnedMessagesCard.scss b/res/css/views/right_panel/_PinnedMessagesCard.scss index ddfd131c8b1..b2c0a132d42 100644 --- a/res/css/views/right_panel/_PinnedMessagesCard.scss +++ b/res/css/views/right_panel/_PinnedMessagesCard.scss @@ -87,4 +87,8 @@ limitations under the License. } } } + + .mx_EventTile_body { + word-break: break-word; + } } diff --git a/res/css/views/right_panel/_ThreadPanel.scss b/res/css/views/right_panel/_ThreadPanel.scss index 369c57cdd21..ce1d6d2d0aa 100644 --- a/res/css/views/right_panel/_ThreadPanel.scss +++ b/res/css/views/right_panel/_ThreadPanel.scss @@ -120,7 +120,7 @@ limitations under the License. &:hover { background-color: $event-selected-color; } - &[aria-selected="true"] { + &[aria-checked="true"] { :first-child { margin-left: -20px; } diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index f7ac714d660..2a49840f17e 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -20,7 +20,7 @@ limitations under the License. margin: auto; border-top: 1px solid $primary-hairline-color; position: relative; - padding-left: 82px; + padding-left: 42px; padding-right: 6px; } @@ -74,7 +74,7 @@ limitations under the License. .mx_MessageComposer_e2eIcon.mx_E2EIcon { position: absolute; - left: 60px; + left: 20px; margin-right: 0; // Counteract the E2EIcon class margin-left: 3px; // Counteract the E2EIcon class width: 12px; diff --git a/res/css/views/rooms/_RecentlyViewedButton.scss b/res/css/views/rooms/_RecentlyViewedButton.scss new file mode 100644 index 00000000000..ba6c6e04f1b --- /dev/null +++ b/res/css/views/rooms/_RecentlyViewedButton.scss @@ -0,0 +1,73 @@ +/* +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_RecentlyViewedButton_ContextMenu { + padding: 16px 8px 16px 16px; + width: max-content; + max-width: 240px; + max-height: 400px; + border: 1px solid rgba($primary-content, .1); + border-radius: 8px; + box-shadow: 0 8px 4px rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + + > h4 { + margin: 0 0 12px 0; + } + + > div { + overflow-y: auto; + + * { + margin-right: 4px; + } + } + + .mx_AccessibleButton { + margin-top: 2px; + padding: 4px; + display: flex; + align-items: center; + border-radius: 8px; + min-height: 34px; + + &:hover { + background-color: $panel-actions; + } + + .mx_BaseAvatar { + margin-right: 8px; + width: 24px; + } + + .mx_RecentlyViewedButton_entry_label { + display: grid; + + > div { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + .mx_RecentlyViewedButton_entry_spaces { + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + } + } +} diff --git a/res/css/views/rooms/_RoomBreadcrumbs.scss b/res/css/views/rooms/_RoomBreadcrumbs.scss index 152b0a45cdb..c4b8efb5abe 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs.scss +++ b/res/css/views/rooms/_RoomBreadcrumbs.scss @@ -21,6 +21,7 @@ limitations under the License. display: flex; flex-direction: row; align-items: flex-start; + margin-bottom: 12px; .mx_RoomBreadcrumbs_crumb { margin-right: 8px; diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss index c9f60887daa..e92951902bc 100644 --- a/res/css/views/rooms/_RoomList.scss +++ b/res/css/views/rooms/_RoomList.scss @@ -21,18 +21,24 @@ limitations under the License. .mx_RoomList_iconPlus::before { mask-image: url('$(res)/img/element-icons/roomlist/plus-circle.svg'); } -.mx_RoomList_iconHash::before { - mask-image: url('$(res)/img/element-icons/roomlist/hash-circle.svg'); +.mx_RoomList_iconCreateNewRoom::before { + mask-image: url('$(res)/img/element-icons/roomlist/hash-plus.svg'); } -.mx_RoomList_iconExplore::before { - mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); +.mx_RoomList_iconAddExistingRoom::before { + mask-image: url('$(res)/img/element-icons/roomlist/hash.svg'); } -.mx_RoomList_iconBrowse::before { - mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); +.mx_RoomList_iconExplore::before { + mask-image: url('$(res)/img/element-icons/roomlist/hash-search.svg'); } .mx_RoomList_iconDialpad::before { mask-image: url('$(res)/img/element-icons/roomlist/dialpad.svg'); } +.mx_RoomList_iconStartChat { + mask-image: url('$(res)/img/element-icons/roomlist/member-plus.svg'); +} +.mx_RoomList_iconInvite { + mask-image: url('$(res)/img/element-icons/room/share.svg'); +} .mx_RoomList_explorePrompt { margin: 4px 12px 4px; @@ -77,13 +83,5 @@ limitations under the License. &.mx_RoomList_explorePrompt_explore::before { mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); } - - &.mx_RoomList_explorePrompt_spaceInvite::before { - mask-image: url('$(res)/img/element-icons/room/invite.svg'); - } - - &.mx_RoomList_explorePrompt_spaceExplore::before { - mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); - } } } diff --git a/res/css/views/rooms/_RoomListHeader.scss b/res/css/views/rooms/_RoomListHeader.scss new file mode 100644 index 00000000000..bdc0ffd75e1 --- /dev/null +++ b/res/css/views/rooms/_RoomListHeader.scss @@ -0,0 +1,111 @@ +/* +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_RoomListHeader { + display: flex; + align-items: center; + + .mx_RoomListHeader_contextLessTitle, + .mx_RoomListHeader_contextMenuButton { + font-size: $font-15px; + line-height: $font-24px; + font-weight: $font-semi-bold; + padding: 1px 24px 1px 4px; + position: relative; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: 8px; + margin-right: auto; + user-select: none; + } + + .mx_RoomListHeader_contextMenuButton { + border-radius: 6px; + + &:hover { + background-color: $quinary-content; + } + + &::before { + content: ''; + width: 20px; + height: 20px; + top: 3px; + right: 0; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: $tertiary-content; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + + &[aria-expanded=true] { + background-color: $quinary-content; + + &::before { + transform: rotate(180deg); + } + } + } + + .mx_RoomListHeader_plusButton { + width: 32px; + height: 32px; + border-radius: 8px; + position: relative; + padding: 8px; + margin-left: 8px; + margin-right: 12px; + background-color: $panel-actions; + box-sizing: border-box; + flex-shrink: 0; + + &::before { + content: ''; + width: 16px; + height: 16px; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: $secondary-content; + mask-image: url('$(res)/img/element-icons/roomlist/plus.svg'); + } + + &:hover { + background-color: $tertiary-content; + + &::before { + background-color: $background; + } + } + } +} + +.mx_RoomListHeader_iconInvite::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); +} +.mx_RoomListHeader_iconStartChat::before { + mask-image: url('$(res)/img/element-icons/roomlist/member-plus.svg'); +} +.mx_RoomListHeader_iconCreateRoom::before { + mask-image: url('$(res)/img/element-icons/roomlist/hash-plus.svg'); +} +.mx_RoomListHeader_iconExplore::before { + mask-image: url('$(res)/img/element-icons/roomlist/hash-search.svg'); +} diff --git a/res/img/element-icons/clock.svg b/res/img/element-icons/clock.svg new file mode 100644 index 00000000000..2fb0705c390 --- /dev/null +++ b/res/img/element-icons/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/flask.svg b/res/img/element-icons/flask.svg new file mode 100644 index 00000000000..1b6d1204ea6 --- /dev/null +++ b/res/img/element-icons/flask.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/roomlist/dnd-avatar-mask.svg b/res/img/element-icons/roomlist/dnd-avatar-mask.svg new file mode 100644 index 00000000000..438d65e5023 --- /dev/null +++ b/res/img/element-icons/roomlist/dnd-avatar-mask.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/roomlist/dnd-cross.svg b/res/img/element-icons/roomlist/dnd-cross.svg new file mode 100644 index 00000000000..2091d598021 --- /dev/null +++ b/res/img/element-icons/roomlist/dnd-cross.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/roomlist/dnd.svg b/res/img/element-icons/roomlist/dnd.svg new file mode 100644 index 00000000000..8c4a86e5196 --- /dev/null +++ b/res/img/element-icons/roomlist/dnd.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/roomlist/feedback.svg b/res/img/element-icons/roomlist/feedback.svg deleted file mode 100644 index c15edd709a1..00000000000 --- a/res/img/element-icons/roomlist/feedback.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/res/img/element-icons/roomlist/hash-plus.svg b/res/img/element-icons/roomlist/hash-plus.svg new file mode 100644 index 00000000000..807c42cd809 --- /dev/null +++ b/res/img/element-icons/roomlist/hash-plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/element-icons/roomlist/hash-search.svg b/res/img/element-icons/roomlist/hash-search.svg new file mode 100644 index 00000000000..1ea59502329 --- /dev/null +++ b/res/img/element-icons/roomlist/hash-search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/roomlist/hash.svg b/res/img/element-icons/roomlist/hash.svg new file mode 100644 index 00000000000..6388116ab0e --- /dev/null +++ b/res/img/element-icons/roomlist/hash.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/roomlist/member-plus.svg b/res/img/element-icons/roomlist/member-plus.svg new file mode 100644 index 00000000000..71269b54ca1 --- /dev/null +++ b/res/img/element-icons/roomlist/member-plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/roomlist/search-clear.svg b/res/img/element-icons/roomlist/search-clear.svg deleted file mode 100644 index 29fc0976006..00000000000 --- a/res/img/element-icons/roomlist/search-clear.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/ia-design-changes.png b/res/img/ia-design-changes.png new file mode 100644 index 00000000000..d13caff4121 Binary files /dev/null and b/res/img/ia-design-changes.png differ diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index ed884340a7d..eaff6ee186a 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -17,43 +17,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* - * Manages a list of all the currently active calls. - * - * This handler dispatches when voip calls are added/updated/removed from this list: - * { - * action: 'call_state' - * room_id: - * } - * - * To know the state of the call, this handler exposes a getter to - * obtain the call for a room: - * var call = CallHandler.getCall(roomId) - * var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing - * - * This handler listens for and handles the following actions: - * { - * action: 'place_call', - * type: 'voice|video', - * room_id: - * } - * - * { - * action: 'incoming_call' - * call: MatrixCall - * } - * - * { - * action: 'hangup' - * room_id: - * } - * - * { - * action: 'answer' - * room_id: - * } - */ - import React from 'react'; import { MatrixClientPeg } from './MatrixClientPeg'; @@ -65,7 +28,6 @@ import SettingsStore from './settings/SettingsStore'; import { Jitsi } from "./widgets/Jitsi"; import { WidgetType } from "./widgets/WidgetType"; import { SettingLevel } from "./settings/SettingLevel"; -import { ActionPayload } from "./dispatcher/payloads"; import { base32 } from "rfc4648"; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; @@ -133,24 +95,24 @@ interface ThirdpartyLookupResponse { fields: ThirdpartyLookupResponseFields; } -export enum PlaceCallType { - Voice = 'voice', - Video = 'video', -} - export enum CallHandlerEvent { CallsChanged = "calls_changed", CallChangeRoom = "call_change_room", SilencedCallsChanged = "silenced_calls_changed", + CallState = "call_state", } +/** + * CallHandler manages all currently active calls. It should be used for + * placing, answering, rejecting and hanging up calls. It also handles ringing, + * PSTN support and other things. + */ export default class CallHandler extends EventEmitter { private calls = new Map(); // roomId -> call // Calls started as an attended transfer, ie. with the intention of transferring another // call with a different party to this one. private transferees = new Map(); // callId (target) -> call (transferee) private audioPromises = new Map>(); - private dispatcherRef: string = null; private supportsPstnProtocol = null; private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native @@ -166,7 +128,7 @@ export default class CallHandler extends EventEmitter { private silencedCalls = new Set(); // callIds - static sharedInstance() { + public static get instance() { if (!window.mxCallHandler) { window.mxCallHandler = new CallHandler(); } @@ -194,8 +156,7 @@ export default class CallHandler extends EventEmitter { return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId; } - start() { - this.dispatcherRef = dis.register(this.onAction); + public start(): void { // add empty handlers for media actions, otherwise the media keys // end up causing the audio elements with our ring/ringback etc // audio clips in to play. @@ -215,18 +176,14 @@ export default class CallHandler extends EventEmitter { this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS); } - stop() { + public stop(): void { const cli = MatrixClientPeg.get(); if (cli) { cli.removeListener('Call.incoming', this.onCallIncoming); } - if (this.dispatcherRef !== null) { - dis.unregister(this.dispatcherRef); - this.dispatcherRef = null; - } } - public silenceCall(callId: string) { + public silenceCall(callId: string): void { this.silencedCalls.add(callId); this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls); @@ -235,7 +192,7 @@ export default class CallHandler extends EventEmitter { this.pause(AudioID.Ring); } - public unSilenceCall(callId: string) { + public unSilenceCall(callId: string): void { this.silencedCalls.delete(callId); this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls); this.play(AudioID.Ring); @@ -261,7 +218,7 @@ export default class CallHandler extends EventEmitter { return false; } - private async checkProtocols(maxTries) { + private async checkProtocols(maxTries: number): Promise { try { const protocols = await MatrixClientPeg.get().getThirdpartyProtocols(); @@ -296,11 +253,11 @@ export default class CallHandler extends EventEmitter { } } - public getSupportsPstnProtocol() { + public getSupportsPstnProtocol(): boolean { return this.supportsPstnProtocol; } - public getSupportsVirtualRooms() { + public getSupportsVirtualRooms(): boolean { return this.supportsSipNativeVirtual; } @@ -328,14 +285,32 @@ export default class CallHandler extends EventEmitter { ); } - private onCallIncoming = (call) => { - // we dispatch this synchronously to make sure that the event - // handlers on the call are set up immediately (so that if - // we get an immediate hangup, we don't get a stuck call) - dis.dispatch({ - action: 'incoming_call', - call: call, - }, true); + private onCallIncoming = (call: MatrixCall): void => { + // if the runtime env doesn't do VoIP, stop here. + if (!MatrixClientPeg.get().supportsVoip()) { + return; + } + + const mappedRoomId = CallHandler.instance.roomIdForCall(call); + if (this.getCallForRoom(mappedRoomId)) { + logger.log( + "Got incoming call for room " + mappedRoomId + + " but there's already a call for this room: ignoring", + ); + return; + } + + Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); + this.addCallForRoom(mappedRoomId, call); + this.setCallListeners(call); + // Explicitly handle first state change + this.onCallStateChanged(call.state, null, call); + + // get ready to send encrypted events in the room, so if the user does answer + // the call, we'll be ready to send. NB. This is the protocol-level room ID not + // the mapped one: that's where we'll send the events. + const cli = MatrixClientPeg.get(); + cli.prepareToEncrypt(cli.getRoom(call.roomId)); }; public getCallById(callId: string): MatrixCall { @@ -345,11 +320,11 @@ export default class CallHandler extends EventEmitter { return null; } - getCallForRoom(roomId: string): MatrixCall { + public getCallForRoom(roomId: string): MatrixCall | null { return this.calls.get(roomId) || null; } - getAnyActiveCall() { + public getAnyActiveCall(): MatrixCall | null { for (const call of this.calls.values()) { if (call.state !== CallState.Ended) { return call; @@ -358,7 +333,7 @@ export default class CallHandler extends EventEmitter { return null; } - getAllActiveCalls() { + public getAllActiveCalls(): MatrixCall[] { const activeCalls = []; for (const call of this.calls.values()) { @@ -369,7 +344,7 @@ export default class CallHandler extends EventEmitter { return activeCalls; } - getAllActiveCallsNotInRoom(notInThisRoomId) { + public getAllActiveCallsNotInRoom(notInThisRoomId: string): MatrixCall[] { const callsNotInThatRoom = []; for (const [roomId, call] of this.calls.entries()) { @@ -390,11 +365,11 @@ export default class CallHandler extends EventEmitter { return this.getAllActiveCallsNotInRoom(roomId); } - getTransfereeForCallId(callId: string): MatrixCall { + public getTransfereeForCallId(callId: string): MatrixCall { return this.transferees[callId]; } - play(audioId: AudioID) { + public play(audioId: AudioID): void { // TODO: Attach an invisible element for this instead // which listens? const audio = document.getElementById(audioId) as HTMLMediaElement; @@ -423,7 +398,7 @@ export default class CallHandler extends EventEmitter { } } - pause(audioId: AudioID) { + public pause(audioId: AudioID): void { // TODO: Attach an invisible element for this instead // which listens? const audio = document.getElementById(audioId) as HTMLMediaElement; @@ -437,7 +412,7 @@ export default class CallHandler extends EventEmitter { } } - private matchesCallForThisRoom(call: MatrixCall) { + private matchesCallForThisRoom(call: MatrixCall): boolean { // We don't allow placing more than one call per room, but that doesn't mean there // can't be more than one, eg. in a glare situation. This checks that the given call // is the call we consider 'the' call for its room. @@ -447,7 +422,7 @@ export default class CallHandler extends EventEmitter { return callForThisRoom && call.callId === callForThisRoom.callId; } - private setCallListeners(call: MatrixCall) { + private setCallListeners(call: MatrixCall): void { let mappedRoomId = this.roomIdForCall(call); call.on(CallEvent.Error, (err: CallError) => { @@ -542,6 +517,11 @@ export default class CallHandler extends EventEmitter { const mappedRoomId = this.roomIdForCall(call); this.setCallState(call, newState); + dis.dispatch({ + action: 'call_state', + room_id: mappedRoomId, + state: newState, + }); switch (oldState) { case CallState.Ringing: @@ -620,7 +600,7 @@ export default class CallHandler extends EventEmitter { } }; - private async logCallStats(call: MatrixCall, mappedRoomId: string) { + private async logCallStats(call: MatrixCall, mappedRoomId: string): Promise { const stats = await call.getCurrentCallStats(); logger.debug( `Call completed. Call ID: ${call.callId}, virtual room ID: ${call.roomId}, ` + @@ -663,8 +643,8 @@ export default class CallHandler extends EventEmitter { } } - private setCallState(call: MatrixCall, status: CallState) { - const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); + private setCallState(call: MatrixCall, status: CallState): void { + const mappedRoomId = CallHandler.instance.roomIdForCall(call); logger.log( `Call state in ${mappedRoomId} changed to ${status}`, @@ -683,20 +663,16 @@ export default class CallHandler extends EventEmitter { ToastStore.sharedInstance().dismissToast(toastKey); } - dis.dispatch({ - action: 'call_state', - room_id: mappedRoomId, - state: status, - }); + this.emit(CallHandlerEvent.CallState, mappedRoomId, status); } - private removeCallForRoom(roomId: string) { + private removeCallForRoom(roomId: string): void { logger.log("Removing call for room ", roomId); this.calls.delete(roomId); this.emit(CallHandlerEvent.CallsChanged, this.calls); } - private showICEFallbackPrompt() { + private showICEFallbackPrompt(): void { const cli = MatrixClientPeg.get(); const code = sub => { sub }; Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, { @@ -725,7 +701,7 @@ export default class CallHandler extends EventEmitter { }, null, true); } - private showMediaCaptureError(call: MatrixCall) { + private showMediaCaptureError(call: MatrixCall): void { let title; let description; @@ -754,9 +730,9 @@ export default class CallHandler extends EventEmitter { }, null, true); } - private async placeCall(roomId: string, type: PlaceCallType, transferee: MatrixCall) { + private async placeMatrixCall(roomId: string, type: CallType, transferee?: MatrixCall): Promise { Analytics.trackEvent('voip', 'placeCall', 'type', type); - CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); + CountlyAnalytics.instance.trackStartCall(roomId, type === CallType.Video, false); const mappedRoomId = (await VoipUserMapper.sharedInstance().getOrCreateVirtualRoomForRoom(roomId)) || roomId; logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId); @@ -782,7 +758,7 @@ export default class CallHandler extends EventEmitter { this.setActiveCallRoomId(roomId); - if (type === PlaceCallType.Voice) { + if (type === CallType.Voice) { call.placeVoiceCall(); } else if (type === 'video') { call.placeVideoCall(); @@ -791,166 +767,102 @@ export default class CallHandler extends EventEmitter { } } - private onAction = (payload: ActionPayload) => { - switch (payload.action) { - case 'place_call': - { - // We might be using managed hybrid widgets - if (isManagedHybridWidgetEnabled()) { - addManagedHybridWidget(payload.room_id); - return; - } + public placeCall(roomId: string, type?: CallType, transferee?: MatrixCall): void { + // We might be using managed hybrid widgets + if (isManagedHybridWidgetEnabled()) { + addManagedHybridWidget(roomId); + return; + } - // if the runtime env doesn't do VoIP, whine. - if (!MatrixClientPeg.get().supportsVoip()) { - Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { - title: _t('VoIP is unsupported'), - description: _t('You cannot place VoIP calls in this browser.'), - }); - return; - } + // if the runtime env doesn't do VoIP, whine. + if (!MatrixClientPeg.get().supportsVoip()) { + Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { + title: _t('VoIP is unsupported'), + description: _t('You cannot place VoIP calls in this browser.'), + }); + return; + } - // don't allow > 2 calls to be placed. - if (this.getAllActiveCalls().length > 1) { - Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { - title: _t('Too Many Calls'), - description: _t("You've reached the maximum number of simultaneous calls."), - }); - return; - } + // don't allow > 2 calls to be placed. + if (this.getAllActiveCalls().length > 1) { + Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { + title: _t('Too Many Calls'), + description: _t("You've reached the maximum number of simultaneous calls."), + }); + return; + } - const room = MatrixClientPeg.get().getRoom(payload.room_id); - if (!room) { - logger.error(`Room ${payload.room_id} does not exist.`); - return; - } + const room = MatrixClientPeg.get().getRoom(roomId); + if (!room) { + logger.error(`Room ${roomId} does not exist.`); + return; + } - // We leave the check for whether there's already a call in this room until later, - // otherwise it can race. - - const members = room.getJoinedMembers(); - if (members.length <= 1) { - Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { - description: _t('You cannot place a call with yourself.'), - }); - return; - } else if (members.length === 2) { - logger.info(`Place ${payload.type} call in ${payload.room_id}`); - - this.placeCall(payload.room_id, payload.type, payload.transferee); - } else { // > 2 - dis.dispatch({ - action: "place_conference_call", - room_id: payload.room_id, - type: payload.type, - }); - } - } - break; - case 'place_conference_call': - logger.info("Place conference call in " + payload.room_id); - Analytics.trackEvent('voip', 'placeConferenceCall'); - CountlyAnalytics.instance.trackStartCall(payload.room_id, payload.type === PlaceCallType.Video, true); - this.startCallApp(payload.room_id, payload.type); - break; - case 'end_conference': - logger.info("Terminating conference call in " + payload.room_id); - this.terminateCallApp(payload.room_id); - break; - case 'hangup_conference': - logger.info("Leaving conference call in "+ payload.room_id); - this.hangupCallApp(payload.room_id); - break; - case 'incoming_call': - { - // if the runtime env doesn't do VoIP, stop here. - if (!MatrixClientPeg.get().supportsVoip()) { - return; - } + // We leave the check for whether there's already a call in this room until later, + // otherwise it can race. - const call = payload.call as MatrixCall; + const members = room.getJoinedMembers(); + if (members.length <= 1) { + Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { + description: _t('You cannot place a call with yourself.'), + }); + } else if (members.length === 2) { + logger.info(`Place ${type} call in ${roomId}`); - const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); - if (this.getCallForRoom(mappedRoomId)) { - logger.log( - "Got incoming call for room " + mappedRoomId + - " but there's already a call for this room: ignoring", - ); - return; - } + this.placeMatrixCall(roomId, type, transferee); + } else { // > 2 + this.placeJitsiCall(roomId, type); + } + } - Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); + public hangupAllCalls(): void { + for (const call of this.calls.values()) { + this.stopRingingIfPossible(call.callId); + call.hangup(CallErrorCode.UserHangup, false); + } + } - this.addCallForRoom(mappedRoomId, call); - this.setCallListeners(call); - // Explicitly handle first state change - this.onCallStateChanged(call.state, null, call); + public hangupOrReject(roomId: string, reject?: boolean): void { + const call = this.calls.get(roomId); - // get ready to send encrypted events in the room, so if the user does answer - // the call, we'll be ready to send. NB. This is the protocol-level room ID not - // the mapped one: that's where we'll send the events. - const cli = MatrixClientPeg.get(); - cli.prepareToEncrypt(cli.getRoom(call.roomId)); - } - break; - case 'hangup': - case 'reject': - this.stopRingingIfPossible(this.calls.get(payload.room_id).callId); + // no call to hangup + if (!call) return; - if (!this.calls.get(payload.room_id)) { - return; // no call to hangup - } - if (payload.action === 'reject') { - this.calls.get(payload.room_id).reject(); - } else { - this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false); - } - // don't remove the call yet: let the hangup event handler do it (otherwise it will throw - // the hangup event away) - break; - case 'hangup_all': - this.stopRingingIfPossible(this.calls.get(payload.room_id).callId); + this.stopRingingIfPossible(call.callId); - for (const call of this.calls.values()) { - call.hangup(CallErrorCode.UserHangup, false); - } - break; - case 'answer': { - this.stopRingingIfPossible(this.calls.get(payload.room_id).callId); + if (reject) { + call.reject(); + } else { + call.hangup(CallErrorCode.UserHangup, false); + } + // don't remove the call yet: let the hangup event handler do it (otherwise it will throw + // the hangup event away) + } - if (!this.calls.has(payload.room_id)) { - return; // no call to answer - } + public answerCall(roomId: string): void { + const call = this.calls.get(roomId); - if (this.getAllActiveCalls().length > 1) { - Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { - title: _t('Too Many Calls'), - description: _t("You've reached the maximum number of simultaneous calls."), - }); - return; - } + this.stopRingingIfPossible(call.callId); - const call = this.calls.get(payload.room_id); - call.answer(); - this.setActiveCallRoomId(payload.room_id); - CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false); - dis.dispatch({ - action: Action.ViewRoom, - room_id: payload.room_id, - }); - break; - } - case Action.DialNumber: - this.dialNumber(payload.number); - break; - case Action.TransferCallToMatrixID: - this.startTransferToMatrixID(payload.call, payload.destination, payload.consultFirst); - break; - case Action.TransferCallToPhoneNumber: - this.startTransferToPhoneNumber(payload.call, payload.destination, payload.consultFirst); - break; + // no call to answer + if (!this.calls.has(roomId)) return; + + if (this.getAllActiveCalls().length > 1) { + Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { + title: _t('Too Many Calls'), + description: _t("You've reached the maximum number of simultaneous calls."), + }); + return; } - }; + + call.answer(); + this.setActiveCallRoomId(roomId); + CountlyAnalytics.instance.trackJoinCall(roomId, call.type === CallType.Video, false); + dis.dispatch({ + action: Action.ViewRoom, + room_id: roomId, + }); + } private stopRingingIfPossible(callId: string): void { this.silencedCalls.delete(callId); @@ -958,7 +870,7 @@ export default class CallHandler extends EventEmitter { this.pause(AudioID.Ring); } - private async dialNumber(number: string) { + public async dialNumber(number: string): Promise { const results = await this.pstnLookup(number); if (!results || results.length === 0 || !results[0].userid) { Modal.createTrackedDialog('', '', ErrorDialog, { @@ -988,10 +900,12 @@ export default class CallHandler extends EventEmitter { room_id: roomId, }); - await this.placeCall(roomId, PlaceCallType.Voice, null); + await this.placeMatrixCall(roomId, CallType.Voice, null); } - private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) { + public async startTransferToPhoneNumber( + call: MatrixCall, destination: string, consultFirst: boolean, + ): Promise { const results = await this.pstnLookup(destination); if (!results || results.length === 0 || !results[0].userid) { Modal.createTrackedDialog('', '', ErrorDialog, { @@ -1004,16 +918,13 @@ export default class CallHandler extends EventEmitter { await this.startTransferToMatrixID(call, results[0].userid, consultFirst); } - private async startTransferToMatrixID(call: MatrixCall, destination: string, consultFirst: boolean) { + public async startTransferToMatrixID( + call: MatrixCall, destination: string, consultFirst: boolean, + ): Promise { if (consultFirst) { const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), destination); - dis.dispatch({ - action: 'place_call', - type: call.type, - room_id: dmRoomId, - transferee: call, - }); + this.placeCall(dmRoomId, call.type, call); dis.dispatch({ action: Action.ViewRoom, room_id: dmRoomId, @@ -1033,7 +944,7 @@ export default class CallHandler extends EventEmitter { } } - setActiveCallRoomId(activeCallRoomId: string) { + public setActiveCallRoomId(activeCallRoomId: string): void { logger.info("Setting call in room " + activeCallRoomId + " active"); for (const [roomId, call] of this.calls.entries()) { @@ -1051,7 +962,7 @@ export default class CallHandler extends EventEmitter { /** * @returns true if we are currently in any call where we haven't put the remote party on hold */ - hasAnyUnheldCall() { + public hasAnyUnheldCall(): boolean { for (const call of this.calls.values()) { if (call.state === CallState.Ended) continue; if (!call.isRemoteOnHold()) return true; @@ -1060,7 +971,11 @@ export default class CallHandler extends EventEmitter { return false; } - private async startCallApp(roomId: string, type: string) { + private async placeJitsiCall(roomId: string, type: string): Promise { + logger.info("Place conference call in " + roomId); + Analytics.trackEvent('voip', 'placeConferenceCall'); + CountlyAnalytics.instance.trackStartCall(roomId, type === CallType.Video, true); + dis.dispatch({ action: 'appsDrawer', show: true, @@ -1126,7 +1041,9 @@ export default class CallHandler extends EventEmitter { }); } - private terminateCallApp(roomId: string) { + public terminateCallApp(roomId: string): void { + logger.info("Terminating conference call in " + roomId); + Modal.createTrackedDialog('Confirm Jitsi Terminate', '', QuestionDialog, { hasCancelButton: true, title: _t("End conference"), @@ -1147,7 +1064,9 @@ export default class CallHandler extends EventEmitter { }); } - private hangupCallApp(roomId: string) { + public hangupCallApp(roomId: string): void { + logger.info("Leaving conference call in " + roomId); + const roomInfo = WidgetStore.instance.getRoom(roomId); if (!roomInfo) return; // "should never happen" clauses go here diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 7087386128d..2f085fe5edf 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -411,8 +411,9 @@ export interface IOptsReturnString extends IOpts { export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnString): string; export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnNode): ReactNode; export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) { - const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; + const isFormattedBody = content.format === "org.matrix.custom.html" && content.formatted_body; let bodyHasEmoji = false; + let isHtmlMessage = false; let sanitizeParams = sanitizeHtmlParams; if (opts.forComposerQuote) { @@ -449,20 +450,23 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyChain.stripHTMLReply(formattedBody); strippedBody = opts.stripReplyFallback ? ReplyChain.stripPlainReply(plainBody) : plainBody; - bodyHasEmoji = mightContainEmoji(isHtmlMessage ? formattedBody : plainBody); + bodyHasEmoji = mightContainEmoji(isFormattedBody ? formattedBody : plainBody); // Only generate safeBody if the message was sent as org.matrix.custom.html - if (isHtmlMessage) { + if (isFormattedBody) { isDisplayedWithHtml = true; + safeBody = sanitizeHtml(formattedBody, sanitizeParams); + const phtml = cheerio.load(safeBody, { + // @ts-ignore: The `_useHtmlParser2` internal option is the + // simplest way to both parse and render using `htmlparser2`. + _useHtmlParser2: true, + decodeEntities: false, + }); + const isPlainText = phtml.html() === phtml.root().text(); + isHtmlMessage = isFormattedBody && !isPlainText; - if (SettingsStore.getValue("feature_latex_maths")) { - const phtml = cheerio.load(safeBody, { - // @ts-ignore: The `_useHtmlParser2` internal option is the - // simplest way to both parse and render using `htmlparser2`. - _useHtmlParser2: true, - decodeEntities: false, - }); + if (isHtmlMessage && SettingsStore.getValue("feature_latex_maths")) { // @ts-ignore - The types for `replaceWith` wrongly expect // Cheerio instance to be returned. phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) { diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index d179f9bed37..84771161040 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -794,7 +794,7 @@ async function startMatrixClient(startSyncing = true): Promise { DMRoomMap.makeShared().start(); IntegrationManagers.sharedInstance().startWatching(); ActiveWidgetStore.instance.start(); - CallHandler.sharedInstance().start(); + CallHandler.instance.start(); // Start Mjolnir even though we haven't checked the feature flag yet. Starting // the thing just wastes CPU cycles, but should result in no actual functionality @@ -897,7 +897,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise void; html_inline: (node: commonmark.Node) => void; // eslint-disable-line camelcase html_block: (node: commonmark.Node) => void; // eslint-disable-line camelcase + text: (node: commonmark.Node) => void; + out: (text: string) => void; + emph: (node: commonmark.Node) => void; } function isAllowedHtmlTag(node: commonmark.Node): boolean { @@ -56,17 +61,38 @@ function isAllowedHtmlTag(node: commonmark.Node): boolean { function isMultiLine(node: commonmark.Node): boolean { let par = node; while (par.parent) { - // commonmark Parser separate quotes with blank quoted lines between them with - // paragraphs, so we need to consider it when the markdown is only a multiline quote. - if (par.type === 'block_quote') { - break; - } - par = par.parent; } return par.firstChild != par.lastChild; } +function getTextUntilEndOrLinebreak(node: commonmark.Node) { + let currentNode = node; + let text = ''; + while (currentNode !== null && currentNode.type !== 'softbreak' && currentNode.type !== 'linebreak') { + const { literal, type } = currentNode; + if (type === 'text' && literal) { + let n = 0; + let char = literal[n]; + while (char !== ' ' && char !== null && n <= literal.length) { + if (char === ' ') { + break; + } + if (char) { + text += char; + } + n += 1; + char = literal[n]; + } + if (char === ' ') { + break; + } + } + currentNode = currentNode.next; + } + return text; +} + /** * Class that wraps commonmark, adding the ability to see whether * a given message actually uses any markdown syntax or whether @@ -76,11 +102,103 @@ export default class Markdown { private input: string; private parsed: commonmark.Node; - constructor(input) { + constructor(input: string) { this.input = input; const parser = new commonmark.Parser(); this.parsed = parser.parse(this.input); + this.parsed = this.repairLinks(this.parsed); + } + + /** + * This method is modifying the parsed AST in such a way that links are always + * properly linkified instead of sometimes being wrongly emphasised in case + * if you were to write a link like the example below: + * https://my_weird-link_domain.domain.com + * ^ this link would be parsed to something like this: + * https://myweird-linkdomain.domain.com + * This method makes it so the link gets properly modified to a version where it is + * not emphasised until it actually ends. + * See: https://github.com/vector-im/element-web/issues/4674 + * @param parsed + */ + private repairLinks(parsed: commonmark.Node) { + const walker = parsed.walker(); + let event: commonmark.NodeWalkingStep = null; + let text = ''; + let isInPara = false; + let previousNode: commonmark.Node | null = null; + let shouldUnlinkEmphasisNode = false; + while ((event = walker.next())) { + const { node } = event; + if (node.type === 'paragraph') { + if (event.entering) { + isInPara = true; + } else { + isInPara = false; + } + } + if (isInPara) { + // Clear saved string when line ends + if ( + node.type === 'softbreak' || + node.type === 'linebreak' || + // Also start calculating the text from the beginning on any spaces + (node.type === 'text' && node.literal === ' ') + ) { + text = ''; + } + if (node.type === 'text') { + text += node.literal; + } + // We should not do this if previous node was not a textnode, as we can't combine it then. + if (node.type === 'emph' && previousNode.type === 'text') { + if (event.entering) { + const foundLinks = linkify.find(text); + for (const { value } of foundLinks) { + if (node.firstChild.literal) { + /** + * NOTE: This technically should unlink the emph node and create LINK nodes instead, adding all the next elements as siblings + * but this solution seems to work well and is hopefully slightly easier to understand too + */ + const nonEmphasizedText = `_${node.firstChild.literal}_`; + const f = getTextUntilEndOrLinebreak(node); + const newText = value + nonEmphasizedText + f; + const newLinks = linkify.find(newText); + // Should always find only one link here, if it finds more it means that the algorithm is broken + if (newLinks.length === 1) { + const emphasisTextNode = new commonmark.Node('text'); + emphasisTextNode.literal = nonEmphasizedText; + previousNode.insertAfter(emphasisTextNode); + node.firstChild.literal = ''; + event = node.walker().next(); + // Remove `em` opening and closing nodes + node.unlink(); + previousNode.insertAfter(event.node); + shouldUnlinkEmphasisNode = true; + } else { + logger.error( + "Markdown links escaping found too many links for following text: ", + text, + ); + logger.error( + "Markdown links escaping found too many links for modified text: ", + newText, + ); + } + } + } + } else { + if (shouldUnlinkEmphasisNode) { + node.unlink(); + shouldUnlinkEmphasisNode = false; + } + } + } + } + previousNode = node; + } + return parsed; } isPlainText(): boolean { @@ -126,16 +244,17 @@ export default class Markdown { // you can nest them. // // Let's try sending with

s anyway for now, though. - const realParagraph = renderer.paragraph; - renderer.paragraph = function(node: commonmark.Node, entering: boolean) { // If there is only one top level node, just return the // bare text: it's a single line of text and so should be // 'inline', rather than unnecessarily wrapped in its own // p tag. If, however, we have multiple nodes, each gets // its own p tag to keep them as separate paragraphs. - if (isMultiLine(node)) { + // However, if it's a blockquote, adds a p tag anyway + // in order to avoid deviation to commonmark and unexpected + // results when parsing the formatted HTML. + if (node.parent.type === 'block_quote'|| isMultiLine(node)) { realParagraph.call(this, node, entering); } }; diff --git a/src/ScalarMessaging.ts b/src/ScalarMessaging.ts index 8db6a17422b..b110aef031e 100644 --- a/src/ScalarMessaging.ts +++ b/src/ScalarMessaging.ts @@ -473,10 +473,7 @@ async function setBotPower( // If the PL is equal to or greater than the requested PL, ignore. if (ignoreIfGreater === true) { // As per https://matrix.org/docs/spec/client_server/r0.6.0#m-room-power-levels - const currentPl = ( - powerLevels.content.users && powerLevels.content.users[userId] - ) || powerLevels.content.users_default || 0; - + const currentPl = powerLevels.users?.[userId] ?? powerLevels.users_default ?? 0; if (currentPl >= level) { return sendResponse(event, { success: true, diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index e9b91600483..baa84f28f5e 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -1037,7 +1037,7 @@ export const Commands = [ return success((async () => { if (isPhoneNumber) { - const results = await CallHandler.sharedInstance().pstnLookup(this.state.value); + const results = await CallHandler.instance.pstnLookup(this.state.value); if (!results || results.length === 0 || !results[0].userid) { throw new Error("Unable to find Matrix ID for phone number"); } @@ -1089,7 +1089,7 @@ export const Commands = [ description: _td("Places the call in the current room on hold"), category: CommandCategories.other, runFn: function(roomId, args) { - const call = CallHandler.sharedInstance().getCallForRoom(roomId); + const call = CallHandler.instance.getCallForRoom(roomId); if (!call) { return reject("No active call in this room"); } @@ -1103,7 +1103,7 @@ export const Commands = [ description: _td("Takes the call in the current room off hold"), category: CommandCategories.other, runFn: function(roomId, args) { - const call = CallHandler.sharedInstance().getCallForRoom(roomId); + const call = CallHandler.instance.getCallForRoom(roomId); if (!call) { return reject("No active call in this room"); } diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts index e2e590548ef..00b9a1167a1 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -36,7 +36,7 @@ export default class VoipUserMapper { } private async userToVirtualUser(userId: string): Promise { - const results = await CallHandler.sharedInstance().sipVirtualLookup(userId); + const results = await CallHandler.instance.sipVirtualLookup(userId); if (results.length === 0 || !results[0].fields.lookup_success) return null; return results[0].userid; } @@ -97,11 +97,11 @@ export default class VoipUserMapper { } public async onNewInvitedRoom(invitedRoom: Room): Promise { - if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return; + if (!CallHandler.instance.getSupportsVirtualRooms()) return; const inviterId = invitedRoom.getDMInviter(); logger.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`); - const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId); + const result = await CallHandler.instance.sipNativeLookup(inviterId); if (result.length === 0) { return; } diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 51cc2322d16..0ef46172940 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -130,7 +130,10 @@ export const reducer = (state: IState, action: IAction) => { state.activeRef = findSiblingElement(state.refs, oldIndex) || findSiblingElement(state.refs, oldIndex, true); } - state.activeRef?.current?.focus(); + if (document.activeElement === document.body) { + // if the focus got reverted to the body then the user was likely focused on the unmounted element + state.activeRef?.current?.focus(); + } } // update the refs list @@ -165,13 +168,13 @@ export const findSiblingElement = ( ): RefObject => { if (backwards) { for (let i = startIndex; i < refs.length && i >= 0; i--) { - if (refs[i].current.offsetParent !== null) { + if (refs[i].current?.offsetParent !== null) { return refs[i]; } } } else { for (let i = startIndex; i < refs.length && i >= 0; i++) { - if (refs[i].current.offsetParent !== null) { + if (refs[i].current?.offsetParent !== null) { return refs[i]; } } diff --git a/src/components/structures/CallEventGrouper.ts b/src/components/structures/CallEventGrouper.ts index 84e004d1de0..f2729f9f293 100644 --- a/src/components/structures/CallEventGrouper.ts +++ b/src/components/structures/CallEventGrouper.ts @@ -20,7 +20,6 @@ import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/we import CallHandler, { CallHandlerEvent } from '../../CallHandler'; import { EventEmitter } from 'events'; import { MatrixClientPeg } from "../../MatrixClientPeg"; -import defaultDispatcher from "../../dispatcher/dispatcher"; export enum CallEventGrouperEvent { StateChanged = "state_changed", @@ -52,8 +51,8 @@ export default class CallEventGrouper extends EventEmitter { constructor() { super(); - CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.setCall); - CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged); + CallHandler.instance.addListener(CallHandlerEvent.CallsChanged, this.setCall); + CallHandler.instance.addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged); } private get invite(): MatrixEvent { @@ -114,7 +113,7 @@ export default class CallEventGrouper extends EventEmitter { } private onSilencedCallsChanged = () => { - const newState = CallHandler.sharedInstance().isCallSilenced(this.callId); + const newState = CallHandler.instance.isCallSilenced(this.callId); this.emit(CallEventGrouperEvent.SilencedChanged, newState); }; @@ -122,33 +121,23 @@ export default class CallEventGrouper extends EventEmitter { this.emit(CallEventGrouperEvent.LengthChanged, length); }; - public answerCall = () => { - defaultDispatcher.dispatch({ - action: 'answer', - room_id: this.roomId, - }); + public answerCall = (): void => { + CallHandler.instance.answerCall(this.roomId); }; - public rejectCall = () => { - defaultDispatcher.dispatch({ - action: 'reject', - room_id: this.roomId, - }); + public rejectCall = (): void => { + CallHandler.instance.hangupOrReject(this.roomId, true); }; - public callBack = () => { - defaultDispatcher.dispatch({ - action: 'place_call', - type: this.isVoice ? CallType.Voice : CallType.Video, - room_id: this.roomId, - }); + public callBack = (): void => { + CallHandler.instance.placeCall(this.roomId, this.isVoice ? CallType.Voice : CallType.Video); }; public toggleSilenced = () => { - const silenced = CallHandler.sharedInstance().isCallSilenced(this.callId); + const silenced = CallHandler.instance.isCallSilenced(this.callId); silenced ? - CallHandler.sharedInstance().unSilenceCall(this.callId) : - CallHandler.sharedInstance().silenceCall(this.callId); + CallHandler.instance.unSilenceCall(this.callId) : + CallHandler.instance.silenceCall(this.callId); }; private setCallListeners() { @@ -174,7 +163,7 @@ export default class CallEventGrouper extends EventEmitter { private setCall = () => { if (this.call) return; - this.call = CallHandler.sharedInstance().getCallById(this.callId); + this.call = CallHandler.instance.getCallById(this.callId); this.setCallListeners(); this.setState(); }; diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 94b4b46fd42..84af76e282b 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -103,7 +103,7 @@ interface IState { // all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1} // this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines. @replaceableComponent("structures.ContextMenu") -export class ContextMenu extends React.PureComponent { +export default class ContextMenu extends React.PureComponent { private readonly initialFocus: HTMLElement; static defaultProps = { @@ -411,6 +411,7 @@ export class ContextMenu extends React.PureComponent { onClick={this.onClick} onContextMenu={this.onContextMenuPreventBubbling} > + { background }

{ > { body }
- { background } ); } @@ -530,30 +530,22 @@ export const useContextMenu = (): ContextMenuTuple< return [isOpen, button, open, close, setIsOpen]; }; -@replaceableComponent("structures.LegacyContextMenu") -export default class LegacyContextMenu extends ContextMenu { - render() { - return this.renderMenu(false); - } -} - // XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs. export function createMenu(ElementClass, props) { const onFinished = function(...args) { ReactDOM.unmountComponentAtNode(getOrCreateContainer()); - - if (props && props.onFinished) { - props.onFinished.apply(null, args); - } + props?.onFinished?.apply(null, args); }; - const menu = - ; + ; ReactDOM.render(menu, getOrCreateContainer()); diff --git a/src/components/structures/IndicatorScrollbar.tsx b/src/components/structures/IndicatorScrollbar.tsx index 85de6594812..f687d9e0673 100644 --- a/src/components/structures/IndicatorScrollbar.tsx +++ b/src/components/structures/IndicatorScrollbar.tsx @@ -14,11 +14,11 @@ 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 AutoHideScrollbar from "./AutoHideScrollbar"; import { replaceableComponent } from "../../utils/replaceableComponent"; -interface IProps { +interface IProps extends Omit, "onWheel"> { // If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator // and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning // by the parent element. @@ -55,6 +55,7 @@ export default class IndicatorScrollbar extends React.Component } private collectScroller = (scroller: HTMLDivElement): void => { + this.props.wrappedRef?.(scroller); if (scroller && !this.scrollElement) { this.scrollElement = scroller; // Using the passive option to not block the main thread @@ -185,10 +186,10 @@ export default class IndicatorScrollbar extends React.Component ?
: null; return ( { leftOverflowIndicator } { children } diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index f25ae269da6..36038f6d9bc 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -24,33 +24,39 @@ import RoomList from "../views/rooms/RoomList"; import CallHandler from "../../CallHandler"; import { HEADER_HEIGHT } from "../views/rooms/RoomSublist"; import { Action } from "../../dispatcher/actions"; -import UserMenu from "./UserMenu"; import RoomSearch from "./RoomSearch"; -import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs"; -import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore"; -import { UPDATE_EVENT } from "../../stores/AsyncStore"; import ResizeNotifier from "../../utils/ResizeNotifier"; -import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore"; -import IndicatorScrollbar from "../structures/IndicatorScrollbar"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; -import RoomListNumResults from "../views/rooms/RoomListNumResults"; import LeftPanelWidget from "./LeftPanelWidget"; import { replaceableComponent } from "../../utils/replaceableComponent"; import SpaceStore from "../../stores/spaces/SpaceStore"; -import { SpaceKey, UPDATE_SELECTED_SPACE } from "../../stores/spaces"; +import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../../stores/spaces"; import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; import UIStore from "../../stores/UIStore"; import { findSiblingElement, IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex"; -import MatrixClientContext from "../../contexts/MatrixClientContext"; +import RoomListHeader from "../views/rooms/RoomListHeader"; import { Key } from "../../Keyboard"; +import RecentlyViewedButton from "../views/rooms/RecentlyViewedButton"; +import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore"; +import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import IndicatorScrollbar from "./IndicatorScrollbar"; +import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs"; +import SettingsStore from "../../settings/SettingsStore"; interface IProps { isMinimized: boolean; resizeNotifier: ResizeNotifier; } +enum BreadcrumbsMode { + Disabled, + Legacy, + Labs, +} + interface IState { - showBreadcrumbs: boolean; + showBreadcrumbs: BreadcrumbsMode; activeSpace: SpaceKey; } @@ -63,15 +69,12 @@ export default class LeftPanel extends React.Component { private focusedElement = null; private isDoingStickyHeaders = false; - static contextType = MatrixClientContext; - public context!: React.ContextType; - constructor(props: IProps) { super(props); this.state = { - showBreadcrumbs: BreadcrumbsStore.instance.visible, activeSpace: SpaceStore.instance.activeSpace, + showBreadcrumbs: LeftPanel.breadcrumbsMode, }; BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); @@ -79,6 +82,11 @@ export default class LeftPanel extends React.Component { SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace); } + private static get breadcrumbsMode(): BreadcrumbsMode { + if (!SettingsStore.getValue("breadcrumbs")) return BreadcrumbsMode.Disabled; + return SettingsStore.getValue("feature_breadcrumbs_v2") ? BreadcrumbsMode.Labs : BreadcrumbsMode.Legacy; + } + public componentDidMount() { UIStore.instance.trackElementDimensions("LeftPanel", this.ref.current); UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current); @@ -121,7 +129,7 @@ export default class LeftPanel extends React.Component { }; private onBreadcrumbsUpdate = () => { - const newVal = BreadcrumbsStore.instance.visible; + const newVal = LeftPanel.breadcrumbsMode; if (newVal !== this.state.showBreadcrumbs) { this.setState({ showBreadcrumbs: newVal }); @@ -306,6 +314,7 @@ export default class LeftPanel extends React.Component { }; private onRoomListKeydown = (ev: React.KeyboardEvent) => { + if (ev.altKey || ev.ctrlKey || ev.metaKey) return; // we cannot handle Space as that is an activation key for all focusable elements in this widget if (ev.key.length === 1) { ev.preventDefault(); @@ -326,16 +335,8 @@ export default class LeftPanel extends React.Component { } }; - private renderHeader(): React.ReactNode { - return ( -
- -
- ); - } - private renderBreadcrumbs(): React.ReactNode { - if (this.state.showBreadcrumbs && !this.props.isMinimized) { + if (this.state.showBreadcrumbs === BreadcrumbsMode.Legacy && !this.props.isMinimized) { return ( { // If we have dialer support, show a button to bring up the dial pad // to start a new call - if (CallHandler.sharedInstance().getSupportsPstnProtocol()) { + if (CallHandler.instance.getSupportsPstnProtocol()) { dialPadButton = { />; } - const space = this.state.activeSpace[0] === "!" ? this.context.getRoom(this.state.activeSpace) : null; + let rightButton: JSX.Element; + if (this.state.showBreadcrumbs === BreadcrumbsMode.Labs) { + rightButton = ; + } else if (this.state.activeSpace === MetaSpace.Home) { + rightButton = ; + } + return (
{ /> { dialPadButton } - - + { rightButton }
); } @@ -414,10 +418,9 @@ export default class LeftPanel extends React.Component { return (