import axios from 'axios'; import * as ContentHelpers from "matrix-js-sdk/lib/content-helpers"; import dataUriToBuffer from "data-uri-to-buffer"; import ImageResize from "image-resize"; import { AutoDiscovery } from 'matrix-js-sdk'; import User from '../models/user'; const prettyBytes = require("pretty-bytes"); import Hammer from "hammerjs"; import { Thread } from 'matrix-js-sdk/lib/models/thread'; export const STATE_EVENT_ROOM_DELETION_NOTICE = "im.keanu.room_deletion_notice"; export const STATE_EVENT_ROOM_DELETED = "im.keanu.room_deleted"; export const ROOM_TYPE_DEFAULT = "im.keanu.room_type_default"; export const ROOM_TYPE_VOICE_MODE = "im.keanu.room_type_voice"; export const ROOM_TYPE_FILE_MODE = "im.keanu.room_type_file"; export const ROOM_TYPE_CHANNEL = "im.keanu.room_type_channel"; export const STATE_EVENT_ROOM_TYPE = "im.keanu.room_type"; const sizeOf = require("image-size"); var dayjs = require('dayjs'); var sha256 = require('js-sha256').sha256; var aesjs = require('aes-js'); var base64Url = require('json-web-key/lib/base64url'); // Install extended localized format var localizedFormat = require('dayjs/plugin/localizedFormat') dayjs.extend(localizedFormat) var duration = require('dayjs/plugin/duration') dayjs.extend(duration); // Store info about getUserMedia BEFORE we aply polyfill(s)! var _browserCanRecordAudioF = function () { var legacyGetUserMedia = (navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia); return legacyGetUserMedia !== undefined || (navigator.mediaDevices && navigator.mediaDevices.getUserMedia !== undefined); } var _browserCanRecordAudio = _browserCanRecordAudioF(); class UploadPromise { aborted = false; onAbort = undefined; constructor(wrappedPromise) { this.wrappedPromise = wrappedPromise; } abort() { this.aborted = true; if (this.onAbort) { this.onAbort(); } } then(resolve, reject) { this.wrappedPromise = this.wrappedPromise.then(resolve, reject); return this; } catch(handler) { this.wrappedPromise = this.wrappedPromise.catch(handler); return this; } } class Util { threadMessageType() { return Thread.hasServerSideSupport ? "m.thread" : "io.element.thread" } getAttachmentUrlAndDuration(event) { return new Promise((resolve, reject) => { const content = event.getContent(); if (content.url != null) { resolve([content.url, content.info.duration]); return; } if (content.file && content.file.url) { resolve([content.file.url, content.info.duration]); } else { reject("No url found!"); } }); } getAttachment(matrixClient, event, progressCallback, asBlob = false, abortController = undefined) { return new Promise((resolve, reject) => { const content = event.getContent(); if (content.url != null) { // Unencrypted, just return! resolve(matrixClient.mxcUrlToHttp(content.url)); return; } var url = null; var file = null; if (content.file && content.file.url) { file = content.file; url = matrixClient.mxcUrlToHttp(file.url); } if (url == null) { reject("No url found!"); } axios.get(url, { signal: abortController ? abortController.signal : undefined, responseType: 'arraybuffer', onDownloadProgress: progressEvent => { let percentCompleted = Math.floor((progressEvent.loaded * 100) / progressEvent.total); if (progressCallback) { progressCallback(percentCompleted); } } }) .then(response => { return this.decryptIfNeeded(file, response); }) .then(bytes => { if (asBlob) { resolve(new Blob([bytes.buffer], { type: file.mimetype })); } else { resolve(URL.createObjectURL(new Blob([bytes.buffer], { type: file.mimetype }))); } }) .catch(err => { console.log("Download error: ", err); reject(err); }) .finally(() => { if (progressCallback) { progressCallback(null); } }); }); } getThumbnail(matrixClient, event, config, ignoredw, ignoredh) { return new Promise((resolve, reject) => { const content = event.getContent(); if (content.url != null) { // Unencrypted, just return! resolve(matrixClient.mxcUrlToHttp(content.url)); return; } var url = null; var file = null; if ( content && content.info && content.info.thumbnail_file && content.info.thumbnail_file.url ) { file = content.info.thumbnail_file; // var width = w; // var height = h; // if (content.info.w < w || content.info.h < h) { // width = content.info.w; // height = content.info.h; // } // url = matrixClient.mxcUrlToHttp( // file.url, // width, height, // "scale", // true // ); url = matrixClient.mxcUrlToHttp(file.url); } else if (content.file && content.file.url && this.getFileSize(event) > 0 && this.getFileSize(event) < config.maxSizeAutoDownloads) { // No thumb, use real url file = content.file; url = matrixClient.mxcUrlToHttp(file.url); } if (url == null) { reject("No url found!"); return; } axios.get(url, { responseType: 'arraybuffer' }) .then(response => { return this.decryptIfNeeded(file, response); }) .then(bytes => { resolve(URL.createObjectURL(new Blob([bytes.buffer], { type: file.mimetype }))); }) .catch(err => { console.log("Download error: ", err); reject(err); }); }); } decryptIfNeeded(file, response) { return new Promise((resolve, reject) => { var key = base64Url.decode(file.key.k); var iv = base64Url.decode(file.iv); var aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(iv)); const data = new Uint8Array(response.data); // Calculate sha256 and compare hashes var hash = new Uint8Array(sha256.create().update(data).arrayBuffer()); const originalHash = base64Url.decode(file.hashes.sha256); if (Buffer.compare(Buffer.from(hash), Buffer.from(originalHash.buffer)) != 0) { reject("Hashes don't match!"); return; } var decryptedBytes = aesCtr.decrypt(data); resolve(decryptedBytes); }); } sendTextMessage(matrixClient, roomId, text, editedEvent, replyToEvent) { var content = ContentHelpers.makeTextMessage(text); if (editedEvent) { content['m.relates_to'] = { rel_type: 'm.replace', event_id: editedEvent.getId() } content['m.new_content'] = { body: content.body, msgtype: content.msgtype } } else if (replyToEvent) { content['m.relates_to'] = { 'm.in_reply_to': { event_id: replyToEvent.getId() } } let senderContent = replyToEvent.getContent() const senderContentBody = Object.getOwnPropertyDescriptor(senderContent, 'body') ? senderContent.body : Object.values(senderContent)[0].question.body // Prefix the content with reply info (seems to be a legacy thing) const prefix = senderContentBody.split('\n').map((item, index) => { return "> " + (index == 0 ? ("<" + replyToEvent.getSender() + "> ") : "") + item; }).join('\n'); content.body = prefix + "\n\n" + content.body; } return this.sendMessage(matrixClient, roomId, "m.room.message", content); } sendQuickReaction(matrixClient, roomId, emoji, event, extraData = {}) { const content = { 'm.relates_to': Object.assign(extraData, { key: emoji, rel_type: 'm.annotation', event_id: event.getId() }) }; return this.sendMessage(matrixClient, roomId, "m.reaction", content); } createPoll(matrixClient, roomId, question, answers, isDisclosed) { var idx = 0; let answerData = answers.map(a => { idx++; return { id: "" + idx, 'org.matrix.msc1767.text': a.text } }); const content = { 'org.matrix.msc3381.poll.start': { question: { 'org.matrix.msc1767.text': question, body: question }, kind: isDisclosed ? "org.matrix.msc3381.poll.disclosed" : "org.matrix.msc3381.poll.undisclosed", max_selections: 1, answers: answerData }, }; return this.sendMessage(matrixClient, roomId, "org.matrix.msc3381.poll.start", content); } closePoll(matrixClient, roomId, event) { const content = { 'm.relates_to': { rel_type: 'm.reference', event_id: event.getId() }, 'org.matrix.msc3381.poll.end': { }, }; return this.sendMessage(matrixClient, roomId, "org.matrix.msc3381.poll.end", content); } sendPollAnswer(matrixClient, roomId, answers, event) { const content = { 'm.relates_to': { rel_type: 'm.reference', event_id: event.getId() }, 'org.matrix.msc3381.poll.response': { answers: answers }, }; return this.sendMessage(matrixClient, roomId, "org.matrix.msc3381.poll.response", content); } sendMessage(matrixClient, roomId, eventType, content) { return new Promise((resolve, reject) => { matrixClient.sendEvent(roomId, eventType, content, undefined, undefined) .then((result) => { console.log("Message sent: ", result); resolve(result.event_id); }) .catch(err => { console.log("Send error: ", err); if (err && err.name == "UnknownDeviceError") { console.log("Unknown devices. Mark as known before retrying."); var setAsKnownPromises = []; for (var user of Object.keys(err.devices)) { const userDevices = err.devices[user]; for (var deviceId of Object.keys(userDevices)) { const deviceInfo = userDevices[deviceId]; if (!deviceInfo.known) { setAsKnownPromises.push( matrixClient.setDeviceKnown( user, deviceId, true ) ); } } } Promise.all(setAsKnownPromises) .then(() => { // All devices now marked as "known", try to resend let event = err.event; if (!event) { // Seems event is no longer send in the UnknownDevices error... const room = matrixClient.getRoom(roomId); if (room) { event = room.getLiveTimeline().getEvents().find(e => { // Find the exact match (= object equality) return e.error === err }); } } matrixClient.resendEvent(event, matrixClient.getRoom(event.getRoomId())) .then((result) => { console.log("Message sent: ", result); resolve(result.event_id); }) .catch((err) => { // Still error, abort reject(err.toLocaleString()); }); }); } else { reject(err.toLocaleString()); } }); }); } sendFile(matrixClient, roomId, file, onUploadProgress, threadRoot) { const uploadPromise = new UploadPromise(undefined); uploadPromise.wrappedPromise = new Promise((resolve, reject) => { var reader = new FileReader(); reader.onload = (e) => { if (uploadPromise.aborted) { reject("Aborted"); return; } const fileContents = e.target.result; var data = new Uint8Array(fileContents); const info = { mimetype: file.type, size: file.size }; // If audio, send duration in ms as well if (file.duration) { info.duration = file.duration; } var description = file.name; var msgtype = 'm.file'; if (file.type.startsWith("image/")) { msgtype = 'm.image'; } else if (file.type.startsWith("audio/")) { msgtype = 'm.audio'; } else if (file.type.startsWith("video/")) { msgtype = 'm.video'; } const opts = { type: file.type, name: description, progressHandler: onUploadProgress, onlyContentUri: false }; var messageContent = { body: description, info: info, msgtype: msgtype } // If thread root (an eventId) is set, add that here if (threadRoot) { messageContent["m.relates_to"] = { "rel_type": this.threadMessageType(), "event_id": threadRoot }; } // Set filename for files if (msgtype == 'm.file') { messageContent.filename = file.name; } if (!matrixClient.isRoomEncrypted(roomId)) { // Not encrypted. const promise = matrixClient.uploadContent(data, opts); uploadPromise.onAbort = () => { matrixClient.cancelUpload(promise); }; promise .then((response) => { messageContent.url = response.content_uri; return (msgtype == 'm.audio' ? this.generateWaveform(fileContents, messageContent) : true); }) .then(() => { return this.sendMessage(matrixClient, roomId, "m.room.message", messageContent) }) .then(result => { resolve(result); }) .catch(err => { reject(err); }); return; // Don't fall through } const crypto = require('crypto'); let key = crypto.randomBytes(256 / 8); let iv = Buffer.concat([crypto.randomBytes(8), Buffer.alloc(8)]); // Initialization vector. // Encrypt var aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(iv)); var encryptedBytes = aesCtr.encrypt(data); data = encryptedBytes; // Calculate sha256 var hash = new Uint8Array(sha256.create().update(data).arrayBuffer()); const jwk = { kty: 'oct', key_ops: ['encrypt', 'decrypt'], alg: 'A256CTR', k: base64Url.encode(key), ext: true }; const encryptedFile = { mimetype: file.type, key: jwk, iv: Buffer.from(iv).toString('base64').replace(/=/g, ''), hashes: { sha256: Buffer.from(hash).toString('base64').replace(/=/g, '') }, v: 'v2' }; messageContent.file = encryptedFile; // Encrypted data sent as octet-stream! opts.type = "application/octet-stream"; const promise = matrixClient.uploadContent(data, opts); uploadPromise.onAbort = () => { matrixClient.cancelUpload(promise); }; promise .then((response) => { if (response.error) { return reject(response.error); } encryptedFile.url = response.content_uri; return (msgtype == 'm.audio' ? this.generateWaveform(fileContents, messageContent) : true); }) .then(() => { return this.sendMessage(matrixClient, roomId, "m.room.message", messageContent) }) .then(result => { resolve(result); }) .catch(err => { reject(err); }); } reader.onerror = (err) => { reject(err); } reader.readAsArrayBuffer(file); }); return uploadPromise; } generateWaveform(data, messageContent) { if (!(window.AudioContext || window.webkitAudioContext)) { return; // No support } const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (audioCtx) { return audioCtx.decodeAudioData(data) .then((audioBuffer) => { const rawData = audioBuffer.getChannelData(0); // TODO - currently using only 1 channel const samples = 1000; // Number of samples const blockSize = Math.floor(rawData.length / samples); let filteredData = []; for (let i = 0; i < samples; i++) { let blockStart = blockSize * i; // the location of the first sample in the block let sum = 0; for (let j = 0; j < blockSize; j++) { sum = sum + Math.abs(rawData[blockStart + j]) // find the sum of all the samples in the block } filteredData.push(sum / blockSize); // divide the sum by the block size to get the average } // Normalize const multiplier = Math.pow(Math.max(...filteredData), -1); filteredData = filteredData.map(n => n * multiplier); // Integerize filteredData = filteredData.map(n => parseInt((n * 255).toFixed())); // Generate SVG of waveform let svg = ``; svg += ``; svg += ""; messageContent.format = "org.matrix.custom.html"; messageContent.formatted_body = svg; }) } } /** * Return what "mode" to use for the given room. * * The default value is given by the room itself (as state events, see roomTypeMixin). * This method just returns if the user has overridden this in room settings (this * fact will be persisted as a user specific tag on the room). Note: currently override * is disabled in the UI... */ roomDisplayTypeOverride(roomOrNull) { if (roomOrNull) { const room = roomOrNull; // Have we changed our local view mode of this room? const tags = room.tags; if (tags && tags["ui_options"]) { console.error("We have a tag!"); if (tags["ui_options"]["voice_mode"] === 1) { return ROOM_TYPE_VOICE_MODE; } else if (tags["ui_options"]["file_mode"] === 1) { return ROOM_TYPE_FILE_MODE; } else if (tags["ui_options"]["file_mode"] === 0 && tags["ui_options"]["file_mode"] === 0) { // Explicitly set to "default" return ROOM_TYPE_DEFAULT; } } } return null; } /** * Return the room type for the current room * @param {*} roomOrNull */ roomDisplayTypeToQueryParam(roomOrNull, roomDisplayType) { const roomType = this.roomDisplayTypeOverride(roomOrNull) || roomDisplayType; if (roomType === ROOM_TYPE_FILE_MODE) { // Send "file" here, so the receiver of the invite link knows to display the "file drop" join page // instead of the standard one. return "file"; } else if (roomType === ROOM_TYPE_VOICE_MODE) { // No need to return "voice" here. The invite page looks the same for default and voice mode, // so currently no point in cluttering the invite link with it. The corrent mode will be picked up from // room creation flags once the user joins. return undefined; } return undefined; // Default, just return undefined } /** Generate a random user name */ randomUser(prefix) { var pfx = prefix ? prefix.replace(/[^0-9a-zA-Z\-_]/gi, '') : null; if (!pfx || pfx.length == 0) { pfx = "weblite-"; } return pfx + this.randomString( 12, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" ); } /** * Generate random 12 char password */ randomPass() { return this.randomString( 12, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#_-*+" ); } // From here: https://stackoverflow.com/questions/1349404/generate-random-string-characters-in-javascript randomString(length, characters) { var result = ""; var charactersLength = characters.length; for (var i = 0; i < length; i++) { result += characters.charAt( Math.floor(Math.random() * charactersLength) ); } return result; } sanitizeRoomId(roomId) { if (roomId && roomId.match(/^(!|#).+$/)) { return roomId; } return null; } sanitizeUserId(userId) { if (userId && userId.match(/^([0-9a-z-.=_/]+|@[0-9a-z-.=_/]+:.+)$/)) { return userId; } return null; } invalidUserIdChars() { return /[^0-9a-z-.=_/]+/g; } getFirstVisibleElement(parentNode, where) { let visible = this.findVisibleElements(parentNode); if (visible) { visible = visible.filter(where); } if (visible && visible.length > 0) { return visible[0]; } return null; } getLastVisibleElement(parentNode, where) { let visible = this.findVisibleElements(parentNode); if (visible) { visible = visible.filter(where); } if (visible && visible.length > 0) { return visible[visible.length - 1]; } return null; } findVisibleElements(parentNode) { const middle = this.findOneVisibleElement(parentNode); if (middle) { var nodes = [parentNode.children[middle]]; var i = middle - 1; while (i >= 0 && this.isChildVisible(parentNode, parentNode.children[i])) { nodes.splice(0, 0, parentNode.children[i]); i -= 1; } i = middle + 1; while (i < parentNode.children.length && this.isChildVisible(parentNode, parentNode.children[i])) { nodes.push(parentNode.children[i]); i += 1; } return nodes; } return null; // No visible found } isChildVisible(parentNode, childNode) { const rect1 = parentNode.getBoundingClientRect(); const rect2 = childNode.getBoundingClientRect(); var overlap = !(rect1.right <= rect2.left || rect1.left >= rect2.right || rect1.bottom <= rect2.top || rect1.top >= rect2.bottom) return overlap; } findOneVisibleElement(parentNode) { let start = 0; let end = (parentNode && parentNode.children) ? parentNode.children.length - 1 : -1; while (start <= end) { let middle = Math.floor((start + end) / 2); let childNode = parentNode.children[middle]; if (this.isChildVisible(parentNode, childNode)) { // found the key return middle; } else if (childNode.getBoundingClientRect().top <= parentNode.getBoundingClientRect().top) { // continue searching to the right start = middle + 1; } else { // search searching to the left end = middle - 1; } } // key wasn't found return null; } _importAll(r) { var images = []; r.keys().forEach(res => { console.log("Avatar", res); // // Remove"./" var name = res.split("_")[1]; name = name.slice(0, name.indexOf(".")); name = name.charAt(0).toUpperCase() + name.slice(1); const image = r(res); const randomNumber = parseInt(this.randomString(4, "0123456789")).toFixed(); images.push({ id: res, image: image, name: "Guest " + name + " " + randomNumber}); }); return images; } getDefaultAvatars() { return this._importAll(require.context('../assets/avatars/', true, /\.(jpeg|jpg|png)$/)); } setAvatar(matrix, file, onUploadProgress) { return new Promise((resolve, reject) => { axios.get(file, { responseType: 'arraybuffer' }) .then(response => { const opts = { type: response.headers['content-type'].split(';')[0], name: "Avatar", progressHandler: onUploadProgress, onlyContentUri: false }; var avatarUri; matrix.matrixClient.uploadContent(response.data, opts) .then((response) => { avatarUri = response.content_uri; return matrix.matrixClient.setAvatarUrl(avatarUri); }) .then(result => { matrix.userAvatar = avatarUri; resolve(result); }) .catch(err => { reject(err); }); }) .catch(err => { reject(err); }); }) } setRoomAvatar(matrixClient, roomId, file, onUploadProgress) { return new Promise((resolve, reject) => { var reader = new FileReader(); reader.onload = (e) => { const fileContents = e.target.result; var data = new Uint8Array(fileContents); const info = { mimetype: file.type, size: file.size }; const opts = { type: file.type, name: "Room Avatar", progressHandler: onUploadProgress, onlyContentUri: false }; var messageContent = { body: file.name, info: info } matrixClient.uploadContent(data, opts) .then((response) => { messageContent.url = response.content_uri; return matrixClient.sendStateEvent(roomId, "m.room.avatar", messageContent); }) .then(result => { resolve(result); }) .catch(err => { reject(err); }); } reader.onerror = (err) => { reject(err); } reader.readAsArrayBuffer(file); }); } loadAvatarFromFile(event, onLoad) { if (event.target.files && event.target.files[0]) { var reader = new FileReader(); reader.onload = (e) => { const file = event.target.files[0]; if (file.type.startsWith("image/")) { try { var image = e.target.result; var dimens = sizeOf(dataUriToBuffer(e.target.result)); // Need to resize? const w = dimens.width; const h = dimens.height; if (w > 640 || h > 640) { var aspect = w / h; var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed()); var newHeight = parseInt( (w > h ? 640 / aspect : 640).toFixed() ); var imageResize = new ImageResize({ format: "png", width: newWidth, height: newHeight, outputType: "blob", }); imageResize .play(event.target) .then((img) => { var resizedImageFile = new File([img], file.name, { type: img.type, lastModified: Date.now(), }); var reader2 = new FileReader(); reader2.onload = (e) => { onLoad(e.target.result); }; reader2.readAsDataURL(resizedImageFile); }) .catch((err) => { console.error("Resize failed:", err); }); } else { onLoad(image); } } catch (error) { console.error("Failed to get image dimensions: " + error); } } }; reader.readAsDataURL(event.target.files[0]); } } /** * Return number of whole days between the timestamps, at end of that day. * @param {*} ts1 * @param {*} ts2 */ dayDiff(ts1, ts2) { var t1 = dayjs(ts1).endOf('day'); var t2 = dayjs(ts2).endOf('day'); return t2.diff(t1, 'day'); } dayDiffToday(timestamp) { var then = dayjs(timestamp).endOf('day'); var now = dayjs().endOf('day'); return now.diff(then, 'day'); } formatDay(timestamp) { var then = dayjs(timestamp).endOf('day'); return then.format('L'); } formatTime(time) { const date = new Date(); date.setTime(time); const today = new Date(); if ( date.getDate() == today.getDate() && date.getMonth() == today.getMonth() && date.getFullYear() == today.getFullYear() ) { // For today, skip the date part return date.toLocaleTimeString(); } return date.toLocaleString(); } formatRecordDuration(ms) { return dayjs.duration(ms).format("HH:mm:ss"); } formatDuration(ms) { if (ms >= (60 * 60000)) { return dayjs.duration(ms).format("H:mm:ss"); } return dayjs.duration(ms).format("m:ss"); } formatRecordStartTime(timestamp) { var then = dayjs(timestamp); return then.format('lll'); } browserCanRecordAudio() { return _browserCanRecordAudio; } getRoomNameFromAlias(alias) { if (alias && alias.startsWith('#') && alias.indexOf(':') > 0) { return alias.slice(1).split(':')[0]; } return undefined; } getUniqueAliasForRoomName(matrixClient, roomName, defaultMatrixDomainPart, iterationCount) { return new Promise((resolve, reject) => { var preferredAlias = roomName.replace(/\s/g, "").toLowerCase(); var tryAlias = "#" + preferredAlias + ":" + defaultMatrixDomainPart; matrixClient.getRoomIdForAlias(tryAlias) .then(ignoredid => { // We got a response, this means the tryAlias already exists. // Try again, with appended random chars if (iterationCount) { // Not the first time around. Reached max tries? if (iterationCount == 5) { reject("Failed to get unique room alias"); return; } // Else, strip random chars from end so we can try again roomName = roomName.substring(0, roomName.length - 5); } const randomChars = this.randomString(4, "abcdefghijklmnopqrstuvwxyz0123456789"); resolve(this.getUniqueAliasForRoomName(matrixClient, roomName + "-" + randomChars, defaultMatrixDomainPart, (iterationCount || 0) + 1)) }) .catch(err => { if (err.errcode == 'M_NOT_FOUND') { resolve(preferredAlias); } else { reject(err); } }) }); } downloadableTypes() { return ['m.video','m.audio','m.image','m.file']; } download(matrixClient, event) { console.log("DOWNLOAD"); this .getAttachment(matrixClient, event) .then((url) => { const link = document.createElement("a"); link.href = url; link.target = "_blank"; if (!this.isFileTypePDF(event)) { // PDFs are shown inline, not downloaded link.download = event.getContent().body || this.$t("fallbacks.download_name"); } console.log("LINK", link); document.body.appendChild(link); link.click(); setTimeout(function () { document.body.removeChild(link); URL.revokeObjectURL(url); }, 200); }) .catch((err) => { console.log("Failed to fetch attachment: ", err); }); } getMatrixBaseUrl(user, config) { if (user) { const domain = User.domainPart(user.user_id); if (domain) { const endpoint = config.getMatrixDomainPartMapping(domain); if (endpoint) { console.log("Mapped to", endpoint); return Promise.resolve(endpoint); } return AutoDiscovery.findClientConfig(domain) .then((clientConfig) => { const hs = clientConfig['m.homeserver']; if (hs && !hs.error && hs.base_url) { console.log("Use home server returned from well-known", hs.base_url); return hs.base_url; } console.log("Fallback to default server"); return config.defaultBaseUrl; }) .catch((err) => { console.error("Failed well-known lookup", err); return config.defaultBaseUrl; }); } } return Promise.resolve(config.defaultBaseUrl); } getMimeType(event) { const content = event.getContent(); return (content.info && content.info.mimetype) ? content.info.mimetype : (content.file && content.file.mimetype) ? content.file.mimetype : ""; } getFileName(event) { const content = event.getContent(); return (content.body || content.filename || "").toLowerCase(); } getFileExtension(event) { const fileName = this.getFileName(event); const parts = fileName.split("."); if (parts.length > 1) { return "." + parts[parts.length - 1].toLowerCase(); } return ""; } getFileSize(event) { const content = event.getContent(); if (content.info) { return content.info.size; } return 0; } getFileSizeFormatted(event) { return prettyBytes(this.getFileSize(event)); } isFileTypeAPK(event) { const mime = this.getMimeType(event); if (mime === "application/vnd.android.package-archive" || this.getFileName(event).endsWith(".apk")) { return true; } return false; } isFileTypeIPA(event) { if (this.getFileName(event).endsWith(".ipa")) { return true; } return false; } isFileTypePDF(event) { const mime = this.getMimeType(event); if (mime === "application/pdf" || this.getFileName(event).endsWith(".pdf")) { return true; } return false; } isFileTypeZip(event) { const mime = this.getMimeType(event); if (["application/zip", "application/x-zip-compressed", "multipart/x-zip"].includes(mime) || this.getFileName(event).endsWith(".zip")) { return true; } return false; } isMobileOrTabletBrowser() { // Regular expression to match common mobile and tablet browser user agent strings const mobileTabletPattern = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Tablet|Mobile|CriOS/i; const userAgent = navigator.userAgent; return mobileTabletPattern.test(userAgent); } singleOrDoubleTabRecognizer(element) { // reference: https://codepen.io/jtangelder/pen/xxYyJQ const hm = new Hammer.Manager(element); hm.add(new Hammer.Tap({ event: 'doubletap', taps: 2 })); hm.add(new Hammer.Tap({ event: 'singletap' }) ); hm.get('doubletap').recognizeWith('singletap'); hm.get('singletap').requireFailure('doubletap'); return hm } } export default new Util();