diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index 3a480a2579..7e5e0afb79 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -25,6 +25,7 @@ import { _t } from '../../../../languageHandler'; import { accessSecretStorage } from '../../../../CrossSigningManager'; import SettingsStore from '../../../../settings/SettingsStore'; import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; +import {copyNode} from "../../../../utils/strings"; const PHASE_PASSPHRASE = 0; const PHASE_PASSPHRASE_CONFIRM = 1; @@ -37,16 +38,6 @@ const PHASE_OPTOUT_CONFIRM = 6; const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms. -// XXX: copied from ShareDialog: factor out into utils -function selectText(target) { - const range = document.createRange(); - range.selectNodeContents(target); - - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); -} - /* * Walks the user through the process of creating an e2e key backup * on the server. @@ -101,8 +92,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { } _onCopyClick = () => { - selectText(this._recoveryKeyNode); - const successful = document.execCommand('copy'); + const successful = copyNode(this._recoveryKeyNode); if (successful) { this.setState({ copied: true, diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js index d63db617d5..cfad49c38d 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -24,6 +24,7 @@ import FileSaver from 'file-saver'; import { _t } from '../../../../languageHandler'; import Modal from '../../../../Modal'; import { promptForBackupPassphrase } from '../../../../CrossSigningManager'; +import {copyNode} from "../../../../utils/strings"; const PHASE_LOADING = 0; const PHASE_MIGRATE = 1; @@ -38,16 +39,6 @@ const PHASE_CONFIRM_SKIP = 8; const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms. -// XXX: copied from ShareDialog: factor out into utils -function selectText(target) { - const range = document.createRange(); - range.selectNodeContents(target); - - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); -} - /* * Walks the user through the process of creating a passphrase to guard Secure * Secret Storage in account data. @@ -169,8 +160,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _onCopyClick = () => { - selectText(this._recoveryKeyNode); - const successful = document.execCommand('copy'); + const successful = copyNode(this._recoveryKeyNode); if (successful) { this.setState({ copied: true, diff --git a/src/components/views/dialogs/ShareDialog.js b/src/components/views/dialogs/ShareDialog.tsx similarity index 77% rename from src/components/views/dialogs/ShareDialog.js rename to src/components/views/dialogs/ShareDialog.tsx index 1bc9decd39..375cb65b5f 100644 --- a/src/components/views/dialogs/ShareDialog.js +++ b/src/components/views/dialogs/ShareDialog.tsx @@ -1,5 +1,6 @@ /* Copyright 2018 New Vector Ltd +Copyright 2020 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,15 +15,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; -import PropTypes from 'prop-types'; -import {Room, User, Group, RoomMember, MatrixEvent} from 'matrix-js-sdk'; +import * as React from 'react'; +import * as PropTypes from 'prop-types'; +import {Room} from "matrix-js-sdk/src/models/room"; +import {User} from "matrix-js-sdk/src/models/user"; +import {Group} from "matrix-js-sdk/src/models/group"; +import {RoomMember} from "matrix-js-sdk/src/models/room-member"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import QRCode from 'qrcode-react'; import {RoomPermalinkCreator, makeGroupPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks"; import * as ContextMenu from "../../structures/ContextMenu"; import {toRightOf} from "../../structures/ContextMenu"; +import {copyPlaintext, selectText} from "../../../utils/strings"; const socials = [ { @@ -52,7 +58,18 @@ const socials = [ }, ]; -export default class ShareDialog extends React.Component { +interface IProps { + onFinished: () => void; + target: Room | User | Group | RoomMember | MatrixEvent; + permalinkCreator: RoomPermalinkCreator; +} + +interface IState { + linkSpecificEvent: boolean; + permalinkCreator: RoomPermalinkCreator; +} + +export default class ShareDialog extends React.PureComponent { static propTypes = { onFinished: PropTypes.func.isRequired, target: PropTypes.oneOfType([ @@ -64,6 +81,8 @@ export default class ShareDialog extends React.Component { ]).isRequired, }; + protected closeCopiedTooltip: () => void; + constructor(props) { super(props); @@ -81,45 +100,26 @@ export default class ShareDialog extends React.Component { linkSpecificEvent: this.props.target instanceof MatrixEvent, permalinkCreator, }; - - this._link = createRef(); - } - - static _selectText(target) { - const range = document.createRange(); - range.selectNodeContents(target); - - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); } static onLinkClick(e) { e.preventDefault(); - const {target} = e; - ShareDialog._selectText(target); + selectText(e.target); } - onCopyClick(e) { + async onCopyClick(e) { e.preventDefault(); + const target = e.target; // copy target before we go async and React throws it away - ShareDialog._selectText(this._link.current); - - let successful; - try { - successful = document.execCommand('copy'); - } catch (err) { - console.error('Failed to copy: ', err); - } - - const buttonRect = e.target.getBoundingClientRect(); + const successful = await copyPlaintext(this.getUrl()); + const buttonRect = target.getBoundingClientRect(); const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); const {close} = ContextMenu.createMenu(GenericTextContextMenu, { ...toRightOf(buttonRect, 2), message: successful ? _t('Copied!') : _t('Failed to copy'), }); // Drop a reference to this close handler for componentWillUnmount - this.closeCopiedTooltip = e.target.onmouseleave = close; + this.closeCopiedTooltip = target.onmouseleave = close; } onLinkSpecificEventCheckboxClick() { @@ -134,10 +134,32 @@ export default class ShareDialog extends React.Component { if (this.closeCopiedTooltip) this.closeCopiedTooltip(); } - render() { - let title; + getUrl() { let matrixToUrl; + if (this.props.target instanceof Room) { + if (this.state.linkSpecificEvent) { + const events = this.props.target.getLiveTimeline().getEvents(); + matrixToUrl = this.state.permalinkCreator.forEvent(events[events.length - 1].getId()); + } else { + matrixToUrl = this.state.permalinkCreator.forRoom(); + } + } else if (this.props.target instanceof User || this.props.target instanceof RoomMember) { + matrixToUrl = makeUserPermalink(this.props.target.userId); + } else if (this.props.target instanceof Group) { + matrixToUrl = makeGroupPermalink(this.props.target.groupId); + } else if (this.props.target instanceof MatrixEvent) { + if (this.state.linkSpecificEvent) { + matrixToUrl = this.props.permalinkCreator.forEvent(this.props.target.getId()); + } else { + matrixToUrl = this.props.permalinkCreator.forRoom(); + } + } + return matrixToUrl; + } + + render() { + let title; let checkbox; if (this.props.target instanceof Room) { @@ -155,18 +177,10 @@ export default class ShareDialog extends React.Component { ; } - - if (this.state.linkSpecificEvent) { - matrixToUrl = this.state.permalinkCreator.forEvent(events[events.length - 1].getId()); - } else { - matrixToUrl = this.state.permalinkCreator.forRoom(); - } } else if (this.props.target instanceof User || this.props.target instanceof RoomMember) { title = _t('Share User'); - matrixToUrl = makeUserPermalink(this.props.target.userId); } else if (this.props.target instanceof Group) { title = _t('Share Community'); - matrixToUrl = makeGroupPermalink(this.props.target.groupId); } else if (this.props.target instanceof MatrixEvent) { title = _t('Share Room Message'); checkbox =
@@ -178,14 +192,9 @@ export default class ShareDialog extends React.Component { { _t('Link to selected message') }
; - - if (this.state.linkSpecificEvent) { - matrixToUrl = this.props.permalinkCreator.forEvent(this.props.target.getId()); - } else { - matrixToUrl = this.props.permalinkCreator.forRoom(); - } } + const matrixToUrl = this.getUrl(); const encodedUrl = encodeURIComponent(matrixToUrl); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); @@ -196,8 +205,7 @@ export default class ShareDialog extends React.Component { >
- @@ -216,17 +224,18 @@ export default class ShareDialog extends React.Component {
- { - socials.map((social) => ( + {social.name} - ) - } + + )) }
diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 27514d0e23..882e331675 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -34,6 +34,7 @@ import {pillifyLinks, unmountPills} from '../../../utils/pillify'; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import {isPermalinkHost} from "../../../utils/permalinks/Permalinks"; import {toRightOf} from "../../structures/ContextMenu"; +import {copyPlaintext} from "../../../utils/strings"; export default createReactClass({ displayName: 'TextualBody', @@ -69,23 +70,6 @@ export default createReactClass({ }; }, - copyToClipboard: function(text) { - const textArea = document.createElement("textarea"); - textArea.value = text; - document.body.appendChild(textArea); - textArea.select(); - - let successful = false; - try { - successful = document.execCommand('copy'); - } catch (err) { - console.log('Unable to copy'); - } - - document.body.removeChild(textArea); - return successful; - }, - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs UNSAFE_componentWillMount: function() { this._content = createRef(); @@ -277,17 +261,17 @@ export default createReactClass({ Array.from(ReactDOM.findDOMNode(this).querySelectorAll('.mx_EventTile_body pre')).forEach((p) => { const button = document.createElement("span"); button.className = "mx_EventTile_copyButton"; - button.onclick = (e) => { + button.onclick = async () => { const copyCode = button.parentNode.getElementsByTagName("pre")[0]; - const successful = this.copyToClipboard(copyCode.textContent); + const successful = await copyPlaintext(copyCode.textContent); - const buttonRect = e.target.getBoundingClientRect(); + const buttonRect = button.getBoundingClientRect(); const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); const {close} = ContextMenu.createMenu(GenericTextContextMenu, { ...toRightOf(buttonRect, 2), message: successful ? _t('Copied!') : _t('Failed to copy'), }); - e.target.onmouseleave = close; + button.onmouseleave = close; }; // Wrap a div around
 so that the copy button can be correctly positioned
diff --git a/src/utils/strings.ts b/src/utils/strings.ts
new file mode 100644
index 0000000000..5856682445
--- /dev/null
+++ b/src/utils/strings.ts
@@ -0,0 +1,75 @@
+/*
+Copyright 2020 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.
+*/
+
+/**
+ * Copy plaintext to user's clipboard
+ * It will overwrite user's selection range
+ * In certain browsers it may only work if triggered by a user action or may ask user for permissions
+ * Tries to use new async clipboard API if available
+ * @param text the plaintext to put in the user's clipboard
+ */
+export async function copyPlaintext(text: string): Promise {
+    try {
+        if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
+            await navigator.clipboard.writeText(text);
+            return true;
+        } else {
+            const textArea = document.createElement("textarea");
+            textArea.value = text;
+
+            // Avoid scrolling to bottom
+            textArea.style.top = "0";
+            textArea.style.left = "0";
+            textArea.style.position = "fixed";
+
+            document.body.appendChild(textArea);
+            const selection = document.getSelection();
+            const range = document.createRange();
+            // range.selectNodeContents(textArea);
+            range.selectNode(textArea);
+            selection.removeAllRanges();
+            selection.addRange(range);
+
+            const successful = document.execCommand("copy");
+            selection.removeAllRanges();
+            document.body.removeChild(textArea);
+            return successful;
+        }
+    } catch (e) {
+        console.error("copyPlaintext failed", e);
+    }
+    return false;
+}
+
+export function selectText(target: Element) {
+    const range = document.createRange();
+    range.selectNodeContents(target);
+
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    selection.addRange(range);
+}
+
+/**
+ * Copy rich text to user's clipboard
+ * It will overwrite user's selection range
+ * In certain browsers it may only work if triggered by a user action or may ask user for permissions
+ * @param ref pointer to the node to copy
+ */
+export function copyNode(ref: Element): boolean {
+    selectText(ref);
+    return document.execCommand('copy');
+}