diff --git a/res/css/_components.scss b/res/css/_components.scss index d8e5247f9d..c8985cbb51 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -98,6 +98,7 @@ @import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TermsDialog.scss"; +@import "./views/dialogs/_UntrustedDeviceDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; @import "./views/dialogs/_WidgetCapabilitiesPromptDialog.scss"; diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index 91947be76a..2776c477fc 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -54,7 +54,8 @@ limitations under the License. display: flex; margin-top: 12px; - .mx_BaseAvatar { + // we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling + .mx_DecoratedRoomAvatar { margin-right: 12px; } @@ -75,6 +76,10 @@ limitations under the License. } .mx_AddExistingToSpace_section_spaces { + .mx_BaseAvatar { + margin-right: 12px; + } + .mx_BaseAvatar_image { border-radius: 8px; } @@ -105,6 +110,90 @@ limitations under the License. mask-position: center; } } + + .mx_AddExistingToSpace_footer { + display: flex; + margin-top: 20px; + + > span { + flex-grow: 1; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + .mx_ProgressBar { + height: 8px; + width: 100%; + + @mixin ProgressBarBorderRadius 8px; + } + + .mx_AddExistingToSpace_progressText { + margin-top: 8px; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; + } + + > * { + vertical-align: middle; + } + } + + .mx_AddExistingToSpace_error { + padding-left: 12px; + + > img { + align-self: center; + } + + .mx_AddExistingToSpace_errorHeading { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + color: $notice-primary-color; + } + + .mx_AddExistingToSpace_errorCaption { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $primary-fg-color; + } + } + + .mx_AccessibleButton { + display: inline-block; + align-self: center; + } + + .mx_AccessibleButton_kind_primary { + padding: 8px 36px; + } + + .mx_AddExistingToSpace_retryButton { + margin-left: 12px; + padding-left: 24px; + position: relative; + + &::before { + content: ''; + position: absolute; + background-color: $primary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/retry.svg'); + width: 18px; + height: 18px; + left: 0; + } + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + } } .mx_AddExistingToSpaceDialog { @@ -189,88 +278,4 @@ limitations under the License. .mx_AddExistingToSpace { display: contents; } - - .mx_AddExistingToSpaceDialog_footer { - display: flex; - margin-top: 20px; - - > span { - flex-grow: 1; - font-size: $font-12px; - line-height: $font-15px; - color: $secondary-fg-color; - - .mx_ProgressBar { - height: 8px; - width: 100%; - - @mixin ProgressBarBorderRadius 8px; - } - - .mx_AddExistingToSpaceDialog_progressText { - margin-top: 8px; - font-size: $font-15px; - line-height: $font-24px; - color: $primary-fg-color; - } - - > * { - vertical-align: middle; - } - } - - .mx_AddExistingToSpaceDialog_error { - padding-left: 12px; - - > img { - align-self: center; - } - - .mx_AddExistingToSpaceDialog_errorHeading { - font-weight: $font-semi-bold; - font-size: $font-15px; - line-height: $font-18px; - color: $notice-primary-color; - } - - .mx_AddExistingToSpaceDialog_errorCaption { - margin-top: 4px; - font-size: $font-12px; - line-height: $font-15px; - color: $primary-fg-color; - } - } - - .mx_AccessibleButton { - display: inline-block; - align-self: center; - } - - .mx_AccessibleButton_kind_primary { - padding: 8px 36px; - } - - .mx_AddExistingToSpaceDialog_retryButton { - margin-left: 12px; - padding-left: 24px; - position: relative; - - &::before { - content: ''; - position: absolute; - background-color: $primary-fg-color; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/element-icons/retry.svg'); - width: 18px; - height: 18px; - left: 0; - } - } - - .mx_AccessibleButton_kind_link { - padding: 0; - } - } } diff --git a/res/css/views/dialogs/_UntrustedDeviceDialog.scss b/res/css/views/dialogs/_UntrustedDeviceDialog.scss new file mode 100644 index 0000000000..0ecd9d4f71 --- /dev/null +++ b/res/css/views/dialogs/_UntrustedDeviceDialog.scss @@ -0,0 +1,26 @@ +/* +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_UntrustedDeviceDialog { + .mx_Dialog_title { + display: flex; + align-items: center; + + .mx_E2EIcon { + margin-left: 0; + } + } +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 3ecbef0d1f..d41ac3a4ba 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -85,6 +85,7 @@ limitations under the License. left: 0; height: 100%; width: 100%; + mask-size: 18px; mask-repeat: no-repeat; mask-position: center; background-color: $message-action-bar-fg-color; diff --git a/res/css/views/messages/_ReactionsRow.scss b/res/css/views/messages/_ReactionsRow.scss index 2f5695e1fb..244439bf74 100644 --- a/res/css/views/messages/_ReactionsRow.scss +++ b/res/css/views/messages/_ReactionsRow.scss @@ -17,18 +17,55 @@ limitations under the License. .mx_ReactionsRow { margin: 6px 0; color: $primary-fg-color; + + .mx_ReactionsRow_addReactionButton { + position: relative; + display: none; // show on hover of the .mx_EventTile + width: 24px; + height: 24px; + vertical-align: middle; + margin-left: 4px; + + &::before { + content: ''; + position: absolute; + height: 100%; + width: 100%; + mask-size: 16px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $tertiary-fg-color; + mask-image: url('$(res)/img/element-icons/room/message-bar/emoji.svg'); + } + + &.mx_ReactionsRow_addReactionButton_active { + display: inline-block; // keep showing whilst the context menu is shown + } + + &:hover, &.mx_ReactionsRow_addReactionButton_active { + &::before { + background-color: $primary-fg-color; + } + } + } +} + +.mx_EventTile:hover .mx_ReactionsRow_addReactionButton { + display: inline-block; } .mx_ReactionsRow_showAll { text-decoration: none; - font-size: $font-10px; - font-weight: 600; - margin-left: 6px; - vertical-align: top; + font-size: $font-12px; + line-height: $font-20px; + margin-left: 4px; + vertical-align: middle; - &:hover, - &:link, - &:visited { - color: $accent-color; + &:link, &:visited { + color: $tertiary-fg-color; + } + + &:hover { + color: $primary-fg-color; } } diff --git a/res/css/views/messages/_ReactionsRowButton.scss b/res/css/views/messages/_ReactionsRowButton.scss index c132fa5a0f..766fea2f8f 100644 --- a/res/css/views/messages/_ReactionsRowButton.scss +++ b/res/css/views/messages/_ReactionsRowButton.scss @@ -16,14 +16,15 @@ limitations under the License. .mx_ReactionsRowButton { display: inline-flex; - line-height: $font-21px; + line-height: $font-20px; margin-right: 6px; - padding: 0 6px; + padding: 1px 6px; border: 1px solid $reaction-row-button-border-color; border-radius: 10px; background-color: $reaction-row-button-bg-color; cursor: pointer; user-select: none; + vertical-align: middle; &:hover { border-color: $reaction-row-button-hover-border-color; diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss index 9d52e40819..1aafa8da0e 100644 --- a/res/css/views/rooms/_RoomSublist.scss +++ b/res/css/views/rooms/_RoomSublist.scss @@ -98,7 +98,7 @@ limitations under the License. position: relative; width: 24px; height: 24px; - border-radius: 32px; + border-radius: 8px; &::before { content: ''; @@ -114,6 +114,11 @@ limitations under the License. } } + .mx_RoomSublist_auxButton:hover, + .mx_RoomSublist_menuButton:hover { + background: $roomlist-button-bg-color; + } + // Hide the menu button by default .mx_RoomSublist_menuButton { visibility: hidden; diff --git a/res/img/betas/spaces.png b/res/img/betas/spaces.png index 87394e0a5b..f4cfa90b4e 100644 Binary files a/res/img/betas/spaces.png and b/res/img/betas/spaces.png differ diff --git a/res/img/element-icons/room/composer/emoji.svg b/res/img/element-icons/room/composer/emoji.svg index 9613d9edd9..b02cb69364 100644 --- a/res/img/element-icons/room/composer/emoji.svg +++ b/res/img/element-icons/room/composer/emoji.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/res/img/element-icons/room/message-bar/emoji.svg b/res/img/element-icons/room/message-bar/emoji.svg index 697f656b8a..07fee5b834 100644 --- a/res/img/element-icons/room/message-bar/emoji.svg +++ b/res/img/element-icons/room/message-bar/emoji.svg @@ -1,5 +1,3 @@ - - - - + + diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index b49a90d175..4cb537f318 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -167,7 +167,7 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn const onKeyDownHandler = useCallback((ev) => { let handled = false; // Don't interfere with input default keydown behaviour - if (handleHomeEnd && ev.target.tagName !== "INPUT") { + if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { // check if we actually have any items switch (ev.key) { case Key.HOME: diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 9d9d57d8a6..ad0f75e162 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -222,10 +222,12 @@ export class ContextMenu extends React.PureComponent { }; private onKeyDown = (ev: React.KeyboardEvent) => { + // don't let keyboard handling escape the context menu + ev.stopPropagation(); + if (!this.props.managed) { if (ev.key === Key.ESCAPE) { this.props.onFinished(); - ev.stopPropagation(); ev.preventDefault(); } return; @@ -258,7 +260,6 @@ export class ContextMenu extends React.PureComponent { if (handled) { // consume all other keys in context menu - ev.stopPropagation(); ev.preventDefault(); } }; diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index e5e0065be8..73a2a3c4b6 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -34,6 +34,7 @@ import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResiz import DMRoomMap from "../../utils/DMRoomMap"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; import {replaceableComponent} from "../../utils/replaceableComponent"; +import defaultDispatcher from '../../dispatcher/dispatcher'; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = ['m.sticker', 'm.room.message']; @@ -471,6 +472,10 @@ export default class MessagePanel extends React.Component { return {nextEvent, nextTile}; } + get _roomHasPendingEdit() { + return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`); + } + _getEventTiles() { this.eventNodes = {}; @@ -559,6 +564,13 @@ export default class MessagePanel extends React.Component { } } + if (!this.props.editState && this._roomHasPendingEdit) { + defaultDispatcher.dispatch({ + action: "edit_event", + event: this.props.room.findEventById(this._roomHasPendingEdit), + }); + } + if (grouper) { ret.push(...grouper.getTiles()); } @@ -574,7 +586,6 @@ export default class MessagePanel extends React.Component { const isEditing = this.props.editState && this.props.editState.getEvent().getId() === mxEv.getId(); - // local echoes have a fake date, which could even be yesterday. Treat them // as 'today' for the date separators. let ts1 = mxEv.getTs(); diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index ed0ae1afe7..56ee9dd62a 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -52,8 +52,6 @@ import {useStateToggle} from "../../hooks/useStateToggle"; import SpaceStore from "../../stores/SpaceStore"; import FacePile from "../views/elements/FacePile"; import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog"; -import {sleep} from "../../utils/promise"; -import {calculateRoomVia} from "../../utils/permalinks/Permalinks"; import {ChevronFace, ContextMenuButton, useContextMenu} from "./ContextMenu"; import IconizedContextMenu, { IconizedContextMenuOption, @@ -78,6 +76,7 @@ interface IProps { interface IState { phase: Phase; + createdRooms?: boolean; // internal state for the creation wizard showRightPanel: boolean; myMembership: string; } @@ -461,7 +460,8 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { setError(""); setBusy(true); try { - await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => { + const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean); + await Promise.all(filteredRoomNames.map(name => { return createRoom({ createOpts: { preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat, @@ -474,7 +474,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { parentSpace: space, }); })); - onFinished(); + onFinished(filteredRoomNames.length > 0); } catch (e) { console.error("Failed to create initial space rooms", e); setError(_t("Failed to create initial space rooms")); @@ -484,7 +484,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { let onClick = (ev) => { ev.preventDefault(); - onFinished(); + onFinished(false); }; let buttonLabel = _t("Skip for now"); if (roomNames.some(name => name.trim())) { @@ -517,39 +517,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { }; const SpaceAddExistingRooms = ({ space, onFinished }) => { - const [selectedToAdd, setSelectedToAdd] = useState(new Set()); - - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - - let onClick = onFinished; - let buttonLabel = _t("Skip for now"); - if (selectedToAdd.size > 0) { - onClick = async () => { - setBusy(true); - - for (const room of selectedToAdd) { - const via = calculateRoomVia(room); - try { - await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => { - if (e.errcode === "M_LIMIT_EXCEEDED") { - await sleep(e.data.retry_after_ms); - return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry - } - - throw e; - }); - } catch (e) { - console.error("Failed to add rooms to space", e); - setError(_t("Failed to add rooms to space")); - break; - } - } - setBusy(false); - }; - buttonLabel = busy ? _t("Adding...") : _t("Add"); - } - return { _t("What do you want to organise?") } @@ -557,35 +524,24 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => { "no one will be informed. You can add more later.") } - { error && { error } } - { - if (checked) { - selectedToAdd.add(room); - } else { - selectedToAdd.delete(room); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} + emptySelectionButton={ + + { _t("Skip for now") } + + } + onFinished={onFinished} /> - - { buttonLabel } - + ; }; -const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished }) => { +const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => { return { _t("Share %(name)s", { name: justCreatedOpts?.createOpts?.name || space.name, @@ -598,7 +554,7 @@ const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished }) => { - { _t("Go to my first room") } + { createdRooms ? _t("Go to my first room") : _t("Go to my space") } @@ -891,13 +847,14 @@ export default class SpaceRoomView extends React.PureComponent { _t("Let's create a room for each of them.") + "\n" + _t("You can add more later too, including already existing ones.") } - onFinished={() => this.setState({ phase: Phase.PublicShare })} + onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })} />; case Phase.PublicShare: return ; case Phase.PrivateScope: @@ -919,7 +876,7 @@ export default class SpaceRoomView extends React.PureComponent { title={_t("What projects are you working on?")} description={_t("We'll create rooms for each of them. " + "You can add more later too, including already existing ones.")} - onFinished={() => this.setState({ phase: Phase.Landing })} + onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })} />; case Phase.PrivateExistingRooms: return { return - + { room?.isSpaceRoom() + ? + : + } { room.name } onChange(e.target.checked) : null} @@ -58,14 +62,23 @@ const Entry = ({ room, checked, onChange }) => { interface IAddExistingToSpaceProps { space: Room; - selected: Set; - onChange(checked: boolean, room: Room): void; + footerPrompt?: ReactNode; + emptySelectionButton?: ReactNode; + onFinished(added: boolean): void; } -export const AddExistingToSpace: React.FC = ({ space, selected, onChange }) => { +export const AddExistingToSpace: React.FC = ({ + space, + footerPrompt, + emptySelectionButton, + onFinished, +}) => { const cli = useContext(MatrixClientContext); const visibleRooms = useMemo(() => sortRooms(cli.getVisibleRooms()), [cli]); + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); const [query, setQuery] = useState(""); const lcQuery = query.toLowerCase(); @@ -93,120 +106,6 @@ export const AddExistingToSpace: React.FC = ({ space, return arr; }, [[], [], []]); - return - - - { rooms.length > 0 ? ( - - { _t("Rooms") } - { rooms.map(room => { - return { - onChange(checked, room); - } : null} - />; - }) } - - ) : undefined } - - { spaces.length > 0 ? ( - - { _t("Spaces") } - - { _t("Feeling experimental?") } - { _t("You can add existing spaces to a space.") } - - { spaces.map(space => { - return { - onChange(checked, space); - } : null} - />; - }) } - - ) : null } - - { dms.length > 0 ? ( - - { _t("Direct Messages") } - { dms.map(room => { - return { - onChange(checked, room); - } : null} - />; - }) } - - ) : null } - - { spaces.length + rooms.length + dms.length < 1 ? - { _t("No results") } - : undefined } - - ; -}; - -const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { - const [selectedSpace, setSelectedSpace] = useState(space); - const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); - const [selectedToAdd, setSelectedToAdd] = useState(new Set()); - - const [progress, setProgress] = useState(null); - const [error, setError] = useState(null); - - let spaceOptionSection; - if (existingSubspaces.length > 0) { - const options = [space, ...existingSubspaces].map((space) => { - const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", { - mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace, - }); - return - - { space.name || getDisplayAliasForRoom(space) || space.roomId } - ; - }); - - spaceOptionSection = ( - { - setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space); - }} - value={selectedSpace.roomId} - label={_t("Space selection")} - > - { options } - - ); - } else { - spaceOptionSection = - { space.name || getDisplayAliasForRoom(space) || space.roomId } - ; - } - - const title = - - - { _t("Add existing rooms") } - { spaceOptionSection } - - ; - const addRooms = async () => { setError(null); setProgress(0); @@ -269,20 +168,145 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, ; } else { + let button = emptySelectionButton; + if (!button || selectedToAdd.size > 0) { + button = + { _t("Add") } + ; + } + footer = <> - { _t("Want to add a new room instead?") } - onCreateRoomClick(cli, space)} kind="link"> - { _t("Create a new room") } - + { footerPrompt } - - { _t("Add") } - + { button } >; } + const onChange = !busy && !error ? (checked, room) => { + if (checked) { + selectedToAdd.add(room); + } else { + selectedToAdd.delete(room); + } + setSelectedToAdd(new Set(selectedToAdd)); + } : null; + + return + + + { rooms.length > 0 ? ( + + { _t("Rooms") } + { rooms.map(room => { + return { + onChange(checked, room); + } : null} + />; + }) } + + ) : undefined } + + { spaces.length > 0 ? ( + + { _t("Spaces") } + + { _t("Feeling experimental?") } + { _t("You can add existing spaces to a space.") } + + { spaces.map(space => { + return { + onChange(checked, space); + } : null} + />; + }) } + + ) : null } + + { dms.length > 0 ? ( + + { _t("Direct Messages") } + { dms.map(room => { + return { + onChange(checked, room); + } : null} + />; + }) } + + ) : null } + + { spaces.length + rooms.length + dms.length < 1 ? + { _t("No results") } + : undefined } + + + + { footer } + + ; +}; + +const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { + const [selectedSpace, setSelectedSpace] = useState(space); + const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); + + let spaceOptionSection; + if (existingSubspaces.length > 0) { + const options = [space, ...existingSubspaces].map((space) => { + const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", { + mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace, + }); + return + + { space.name || getDisplayAliasForRoom(space) || space.roomId } + ; + }); + + spaceOptionSection = ( + { + setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space); + }} + value={selectedSpace.roomId} + label={_t("Space selection")} + > + { options } + + ); + } else { + spaceOptionSection = + { space.name || getDisplayAliasForRoom(space) || space.roomId } + ; + } + + const title = + + + { _t("Add existing rooms") } + { spaceOptionSection } + + ; + return = ({ matrixClient: cli, space, { - if (checked) { - selectedToAdd.add(room); - } else { - selectedToAdd.delete(room); - } - setSelectedToAdd(new Set(selectedToAdd)); - } : null} + onFinished={onFinished} + footerPrompt={<> + { _t("Want to add a new room instead?") } + onCreateRoomClick(cli, space)} kind="link"> + { _t("Create a new room") } + + >} /> - - { footer } - onFinished(false)} /> ; }; diff --git a/src/components/views/dialogs/UntrustedDeviceDialog.tsx b/src/components/views/dialogs/UntrustedDeviceDialog.tsx new file mode 100644 index 0000000000..da1492e30d --- /dev/null +++ b/src/components/views/dialogs/UntrustedDeviceDialog.tsx @@ -0,0 +1,73 @@ +/* +Copyright 2019, 2020, 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 { User } from "matrix-js-sdk/src/models/user"; + +import { _t } from "../../../languageHandler"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import E2EIcon from "../rooms/E2EIcon"; +import AccessibleButton from "../elements/AccessibleButton"; +import BaseDialog from "./BaseDialog"; +import { IDialogProps } from "./IDialogProps"; +import { IDevice } from "../right_panel/UserInfo"; + +interface IProps extends IDialogProps { + user: User; + device: IDevice; +} + +const UntrustedDeviceDialog: React.FC = ({device, user, onFinished}) => { + let askToVerifyText; + let newSessionText; + + if (MatrixClientPeg.get().getUserId() === user.userId) { + newSessionText = _t("You signed in to a new session without verifying it:"); + askToVerifyText = _t("Verify your other session using one of the options below."); + } else { + newSessionText = _t("%(name)s (%(userId)s) signed in to a new session without verifying it:", + {name: user.displayName, userId: user.userId}); + askToVerifyText = _t("Ask this user to verify their session, or manually verify it below."); + } + + return + + { _t("Not Trusted")} + >} + > + + {newSessionText} + {device.getDisplayName()} ({device.deviceId}) + {askToVerifyText} + + + onFinished("legacy")}> + { _t("Manually Verify by Text") } + + onFinished("sas")}> + { _t("Interactively verify by Emoji") } + + onFinished(false)}> + { _t("Done") } + + + ; +}; + +export default UntrustedDeviceDialog; diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index f736736acc..df73e1a8cb 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -207,6 +207,7 @@ export default class ImageView extends React.Component { a.href = this.props.src; a.download = this.props.name; a.target = "_blank"; + a.rel = "noreferrer noopener"; a.click(); }; @@ -442,16 +443,16 @@ export default class ImageView extends React.Component { {info} - - + + {zoomOutButton} {zoomInButton} { + const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); + + let contextMenu; + if (menuDisplayed) { + const buttonRect = button.current.getBoundingClientRect(); + contextMenu = + + ; } - constructor(props) { - super(props); + return + + + { contextMenu } + ; +}; + +interface IProps { + // The event we're displaying reactions for + mxEvent: MatrixEvent; + // The Relations model from the JS SDK for reactions to `mxEvent` + reactions?: Relations; +} + +interface IState { + myReactions: MatrixEvent[]; + showAll: boolean; +} + +@replaceableComponent("views.messages.ReactionsRow") +export default class ReactionsRow extends React.PureComponent { + static contextType = MatrixClientContext; + + constructor(props, context) { + super(props, context); if (props.reactions) { props.reactions.on("Relations.add", this.onReactionsChange); @@ -92,7 +131,7 @@ export default class ReactionsRow extends React.PureComponent { if (!reactions) { return null; } - const userId = MatrixClientPeg.get().getUserId(); + const userId = this.context.getUserId(); const myReactions = reactions.getAnnotationsBySender()[userId]; if (!myReactions) { return null; @@ -114,7 +153,6 @@ export default class ReactionsRow extends React.PureComponent { return null; } - const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton'); let items = reactions.getSortedAnnotationsByKey().map(([content, events]) => { const count = events.size; if (!count) { @@ -151,13 +189,21 @@ export default class ReactionsRow extends React.PureComponent { ; } + const cli = this.context; + + let addReactionButton; + if (cli.getRoom(mxEvent.getRoomId()).currentState.maySendEvent(EventType.Reaction, cli.getUserId())) { + addReactionButton = ; + } + return - {items} - {showAllButton} + { items } + { showAllButton } + { addReactionButton } ; } } diff --git a/src/components/views/messages/ReactionsRowButton.js b/src/components/views/messages/ReactionsRowButton.tsx similarity index 73% rename from src/components/views/messages/ReactionsRowButton.js rename to src/components/views/messages/ReactionsRowButton.tsx index b37a949e57..d163f1ad30 100644 --- a/src/components/views/messages/ReactionsRowButton.js +++ b/src/components/views/messages/ReactionsRowButton.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +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. @@ -14,49 +14,54 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; +import React from "react"; +import classNames from "classnames"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import dis from "../../../dispatcher/dispatcher"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ReactionsRowButtonTooltip from "./ReactionsRowButtonTooltip"; +import AccessibleButton from "../elements/AccessibleButton"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; + +interface IProps { + // The event we're displaying reactions for + mxEvent: MatrixEvent; + // The reaction content / key / emoji + content: string; + // The count of votes for this key + count: number; + // A Set of Matrix reaction events for this key + reactionEvents: Set; + // A possible Matrix event if the current user has voted for this type + myReactionEvent?: MatrixEvent; +} + +interface IState { + tooltipRendered: boolean; + tooltipVisible: boolean; +} @replaceableComponent("views.messages.ReactionsRowButton") -export default class ReactionsRowButton extends React.PureComponent { - static propTypes = { - // The event we're displaying reactions for - mxEvent: PropTypes.object.isRequired, - // The reaction content / key / emoji - content: PropTypes.string.isRequired, - // The count of votes for this key - count: PropTypes.number.isRequired, - // A Set of Martix reaction events for this key - reactionEvents: PropTypes.object.isRequired, - // A possible Matrix event if the current user has voted for this type - myReactionEvent: PropTypes.object, - } +export default class ReactionsRowButton extends React.PureComponent { + static contextType = MatrixClientContext; - constructor(props) { - super(props); + state = { + tooltipRendered: false, + tooltipVisible: false, + }; - this.state = { - tooltipVisible: false, - }; - } - - onClick = (ev) => { + onClick = () => { const { mxEvent, myReactionEvent, content } = this.props; if (myReactionEvent) { - MatrixClientPeg.get().redactEvent( + this.context.redactEvent( mxEvent.getRoomId(), myReactionEvent.getId(), ); } else { - MatrixClientPeg.get().sendEvent(mxEvent.getRoomId(), "m.reaction", { + this.context.sendEvent(mxEvent.getRoomId(), "m.reaction", { "m.relates_to": { "rel_type": "m.annotation", "event_id": mxEvent.getId(), @@ -83,8 +88,6 @@ export default class ReactionsRowButton extends React.PureComponent { } render() { - const ReactionsRowButtonTooltip = - sdk.getComponent('messages.ReactionsRowButtonTooltip'); const { mxEvent, content, count, reactionEvents, myReactionEvent } = this.props; const classes = classNames({ @@ -102,7 +105,7 @@ export default class ReactionsRowButton extends React.PureComponent { />; } - const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId()); + const room = this.context.getRoom(mxEvent.getRoomId()); let label; if (room) { const senders = []; @@ -130,7 +133,6 @@ export default class ReactionsRowButton extends React.PureComponent { ); } const isPeeking = room.getMyMembership() !== "join"; - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return ; + visible: boolean; +} @replaceableComponent("views.messages.ReactionsRowButtonTooltip") -export default class ReactionsRowButtonTooltip extends React.PureComponent { - static propTypes = { - // The event we're displaying reactions for - mxEvent: PropTypes.object.isRequired, - // The reaction content / key / emoji - content: PropTypes.string.isRequired, - // A Set of Martix reaction events for this key - reactionEvents: PropTypes.object.isRequired, - visible: PropTypes.bool.isRequired, - } +export default class ReactionsRowButtonTooltip extends React.PureComponent { + static contextType = MatrixClientContext; render() { - const Tooltip = sdk.getComponent('elements.Tooltip'); const { content, reactionEvents, mxEvent, visible } = this.props; - const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId()); + const room = this.context.getRoom(mxEvent.getRoomId()); let tooltipLabel; if (room) { const senders = []; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index faf8860cad..d5f67623a2 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -67,7 +67,7 @@ import RoomAvatar from "../avatars/RoomAvatar"; import RoomName from "../elements/RoomName"; import {mediaFromMxc} from "../../../customisations/Media"; -interface IDevice { +export interface IDevice { deviceId: string; ambiguous?: boolean; getDisplayName(): string; diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index c0b60a3247..897388d5d2 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -35,6 +35,7 @@ import {Action} from "../../../dispatcher/actions"; import CountlyAnalytics from "../../../CountlyAnalytics"; import {getKeyBindingsManager, MessageComposerAction} from '../../../KeyBindingsManager'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import SendHistoryManager from '../../../SendHistoryManager'; import Modal from '../../../Modal'; function _isReply(mxEvent) { @@ -122,6 +123,7 @@ export default class EditMessageComposer extends React.Component { saveDisabled: true, }; this._createEditorModel(); + window.addEventListener("beforeunload", this._saveStoredEditorState); } _setEditorRef = ref => { @@ -175,11 +177,55 @@ export default class EditMessageComposer extends React.Component { } } + get _editorRoomKey() { + return `mx_edit_room_${this._getRoom().roomId}`; + } + + get _editorStateKey() { + return `mx_edit_state_${this.props.editState.getEvent().getId()}`; + } + _cancelEdit = () => { + this._clearStoredEditorState(); dis.dispatch({action: "edit_event", event: null}); dis.fire(Action.FocusComposer); } + get _shouldSaveStoredEditorState() { + return localStorage.getItem(this._editorRoomKey) !== null; + } + + _restoreStoredEditorState(partCreator) { + const json = localStorage.getItem(this._editorStateKey); + if (json) { + try { + const {parts: serializedParts} = JSON.parse(json); + const parts = serializedParts.map(p => partCreator.deserializePart(p)); + return parts; + } catch (e) { + console.error("Error parsing editing state: ", e); + } + } + } + + _clearStoredEditorState() { + localStorage.removeItem(this._editorRoomKey); + localStorage.removeItem(this._editorStateKey); + } + + _clearPreviousEdit() { + if (localStorage.getItem(this._editorRoomKey)) { + localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this._editorRoomKey)}`); + } + } + + _saveStoredEditorState() { + const item = SendHistoryManager.createItem(this.model); + this._clearPreviousEdit(); + localStorage.setItem(this._editorRoomKey, this.props.editState.getEvent().getId()); + localStorage.setItem(this._editorStateKey, JSON.stringify(item)); + } + _isSlashCommand() { const parts = this.model.parts; const firstPart = parts[0]; @@ -266,6 +312,7 @@ export default class EditMessageComposer extends React.Component { const editedEvent = this.props.editState.getEvent(); const editContent = createEditContent(this.model, editedEvent); const newContent = editContent["m.new_content"]; + let shouldSend = true; // If content is modified then send an updated event into the room @@ -311,6 +358,7 @@ export default class EditMessageComposer extends React.Component { if (shouldSend) { this._cancelPreviousPendingEdit(); const prom = this.context.sendMessage(roomId, editContent); + this._clearStoredEditorState(); dis.dispatch({action: "message_sent"}); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); } @@ -346,6 +394,10 @@ export default class EditMessageComposer extends React.Component { // then when mounting the editor again with the same editor state, // it will set the cursor at the end. this.props.editState.setEditorState(caret, parts); + window.removeEventListener("beforeunload", this._saveStoredEditorState); + if (this._shouldSaveStoredEditorState) { + this._saveStoredEditorState(); + } } _createEditorModel() { @@ -358,10 +410,11 @@ export default class EditMessageComposer extends React.Component { // restore serialized parts from the state parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p)); } else { - // otherwise, parse the body of the event - parts = parseEvent(editState.getEvent(), partCreator); + //otherwise, either restore serialized parts from localStorage or parse the body of the event + parts = this._restoreStoredEditorState(partCreator) || parseEvent(editState.getEvent(), partCreator); } this.model = new EditorModel(parts, partCreator); + this._saveStoredEditorState(); } _getInitialCaretPosition() { diff --git a/src/components/views/rooms/RoomBreadcrumbs.tsx b/src/components/views/rooms/RoomBreadcrumbs.tsx index ea0ff233da..00f13209a2 100644 --- a/src/components/views/rooms/RoomBreadcrumbs.tsx +++ b/src/components/views/rooms/RoomBreadcrumbs.tsx @@ -23,11 +23,9 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import Analytics from "../../../Analytics"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { CSSTransition } from "react-transition-group"; -import RoomListStore from "../../../stores/room-list/RoomListStore"; -import { DefaultTagID } from "../../../stores/room-list/models"; import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; import Toolbar from "../../../accessibility/Toolbar"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { } @@ -84,8 +82,6 @@ export default class RoomBreadcrumbs extends React.PureComponent public render(): React.ReactElement { const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => { - const roomTags = RoomListStore.instance.getTagsForRoom(r); - const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0]; return ( diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index f856f7f6ef..6d3b50c10d 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -27,7 +27,6 @@ import SettingsStore from "../../../settings/SettingsStore"; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; import E2EIcon from './E2EIcon'; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; -import {DefaultTagID} from "../../../stores/room-list/models"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import RoomTopic from "../elements/RoomTopic"; import RoomName from "../elements/RoomName"; @@ -177,7 +176,6 @@ export default class RoomHeader extends React.Component { roomAvatar = ; diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 7303f36489..edd644fd65 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -576,7 +576,6 @@ export default class RoomTile extends React.PureComponent { const roomAvatar = ; diff --git a/src/components/views/settings/EventIndexPanel.tsx b/src/components/views/settings/EventIndexPanel.tsx index fa84063ee8..e693f45c5f 100644 --- a/src/components/views/settings/EventIndexPanel.tsx +++ b/src/components/views/settings/EventIndexPanel.tsx @@ -232,7 +232,7 @@ export default class EventIndexPanel extends React.Component<{}, IState> { {this.state.enabling ? - : _t("Message search initilisation failed") + : _t("Message search initialisation failed") } {EventIndexPeg.error && ( diff --git a/src/components/views/spaces/SpacePublicShare.tsx b/src/components/views/spaces/SpacePublicShare.tsx index fa81b75525..207360c2c2 100644 --- a/src/components/views/spaces/SpacePublicShare.tsx +++ b/src/components/views/spaces/SpacePublicShare.tsx @@ -23,6 +23,7 @@ import {copyPlaintext} from "../../../utils/strings"; import {sleep} from "../../../utils/promise"; import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"; import {showRoomInviteDialog} from "../../../RoomInvite"; +import {MatrixClientPeg} from "../../../MatrixClientPeg"; interface IProps { space: Room; @@ -50,7 +51,7 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => { { _t("Share invite link") } { copiedText } - { showRoomInviteDialog(space.roomId); @@ -59,7 +60,7 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => { > { _t("Invite people") } { _t("Invite with email or username") } - + : null } ; }; diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index f0a0605584..f34baf256b 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -209,7 +209,7 @@ export class SpaceItem extends React.PureComponent { const userId = this.context.getUserId(); let inviteOption; - if (this.props.space.canInvite(userId)) { + if (this.props.space.getJoinRule() === "public" || this.props.space.canInvite(userId)) { inviteOption = ( { - // Track percentages to very coarse precision, otherwise 0.002 ends up highlighting a bar. - const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(1)); + // Track percentages to a general precision to avoid over-waking the component. + const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(3)); this.setState({progress}); }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index dbe712c691..7e54c69128 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -578,14 +578,6 @@ "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", "Light": "Light", "Dark": "Dark", - "You signed in to a new session without verifying it:": "You signed in to a new session without verifying it:", - "Verify your other session using one of the options below.": "Verify your other session using one of the options below.", - "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) signed in to a new session without verifying it:", - "Ask this user to verify their session, or manually verify it below.": "Ask this user to verify their session, or manually verify it below.", - "Not Trusted": "Not Trusted", - "Manually Verify by Text": "Manually Verify by Text", - "Interactively verify by Emoji": "Interactively verify by Emoji", - "Done": "Done", "%(displayName)s is typing …": "%(displayName)s is typing …", "%(names)s and %(count)s others are typing …|other": "%(names)s and %(count)s others are typing …", "%(names)s and %(count)s others are typing …|one": "%(names)s and one other is typing …", @@ -1097,7 +1089,7 @@ "Securely cache encrypted messages locally for them to appear in search results.": "Securely cache encrypted messages locally for them to appear in search results.", "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.": "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.", "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.": "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.", - "Message search initilisation failed": "Message search initilisation failed", + "Message search initialisation failed": "Message search initialisation failed", "Connecting to integration manager...": "Connecting to integration manager...", "Cannot connect to integration manager": "Cannot connect to integration manager", "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", @@ -1864,6 +1856,7 @@ "You sent a verification request": "You sent a verification request", "Error decrypting video": "Error decrypting video", "Error processing voice message": "Error processing voice message", + "Add reaction": "Add reaction", "Show all": "Show all", "Reactions": "Reactions", " reacted with %(content)s": " reacted with %(content)s", @@ -1948,8 +1941,8 @@ "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", "Zoom out": "Zoom out", "Zoom in": "Zoom in", - "Rotate Right": "Rotate Right", "Rotate Left": "Rotate Left", + "Rotate Right": "Rotate Right", "Download": "Download", "Information": "Information", "View message": "View message", @@ -2040,15 +2033,15 @@ "Add a new server...": "Add a new server...", "%(networkName)s rooms": "%(networkName)s rooms", "Matrix rooms": "Matrix rooms", + "Not all selected were added": "Not all selected were added", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...", "Filter your rooms and spaces": "Filter your rooms and spaces", "Feeling experimental?": "Feeling experimental?", "You can add existing spaces to a space.": "You can add existing spaces to a space.", "Direct Messages": "Direct Messages", "Space selection": "Space selection", "Add existing rooms": "Add existing rooms", - "Not all selected were added": "Not all selected were added", - "Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)", - "Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...", "Want to add a new room instead?": "Want to add a new room instead?", "Create a new room": "Create a new room", "Matrix ID": "Matrix ID", @@ -2066,6 +2059,7 @@ "Close dialog": "Close dialog", "Beta feedback": "Beta feedback", "Thank you for your feedback, we really appreciate it.": "Thank you for your feedback, we really appreciate it.", + "Done": "Done", "%(featureName)s beta feedback": "%(featureName)s beta feedback", "Your platform and username will be noted to help us use your feedback as much as we can.": "Your platform and username will be noted to help us use your feedback as much as we can.", "To leave the beta, visit your settings.": "To leave the beta, visit your settings.", @@ -2389,6 +2383,13 @@ "Summary": "Summary", "Document": "Document", "Next": "Next", + "You signed in to a new session without verifying it:": "You signed in to a new session without verifying it:", + "Verify your other session using one of the options below.": "Verify your other session using one of the options below.", + "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) signed in to a new session without verifying it:", + "Ask this user to verify their session, or manually verify it below.": "Ask this user to verify their session, or manually verify it below.", + "Not Trusted": "Not Trusted", + "Manually Verify by Text": "Manually Verify by Text", + "Interactively verify by Emoji": "Interactively verify by Emoji", "Upload files (%(current)s of %(total)s)": "Upload files (%(current)s of %(total)s)", "Upload files": "Upload files", "Upload all": "Upload all", @@ -2703,13 +2704,12 @@ "Failed to create initial space rooms": "Failed to create initial space rooms", "Skip for now": "Skip for now", "Creating rooms...": "Creating rooms...", - "Failed to add rooms to space": "Failed to add rooms to space", - "Adding...": "Adding...", "What do you want to organise?": "What do you want to organise?", "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.", "Share %(name)s": "Share %(name)s", "It's just you at the moment, it will be even better with others.": "It's just you at the moment, it will be even better with others.", "Go to my first room": "Go to my first room", + "Go to my space": "Go to my space", "Who are you working with?": "Who are you working with?", "Make sure the right people have access to %(name)s": "Make sure the right people have access to %(name)s", "Just me": "Just me", diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js index 84a131f23a..feda257d8b 100644 --- a/src/linkify-matrix.js +++ b/src/linkify-matrix.js @@ -254,11 +254,15 @@ matrixLinkify.options = { target: function(href, type) { if (type === 'url') { - const transformed = tryTransformPermalinkToLocalHref(href); - if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) { - return null; - } else { - return '_blank'; + try { + const transformed = tryTransformPermalinkToLocalHref(href); + if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) { + return null; + } else { + return '_blank'; + } + } catch (e) { + // malformed URI } } return null; diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index 2d59bc7d02..1a78a1b485 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -16,14 +16,12 @@ limitations under the License. import SettingsStore from "../settings/SettingsStore"; import { Room } from "matrix-js-sdk/src/models/room"; -import { EventType } from "matrix-js-sdk/src/@types/event"; import { ActionPayload } from "../dispatcher/payloads"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import { arrayHasDiff } from "../utils/arrays"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { SettingLevel } from "../settings/SettingLevel"; -import { MatrixClientPeg } from '../MatrixClientPeg'; const MAX_ROOMS = 20; // arbitrary const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up @@ -61,30 +59,6 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { return this.matrixClient && this.matrixClient.getVisibleRooms().length >= 20; } - componentDidUpdate(prevProps, prevState) { - const prevRoomCount = (prevState.rooms?.length || 0); - const currentRoomCount = (this.state.rooms?.length || 0) - - // Only decrypting the breadcrumb rooms events on app initialisation - // when room count transitions from 0 to the number of rooms it contains - if (prevRoomCount === 0 && currentRoomCount > prevRoomCount) { - const client = MatrixClientPeg.get(); - // Rooms in the breadcrumb have a good chance to be interacted with - // again by a user. Decrypting the messages ahead of time will help - // reduce content shift on first render - this.state.rooms?.forEach(async room => { - const [cryptoEvent] = room.currentState.getStateEvents(EventType.RoomEncryption); - if (cryptoEvent) { - if (!client.isRoomEncrypted(room.roomId)) { - // XXX: Private member access - await client._crypto.onCryptoEvent(cryptoEvent); - } - room?.decryptAllEvents(); - } - }); - } - } - protected async onAction(payload: ActionPayload) { if (!this.matrixClient) return; diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 024c484c41..c50db43d08 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -34,6 +34,7 @@ import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { getListAlgorithmInstance } from "./list-ordering"; import SettingsStore from "../../../settings/SettingsStore"; import { VisibilityProvider } from "../filters/VisibilityProvider"; +import { MultiLock } from "../../../utils/MultiLock"; /** * Fired when the Algorithm has determined a list has been updated. @@ -77,6 +78,7 @@ export class Algorithm extends EventEmitter { } = {}; private allowedByFilter: Map = new Map(); private allowedRoomsByFilters: Set = new Set(); + private handlerLock = new MultiLock(); /** * Set to true to suspend emissions of algorithm updates. @@ -679,191 +681,204 @@ export class Algorithm extends EventEmitter { public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise { if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Handle room update for ${room.roomId} called with cause ${cause}`); + console.log(`Acquiring lock for ${room.roomId} with cause ${cause}`); } - if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from"); - - // Note: check the isSticky against the room ID just in case the reference is wrong - const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId; - if (cause === RoomUpdateCause.NewRoom) { - const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room; - const roomTags = this.roomIdsToTags[room.roomId]; - const hasTags = roomTags && roomTags.length > 0; - - // Don't change the cause if the last sticky room is being re-added. If we fail to - // pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus - // lose the room. - if (hasTags && !isForLastSticky) { - console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`); - cause = RoomUpdateCause.PossibleTagChange; + const release = await this.handlerLock.acquire(room.roomId); + try { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`Handle room update for ${room.roomId} called with cause ${cause}`); } + if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from"); - // Check to see if the room is known first - let knownRoomRef = this.rooms.includes(room); - if (hasTags && !knownRoomRef) { - console.warn(`${room.roomId} might be a reference change - attempting to update reference`); - this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r); - knownRoomRef = this.rooms.includes(room); - if (!knownRoomRef) { - console.warn(`${room.roomId} is still not referenced. It may be sticky.`); + // Note: check the isSticky against the room ID just in case the reference is wrong + const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId; + if (cause === RoomUpdateCause.NewRoom) { + const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room; + const roomTags = this.roomIdsToTags[room.roomId]; + const hasTags = roomTags && roomTags.length > 0; + + // Don't change the cause if the last sticky room is being re-added. If we fail to + // pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus + // lose the room. + if (hasTags && !isForLastSticky) { + console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`); + cause = RoomUpdateCause.PossibleTagChange; + } + + // Check to see if the room is known first + let knownRoomRef = this.rooms.includes(room); + if (hasTags && !knownRoomRef) { + console.warn(`${room.roomId} might be a reference change - attempting to update reference`); + this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r); + knownRoomRef = this.rooms.includes(room); + if (!knownRoomRef) { + console.warn(`${room.roomId} is still not referenced. It may be sticky.`); + } + } + + // If we have tags for a room and don't have the room referenced, something went horribly + // wrong - the reference should have been updated above. + if (hasTags && !knownRoomRef && !isSticky) { + throw new Error(`${room.roomId} is missing from room array but is known`); + } + + // Like above, update the reference to the sticky room if we need to + if (hasTags && isSticky) { + // Go directly in and set the sticky room's new reference, being careful not + // to trigger a sticky room update ourselves. + this._stickyRoom.room = room; + } + + // If after all that we're still a NewRoom update, add the room if applicable. + // We don't do this for the sticky room (because it causes duplication issues) + // or if we know about the reference (as it should be replaced). + if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) { + this.rooms.push(room); } } - // If we have tags for a room and don't have the room referenced, something went horribly - // wrong - the reference should have been updated above. - if (hasTags && !knownRoomRef && !isSticky) { - throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`); - } + let didTagChange = false; + if (cause === RoomUpdateCause.PossibleTagChange) { + const oldTags = this.roomIdsToTags[room.roomId] || []; + const newTags = this.getTagsForRoom(room); + const diff = arrayDiff(oldTags, newTags); + if (diff.removed.length > 0 || diff.added.length > 0) { + for (const rmTag of diff.removed) { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`Removing ${room.roomId} from ${rmTag}`); + } + const algorithm: OrderingAlgorithm = this.algorithms[rmTag]; + if (!algorithm) throw new Error(`No algorithm for ${rmTag}`); + await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved); + this._cachedRooms[rmTag] = algorithm.orderedRooms; + this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list + this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed + } + for (const addTag of diff.added) { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`Adding ${room.roomId} to ${addTag}`); + } + const algorithm: OrderingAlgorithm = this.algorithms[addTag]; + if (!algorithm) throw new Error(`No algorithm for ${addTag}`); + await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom); + this._cachedRooms[addTag] = algorithm.orderedRooms; + } - // Like above, update the reference to the sticky room if we need to - if (hasTags && isSticky) { - // Go directly in and set the sticky room's new reference, being careful not - // to trigger a sticky room update ourselves. - this._stickyRoom.room = room; - } + // Update the tag map so we don't regen it in a moment + this.roomIdsToTags[room.roomId] = newTags; - // If after all that we're still a NewRoom update, add the room if applicable. - // We don't do this for the sticky room (because it causes duplication issues) - // or if we know about the reference (as it should be replaced). - if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) { - this.rooms.push(room); - } - } - - let didTagChange = false; - if (cause === RoomUpdateCause.PossibleTagChange) { - const oldTags = this.roomIdsToTags[room.roomId] || []; - const newTags = this.getTagsForRoom(room); - const diff = arrayDiff(oldTags, newTags); - if (diff.removed.length > 0 || diff.added.length > 0) { - for (const rmTag of diff.removed) { if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Removing ${room.roomId} from ${rmTag}`); + console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`); } - const algorithm: OrderingAlgorithm = this.algorithms[rmTag]; - if (!algorithm) throw new Error(`No algorithm for ${rmTag}`); - await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved); - this._cachedRooms[rmTag] = algorithm.orderedRooms; - this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list - this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed - } - for (const addTag of diff.added) { - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Adding ${room.roomId} to ${addTag}`); - } - const algorithm: OrderingAlgorithm = this.algorithms[addTag]; - if (!algorithm) throw new Error(`No algorithm for ${addTag}`); - await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom); - this._cachedRooms[addTag] = algorithm.orderedRooms; - } - - // Update the tag map so we don't regen it in a moment - this.roomIdsToTags[room.roomId] = newTags; - - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`); - } - cause = RoomUpdateCause.Timeline; - didTagChange = true; - } else { - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`); - } - cause = RoomUpdateCause.Timeline; - } - - if (didTagChange && isSticky) { - // Manually update the tag for the sticky room without triggering a sticky room - // update. The update will be handled implicitly by the sticky room handling and - // requires no changes on our part, if we're in the middle of a sticky room change. - if (this._lastStickyRoom) { - this._stickyRoom = { - room, - tag: this.roomIdsToTags[room.roomId][0], - position: 0, // right at the top as it changed tags - }; + cause = RoomUpdateCause.Timeline; + didTagChange = true; } else { - // We have to clear the lock as the sticky room change will trigger updates. - await this.setStickyRoom(room); + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`); + } + cause = RoomUpdateCause.Timeline; + } + + if (didTagChange && isSticky) { + // Manually update the tag for the sticky room without triggering a sticky room + // update. The update will be handled implicitly by the sticky room handling and + // requires no changes on our part, if we're in the middle of a sticky room change. + if (this._lastStickyRoom) { + this._stickyRoom = { + room, + tag: this.roomIdsToTags[room.roomId][0], + position: 0, // right at the top as it changed tags + }; + } else { + // We have to clear the lock as the sticky room change will trigger updates. + await this.setStickyRoom(room); + } } } - } - // If the update is for a room change which might be the sticky room, prevent it. We - // need to make sure that the causes (NewRoom and RoomRemoved) are still triggered though - // as the sticky room relies on this. - if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) { - if (this.stickyRoom === room) { - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`); + // If the update is for a room change which might be the sticky room, prevent it. We + // need to make sure that the causes (NewRoom and RoomRemoved) are still triggered though + // as the sticky room relies on this. + if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) { + if (this.stickyRoom === room) { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.warn( + `[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`, + ); + } + return false; } - return false; } - } - if (!this.roomIdsToTags[room.roomId]) { - if (CAUSES_REQUIRING_ROOM.includes(cause)) { + if (!this.roomIdsToTags[room.roomId]) { + if (CAUSES_REQUIRING_ROOM.includes(cause)) { + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`); + } + return false; + } + if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`); + console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`); + } + + // Get the tags for the room and populate the cache + const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t])); + + // "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(), + // which means we should *always* have a tag to go off of. + if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`); + + this.roomIdsToTags[room.roomId] = roomTags; + + if (SettingsStore.getValue("advancedRoomListLogging")) { + // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 + console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags); } - return false; } if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`); + console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`); } - // Get the tags for the room and populate the cache - const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t])); + const tags = this.roomIdsToTags[room.roomId]; + if (!tags) { + console.warn(`No tags known for "${room.name}" (${room.roomId})`); + return false; + } - // "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(), - // which means we should *always* have a tag to go off of. - if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`); + let changed = didTagChange; + for (const tag of tags) { + const algorithm: OrderingAlgorithm = this.algorithms[tag]; + if (!algorithm) throw new Error(`No algorithm for ${tag}`); - this.roomIdsToTags[room.roomId] = roomTags; + await algorithm.handleRoomUpdate(room, cause); + this._cachedRooms[tag] = algorithm.orderedRooms; + + // Flag that we've done something + this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list + this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed + changed = true; + } if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags); + console.log( + `[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`, + ); } + return changed; + } finally { + release(); } - - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`); - } - - const tags = this.roomIdsToTags[room.roomId]; - if (!tags) { - console.warn(`No tags known for "${room.name}" (${room.roomId})`); - return false; - } - - let changed = didTagChange; - for (const tag of tags) { - const algorithm: OrderingAlgorithm = this.algorithms[tag]; - if (!algorithm) throw new Error(`No algorithm for ${tag}`); - - await algorithm.handleRoomUpdate(room, cause); - this._cachedRooms[tag] = algorithm.orderedRooms; - - // Flag that we've done something - this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list - this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed - changed = true; - } - - if (SettingsStore.getValue("advancedRoomListLogging")) { - // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 - console.log(`[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`); - } - return changed; } } diff --git a/src/utils/MultiLock.ts b/src/utils/MultiLock.ts new file mode 100644 index 0000000000..507a924dda --- /dev/null +++ b/src/utils/MultiLock.ts @@ -0,0 +1,30 @@ +/* +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 { EnhancedMap } from "./maps"; +import AwaitLock from "await-lock"; + +export type DoneFn = () => void; + +export class MultiLock { + private locks = new EnhancedMap(); + + public async acquire(key: string): Promise { + const lock = this.locks.getOrCreate(key, new AwaitLock()); + await lock.acquireAsync(); + return () => lock.release(); + } +} diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 56bce5b2da..e527f43c29 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -75,7 +75,8 @@ export function arraySmoothingResample(input: number[], points: number): number[ for (let i = 1; i < input.length - 1; i += 2) { const prevPoint = input[i - 1]; const nextPoint = input[i + 1]; - const average = (prevPoint + nextPoint) / 2; + const currPoint = input[i]; + const average = (prevPoint + nextPoint + currPoint) / 3; samples.push(average); } input = samples; diff --git a/src/utils/permalinks/Permalinks.ts b/src/utils/permalinks/Permalinks.ts index 015ecca22e..d87c826cc2 100644 --- a/src/utils/permalinks/Permalinks.ts +++ b/src/utils/permalinks/Permalinks.ts @@ -346,9 +346,14 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string { return permalink; } - const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN); - if (m) { - return m[1]; + try { + const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN); + if (m) { + return m[1]; + } + } catch (e) { + // Not a valid URI + return permalink; } // A bit of a hack to convert permalinks of unknown origin to Element links diff --git a/src/verification.js b/src/verification.ts similarity index 62% rename from src/verification.js rename to src/verification.ts index 74e3897d3a..acd9f6d2b2 100644 --- a/src/verification.js +++ b/src/verification.ts @@ -1,5 +1,5 @@ /* -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020, 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. @@ -14,16 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { User } from "matrix-js-sdk/src/models/user"; + +import { MatrixClientPeg } from './MatrixClientPeg'; import dis from "./dispatcher/dispatcher"; import Modal from './Modal'; import * as sdk from './index'; -import { _t } from './languageHandler'; -import {RightPanelPhases} from "./stores/RightPanelStorePhases"; -import {findDMForUser} from './createRoom'; -import {accessSecretStorage} from './SecurityManager'; -import {verificationMethods} from 'matrix-js-sdk/src/crypto'; -import {Action} from './dispatcher/actions'; +import { RightPanelPhases } from "./stores/RightPanelStorePhases"; +import { findDMForUser } from './createRoom'; +import { accessSecretStorage } from './SecurityManager'; +import { verificationMethods } from 'matrix-js-sdk/src/crypto'; +import { Action } from './dispatcher/actions'; +import UntrustedDeviceDialog from "./components/views/dialogs/UntrustedDeviceDialog"; +import {IDevice} from "./components/views/right_panel/UserInfo"; async function enable4SIfNeeded() { const cli = MatrixClientPeg.get(); @@ -39,40 +42,7 @@ async function enable4SIfNeeded() { return true; } -function UntrustedDeviceDialog(props) { - const {device, user, onFinished} = props; - const BaseDialog = sdk.getComponent("dialogs.BaseDialog"); - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); - let askToVerifyText; - let newSessionText; - - if (MatrixClientPeg.get().getUserId() === user.userId) { - newSessionText = _t("You signed in to a new session without verifying it:"); - askToVerifyText = _t("Verify your other session using one of the options below."); - } else { - newSessionText = _t("%(name)s (%(userId)s) signed in to a new session without verifying it:", - {name: user.displayName, userId: user.userId}); - askToVerifyText = _t("Ask this user to verify their session, or manually verify it below."); - } - - return - - {newSessionText} - {device.getDisplayName()} ({device.deviceId}) - {askToVerifyText} - - - onFinished("legacy")}>{_t("Manually Verify by Text")} - onFinished("sas")}>{_t("Interactively verify by Emoji")} - onFinished()}>{_t("Done")} - - ; -} - -export async function verifyDevice(user, device) { +export async function verifyDevice(user: User, device: IDevice) { const cli = MatrixClientPeg.get(); if (cli.isGuest()) { dis.dispatch({action: 'require_registration'}); @@ -115,7 +85,7 @@ export async function verifyDevice(user, device) { }); } -export async function legacyVerifyUser(user) { +export async function legacyVerifyUser(user: User) { const cli = MatrixClientPeg.get(); if (cli.isGuest()) { dis.dispatch({action: 'require_registration'}); @@ -135,7 +105,7 @@ export async function legacyVerifyUser(user) { }); } -export async function verifyUser(user) { +export async function verifyUser(user: User) { const cli = MatrixClientPeg.get(); if (cli.isGuest()) { dis.dispatch({action: 'require_registration'}); @@ -155,7 +125,7 @@ export async function verifyUser(user) { }); } -export function pendingVerificationRequestForUser(user) { +export function pendingVerificationRequestForUser(user: User) { const cli = MatrixClientPeg.get(); const dmRoom = findDMForUser(cli, user.userId); if (dmRoom) { diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts index 5488ed6b84..61da435151 100644 --- a/src/voice/Playback.ts +++ b/src/voice/Playback.ts @@ -15,12 +15,13 @@ limitations under the License. */ import EventEmitter from "events"; -import {UPDATE_EVENT} from "../stores/AsyncStore"; -import {arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample} from "../utils/arrays"; -import {SimpleObservable} from "matrix-widget-api"; -import {IDestroyable} from "../utils/IDestroyable"; -import {PlaybackClock} from "./PlaybackClock"; -import {createAudioContext, decodeOgg} from "./compat"; +import { UPDATE_EVENT } from "../stores/AsyncStore"; +import { arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample } from "../utils/arrays"; +import { SimpleObservable } from "matrix-widget-api"; +import { IDestroyable } from "../utils/IDestroyable"; +import { PlaybackClock } from "./PlaybackClock"; +import { createAudioContext, decodeOgg } from "./compat"; +import { clamp } from "../utils/numbers"; export enum PlaybackState { Decoding = "decoding", @@ -33,9 +34,20 @@ export const PLAYBACK_WAVEFORM_SAMPLES = 39; const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); function makePlaybackWaveform(input: number[]): number[] { - // We use a smoothing resample to keep the rough shape of the waveform the user will be seeing. We - // then rescale so the user can see the waveform properly (loud noises == 100%). - return arrayRescale(arraySmoothingResample(input, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); + // First, convert negative amplitudes to positive so we don't detect zero as "noisy". + const noiseWaveform = input.map(v => Math.abs(v)); + + // Next, we'll resample the waveform using a smoothing approach so we can keep the same rough shape. + // We also rescale the waveform to be 0-1 for the remaining function logic. + const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); + + // Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled + // waveform. Most speech happens below the 0.5 mark. + const filtered = resampled.map(v => clamp(v, 0.1, 0.5)); + + // Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something + // sensible. This is what we return to keep our contract of "values between zero and one". + return arrayRescale(filtered, 0, 1); } export class Playback extends EventEmitter implements IDestroyable { @@ -126,6 +138,7 @@ export class Playback extends EventEmitter implements IDestroyable { this.waveformObservable.update(this.resampledWaveform); this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore + this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update this.clock.durationSeconds = this.audioBuf.duration; } diff --git a/src/voice/PlaybackClock.ts b/src/voice/PlaybackClock.ts index 06d6381691..d6d36e861f 100644 --- a/src/voice/PlaybackClock.ts +++ b/src/voice/PlaybackClock.ts @@ -54,6 +54,15 @@ export class PlaybackClock implements IDestroyable { } }; + /** + * Mark the time in the audio context where the clip starts/has been loaded. + * This is to ensure the clock isn't skewed into thinking it is ~0.5s into + * a clip when the duration is set. + */ + public flagLoadTime() { + this.clipStart = this.context.currentTime; + } + public flagStart() { if (this.stopped) { this.clipStart = this.context.currentTime; diff --git a/test/utils/arrays-test.ts b/test/utils/arrays-test.ts index b55de3b73b..5974915965 100644 --- a/test/utils/arrays-test.ts +++ b/test/utils/arrays-test.ts @@ -73,10 +73,10 @@ describe('arrays', () => { // we'd be feeding a thousand values in and seeing what a curve of 250 values looks like, // but that's not really feasible to manually verify accuracy. [ - {input: [2, 2, 0, 2, 2, 0, 2, 2, 0], output: [1, 1, 2, 1]}, // Odd -> Even - {input: [2, 2, 0, 2, 2, 0, 2, 2, 0], output: [1, 1, 2]}, // Odd -> Odd - {input: [2, 2, 0, 2, 2, 0, 2, 2], output: [1, 1, 2]}, // Even -> Odd - {input: [2, 2, 0, 2, 2, 0, 2, 2], output: [1, 2]}, // Even -> Even + {input: [4, 4, 1, 4, 4, 1, 4, 4, 1], output: [3, 3, 3, 3]}, // Odd -> Even + {input: [4, 4, 1, 4, 4, 1, 4, 4, 1], output: [3, 3, 3]}, // Odd -> Odd + {input: [4, 4, 1, 4, 4, 1, 4, 4], output: [3, 3, 3]}, // Even -> Odd + {input: [4, 4, 1, 4, 4, 1, 4, 4], output: [3, 3]}, // Even -> Even ].forEach((c, i) => expectSample(i, c.input, c.output, true)); });
{newSessionText}
{device.getDisplayName()} ({device.deviceId})
{askToVerifyText}
{this.state.enabling ? - : _t("Message search initilisation failed") + : _t("Message search initialisation failed") }