matrix-puppeteer-line/puppet/src/contentscript.js

1310 lines
41 KiB
JavaScript
Raw Normal View History

2021-03-15 01:40:56 -04:00
// matrix-puppeteer-line - A very hacky Matrix-LINE bridge based on running LINE's Chrome extension in Puppeteer
2021-02-26 01:28:54 -05:00
// Copyright (C) 2020-2021 Tulir Asokan, Andrew Ferrazzutti
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Definitions and docs for methods that the Puppeteer script exposes for the content script
/**
* @param {string} text - The date string to parse
* @param {Date} [ref] - Reference date to parse relative times
* @param {{[forwardDate]: boolean}} [option] - Extra options for parser
* @return {Promise<Date>}
*/
2020-08-24 15:24:19 -04:00
window.__chronoParseDate = function (text, ref, option) {}
/**
2020-08-24 16:00:32 -04:00
* @param {string[]} changes - The hrefs of the chats that changed.
2020-08-24 15:24:19 -04:00
* @return {Promise<void>}
*/
2020-08-24 15:24:19 -04:00
window.__mautrixReceiveChanges = function (changes) {}
2021-04-23 03:38:13 -04:00
/**
* @param {string} messages - The ID of the chat receiving messages.
* @param {MessageData[]} messages - The messages added to a chat.
* @return {Promise<void>}
*/
window.__mautrixReceiveMessages = function (chatID, messages) {}
/**
2021-05-28 02:27:14 -04:00
* @param {string} chatID - The ID of the chat whose receipts are being processed.
* @param {string} receipt_id - The ID of the most recently-read message for the current chat.
* @return {Promise<void>}
*/
2021-05-28 02:27:14 -04:00
window.__mautrixReceiveReceiptDirectLatest = function (chatID, receipt_id) {}
/**
2021-05-28 02:27:14 -04:00
* @param {string} chatID - The ID of the chat whose receipts are being processed.
* @param {[Receipt]} receipts - All newly-seen receipts for the current chat.
* @return {Promise<void>}
*/
window.__mautrixReceiveReceiptMulti = function (chat_id, receipts) {}
/**
* @param {string} url - The URL for the QR code.
2020-08-24 15:24:19 -04:00
* @return {Promise<void>}
*/
2020-08-24 15:24:19 -04:00
window.__mautrixReceiveQR = function (url) {}
2021-02-10 02:34:19 -05:00
/**
* @return {Promise<void>}
*/
window.__mautrixSendEmailCredentials = function () {}
/**
* @param {string} pin - The login PIN.
* @return {Promise<void>}
*/
window.__mautrixReceivePIN = function (pin) {}
/**
* @param {Element} button - The button to click when a QR code or PIN expires.
* @return {Promise<void>}
*/
window.__mautrixExpiry = function (button) {}
/**
* @param {number} id - The ID of the message that was sent
* @return {Promise<void>}
*/
window.__mautrixReceiveMessageID = function(id) {}
/**
* @return {void}
*/
window.__mautrixLoggedOut = function() {}
2021-05-28 02:27:14 -04:00
2021-02-25 22:21:11 -05:00
/**
2021-05-28 02:27:14 -04:00
* typedef ChatTypeEnum
2021-02-25 22:21:11 -05:00
*/
const ChatTypeEnum = Object.freeze({
DIRECT: 1,
GROUP: 2,
ROOM: 3,
})
2021-05-28 02:27:14 -04:00
const MSG_DECRYPTING = "ⓘ Decrypting..."
// TODO consts for common selectors
class MautrixController {
2021-04-27 02:59:16 -04:00
constructor() {
this.chatListObserver = null
this.msgListObserver = null
2021-04-23 03:38:13 -04:00
this.receiptObserver = null
2021-04-27 02:59:16 -04:00
2021-02-10 02:34:19 -05:00
this.qrChangeObserver = null
this.qrAppearObserver = null
this.emailAppearObserver = null
this.pinAppearObserver = null
2021-02-25 22:21:11 -05:00
this.ownID = null
2021-04-27 02:59:16 -04:00
this._promiseOwnMsgReset()
2021-02-25 22:21:11 -05:00
}
setOwnID(ownID) {
// Remove characters that will conflict with mxid grammar
const suffix = ownID.slice(1).replace(":", "_ON_")
this.ownID = `_OWN_${suffix}`
}
// TODO Commonize with Node context
getChatType(id) {
switch (id.charAt(0)) {
case "u":
return ChatTypeEnum.DIRECT
case "c":
return ChatTypeEnum.GROUP
case "r":
return ChatTypeEnum.ROOM
default:
throw `Invalid chat ID: ${id}`
}
}
2021-04-23 03:38:13 -04:00
getCurrentChatID() {
const chatListElement = document.querySelector("#_chat_list_body > .ExSelected > .chatList")
2021-04-23 03:38:13 -04:00
return chatListElement ? this.getChatListItemID(chatListElement) : null
}
/**
* Parse a date string.
*
* @param {string} text - The string to parse
2020-08-28 09:38:06 -04:00
* @param {Date} [ref] - Reference date to parse relative times
* @param {{[forwardDate]: boolean}} [option] - Extra options for parser
2021-04-06 01:56:46 -04:00
* @return {Promise<?Date>} - The date, or null if parsing failed.
* @private
*/
2020-08-28 09:38:06 -04:00
async _tryParseDate(text, ref, option) {
const parsed = await window.__chronoParseDate(text, ref, option)
2021-02-20 20:00:32 -05:00
return parsed ? new Date(parsed) : null
}
/**
2021-05-28 02:27:14 -04:00
* Parse a date separator.
*
2021-05-28 02:27:14 -04:00
* @param {string} text - The text in the date saparator.
* @return {Promise<?Date>} - The value of the date separator.
* @private
*/
2021-05-28 02:27:14 -04:00
async _tryParseDateSeparator(text) {
if (!text) {
return null
}
2021-05-28 02:27:14 -04:00
// Must prefix with midnight to prevent getting noon
text = "00:00 " + text.replace(/\. /, "/")
const now = new Date()
let newDate = await this._tryParseDate(text)
if (!newDate || newDate > now) {
const lastWeek = new Date()
lastWeek.setDate(lastWeek.getDate() - 7)
newDate = await this._tryParseDate(text, lastWeek, { forwardDate: true })
}
2021-02-20 20:00:32 -05:00
return newDate && newDate <= now ? newDate : null
}
2021-02-25 22:21:11 -05:00
/**
* Try to match a user against an entry in the friends list to get their ID.
*
* @param {string} senderName - The display name of the user to find the ID for.
* @return {?string} - The user's ID if found.
2021-02-25 22:21:11 -05:00
*/
getUserIdFromFriendsList(senderName) {
return document.querySelector(`#contact_wrap_friends > ul > li[title='${senderName}']`)?.getAttribute("data-mid")
}
/**
* @typedef MessageData
* @type {object}
2020-08-18 09:47:06 -04:00
* @property {number} id - The ID of the message. Seems to be sequential.
2021-05-28 02:27:14 -04:00
* @property {number} timestamp - The unix timestamp of the message. Accurate to the minute.
2020-08-18 09:47:06 -04:00
* @property {boolean} is_outgoing - Whether or not this user sent the message.
* @property {?Participant} sender - Full data of the participant who sent the message, if needed and available.
* @property {?string} html - The HTML format of the message, if necessary.
2021-05-28 02:27:14 -04:00
* @property {?ImageInfo} image - Information of the image in the message, if it's an image-only message.
* @property {?int} receipt_count - The number of users who have read the message.
*/
2021-05-28 02:27:14 -04:00
/**
* @typedef ImageInfo
* @type {object}
* @property {string} url - The URL of the image's location.
* @property {boolean} is_sticker - Whether the sent image is a sticker.
* @property {boolean} animated - Whether the sent image is animated. Only used for stickers (for now...?).
*/
/**
* Return whether a URL points to a loaded image or not.
*
* @param {string} src
* @return boolean
* @private
*/
_isLoadedImageURL(src) {
2021-05-28 02:27:14 -04:00
return src && (
src.startsWith(`blob:`) ||
src.startsWith(`${document.location.origin}/res/`) && !src.startsWith(`${document.location.origin}/res/img/noimg/`))
}
/**
2021-05-28 02:27:14 -04:00
* Parse a message element.
*
* @param {Element} element - The message element.
2021-05-28 02:27:14 -04:00
* @param {Number} chatType - What kind of chat this message is part of.
* @param {Date} refDate - The most recent date indicator. If undefined, do not retrieve the timestamp of this message.
* @return {Promise<MessageData>}
* @private
*/
2021-05-28 02:27:14 -04:00
async _parseMessage(element, chatType, refDate) {
2021-02-25 22:21:11 -05:00
const is_outgoing = element.classList.contains("mdRGT07Own")
let sender = {}
const receipt = element.querySelector(".mdRGT07Own .mdRGT07Read:not(.MdNonDisp)")
let receipt_count
2021-02-25 22:21:11 -05:00
// TODO Clean up participantsList access...
const participantsListSelector = "#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul"
// Don't need sender ID for direct chats, since the portal will have it already.
if (chatType == ChatTypeEnum.DIRECT) {
sender = null
receipt_count = is_outgoing ? (receipt ? 1 : 0) : null
2021-02-25 22:21:11 -05:00
} else if (!is_outgoing) {
2021-05-28 02:27:14 -04:00
let imgElement
2021-02-25 22:21:11 -05:00
sender.name = element.querySelector(".mdRGT07Body > .mdRGT07Ttl").innerText
// Room members are always friends (right?),
// so search the friend list for the sender's name
// and get their ID from there.
sender.id = this.getUserIdFromFriendsList(sender.name)
// Group members aren't necessarily friends,
// but the participant list includes their ID.
if (!sender.id) {
const participantsList = document.querySelector(participantsListSelector)
2021-05-28 02:27:14 -04:00
imgElement = participantsList.querySelector(`img[alt='${sender.name}'`)
sender.id = imgElement.parentElement.parentElement.getAttribute("data-mid")
} else {
imgElement = element.querySelector(".mdRGT07Img > img")
2021-02-25 22:21:11 -05:00
}
2021-05-28 02:27:14 -04:00
sender.avatar = this._getPathImage(imgElement)
receipt_count = null
2021-02-25 22:21:11 -05:00
} else {
// TODO Get own ID and store it somewhere appropriate.
// Unable to get own ID from a room chat...
// if (chatType == ChatTypeEnum.GROUP) {
// const participantsList = document.querySelector("#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul")
// // TODO The first member is always yourself, right?
// // TODO Cache this so own ID can be used later
// sender = participantsList.children[0].getAttribute("data-mid")
// }
const participantsList = document.querySelector(participantsListSelector)
sender.name = this.getParticipantListItemName(participantsList.children[0])
2021-03-26 02:27:21 -04:00
sender.avatar = this.getParticipantListItemAvatar(participantsList.children[0])
2021-02-25 22:21:11 -05:00
sender.id = this.ownID
receipt_count = receipt ? this._getReceiptCount(receipt) : null
2021-02-25 22:21:11 -05:00
}
const messageData = {
2021-02-20 20:00:32 -05:00
id: +element.getAttribute("data-local-id"),
2021-05-28 02:27:14 -04:00
timestamp:
refDate !== undefined
? (await this._tryParseDate(element.querySelector("time")?.innerText, refDate))?.getTime()
: null,
2021-02-25 22:21:11 -05:00
is_outgoing: is_outgoing,
sender: sender,
2021-05-28 02:27:14 -04:00
receipt_count: receipt_count,
}
2021-05-28 02:27:14 -04:00
2021-02-20 20:00:32 -05:00
const messageElement = element.querySelector(".mdRGT07Body > .mdRGT07Msg")
2021-05-28 02:27:14 -04:00
const is_sticker = messageElement.classList.contains("mdRGT07Sticker")
2021-02-20 20:00:32 -05:00
if (messageElement.classList.contains("mdRGT07Text")) {
2021-05-28 02:27:14 -04:00
let msgSpan = messageElement.querySelector(".mdRGT07MsgTextInner")
try {
if (msgSpan.innerHTML == MSG_DECRYPTING) {
msgSpan = await this._waitForDecryptedMessage(element, msgSpan, 5000)
}
messageData.html = await this._parseMessageHTML(msgSpan)
} catch {
// Throw to reject, but return what was parsed so far
throw messageData
}
} else if (is_sticker || messageElement.classList.contains("mdRGT07Image")) {
// TODO Animated non-sticker images require clicking its img element, which is just a thumbnail
// Real image: "#wrap_single_image img"
// Close button: "#wrap_single_image button"
// Viewer is open/closed based on "#wrap_single_image.MdNonDisp" / "#wrap_single_image:not(.MdNonDisp)"
let img = messageElement.querySelector(".mdRGT07MsgImg > img")
if (!this._isLoadedImageURL(img.src)) {
try {
img = await this._waitForLoadedImage(img, 10000)
} catch {
// Throw to reject, but return what was parsed so far
throw messageData
}
}
messageData.image = {
url: img.src,
is_sticker: is_sticker,
is_animated: is_sticker && img.parentElement.classList.contains("animationSticker"),
}
}
return messageData
}
/**
* @param {Element} msgSpan
* @return Promise<DOMString>
* @private
*/
async _parseMessageHTML(msgSpan) {
const msgSpanImgs = msgSpan.getElementsByTagName("img")
if (msgSpanImgs.length == 0) {
return msgSpan.innerHTML
} else {
const unloadedImgs = Array.from(msgSpanImgs).filter(img => !this._isLoadedImageURL(img.src))
if (unloadedImgs.length > 0) {
// NOTE Use allSettled to not throw if any images time out
await Promise.allSettled(
unloadedImgs.map(img => this._waitForLoadedImage(img, 2000))
)
}
// Hack to put sticon dimensions in HTML (which are excluded by default)
// in such a way that doesn't alter the elements that are in the DOM
const msgSpanCopy = msgSpan.cloneNode(true)
const msgSpanCopyImgs = msgSpanCopy.getElementsByTagName("img")
for (let i = 0, n = msgSpanImgs.length; i < n; i++) {
msgSpanCopyImgs[i].height = msgSpanImgs[i].height
msgSpanCopyImgs[i].width = msgSpanImgs[i].width
}
return msgSpanCopy.innerHTML
}
}
/**
* @param {Element} element
* @param {Element} msgSpan
* @param {Number} timeoutLimitMillis
* @return {Promise<Element>}
* @private
*/
_waitForDecryptedMessage(element, msgSpan, timeoutLimitMillis) {
console.debug("Wait for message element to finish decrypting")
console.debug(element)
return new Promise((resolve, reject) => {
let observer = new MutationObserver(changes => {
for (const change of changes) {
const isTextUpdate = change.type == "characterData"
const target = isTextUpdate ? msgSpan : element.querySelector(".mdRGT07MsgTextInner")
if (target && target.innerHTML != MSG_DECRYPTING) {
if (isTextUpdate) {
console.debug("UNLIKELY(?) EVENT -- Found decrypted message from text update")
} else {
// TODO Looks like it's div.mdRGT07Body that gets always replaced. If so, watch only for that
console.debug("Found decrypted message from element replacement")
console.debug(target)
console.debug("Added:")
for (const change of changes) {
console.debug(change.removedNodes)
}
console.debug("Removed:")
for (const change of changes) {
console.debug(change.addedNodes)
}
}
2021-05-28 02:27:14 -04:00
observer.disconnect()
observer = null
resolve(target)
return
}
2021-05-28 02:27:14 -04:00
if (target && target != msgSpan) {
console.debug("UNLIKELY EVENT -- Somehow added a new \"decrypting\" span, it's the one to watch now")
console.debug(target)
msgSpan = target
observer.observe(msgSpan, { characterData: true })
}
}
})
// Either the span element or one of its ancestors is replaced,
// or the span element's content is updated.
// Not exactly sure which of these happens, or if the same kind
// of mutation always happens, so just look for them all...
observer.observe(element, { childList: true, subtree: true })
observer.observe(msgSpan, { characterData: true })
setTimeout(() => {
if (observer) {
observer.disconnect()
// Don't print log message, as this may be a safe timeout
reject()
}
}, timeoutLimitMillis)
})
}
2021-05-28 02:27:14 -04:00
/**
* @param {Element} img
* @param {Number} timeoutLimitMillis
* @return {Promise<Element>}
* @private
*/
_waitForLoadedImage(img, timeoutLimitMillis) {
console.debug("Wait for image element to finish loading")
console.debug(img)
// TODO Should reject on "#_chat_message_image_failure"
return new Promise((resolve, reject) => {
let observer = new MutationObserver(changes => {
for (const change of changes) {
if (this._isLoadedImageURL(change.target.src)) {
console.debug("Image element finished loading")
console.debug(change.target)
observer.disconnect()
observer = null
resolve(change.target)
return
}
}
})
observer.observe(img, { attributes: true, attributeFilter: ["src"] })
setTimeout(() => {
if (observer) {
observer.disconnect()
2021-05-28 02:27:14 -04:00
// Don't print log message, as this may be a safe timeout
reject()
2021-03-28 04:23:07 -04:00
}
2021-05-28 02:27:14 -04:00
}, timeoutLimitMillis)
})
}
/**
* Find the number in the "Read #" receipt message.
* Don't look for "Read" specifically, to support multiple languages.
*
* @param {Element} receipt - The element containing the receipt message.
* @private
*/
_getReceiptCount(receipt) {
const match = receipt.innerText.match(/\d+/)
return Number.parseInt(match ? match[0] : 0) || null
}
2021-02-20 03:47:26 -05:00
2021-04-27 02:59:16 -04:00
/**
* Create and store a promise that resolves when a message written
* by the user finishes getting sent.
* Accepts selectors for elements that become visible once the message
* has succeeded or failed to be sent.
*
* @param {int} timeoutLimitMillis - The maximum amount of time to wait for the message to be sent.
2021-05-28 02:27:14 -04:00
* @param {string} successSelector - The selector for the element that indicates the message was sent.
* @param {?string} failureSelector - The selector for the element that indicates the message failed to be sent.
2021-04-27 02:59:16 -04:00
*/
promiseOwnMessage(timeoutLimitMillis, successSelector, failureSelector=null) {
2021-04-27 02:59:16 -04:00
this.promiseOwnMsgSuccessSelector = successSelector
this.promiseOwnMsgFailureSelector = failureSelector
2021-02-20 03:47:26 -05:00
2021-04-27 02:59:16 -04:00
this.ownMsgPromise = new Promise((resolve, reject) => {
this.promiseOwnMsgResolve = resolve
this.promiseOwnMsgReject = reject
})
2021-05-28 02:27:14 -04:00
this.promiseOwnMsgTimeoutID = setTimeout(() => {
if (this.promiseOwnMsgReject) {
console.error("Timed out waiting for own message to be sent")
this._rejectOwnMessage()
}
}, timeoutLimitMillis)
}
2021-04-27 02:59:16 -04:00
/**
* Wait for a user-sent message to finish getting sent.
*
* @return {Promise<int>} - The ID of the sent message.
*/
async waitForOwnMessage() {
2021-05-28 02:27:14 -04:00
return this.ownMsgPromise ? await this.ownMsgPromise : -1
2021-04-27 02:59:16 -04:00
}
2021-05-28 02:27:14 -04:00
/**
* Parse the message list of whatever the currently-viewed chat is.
*
* @param {int} minID - The minimum message ID to consider.
* @return {Promise<[MessageData]>} - A list of messages.
*/
async parseMessageList(minID = 0) {
console.debug(`minID for full refresh: ${minID}`)
const msgList =
Array.from(document.querySelectorAll("#_chat_room_msg_list > div[data-local-id]"))
.filter(msg =>
msg.hasAttribute("data-local-id") &&
(!msg.classList.contains("MdRGT07Cont") || msg.getAttribute("data-local-id") > minID))
if (msgList.length == 0) {
return []
}
const messagePromises = []
const chatType = this.getChatType(this.getCurrentChatID())
2021-02-20 20:00:32 -05:00
let refDate = null
2021-04-23 03:38:13 -04:00
for (const child of msgList) {
if (child.classList.contains("mdRGT10Date")) {
2021-05-28 02:27:14 -04:00
refDate = await this._tryParseDateSeparator(child.firstElementChild.innerText)
2021-04-23 03:38:13 -04:00
} else if (child.classList.contains("MdRGT07Cont")) {
2021-05-28 02:27:14 -04:00
messagePromises.push(this._parseMessage(child, chatType, refDate))
}
}
2021-05-28 02:27:14 -04:00
// NOTE No message should ever time out, but use allSettled to not throw if any do
return (await Promise.allSettled(messagePromises))
.filter(value => value.status == "fulfilled")
.map(value => value.value)
2021-04-23 03:38:13 -04:00
}
2021-03-26 02:27:21 -04:00
/**
* @typedef PathImage
* @type object
* @property {?string} path - The virtual path of the image (behaves like an ID). Optional.
* @property {string} src - The URL of the image. Mandatory.
2021-03-26 02:27:21 -04:00
*/
/**
* @param {Element} img - The image element to get the URL and path of.
* @return {?PathImage} - The image URL and its path, if found.
*/
2021-03-26 02:27:21 -04:00
_getPathImage(img) {
if (img && img.src.startsWith("blob:")) {
// NOTE Having a blob but no path means the image exists,
// but in a form that cannot be uniquely identified.
// If instead there is no blob, the image is blank.
return {
path: img.getAttribute("data-picture-path"),
url: img.src,
}
} else {
return null
}
}
/**
* @typedef Participant
* @type object
* @property {string} id - The member ID for the participant
* @property {?PathImage} avatar - The path and blob URL of the participant's avatar
* @property {string} name - The contact list name of the participant
*/
2021-02-25 22:21:11 -05:00
getParticipantListItemName(element) {
return element.querySelector(".mdRGT13Ttl").innerText
}
2021-03-26 02:27:21 -04:00
getParticipantListItemAvatar(element) {
return this._getPathImage(element.querySelector(".mdRGT13Img img[src]"))
2021-03-23 02:37:30 -04:00
}
2021-04-23 03:38:13 -04:00
getParticipantListItemID(element) {
2021-02-25 22:21:11 -05:00
// TODO Cache own ID
return element.getAttribute("data-mid")
}
/**
* Parse a group participants list.
* TODO Find what works for a *room* participants list...!
*
* @param {Element} element - The participant list element.
* @return {[Participant]} - The list of participants.
*/
parseParticipantList(element) {
2021-02-25 22:21:11 -05:00
// TODO Might need to explicitly exclude own user if double-puppeting is enabled.
// TODO The first member is always yourself, right?
const ownParticipant = {
// TODO Find way to make this work with multiple mxids using the bridge.
// One idea is to add real ID as suffix if we're in a group, and
// put in the puppet DB table somehow.
id: this.ownID,
2021-03-26 02:27:21 -04:00
avatar: this.getParticipantListItemAvatar(element.children[0]),
2021-02-25 22:21:11 -05:00
name: this.getParticipantListItemName(element.children[0]),
}
return [ownParticipant].concat(Array.from(element.children).slice(1).map(child => {
const name = this.getParticipantListItemName(child)
2021-04-23 03:38:13 -04:00
const id = this.getParticipantListItemID(child) || this.getUserIdFromFriendsList(name)
return {
2021-05-28 02:27:14 -04:00
id: id,
2021-03-26 02:27:21 -04:00
avatar: this.getParticipantListItemAvatar(child),
2021-02-25 22:21:11 -05:00
name: name,
}
2021-02-25 22:21:11 -05:00
}))
}
/**
* @typedef ChatListInfo
* @type object
2021-03-26 02:27:21 -04:00
* @property {number} id - The ID of the chat.
* @property {string} name - The name of the chat.
* @property {PathImage} icon - The path and blob URL of the chat icon.
2020-08-18 09:47:06 -04:00
* @property {string} lastMsg - The most recent message in the chat.
* May be prefixed by sender name.
2020-08-24 16:00:32 -04:00
* @property {string} lastMsgDate - An imprecise date for the most recent message
* (e.g. "7:16 PM", "Thu" or "Aug 4")
*/
2021-04-23 03:38:13 -04:00
getChatListItemID(element) {
return element.getAttribute("data-chatid")
}
getChatListItemName(element) {
return element.querySelector(".mdCMN04Ttl").innerText
}
2021-03-26 02:27:21 -04:00
getChatListItemIcon(element) {
return this._getPathImage(element.querySelector(".mdCMN04Img > :not(.mdCMN04ImgInner) > img[src]"))
2021-03-23 02:37:30 -04:00
}
getChatListItemLastMsg(element) {
return element.querySelector(".mdCMN04Desc").innerText
}
getChatListItemLastMsgDate(element) {
return element.querySelector("time").innerText
}
/**
* Parse a conversation list item element.
*
* @param {Element} element - The element to parse.
2021-04-23 03:38:13 -04:00
* @param {?string} knownID - The ID of this element, if it is known.
2021-04-06 01:56:46 -04:00
* @return {ChatListInfo} - The info in the element.
*/
2021-04-23 03:38:13 -04:00
parseChatListItem(element, knownID) {
2021-02-25 23:59:25 -05:00
return !element.classList.contains("chatList") ? null : {
2021-04-23 03:38:13 -04:00
id: knownID || this.getChatListItemID(element),
name: this.getChatListItemName(element),
2021-03-26 02:27:21 -04:00
icon: this.getChatListItemIcon(element),
lastMsg: this.getChatListItemLastMsg(element),
lastMsgDate: this.getChatListItemLastMsgDate(element),
}
}
/**
2021-02-21 02:07:48 -05:00
* Parse the list of recent/saved chats.
2021-05-28 02:27:14 -04:00
*
* @return {[ChatListInfo]} - The list of chats.
*/
2021-02-21 02:07:48 -05:00
parseChatList() {
const chatList = document.querySelector("#_chat_list_body")
return Array.from(chatList.children).map(
child => this.parseChatListItem(child.firstElementChild))
}
/**
2021-03-26 02:27:21 -04:00
* Download an image at a given URL and return it as a data URL.
*
2021-03-26 02:27:21 -04:00
* @param {string} url - The URL of the image to download.
* @return {Promise<string>} - The data URL (containing the mime type and base64 data)
*/
2021-03-26 02:27:21 -04:00
async readImage(url) {
const resp = await fetch(url)
const reader = new FileReader()
const promise = new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result)
reader.onerror = reject
})
reader.readAsDataURL(await resp.blob())
return promise
}
/**
* @param {[MutationRecord]} mutations - The mutation records that occurred
* @private
*/
_observeChatListMutations(mutations) {
2021-02-25 23:59:25 -05:00
// TODO Observe *added/removed* chats, not just new messages
const changedChatIDs = new Set()
for (const change of mutations) {
2021-02-25 23:59:25 -05:00
if (change.target.id == "_chat_list_body") {
// TODO
// These could be new chats, or they're
// existing ones that just moved around.
/*
for (const node of change.addedNodes) {
}
*/
2021-04-23 03:38:13 -04:00
} else if (change.target.tagName == "LI") {
2021-04-27 02:59:16 -04:00
if (change.target.classList.contains("ExSelected")) {
2021-05-28 02:27:14 -04:00
console.debug("Not using chat list mutation response for currently-active chat")
2021-04-23 03:38:13 -04:00
continue
}
2021-02-25 23:59:25 -05:00
for (const node of change.addedNodes) {
const chat = this.parseChatListItem(node)
if (chat) {
2021-05-28 02:27:14 -04:00
console.log("Added chat list item:", chat)
2021-02-25 23:59:25 -05:00
changedChatIDs.add(chat.id)
} else {
2021-05-28 02:27:14 -04:00
console.debug("Could not parse added node as a chat list item:", node)
2021-02-25 23:59:25 -05:00
}
}
}
// change.removedNodes tells you which chats that had notifications are now read.
}
if (changedChatIDs.size > 0) {
console.debug("Dispatching chat list mutations:", changedChatIDs)
2020-08-24 16:00:32 -04:00
window.__mautrixReceiveChanges(Array.from(changedChatIDs)).then(
() => console.debug("Chat list mutations dispatched"),
err => console.error("Error dispatching chat list mutations:", err))
}
}
/**
2021-02-25 23:59:25 -05:00
* Add a mutation observer to the chat list.
*/
2021-02-25 23:59:25 -05:00
addChatListObserver() {
2021-04-23 03:38:13 -04:00
this.removeChatListObserver()
2021-04-27 02:59:16 -04:00
this.chatListObserver = new MutationObserver(async (mutations) => {
2021-05-28 02:27:14 -04:00
if (this.ownMsgPromise) {
// Wait for pending sent messages to be resolved before responding to mutations
try {
await this.ownMsgPromise
} catch (e) {}
}
2021-04-27 02:59:16 -04:00
2020-08-24 16:00:32 -04:00
try {
this._observeChatListMutations(mutations)
} catch (err) {
console.error("Error observing chat list mutations:", err)
}
})
2021-02-25 23:59:25 -05:00
this.chatListObserver.observe(
document.querySelector("#_chat_list_body"),
{ childList: true, subtree: true })
2021-05-28 02:27:14 -04:00
console.log("Started chat list observer")
}
/**
* Disconnect the most recently added mutation observer.
*/
removeChatListObserver() {
if (this.chatListObserver !== null) {
this.chatListObserver.disconnect()
this.chatListObserver = null
2021-05-28 02:27:14 -04:00
console.log("Disconnected chat list observer")
}
}
/**
* @param {[MutationRecord]} mutations - The mutation records that occurred
2021-05-28 02:27:14 -04:00
* @param {string} chatID - The ID of the chat being observed.
* @private
*/
_observeReceiptsDirect(mutations, chatID) {
let receipt_id
for (const change of mutations) {
if ( change.target.classList.contains("mdRGT07Read") &&
!change.target.classList.contains("MdNonDisp")) {
const msgElement = change.target.closest(".mdRGT07Own")
if (msgElement) {
let id = +msgElement.getAttribute("data-local-id")
if (!receipt_id || receipt_id < id) {
receipt_id = id
}
}
}
}
if (receipt_id) {
window.__mautrixReceiveReceiptDirectLatest(chatID, receipt_id).then(
() => console.debug(`Receipt sent for message ${receipt_id}`),
err => console.error(`Error sending receipt for message ${receipt_id}:`, err))
}
}
/**
* @param {[MutationRecord]} mutations - The mutation records that occurred
2021-05-28 02:27:14 -04:00
* @param {string} chatID - The ID of the chat being observed.
* @private
*/
_observeReceiptsMulti(mutations, chatID) {
2021-04-23 03:38:13 -04:00
const ids = new Set()
const receipts = []
for (const change of mutations) {
const target = change.type == "characterData" ? change.target.parentElement : change.target
2021-05-28 02:27:14 -04:00
if ( target.classList.contains("mdRGT07Read") &&
!target.classList.contains("MdNonDisp"))
{
2021-05-28 02:27:14 -04:00
const msgElement = target.closest(".mdRGT07Own")
if (msgElement) {
2021-04-23 03:38:13 -04:00
const id = +msgElement.getAttribute("data-local-id")
if (!ids.has(id)) {
ids.add(id)
receipts.push({
id: id,
2021-05-28 02:27:14 -04:00
count: this._getReceiptCount(target),
2021-04-23 03:38:13 -04:00
})
}
}
}
}
if (receipts.length > 0) {
window.__mautrixReceiveReceiptMulti(chatID, receipts).then(
2021-04-23 03:38:13 -04:00
() => console.debug(`Receipts sent for ${receipts.length} messages`),
err => console.error(`Error sending receipts for ${receipts.length} messages`, err))
}
}
2021-05-28 02:27:14 -04:00
/**
* @typedef PendingMessage
* @type object
*
* @property {Promise<MessageData>} promise
* @property {Number} id
*/
/**
* @typedef SameIDMsgs
* @type object
*
* @property {Number} id
* @property {PendingMessage[]} msgs
* @property {Function} resolve
* @property {Number} numRejected
*/
/**
* Binary search for the array of messages with the provided ID.
*
* @param {SameIDMsgs[]} sortedSameIDMsgs
* @param {Number} id
* @param {boolean} returnClosest - If true, return the index of the nearest result on miss instead of -1.
* @return {Number} The index of the matched element, or -1 if not found.
*/
_findMsgsForID(
sortedSameIDMsgs, id, returnClosest = false,
lowerBound = 0, upperBound = sortedSameIDMsgs.length - 1)
{
if (lowerBound > upperBound) {
return -1
}
if (returnClosest && lowerBound == upperBound) {
// Caller must check if the result has a matching ID or not
return sortedSameIDMsgs[lowerBound].id <= id ? lowerBound : lowerBound-1
}
const i = lowerBound + Math.floor((upperBound - lowerBound)/2)
const val = sortedSameIDMsgs[i]
if (val.id == id) {
return i
} else if (val.id < id) {
return this._findMsgsForID(
sortedSameIDMsgs, id, returnClosest,
i+1, upperBound)
} else {
return this._findMsgsForID(
sortedSameIDMsgs, id, returnClosest,
lowerBound, i-1)
}
}
/**
* Insert the given message to the proper inner array.
* In no inner array exists, insert a new one, preserving sort order.
* Return the wrapper of which inner array was added to or created.
*
* @param {SameIDMsgs[]} sortedSameIDMsgs
* @param {PendingMessage} msg
* @return {SameIDMsgs}
*/
_insertMsgByID(sortedSameIDMsgs, msg) {
let i = this._findMsgsForID(sortedSameIDMsgs, msg.id, true)
if (i != -1 && sortedSameIDMsgs[i].id == msg.id) {
sortedSameIDMsgs[i].msgs.push(msg)
console.debug("UNLIKELY(?) EVENT -- Found two new message elements with the same ID, so tracking both of them")
} else {
sortedSameIDMsgs.splice(++i, 0, {
id: msg.id,
msgs: [msg],
numRejected: 0,
resolve: null,
})
}
return sortedSameIDMsgs[i]
}
/**
2021-04-23 03:38:13 -04:00
* Add a mutation observer to the message list of the current chat.
* Used for observing new messages & read receipts.
2021-05-28 02:27:14 -04:00
*
* @param {int} minID - The minimum message ID to consider.
*/
2021-05-28 02:27:14 -04:00
addMsgListObserver(minID = 0) {
const chat_room_msg_list = document.querySelector("#_chat_room_msg_list")
if (!chat_room_msg_list) {
console.debug("Could not start msg list observer: no msg list available!")
return
}
2021-04-23 03:38:13 -04:00
this.removeMsgListObserver()
const chatID = this.getCurrentChatID()
const chatType = this.getChatType(chatID)
2021-05-28 02:27:14 -04:00
// NEED TO HANDLE:
// * message elements arriving in any order
// * messages being potentially pending (i.e. decrypting or loading),
// and resolving in a potentially different order than they arrived in
// * pending messages potentially having multiple elements associated with
// them, where only one of them resolves
// * message elements being added/removed any number of times, which may
// or may not ever resolve
// * outgoing messages (i.e. sent by the bridge)
// And must send resolved messages to the bridge *in order*!
// BUT: Assuming that incoming messages will never be younger than a resolved one.
const sortedSameIDMsgs = []
const pendingMsgElements = new Set()
2021-04-27 02:59:16 -04:00
this.msgListObserver = new MutationObserver(changes => {
2021-05-28 02:27:14 -04:00
console.debug(`MESSAGE LIST CHANGES: check since ${minID}`)
const remoteMsgs = []
2021-04-23 03:38:13 -04:00
for (const change of changes) {
2021-05-28 02:27:14 -04:00
console.debug("---new change set---")
for (const child of change.addedNodes) {
if (!pendingMsgElements.has(child) &&
child.tagName == "DIV" &&
child.hasAttribute("data-local-id") &&
// Skip timestamps, as these are always current
child.classList.contains("MdRGT07Cont"))
{
const msgID = child.getAttribute("data-local-id")
if (msgID > minID) {
pendingMsgElements.add(child)
// TODO Maybe handle own messages somewhere else...?
const ownMsg = this._observeOwnMessage(child)
if (ownMsg) {
console.log("Found own bridge-sent message, will wait for it to resolve")
console.debug(child)
this.ownMsgPromise
.then(msgID => {
console.log("Resolved own bridge-sent message")
console.debug(ownMsg)
pendingMsgElements.delete(ownMsg)
if (minID < msgID) {
minID = msgID
}
})
.catch(() => {
console.log("Rejected own bridge-sent message")
console.debug(ownMsg)
pendingMsgElements.delete(ownMsg)
})
} else {
console.log("Found remote message")
console.debug(child)
remoteMsgs.push({
id: msgID,
element: child
})
}
}
2021-04-27 02:59:16 -04:00
}
2021-05-28 02:27:14 -04:00
}
// NOTE Ignoring removedNodes because an element can always be added back.
// Will simply let permanently-removed nodes time out.
2021-04-27 02:59:16 -04:00
}
2021-05-28 02:27:14 -04:00
if (remoteMsgs.length == 0) {
console.debug("Found no new remote messages")
2021-04-27 02:59:16 -04:00
return
}
2021-05-28 02:27:14 -04:00
// No need to sort remoteMsgs, because sortedSameIDMsgs is enough
for (const msg of remoteMsgs) {
const messageElement = msg.element
const pendingMessage = {
id: msg.id,
promise: this._parseMessage(messageElement, chatType)
}
const sameIDMsgs = this._insertMsgByID(sortedSameIDMsgs, pendingMessage)
const handleMessage = async (messageData) => {
minID = messageData.id
sortedSameIDMsgs.shift()
await window.__mautrixReceiveMessages(chatID, [messageData])
if (sortedSameIDMsgs.length > 0 && sortedSameIDMsgs[0].resolve) {
console.debug("Allowing queued resolved message to be sent")
console.debug(sortedSameIDMsgs[0])
sortedSameIDMsgs[0].resolve()
}
}
pendingMessage.promise.then(
async (messageData) => {
const i = this._findMsgsForID(sortedSameIDMsgs, messageData.id)
if (i == -1) {
console.debug(`Got resolved message for already-handled ID ${messageData.id}, ignore it`)
pendingMsgElements.delete(messageElement)
return
}
if (i != 0) {
console.debug(`Got resolved message for later ID ${messageData.id}, wait for earlier messages`)
await new Promise(resolve => sameIDMsgs.resolve = resolve)
console.debug(`Message before ID ${messageData.id} finished, can now send this one`)
} else {
console.debug(`Got resolved message for earliest ID ${messageData.id}, send it`)
}
console.debug(messageElement)
pendingMsgElements.delete(messageElement)
handleMessage(messageData)
},
// error case
async (messageData) => {
console.debug("Message element rejected")
console.debug(messageElement)
pendingMsgElements.delete(messageElement)
if (++sameIDMsgs.numRejected == sameIDMsgs.msgs.length) {
// Note that if another message element with this ID somehow comes later, it'll be ignored.
console.debug(`All messages for ID ${sameIDMsgs.id} rejected, abandoning this ID and sending dummy message`)
// Choice of which message to send should be arbitrary
handleMessage(messageData)
}
})
2021-04-23 03:38:13 -04:00
}
})
2021-04-27 02:59:16 -04:00
this.msgListObserver.observe(
chat_room_msg_list,
2021-04-23 03:38:13 -04:00
{ childList: true })
2021-05-28 02:27:14 -04:00
console.debug(`Started msg list observer with minID = ${minID}`)
2021-04-23 03:38:13 -04:00
const observeReadReceipts = (
chatType == ChatTypeEnum.DIRECT ?
this._observeReceiptsDirect :
this._observeReceiptsMulti
2021-04-23 03:38:13 -04:00
).bind(this)
2021-04-23 03:38:13 -04:00
this.receiptObserver = new MutationObserver(changes => {
try {
2021-04-23 03:38:13 -04:00
observeReadReceipts(changes, chatID)
} catch (err) {
console.error("Error observing msg list mutations:", err)
}
})
2021-04-23 03:38:13 -04:00
this.receiptObserver.observe(
chat_room_msg_list, {
subtree: true,
attributes: true,
attributeFilter: ["class"],
characterData: chatType != ChatTypeEnum.DIRECT,
})
2021-04-23 03:38:13 -04:00
console.debug("Started receipt observer")
}
2021-05-28 02:27:14 -04:00
_observeOwnMessage(ownMsg) {
if (!this.ownMsgPromise) {
2021-04-27 02:59:16 -04:00
// Not waiting for a pending sent message
2021-05-28 02:27:14 -04:00
return null
2021-04-27 02:59:16 -04:00
}
2021-05-28 02:27:14 -04:00
const successElement =
ownMsg.querySelector(this.promiseOwnMsgSuccessSelector)
if (successElement) {
if (successElement.classList.contains("MdNonDisp")) {
console.log("Invisible success for own bridge-sent message, will wait for it to resolve")
console.log(successElement)
2021-05-06 00:43:26 -04:00
} else {
2021-05-28 02:27:14 -04:00
console.debug("Already visible success, must not be it")
console.debug(successElement)
return null
2021-04-27 02:59:16 -04:00
}
2021-05-28 02:27:14 -04:00
} else {
return null
}
2021-04-27 02:59:16 -04:00
2021-05-28 02:27:14 -04:00
const failureElement =
this.promiseOwnMsgFailureSelector &&
ownMsg.querySelector(this.promiseOwnMsgFailureSelector)
if (failureElement) {
if (failureElement.classList.contains("MdNonDisp")) {
console.log("Invisible failure for own bridge-sent message, will wait for it (or success) to resolve")
console.log(failureElement)
} else {
console.debug("Already visible failure, must not be it")
console.log(failureElement)
return null
2021-04-27 02:59:16 -04:00
}
2021-05-28 02:27:14 -04:00
} else if (this.promiseOwnMsgFailureSelector) {
return null
}
2021-04-27 02:59:16 -04:00
2021-05-28 02:27:14 -04:00
const msgID = +ownMsg.getAttribute("data-local-id")
this.visibleSuccessObserver = new MutationObserver(
this._getOwnVisibleCallback(msgID))
this.visibleSuccessObserver.observe(
successElement,
{ attributes: true, attributeFilter: ["class"] })
if (this.promiseOwnMsgFailureSelector) {
this.visibleFailureObserver = new MutationObserver(
this._getOwnVisibleCallback())
this.visibleFailureObserver.observe(
failureElement,
2021-05-06 00:43:26 -04:00
{ attributes: true, attributeFilter: ["class"] })
2021-04-27 02:59:16 -04:00
}
2021-05-28 02:27:14 -04:00
return ownMsg
2021-04-27 02:59:16 -04:00
}
_getOwnVisibleCallback(msgID=null) {
const isSuccess = !!msgID
return changes => {
for (const change of changes) {
if (!change.target.classList.contains("MdNonDisp")) {
2021-05-28 02:27:14 -04:00
console.log(`Resolved ${isSuccess ? "success" : "failure"} for own bridge-sent message`)
2021-04-27 02:59:16 -04:00
console.log(change.target)
isSuccess ? this._resolveOwnMessage(msgID) : this._rejectOwnMessage(change.target)
return
}
}
}
}
_resolveOwnMessage(msgID) {
2021-05-06 00:43:26 -04:00
if (!this.promiseOwnMsgResolve) return
2021-05-28 02:27:14 -04:00
clearTimeout(this.promiseOwnMsgTimeoutID)
2021-04-27 02:59:16 -04:00
const resolve = this.promiseOwnMsgResolve
this._promiseOwnMsgReset()
window.__mautrixReceiveMessageID(msgID).then(
() => resolve(msgID))
}
2021-05-06 00:43:26 -04:00
_rejectOwnMessage(failureElement = null) {
if (!this.promiseOwnMsgReject) return
2021-04-27 02:59:16 -04:00
const reject = this.promiseOwnMsgReject
this._promiseOwnMsgReset()
reject(failureElement)
}
_promiseOwnMsgReset() {
2021-05-28 02:27:14 -04:00
this.ownMsgPromise = null
2021-04-27 02:59:16 -04:00
this.promiseOwnMsgSuccessSelector = null
this.promiseOwnMsgFailureSelector = null
this.promiseOwnMsgResolve = null
this.promiseOwnMsgReject = null
2021-05-28 02:27:14 -04:00
this.promiseOwnMsgTimeoutID = null
2021-04-27 02:59:16 -04:00
if (this.visibleSuccessObserver) {
this.visibleSuccessObserver.disconnect()
}
this.visibleSuccessObserver = null
if (this.visibleFailureObserver) {
this.visibleFailureObserver.disconnect()
}
this.visibleFailureObserver = null
}
removeMsgListObserver() {
2021-04-23 03:38:13 -04:00
let result = false
if (this.msgListObserver !== null) {
this.msgListObserver.disconnect()
this.msgListObserver = null
console.debug("Disconnected msg list observer")
2021-04-23 03:38:13 -04:00
result = true
}
if (this.receiptObserver !== null) {
this.receiptObserver.disconnect()
this.receiptObserver = null
console.debug("Disconnected receipt observer")
result = true
}
2021-04-23 03:38:13 -04:00
return result
}
2021-02-10 02:34:19 -05:00
addQRChangeObserver(element) {
2021-04-23 03:38:13 -04:00
this.removeQRChangeObserver()
2021-02-10 02:34:19 -05:00
this.qrChangeObserver = new MutationObserver(changes => {
for (const change of changes) {
if (change.attributeName === "title" && change.target instanceof Element) {
window.__mautrixReceiveQR(change.target.getAttribute("title"))
}
}
})
this.qrChangeObserver.observe(element, {
attributes: true,
attributeFilter: ["title"],
})
}
removeQRChangeObserver() {
if (this.qrChangeObserver !== null) {
this.qrChangeObserver.disconnect()
this.qrChangeObserver = null
}
}
addQRAppearObserver(element) {
2021-04-23 03:38:13 -04:00
this.removeQRAppearObserver()
2021-02-10 02:34:19 -05:00
this.qrAppearObserver = new MutationObserver(changes => {
for (const change of changes) {
for (const node of change.addedNodes) {
const qrElement = node.querySelector("#login_qrcode_area div[title]")
if (qrElement) {
window.__mautrixReceiveQR(qrElement.title)
window.__mautrixController.addQRChangeObserver(element)
return
}
}
}
})
this.qrAppearObserver.observe(element, {
childList: true,
})
}
removeQRAppearObserver() {
if (this.qrAppearObserver !== null) {
this.qrAppearObserver.disconnect()
this.qrAppearObserver = null
}
}
addEmailAppearObserver(element) {
2021-04-23 03:38:13 -04:00
this.removeEmailAppearObserver()
2021-02-10 02:34:19 -05:00
this.emailAppearObserver = new MutationObserver(changes => {
for (const change of changes) {
2021-02-10 02:34:19 -05:00
for (const node of change.addedNodes) {
2021-02-11 00:04:25 -05:00
const emailArea = node.querySelector("#login_email_area")
2021-02-20 03:48:08 -05:00
if (emailArea && !emailArea.classList.contains("MdNonDisp")) {
2021-02-10 02:34:19 -05:00
window.__mautrixSendEmailCredentials()
return
}
}
}
})
2021-02-10 02:34:19 -05:00
this.emailAppearObserver.observe(element, {
childList: true,
})
}
removeEmailAppearObserver() {
if (this.emailAppearObserver !== null) {
this.emailAppearObserver.disconnect()
this.emailAppearObserver = null
}
}
addPINAppearObserver(element) {
2021-04-23 03:38:13 -04:00
this.removePINAppearObserver()
2021-02-10 02:34:19 -05:00
this.pinAppearObserver = new MutationObserver(changes => {
for (const change of changes) {
for (const node of change.addedNodes) {
const pinElement = node.querySelector("div.mdCMN01Code")
if (pinElement) {
window.__mautrixReceivePIN(pinElement.innerText)
return
}
}
}
})
this.pinAppearObserver.observe(element, {
childList: true,
})
}
removePINAppearObserver() {
if (this.pinAppearObserver !== null) {
this.pinAppearObserver.disconnect()
this.pinAppearObserver = null
}
}
}
window.__mautrixController = new MautrixController()
2021-05-28 02:27:14 -04:00
/**
* Watch for an error dialog / PIN expiry dialog to appear, and click its "OK" button.
* Must watch for both its parent appearing & it being added to its parent in the first place.
*/
const layer = document.querySelector("#layer_contents")
new MutationObserver(() => {
if (!layer.classList.contains("MdNonDisp")) {
const button = layer.querySelector("dialog button")
if (button) {
console.log("Something expired, clicking OK button to continue")
button.click()
}
}
}).observe(layer, {
attributes: true,
attributeFilter: ["class"],
childList: true,
})
/**
* Watch for being logged out.
*/
const mainApp = document.querySelector("#mainApp")
new MutationObserver(() => {
if (mainApp.classList.contains("MdNonDisp")) {
window.__mautrixLoggedOut()
}
}).observe(mainApp, {
attributes: true,
attributeFilter: ["class"],
2021-05-28 02:27:14 -04:00
})