mirror of https://github.com/vector-im/riot-web
Merge pull request #6081 from jaiwanth-v/export-conversations
commit
5eaf0e7e25
|
@ -79,6 +79,7 @@
|
|||
"highlight.js": "^10.5.0",
|
||||
"html-entities": "^1.4.0",
|
||||
"is-ip": "^3.1.0",
|
||||
"jszip": "^3.7.0",
|
||||
"katex": "^0.12.0",
|
||||
"linkifyjs": "^2.1.9",
|
||||
"lodash": "^4.17.20",
|
||||
|
@ -133,6 +134,7 @@
|
|||
"@types/counterpart": "^0.18.1",
|
||||
"@types/css-font-loading-module": "^0.0.6",
|
||||
"@types/diff-match-patch": "^1.0.32",
|
||||
"@types/file-saver": "^2.0.3",
|
||||
"@types/flux": "^3.1.9",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/linkifyjs": "^2.1.3",
|
||||
|
@ -166,9 +168,11 @@
|
|||
"jest-canvas-mock": "^2.3.0",
|
||||
"jest-environment-jsdom-sixteen": "^1.0.3",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jest-raw-loader": "^1.0.1",
|
||||
"matrix-mock-request": "^1.2.3",
|
||||
"matrix-react-test-utils": "^0.2.3",
|
||||
"matrix-web-i18n": "github:matrix-org/matrix-web-i18n",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react-test-renderer": "^17.0.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"rrweb-snapshot": "1.1.7",
|
||||
|
@ -199,6 +203,7 @@
|
|||
"decoderWorker\\.min\\.wasm": "<rootDir>/__mocks__/empty.js",
|
||||
"waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
|
||||
"workers/(.+)\\.worker\\.ts": "<rootDir>/__mocks__/workerMock.js",
|
||||
"^!!raw-loader!.*": "jest-raw-loader",
|
||||
"RecorderWorklet": "<rootDir>/__mocks__/empty.js"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
|
|
|
@ -82,6 +82,7 @@
|
|||
@import "./views/dialogs/_DeactivateAccountDialog.scss";
|
||||
@import "./views/dialogs/_DevtoolsDialog.scss";
|
||||
@import "./views/dialogs/_EditCommunityPrototypeDialog.scss";
|
||||
@import "./views/dialogs/_ExportDialog.scss";
|
||||
@import "./views/dialogs/_FeedbackDialog.scss";
|
||||
@import "./views/dialogs/_ForwardDialog.scss";
|
||||
@import "./views/dialogs/_GenericFeatureFeedbackDialog.scss";
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.mx_ExportDialog {
|
||||
.mx_ExportDialog_subheading {
|
||||
font-size: $font-16px;
|
||||
display: block;
|
||||
font-family: $font-family;
|
||||
font-weight: $font-semi-bold;
|
||||
color: $accent-fg-color;
|
||||
margin-top: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&.mx_ExportDialog_Exporting {
|
||||
.mx_ExportDialog_options {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mx_Field_select::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mx_RadioButton input[type="radio"]:checked + div > div {
|
||||
background: $greyed-fg-color;
|
||||
}
|
||||
|
||||
.mx_RadioButton input[type=radio]:checked + div {
|
||||
border-color: unset;
|
||||
}
|
||||
|
||||
.mx_Field_valid.mx_Field label,
|
||||
.mx_Field_valid.mx_Field:focus-within label {
|
||||
color: unset;
|
||||
}
|
||||
|
||||
.mx_Field_valid.mx_Field, .mx_Field_valid.mx_Field:focus-within {
|
||||
border-color: $input-border-color;
|
||||
}
|
||||
|
||||
.mx_Checkbox input[type="checkbox"]:checked + label > .mx_Checkbox_background {
|
||||
background: $greyed-fg-color;
|
||||
border-color: $greyed-fg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_ExportDialog_progress {
|
||||
.mx_Dialog_buttons {
|
||||
margin-top: unset;
|
||||
margin-left: 18px;
|
||||
}
|
||||
|
||||
.mx_Spinner {
|
||||
width: unset;
|
||||
height: unset;
|
||||
flex: unset;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mx_RadioButton > .mx_RadioButton_content {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.mx_Field {
|
||||
width: 256px;
|
||||
}
|
||||
|
||||
.mx_Field_postfix {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
}
|
|
@ -243,3 +243,7 @@ limitations under the License.
|
|||
.mx_RoomSummaryCard_icon_settings::before {
|
||||
mask-image: url('$(res)/img/element-icons/settings.svg');
|
||||
}
|
||||
|
||||
.mx_RoomSummaryCard_icon_export::before {
|
||||
mask-image: url('$(res)/img/element-icons/export.svg');
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47716 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22ZM12.7071 17.7071C12.6112 17.803 12.5007 17.8753 12.3828 17.9241L11.2929 17.7071L11.2925 17.7067L7.2929 13.7071C6.90237 13.3166 6.90237 12.6834 7.2929 12.2929C7.68342 11.9024 8.31658 11.9024 8.70711 12.2929L11 14.5858L11 7C11 6.44771 11.4477 6 12 6C12.5523 6 13 6.44771 13 7L13 14.5858L15.2929 12.2929C15.6834 11.9024 16.3166 11.9024 16.7071 12.2929C17.0976 12.6834 17.0976 13.3166 16.7071 13.7071L12.7071 17.7071ZM12.3828 17.9241L11.295 17.7092C11.4758 17.8889 11.7249 18 12 18C12.1356 18 12.2649 17.973 12.3828 17.9241Z"
|
||||
fill="#C1C6CD"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 821 B |
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
declare module '!!raw-loader!*' {
|
||||
const contents: string;
|
||||
export default contents;
|
||||
}
|
|
@ -161,3 +161,20 @@ export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): bo
|
|||
// Compare weekdays
|
||||
return prevEventDate.getDay() !== nextEventDate.getDay();
|
||||
}
|
||||
|
||||
export function formatFullDateNoDay(date: Date) {
|
||||
return _t("%(date)s at %(time)s", {
|
||||
date: date.toLocaleDateString().replace(/\//g, '-'),
|
||||
time: date.toLocaleTimeString().replace(/:/g, '-'),
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFullDateNoDayNoTime(date: Date) {
|
||||
return (
|
||||
date.getFullYear() +
|
||||
"/" +
|
||||
pad(date.getMonth() + 1) +
|
||||
"/" +
|
||||
pad(date.getDate())
|
||||
);
|
||||
}
|
||||
|
|
|
@ -166,6 +166,11 @@ function textForTopicEvent(ev: MatrixEvent): () => string | null {
|
|||
});
|
||||
}
|
||||
|
||||
function textForRoomAvatarEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev?.sender?.name || ev.getSender();
|
||||
return () => _t('%(senderDisplayName)s changed the room avatar.', { senderDisplayName });
|
||||
}
|
||||
|
||||
function textForRoomNameEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
|
||||
|
@ -289,11 +294,27 @@ function textForServerACLEvent(ev: MatrixEvent): () => string | null {
|
|||
function textForMessageEvent(ev: MatrixEvent): () => string | null {
|
||||
return () => {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
let message = senderDisplayName + ': ' + ev.getContent().body;
|
||||
let message = ev.getContent().body;
|
||||
if (ev.isRedacted()) {
|
||||
message = _t("Message deleted");
|
||||
const unsigned = ev.getUnsigned();
|
||||
const redactedBecauseUserId = unsigned?.redacted_because?.sender;
|
||||
if (redactedBecauseUserId && redactedBecauseUserId !== ev.getSender()) {
|
||||
const room = MatrixClientPeg.get().getRoom(ev.getRoomId());
|
||||
const sender = room?.getMember(redactedBecauseUserId);
|
||||
message = _t("Message deleted by %(name)s", { name: sender?.name
|
||||
|| redactedBecauseUserId });
|
||||
}
|
||||
}
|
||||
if (ev.getContent().msgtype === "m.emote") {
|
||||
message = "* " + senderDisplayName + " " + message;
|
||||
} else if (ev.getContent().msgtype === "m.image") {
|
||||
message = _t('%(senderDisplayName)s sent an image.', { senderDisplayName });
|
||||
} else if (ev.getType() == "m.sticker") {
|
||||
message = _t('%(senderDisplayName)s sent a sticker.', { senderDisplayName });
|
||||
} else {
|
||||
// in this case, parse it as a plain text message
|
||||
message = senderDisplayName + ': ' + message;
|
||||
}
|
||||
return message;
|
||||
};
|
||||
|
@ -669,6 +690,7 @@ interface IHandlers {
|
|||
|
||||
const handlers: IHandlers = {
|
||||
'm.room.message': textForMessageEvent,
|
||||
'm.sticker': textForMessageEvent,
|
||||
'm.call.invite': textForCallInviteEvent,
|
||||
};
|
||||
|
||||
|
@ -677,6 +699,7 @@ const stateHandlers: IHandlers = {
|
|||
'm.room.name': textForRoomNameEvent,
|
||||
'm.room.topic': textForTopicEvent,
|
||||
'm.room.member': textForMemberEvent,
|
||||
"m.room.avatar": textForRoomAvatarEvent,
|
||||
'm.room.third_party_invite': textForThreePidInviteEvent,
|
||||
'm.room.history_visibility': textForHistoryVisibilityEvent,
|
||||
'm.room.power_levels': textForPowerEvent,
|
||||
|
|
|
@ -60,7 +60,7 @@ const groupedEvents = [
|
|||
|
||||
// check if there is a previous event and it has the same sender as this event
|
||||
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
|
||||
function shouldFormContinuation(
|
||||
export function shouldFormContinuation(
|
||||
prevEvent: MatrixEvent,
|
||||
mxEvent: MatrixEvent,
|
||||
showHiddenEvents: boolean,
|
||||
|
|
|
@ -33,7 +33,7 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
|
|||
resizeMethod?: ResizeMethod;
|
||||
// The onClick to give the avatar
|
||||
onClick?: React.MouseEventHandler;
|
||||
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
|
||||
// Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser`
|
||||
viewUserOnClick?: boolean;
|
||||
title?: string;
|
||||
style?: any;
|
||||
|
|
|
@ -0,0 +1,397 @@
|
|||
/*
|
||||
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 React, { useRef, useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import Field from "../elements/Field";
|
||||
import StyledRadioGroup from "../elements/StyledRadioGroup";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
import {
|
||||
ExportFormat,
|
||||
ExportType,
|
||||
textForFormat,
|
||||
textForType,
|
||||
} from "../../../utils/exportUtils/exportUtils";
|
||||
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 InfoDialog from "./InfoDialog";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
room: Room;
|
||||
}
|
||||
|
||||
const ExportDialog: React.FC<IProps> = ({ room, onFinished }) => {
|
||||
const [exportFormat, setExportFormat] = useState(ExportFormat.Html);
|
||||
const [exportType, setExportType] = useState(ExportType.Timeline);
|
||||
const [includeAttachments, setAttachments] = useState(false);
|
||||
const [isExporting, setExporting] = useState(false);
|
||||
const [numberOfMessages, setNumberOfMessages] = useState<number>(100);
|
||||
const [sizeLimit, setSizeLimit] = useState<number | null>(8);
|
||||
const sizeLimitRef = useRef<Field>();
|
||||
const messageCountRef = useRef<Field>();
|
||||
const [exportProgressText, setExportProgressText] = useState("Processing...");
|
||||
const [displayCancel, setCancelWarning] = useState(false);
|
||||
const [exportCancelled, setExportCancelled] = useState(false);
|
||||
const [exportSuccessful, setExportSuccessful] = useState(false);
|
||||
const [exporter, setExporter] = useStateCallback<Exporter>(
|
||||
null,
|
||||
async (exporter: Exporter) => {
|
||||
await exporter?.export().then(() => {
|
||||
if (!exportCancelled) setExportSuccessful(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const startExport = async () => {
|
||||
const exportOptions = {
|
||||
numberOfMessages,
|
||||
attachmentsIncluded: includeAttachments,
|
||||
maxSize: sizeLimit * 1024 * 1024,
|
||||
};
|
||||
switch (exportFormat) {
|
||||
case ExportFormat.Html:
|
||||
setExporter(
|
||||
new HTMLExporter(
|
||||
room,
|
||||
ExportType[exportType],
|
||||
exportOptions,
|
||||
setExportProgressText,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case ExportFormat.Json:
|
||||
setExporter(
|
||||
new JSONExporter(
|
||||
room,
|
||||
ExportType[exportType],
|
||||
exportOptions,
|
||||
setExportProgressText,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case ExportFormat.PlainText:
|
||||
setExporter(
|
||||
new PlainTextExporter(
|
||||
room,
|
||||
ExportType[exportType],
|
||||
exportOptions,
|
||||
setExportProgressText,
|
||||
),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.error("Unknown export format");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onExportClick = async () => {
|
||||
const isValidSize = await sizeLimitRef.current.validate({
|
||||
focused: false,
|
||||
});
|
||||
if (!isValidSize) {
|
||||
sizeLimitRef.current.validate({ focused: true });
|
||||
return;
|
||||
}
|
||||
if (exportType === ExportType.LastNMessages) {
|
||||
const isValidNumberOfMessages =
|
||||
await messageCountRef.current.validate({ focused: false });
|
||||
if (!isValidNumberOfMessages) {
|
||||
messageCountRef.current.validate({ focused: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
setExporting(true);
|
||||
await startExport();
|
||||
};
|
||||
|
||||
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 },
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const onValidateSize = async (fieldState: IFieldState): Promise<IValidationResult> => {
|
||||
const result = await validateSize(fieldState);
|
||||
return result;
|
||||
};
|
||||
|
||||
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 },
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const onValidateNumberOfMessages = async (fieldState: IFieldState): Promise<IValidationResult> => {
|
||||
const result = await validateNumberOfMessages(fieldState);
|
||||
return result;
|
||||
};
|
||||
|
||||
const onCancel = async () => {
|
||||
if (isExporting) setCancelWarning(true);
|
||||
else onFinished(false);
|
||||
};
|
||||
|
||||
const confirmCanel = async () => {
|
||||
await exporter?.cancelExport();
|
||||
setExportCancelled(true);
|
||||
setExporting(false);
|
||||
setExporter(null);
|
||||
};
|
||||
|
||||
const exportFormatOptions = Object.keys(ExportFormat).map((format) => ({
|
||||
value: ExportFormat[format],
|
||||
label: textForFormat(ExportFormat[format]),
|
||||
}));
|
||||
|
||||
const exportTypeOptions = Object.keys(ExportType).map((type) => {
|
||||
return (
|
||||
<option key={type} value={ExportType[type]}>
|
||||
{ textForType(ExportType[type]) }
|
||||
</option>
|
||||
);
|
||||
});
|
||||
|
||||
let messageCount = null;
|
||||
if (exportType === ExportType.LastNMessages) {
|
||||
messageCount = (
|
||||
<Field
|
||||
element="input"
|
||||
type="number"
|
||||
value={numberOfMessages.toString()}
|
||||
ref={messageCountRef}
|
||||
onValidate={onValidateNumberOfMessages}
|
||||
label={_t("Number of messages")}
|
||||
onChange={(e) => {
|
||||
setNumberOfMessages(parseInt(e.target.value));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sizePostFix = <span>{ _t("MB") }</span>;
|
||||
|
||||
if (exportCancelled) {
|
||||
// Display successful cancellation message
|
||||
return (
|
||||
<InfoDialog
|
||||
title={_t("Export Successful")}
|
||||
description={_t("The export was cancelled successfully")}
|
||||
hasCloseButton={true}
|
||||
onFinished={onFinished}
|
||||
/>
|
||||
);
|
||||
} else if (exportSuccessful) {
|
||||
// Display successful export message
|
||||
return (
|
||||
<InfoDialog
|
||||
title={_t("Export Successful")}
|
||||
description={_t(
|
||||
"Your export was successful. Find it in your Downloads folder.",
|
||||
)}
|
||||
hasCloseButton={true}
|
||||
onFinished={onFinished}
|
||||
/>
|
||||
);
|
||||
} else if (displayCancel) {
|
||||
// Display cancel warning
|
||||
return (
|
||||
<BaseDialog
|
||||
title={_t("Warning")}
|
||||
className="mx_ExportDialog"
|
||||
contentId="mx_Dialog_content"
|
||||
onFinished={onFinished}
|
||||
fixedWidth={true}
|
||||
>
|
||||
<p>
|
||||
{ _t(
|
||||
"Are you sure you want to stop exporting your data? If you do, you'll need to start over.",
|
||||
) }
|
||||
</p>
|
||||
<DialogButtons
|
||||
primaryButton={_t("Stop")}
|
||||
primaryButtonClass="danger"
|
||||
hasCancel={true}
|
||||
cancelButton={_t("Continue")}
|
||||
onCancel={() => setCancelWarning(false)}
|
||||
onPrimaryButtonClick={confirmCanel}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
} else {
|
||||
// Display export settings
|
||||
return (
|
||||
<BaseDialog
|
||||
title={isExporting ? _t("Exporting your data") : _t("Export Chat")}
|
||||
className={`mx_ExportDialog ${isExporting && "mx_ExportDialog_Exporting"}`}
|
||||
contentId="mx_Dialog_content"
|
||||
hasCancel={true}
|
||||
onFinished={onFinished}
|
||||
fixedWidth={true}
|
||||
>
|
||||
{ !isExporting ? <p>
|
||||
{ _t(
|
||||
"Select from the options below to export chats from your timeline",
|
||||
) }
|
||||
</p> : null }
|
||||
|
||||
<span className="mx_ExportDialog_subheading">
|
||||
{ _t("Format") }
|
||||
</span>
|
||||
|
||||
<div className="mx_ExportDialog_options">
|
||||
<StyledRadioGroup
|
||||
name="exportFormat"
|
||||
value={exportFormat}
|
||||
onChange={(key) => setExportFormat(ExportFormat[key])}
|
||||
definitions={exportFormatOptions}
|
||||
/>
|
||||
|
||||
<span className="mx_ExportDialog_subheading">
|
||||
{ _t("Messages") }
|
||||
</span>
|
||||
|
||||
<Field
|
||||
element="select"
|
||||
value={exportType}
|
||||
onChange={(e) => {
|
||||
setExportType(ExportType[e.target.value]);
|
||||
}}
|
||||
>
|
||||
{ exportTypeOptions }
|
||||
</Field>
|
||||
{ messageCount }
|
||||
|
||||
<span className="mx_ExportDialog_subheading">
|
||||
{ _t("Size Limit") }
|
||||
</span>
|
||||
|
||||
<Field
|
||||
type="number"
|
||||
autoComplete="off"
|
||||
onValidate={onValidateSize}
|
||||
element="input"
|
||||
ref={sizeLimitRef}
|
||||
value={sizeLimit.toString()}
|
||||
postfixComponent={sizePostFix}
|
||||
onChange={(e) => setSizeLimit(parseInt(e.target.value))}
|
||||
/>
|
||||
|
||||
<StyledCheckbox
|
||||
checked={includeAttachments}
|
||||
onChange={(e) =>
|
||||
setAttachments(
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)
|
||||
}
|
||||
>
|
||||
{ _t("Include Attachments") }
|
||||
</StyledCheckbox>
|
||||
</div>
|
||||
{ isExporting ? (
|
||||
<div className="mx_ExportDialog_progress">
|
||||
<Spinner w={24} h={24} />
|
||||
<p>
|
||||
{ exportProgressText }
|
||||
</p>
|
||||
<DialogButtons
|
||||
primaryButton={_t("Cancel")}
|
||||
primaryButtonClass="danger"
|
||||
hasCancel={false}
|
||||
onPrimaryButtonClick={onCancel}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DialogButtons
|
||||
primaryButton={_t("Export")}
|
||||
onPrimaryButtonClick={onExportClick}
|
||||
onCancel={() => onFinished(false)}
|
||||
/>
|
||||
) }
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ExportDialog;
|
|
@ -53,6 +53,7 @@ interface IProps {
|
|||
layout?: Layout;
|
||||
// Whether to always show a timestamp
|
||||
alwaysShowTimestamps?: boolean;
|
||||
forExport?: boolean;
|
||||
isQuoteExpanded?: boolean;
|
||||
setQuoteExpanded: (isExpanded: boolean) => void;
|
||||
}
|
||||
|
@ -381,6 +382,17 @@ export default class ReplyThread extends React.Component<IProps, IState> {
|
|||
})
|
||||
}
|
||||
</blockquote>;
|
||||
} else if (this.props.forExport) {
|
||||
const eventId = ReplyThread.getParentEventId(this.props.parentEv);
|
||||
header = <p className="mx_ReplyThread_Export">
|
||||
{ _t("In reply to <a>this message</a>",
|
||||
{},
|
||||
{ a: (sub) => (
|
||||
<a className="mx_reply_anchor" href={`#${eventId}`} scroll-to={eventId}> { sub } </a>
|
||||
),
|
||||
})
|
||||
}
|
||||
</p>;
|
||||
} else if (this.state.loading) {
|
||||
header = <Spinner w={16} h={16} />;
|
||||
}
|
||||
|
|
|
@ -35,12 +35,17 @@ function getDaysArray(): string[] {
|
|||
|
||||
interface IProps {
|
||||
ts: number;
|
||||
forExport?: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.DateSeparator")
|
||||
export default class DateSeparator extends React.Component<IProps> {
|
||||
private getLabel() {
|
||||
const date = new Date(this.props.ts);
|
||||
|
||||
// During the time the archive is being viewed, a specific day might not make sense, so we return the full date
|
||||
if (this.props.forExport) return formatFullDateNoTime(date);
|
||||
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
const days = getDaysArray();
|
||||
|
|
|
@ -33,6 +33,7 @@ export interface IBodyProps {
|
|||
onHeightChanged: () => void;
|
||||
|
||||
showUrlPreview?: boolean;
|
||||
forExport?: boolean;
|
||||
tileShape: TileShape;
|
||||
maxImageHeight?: number;
|
||||
replacingEventId?: string;
|
||||
|
|
|
@ -90,6 +90,17 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
|
|||
);
|
||||
}
|
||||
|
||||
if (this.props.forExport) {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
// During export, the content url will point to the MSC, which will later point to a local url
|
||||
const contentUrl = content.file?.url || content.url;
|
||||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
<audio src={contentUrl} controls />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.state.playback) {
|
||||
return (
|
||||
<span className="mx_MAudioBody">
|
||||
|
|
|
@ -123,6 +123,11 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
this.state = {};
|
||||
}
|
||||
|
||||
private getContentUrl(): string | null {
|
||||
if (this.props.forExport) return null;
|
||||
const media = mediaFromContent(this.props.mxEvent.getContent());
|
||||
return media.srcHttp;
|
||||
}
|
||||
private get content(): IMediaEventContent {
|
||||
return this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
}
|
||||
|
@ -149,11 +154,6 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
private getContentUrl(): string {
|
||||
const media = mediaFromContent(this.props.mxEvent.getContent());
|
||||
return media.srcHttp;
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps, prevState) {
|
||||
if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) {
|
||||
this.props.onHeightChanged();
|
||||
|
@ -213,6 +213,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
|||
);
|
||||
}
|
||||
|
||||
if (this.props.forExport) {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
// During export, the content url will point to the MSC, which will later point to a local url
|
||||
return <span className="mx_MFileBody">
|
||||
<a href={content.file?.url || content.url}>
|
||||
{ placeholder }
|
||||
</a>
|
||||
</span>;
|
||||
}
|
||||
|
||||
const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder;
|
||||
|
||||
if (isEncrypted) {
|
||||
|
|
|
@ -179,6 +179,9 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
};
|
||||
|
||||
protected getContentUrl(): string {
|
||||
const content: IMediaEventContent = this.props.mxEvent.getContent();
|
||||
// During export, the content url will point to the MSC, which will later point to a local url
|
||||
if (this.props.forExport) return content.url || content.file?.url;
|
||||
if (this.media.isEncrypted) {
|
||||
return this.state.decryptedUrl;
|
||||
} else {
|
||||
|
@ -372,7 +375,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
let placeholder = null;
|
||||
let gifLabel = null;
|
||||
|
||||
if (!this.state.imgLoaded) {
|
||||
if (!this.props.forExport && !this.state.imgLoaded) {
|
||||
placeholder = this.getPlaceholder(maxWidth, maxHeight);
|
||||
}
|
||||
|
||||
|
@ -462,7 +465,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
|
||||
// Overidden by MStickerBody
|
||||
protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element {
|
||||
return <a href={contentUrl} onClick={this.onClick}>
|
||||
return <a href={contentUrl} target={this.props.forExport ? "_blank" : undefined} onClick={this.onClick}>
|
||||
{ children }
|
||||
</a>;
|
||||
}
|
||||
|
@ -490,6 +493,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
|
||||
// Overidden by MStickerBody
|
||||
protected getFileBody(): string | JSX.Element {
|
||||
if (this.props.forExport) return null;
|
||||
// We only ever need the download bar if we're appearing outside of the timeline
|
||||
if (this.props.tileShape) {
|
||||
return <MFileBody {...this.props} showGenericPlaceholder={false} />;
|
||||
|
@ -510,7 +514,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
|
||||
const contentUrl = this.getContentUrl();
|
||||
let thumbUrl;
|
||||
if (this.isGif() && SettingsStore.getValue("autoplayGifs")) {
|
||||
if (this.props.forExport || (this.isGif() && SettingsStore.getValue("autoplayGifs"))) {
|
||||
thumbUrl = contentUrl;
|
||||
} else {
|
||||
thumbUrl = this.getThumbUrl();
|
||||
|
|
|
@ -79,7 +79,10 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
|
|||
}
|
||||
|
||||
private getContentUrl(): string|null {
|
||||
const media = mediaFromContent(this.props.mxEvent.getContent());
|
||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
// During export, the content url will point to the MSC, which will later point to a local url
|
||||
if (this.props.forExport) return content.file?.url || content.url;
|
||||
const media = mediaFromContent(content);
|
||||
if (media.isEncrypted) {
|
||||
return this.state.decryptedUrl;
|
||||
} else {
|
||||
|
@ -93,6 +96,9 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
|
|||
}
|
||||
|
||||
private getThumbUrl(): string|null {
|
||||
// there's no need of thumbnail when the content is local
|
||||
if (this.props.forExport) return null;
|
||||
|
||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
const media = mediaFromContent(content);
|
||||
|
||||
|
@ -209,6 +215,11 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
|
|||
this.props.onHeightChanged();
|
||||
};
|
||||
|
||||
private getFileBody = () => {
|
||||
if (this.props.forExport) return null;
|
||||
return this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} />;
|
||||
};
|
||||
|
||||
render() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const autoplay = SettingsStore.getValue("autoplayVideo");
|
||||
|
@ -222,8 +233,8 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
|
|||
);
|
||||
}
|
||||
|
||||
// Important: If we aren't autoplaying and we haven't decrypred it yet, show a video with a poster.
|
||||
if (content.file !== undefined && this.state.decryptedUrl === null && autoplay) {
|
||||
// Important: If we aren't autoplaying and we haven't decrypted it yet, show a video with a poster.
|
||||
if (!this.props.forExport && content.file !== undefined && this.state.decryptedUrl === null && autoplay) {
|
||||
// Need to decrypt the attachment
|
||||
// The attachment is decrypted in componentDidMount.
|
||||
// For now add an img tag with a spinner.
|
||||
|
@ -254,6 +265,8 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
|
|||
preload = "none";
|
||||
}
|
||||
}
|
||||
|
||||
const fileBody = this.getFileBody();
|
||||
return (
|
||||
<span className="mx_MVideoBody">
|
||||
<video
|
||||
|
@ -270,7 +283,7 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
|
|||
poster={poster}
|
||||
onPlay={this.videoOnPlay}
|
||||
/>
|
||||
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
|
||||
{ fileBody }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ import { isVoiceMessage } from "../../../utils/EventUtils";
|
|||
@replaceableComponent("views.messages.MVoiceOrAudioBody")
|
||||
export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
|
||||
public render() {
|
||||
if (isVoiceMessage(this.props.mxEvent)) {
|
||||
if (!this.props.forExport && isVoiceMessage(this.props.mxEvent)) {
|
||||
return <MVoiceMessageBody {...this.props} />;
|
||||
} else {
|
||||
return <MAudioBody {...this.props} />;
|
||||
|
|
|
@ -136,6 +136,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
|||
highlightLink={this.props.highlightLink}
|
||||
showUrlPreview={this.props.showUrlPreview}
|
||||
tileShape={this.props.tileShape}
|
||||
forExport={this.props.forExport}
|
||||
maxImageHeight={this.props.maxImageHeight}
|
||||
replacingEventId={this.props.replacingEventId}
|
||||
editState={this.props.editState}
|
||||
|
|
|
@ -29,7 +29,6 @@ interface IProps {
|
|||
|
||||
const RedactedBody = React.forwardRef<any, IProps | IBodyProps>(({ mxEvent }, ref) => {
|
||||
const cli: MatrixClient = useContext(MatrixClientContext);
|
||||
|
||||
let text = _t("Message deleted");
|
||||
const unsigned = mxEvent.getUnsigned();
|
||||
const redactedBecauseUserId = unsigned && unsigned.redacted_because && unsigned.redacted_because.sender;
|
||||
|
|
|
@ -47,6 +47,7 @@ import { useRoomMemberCount } from "../../../hooks/useRoomMembers";
|
|||
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import RoomName from "../elements/RoomName";
|
||||
import UIStore from "../../../stores/UIStore";
|
||||
import ExportDialog from "../dialogs/ExportDialog";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
|
@ -240,6 +241,12 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
|
|||
});
|
||||
};
|
||||
|
||||
const onRoomExportClick = async () => {
|
||||
Modal.createTrackedDialog('export room dialog', '', ExportDialog, {
|
||||
room,
|
||||
});
|
||||
};
|
||||
|
||||
const isRoomEncrypted = useIsEncrypted(cli, room);
|
||||
const roomContext = useContext(RoomContext);
|
||||
const e2eStatus = roomContext.e2eStatus;
|
||||
|
@ -280,6 +287,9 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
|
|||
<Button className="mx_RoomSummaryCard_icon_files" onClick={onRoomFilesClick}>
|
||||
{ _t("Show files") }
|
||||
</Button>
|
||||
<Button className="mx_RoomSummaryCard_icon_export" onClick={onRoomExportClick}>
|
||||
{ _t("Export chat") }
|
||||
</Button>
|
||||
{ SettingsStore.getValue("feature_thread") && (
|
||||
<Button className="mx_RoomSummaryCard_icon_threads" onClick={onRoomThreadsClick}>
|
||||
{ _t("Show threads") }
|
||||
|
|
|
@ -264,6 +264,8 @@ interface IProps {
|
|||
// for now.
|
||||
tileShape?: TileShape;
|
||||
|
||||
forExport?: boolean;
|
||||
|
||||
// show twelve hour timestamps
|
||||
isTwelveHour?: boolean;
|
||||
|
||||
|
@ -340,6 +342,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
static defaultProps = {
|
||||
// no-op function because onHeightChanged is optional yet some sub-components assume its existence
|
||||
onHeightChanged: function() {},
|
||||
forExport: false,
|
||||
layout: Layout.Group,
|
||||
};
|
||||
|
||||
|
@ -382,7 +385,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
* or 'sent' receipt, for example.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
private get isEligibleForSpecialReceipt() {
|
||||
private get isEligibleForSpecialReceipt(): boolean {
|
||||
// First, if there are other read receipts then just short-circuit this.
|
||||
if (this.props.readReceipts && this.props.readReceipts.length > 0) return false;
|
||||
if (!this.props.mxEvent) return false;
|
||||
|
@ -453,16 +456,18 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
componentDidMount() {
|
||||
this.suppressReadReceiptAnimation = false;
|
||||
const client = this.context;
|
||||
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
|
||||
client.on("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
|
||||
if (this.props.showReactions) {
|
||||
this.props.mxEvent.on("Event.relationsCreated", this.onReactionsCreated);
|
||||
}
|
||||
if (!this.props.forExport) {
|
||||
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
|
||||
client.on("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
|
||||
if (this.props.showReactions) {
|
||||
this.props.mxEvent.on("Event.relationsCreated", this.onReactionsCreated);
|
||||
}
|
||||
|
||||
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
|
||||
client.on("Room.receipt", this.onRoomReceipt);
|
||||
this.isListeningForReceipts = true;
|
||||
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
|
||||
client.on("Room.receipt", this.onRoomReceipt);
|
||||
this.isListeningForReceipts = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("feature_thread")) {
|
||||
|
@ -698,6 +703,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
shouldHighlight() {
|
||||
if (this.props.forExport) return false;
|
||||
const actions = this.context.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent);
|
||||
if (!actions || !actions.tweaks) { return false; }
|
||||
|
||||
|
@ -1056,10 +1062,11 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
const showMessageActionBar = !isEditing && !this.props.forExport;
|
||||
const renderingContext = this.props.tileShape === TileShape.Thread
|
||||
? ActionBarRenderingContext.Thread
|
||||
: ActionBarRenderingContext.Room;
|
||||
const actionBar = !isEditing ? <MessageActionBar
|
||||
const actionBar = showMessageActionBar ? <MessageActionBar
|
||||
mxEvent={this.props.mxEvent}
|
||||
reactions={this.state.reactions}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
|
@ -1247,6 +1254,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
parentEv={this.props.mxEvent}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
ref={this.replyThread}
|
||||
forExport={this.props.forExport}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
layout={this.props.layout}
|
||||
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover}
|
||||
|
@ -1280,6 +1288,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
{ thread }
|
||||
<EventTileType ref={this.tile}
|
||||
mxEvent={this.props.mxEvent}
|
||||
forExport={this.props.forExport}
|
||||
replacingEventId={this.props.replacingEventId}
|
||||
editState={this.props.editState}
|
||||
highlights={this.props.highlights}
|
||||
|
@ -1305,7 +1314,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
|
||||
// XXX this'll eventually be dynamic based on the fields once we have extensible event types
|
||||
const messageTypes = ['m.room.message', 'm.sticker'];
|
||||
function isMessageEvent(ev) {
|
||||
function isMessageEvent(ev: MatrixEvent): boolean {
|
||||
return (messageTypes.includes(ev.getType()));
|
||||
}
|
||||
|
||||
|
|
|
@ -102,6 +102,7 @@
|
|||
"%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(time)s",
|
||||
"%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s",
|
||||
"%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s",
|
||||
"%(date)s at %(time)s": "%(date)s at %(time)s",
|
||||
"Who would you like to add to this community?": "Who would you like to add to this community?",
|
||||
"Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID",
|
||||
"Invite new community members": "Invite new community members",
|
||||
|
@ -509,6 +510,7 @@
|
|||
"%(senderName)s kicked %(targetName)s: %(reason)s": "%(senderName)s kicked %(targetName)s: %(reason)s",
|
||||
"%(senderName)s kicked %(targetName)s": "%(senderName)s kicked %(targetName)s",
|
||||
"%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s changed the topic to \"%(topic)s\".",
|
||||
"%(senderDisplayName)s changed the room avatar.": "%(senderDisplayName)s changed the room avatar.",
|
||||
"%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s removed the room name.",
|
||||
"%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.",
|
||||
"%(senderDisplayName)s changed the room name to %(roomName)s.": "%(senderDisplayName)s changed the room name to %(roomName)s.",
|
||||
|
@ -525,7 +527,10 @@
|
|||
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s set the server ACLs for this room.",
|
||||
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s changed the server ACLs for this room.",
|
||||
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 All servers are banned from participating! This room can no longer be used.",
|
||||
"Message deleted": "Message deleted",
|
||||
"Message deleted by %(name)s": "Message deleted by %(name)s",
|
||||
"%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.",
|
||||
"%(senderDisplayName)s sent a sticker.": "%(senderDisplayName)s sent a sticker.",
|
||||
"%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.",
|
||||
"%(senderName)s removed the main address for this room.": "%(senderName)s removed the main address for this room.",
|
||||
"%(senderName)s added the alternative addresses %(addresses)s for this room.|other": "%(senderName)s added the alternative addresses %(addresses)s for this room.",
|
||||
|
@ -723,6 +728,20 @@
|
|||
"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",
|
||||
"From the beginning": "From the beginning",
|
||||
"Specify a number of messages": "Specify a number of messages",
|
||||
"Current Timeline": "Current Timeline",
|
||||
"Media omitted": "Media omitted",
|
||||
"Media omitted - file size limit exceeded": "Media omitted - file size limit exceeded",
|
||||
"%(creatorName)s created this room.": "%(creatorName)s created this room.",
|
||||
"This is the start of export of <roomName/>. Exported by <exporterDetails/> at %(exportDate)s.": "This is the start of export of <roomName/>. Exported by <exporterDetails/> at %(exportDate)s.",
|
||||
"Topic: %(topic)s": "Topic: %(topic)s",
|
||||
"Error fetching file": "Error fetching file",
|
||||
"File Attached": "File Attached",
|
||||
"Help us improve %(brand)s": "Help us improve %(brand)s",
|
||||
"Send <UsageDataLink>anonymous usage data</UsageDataLink> which helps us improve %(brand)s. This will use a <PolicyLink>cookie</PolicyLink>.": "Send <UsageDataLink>anonymous usage data</UsageDataLink> which helps us improve %(brand)s. This will use a <PolicyLink>cookie</PolicyLink>.",
|
||||
"Yes": "Yes",
|
||||
|
@ -1816,6 +1835,7 @@
|
|||
"%(count)s people|other": "%(count)s people",
|
||||
"%(count)s people|one": "%(count)s person",
|
||||
"Show files": "Show files",
|
||||
"Export chat": "Export chat",
|
||||
"Show threads": "Show threads",
|
||||
"Share room": "Share room",
|
||||
"Room settings": "Room settings",
|
||||
|
@ -1974,8 +1994,6 @@
|
|||
"Reactions": "Reactions",
|
||||
"%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s",
|
||||
"<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>": "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>",
|
||||
"Message deleted": "Message deleted",
|
||||
"Message deleted by %(name)s": "Message deleted by %(name)s",
|
||||
"Message deleted on %(date)s": "Message deleted on %(date)s",
|
||||
"%(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.",
|
||||
|
@ -2126,6 +2144,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.",
|
||||
"<a>In reply to</a> <pill>": "<a>In reply to</a> <pill>",
|
||||
"In reply to <a>this message</a>": "In reply to <a>this message</a>",
|
||||
"Room address": "Room address",
|
||||
"e.g. my-room": "e.g. my-room",
|
||||
"Some characters not allowed": "Some characters not allowed",
|
||||
|
@ -2332,6 +2351,23 @@
|
|||
"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.",
|
||||
"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 Successful": "Export Successful",
|
||||
"The export was cancelled successfully": "The export was cancelled successfully",
|
||||
"Your export was successful. Find it in your Downloads folder.": "Your export was successful. Find it in your Downloads folder.",
|
||||
"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.",
|
||||
"Stop": "Stop",
|
||||
"Exporting your data": "Exporting your data",
|
||||
"Export Chat": "Export Chat",
|
||||
"Select from the options below to export chats from your timeline": "Select from the options below to export chats from your timeline",
|
||||
"Format": "Format",
|
||||
"Size Limit": "Size Limit",
|
||||
"Include Attachments": "Include Attachments",
|
||||
"Export": "Export",
|
||||
"Feedback sent": "Feedback sent",
|
||||
"Rate %(brand)s": "Rate %(brand)s",
|
||||
"Tell us below how you feel about %(brand)s so far.": "Tell us below how you feel about %(brand)s so far.",
|
||||
|
@ -3087,7 +3123,6 @@
|
|||
"The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.",
|
||||
"Enter passphrase": "Enter passphrase",
|
||||
"Confirm passphrase": "Confirm passphrase",
|
||||
"Export": "Export",
|
||||
"Import room keys": "Import room keys",
|
||||
"This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.",
|
||||
"The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.": "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.",
|
||||
|
|
|
@ -0,0 +1,263 @@
|
|||
/*
|
||||
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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { IExportOptions, ExportType } from "./exportUtils";
|
||||
import { decryptFile } from "../DecryptFile";
|
||||
import { mediaFromContent } from "../../customisations/Media";
|
||||
import { formatFullDateNoDay } from "../../DateUtils";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { Direction } from "matrix-js-sdk/src/models/event-timeline";
|
||||
import { saveAs } from "file-saver";
|
||||
import { _t } from "../../languageHandler";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
|
||||
type BlobFile = {
|
||||
name: string;
|
||||
blob: Blob;
|
||||
};
|
||||
|
||||
export default abstract class Exporter {
|
||||
protected files: BlobFile[] = [];
|
||||
protected client: MatrixClient;
|
||||
protected cancelled = false;
|
||||
|
||||
protected constructor(
|
||||
protected room: Room,
|
||||
protected exportType: ExportType,
|
||||
protected exportOptions: IExportOptions,
|
||||
protected setProgressText: React.Dispatch<React.SetStateAction<string>>,
|
||||
) {
|
||||
if (exportOptions.maxSize < 1 * 1024 * 1024|| // Less than 1 MB
|
||||
exportOptions.maxSize > 2000 * 1024 * 1024|| // More than ~ 2 GB
|
||||
exportOptions.numberOfMessages > 10**8
|
||||
) {
|
||||
throw new Error("Invalid export options");
|
||||
}
|
||||
this.client = MatrixClientPeg.get();
|
||||
window.addEventListener("beforeunload", this.onBeforeUnload);
|
||||
}
|
||||
|
||||
protected onBeforeUnload(e: BeforeUnloadEvent): string {
|
||||
e.preventDefault();
|
||||
return e.returnValue = _t("Are you sure you want to exit during this export?");
|
||||
}
|
||||
|
||||
protected updateProgress(progress: string, log = true, show = true): void {
|
||||
if (log) console.log(progress);
|
||||
if (show) this.setProgressText(progress);
|
||||
}
|
||||
|
||||
protected addFile(filePath: string, blob: Blob): void {
|
||||
const file = {
|
||||
name: filePath,
|
||||
blob,
|
||||
};
|
||||
this.files.push(file);
|
||||
}
|
||||
|
||||
protected async downloadZIP(): Promise<string | void> {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const filename = `${brand} - Chat Export - ${formatFullDateNoDay(new Date())}.zip`;
|
||||
const { default: JSZip } = await import('jszip');
|
||||
|
||||
const zip = new JSZip();
|
||||
// Create a writable stream to the directory
|
||||
if (!this.cancelled) this.updateProgress("Generating a ZIP");
|
||||
else return this.cleanUp();
|
||||
|
||||
for (const file of this.files) zip.file(file.name, file.blob);
|
||||
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
|
||||
saveAs(content, filename);
|
||||
}
|
||||
|
||||
protected cleanUp(): string {
|
||||
console.log("Cleaning up...");
|
||||
window.removeEventListener("beforeunload", this.onBeforeUnload);
|
||||
return "";
|
||||
}
|
||||
|
||||
public async cancelExport(): Promise<void> {
|
||||
console.log("Cancelling export...");
|
||||
this.cancelled = true;
|
||||
}
|
||||
|
||||
protected downloadPlainText(fileName: string, text: string) {
|
||||
const content = new Blob([text], { type: "text" });
|
||||
saveAs(content, fileName);
|
||||
}
|
||||
|
||||
protected setEventMetadata(event: MatrixEvent): MatrixEvent {
|
||||
const roomState = this.client.getRoom(this.room.roomId).currentState;
|
||||
event.sender = roomState.getSentinelMember(
|
||||
event.getSender(),
|
||||
);
|
||||
if (event.getType() === "m.room.member") {
|
||||
event.target = roomState.getSentinelMember(
|
||||
event.getStateKey(),
|
||||
);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
public getLimit(): number {
|
||||
let limit: number;
|
||||
switch (this.exportType) {
|
||||
case ExportType.LastNMessages:
|
||||
limit = this.exportOptions.numberOfMessages;
|
||||
break;
|
||||
case ExportType.Timeline:
|
||||
limit = 40;
|
||||
break;
|
||||
default:
|
||||
limit = 10**8;
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
protected async getRequiredEvents(): Promise<MatrixEvent[]> {
|
||||
const eventMapper = this.client.getEventMapper();
|
||||
|
||||
let prevToken: string|null = null;
|
||||
let limit = this.getLimit();
|
||||
const events: MatrixEvent[] = [];
|
||||
|
||||
while (limit) {
|
||||
const eventsPerCrawl = Math.min(limit, 1000);
|
||||
const res = await this.client.createMessagesRequest(
|
||||
this.room.roomId,
|
||||
prevToken,
|
||||
eventsPerCrawl,
|
||||
Direction.Backward,
|
||||
);
|
||||
|
||||
if (this.cancelled) {
|
||||
this.cleanUp();
|
||||
return [];
|
||||
}
|
||||
|
||||
if (res.chunk.length === 0) break;
|
||||
|
||||
limit -= res.chunk.length;
|
||||
|
||||
const matrixEvents: MatrixEvent[] = res.chunk.map(eventMapper);
|
||||
|
||||
for (const mxEv of matrixEvents) {
|
||||
// if (this.exportOptions.startDate && mxEv.getTs() < this.exportOptions.startDate) {
|
||||
// // Once the last message received is older than the start date, we break out of both the loops
|
||||
// limit = 0;
|
||||
// break;
|
||||
// }
|
||||
events.push(mxEv);
|
||||
}
|
||||
this.updateProgress(
|
||||
("Fetched " + events.length + " events ") + (this.exportType === ExportType.LastNMessages
|
||||
? `out of ${this.exportOptions.numberOfMessages}`
|
||||
: "so far"),
|
||||
);
|
||||
prevToken = res.end;
|
||||
}
|
||||
// Reverse the events so that we preserve the order
|
||||
for (let i = 0; i < Math.floor(events.length/2); i++) {
|
||||
[events[i], events[events.length - i - 1]] = [events[events.length - i - 1], events[i]];
|
||||
}
|
||||
|
||||
const decryptionPromises = events
|
||||
.filter(event => event.isEncrypted())
|
||||
.map(event => {
|
||||
return this.client.decryptEventIfNeeded(event, {
|
||||
isRetry: true,
|
||||
emit: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for all the events to get decrypted.
|
||||
await Promise.all(decryptionPromises);
|
||||
|
||||
for (let i = 0; i < events.length; i++) this.setEventMetadata(events[i]);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
protected async getMediaBlob(event: MatrixEvent): Promise<Blob> {
|
||||
let blob: Blob;
|
||||
try {
|
||||
const isEncrypted = event.isEncrypted();
|
||||
const content = event.getContent();
|
||||
const shouldDecrypt = isEncrypted && !content.hasOwnProperty("org.matrix.msc1767.file")
|
||||
&& event.getType() !== "m.sticker";
|
||||
if (shouldDecrypt) {
|
||||
blob = await decryptFile(content.file);
|
||||
} else {
|
||||
const media = mediaFromContent(event.getContent());
|
||||
const image = await fetch(media.srcHttp);
|
||||
blob = await image.blob();
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("Error decrypting media");
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
public splitFileName(file: string): string[] {
|
||||
const lastDot = file.lastIndexOf('.');
|
||||
if (lastDot === -1) return [file, ""];
|
||||
const fileName = file.slice(0, lastDot);
|
||||
const ext = file.slice(lastDot + 1);
|
||||
return [fileName, '.' + ext];
|
||||
}
|
||||
|
||||
public getFilePath(event: MatrixEvent): string {
|
||||
const mediaType = event.getContent().msgtype;
|
||||
let fileDirectory: string;
|
||||
switch (mediaType) {
|
||||
case "m.image":
|
||||
fileDirectory = "images";
|
||||
break;
|
||||
case "m.video":
|
||||
fileDirectory = "videos";
|
||||
break;
|
||||
case "m.audio":
|
||||
fileDirectory = "audio";
|
||||
break;
|
||||
default:
|
||||
fileDirectory = event.getType() === "m.sticker" ? "stickers" : "files";
|
||||
}
|
||||
const fileDate = formatFullDateNoDay(new Date(event.getTs()));
|
||||
let [fileName, fileExt] = this.splitFileName(event.getContent().body);
|
||||
if (event.getType() === "m.sticker") fileExt = ".png";
|
||||
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);
|
||||
}
|
||||
|
||||
abstract export(): Promise<void>;
|
||||
}
|
|
@ -0,0 +1,442 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import Exporter from "./Exporter";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Layout } from "../../settings/Layout";
|
||||
import { shouldFormContinuation } from "../../components/structures/MessagePanel";
|
||||
import { formatFullDateNoDayNoTime, wantsDateSeparator } from "../../DateUtils";
|
||||
import { RoomPermalinkCreator } from "../permalinks/Permalinks";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
|
||||
import * as Avatar from "../../Avatar";
|
||||
import EventTile, { haveTileForEvent } from "../../components/views/rooms/EventTile";
|
||||
import DateSeparator from "../../components/views/messages/DateSeparator";
|
||||
import BaseAvatar from "../../components/views/avatars/BaseAvatar";
|
||||
import exportJS from "!!raw-loader!./exportJS";
|
||||
import { ExportType } from "./exportUtils";
|
||||
import { IExportOptions } from "./exportUtils";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import getExportCSS from "./exportCSS";
|
||||
import { textForEvent } from "../../TextForEvent";
|
||||
|
||||
export default class HTMLExporter extends Exporter {
|
||||
protected avatars: Map<string, boolean>;
|
||||
protected permalinkCreator: RoomPermalinkCreator;
|
||||
protected totalSize: number;
|
||||
protected mediaOmitText: string;
|
||||
|
||||
constructor(
|
||||
room: Room,
|
||||
exportType: ExportType,
|
||||
exportOptions: IExportOptions,
|
||||
setProgressText: React.Dispatch<React.SetStateAction<string>>,
|
||||
) {
|
||||
super(room, exportType, exportOptions, setProgressText);
|
||||
this.avatars = new Map<string, boolean>();
|
||||
this.permalinkCreator = new RoomPermalinkCreator(this.room);
|
||||
this.totalSize = 0;
|
||||
this.mediaOmitText = !this.exportOptions.attachmentsIncluded
|
||||
? _t("Media omitted")
|
||||
: _t("Media omitted - file size limit exceeded");
|
||||
}
|
||||
|
||||
protected async getRoomAvatar() {
|
||||
let blob: Blob;
|
||||
const avatarUrl = Avatar.avatarUrlForRoom(this.room, 32, 32, "crop");
|
||||
const avatarPath = "room.png";
|
||||
if (avatarUrl) {
|
||||
try {
|
||||
const image = await fetch(avatarUrl);
|
||||
blob = await image.blob();
|
||||
this.totalSize += blob.size;
|
||||
this.addFile(avatarPath, blob);
|
||||
} catch (err) {
|
||||
console.log("Failed to fetch room's avatar" + err);
|
||||
}
|
||||
}
|
||||
const avatar = (
|
||||
<BaseAvatar
|
||||
width={32}
|
||||
height={32}
|
||||
name={this.room.name}
|
||||
title={this.room.name}
|
||||
url={blob ? avatarPath : null}
|
||||
resizeMethod="crop"
|
||||
/>
|
||||
);
|
||||
return renderToStaticMarkup(avatar);
|
||||
}
|
||||
|
||||
protected async wrapHTML(content: string) {
|
||||
const roomAvatar = await this.getRoomAvatar();
|
||||
const exportDate = formatFullDateNoDayNoTime(new Date());
|
||||
const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
|
||||
const creatorName = this.room?.getMember(creator)?.rawDisplayName || creator;
|
||||
const exporter = this.client.getUserId();
|
||||
const exporterName = this.room?.getMember(exporter)?.rawDisplayName;
|
||||
const topic = this.room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || "";
|
||||
const createdText = _t("%(creatorName)s created this room.", {
|
||||
creatorName,
|
||||
});
|
||||
|
||||
const exportedText = renderToStaticMarkup(
|
||||
<p>
|
||||
{ _t(
|
||||
"This is the start of export of <roomName/>. Exported by <exporterDetails/> at %(exportDate)s.",
|
||||
{
|
||||
exportDate,
|
||||
},
|
||||
{
|
||||
roomName: () => <b>{ this.room.name }</b>,
|
||||
exporterDetails: () => (
|
||||
<a
|
||||
href={`https://matrix.to/#/${exporter}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{ exporterName ? (
|
||||
<>
|
||||
<b>{ exporterName }</b>
|
||||
{ " (" + exporter + ")" }
|
||||
</>
|
||||
) : (
|
||||
<b>{ exporter }</b>
|
||||
) }
|
||||
</a>
|
||||
),
|
||||
},
|
||||
) }
|
||||
</p>,
|
||||
);
|
||||
|
||||
const topicText = topic ? _t("Topic: %(topic)s", { topic }) : "";
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="css/style.css" rel="stylesheet" />
|
||||
<script src="js/script.js"></script>
|
||||
<title>Exported Data</title>
|
||||
</head>
|
||||
<body style="height: 100vh;">
|
||||
<section
|
||||
id="matrixchat"
|
||||
style="height: 100%; overflow: auto"
|
||||
class="notranslate"
|
||||
>
|
||||
<div class="mx_MatrixChat_wrapper" aria-hidden="false">
|
||||
<div class="mx_MatrixChat">
|
||||
<main class="mx_RoomView">
|
||||
<div class="mx_RoomHeader light-panel">
|
||||
<div class="mx_RoomHeader_wrapper" aria-owns="mx_RightPanel">
|
||||
<div class="mx_RoomHeader_avatar">
|
||||
<div class="mx_DecoratedRoomAvatar">
|
||||
${roomAvatar}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx_RoomHeader_name">
|
||||
<div
|
||||
dir="auto"
|
||||
class="mx_RoomHeader_nametext"
|
||||
title="${this.room.name}"
|
||||
>
|
||||
${this.room.name}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx_RoomHeader_topic" dir="auto"> ${topic} </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx_MainSplit">
|
||||
<div class="mx_RoomView_body">
|
||||
<div
|
||||
class="mx_RoomView_timeline mx_RoomView_timeline_rr_enabled"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
mx_AutoHideScrollbar
|
||||
mx_ScrollPanel
|
||||
mx_RoomView_messagePanel
|
||||
mx_GroupLayout
|
||||
"
|
||||
>
|
||||
<div class="mx_RoomView_messageListWrapper">
|
||||
<ol
|
||||
class="mx_RoomView_MessageList"
|
||||
aria-live="polite"
|
||||
role="list"
|
||||
>
|
||||
<div class="mx_NewRoomIntro">
|
||||
${roomAvatar}
|
||||
<h2> ${this.room.name} </h2>
|
||||
<p> ${createdText} <br/><br/> ${exportedText} </p>
|
||||
<br/>
|
||||
<p> ${topicText} </p>
|
||||
</div>
|
||||
${content}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx_RoomView_statusArea">
|
||||
<div class="mx_RoomView_statusAreaBox">
|
||||
<div class="mx_RoomView_statusAreaBox_line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="snackbar"/>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
protected getAvatarURL(event: MatrixEvent): string {
|
||||
const member = event.sender;
|
||||
return (
|
||||
member.getMxcAvatarUrl() &&
|
||||
mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(
|
||||
30,
|
||||
30,
|
||||
"crop",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected async saveAvatarIfNeeded(event: MatrixEvent) {
|
||||
const member = event.sender;
|
||||
if (!this.avatars.has(member.userId)) {
|
||||
try {
|
||||
const avatarUrl = this.getAvatarURL(event);
|
||||
this.avatars.set(member.userId, true);
|
||||
const image = await fetch(avatarUrl);
|
||||
const blob = await image.blob();
|
||||
this.addFile(`users/${member.userId.replace(/:/g, '-')}.png`, blob);
|
||||
} catch (err) {
|
||||
console.log("Failed to fetch user's avatar" + err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected getDateSeparator(event: MatrixEvent) {
|
||||
const ts = event.getTs();
|
||||
const dateSeparator = <li key={ts}><DateSeparator forExport={true} key={ts} ts={ts} /></li>;
|
||||
return renderToStaticMarkup(dateSeparator);
|
||||
}
|
||||
|
||||
protected needsDateSeparator(event: MatrixEvent, prevEvent: MatrixEvent) {
|
||||
if (prevEvent == null) return true;
|
||||
return wantsDateSeparator(prevEvent.getDate(), event.getDate());
|
||||
}
|
||||
|
||||
public getEventTile(mxEv: MatrixEvent, continuation: boolean) {
|
||||
return <div className="mx_Export_EventWrapper" id={mxEv.getId()}>
|
||||
<MatrixClientContext.Provider value={this.client}>
|
||||
<EventTile
|
||||
mxEvent={mxEv}
|
||||
continuation={continuation}
|
||||
isRedacted={mxEv.isRedacted()}
|
||||
replacingEventId={mxEv.replacingEventId()}
|
||||
forExport={true}
|
||||
readReceipts={null}
|
||||
alwaysShowTimestamps={true}
|
||||
readReceiptMap={null}
|
||||
showUrlPreview={false}
|
||||
checkUnmounting={() => false}
|
||||
isTwelveHour={false}
|
||||
last={false}
|
||||
lastInSection={false}
|
||||
permalinkCreator={this.permalinkCreator}
|
||||
lastSuccessful={false}
|
||||
isSelectedEvent={false}
|
||||
getRelationsForEvent={null}
|
||||
showReactions={false}
|
||||
layout={Layout.Group}
|
||||
enableFlair={false}
|
||||
showReadReceipts={false}
|
||||
/>
|
||||
</MatrixClientContext.Provider>
|
||||
</div>;
|
||||
}
|
||||
|
||||
protected async getEventTileMarkup(mxEv: MatrixEvent, continuation: boolean, filePath?: string) {
|
||||
const hasAvatar = !!this.getAvatarURL(mxEv);
|
||||
if (hasAvatar) await this.saveAvatarIfNeeded(mxEv);
|
||||
const EventTile = this.getEventTile(mxEv, continuation);
|
||||
let eventTileMarkup: string;
|
||||
|
||||
if (
|
||||
mxEv.getContent().msgtype == MsgType.Emote ||
|
||||
mxEv.getContent().msgtype == MsgType.Notice ||
|
||||
mxEv.getContent().msgtype === MsgType.Text
|
||||
) {
|
||||
// to linkify textual events, we'll need lifecycle methods which won't be invoked in renderToString
|
||||
// So, we'll have to render the component into a temporary root element
|
||||
const tempRoot = document.createElement('div');
|
||||
ReactDOM.render(
|
||||
EventTile,
|
||||
tempRoot,
|
||||
);
|
||||
eventTileMarkup = tempRoot.innerHTML;
|
||||
} else {
|
||||
eventTileMarkup = renderToStaticMarkup(EventTile);
|
||||
}
|
||||
|
||||
if (filePath) {
|
||||
const mxc = mxEv.getContent().url || mxEv.getContent().file?.url;
|
||||
eventTileMarkup = eventTileMarkup.split(mxc).join(filePath);
|
||||
}
|
||||
eventTileMarkup = eventTileMarkup.replace(/<span class="mx_MFileBody_info_icon".*?>.*?<\/span>/, '');
|
||||
if (hasAvatar) {
|
||||
eventTileMarkup = eventTileMarkup.replace(
|
||||
encodeURI(this.getAvatarURL(mxEv)).replace(/&/g, '&'),
|
||||
`users/${mxEv.sender.userId.replace(/:/g, "-")}.png`,
|
||||
);
|
||||
}
|
||||
return eventTileMarkup;
|
||||
}
|
||||
|
||||
protected createModifiedEvent(text: string, mxEv: MatrixEvent, italic=true) {
|
||||
const modifiedContent = {
|
||||
msgtype: "m.text",
|
||||
body: `${text}`,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: `${text}`,
|
||||
};
|
||||
if (italic) {
|
||||
modifiedContent.formatted_body = '<em>' + modifiedContent.formatted_body + '</em>';
|
||||
modifiedContent.body = '*' + modifiedContent.body + '*';
|
||||
}
|
||||
const modifiedEvent = new MatrixEvent();
|
||||
modifiedEvent.event = mxEv.event;
|
||||
modifiedEvent.sender = mxEv.sender;
|
||||
modifiedEvent.event.type = "m.room.message";
|
||||
modifiedEvent.event.content = modifiedContent;
|
||||
return modifiedEvent;
|
||||
}
|
||||
|
||||
protected async createMessageBody(mxEv: MatrixEvent, joined = false) {
|
||||
let eventTile: string;
|
||||
try {
|
||||
if (this.isAttachment(mxEv)) {
|
||||
if (this.exportOptions.attachmentsIncluded) {
|
||||
try {
|
||||
const blob = await this.getMediaBlob(mxEv);
|
||||
if (this.totalSize + blob.size > this.exportOptions.maxSize) {
|
||||
eventTile = await this.getEventTileMarkup(
|
||||
this.createModifiedEvent(this.mediaOmitText, mxEv),
|
||||
joined,
|
||||
);
|
||||
} else {
|
||||
this.totalSize += blob.size;
|
||||
const filePath = this.getFilePath(mxEv);
|
||||
eventTile = await this.getEventTileMarkup(mxEv, joined, filePath);
|
||||
if (this.totalSize == this.exportOptions.maxSize) {
|
||||
this.exportOptions.attachmentsIncluded = false;
|
||||
}
|
||||
this.addFile(filePath, blob);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Error while fetching file" + e);
|
||||
eventTile = await this.getEventTileMarkup(
|
||||
this.createModifiedEvent(_t("Error fetching file"), mxEv),
|
||||
joined,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
eventTile = await this.getEventTileMarkup(
|
||||
this.createModifiedEvent(this.mediaOmitText, mxEv),
|
||||
joined,
|
||||
);
|
||||
}
|
||||
} else eventTile = await this.getEventTileMarkup(mxEv, joined);
|
||||
} catch (e) {
|
||||
// TODO: Handle callEvent errors
|
||||
console.error(e);
|
||||
eventTile = await this.getEventTileMarkup(
|
||||
this.createModifiedEvent(textForEvent(mxEv), mxEv, false),
|
||||
joined,
|
||||
);
|
||||
}
|
||||
|
||||
return eventTile;
|
||||
}
|
||||
|
||||
protected async createHTML(events: MatrixEvent[], start: number) {
|
||||
let content = "";
|
||||
let prevEvent = null;
|
||||
for (let i = start; i < Math.min(start + 1000, events.length); i++) {
|
||||
const event = events[i];
|
||||
this.updateProgress(`Processing event ${i + 1} out of ${events.length}`, false, true);
|
||||
if (this.cancelled) return this.cleanUp();
|
||||
if (!haveTileForEvent(event)) continue;
|
||||
|
||||
content += this.needsDateSeparator(event, prevEvent) ? this.getDateSeparator(event) : "";
|
||||
const shouldBeJoined = !this.needsDateSeparator(event, prevEvent)
|
||||
&& shouldFormContinuation(prevEvent, event, false);
|
||||
const body = await this.createMessageBody(event, shouldBeJoined);
|
||||
this.totalSize += Buffer.byteLength(body);
|
||||
content += body;
|
||||
prevEvent = event;
|
||||
}
|
||||
return await this.wrapHTML(content);
|
||||
}
|
||||
|
||||
public async export() {
|
||||
this.updateProgress("Starting export...");
|
||||
|
||||
const fetchStart = performance.now();
|
||||
const res = await this.getRequiredEvents();
|
||||
const fetchEnd = performance.now();
|
||||
|
||||
this.updateProgress(`Fetched ${res.length} events in ${(fetchEnd - fetchStart)/1000}s`, true, false);
|
||||
|
||||
this.updateProgress("Creating HTML...");
|
||||
for (let page = 0; page < res.length / 1000; page++) {
|
||||
const html = await this.createHTML(res, page * 1000);
|
||||
this.addFile(`messages${page ? page + 1 : ""}.html`, new Blob([html]));
|
||||
}
|
||||
const exportCSS = await getExportCSS();
|
||||
this.addFile("css/style.css", new Blob([exportCSS]));
|
||||
this.addFile("js/script.js", new Blob([exportJS]));
|
||||
|
||||
await this.downloadZIP();
|
||||
|
||||
const exportEnd = performance.now();
|
||||
|
||||
if (this.cancelled) {
|
||||
console.info("Export cancelled successfully");
|
||||
} else {
|
||||
this.updateProgress("Export successful!");
|
||||
this.updateProgress(`Exported ${res.length} events in ${(exportEnd - fetchStart)/1000} seconds`);
|
||||
}
|
||||
|
||||
this.cleanUp();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
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 Exporter from "./Exporter";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { formatFullDateNoDay, formatFullDateNoDayNoTime } from "../../DateUtils";
|
||||
import { haveTileForEvent } from "../../components/views/rooms/EventTile";
|
||||
import { ExportType } from "./exportUtils";
|
||||
import { IExportOptions } from "./exportUtils";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
export default class JSONExporter extends Exporter {
|
||||
protected totalSize = 0;
|
||||
protected messages: Record<string, any>[] = [];
|
||||
|
||||
constructor(
|
||||
room: Room,
|
||||
exportType: ExportType,
|
||||
exportOptions: IExportOptions,
|
||||
setProgressText: React.Dispatch<React.SetStateAction<string>>,
|
||||
) {
|
||||
super(room, exportType, exportOptions, setProgressText);
|
||||
}
|
||||
|
||||
protected createJSONString(): string {
|
||||
const exportDate = formatFullDateNoDayNoTime(new Date());
|
||||
const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
|
||||
const creatorName = this.room?.getMember(creator)?.rawDisplayName || creator;
|
||||
const topic = this.room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || "";
|
||||
const exporter = this.client.getUserId();
|
||||
const exporterName = this.room?.getMember(exporter)?.rawDisplayName || exporter;
|
||||
const jsonObject = {
|
||||
room_name: this.room.name,
|
||||
room_creator: creatorName,
|
||||
topic,
|
||||
export_date: exportDate,
|
||||
exported_by: exporterName,
|
||||
messages: this.messages,
|
||||
};
|
||||
return JSON.stringify(jsonObject, null, 2);
|
||||
}
|
||||
|
||||
protected async getJSONString(mxEv: MatrixEvent) {
|
||||
if (this.exportOptions.attachmentsIncluded && this.isAttachment(mxEv)) {
|
||||
try {
|
||||
const blob = await this.getMediaBlob(mxEv);
|
||||
if (this.totalSize + blob.size < this.exportOptions.maxSize) {
|
||||
this.totalSize += blob.size;
|
||||
const filePath = this.getFilePath(mxEv);
|
||||
if (this.totalSize == this.exportOptions.maxSize) {
|
||||
this.exportOptions.attachmentsIncluded = false;
|
||||
}
|
||||
this.addFile(filePath, blob);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("Error fetching file: " + err);
|
||||
}
|
||||
}
|
||||
const jsonEvent: any = mxEv.toJSON();
|
||||
const clearEvent = mxEv.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
|
||||
return clearEvent;
|
||||
}
|
||||
|
||||
protected async createOutput(events: MatrixEvent[]) {
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
this.updateProgress(`Processing event ${i + 1} out of ${events.length}`, false, true);
|
||||
if (this.cancelled) return this.cleanUp();
|
||||
if (!haveTileForEvent(event)) continue;
|
||||
this.messages.push(await this.getJSONString(event));
|
||||
}
|
||||
return this.createJSONString();
|
||||
}
|
||||
|
||||
public async export() {
|
||||
console.info("Starting export process...");
|
||||
console.info("Fetching events...");
|
||||
|
||||
const fetchStart = performance.now();
|
||||
const res = await this.getRequiredEvents();
|
||||
const fetchEnd = performance.now();
|
||||
|
||||
console.log(`Fetched ${res.length} events in ${(fetchEnd - fetchStart)/1000}s`);
|
||||
|
||||
console.info("Creating output...");
|
||||
const text = await this.createOutput(res);
|
||||
|
||||
if (this.files.length) {
|
||||
this.addFile("export.json", new Blob([text]));
|
||||
await this.downloadZIP();
|
||||
} else {
|
||||
const fileName = `matrix-export-${formatFullDateNoDay(new Date())}.json`;
|
||||
this.downloadPlainText(fileName, text);
|
||||
}
|
||||
|
||||
const exportEnd = performance.now();
|
||||
|
||||
if (this.cancelled) {
|
||||
console.info("Export cancelled successfully");
|
||||
} else {
|
||||
console.info("Export successful!");
|
||||
console.log(`Exported ${res.length} events in ${(exportEnd - fetchStart)/1000} seconds`);
|
||||
}
|
||||
|
||||
this.cleanUp();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
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 Exporter from "./Exporter";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { IContent, 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 { IExportOptions } from "./exportUtils";
|
||||
import { textForEvent } from "../../TextForEvent";
|
||||
|
||||
export default class PlainTextExporter extends Exporter {
|
||||
protected totalSize: number;
|
||||
protected mediaOmitText: string;
|
||||
|
||||
constructor(
|
||||
room: Room,
|
||||
exportType: ExportType,
|
||||
exportOptions: IExportOptions,
|
||||
setProgressText: React.Dispatch<React.SetStateAction<string>>,
|
||||
) {
|
||||
super(room, exportType, exportOptions, setProgressText);
|
||||
this.totalSize = 0;
|
||||
this.mediaOmitText = !this.exportOptions.attachmentsIncluded
|
||||
? _t("Media omitted")
|
||||
: _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 = "";
|
||||
if (this.isAttachment(mxEv)) {
|
||||
if (this.exportOptions.attachmentsIncluded) {
|
||||
try {
|
||||
const blob = await this.getMediaBlob(mxEv);
|
||||
if (this.totalSize + blob.size > this.exportOptions.maxSize) {
|
||||
mediaText = ` (${this.mediaOmitText})`;
|
||||
} else {
|
||||
this.totalSize += blob.size;
|
||||
const filePath = this.getFilePath(mxEv);
|
||||
mediaText = " (" + _t("File Attached") + ")";
|
||||
this.addFile(filePath, blob);
|
||||
if (this.totalSize == this.exportOptions.maxSize) {
|
||||
this.exportOptions.attachmentsIncluded = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
mediaText = " (" + _t("Error fetching file") + ")";
|
||||
console.log("Error fetching file " + error);
|
||||
}
|
||||
} else mediaText = ` (${this.mediaOmitText})`;
|
||||
}
|
||||
if (this.isReply(mxEv)) return senderDisplayName + ": " + this.textForReplyEvent(mxEv.getContent()) + mediaText;
|
||||
else return textForEvent(mxEv) + mediaText;
|
||||
};
|
||||
|
||||
protected async createOutput(events: MatrixEvent[]) {
|
||||
let content = "";
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
this.updateProgress(`Processing event ${i + 1} out of ${events.length}`, false, true);
|
||||
if (this.cancelled) return this.cleanUp();
|
||||
if (!haveTileForEvent(event)) continue;
|
||||
const textForEvent = await this.plainTextForEvent(event);
|
||||
content += textForEvent && `${new Date(event.getTs()).toLocaleString()} - ${textForEvent}\n`;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
public async export() {
|
||||
this.updateProgress("Starting export process...");
|
||||
this.updateProgress("Fetching events...");
|
||||
|
||||
const fetchStart = performance.now();
|
||||
const res = await this.getRequiredEvents();
|
||||
const fetchEnd = performance.now();
|
||||
|
||||
console.log(`Fetched ${res.length} events in ${(fetchEnd - fetchStart)/1000}s`);
|
||||
|
||||
this.updateProgress("Creating output...");
|
||||
const text = await this.createOutput(res);
|
||||
|
||||
if (this.files.length) {
|
||||
this.addFile("export.txt", new Blob([text]));
|
||||
await this.downloadZIP();
|
||||
} else {
|
||||
const fileName = `matrix-export-${formatFullDateNoDay(new Date())}.txt`;
|
||||
this.downloadPlainText(fileName, text);
|
||||
}
|
||||
|
||||
const exportEnd = performance.now();
|
||||
|
||||
if (this.cancelled) {
|
||||
console.info("Export cancelled successfully");
|
||||
} else {
|
||||
console.info("Export successful!");
|
||||
console.log(`Exported ${res.length} events in ${(exportEnd - fetchStart)/1000} seconds`);
|
||||
}
|
||||
|
||||
this.cleanUp();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/* eslint-disable max-len, camelcase */
|
||||
|
||||
import customCSS from "!!raw-loader!./exportCustomCSS.css";
|
||||
|
||||
const getExportCSS = async (): Promise<string> => {
|
||||
const stylesheets: string[] = [];
|
||||
document.querySelectorAll('link[rel="stylesheet"]').forEach((e: any) => {
|
||||
if (e.href.endsWith("bundle.css") || e.href.endsWith("theme-light.css")) {
|
||||
stylesheets.push(e.href);
|
||||
}
|
||||
});
|
||||
let CSS = "";
|
||||
for (const stylesheet of stylesheets) {
|
||||
const res = await fetch(stylesheet);
|
||||
const innerText = await res.text();
|
||||
CSS += innerText;
|
||||
}
|
||||
const fontFaceRegex = /@font-face {.*?}/sg;
|
||||
|
||||
CSS = CSS.replace(fontFaceRegex, '');
|
||||
CSS = CSS.replace(
|
||||
/font-family: (Inter|'Inter')/g,
|
||||
`font-family: -apple-system, BlinkMacSystemFont, avenir next,
|
||||
avenir, segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial, sans-serif`,
|
||||
);
|
||||
CSS = CSS.replace(
|
||||
/font-family: Inconsolata/g,
|
||||
"font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace",
|
||||
);
|
||||
|
||||
return CSS + customCSS;
|
||||
};
|
||||
|
||||
export default getExportCSS;
|
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/*
|
||||
This file is raw-imported (imported as plain text) for the export bundle, which is the reason for the .css format and the colours being hard-coded hard-coded.
|
||||
*/
|
||||
|
||||
#snackbar {
|
||||
display: flex;
|
||||
visibility: hidden;
|
||||
min-width: 250px;
|
||||
margin-left: -125px;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
left: 50%;
|
||||
bottom: 30px;
|
||||
font-size: 17px;
|
||||
padding: 6px 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir,
|
||||
segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial,
|
||||
sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 1.43;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.01071em;
|
||||
}
|
||||
|
||||
#snackbar.mx_show {
|
||||
visibility: visible;
|
||||
-webkit-animation: mx_snackbar_fadein 0.5s, mx_snackbar_fadeout 0.5s 2.5s;
|
||||
animation: mx_snackbar_fadein 0.5s, mx_snackbar_fadeout 0.5s 2.5s;
|
||||
}
|
||||
|
||||
a.mx_reply_anchor {
|
||||
cursor: pointer;
|
||||
color: #238cf5;
|
||||
}
|
||||
|
||||
a.mx_reply_anchor:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@-webkit-keyframes mx_snackbar_fadein {
|
||||
from {
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
bottom: 30px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mx_snackbar_fadein {
|
||||
from {
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
bottom: 30px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes mx_snackbar_fadeout {
|
||||
from {
|
||||
bottom: 30px;
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mx_snackbar_fadeout {
|
||||
from {
|
||||
bottom: 30px;
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
scroll-behavior: smooth !important;
|
||||
}
|
||||
|
||||
.mx_Export_EventWrapper:target {
|
||||
background: white;
|
||||
animation: mx_event_highlight_animation 2s linear;
|
||||
}
|
||||
|
||||
@keyframes mx_event_highlight_animation {
|
||||
0%,
|
||||
100% {
|
||||
background: white;
|
||||
}
|
||||
50% {
|
||||
background: #e3e2df;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_ReplyThread_Export {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.mx_RedactedBody {
|
||||
padding-left: unset;
|
||||
}
|
||||
|
||||
img {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mx_MatrixChat {
|
||||
max-width: 100%;
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// This file is raw-imported (imported as plain text) for the export bundle, which is why this is in JS
|
||||
function showToastIfNeeded(replyId) {
|
||||
const el = document.getElementById(replyId);
|
||||
if (!el) {
|
||||
showToast("The message you're looking for wasn't exported");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(text) {
|
||||
const el = document.getElementById("snackbar");
|
||||
el.innerHTML = text;
|
||||
el.className = "mx_show";
|
||||
setTimeout(() => {
|
||||
el.className = el.className.replace("mx_show", "");
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
window.onload = () => {
|
||||
document.querySelectorAll('.mx_reply_anchor').forEach(element => {
|
||||
element.addEventListener('click', event => {
|
||||
showToastIfNeeded(event.target.getAttribute("scroll-to"));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
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 { _t } from "../../languageHandler";
|
||||
|
||||
export enum ExportFormat {
|
||||
Html = "Html",
|
||||
PlainText = "PlainText",
|
||||
Json = "Json",
|
||||
}
|
||||
|
||||
export enum ExportType {
|
||||
Timeline = "Timeline",
|
||||
Beginning = "Beginning",
|
||||
LastNMessages = "LastNMessages",
|
||||
// START_DATE = "START_DATE",
|
||||
}
|
||||
|
||||
export const textForFormat = (format: ExportFormat): string => {
|
||||
switch (format) {
|
||||
case ExportFormat.Html:
|
||||
return _t("HTML");
|
||||
case ExportFormat.Json:
|
||||
return _t("JSON");
|
||||
case ExportFormat.PlainText:
|
||||
return _t("Plain Text");
|
||||
default:
|
||||
throw new Error("Unknown format");
|
||||
}
|
||||
};
|
||||
|
||||
export const textForType = (type: ExportType): string => {
|
||||
switch (type) {
|
||||
case ExportType.Beginning:
|
||||
return _t("From the beginning");
|
||||
case ExportType.LastNMessages:
|
||||
return _t("Specify a number of messages");
|
||||
case ExportType.Timeline:
|
||||
return _t("Current Timeline");
|
||||
default:
|
||||
throw new Error("Unknown type: " + type);
|
||||
// case exportTypes.START_DATE:
|
||||
// return _t("From a specific date");
|
||||
}
|
||||
};
|
||||
|
||||
export interface IExportOptions {
|
||||
// startDate?: number;
|
||||
numberOfMessages?: number;
|
||||
attachmentsIncluded: boolean;
|
||||
maxSize: number;
|
||||
}
|
|
@ -112,6 +112,7 @@ export function createTestClient() {
|
|||
* @param {number=} opts.ts Optional. Timestamp for the event
|
||||
* @param {Object} opts.content The event.content
|
||||
* @param {boolean} opts.event True to make a MatrixEvent.
|
||||
* @param {unsigned=} opts.unsigned
|
||||
* @return {Object} a JSON object representing this event.
|
||||
*/
|
||||
export function mkEvent(opts) {
|
||||
|
@ -166,12 +167,13 @@ export function mkPresence(opts) {
|
|||
* @param {string} opts.room The room ID for the event.
|
||||
* @param {string} opts.mship The content.membership for the event.
|
||||
* @param {string} opts.prevMship The prev_content.membership for the event.
|
||||
* @param {number=} opts.ts Optional. Timestamp for the event
|
||||
* @param {string} opts.user The user ID for the event.
|
||||
* @param {RoomMember} opts.target The target of the event.
|
||||
* @param {string} opts.skey The other user ID for the event if applicable
|
||||
* @param {string=} opts.skey The other user ID for the event if applicable
|
||||
* e.g. for invites/bans.
|
||||
* @param {string} opts.name The content.displayname for the event.
|
||||
* @param {string} opts.url The content.avatar_url for the event.
|
||||
* @param {string=} opts.url The content.avatar_url for the event.
|
||||
* @param {boolean} opts.event True to make a MatrixEvent.
|
||||
* @return {Object|MatrixEvent} The event
|
||||
*/
|
||||
|
@ -203,8 +205,9 @@ export function mkMembership(opts) {
|
|||
* @param {Object} opts Values for the message
|
||||
* @param {string} opts.room The room ID for the event.
|
||||
* @param {string} opts.user The user ID for the event.
|
||||
* @param {string} opts.msg Optional. The content.body for the event.
|
||||
* @param {number} opts.ts The timestamp for the event.
|
||||
* @param {boolean} opts.event True to make a MatrixEvent.
|
||||
* @param {string=} opts.msg Optional. The content.body for the event.
|
||||
* @return {Object|MatrixEvent} The event
|
||||
*/
|
||||
export function mkMessage(opts) {
|
||||
|
|
|
@ -0,0 +1,274 @@
|
|||
/*
|
||||
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 { IContent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk";
|
||||
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
||||
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";
|
||||
import * as TestUtilsMatrix from '../test-utils';
|
||||
import { stubClient } from '../test-utils';
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
let client: MatrixClient;
|
||||
|
||||
const MY_USER_ID = "@me:here";
|
||||
|
||||
function generateRoomId() {
|
||||
return '!' + Math.random().toString().slice(2, 10) + ':domain';
|
||||
}
|
||||
|
||||
interface ITestContent extends IContent {
|
||||
expectedText: string;
|
||||
}
|
||||
|
||||
describe('export', function() {
|
||||
stubClient();
|
||||
client = MatrixClientPeg.get();
|
||||
client.getUserId = () => {
|
||||
return MY_USER_ID;
|
||||
};
|
||||
|
||||
const mockExportOptions: IExportOptions = {
|
||||
numberOfMessages: 5,
|
||||
maxSize: 100 * 1024 * 1024,
|
||||
attachmentsIncluded: false,
|
||||
};
|
||||
|
||||
const invalidExportOptions: IExportOptions[] = [
|
||||
{
|
||||
numberOfMessages: 10**9,
|
||||
maxSize: 1024 * 1024 * 1024,
|
||||
attachmentsIncluded: false,
|
||||
},
|
||||
{
|
||||
numberOfMessages: -1,
|
||||
maxSize: 4096 * 1024 * 1024,
|
||||
attachmentsIncluded: false,
|
||||
},
|
||||
{
|
||||
numberOfMessages: 0,
|
||||
maxSize: 0,
|
||||
attachmentsIncluded: false,
|
||||
},
|
||||
];
|
||||
|
||||
function createRoom() {
|
||||
const room = new Room(generateRoomId(), null, client.getUserId());
|
||||
return room;
|
||||
}
|
||||
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 = [];
|
||||
let i: number;
|
||||
// plain text
|
||||
for (i = 0; i < 10; i++) {
|
||||
matrixEvents.push(TestUtilsMatrix.mkMessage({
|
||||
event: true, room: "!room:id", user: "@user:id",
|
||||
ts: ts0 + i * 1000,
|
||||
}));
|
||||
}
|
||||
// reply events
|
||||
for (i = 0; i < 10; i++) {
|
||||
matrixEvents.push(TestUtilsMatrix.mkEvent({
|
||||
"content": {
|
||||
"body": "> <@me:here> Hi\n\nTest",
|
||||
"format": "org.matrix.custom.html",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
"event_id": "$" + Math.random() + "-" + Math.random(),
|
||||
},
|
||||
},
|
||||
"msgtype": "m.text",
|
||||
},
|
||||
"user": "@me:here",
|
||||
"type": "m.room.message",
|
||||
"room": mockRoom.roomId,
|
||||
"event": true,
|
||||
}));
|
||||
}
|
||||
// membership events
|
||||
for (i = 0; i < 10; i++) {
|
||||
matrixEvents.push(TestUtilsMatrix.mkMembership({
|
||||
event: true, room: "!room:id", user: "@user:id",
|
||||
target: {
|
||||
userId: "@user:id",
|
||||
name: "Bob",
|
||||
getAvatarUrl: () => {
|
||||
return "avatar.jpeg";
|
||||
},
|
||||
getMxcAvatarUrl: () => 'mxc://avatar.url/image.png',
|
||||
},
|
||||
ts: ts0 + i*1000,
|
||||
mship: 'join',
|
||||
prevMship: 'join',
|
||||
name: 'A user',
|
||||
}));
|
||||
}
|
||||
// emote
|
||||
matrixEvents.push(TestUtilsMatrix.mkEvent({
|
||||
"content": {
|
||||
"body": "waves",
|
||||
"msgtype": "m.emote",
|
||||
},
|
||||
"user": "@me:here",
|
||||
"type": "m.room.message",
|
||||
"room": mockRoom.roomId,
|
||||
"event": true,
|
||||
}));
|
||||
// redacted events
|
||||
for (i = 0; i < 10; i++) {
|
||||
matrixEvents.push(mkRedactedEvent(i));
|
||||
}
|
||||
return matrixEvents;
|
||||
}
|
||||
|
||||
const events: MatrixEvent[] = mkEvents();
|
||||
|
||||
it('checks if the export format is valid', function() {
|
||||
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 class="mx_MFileBody_info_icon">.*?<\/span>/;
|
||||
expect(fileRegex.test(
|
||||
renderToString(exporter.getEventTile(mkFileEvent(), true))),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('checks if the export options are valid', function() {
|
||||
for (const exportOption of invalidExportOptions) {
|
||||
expect(
|
||||
() =>
|
||||
new PlainTextExporter(mockRoom, ExportType.Beginning, exportOption, null),
|
||||
).toThrowError("Invalid export options");
|
||||
}
|
||||
});
|
||||
|
||||
it('tests the file extension splitter', function() {
|
||||
const exporter = new PlainTextExporter(mockRoom, ExportType.Beginning, mockExportOptions, null);
|
||||
const fileNameWithExtensions = {
|
||||
"": ["", ""],
|
||||
"name": ["name", ""],
|
||||
"name.txt": ["name", ".txt"],
|
||||
".htpasswd": ["", ".htpasswd"],
|
||||
"name.with.many.dots.myext": ["name.with.many.dots", ".myext"],
|
||||
};
|
||||
for (const fileName in fileNameWithExtensions) {
|
||||
expect(exporter.splitFileName(fileName)).toStrictEqual(fileNameWithExtensions[fileName]);
|
||||
}
|
||||
});
|
||||
|
||||
it('checks if the reply regex executes correctly', function() {
|
||||
const eventContents: ITestContent[] = [
|
||||
{
|
||||
"msgtype": "m.text",
|
||||
"body": "> <@me:here> Source\n\nReply",
|
||||
"expectedText": "<@me:here \"Source\"> Reply",
|
||||
},
|
||||
{
|
||||
"msgtype": "m.text",
|
||||
// if the reply format is invalid, then return the body
|
||||
"body": "Invalid reply format",
|
||||
"expectedText": "Invalid reply format",
|
||||
},
|
||||
{
|
||||
"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",
|
||||
},
|
||||
{
|
||||
"msgtype": "m.text",
|
||||
"body": "> <@me:here> This\nsource\nhas\nnew\nlines\n\nReply",
|
||||
"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);
|
||||
}
|
||||
});
|
||||
|
||||
it("checks if the render to string doesn't throw any error for different types of events", function() {
|
||||
const exporter = new HTMLExporter(mockRoom, ExportType.Beginning, mockExportOptions, null);
|
||||
for (const event of events) {
|
||||
expect(renderToString(exporter.getEventTile(event, false))).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
95
yarn.lock
95
yarn.lock
|
@ -1654,6 +1654,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/fbemitter/-/fbemitter-2.0.32.tgz#8ed204da0f54e9c8eaec31b1eec91e25132d082c"
|
||||
integrity sha1-jtIE2g9U6cjq7DGx7skeJRMtCCw=
|
||||
|
||||
"@types/file-saver@^2.0.3":
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.3.tgz#b734c4f5a04d20615eaed3dc106e2ab321082009"
|
||||
integrity sha512-MBIou8pd/41jkff7s97B47bc9+p0BszqqDJsO51yDm49uUxeKzrfuNl5fSLC6BpLEWKA8zlwyqALVmXrFwoBHQ==
|
||||
|
||||
"@types/flux@^3.1.9":
|
||||
version "3.1.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/flux/-/flux-3.1.10.tgz#7c6306e86ecb434d00f38cb82f092640c7bd4098"
|
||||
|
@ -1712,10 +1717,10 @@
|
|||
jest-diff "^26.0.0"
|
||||
pretty-format "^26.0.0"
|
||||
|
||||
"@types/json-schema@^7.0.7":
|
||||
version "7.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
|
||||
integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==
|
||||
"@types/json-schema@^7.0.7", "@types/json-schema@^7.0.8":
|
||||
version "7.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.8.tgz#edf1bf1dbf4e04413ca8e5b17b3b7d7d54b59818"
|
||||
integrity sha512-YSBPTLTVm2e2OoQIDYx8HaeWJ5tTToLH67kXR7zYNGupXMEHa2++G8k+DczX2cFVgalypqtyZIcU19AFcmOpmg==
|
||||
|
||||
"@types/linkifyjs@^2.1.3":
|
||||
version "2.1.4"
|
||||
|
@ -2016,7 +2021,12 @@ agent-base@6:
|
|||
dependencies:
|
||||
debug "4"
|
||||
|
||||
ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4:
|
||||
ajv-keywords@^3.5.2:
|
||||
version "3.5.2"
|
||||
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d"
|
||||
integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==
|
||||
|
||||
ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5:
|
||||
version "6.12.6"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
|
||||
integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
|
||||
|
@ -2421,6 +2431,11 @@ before-after-hook@^2.2.0:
|
|||
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.2.tgz#a6e8ca41028d90ee2c24222f201c90956091613e"
|
||||
integrity sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==
|
||||
|
||||
big.js@^5.2.2:
|
||||
version "5.2.2"
|
||||
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
|
||||
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
|
||||
|
||||
binary-extensions@^1.0.0:
|
||||
version "1.13.1"
|
||||
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65"
|
||||
|
@ -3316,6 +3331,11 @@ emojibase-regex@^5.1.3:
|
|||
resolved "https://registry.yarnpkg.com/emojibase-regex/-/emojibase-regex-5.1.3.tgz#f0ef621ed6ec624becd2326f999fd4ea01b94554"
|
||||
integrity sha512-gT8T9LxLA8VJdI+8KQtyykB9qKzd7WuUL3M2yw6y9tplFeufOUANg3UKVaKUvkMcRNvZsSElWhxcJrx8WPE12g==
|
||||
|
||||
emojis-list@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
|
||||
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
|
||||
|
||||
encoding@^0.1.11:
|
||||
version "0.1.13"
|
||||
resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9"
|
||||
|
@ -4464,6 +4484,11 @@ ignore@^5.1.4, ignore@^5.1.8:
|
|||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
|
||||
integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
|
||||
|
||||
immediate@~3.0.5:
|
||||
version "3.0.6"
|
||||
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
|
||||
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
|
||||
|
||||
immutable@^3.7.4:
|
||||
version "3.8.2"
|
||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
|
||||
|
@ -5273,6 +5298,11 @@ jest-pnp-resolver@^1.2.2:
|
|||
resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c"
|
||||
integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==
|
||||
|
||||
jest-raw-loader@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/jest-raw-loader/-/jest-raw-loader-1.0.1.tgz#ce9f56d54650f157c4a7d16d224ba5d613bcd626"
|
||||
integrity sha1-zp9W1UZQ8VfEp9FtIkul1hO81iY=
|
||||
|
||||
jest-regex-util@^26.0.0:
|
||||
version "26.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-26.0.0.tgz#d25e7184b36e39fd466c3bc41be0971e821fee28"
|
||||
|
@ -5584,6 +5614,16 @@ jsprim@^1.2.2:
|
|||
array-includes "^3.1.2"
|
||||
object.assign "^4.1.2"
|
||||
|
||||
jszip@^3.7.0:
|
||||
version "3.7.0"
|
||||
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.7.0.tgz#9b8b995a4e7c9024653ce743e902076a82fdf4e6"
|
||||
integrity sha512-Y2OlFIzrDOPWUnpU0LORIcDn2xN7rC9yKffFM/7pGhQuhO+SUhfm2trkJ/S5amjFvem0Y+1EALz/MEPkvHXVNw==
|
||||
dependencies:
|
||||
lie "~3.3.0"
|
||||
pako "~1.0.2"
|
||||
readable-stream "~2.3.6"
|
||||
set-immediate-shim "~1.0.1"
|
||||
|
||||
katex@^0.12.0:
|
||||
version "0.12.0"
|
||||
resolved "https://registry.yarnpkg.com/katex/-/katex-0.12.0.tgz#2fb1c665dbd2b043edcf8a1f5c555f46beaa0cb9"
|
||||
|
@ -5651,6 +5691,13 @@ levn@~0.3.0:
|
|||
prelude-ls "~1.1.2"
|
||||
type-check "~0.3.2"
|
||||
|
||||
lie@~3.3.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
|
||||
integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==
|
||||
dependencies:
|
||||
immediate "~3.0.5"
|
||||
|
||||
lines-and-columns@^1.1.6:
|
||||
version "1.1.6"
|
||||
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
|
||||
|
@ -5661,6 +5708,15 @@ linkifyjs@^2.1.9:
|
|||
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-2.1.9.tgz#af06e45a2866ff06c4766582590d098a4d584702"
|
||||
integrity sha512-74ivurkK6WHvHFozVaGtQWV38FzBwSTGNmJolEgFp7QgR2bl6ArUWlvT4GcHKbPe1z3nWYi+VUdDZk16zDOVug==
|
||||
|
||||
loader-utils@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0"
|
||||
integrity sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==
|
||||
dependencies:
|
||||
big.js "^5.2.2"
|
||||
emojis-list "^3.0.0"
|
||||
json5 "^2.1.2"
|
||||
|
||||
locate-path@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
|
||||
|
@ -6399,6 +6455,11 @@ pako@^2.0.3:
|
|||
resolved "https://registry.yarnpkg.com/pako/-/pako-2.0.4.tgz#6cebc4bbb0b6c73b0d5b8d7e8476e2b2fbea576d"
|
||||
integrity sha512-v8tweI900AUkZN6heMU/4Uy4cXRc2AYNRggVmTR+dEncawDJgCdLMximOVA2p4qO57WMynangsfGRb5WD6L1Bg==
|
||||
|
||||
pako@~1.0.2:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
|
||||
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
|
||||
|
||||
parent-module@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
||||
|
@ -6804,6 +6865,14 @@ randexp@0.4.6:
|
|||
discontinuous-range "1.0.0"
|
||||
ret "~0.1.10"
|
||||
|
||||
raw-loader@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-4.0.2.tgz#1aac6b7d1ad1501e66efdac1522c73e59a584eb6"
|
||||
integrity sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==
|
||||
dependencies:
|
||||
loader-utils "^2.0.0"
|
||||
schema-utils "^3.0.0"
|
||||
|
||||
re-resizable@^6.9.0:
|
||||
version "6.9.1"
|
||||
resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.9.1.tgz#6be082b55d02364ca4bfee139e04feebdf52441c"
|
||||
|
@ -6943,7 +7012,7 @@ read-pkg@^5.2.0:
|
|||
parse-json "^5.0.0"
|
||||
type-fest "^0.6.0"
|
||||
|
||||
readable-stream@^2.0.2:
|
||||
readable-stream@^2.0.2, readable-stream@~2.3.6:
|
||||
version "2.3.7"
|
||||
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
|
||||
integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
|
||||
|
@ -7322,6 +7391,15 @@ scheduler@^0.20.2:
|
|||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
schema-utils@^3.0.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.1.tgz#bc74c4b6b6995c1d88f76a8b77bea7219e0c8281"
|
||||
integrity sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.8"
|
||||
ajv "^6.12.5"
|
||||
ajv-keywords "^3.5.2"
|
||||
|
||||
"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0:
|
||||
version "5.7.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
|
||||
|
@ -7349,6 +7427,11 @@ set-blocking@^2.0.0:
|
|||
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
|
||||
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
|
||||
|
||||
set-immediate-shim@~1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61"
|
||||
integrity sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=
|
||||
|
||||
set-value@^2.0.0, set-value@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
|
||||
|
|
Loading…
Reference in New Issue