From 89bc3bdd5bb1fb0668f76981b05f8712de33b89d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Apr 2020 00:16:11 +0100 Subject: [PATCH 1/3] consolidate and extract copyPlaintext, copyNode and selectText Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../keybackup/CreateKeyBackupDialog.js | 14 +--- .../CreateSecretStorageDialog.js | 14 +--- src/components/views/dialogs/ShareDialog.js | 76 ++++++++----------- src/components/views/messages/TextualBody.js | 26 ++----- src/utils/strings.ts | 75 ++++++++++++++++++ 5 files changed, 117 insertions(+), 88 deletions(-) create mode 100644 src/utils/strings.ts 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.js index 1bc9decd39..ebd1f8d1eb 100644 --- a/src/components/views/dialogs/ShareDialog.js +++ b/src/components/views/dialogs/ShareDialog.js @@ -23,6 +23,7 @@ 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 = [ { @@ -81,45 +82,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 +116,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 +159,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 +174,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 +187,7 @@ export default class ShareDialog extends React.Component { >
- 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..7d1fa0049d
--- /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(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');
+}

From 276b5b874c1a8e7c4fa826c7fba0507fcc9babbe Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 15 Apr 2020 00:22:19 +0100
Subject: [PATCH 2/3] Convert ShareDialog to Typescript

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
 .../{ShareDialog.js => ShareDialog.tsx}       | 45 +++++++++++++------
 1 file changed, 32 insertions(+), 13 deletions(-)
 rename src/components/views/dialogs/{ShareDialog.js => ShareDialog.tsx} (86%)

diff --git a/src/components/views/dialogs/ShareDialog.js b/src/components/views/dialogs/ShareDialog.tsx
similarity index 86%
rename from src/components/views/dialogs/ShareDialog.js
rename to src/components/views/dialogs/ShareDialog.tsx
index ebd1f8d1eb..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,9 +15,13 @@ 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';
@@ -53,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([
@@ -65,6 +81,8 @@ export default class ShareDialog extends React.Component {
         ]).isRequired,
     };
 
+    protected closeCopiedTooltip: () => void;
+
     constructor(props) {
         super(props);
 
@@ -206,17 +224,18 @@ export default class ShareDialog extends React.Component {
                         
                     
- { - socials.map((social) => ( + {social.name} - ) - } + + )) }
From a37ecbbb34ef83ef6f38bae2895a95c325401cfe Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Apr 2020 19:24:33 +0100 Subject: [PATCH 3/3] update console.error --- src/utils/strings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/strings.ts b/src/utils/strings.ts index 7d1fa0049d..5856682445 100644 --- a/src/utils/strings.ts +++ b/src/utils/strings.ts @@ -49,7 +49,7 @@ export async function copyPlaintext(text: string): Promise { return successful; } } catch (e) { - console.error(e); + console.error("copyPlaintext failed", e); } return false; }