diff --git a/res/css/views/dialogs/_ExportDialog.scss b/res/css/views/dialogs/_ExportDialog.scss index b4bc2b84b2..f3e418354c 100644 --- a/res/css/views/dialogs/_ExportDialog.scss +++ b/res/css/views/dialogs/_ExportDialog.scss @@ -35,7 +35,7 @@ limitations under the License. } .mx_RadioButton input[type="radio"]:checked + div > div { - background: gray; + background: $greyed-fg-color; } .mx_RadioButton input[type=radio]:checked + div { @@ -52,8 +52,8 @@ limitations under the License. } .mx_Checkbox input[type="checkbox"]:checked + label > .mx_Checkbox_background { - background: gray; - border-color: gray; + background: $greyed-fg-color; + border-color: $greyed-fg-color; } } diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 7845091c48..5f4ae4a702 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -312,7 +312,10 @@ function textForMessageEvent(ev: MatrixEvent): () => string | null { message = _t('%(senderDisplayName)s sent an image.', { senderDisplayName }); } else if (ev.getType() == "m.sticker") { message = _t('%(senderDisplayName)s sent a sticker.', { senderDisplayName }); - } else message = senderDisplayName + ': ' + message; + } else { + // in this case, parse it as a plain text message + message = senderDisplayName + ': ' + message; + } return message; }; } diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 9554a50684..933cd58880 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -36,7 +36,6 @@ interface IProps extends Omit, "name" | // Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser` viewUserOnClick?: boolean; title?: string; - forExport?: boolean; } interface IState { @@ -90,8 +89,7 @@ export default class MemberAvatar extends React.Component { } render() { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let { member, fallbackUserId, onClick, viewUserOnClick, forExport, ...otherProps } = this.props; + let { member, fallbackUserId, onClick, viewUserOnClick, ...otherProps } = this.props; const userId = member ? member.userId : fallbackUserId; if (viewUserOnClick) { diff --git a/src/components/views/dialogs/ExportDialog.tsx b/src/components/views/dialogs/ExportDialog.tsx index d2f1da861a..f474bcdb70 100644 --- a/src/components/views/dialogs/ExportDialog.tsx +++ b/src/components/views/dialogs/ExportDialog.tsx @@ -29,13 +29,15 @@ import { textForFormat, textForType, } from "../../../utils/exportUtils/exportUtils"; -import { IFieldState, IValidationResult } from "../elements/Validation"; +import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; import HTMLExporter from "../../../utils/exportUtils/HtmlExport"; import JSONExporter from "../../../utils/exportUtils/JSONExport"; import PlainTextExporter from "../../../utils/exportUtils/PlainTextExport"; import { useStateCallback } from "../../../hooks/useStateCallback"; import Exporter from "../../../utils/exportUtils/Exporter"; import Spinner from "../elements/Spinner"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; interface IProps extends IDialogProps { room: Room; @@ -126,67 +128,85 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { await startExport(); }; - const onValidateSize = async ({ - value, - }: Pick): Promise => { - const parsedSize = parseFloat(value); - const min = 1; - const max = 2000; + const validateSize = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => { + const min = 1; + const max = 10 ** 8; + return _t("Enter a number between %(min)s and %(max)s", { + min, + max, + }); + }, + }, { + key: "number", + test: ({ value }) => { + const parsedSize = parseFloat(value); + const min = 1; + const max = 2000; + return !(isNaN(parsedSize) || min > parsedSize || parsedSize > max); + }, + invalid: () => { + const min = 1; + const max = 2000; + return _t( + "Size can only be a number between %(min)s MB and %(max)s MB", + { min, max }, + ); + }, + }, + ], + }); - if (isNaN(parsedSize)) { - return { valid: false, feedback: _t("Size must be a number") }; - } - - if (min > parsedSize || parsedSize > max) { - return { - valid: false, - feedback: _t( - "Size can only be between %(min)s MB and %(max)s MB", - { min, max }, - ), - }; - } - - return { - valid: true, - feedback: _t("Enter size between %(min)s MB and %(max)s MB", { - min, - max, - }), - }; + const onValidateSize = async (fieldState: IFieldState): Promise => { + const result = await validateSize(fieldState); + return result; }; - const onValidateNumberOfMessages = async ({ - value, - }: Pick): Promise => { - const parsedSize = parseFloat(value); - const min = 1; - const max = 10 ** 8; + const validateNumberOfMessages = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => { + const min = 1; + const max = 10 ** 8; + return _t("Enter a number between %(min)s and %(max)s", { + min, + max, + }); + }, + }, { + key: "number", + test: ({ value }) => { + const parsedSize = parseFloat(value); + const min = 1; + const max = 10 ** 8; + if (isNaN(parsedSize)) return false; + return !(min > parsedSize || parsedSize > max); + }, + invalid: () => { + const min = 1; + const max = 10 ** 8; + return _t( + "Number of messages can only be a number between %(min)s and %(max)s", + { min, max }, + ); + }, + }, + ], + }); - if (isNaN(parsedSize)) { - return { - valid: false, - feedback: _t("Number of messages must be a number"), - }; - } - - if (min > parsedSize || parsedSize > max) { - return { - valid: false, - feedback: _t( - "Number of messages can only be between %(min)s and %(max)s", - { min, max }, - ), - }; - } - - return { - valid: true, - feedback: _t("Enter a number between %(min)s and %(max)s", { - min, - max, - }), - }; + const onValidateNumberOfMessages = async (fieldState: IFieldState): Promise => { + const result = await validateNumberOfMessages(fieldState); + return result; }; const onCancel = async () => { @@ -236,42 +256,20 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { if (exportCancelled) { // Display successful cancellation message - return ( - -

{ _t("The export was cancelled successfully") }

- - -
- ); + Modal.createTrackedDialog("Export Cancelled", "", InfoDialog, { + title: _t("Export Cancelled"), + description:

{ _t("The export was cancelled successfully") }

, + hasCloseButton: true, + }); + return null; } else if (exportSuccessful) { // Display successful export message - return ( - -

{ _t("Your messages were successfully exported") }

- - -
- ); + Modal.createTrackedDialog("Export Successful", "", InfoDialog, { + title: _t("Export Successful"), + description:

{ _t("Your messages were successfully exported") }

, + hasCloseButton: true, + }); + return null; } else if (displayCancel) { // Display cancel warning return ( diff --git a/src/components/views/elements/ReplyThread.tsx b/src/components/views/elements/ReplyThread.tsx index 2144cc602c..86330c1dbc 100644 --- a/src/components/views/elements/ReplyThread.tsx +++ b/src/components/views/elements/ReplyThread.tsx @@ -355,10 +355,10 @@ export default class ReplyThread extends React.Component { } else if (this.props.forExport) { const eventId = ReplyThread.getParentEventId(this.props.parentEv); header =

- { _t("In reply to ", + { _t("In reply to this message", {}, - { messageLink: () => ( - { _t("this message") } + { a: (sub) => ( + { sub } ), }) } diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index 0a32e7f637..bc868c35b3 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -47,7 +47,7 @@ export default class CallEvent extends React.Component { super(props); this.state = { - callState: this.props.callEventGrouper?.state, + callState: this.props.callEventGrouper.state, silenced: false, }; } @@ -210,7 +210,7 @@ export default class CallEvent extends React.Component { render() { const event = this.props.mxEvent; const sender = event.sender ? event.sender.name : event.getSender(); - const isVoice = this.props.callEventGrouper?.isVoice; + const isVoice = this.props.callEventGrouper.isVoice; const callType = isVoice ? _t("Voice call") : _t("Video call"); const callState = this.state.callState; const hangupReason = this.props.callEventGrouper.hangupReason; diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx index 1f079cd1e2..3675b14295 100644 --- a/src/components/views/messages/MFileBody.tsx +++ b/src/components/views/messages/MFileBody.tsx @@ -201,11 +201,7 @@ export default class MFileBody extends React.Component { if (this.props.showGenericPlaceholder) { placeholder = ( - - { this.props.forExport ? - Attachment - : null } - + { presentableTextForFile(this.content, _t("Attachment"), true, true) } diff --git a/src/components/views/messages/RedactedBody.tsx b/src/components/views/messages/RedactedBody.tsx index 65933cb801..4ec528fdf3 100644 --- a/src/components/views/messages/RedactedBody.tsx +++ b/src/components/views/messages/RedactedBody.tsx @@ -39,7 +39,6 @@ const RedactedBody = React.forwardRef(({ mxEvent, forExport }, return ( - { forExport ? {_t("Redacted")} : null } { text } ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 81f8375000..f05025ea32 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -725,6 +725,7 @@ "Invite to %(spaceName)s": "Invite to %(spaceName)s", "Share your public space": "Share your public space", "Unknown App": "Unknown App", + "Are you sure you want to exit during this export?": "Are you sure you want to exit during this export?", "HTML": "HTML", "JSON": "JSON", "Plain Text": "Plain Text", @@ -1972,7 +1973,6 @@ " reacted with %(content)s": " reacted with %(content)s", "reacted with %(shortName)s": "reacted with %(shortName)s", "Message deleted on %(date)s": "Message deleted on %(date)s", - "Redacted": "Redacted", "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s", "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.", "%(senderDisplayName)s changed the room avatar to ": "%(senderDisplayName)s changed the room avatar to ", @@ -2122,8 +2122,7 @@ "QR Code": "QR Code", "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.", "In reply to ": "In reply to ", - "In reply to ": "In reply to ", - "this message": "this message", + "In reply to this message": "In reply to this message", "Room address": "Room address", "e.g. my-room": "e.g. my-room", "Some characters not allowed": "Some characters not allowed", @@ -2329,16 +2328,13 @@ "There was an error updating your community. The server is unable to process your request.": "There was an error updating your community. The server is unable to process your request.", "Update community": "Update community", "An error has occurred.": "An error has occurred.", - "Size can only be between %(min)s MB and %(max)s MB": "Size can only be between %(min)s MB and %(max)s MB", - "Enter size between %(min)s MB and %(max)s MB": "Enter size between %(min)s MB and %(max)s MB", - "Number of messages must be a number": "Number of messages must be a number", - "Number of messages can only be between %(min)s and %(max)s": "Number of messages can only be between %(min)s and %(max)s", "Enter a number between %(min)s and %(max)s": "Enter a number between %(min)s and %(max)s", + "Size can only be a number between %(min)s MB and %(max)s MB": "Size can only be a number between %(min)s MB and %(max)s MB", + "Number of messages can only be a number between %(min)s and %(max)s": "Number of messages can only be a number between %(min)s and %(max)s", "Number of messages": "Number of messages", "MB": "MB", "Export Cancelled": "Export Cancelled", "The export was cancelled successfully": "The export was cancelled successfully", - "Okay": "Okay", "Export Successful": "Export Successful", "Your messages were successfully exported": "Your messages were successfully exported", "Are you sure you want to stop exporting your data? If you do, you'll need to start over.": "Are you sure you want to stop exporting your data? If you do, you'll need to start over.", diff --git a/src/utils/exportUtils/Exporter.ts b/src/utils/exportUtils/Exporter.ts index 8c419ce657..0151836792 100644 --- a/src/utils/exportUtils/Exporter.ts +++ b/src/utils/exportUtils/Exporter.ts @@ -25,6 +25,7 @@ import { Direction, MatrixClient } from "matrix-js-sdk"; import { MutableRefObject } from "react"; import JSZip from "jszip"; import { saveAs } from "file-saver"; +import { _t } from "../../languageHandler"; type BlobFile = { name: string; @@ -54,7 +55,7 @@ export default abstract class Exporter { protected onBeforeUnload(e: BeforeUnloadEvent): string { e.preventDefault(); - return e.returnValue = "Are you sure you want to exit during this export?"; + return e.returnValue = _t("Are you sure you want to exit during this export?"); } protected updateProgress(progress: string, log = true, show = true): void { @@ -70,7 +71,7 @@ export default abstract class Exporter { this.files.push(file); } - protected async downloadZIP(): Promise { + protected async downloadZIP(): Promise { const filename = `matrix-export-${formatFullDateNoDay(new Date())}.zip`; const zip = new JSZip(); diff --git a/src/utils/exportUtils/HtmlExport.tsx b/src/utils/exportUtils/HtmlExport.tsx index 6cdbae6c48..3608cff1f2 100644 --- a/src/utils/exportUtils/HtmlExport.tsx +++ b/src/utils/exportUtils/HtmlExport.tsx @@ -31,7 +31,6 @@ import EventTile, { haveTileForEvent } from "../../components/views/rooms/EventT import DateSeparator from "../../components/views/messages/DateSeparator"; import BaseAvatar from "../../components/views/avatars/BaseAvatar"; import exportJS from "!!raw-loader!./exportJS"; -import exportIcons from "./exportIcons"; import { ExportType } from "./exportUtils"; import { IExportOptions } from "./exportUtils"; import MatrixClientContext from "../../contexts/MatrixClientContext"; @@ -294,6 +293,7 @@ export default class HTMLExporter extends Exporter { const mxc = mxEv.getContent().url || mxEv.getContent().file?.url; eventTileMarkup = eventTileMarkup.split(mxc).join(filePath); } + eventTileMarkup = eventTileMarkup.replace(/.*?<\/span>/, ''); if (hasAvatar) { eventTileMarkup = eventTileMarkup.replace( encodeURI(this.getAvatarURL(mxEv)).replace(/&/g, '&'), @@ -406,10 +406,6 @@ export default class HTMLExporter extends Exporter { this.addFile("css/style.css", new Blob([exportCSS])); this.addFile("js/script.js", new Blob([exportJS])); - for (const iconName in exportIcons) { - this.addFile(`icons/${iconName}`, new Blob([exportIcons[iconName]])); - } - await this.downloadZIP(); const exportEnd = performance.now(); diff --git a/src/utils/exportUtils/exportCSS.ts b/src/utils/exportUtils/exportCSS.ts index 1a3a575f07..f19bc1004b 100644 --- a/src/utils/exportUtils/exportCSS.ts +++ b/src/utils/exportUtils/exportCSS.ts @@ -16,33 +16,31 @@ limitations under the License. /* eslint-disable max-len, camelcase */ -declare const __webpack_hash__: string; - import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; const getExportCSS = async (): Promise => { const theme = new ThemeWatcher().getEffectiveTheme(); - const hash = __webpack_hash__; - - const bundle = await fetch(`bundles/${hash}/bundle.css`); - const bundleCSS = await bundle.text(); - let themeCSS: string; - if (theme === 'light') { - const res = await fetch(`bundles/${hash}/theme-light.css`); - themeCSS = await res.text(); - } else { - const res = await fetch(`bundles/${hash}/theme-dark.css`); - themeCSS = await res.text(); + const stylesheets: string[] = []; + document.querySelectorAll('link[rel="stylesheet"]').forEach((e: any) => { + if (e.href.endsWith("bundle.css") || e.href.endsWith(`theme-${theme}.css`)) { + stylesheets.push(e.href); + } + }); + let CSS: string; + for (const stylesheet of stylesheets) { + const res = await fetch(stylesheet); + const innerText = await res.text(); + CSS += innerText; } const fontFaceRegex = /@font-face {.*?}/sg; - themeCSS = themeCSS.replace(fontFaceRegex, ''); - themeCSS = themeCSS.replace( + CSS = CSS.replace(fontFaceRegex, ''); + CSS = CSS.replace( /font-family: Inter/g, `font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial, sans-serif`, ); - themeCSS = themeCSS.replace( + CSS = CSS.replace( /font-family: Inconsolata/g, "font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace", ); @@ -148,6 +146,10 @@ const getExportCSS = async (): Promise => { top: 1px; left: 0; } + + .mx_RedactedBody { + padding-left: unset; + } img { white-space: nowrap; @@ -155,7 +157,7 @@ const getExportCSS = async (): Promise => { } `; - return themeCSS + bundleCSS + customCSS; + return CSS + customCSS; }; export default getExportCSS; diff --git a/src/utils/exportUtils/exportIcons.ts b/src/utils/exportUtils/exportIcons.ts deleted file mode 100644 index 7a8264f7e9..0000000000 --- a/src/utils/exportUtils/exportIcons.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2021 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. -*/ - -import trashSVG from "!!raw-loader!../../../res/img/element-icons/trashcan.svg"; -import attachSVG from "!!raw-loader!../../../res/img/element-icons/room/composer/attach.svg"; - -export default { - "trash.svg": trashSVG, - "attach.svg": attachSVG, -}; diff --git a/test/utils/export-test.tsx b/test/utils/export-test.tsx index c4d9b43765..66436da9c5 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 { textForFormat, IExportOptions, ExportType } from "../../src/utils/exportUtils/exportUtils"; +import { IExportOptions, ExportType, ExportFormat } from "../../src/utils/exportUtils/exportUtils"; import '../skinned-sdk'; import PlainTextExporter from "../../src/utils/exportUtils/PlainTextExport"; import HTMLExporter from "../../src/utils/exportUtils/HtmlExport"; @@ -73,9 +73,60 @@ describe('export', function() { } const mockRoom = createRoom(); + const ts0 = Date.now(); + + function mkRedactedEvent(i = 0) { + return new MatrixEvent({ + type: "m.room.message", + sender: MY_USER_ID, + content: {}, + unsigned: { + "age": 72, + "transaction_id": "m1212121212.23", + "redacted_because": { + "content": {}, + "origin_server_ts": ts0 + i*1000, + "redacts": "$9999999999999999999999999999999999999999998", + "sender": "@me:here", + "type": "m.room.redaction", + "unsigned": { + "age": 94, + "transaction_id": "m1111111111.1", + }, + "event_id": "$9999999999999999999999999999999999999999998", + "room_id": mockRoom.roomId, + }, + }, + event_id: "$9999999999999999999999999999999999999999999", + room_id: mockRoom.roomId, + }); + } + + function mkFileEvent() { + return new MatrixEvent({ + "content": { + "body": "index.html", + "info": { + "mimetype": "text/html", + "size": 31613, + }, + "msgtype": "m.file", + "url": "mxc://test.org", + }, + "origin_server_ts": 1628872988364, + "sender": MY_USER_ID, + "type": "m.room.message", + "unsigned": { + "age": 266, + "transaction_id": "m99999999.2", + }, + "event_id": "$99999999999999999999", + "room_id": mockRoom.roomId, + }); + } + function mkEvents() { const matrixEvents = []; - const ts0 = Date.now(); let i: number; // plain text for (i = 0; i < 10; i++) { @@ -134,30 +185,7 @@ describe('export', function() { })); // redacted events for (i = 0; i < 10; i++) { - matrixEvents.push(new MatrixEvent({ - type: "m.room.message", - sender: MY_USER_ID, - content: {}, - unsigned: { - "age": 72, - "transaction_id": "m1212121212.23", - "redacted_because": { - "content": {}, - "origin_server_ts": ts0 + i*1000, - "redacts": "$9999999999999999999999999999999999999999998", - "sender": "@me:here", - "type": "m.room.redaction", - "unsigned": { - "age": 94, - "transaction_id": "m1111111111.1", - }, - "event_id": "$9999999999999999999999999999999999999999998", - "room_id": mockRoom.roomId, - }, - }, - event_id: "$9999999999999999999999999999999999999999999", - room_id: mockRoom.roomId, - })); + matrixEvents.push(mkRedactedEvent(i)); } return matrixEvents; } @@ -165,10 +193,22 @@ describe('export', function() { const events: MatrixEvent[] = mkEvents(); it('checks if the export format is valid', function() { - expect(textForFormat('HTML')).toBeTruthy(); - expect(textForFormat('JSON')).toBeTruthy(); - expect(textForFormat('PLAIN_TEXT')).toBeTruthy(); - expect(() => textForFormat('PDF')).toThrowError("Unknown format"); + function isValidFormat(format: string): boolean { + const options: string[] = Object.values(ExportFormat); + return options.includes(format); + } + expect(isValidFormat("Html")).toBeTruthy(); + expect(isValidFormat("Json")).toBeTruthy(); + expect(isValidFormat("PlainText")).toBeTruthy(); + expect(isValidFormat("Pdf")).toBeFalsy(); + }); + + it("checks if the icons' html corresponds to export regex", function() { + const exporter = new HTMLExporter(mockRoom, ExportType.Beginning, mockExportOptions, null); + const fileRegex = /.*?<\/span>/; + expect(fileRegex.test( + renderToString(exporter.getEventTile(mkFileEvent(), true))), + ).toBeTruthy(); }); it('checks if the export options are valid', function() {