diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 86fa7adb4a8..85e2547c654 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -67,6 +67,8 @@ import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { haveRendererForEvent } from "../../events/EventTileFactory"; +import { isReply, textForReplyEvent } from "../../utils/exportUtils/exportUtils"; +import { textForEvent } from "../../TextForEvent"; // These pagination sizes are higher than they may possibly need be // once https://github.com/matrix-org/matrix-spec-proposals/pull/3874 lands @@ -333,6 +335,8 @@ class TimelinePanel extends React.Component { } public componentDidMount(): void { + document.addEventListener("copy", this.formatCopy); + if (this.props.manageReadReceipts) { this.updateReadReceiptOnUserActivity(); } @@ -410,6 +414,8 @@ class TimelinePanel extends React.Component { client.removeListener(ClientEvent.Sync, this.onSync); this.props.timelineSet.room?.removeListener(ThreadEvent.Update, this.onThreadUpdate); } + + document.removeEventListener("copy", this.formatCopy); } /** @@ -2004,6 +2010,73 @@ class TimelinePanel extends React.Component { return null; } + private createFormattedCopyText = (events: MatrixEvent[]): string => { + let content = ""; + const client = MatrixClientPeg.safeGet(); + + for (let i = 0; i < events.length; i++) { + const mxEv = events[i]; + if (!mxEv || !haveRendererForEvent(mxEv, client, false)) 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, client); + content += text && `${new Date(mxEv.getTs()).toLocaleString()} - ${text}\n`; + } + return content; + }; + + private getClosestEvent = (el: HTMLElement, fromTop: boolean): string => { + 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")) { + el = el.closest("li"); + if (fromTop) requiredElement = el.nextElementSibling; + else requiredElement = el.previousElementSibling; + } else { + 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 + 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): void => { + const range = window.getSelection(); + let anchorEl = range.anchorNode.parentElement; + let focusEl = range.focusNode.parentElement; + 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) { + // 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.createFormattedCopyText(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 575a0e34e9c..636d098bb67 100644 --- a/src/utils/exportUtils/Exporter.ts +++ b/src/utils/exportUtils/Exporter.ts @@ -282,14 +282,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 4c4471340f5..10b2fc03ff3 100644 --- a/src/utils/exportUtils/PlainTextExport.ts +++ b/src/utils/exportUtils/PlainTextExport.ts @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Room, IContent, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { Room, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import React from "react"; import Exporter from "./Exporter"; import { _t } from "../../languageHandler"; -import { ExportType, IExportOptions } from "./exportUtils"; +import { ExportType, IExportOptions, isReply, textForReplyEvent } from "./exportUtils"; import { textForEvent } from "../../TextForEvent"; import { haveRendererForEvent } from "../../events/EventTileFactory"; import SettingsStore from "../../settings/SettingsStore"; @@ -47,39 +47,6 @@ export default class PlainTextExporter extends Exporter { return this.makeFileNameNoExtension() + ".txt"; } - public textForReplyEvent = (content: IContent): string => { - 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): Promise => { const senderDisplayName = mxEv.sender && mxEv.sender.name ? mxEv.sender.name : mxEv.getSender(); let mediaText = ""; @@ -104,7 +71,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, this.room.client) + mediaText; }; diff --git a/src/utils/exportUtils/exportUtils.ts b/src/utils/exportUtils/exportUtils.ts index 7fc7fe8007e..a6b5faa87c1 100644 --- a/src/utils/exportUtils/exportUtils.ts +++ b/src/utils/exportUtils/exportUtils.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { IContent, MatrixEvent } from "matrix-js-sdk/src/matrix"; + import { _t } from "../../languageHandler"; export enum ExportFormat { @@ -61,6 +63,47 @@ export const textForType = (type: ExportType): string => { } }; +export const textForReplyEvent = (content: IContent): string => { + 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 8565c42ef6d..3968ca518b5 100644 --- a/test/utils/export-test.tsx +++ b/test/utils/export-test.tsx @@ -26,7 +26,7 @@ import { } from "matrix-js-sdk/src/matrix"; 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 PlainTextExporter from "../../src/utils/exportUtils/PlainTextExport"; import HTMLExporter from "../../src/utils/exportUtils/HtmlExport"; import * as TestUtilsMatrix from "../test-utils"; @@ -333,9 +333,8 @@ describe("export", function () { expectedText: '<@me:here "This"> Reply', }, ]; - const exporter = new PlainTextExporter(mockRoom, ExportType.Beginning, mockExportOptions, setProgressText); for (const content of eventContents) { - expect(exporter.textForReplyEvent(content)).toBe(content.expectedText); + expect(textForReplyEvent(content)).toBe(content.expectedText); } });