From 2da3fb322d9e714c62a736b1e578207e57452c40 Mon Sep 17 00:00:00 2001 From: Jaiwanth Date: Wed, 24 Nov 2021 16:45:59 +0530 Subject: [PATCH 1/4] Copy formatted text to clipboard on selecting chat --- src/components/structures/TimelinePanel.tsx | 70 +++++++++++++++++++++ src/utils/exportUtils/Exporter.ts | 9 --- src/utils/exportUtils/PlainTextExport.ts | 39 +----------- src/utils/exportUtils/exportUtils.ts | 42 +++++++++++++ test/utils/export-test.tsx | 5 +- 5 files changed, 117 insertions(+), 48 deletions(-) diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index aa5fda2f286..162c9a6f858 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -50,6 +50,8 @@ import ErrorDialog from '../views/dialogs/ErrorDialog'; import { debounce } from 'lodash'; import { logger } from "matrix-js-sdk/src/logger"; +import { isReply, textForReplyEvent } from '../../utils/exportUtils/exportUtils'; +import { textForEvent } from '../../TextForEvent'; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -282,6 +284,7 @@ class TimelinePanel extends React.Component { cli.on("Event.decrypted", this.onEventDecrypted); cli.on("Event.replaced", this.onEventReplaced); cli.on("sync", this.onSync); + document.addEventListener("copy", this.formatCopy); } // TODO: [REACT-WARNING] Move into constructor @@ -354,6 +357,7 @@ class TimelinePanel extends React.Component { client.removeListener("Event.replaced", this.onEventReplaced); client.removeListener("sync", this.onSync); } + document.removeEventListener("copy", this.formatCopy); } private onMessageListUnfillRequest = (backwards: boolean, scrollToken: string): void => { @@ -1394,6 +1398,72 @@ class TimelinePanel extends React.Component { return null; } + private createFormattedText = (events: MatrixEvent[]) => { + let content = ""; + for (let i = 0; i < events.length; i++) { + const mxEv = events[i]; + if (!mxEv || !haveTileForEvent(mxEv)) continue; + const senderDisplayName = mxEv.sender && mxEv.sender.name ? mxEv.sender.name : mxEv.getSender(); + let text = ""; + if (isReply(mxEv)) text = senderDisplayName + ": " + textForReplyEvent(mxEv.getContent()); + else text = textForEvent(mxEv); + content += text && `${new Date(mxEv.getTs()).toLocaleString()} - ${text}\n`; + } + return content; + }; + + private getClosestEvent = (el: HTMLElement, fromTop: boolean): string => { + const messageList = document.querySelector("ol.mx_RoomView_MessageList"); + let requiredElement: Element; + // if the selected element belongs to a date separator, assign its neighbouring element as the required element + if (el.parentElement.classList.contains("mx_DateSeparator")) { + while (el.parentElement != messageList) el = el.parentElement; + if (fromTop) requiredElement = el.nextElementSibling; + else requiredElement = el.previousElementSibling; + } else { + while (!el.getAttribute("data-scroll-tokens")) el = el.parentElement; + requiredElement = el; + } + // if the element is a part of EventListSummary, and we're selecting from the top + // return the first event else return the last event of the list + if (requiredElement.classList.contains("mx_EventListSummary")) { + const eventsList = requiredElement.getAttribute("data-scroll-tokens").split(','); + return fromTop ? eventsList[0] : eventsList[eventsList.length - 1]; + } + return requiredElement.getAttribute("data-scroll-tokens"); + }; + + private formatCopy = (e: ClipboardEvent) => { + const range = window.getSelection(); + let anchorEl = range.anchorNode.parentElement; + let focusEl = range.focusNode.parentElement; + const messageList = document.querySelector("ol.mx_RoomView_MessageList"); + // if both the elements are not inside messageList, then let the default behaviour continue + if (!messageList.contains(anchorEl) || !messageList.contains(focusEl)) return; + if (focusEl.getBoundingClientRect().top > anchorEl.getBoundingClientRect().top) { + // make anchorEl to be always at the bottom + [focusEl, anchorEl] = [anchorEl, focusEl]; + } + // get closest eventIds to the bottom and top selected elements + const closestTopEvent = this.getClosestEvent(focusEl, true); + const closestBottomEvent = this.getClosestEvent(anchorEl, false); + // if both the eventIds are same, then the user is copying with in a single event. So, no need of any processing + if (closestTopEvent === closestBottomEvent) return; + + e.preventDefault(); + + const events = this.getEvents().events; + const filteredEvents = []; + + const closestTopEventIdx = events.findIndex(ev => ev.getId() === closestTopEvent); + + for (let i = closestTopEventIdx; i < events.length; i++) { + filteredEvents.push(events[i]); + if (events[i] && events[i].getId() === closestBottomEvent) break; + } + e.clipboardData.setData("text/plain", this.createFormattedText(filteredEvents)); + }; + /** * Get the id of the event corresponding to our user's latest read-receipt. * diff --git a/src/utils/exportUtils/Exporter.ts b/src/utils/exportUtils/Exporter.ts index 7861e5ce9a3..a6f877d6bdb 100644 --- a/src/utils/exportUtils/Exporter.ts +++ b/src/utils/exportUtils/Exporter.ts @@ -252,15 +252,6 @@ export default abstract class Exporter { return fileDirectory + "/" + fileName + '-' + fileDate + fileExt; } - - protected isReply(event: MatrixEvent): boolean { - const isEncrypted = event.isEncrypted(); - // If encrypted, in_reply_to lies in event.event.content - const content = isEncrypted ? event.event.content : event.getContent(); - const relatesTo = content["m.relates_to"]; - return !!(relatesTo && relatesTo["m.in_reply_to"]); - } - protected isAttachment(mxEv: MatrixEvent): boolean { const attachmentTypes = ["m.sticker", "m.image", "m.file", "m.video", "m.audio"]; return mxEv.getType() === attachmentTypes[0] || attachmentTypes.includes(mxEv.getContent().msgtype); diff --git a/src/utils/exportUtils/PlainTextExport.ts b/src/utils/exportUtils/PlainTextExport.ts index e3201314f95..31f37df5c63 100644 --- a/src/utils/exportUtils/PlainTextExport.ts +++ b/src/utils/exportUtils/PlainTextExport.ts @@ -16,11 +16,11 @@ limitations under the License. import Exporter from "./Exporter"; import { Room } from "matrix-js-sdk/src/models/room"; -import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { formatFullDateNoDay } from "../../DateUtils"; import { _t } from "../../languageHandler"; import { haveTileForEvent } from "../../components/views/rooms/EventTile"; -import { ExportType } from "./exportUtils"; +import { ExportType, isReply, textForReplyEvent } from "./exportUtils"; import { IExportOptions } from "./exportUtils"; import { textForEvent } from "../../TextForEvent"; @@ -43,39 +43,6 @@ export default class PlainTextExporter extends Exporter { : _t("Media omitted - file size limit exceeded"); } - public textForReplyEvent = (content: IContent) => { - const REPLY_REGEX = /> <(.*?)>(.*?)\n\n(.*)/s; - const REPLY_SOURCE_MAX_LENGTH = 32; - - const match = REPLY_REGEX.exec(content.body); - - // if the reply format is invalid, then return the body - if (!match) return content.body; - - let rplSource: string; - const rplName = match[1]; - const rplText = match[3]; - - rplSource = match[2].substring(1); - // Get the first non-blank line from the source. - const lines = rplSource.split('\n').filter((line) => !/^\s*$/.test(line)); - if (lines.length > 0) { - // Cut to a maximum length. - rplSource = lines[0].substring(0, REPLY_SOURCE_MAX_LENGTH); - // Ellipsis if needed. - if (lines[0].length > REPLY_SOURCE_MAX_LENGTH) { - rplSource = rplSource + "..."; - } - // Wrap in formatting - rplSource = ` "${rplSource}"`; - } else { - // Don't show a source because we couldn't format one. - rplSource = ""; - } - - return `<${rplName}${rplSource}> ${rplText}`; - }; - protected plainTextForEvent = async (mxEv: MatrixEvent) => { const senderDisplayName = mxEv.sender && mxEv.sender.name ? mxEv.sender.name : mxEv.getSender(); let mediaText = ""; @@ -100,7 +67,7 @@ export default class PlainTextExporter extends Exporter { } } else mediaText = ` (${this.mediaOmitText})`; } - if (this.isReply(mxEv)) return senderDisplayName + ": " + this.textForReplyEvent(mxEv.getContent()) + mediaText; + if (isReply(mxEv)) return senderDisplayName + ": " + textForReplyEvent(mxEv.getContent()) + mediaText; else return textForEvent(mxEv) + mediaText; }; diff --git a/src/utils/exportUtils/exportUtils.ts b/src/utils/exportUtils/exportUtils.ts index 139e3a37407..02f1c554ee6 100644 --- a/src/utils/exportUtils/exportUtils.ts +++ b/src/utils/exportUtils/exportUtils.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { IContent, MatrixEvent } from "matrix-js-sdk"; import { _t } from "../../languageHandler"; export enum ExportFormat { @@ -57,6 +58,47 @@ export const textForType = (type: ExportType): string => { } }; +export const textForReplyEvent = (content: IContent) => { + const REPLY_REGEX = /> <(.*?)>(.*?)\n\n(.*)/s; + const REPLY_SOURCE_MAX_LENGTH = 32; + + const match = REPLY_REGEX.exec(content.body); + + // if the reply format is invalid, then return the body + if (!match) return content.body; + + let rplSource: string; + const rplName = match[1]; + const rplText = match[3]; + + rplSource = match[2].substring(1); + // Get the first non-blank line from the source. + const lines = rplSource.split('\n').filter((line) => !/^\s*$/.test(line)); + if (lines.length > 0) { + // Cut to a maximum length. + rplSource = lines[0].substring(0, REPLY_SOURCE_MAX_LENGTH); + // Ellipsis if needed. + if (lines[0].length > REPLY_SOURCE_MAX_LENGTH) { + rplSource = rplSource + "..."; + } + // Wrap in formatting + rplSource = ` "${rplSource}"`; + } else { + // Don't show a source because we couldn't format one. + rplSource = ""; + } + + return `<${rplName}${rplSource}> ${rplText}`; +}; + +export const isReply = (event: MatrixEvent): boolean => { + const isEncrypted = event.isEncrypted(); + // If encrypted, in_reply_to lies in event.event.content + const content = isEncrypted ? event.event.content : event.getContent(); + const relatesTo = content["m.relates_to"]; + return !!(relatesTo && relatesTo["m.in_reply_to"]); +}; + export interface IExportOptions { // startDate?: number; numberOfMessages?: number; diff --git a/test/utils/export-test.tsx b/test/utils/export-test.tsx index 66436da9c5b..a2a75d23c76 100644 --- a/test/utils/export-test.tsx +++ b/test/utils/export-test.tsx @@ -16,7 +16,7 @@ limitations under the License. import { IContent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; -import { IExportOptions, ExportType, ExportFormat } from "../../src/utils/exportUtils/exportUtils"; +import { IExportOptions, ExportType, ExportFormat, textForReplyEvent } from "../../src/utils/exportUtils/exportUtils"; import '../skinned-sdk'; import PlainTextExporter from "../../src/utils/exportUtils/PlainTextExport"; import HTMLExporter from "../../src/utils/exportUtils/HtmlExport"; @@ -258,9 +258,8 @@ describe('export', function() { "expectedText": "<@me:here \"This\"> Reply", }, ]; - const exporter = new PlainTextExporter(mockRoom, ExportType.Beginning, mockExportOptions, null); for (const content of eventContents) { - expect(exporter.textForReplyEvent(content)).toBe(content.expectedText); + expect(textForReplyEvent(content)).toBe(content.expectedText); } }); From 96cf6f718d4e8cc58d0d0cb258c6f4d0df1a540a Mon Sep 17 00:00:00 2001 From: Jaiwanth Date: Fri, 26 Nov 2021 12:12:47 +0530 Subject: [PATCH 2/4] fix typecheck test --- src/utils/exportUtils/exportUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/exportUtils/exportUtils.ts b/src/utils/exportUtils/exportUtils.ts index 02f1c554ee6..b6d38d18371 100644 --- a/src/utils/exportUtils/exportUtils.ts +++ b/src/utils/exportUtils/exportUtils.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IContent, MatrixEvent } from "matrix-js-sdk"; +import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { _t } from "../../languageHandler"; export enum ExportFormat { From 1cb5284e69f51dc08b1319a554cd9f4f18e2ec2b Mon Sep 17 00:00:00 2001 From: Jaiwanth Date: Fri, 26 Nov 2021 17:15:33 +0530 Subject: [PATCH 3/4] apply pr suggestions --- src/components/structures/TimelinePanel.tsx | 9 +++++---- src/utils/exportUtils/exportUtils.ts | 8 ++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 162c9a6f858..e38dcd56955 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -1413,7 +1413,8 @@ class TimelinePanel extends React.Component { }; private getClosestEvent = (el: HTMLElement, fromTop: boolean): string => { - const messageList = document.querySelector("ol.mx_RoomView_MessageList"); + const timelinePanel = ReactDOM.findDOMNode(this) as Element; + const messageList = timelinePanel.querySelector(".mx_RoomView_MessageList"); let requiredElement: Element; // if the selected element belongs to a date separator, assign its neighbouring element as the required element if (el.parentElement.classList.contains("mx_DateSeparator")) { @@ -1421,8 +1422,7 @@ class TimelinePanel extends React.Component { if (fromTop) requiredElement = el.nextElementSibling; else requiredElement = el.previousElementSibling; } else { - while (!el.getAttribute("data-scroll-tokens")) el = el.parentElement; - requiredElement = el; + requiredElement = el.closest("[data-scroll-tokens]"); } // if the element is a part of EventListSummary, and we're selecting from the top // return the first event else return the last event of the list @@ -1437,7 +1437,8 @@ class TimelinePanel extends React.Component { const range = window.getSelection(); let anchorEl = range.anchorNode.parentElement; let focusEl = range.focusNode.parentElement; - const messageList = document.querySelector("ol.mx_RoomView_MessageList"); + const timelinePanel = ReactDOM.findDOMNode(this) as Element; + const messageList = timelinePanel.querySelector(".mx_RoomView_MessageList"); // if both the elements are not inside messageList, then let the default behaviour continue if (!messageList.contains(anchorEl) || !messageList.contains(focusEl)) return; if (focusEl.getBoundingClientRect().top > anchorEl.getBoundingClientRect().top) { diff --git a/src/utils/exportUtils/exportUtils.ts b/src/utils/exportUtils/exportUtils.ts index b6d38d18371..a807490513f 100644 --- a/src/utils/exportUtils/exportUtils.ts +++ b/src/utils/exportUtils/exportUtils.ts @@ -79,7 +79,7 @@ export const textForReplyEvent = (content: IContent) => { rplSource = lines[0].substring(0, REPLY_SOURCE_MAX_LENGTH); // Ellipsis if needed. if (lines[0].length > REPLY_SOURCE_MAX_LENGTH) { - rplSource = rplSource + "..."; + rplSource = rplSource + "…"; } // Wrap in formatting rplSource = ` "${rplSource}"`; @@ -92,11 +92,7 @@ export const textForReplyEvent = (content: IContent) => { }; export const isReply = (event: MatrixEvent): boolean => { - const isEncrypted = event.isEncrypted(); - // If encrypted, in_reply_to lies in event.event.content - const content = isEncrypted ? event.event.content : event.getContent(); - const relatesTo = content["m.relates_to"]; - return !!(relatesTo && relatesTo["m.in_reply_to"]); + return !!event.replyEventId; }; export interface IExportOptions { From 6f895cbdfbff10efeceecf7def6066477e4eeca7 Mon Sep 17 00:00:00 2001 From: Jaiwanth Date: Fri, 26 Nov 2021 17:41:24 +0530 Subject: [PATCH 4/4] fix test and refactor --- src/components/structures/TimelinePanel.tsx | 4 +--- test/utils/export-test.tsx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index e38dcd56955..2520ae45224 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -1413,12 +1413,10 @@ class TimelinePanel extends React.Component { }; private getClosestEvent = (el: HTMLElement, fromTop: boolean): string => { - const timelinePanel = ReactDOM.findDOMNode(this) as Element; - const messageList = timelinePanel.querySelector(".mx_RoomView_MessageList"); let requiredElement: Element; // if the selected element belongs to a date separator, assign its neighbouring element as the required element if (el.parentElement.classList.contains("mx_DateSeparator")) { - while (el.parentElement != messageList) el = el.parentElement; + el = el.closest("li"); if (fromTop) requiredElement = el.nextElementSibling; else requiredElement = el.previousElementSibling; } else { diff --git a/test/utils/export-test.tsx b/test/utils/export-test.tsx index a2a75d23c76..b5f9f3c1898 100644 --- a/test/utils/export-test.tsx +++ b/test/utils/export-test.tsx @@ -250,7 +250,7 @@ describe('export', function() { { "msgtype": "m.text", "body": "> <@me:here> The source is more than 32 characters\n\nReply", - "expectedText": "<@me:here \"The source is more than 32 chara...\"> Reply", + "expectedText": "<@me:here \"The source is more than 32 chara…\"> Reply", }, { "msgtype": "m.text",