diff --git a/package.json b/package.json index f8d94e2d21..9ac4890465 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "highlight.js": "^10.5.0", "html-entities": "^1.4.0", "is-ip": "^3.1.0", + "jszip": "^3.6.0", "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.20", @@ -98,6 +99,7 @@ "resize-observer-polyfill": "^1.5.1", "rfc4648": "^1.4.0", "sanitize-html": "^2.3.2", + "streamsaver": "^2.0.5", "tar-js": "^0.3.0", "text-encoding-utf-8": "^1.0.2", "url": "^0.11.0", diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 6d3b50c10d..d6b4822cc2 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -32,6 +32,8 @@ import RoomTopic from "../elements/RoomTopic"; import RoomName from "../elements/RoomName"; import {PlaceCallType} from "../../../CallHandler"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import exportConversationalHistory from '../../../utils/exportUtils'; + @replaceableComponent("views.rooms.RoomHeader") export default class RoomHeader extends React.Component { @@ -117,6 +119,10 @@ export default class RoomHeader extends React.Component { return !(currentPinEvent.getContent().pinned && currentPinEvent.getContent().pinned.length <= 0); } + _exportConvertionalHistory = async () => { + exportConversationalHistory(this.props.room); + } + render() { let searchStatus = null; let cancelButton = null; @@ -244,8 +250,14 @@ export default class RoomHeader extends React.Component { title={_t("Video call")} />; } + const exportButton = ; + const rightRow =
+ { exportButton } { videoCallButton } { voiceCallButton } { pinnedEventsButton } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d1fe791319..90f3e2d89c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1545,6 +1545,7 @@ "Search": "Search", "Voice call": "Voice call", "Video call": "Video call", + "Export conversation": "Export conversation", "Start a Conversation": "Start a Conversation", "Open dial pad": "Open dial pad", "Invites": "Invites", diff --git a/src/utils/exportUtils.js b/src/utils/exportUtils.js new file mode 100644 index 0000000000..f784c48f8f --- /dev/null +++ b/src/utils/exportUtils.js @@ -0,0 +1,435 @@ +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { arrayFastClone } from "./arrays"; +import { TimelineWindow } from "matrix-js-sdk/src/timeline-window"; +import JSZip from "jszip"; +import { textForEvent } from "../TextForEvent"; +import streamSaver from "streamsaver"; + +const wrapHTML = (content, room) => (` + + + + + Exported Data + + + + + +
+ +
+
+ ${content} +
+
+
+ + +`); + + +const getTimelineConversation = (room) => { + if (!room) return; + + const cli = MatrixClientPeg.get(); + + const timelineSet = room.getUnfilteredTimelineSet(); + + const timelineWindow = new TimelineWindow( + cli, timelineSet, + {windowLimit: Number.MAX_VALUE}); + + timelineWindow.load(null, 20); + + const events = timelineWindow.getEvents(); + + // Clone and reverse the events so that we preserve the order + arrayFastClone(events) + .reverse() + .forEach(event => { + cli.decryptEventIfNeeded(event); + }); + + if (!timelineWindow.canPaginate('f')) { + events.push(...timelineSet.getPendingEvents()); + } + + return events; +}; + + +const css = ` +body { + margin: 0; + font: 12px/18px 'Inter', 'Open Sans',"Lucida Grande","Lucida Sans Unicode",Arial,Helvetica,Verdana,sans-serif; +} + +strong { + font-weight: 700; +} + +code, kbd, pre, samp { + font-family: Menlo,Monaco,Consolas,"Courier New",monospace; +} + +code { + padding: 2px 4px; + font-size: 90%; + color: #c7254e; + background-color: #f9f2f4; + border-radius: 4px; +} + +pre { + display: block; + margin: 0; + line-height: 1.42857143; + word-break: break-all; + word-wrap: break-word; + color: #333; + background-color: #f5f5f5; + border-radius: 4px; + overflow: auto; + padding: 3px; + border: 1px solid #eee; + max-height: none; + font-size: inherit; +} + +.clearfix:after { + content: " "; + visibility: hidden; + display: block; + height: 0; + clear: both; +} + +.pull_left { + float: left; +} + +.pull_right { + float: right; +} + +.page_wrap { + background-color: #ffffff; + color: #000000; +} + +.page_wrap a { + color: #168acd; + text-decoration: none; +} + +.page_wrap a:hover { + text-decoration: underline; +} + +.page_header { + position: fixed; + z-index: 10; + background-color: #ffffff; + width: 100%; + border-bottom: 1px solid #e3e6e8; +} + +.page_header .content { + width: 480px; + margin: 0 auto; + border-radius: 0 !important; +} + +.page_header a.content { + background-repeat: no-repeat; + background-position: 24px 21px; + background-size: 24px 24px; +} + +.bold { + color: #212121; + font-weight: 700; +} + +.details { + color: #70777b; +} + +.page_header .content .text { + padding: 24px 24px 22px 24px; + font-size: 22px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; +} + +.page_header a.content .text { + padding: 24px 24px 22px 82px; +} + +.page_body { + padding-top: 64px; + width: 480px; + margin: 0 auto; +} + +.userpic { + display: block; + border-radius: 50%; + overflow: hidden; +} + +.userpic .initials { + display: block; + color: #fff; + text-align: center; + text-transform: uppercase; + user-select: none; +} + +a.block_link { + display: block; + text-decoration: none !important; + border-radius: 4px; +} + +a.block_link:hover { + text-decoration: none !important; + background-color: #f5f7f8; +} + +.history { + padding: 16px 0; +} + +.message { + margin: 0 -10px; + transition: background-color 2.0s ease; +} + +div.selected { + background-color: rgba(242,246,250,255); + transition: background-color 0.5s ease; +} + +.service { + padding: 10px 24px; +} + +.service .body { + text-align: center; +} + +.message .userpic .initials { + font-size: 16px; +} + +.default { + padding: 10px; +} + +.default.joined { + margin-top: -10px; +} + +.default .from_name { + color: #3892db; + font-weight: 700; + padding-bottom: 5px; +} + +.default .body { + margin-left: 60px; +} + +.default .text { + word-wrap: break-word; + line-height: 150%; +} + +.default .reply_to, +.default .media_wrap { + padding-bottom: 5px; +} + +.default .media { + margin: 0 -10px; + padding: 5px 10px; +} + +.default .media .fill, +.default .media .thumb { + width: 48px; + height: 48px; + border-radius: 50%; +} + +.default .media .fill { + background-repeat: no-repeat; + background-position: 12px 12px; + background-size: 24px 24px; +} + +.default .media .title, +.default .media_poll .question { + padding-top: 4px; + font-size: 14px; +} + +.default .media .description { + color: #000000; + padding-top: 4px; + font-size: 13px; +} + +.default .media .status { + padding-top: 4px; + font-size: 13px; +} + +.default .photo { + display: block; +} +`; + +const userColors = [ + "#64bf47", + "#4f9cd9", + "#9884e8", + "#e671a5", + "#47bcd1", + "#ff8c44", +]; + +const createDiv = (content, id, ...classNames) => { + return `
+ ${content} +
`; +}; + + +//Get a color associated with a string. This is to map userId to a specific color +const getUserColor = (userId) => { + return userColors[userId.length % 4]; +}; + +const createBody = (event, joined = false) => { + return ` +
+ ${!joined ? `
+
+
${event.sender.name[0]}
+
+
` : ``} +
+
${new Date(event._localTimestamp).toLocaleTimeString().slice(0, -3)}
+ ${!joined ? `
+ ${event.sender.name} +
`: ``} +
${event.getContent().body}
+
+
+ `; +}; + +const replyAnchor = (eventId) => { + return `this message}`; +}; + +const _isReply = (event) => { + const relatesTo = event.getContent()["m.relates_to"]; + const isReply = !!(relatesTo && relatesTo["m.in_reply_to"]); + return isReply; +}; + + +const dateSeparator = (event, prevEvent) => { + const prevDate = prevEvent ? new Date(prevEvent._localTimestamp) : null; + const currDate = new Date(event._localTimestamp); + if (!prevDate || currDate.setHours(0, 0, 0, 0) !== prevDate.setHours(0, 0, 0, 0)) { + return ` +
+
+ ${new Date(event._localTimestamp) + .toLocaleString("en-us", {year: "numeric", month: "long", day: "numeric" })} +
+
+ `; + } + return ""; +}; + +const createHTML = (events, room) => { + let content = ""; + let prevEvent = null; + for (const event of events) { + content += dateSeparator(event, prevEvent); + if (event.getContent().msgtype === "m.text") { + const shouldBeJoined = prevEvent && prevEvent.getContent().msgtype === "m.text" + && event.sender.userId === prevEvent.sender.userId && !dateSeparator(event, prevEvent); + + const body = createBody(event, shouldBeJoined); + + content += body; + } else { + content += ` +
+
+ ${textForEvent(event)} +
+
+ `; + } + prevEvent = event; + } + return wrapHTML(content, room); +}; + + +const exportConversationalHistory = async (room) => { + const res = getTimelineConversation(room); + const html = createHTML(res, room); + const zip = new JSZip(); + zip.file("css/style.css", css); + zip.file("index.html", html); + const filename = `matrix-export-${new Date().toISOString()}.zip`; + + //Generate the zip file asynchronously + + const blob = await zip.generateAsync({ type: "blob" }); + + //Create a writable stream to the directory + const fileStream = streamSaver.createWriteStream(filename, blob.size); + const writer = fileStream.getWriter(); + + // console.log(blob.size); + + // Here we chunk the blob into pieces of 10 MiB + const sliceSize = 10 * 1e6; + for (let fPointer = 0; fPointer < blob.size; fPointer += sliceSize) { + // console.log(fPointer); + const blobPiece = blob.slice(fPointer, fPointer + sliceSize); + const reader = new FileReader(); + + const waiter = new Promise((resolve, reject) => { + reader.onloadend = evt => { + const arrayBufferNew = evt.target.result; + const uint8ArrayNew = new Uint8Array(arrayBufferNew); + // Buffer.from(reader.result) + writer.write(uint8ArrayNew); + resolve(); + }; + reader.readAsArrayBuffer(blobPiece); + }); + await waiter; + } + writer.close(); +}; + +export default exportConversationalHistory; diff --git a/yarn.lock b/yarn.lock index 19c0646d32..438cc94f27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4334,6 +4334,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" @@ -5442,6 +5447,16 @@ jsprim@^1.2.2: array-includes "^3.1.2" object.assign "^4.1.2" +jszip@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.6.0.tgz#839b72812e3f97819cc13ac4134ffced95dd6af9" + integrity sha512-jgnQoG9LKnWO3mnVNBnfhkh0QknICd1FGSrXcgrl67zioyJ4wgx25o9ZqwNtrROSflGBCGYnJfjrIyRIby1OoQ== + 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" @@ -5509,6 +5524,13 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +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" @@ -6256,6 +6278,11 @@ pako@^2.0.3: resolved "https://registry.yarnpkg.com/pako/-/pako-2.0.3.tgz#cdf475e31b678565251406de9e759196a0ea7a43" integrity sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw== +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" @@ -6862,7 +6889,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== @@ -7301,6 +7328,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" @@ -7556,6 +7588,11 @@ stealthy-require@^1.1.1: resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= +streamsaver@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/streamsaver/-/streamsaver-2.0.5.tgz#3212f0e908fcece5b3a65591094475cf87850d00" + integrity sha512-KIWtBvi8A6FiFZGNSyuIZRZM6C8AvnWTiCx/TYa7so420vC5sQwcBKkdqInuGWoWMfeWy/P+/cRqMtWVf4RW9w== + string-length@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.1.tgz#4a973bf31ef77c4edbceadd6af2611996985f8a1"