diff --git a/package.json b/package.json index 89084acd68..652a29195b 100644 --- a/package.json +++ b/package.json @@ -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": "/__mocks__/empty.js", "waveWorker\\.min\\.js": "/__mocks__/empty.js", "workers/(.+)\\.worker\\.ts": "/__mocks__/workerMock.js", + "^!!raw-loader!.*": "jest-raw-loader", "RecorderWorklet": "/__mocks__/empty.js" }, "transformIgnorePatterns": [ diff --git a/res/css/_components.scss b/res/css/_components.scss index 4ef95e8cd8..1bf17ab265 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -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"; diff --git a/res/css/views/dialogs/_ExportDialog.scss b/res/css/views/dialogs/_ExportDialog.scss new file mode 100644 index 0000000000..4727ab5f31 --- /dev/null +++ b/res/css/views/dialogs/_ExportDialog.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; + } +} diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index 7ac4787111..c137bb7677 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -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'); +} diff --git a/res/img/element-icons/export.svg b/res/img/element-icons/export.svg new file mode 100644 index 0000000000..49899e9520 --- /dev/null +++ b/res/img/element-icons/export.svg @@ -0,0 +1,14 @@ + + + diff --git a/src/@types/raw-loader.d.ts b/src/@types/raw-loader.d.ts new file mode 100644 index 0000000000..efd825204e --- /dev/null +++ b/src/@types/raw-loader.d.ts @@ -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; +} diff --git a/src/DateUtils.ts b/src/DateUtils.ts index c81099b893..221ecfeb2a 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -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()) + ); +} diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 0e9dc1cf15..6fb4107d20 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -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, diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 74f281405c..fe5f8699b4 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -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, diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 3c734705b7..001df16d40 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -33,7 +33,7 @@ interface IProps extends Omit, "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; diff --git a/src/components/views/dialogs/ExportDialog.tsx b/src/components/views/dialogs/ExportDialog.tsx new file mode 100644 index 0000000000..33a14887fb --- /dev/null +++ b/src/components/views/dialogs/ExportDialog.tsx @@ -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 = ({ 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(100); + const [sizeLimit, setSizeLimit] = useState(8); + const sizeLimitRef = useRef(); + const messageCountRef = useRef(); + const [exportProgressText, setExportProgressText] = useState("Processing..."); + const [displayCancel, setCancelWarning] = useState(false); + const [exportCancelled, setExportCancelled] = useState(false); + const [exportSuccessful, setExportSuccessful] = useState(false); + const [exporter, setExporter] = useStateCallback( + 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 => { + 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 => { + 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 ( + + ); + }); + + let messageCount = null; + if (exportType === ExportType.LastNMessages) { + messageCount = ( + { + setNumberOfMessages(parseInt(e.target.value)); + }} + /> + ); + } + + const sizePostFix = { _t("MB") }; + + if (exportCancelled) { + // Display successful cancellation message + return ( + + ); + } else if (exportSuccessful) { + // Display successful export message + return ( + + ); + } else if (displayCancel) { + // Display cancel warning + return ( + +

+ { _t( + "Are you sure you want to stop exporting your data? If you do, you'll need to start over.", + ) } +

+ setCancelWarning(false)} + onPrimaryButtonClick={confirmCanel} + /> +
+ ); + } else { + // Display export settings + return ( + + { !isExporting ?

+ { _t( + "Select from the options below to export chats from your timeline", + ) } +

: null } + + + { _t("Format") } + + +
+ setExportFormat(ExportFormat[key])} + definitions={exportFormatOptions} + /> + + + { _t("Messages") } + + + { + setExportType(ExportType[e.target.value]); + }} + > + { exportTypeOptions } + + { messageCount } + + + { _t("Size Limit") } + + + setSizeLimit(parseInt(e.target.value))} + /> + + + setAttachments( + (e.target as HTMLInputElement).checked, + ) + } + > + { _t("Include Attachments") } + +
+ { isExporting ? ( +
+ +

+ { exportProgressText } +

+ +
+ ) : ( + onFinished(false)} + /> + ) } +
+ ); + } +}; + +export default ExportDialog; diff --git a/src/components/views/elements/ReplyThread.tsx b/src/components/views/elements/ReplyThread.tsx index bd81218623..b5e2d1191c 100644 --- a/src/components/views/elements/ReplyThread.tsx +++ b/src/components/views/elements/ReplyThread.tsx @@ -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 { }) } ; + } else if (this.props.forExport) { + const eventId = ReplyThread.getParentEventId(this.props.parentEv); + header =

+ { _t("In reply to this message", + {}, + { a: (sub) => ( + { sub } + ), + }) + } +

; } else if (this.state.loading) { header = ; } diff --git a/src/components/views/messages/DateSeparator.tsx b/src/components/views/messages/DateSeparator.tsx index 5d43e2182d..d66fcbf118 100644 --- a/src/components/views/messages/DateSeparator.tsx +++ b/src/components/views/messages/DateSeparator.tsx @@ -35,12 +35,17 @@ function getDaysArray(): string[] { interface IProps { ts: number; + forExport?: boolean; } @replaceableComponent("views.messages.DateSeparator") export default class DateSeparator extends React.Component { 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(); diff --git a/src/components/views/messages/IBodyProps.ts b/src/components/views/messages/IBodyProps.ts index 8aabd3080c..daa05c3b1a 100644 --- a/src/components/views/messages/IBodyProps.ts +++ b/src/components/views/messages/IBodyProps.ts @@ -33,6 +33,7 @@ export interface IBodyProps { onHeightChanged: () => void; showUrlPreview?: boolean; + forExport?: boolean; tileShape: TileShape; maxImageHeight?: number; replacingEventId?: string; diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx index 1975fe8d42..3611435e55 100644 --- a/src/components/views/messages/MAudioBody.tsx +++ b/src/components/views/messages/MAudioBody.tsx @@ -90,6 +90,17 @@ export default class MAudioBody extends React.PureComponent ); } + 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 ( + + + ); + } + if (!this.state.playback) { return ( diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx index c997aa6666..80c6b16f0d 100644 --- a/src/components/views/messages/MFileBody.tsx +++ b/src/components/views/messages/MFileBody.tsx @@ -123,6 +123,11 @@ export default class MFileBody extends React.Component { 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(); } @@ -149,11 +154,6 @@ export default class MFileBody extends React.Component { }); } - 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 { ); } + 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 + + { placeholder } + + ; + } + const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder; if (isEncrypted) { diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index 01bbf3403f..072e111c4b 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -179,6 +179,9 @@ export default class MImageBody extends React.Component { }; 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 { 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 { // Overidden by MStickerBody protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element { - return + return { children } ; } @@ -490,6 +493,7 @@ export default class MImageBody extends React.Component { // 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 ; @@ -510,7 +514,7 @@ export default class MImageBody extends React.Component { 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(); diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index 5af1d7d9f5..d119662f8a 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -79,7 +79,10 @@ export default class MVideoBody extends React.PureComponent } private getContentUrl(): string|null { - const media = mediaFromContent(this.props.mxEvent.getContent()); + const content = 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.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 } 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(); const media = mediaFromContent(content); @@ -209,6 +215,11 @@ export default class MVideoBody extends React.PureComponent this.props.onHeightChanged(); }; + private getFileBody = () => { + if (this.props.forExport) return null; + return this.props.tileShape && ; + }; + render() { const content = this.props.mxEvent.getContent(); const autoplay = SettingsStore.getValue("autoplayVideo"); @@ -222,8 +233,8 @@ export default class MVideoBody extends React.PureComponent ); } - // 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 preload = "none"; } } + + const fileBody = this.getFileBody(); return ( ); } diff --git a/src/components/views/messages/MVoiceOrAudioBody.tsx b/src/components/views/messages/MVoiceOrAudioBody.tsx index 5a7e34b8a1..b4dce5d1aa 100644 --- a/src/components/views/messages/MVoiceOrAudioBody.tsx +++ b/src/components/views/messages/MVoiceOrAudioBody.tsx @@ -24,7 +24,7 @@ import { isVoiceMessage } from "../../../utils/EventUtils"; @replaceableComponent("views.messages.MVoiceOrAudioBody") export default class MVoiceOrAudioBody extends React.PureComponent { public render() { - if (isVoiceMessage(this.props.mxEvent)) { + if (!this.props.forExport && isVoiceMessage(this.props.mxEvent)) { return ; } else { return ; diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 53592e3985..b72e40d194 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -136,6 +136,7 @@ export default class MessageEvent extends React.Component 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} diff --git a/src/components/views/messages/RedactedBody.tsx b/src/components/views/messages/RedactedBody.tsx index 66200036cd..9af4ebf1cb 100644 --- a/src/components/views/messages/RedactedBody.tsx +++ b/src/components/views/messages/RedactedBody.tsx @@ -29,7 +29,6 @@ interface IProps { const RedactedBody = React.forwardRef(({ 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; diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index 00d52831c7..1fe556dde2 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -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 = ({ 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 = ({ room, onClose }) => { + { SettingsStore.getValue("feature_thread") && (