diff --git a/docs/room-list-store.md b/docs/room-list-store.md index fa849e2505..6fc5f71124 100644 --- a/docs/room-list-store.md +++ b/docs/room-list-store.md @@ -6,7 +6,7 @@ It's so complicated it needs its own README. Legend: * Orange = External event. -* Purple = Deterministic flow. +* Purple = Deterministic flow. * Green = Algorithm definition. * Red = Exit condition/point. * Blue = Process definition. @@ -24,8 +24,8 @@ algorithm to call, instead of having all the logic in the room list store itself Tag sorting is effectively the comparator supplied to the list algorithm. This gives the list algorithm -the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm, -later described in this document, heavily uses the list ordering behaviour to break the tag into categories. +the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm, +later described in this document, heavily uses the list ordering behaviour to break the tag into categories. Each category then gets sorted by the appropriate tag sorting algorithm. ### Tag sorting algorithm: Alphabetical @@ -36,7 +36,7 @@ useful. ### Tag sorting algorithm: Manual -Manual sorting makes use of the `order` property present on all tags for a room, per the +Manual sorting makes use of the `order` property present on all tags for a room, per the [Matrix specification](https://matrix.org/docs/spec/client_server/r0.6.0#room-tagging). Smaller values of `order` cause rooms to appear closer to the top of the list. @@ -74,7 +74,7 @@ relative (perceived) importance to the user: set to 'All Messages'. * **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without a badge/notification count (or 'Mentions Only'/'Muted'). -* **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user +* **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user last read it. Conveniently, each tag gets ordered by those categories as presented: red rooms appear above grey, grey @@ -82,7 +82,7 @@ above bold, etc. Once the algorithm has determined which rooms belong in which categories, the tag sorting algorithm gets applied to each category in a sub-list fashion. This should result in the red rooms (for example) -being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but +being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but collectively the tag will be sorted into categories with red being at the top. ## Sticky rooms @@ -103,48 +103,62 @@ receive another notification which causes the room to move into the topmost posi above the sticky room will move underneath to allow for the new room to take the top slot, maintaining the sticky room's position. -Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries -and thus the user can see a shift in what kinds of rooms move around their selection. An example would -be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having -the rooms above it read on another device. This would result in 1 red room and 1 other kind of room +Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries +and thus the user can see a shift in what kinds of rooms move around their selection. An example would +be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having +the rooms above it read on another device. This would result in 1 red room and 1 other kind of room above the sticky room as it will try to maintain 2 rooms above the sticky room. An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain the N required rooms above the sticky room. In this case, the sticky room will simply decrease N as needed. -The N value will never increase while selection remains unchanged: adding a bunch of rooms after having +The N value will never increase while selection remains unchanged: adding a bunch of rooms after having put the sticky room in a position where it's had to decrease N will not increase N. ## Responsibilities of the store -The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets -an object containing the tags it needs to worry about and the rooms within. The room list component will -decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with +The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets +an object containing the tags it needs to worry about and the rooms within. The room list component will +decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with all kinds of filtering. ## Filtering -Filters are provided to the store as condition classes, which are then passed along to the algorithm -implementations. The implementations then get to decide how to actually filter the rooms, however in -practice the base `Algorithm` class deals with the filtering in a more optimized/generic way. +Filters are provided to the store as condition classes and have two major kinds: Prefilters and Runtime. -The results of filters get cached to avoid needlessly iterating over potentially thousands of rooms, -as the old room list store does. When a filter condition changes, it emits an update which (in this -case) the `Algorithm` class will pick up and act accordingly. Typically, this also means filtering a +Prefilters flush out rooms which shouldn't appear to the algorithm implementations. Typically this is +due to some higher order room list filtering (such as spaces or tags) deliberately exposing a subset of +rooms to the user. The algorithm implementations will not see a room being prefiltered out. + +Runtime filters are used for more dynamic filtering, such as the user filtering by room name. These +filters are passed along to the algorithm implementations where those implementations decide how and +when to apply the filter. In practice, the base `Algorithm` class ends up doing the heavy lifting for +optimization reasons. + +The results of runtime filters get cached to avoid needlessly iterating over potentially thousands of +rooms, as the old room list store does. When a filter condition changes, it emits an update which (in this +case) the `Algorithm` class will pick up and act accordingly. Typically, this also means filtering a minor subset where possible to avoid over-iterating rooms. All filter conditions are considered "stable" by the consumers, meaning that the consumer does not expect a change in the condition unless the condition says it has changed. This is intentional to maintain the caching behaviour described above. +One might ask why we don't just use prefilter conditions for everything, and the answer is one of slight +subtlety: in the cases of prefilters we are knowingly exposing the user to a workspace-style UX where +room notifications are self-contained within that workspace. Runtime filters tend to not want to affect +visible notification counts (as it doesn't want the room header to suddenly be confusing to the user as +they type), and occasionally UX like "found 2/12 rooms" is desirable. If prefiltering were used instead, +the notification counts would vary while the user was typing and "found 2/12" UX would not be possible. + ## Class breakdowns -The `RoomListStore` is the major coordinator of various algorithm implementations, which take care -of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` class is responsible -for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get -defined on rooms and are not defined as a collection of rooms (unlike how they are presented to the -user). Various list-specific utilities are also included, though they are expected to move somewhere -more general when needed. For example, the `membership` utilities could easily be moved elsewhere +The `RoomListStore` is the major coordinator of various algorithm implementations, which take care +of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` class is responsible +for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get +defined on rooms and are not defined as a collection of rooms (unlike how they are presented to the +user). Various list-specific utilities are also included, though they are expected to move somewhere +more general when needed. For example, the `membership` utilities could easily be moved elsewhere as needed. The various bits throughout the room list store should also have jsdoc of some kind to help describe diff --git a/res/css/_components.scss b/res/css/_components.scss index 215b6605a5..31bdff90bf 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -250,6 +250,7 @@ @import "./views/voice_messages/_Waveform.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; +@import "./views/voip/_CallViewForRoom.scss"; @import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadModal.scss"; diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 26382b55e8..cdbe47178d 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -262,12 +262,6 @@ hr.mx_RoomView_myReadMarker { padding-top: 1px; } -.mx_RoomView_inCall .mx_RoomView_statusAreaBox { - background-color: $accent-color; - color: $accent-fg-color; - position: relative; -} - .mx_RoomView_voipChevron { position: absolute; bottom: -11px; diff --git a/res/css/views/rooms/_RoomPreviewBar.scss b/res/css/views/rooms/_RoomPreviewBar.scss index 0b1da7a41c..b340080837 100644 --- a/res/css/views/rooms/_RoomPreviewBar.scss +++ b/res/css/views/rooms/_RoomPreviewBar.scss @@ -40,6 +40,35 @@ limitations under the License. word-break: break-word; } + .mx_RoomPreviewBar_reason { + text-align: left; + background-color: $primary-bg-color; + border: 1px solid $invite-reason-border-color; + border-radius: 10px; + padding: 0 16px 12px 16px; + margin: 5px 0 20px 0; + + div { + pointer-events: none; + } + + .mx_EventTile_msgOption { + display: none; + } + + .mx_MatrixChat_useCompactLayout & { + padding-top: 9px; + } + + &.mx_EventTilePreview_faded { + cursor: pointer; + + .mx_SenderProfile, .mx_EventTile_avatar { + opacity: 0.3; + } + } + } + .mx_Spinner { width: auto; height: auto; diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 7eb329594a..d13272c8c0 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -27,9 +27,12 @@ limitations under the License. .mx_CallView_large { padding-bottom: 10px; margin: 5px 5px 5px 18px; + display: flex; + flex-direction: column; + flex: 1; .mx_CallView_voice { - height: 360px; + flex: 1; } } @@ -55,7 +58,7 @@ limitations under the License. } } - .mx_CallView_voice_holdText { + .mx_CallView_holdTransferContent { padding-top: 10px; padding-bottom: 25px; } @@ -82,7 +85,7 @@ limitations under the License. } } -.mx_CallView_voice_hold { +.mx_CallView_voice .mx_CallView_holdTransferContent { // This masks the avatar image so when it's blurred, the edge is still crisp .mx_CallView_voice_avatarContainer { border-radius: 2000px; @@ -91,7 +94,7 @@ limitations under the License. } } -.mx_CallView_voice_holdText { +.mx_CallView_holdTransferContent { height: 20px; padding-top: 20px; padding-bottom: 15px; @@ -104,6 +107,7 @@ limitations under the License. .mx_CallView_video { width: 100%; + height: 100%; position: relative; z-index: 30; border-radius: 8px; @@ -142,7 +146,7 @@ limitations under the License. } } -.mx_CallView_video_holdContent { +.mx_CallView_video .mx_CallView_holdTransferContent { position: absolute; top: 50%; left: 50%; @@ -177,6 +181,7 @@ limitations under the License. flex-direction: row; align-items: center; justify-content: left; + flex-shrink: 0; } .mx_CallView_header_callType { diff --git a/res/css/views/voip/_CallViewForRoom.scss b/res/css/views/voip/_CallViewForRoom.scss new file mode 100644 index 0000000000..769e00338e --- /dev/null +++ b/res/css/views/voip/_CallViewForRoom.scss @@ -0,0 +1,46 @@ +/* +Copyright 2021 Šimon Brandner + +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_CallViewForRoom { + overflow: hidden; + + .mx_CallViewForRoom_ResizeWrapper { + display: flex; + margin-bottom: 8px; + + &:hover .mx_CallViewForRoom_ResizeHandle { + // Need to use important to override element style attributes + // set by re-resizable + width: 100% !important; + + display: flex; + justify-content: center; + + &::after { + content: ''; + margin-top: 3px; + + border-radius: 4px; + + height: 4px; + width: 100%; + max-width: 64px; + + background-color: $primary-fg-color; + } + } + } +} diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 3e473a80b2..8ead8bba3e 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_VideoFeed_remote { width: 100%; - max-height: 100%; + height: 100%; background-color: #000; z-index: 50; } diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index cf1fd17e58..f7fda92346 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -209,6 +209,8 @@ $message-body-panel-fg-color: $primary-fg-color; // Appearance tab colors $appearance-tab-border-color: $room-highlight-color; +$invite-reason-border-color: $room-highlight-color; + // blur amounts for left left panel (only for element theme, used in _mods.scss) $roomlist-background-blur-amount: 60px; $groupFilterPanel-background-blur-amount: 30px; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index ff58314bdd..95c558a0e4 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -204,6 +204,8 @@ $message-body-panel-fg-color: $primary-fg-color; // Appearance tab colors $appearance-tab-border-color: $room-highlight-color; +$invite-reason-border-color: $room-highlight-color; + $composer-shadow-color: tranparent; // ***** Mixins! ***** diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 121366decb..a3f83cabe0 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -333,6 +333,8 @@ $message-body-panel-fg-color: $muted-fg-color; // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; +$invite-reason-border-color: $input-darker-bg-color; + $composer-shadow-color: tranparent; // ***** Mixins! ***** diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index f082247754..3465f555b6 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -331,6 +331,8 @@ $message-body-panel-fg-color: $muted-fg-color; // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; +$invite-reason-border-color: $input-darker-bg-color; + // blur amounts for left left panel (only for element theme, used in _mods.scss) $roomlist-background-blur-amount: 40px; $groupFilterPanel-background-blur-amount: 20px; diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index ce779f12a5..be687a4474 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -154,6 +154,9 @@ function getRemoteAudioElement(): HTMLAudioElement { export default class CallHandler { 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; @@ -325,6 +328,10 @@ export default class CallHandler { return callsNotInThatRoom; } + getTransfereeForCallId(callId: string): MatrixCall { + return this.transferees[callId]; + } + play(audioId: AudioID) { // TODO: Attach an invisible element for this instead // which listens? @@ -622,6 +629,7 @@ export default class CallHandler { private async placeCall( roomId: string, type: PlaceCallType, localElement: HTMLVideoElement, remoteElement: HTMLVideoElement, + transferee: MatrixCall, ) { Analytics.trackEvent('voip', 'placeCall', 'type', type); CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); @@ -634,6 +642,9 @@ export default class CallHandler { const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId); this.calls.set(roomId, call); + if (transferee) { + this.transferees[call.callId] = transferee; + } this.setCallListeners(call); this.setCallAudioElement(call); @@ -723,7 +734,10 @@ export default class CallHandler { } else if (members.length === 2) { console.info(`Place ${payload.type} call in ${payload.room_id}`); - this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element); + this.placeCall( + payload.room_id, payload.type, payload.local_element, payload.remote_element, + payload.transferee, + ); } else { // > 2 dis.dispatch({ action: "place_conference_call", diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index 0e9d14ea8f..63c4ac0f86 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -161,27 +161,27 @@ const messageComposerBindings = (): KeyBinding[] => { const autocompleteBindings = (): KeyBinding[] => { return [ { - action: AutocompleteAction.ApplySelection, + action: AutocompleteAction.CompleteOrNextSelection, keyCombo: { key: Key.TAB, }, }, { - action: AutocompleteAction.ApplySelection, + action: AutocompleteAction.CompleteOrNextSelection, keyCombo: { key: Key.TAB, ctrlKey: true, }, }, { - action: AutocompleteAction.ApplySelection, + action: AutocompleteAction.CompleteOrPrevSelection, keyCombo: { key: Key.TAB, shiftKey: true, }, }, { - action: AutocompleteAction.ApplySelection, + action: AutocompleteAction.CompleteOrPrevSelection, keyCombo: { key: Key.TAB, ctrlKey: true, @@ -360,11 +360,11 @@ const navigationBindings = (): KeyBinding[] => { action: NavigationAction.GoToHome, keyCombo: { key: Key.H, - ctrlOrCmd: true, - altKey: true, + ctrlKey: true, + altKey: !isMac, + shiftKey: isMac, }, }, - { action: NavigationAction.SelectPrevRoom, keyCombo: { diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 45ef97b121..d862f10c02 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -52,14 +52,19 @@ export enum MessageComposerAction { /** Actions for text editing autocompletion */ export enum AutocompleteAction { - /** Apply the current autocomplete selection */ - ApplySelection = 'ApplySelection', - /** Cancel autocompletion */ - Cancel = 'Cancel', + /** + * Select previous selection or, if the autocompletion window is not shown, open the window and select the first + * selection. + */ + CompleteOrPrevSelection = 'ApplySelection', + /** Select next selection or, if the autocompletion window is not shown, open it and select the first selection */ + CompleteOrNextSelection = 'CompleteOrNextSelection', /** Move to the previous autocomplete selection */ PrevSelection = 'PrevSelection', /** Move to the next autocomplete selection */ NextSelection = 'NextSelection', + /** Close the autocompletion window */ + Cancel = 'Cancel', } /** Actions for the room list sidebar */ diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index 7a0ba58c97..2a3e576e31 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -265,7 +265,7 @@ const shortcuts: Record = { description: _td("Toggle this dialog"), }, { keybinds: [{ - modifiers: [CMD_OR_CTRL, Modifiers.ALT], + modifiers: [Modifiers.CONTROL, isMac ? Modifiers.SHIFT : Modifiers.ALT], key: Key.H, }], description: _td("Go to Home View"), diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index a07ed29c7e..91fbea4d6a 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -23,7 +23,6 @@ interface IOptions { keys: Array; funcs?: Array<(T) => string>; shouldMatchWordsOnly?: boolean; - shouldMatchPrefix?: boolean; // whether to apply unhomoglyph and strip diacritics to fuzz up the search. Defaults to true fuzzy?: boolean; } @@ -56,12 +55,6 @@ export default class QueryMatcher { if (this._options.shouldMatchWordsOnly === undefined) { this._options.shouldMatchWordsOnly = true; } - - // By default, match anywhere in the string being searched. If enabled, only return - // matches that are prefixed with the query. - if (this._options.shouldMatchPrefix === undefined) { - this._options.shouldMatchPrefix = false; - } } setObjects(objects: T[]) { @@ -112,7 +105,7 @@ export default class QueryMatcher { resultKey = resultKey.replace(/[^\w]/g, ''); } const index = resultKey.indexOf(query); - if (index !== -1 && (!this._options.shouldMatchPrefix || index === 0)) { + if (index !== -1) { matches.push( ...candidates.map((candidate) => ({index, ...candidate})), ); diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 7fc01daef9..5f0cfc2df1 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -56,7 +56,6 @@ export default class UserProvider extends AutocompleteProvider { this.matcher = new QueryMatcher([], { keys: ['name'], funcs: [obj => obj.userId.slice(1)], // index by user id minus the leading '@' - shouldMatchPrefix: true, shouldMatchWordsOnly: false, }); diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index b006b323fb..ed6167cbe7 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -981,7 +981,7 @@ export default class GroupView extends React.Component { ; } - const httpInviterAvatar = this.state.inviterProfile + const httpInviterAvatar = this.state.inviterProfile && this.state.inviterProfile.avatarUrl ? mediaFromMxc(this.state.inviterProfile.avatarUrl).getSquareThumbnailHttp(36) : null; diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 2861cfd7e7..cbfc7b476b 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -34,7 +34,6 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore"; import ResizeNotifier from "../../utils/ResizeNotifier"; import SettingsStore from "../../settings/SettingsStore"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore"; -import {Key} from "../../Keyboard"; import IndicatorScrollbar from "../structures/IndicatorScrollbar"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; @@ -43,6 +42,7 @@ import LeftPanelWidget from "./LeftPanelWidget"; import {replaceableComponent} from "../../utils/replaceableComponent"; import {mediaFromMxc} from "../../customisations/Media"; import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore"; +import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; interface IProps { isMinimized: boolean; @@ -297,17 +297,18 @@ export default class LeftPanel extends React.Component { private onKeyDown = (ev: React.KeyboardEvent) => { if (!this.focusedElement) return; - switch (ev.key) { - case Key.ARROW_UP: - case Key.ARROW_DOWN: + const action = getKeyBindingsManager().getRoomListAction(ev); + switch (action) { + case RoomListAction.NextRoom: + case RoomListAction.PrevRoom: ev.stopPropagation(); ev.preventDefault(); - this.onMoveFocus(ev.key === Key.ARROW_UP); + this.onMoveFocus(action === RoomListAction.PrevRoom); break; } }; - private onEnter = () => { + private selectRoom = () => { const firstRoom = this.listContainerRef.current.querySelector(".mx_RoomTile"); if (firstRoom) { firstRoom.click(); @@ -388,8 +389,8 @@ export default class LeftPanel extends React.Component { > { case RoomAction.RoomScrollDown: case RoomAction.JumpToFirstMessage: case RoomAction.JumpToLatestMessage: + // pass the event down to the scroll panel this._onScrollKeyPressed(ev); handled = true; break; diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index c44917ddbe..a64feed42c 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -30,8 +30,11 @@ import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore"; interface IProps { isMinimized: boolean; - onVerticalArrow(ev: React.KeyboardEvent): void; - onEnter(ev: React.KeyboardEvent): boolean; + onKeyDown(ev: React.KeyboardEvent): void; + /** + * @returns true if a room has been selected and the search field should be cleared + */ + onSelectRoom(): boolean; } interface IState { @@ -120,10 +123,11 @@ export default class RoomSearch extends React.PureComponent { break; case RoomListAction.NextRoom: case RoomListAction.PrevRoom: - this.props.onVerticalArrow(ev); + // we don't handle these actions here put pass the event on to the interested party (LeftPanel) + this.props.onKeyDown(ev); break; case RoomListAction.SelectRoom: { - const shouldClear = this.props.onEnter(ev); + const shouldClear = this.props.onSelectRoom(); if (shouldClear) { // wrap in set immediate to delay it so that we don't clear the filter & then change room setImmediate(() => { diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 3a9b2b8a77..976734680c 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -16,10 +16,10 @@ limitations under the License. import React, {createRef} from "react"; import PropTypes from 'prop-types'; -import { Key } from '../../Keyboard'; import Timer from '../../utils/Timer'; import AutoHideScrollbar from "./AutoHideScrollbar"; import {replaceableComponent} from "../../utils/replaceableComponent"; +import {getKeyBindingsManager, RoomAction} from "../../KeyBindingsManager"; const DEBUG_SCROLL = false; @@ -535,29 +535,19 @@ export default class ScrollPanel extends React.Component { * @param {object} ev the keyboard event */ handleScrollKey = ev => { - switch (ev.key) { - case Key.PAGE_UP: - if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this.scrollRelative(-1); - } + const roomAction = getKeyBindingsManager().getRoomAction(ev); + switch (roomAction) { + case RoomAction.ScrollUp: + this.scrollRelative(-1); break; - - case Key.PAGE_DOWN: - if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this.scrollRelative(1); - } + case RoomAction.RoomScrollDown: + this.scrollRelative(1); break; - - case Key.HOME: - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this.scrollToTop(); - } + case RoomAction.JumpToFirstMessage: + this.scrollToTop(); break; - - case Key.END: - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { - this.scrollToBottom(); - } + case RoomAction.JumpToLatestMessage: + this.scrollToBottom(); break; } }; diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.js index 31a5de0222..6188fdb5e4 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.js @@ -18,7 +18,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; +import { _t, _td } from '../../../languageHandler'; import * as sdk from '../../../index'; import Modal from "../../../Modal"; import PasswordReset from "../../../PasswordReset"; @@ -27,7 +27,9 @@ import classNames from 'classnames'; import AuthPage from "../../views/auth/AuthPage"; import CountlyAnalytics from "../../../CountlyAnalytics"; import ServerPicker from "../../views/elements/ServerPicker"; +import PassphraseField from '../../views/auth/PassphraseField'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm'; // Phases // Show the forgot password inputs @@ -137,10 +139,14 @@ export default class ForgotPassword extends React.Component { // refresh the server errors, just in case the server came back online await this._checkServerLiveliness(this.props.serverConfig); + await this['password_field'].validate({ allowEmpty: false }); + if (!this.state.email) { this.showErrorDialog(_t('The email address linked to your account must be entered.')); } else if (!this.state.password || !this.state.password2) { this.showErrorDialog(_t('A new password must be entered.')); + } else if (!this.state.passwordFieldValid) { + this.showErrorDialog(_t('Please choose a strong password')); } else if (this.state.password !== this.state.password2) { this.showErrorDialog(_t('New passwords must match each other.')); } else { @@ -186,6 +192,12 @@ export default class ForgotPassword extends React.Component { }); } + onPasswordValidate(result) { + this.setState({ + passwordFieldValid: result.valid, + }); + } + renderForgot() { const Field = sdk.getComponent('elements.Field'); @@ -230,12 +242,15 @@ export default class ForgotPassword extends React.Component { />
- this['password_field'] = field} + onValidate={(result) => this.onPasswordValidate(result)} onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")} onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")} autoComplete="new-password" diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index 85e0933be9..8f0a293a3c 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -40,7 +40,7 @@ enum RegistrationField { PasswordConfirm = "field_password_confirm", } -const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. +export const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. interface IProps { // Values pre-filled in the input boxes when the component loads diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 6b17d3ce60..a274f96a17 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -29,7 +29,9 @@ import dis from "../../../dispatcher/dispatcher"; import IdentityAuthClient from "../../../IdentityAuthClient"; import Modal from "../../../Modal"; import {humanizeTime} from "../../../utils/humanize"; -import createRoom, {canEncryptToAllUsers, findDMForUser, privateShouldBeEncrypted} from "../../../createRoom"; +import createRoom, { + canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted, +} from "../../../createRoom"; import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite"; import {Key} from "../../../Keyboard"; import {Action} from "../../../dispatcher/actions"; @@ -332,6 +334,7 @@ interface IInviteDialogState { threepidResultsMixin: { user: Member, userId: string}[]; canUseIdentityServer: boolean; tryingIdentityServer: boolean; + consultFirst: boolean; // These two flags are used for the 'Go' button to communicate what is going on. busy: boolean, @@ -380,6 +383,7 @@ export default class InviteDialog extends React.PureComponent { + this.setState({consultFirst: ev.target.checked}); + } + static buildRecents(excludedTargetIds: Set): {userId: string, user: RoomMember, lastActive: number}[] { const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room @@ -745,16 +753,34 @@ export default class InviteDialog extends React.PureComponent; const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); @@ -1339,6 +1366,12 @@ export default class InviteDialog extends React.PureComponent + +
; } else { console.error("Unknown kind of InviteDialog: " + this.props.kind); } @@ -1375,6 +1408,7 @@ export default class InviteDialog extends React.PureComponent + {consultSection} ); diff --git a/src/components/views/dialogs/SeshatResetDialog.tsx b/src/components/views/dialogs/SeshatResetDialog.tsx new file mode 100644 index 0000000000..135f5d8197 --- /dev/null +++ b/src/components/views/dialogs/SeshatResetDialog.tsx @@ -0,0 +1,54 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import {_t} from "../../../languageHandler"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; + +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; + +import {IDialogProps} from "./IDialogProps"; + +@replaceableComponent("views.dialogs.SeshatResetDialog") +export default class SeshatResetDialog extends React.PureComponent { + render() { + return ( + +
+

+ {_t("You most likely do not want to reset your event index store")} +
+ {_t("If you do, please note that none of your messages will be deleted, " + + "but the search experience might be degraded for a few moments" + + "whilst the index is recreated", + )} +

+
+ +
+ ); + } +} diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index 5fd73f974d..077c116873 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -19,7 +19,6 @@ import classnames from 'classnames'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import * as Avatar from '../../../Avatar'; -import { MatrixClientPeg } from '../../../MatrixClientPeg'; import EventTile from '../rooms/EventTile'; import SettingsStore from "../../../settings/SettingsStore"; import {Layout} from "../../../settings/Layout"; @@ -41,15 +40,38 @@ interface IProps { * classnames to apply to the wrapper of the preview */ className: string; + + /** + * The ID of the displayed user + */ + userId: string; + + /** + * The display name of the displayed user + */ + displayName?: string; + + /** + * The mxc:// avatar URL of the displayed user + */ + avatarUrl?: string; + + /** + * Whether the EventTile should appear faded + */ + faded?: boolean; + + /** + * Callback for when the component is clicked + */ + onClick?: () => void; } -/* eslint-disable camelcase */ interface IState { - userId: string; - displayname: string; - avatar_url: string; + message: string; + faded: boolean; + eventTileKey: number; } -/* eslint-enable camelcase */ const AVATAR_SIZE = 32; @@ -57,45 +79,42 @@ const AVATAR_SIZE = 32; export default class EventTilePreview extends React.Component { constructor(props: IProps) { super(props); - this.state = { - userId: "@erim:fink.fink", - displayname: "Erimayas Fink", - avatar_url: null, + message: props.message, + faded: !!props.faded, + eventTileKey: 0, }; } - async componentDidMount() { - // Fetch current user data - const client = MatrixClientPeg.get(); - const userId = client.getUserId(); - const profileInfo = await client.getProfileInfo(userId); - const avatarUrl = profileInfo.avatar_url; - + changeMessage(message: string) { this.setState({ - userId, - displayname: profileInfo.displayname, - avatar_url: avatarUrl, + message, + // Change the EventTile key to force React to create a new instance + eventTileKey: this.state.eventTileKey + 1, }); } - private fakeEvent({userId, displayname, avatar_url: avatarUrl}: IState) { + unfade() { + this.setState({ faded: false }); + } + + private fakeEvent({message}: IState) { // Fake it till we make it /* eslint-disable quote-props */ const rawEvent = { type: "m.room.message", - sender: userId, + sender: this.props.userId, content: { "m.new_content": { msgtype: "m.text", - body: this.props.message, - displayname: displayname, - avatar_url: avatarUrl, + body: message, + displayname: this.props.displayName, + avatar_url: this.props.avatarUrl, }, msgtype: "m.text", - body: this.props.message, - displayname: displayname, - avatar_url: avatarUrl, + body: message, + displayname: this.props.displayName, + avatar_url: this.props.avatarUrl, }, unsigned: { age: 97, @@ -108,12 +127,15 @@ export default class EventTilePreview extends React.Component { // Fake it more event.sender = { - name: displayname, - userId: userId, + name: this.props.displayName, + userId: this.props.userId, getAvatarUrl: (..._) => { - return Avatar.avatarUrlForUser({avatarUrl}, AVATAR_SIZE, AVATAR_SIZE, "crop"); + return Avatar.avatarUrlForUser( + { avatarUrl: this.props.avatarUrl }, + AVATAR_SIZE, AVATAR_SIZE, "crop", + ); }, - getMxcAvatarUrl: () => avatarUrl, + getMxcAvatarUrl: () => this.props.avatarUrl, }; return event; @@ -125,10 +147,12 @@ export default class EventTilePreview extends React.Component { const className = classnames(this.props.className, { "mx_IRCLayout": this.props.layout == Layout.IRC, "mx_GroupLayout": this.props.layout == Layout.Group, + "mx_EventTilePreview_faded": this.state.faded, }); - return
+ return
{ // enable the play button. Firefox does not seem to care either // way, so it's fine to do for all browsers. decryptedUrl: `data:${content?.info?.mimetype},`, - decryptedThumbnailUrl: thumbnailUrl, + decryptedThumbnailUrl: thumbnailUrl || `data:${content?.info?.mimetype},`, decryptedBlob: null, }); } diff --git a/src/components/views/rooms/AuxPanel.tsx b/src/components/views/rooms/AuxPanel.tsx index 3d431f7c67..6d2ae39059 100644 --- a/src/components/views/rooms/AuxPanel.tsx +++ b/src/components/views/rooms/AuxPanel.tsx @@ -149,8 +149,8 @@ export default class AuxPanel extends React.Component { const callView = ( ); diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 5dabd80399..9d9e3a1ba0 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -485,16 +485,14 @@ export default class BasicMessageEditor extends React.Component if (model.autoComplete && model.autoComplete.hasCompletions()) { const autoComplete = model.autoComplete; switch (autocompleteAction) { + case AutocompleteAction.CompleteOrPrevSelection: case AutocompleteAction.PrevSelection: - autoComplete.onUpArrow(event); + autoComplete.selectPreviousSelection(); handled = true; break; + case AutocompleteAction.CompleteOrNextSelection: case AutocompleteAction.NextSelection: - autoComplete.onDownArrow(event); - handled = true; - break; - case AutocompleteAction.ApplySelection: - autoComplete.onTab(event); + autoComplete.selectNextSelection(); handled = true; break; case AutocompleteAction.Cancel: @@ -504,8 +502,10 @@ export default class BasicMessageEditor extends React.Component default: return; // don't preventDefault on anything else } - } else if (autocompleteAction === AutocompleteAction.ApplySelection) { - this.tabCompleteName(event); + } else if (autocompleteAction === AutocompleteAction.CompleteOrPrevSelection + || autocompleteAction === AutocompleteAction.CompleteOrNextSelection) { + // there is no current autocomplete window, try to open it + this.tabCompleteName(); handled = true; } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) { this.formatBarRef.current.hide(); @@ -517,7 +517,7 @@ export default class BasicMessageEditor extends React.Component } }; - private async tabCompleteName(event: React.KeyboardEvent) { + private async tabCompleteName() { try { await new Promise(resolve => this.setState({showVisualBell: false}, resolve)); const {model} = this.props; @@ -540,7 +540,7 @@ export default class BasicMessageEditor extends React.Component // Don't try to do things with the autocomplete if there is none shown if (model.autoComplete) { - await model.autoComplete.onTab(event); + await model.autoComplete.startSelection(); if (!model.autoComplete.hasSelection()) { this.setState({showVisualBell: true}); model.autoComplete.close(); diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 644d64d322..d51f4c00f1 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -936,7 +936,7 @@ export default class EventTile extends React.Component { ); const TooltipButton = sdk.getComponent('elements.TooltipButton'); - const keyRequestInfo = isEncryptionFailure ? + const keyRequestInfo = isEncryptionFailure && !isRedacted ?
{ keyRequestInfoContent } diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js index 36038da61c..f84458a32f 100644 --- a/src/components/views/rooms/RoomPreviewBar.js +++ b/src/components/views/rooms/RoomPreviewBar.js @@ -25,6 +25,7 @@ import classNames from 'classnames'; import { _t } from '../../../languageHandler'; import SdkConfig from "../../../SdkConfig"; import IdentityAuthClient from '../../../IdentityAuthClient'; +import SettingsStore from "../../../settings/SettingsStore"; import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; import {UPDATE_EVENT} from "../../../stores/AsyncStore"; import {replaceableComponent} from "../../../utils/replaceableComponent"; @@ -302,10 +303,12 @@ export default class RoomPreviewBar extends React.Component { const brand = SdkConfig.get().brand; const Spinner = sdk.getComponent('elements.Spinner'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const EventTilePreview = sdk.getComponent('elements.EventTilePreview'); let showSpinner = false; let title; let subTitle; + let reasonElement; let primaryActionHandler; let primaryActionLabel; let secondaryActionHandler; @@ -491,6 +494,29 @@ export default class RoomPreviewBar extends React.Component { primaryActionLabel = _t("Accept"); } + const myUserId = MatrixClientPeg.get().getUserId(); + const reason = this.props.room.currentState.getMember(myUserId).events.member.event.content.reason; + if (reason) { + this.reasonElement = React.createRef(); + // We hide the reason for invitation by default, since it can be a + // vector for spam/harassment. + const showReason = () => { + this.reasonElement.current.unfade(); + this.reasonElement.current.changeMessage(reason); + }; + reasonElement =
+ { reasonElement }
{ secondaryButton } { extraComponents } diff --git a/src/components/views/settings/ChangePassword.js b/src/components/views/settings/ChangePassword.js index aa635ef974..3a7fb2e2b3 100644 --- a/src/components/views/settings/ChangePassword.js +++ b/src/components/views/settings/ChangePassword.js @@ -28,13 +28,12 @@ import Modal from "../../../Modal"; import PassphraseField from "../auth/PassphraseField"; import CountlyAnalytics from "../../../CountlyAnalytics"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { PASSWORD_MIN_SCORE } from '../auth/RegistrationForm'; const FIELD_OLD_PASSWORD = 'field_old_password'; const FIELD_NEW_PASSWORD = 'field_new_password'; const FIELD_NEW_PASSWORD_CONFIRM = 'field_new_password_confirm'; -const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. - @replaceableComponent("views.settings.ChangePassword") export default class ChangePassword extends React.Component { static propTypes = { diff --git a/src/components/views/settings/EventIndexPanel.js b/src/components/views/settings/EventIndexPanel.js index a48583b61d..d1a02de16d 100644 --- a/src/components/views/settings/EventIndexPanel.js +++ b/src/components/views/settings/EventIndexPanel.js @@ -26,6 +26,7 @@ import {formatBytes, formatCountLong} from "../../../utils/FormattingUtils"; import EventIndexPeg from "../../../indexing/EventIndexPeg"; import {SettingLevel} from "../../../settings/SettingLevel"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import SeshatResetDialog from '../dialogs/SeshatResetDialog'; @replaceableComponent("views.settings.EventIndexPanel") export default class EventIndexPanel extends React.Component { @@ -122,6 +123,20 @@ export default class EventIndexPanel extends React.Component { await this.updateState(); } + _confirmEventStoreReset = () => { + const self = this; + const { close } = Modal.createDialog(SeshatResetDialog, { + onFinished: async (success) => { + if (success) { + await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false); + await EventIndexPeg.deleteEventIndex(); + await self._onEnable(); + close(); + } + }, + }); + } + render() { let eventIndexingSettings = null; const InlineSpinner = sdk.getComponent('elements.InlineSpinner'); @@ -167,7 +182,7 @@ export default class EventIndexPanel extends React.Component { ); } else if (EventIndexPeg.platformHasSupport() && !EventIndexPeg.supportIsInstalled()) { const nativeLink = ( - "https://github.com/vector-im/element-web/blob/develop/" + + "https://github.com/vector-im/element-desktop/blob/develop/" + "docs/native-node-modules.md#" + "adding-seshat-for-search-in-e2e-encrypted-rooms" ); @@ -212,7 +227,10 @@ export default class EventIndexPanel extends React.Component { eventIndexingSettings = (

- {_t("Message search initilisation failed")} + {this.state.enabling + ? + : _t("Message search initilisation failed") + }

{EventIndexPeg.error && (
@@ -220,6 +238,11 @@ export default class EventIndexPanel extends React.Component { {EventIndexPeg.error.message} +

+ + {_t("Reset")} + +

)} diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index d6e01d194c..bc40c36bda 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -18,6 +18,7 @@ limitations under the License. import React from 'react'; import {_t} from "../../../../../languageHandler"; import SdkConfig from "../../../../../SdkConfig"; +import { MatrixClientPeg } from '../../../../../MatrixClientPeg'; import SettingsStore from "../../../../../settings/SettingsStore"; import { enumerateThemes } from "../../../../../theme"; import ThemeWatcher from "../../../../../settings/watchers/ThemeWatcher"; @@ -63,6 +64,10 @@ interface IState extends IThemeState { systemFont: string; showAdvanced: boolean; layout: Layout; + // User profile data for the message preview + userId: string; + displayName: string; + avatarUrl: string; } @replaceableComponent("views.settings.tabs.user.AppearanceUserSettingsTab") @@ -84,9 +89,25 @@ export default class AppearanceUserSettingsTab extends React.Component
Aa
diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 9bdc8fb11d..8a6ed75fee 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -40,9 +40,6 @@ interface IProps { // Another ongoing call to display information about secondaryCall?: MatrixCall, - // maxHeight style attribute for the video panel - maxVideoHeight?: number; - // a callback which is called when the content in the callview changes // in a way that is likely to cause a resize. onResize?: any; @@ -96,9 +93,6 @@ function exitFullscreen() { const CONTROLS_HIDE_DELAY = 1000; // Height of the header duplicated from CSS because we need to subtract it from our max // height to get the max height of the video -const HEADER_HEIGHT = 44; -const BOTTOM_PADDING = 10; -const BOTTOM_MARGIN_TOP_BOTTOM = 10; // top margin plus bottom margin const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px) @replaceableComponent("views.voip.CallView") @@ -364,6 +358,11 @@ export default class CallView extends React.Component { CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId); } + private onTransferClick = () => { + const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId); + this.props.call.transferToCall(transfereeCall); + } + public render() { const client = MatrixClientPeg.get(); const callRoomId = CallHandler.roomIdForCall(this.props.call); @@ -479,25 +478,52 @@ export default class CallView extends React.Component { // for voice calls (fills the bg) let contentView: React.ReactNode; + const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId); const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold; - let onHoldText = null; - if (this.state.isRemoteOnHold) { - const holdString = CallHandler.sharedInstance().hasAnyUnheldCall() ? - _td("You held the call Switch") : _td("You held the call Resume"); - onHoldText = _t(holdString, {}, { - a: sub => - {sub} - , - }); - } else if (this.state.isLocalOnHold) { - onHoldText = _t("%(peerName)s held the call", { - peerName: this.props.call.getOpponentMember().name, - }); + let holdTransferContent; + if (transfereeCall) { + const transferTargetRoom = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.props.call)); + const transferTargetName = transferTargetRoom ? transferTargetRoom.name : _t("unknown person"); + + const transfereeRoom = MatrixClientPeg.get().getRoom( + CallHandler.roomIdForCall(transfereeCall), + ); + const transfereeName = transfereeRoom ? transfereeRoom.name : _t("unknown person"); + + holdTransferContent =
+ {_t( + "Consulting with %(transferTarget)s. Transfer to %(transferee)s", + { + transferTarget: transferTargetName, + transferee: transfereeName, + }, + { + a: sub => {sub}, + }, + )} +
; + } else if (isOnHold) { + let onHoldText = null; + if (this.state.isRemoteOnHold) { + const holdString = CallHandler.sharedInstance().hasAnyUnheldCall() ? + _td("You held the call Switch") : _td("You held the call Resume"); + onHoldText = _t(holdString, {}, { + a: sub => + {sub} + , + }); + } else if (this.state.isLocalOnHold) { + onHoldText = _t("%(peerName)s held the call", { + peerName: this.props.call.getOpponentMember().name, + }); + } + holdTransferContent =
+ {onHoldText} +
; } if (this.props.call.type === CallType.Video) { let localVideoFeed = null; - let onHoldContent = null; let onHoldBackground = null; const backgroundStyle: CSSProperties = {}; const containerClasses = classNames({ @@ -505,9 +531,6 @@ export default class CallView extends React.Component { mx_CallView_video_hold: isOnHold, }); if (isOnHold) { - onHoldContent =
- {onHoldText} -
; const backgroundAvatarUrl = avatarUrlForMember( // is it worth getting the size of the div to pass here? this.props.call.getOpponentMember(), 1024, 1024, 'crop', @@ -519,22 +542,11 @@ export default class CallView extends React.Component { localVideoFeed = ; } - // if we're fullscreen, we don't want to set a maxHeight on the video element. - const maxVideoHeight = getFullScreenElement() || !this.props.maxVideoHeight ? null : ( - this.props.maxVideoHeight - (HEADER_HEIGHT + BOTTOM_PADDING + BOTTOM_MARGIN_TOP_BOTTOM) - ); - contentView =
+ contentView =
{onHoldBackground} - + {localVideoFeed} - {onHoldContent} + {holdTransferContent} {callControls}
; } else { @@ -554,7 +566,7 @@ export default class CallView extends React.Component { />
-
{onHoldText}
+ {holdTransferContent} {callControls}
; } diff --git a/src/components/views/voip/CallViewForRoom.tsx b/src/components/views/voip/CallViewForRoom.tsx index 97960d1e0b..878b6af20f 100644 --- a/src/components/views/voip/CallViewForRoom.tsx +++ b/src/components/views/voip/CallViewForRoom.tsx @@ -19,6 +19,8 @@ import React from 'react'; import CallHandler from '../../../CallHandler'; import CallView from './CallView'; import dis from '../../../dispatcher/dispatcher'; +import {Resizable} from "re-resizable"; +import ResizeNotifier from "../../../utils/ResizeNotifier"; import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps { @@ -28,9 +30,7 @@ interface IProps { // maxHeight style attribute for the video panel maxVideoHeight?: number; - // a callback which is called when the content in the callview changes - // in a way that is likely to cause a resize. - onResize?: any; + resizeNotifier: ResizeNotifier, } interface IState { @@ -79,11 +79,50 @@ export default class CallViewForRoom extends React.Component { return call; } + private onResizeStart = () => { + this.props.resizeNotifier.startResizing(); + }; + + private onResize = () => { + this.props.resizeNotifier.notifyTimelineHeightChanged(); + }; + + private onResizeStop = () => { + this.props.resizeNotifier.stopResizing(); + }; + public render() { if (!this.state.call) return null; + // We subtract 8 as it the margin-bottom of the mx_CallViewForRoom_ResizeWrapper + const maxHeight = this.props.maxVideoHeight - 8; - return ; + return ( +
+ + + +
+ ); } } diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 23dbe4d46b..2981fb6c04 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -30,9 +30,6 @@ interface IProps { type: VideoFeedType, - // maxHeight style attribute for the video element - maxHeight?: number, - // a callback which is called when the video element is resized // due to a change in video metadata onResize?: (e: Event) => void, @@ -82,9 +79,6 @@ export default class VideoFeed extends React.Component { ), }; - let videoStyle = {}; - if (this.props.maxHeight) videoStyle = { maxHeight: this.props.maxHeight }; - - return