Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Copy formatted text to clipboard on selecting chat #7192

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/components/structures/TimelinePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -333,6 +335,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
}

public componentDidMount(): void {
document.addEventListener("copy", this.formatCopy);

if (this.props.manageReadReceipts) {
this.updateReadReceiptOnUserActivity();
}
Expand Down Expand Up @@ -410,6 +414,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
client.removeListener(ClientEvent.Sync, this.onSync);
this.props.timelineSet.room?.removeListener(ThreadEvent.Update, this.onThreadUpdate);
}

document.removeEventListener("copy", this.formatCopy);
}

/**
Expand Down Expand Up @@ -2004,6 +2010,73 @@ class TimelinePanel extends React.Component<IProps, IState> {
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.
*
Expand Down
8 changes: 0 additions & 8 deletions src/utils/exportUtils/Exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!);
Expand Down
39 changes: 3 additions & 36 deletions src/utils/exportUtils/PlainTextExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string> => {
const senderDisplayName = mxEv.sender && mxEv.sender.name ? mxEv.sender.name : mxEv.getSender();
let mediaText = "";
Expand All @@ -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;
};

Expand Down
43 changes: 43 additions & 0 deletions src/utils/exportUtils/exportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 + "...";
jaiwanth-v marked this conversation as resolved.
Show resolved Hide resolved
}
// 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;
Expand Down
5 changes: 2 additions & 3 deletions test/utils/export-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
});

Expand Down