From 7f937d34e28854f394710c2401670b717b78df7f Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 23 Apr 2021 03:38:13 -0400 Subject: [PATCH] WIP read receipt improvements --- puppet/src/contentscript.js | 196 ++++++++++++++++++++---------------- puppet/src/puppet.js | 85 ++++++++++------ 2 files changed, 162 insertions(+), 119 deletions(-) diff --git a/puppet/src/contentscript.js b/puppet/src/contentscript.js index 946f5fc..a40c5ca 100644 --- a/puppet/src/contentscript.js +++ b/puppet/src/contentscript.js @@ -27,6 +27,12 @@ window.__chronoParseDate = function (text, ref, option) {} * @return {Promise} */ window.__mautrixReceiveChanges = function (changes) {} +/** + * @param {string} messages - The ID of the chat receiving messages. + * @param {MessageData[]} messages - The messages added to a chat. + * @return {Promise} + */ +window.__mautrixReceiveMessages = function (chatID, messages) {} /** * @param {str} chatID - The ID of the chat whose receipts are being processed. * @param {str} receipt_id - The ID of the most recently-read message for the current chat. @@ -78,6 +84,7 @@ class MautrixController { constructor(ownID) { this.chatListObserver = null this.msgListObserver = null + this.receiptObserver = null this.qrChangeObserver = null this.qrAppearObserver = null this.emailAppearObserver = null @@ -106,9 +113,9 @@ class MautrixController { } } - getCurrentChatId() { + getCurrentChatID() { const chatListElement = document.querySelector("#_chat_list_body > .ExSelected > .chatList") - return chatListElement ? this.getChatListItemId(chatListElement) : null + return chatListElement ? this.getChatListItemID(chatListElement) : null } /** @@ -182,7 +189,7 @@ class MautrixController { * @return {MessageData} * @private */ - async _tryParseMessage(date, element, chatType) { + async _parseMessage(date, element, chatType) { const is_outgoing = element.classList.contains("mdRGT07Own") let sender = {} @@ -386,40 +393,37 @@ class MautrixController { }) } - /** - * Parse the message list of whatever the currently-viewed chat is. - * - * @param {?string} chatId - The ID of the currently-viewed chat, if known. - * @return {[MessageData]} - A list of messages. - */ - async parseMessageList(chatId) { - if (!chatId) { - chatId = this.getCurrentChatId() - } - const chatType = this.getChatType(chatId) - const msgList = document.querySelector("#_chat_room_msg_list") + async _tryParseMessages(msgList, chatType) { const messages = [] let refDate = null - for (const child of msgList.children) { - if (child.tagName == "DIV") { - if (child.classList.contains("mdRGT10Date")) { - refDate = await this._tryParseDayDate(child.firstElementChild.innerText) - } - else if (child.classList.contains("MdRGT07Cont")) { - // TODO :not(.MdNonDisp) to exclude not-yet-posted messages, - // but that is unlikely to be a problem here. - // Also, offscreen times may have .MdNonDisp on them - const timeElement = child.querySelector("time") - if (timeElement) { - const messageDate = await this._tryParseDate(timeElement.innerText, refDate) - messages.push(await this._tryParseMessage(messageDate, child, chatType)) - } + for (const child of msgList) { + if (child.classList.contains("mdRGT10Date")) { + refDate = await this._tryParseDayDate(child.firstElementChild.innerText) + } else if (child.classList.contains("MdRGT07Cont")) { + // TODO :not(.MdNonDisp) to exclude not-yet-posted messages, + // but that is unlikely to be a problem here. + // Also, offscreen times may have .MdNonDisp on them + const timeElement = child.querySelector("time") + if (timeElement) { + const messageDate = await this._tryParseDate(timeElement.innerText, refDate) + messages.push(await this._parseMessage(messageDate, child, chatType)) } } } return messages } + /** + * Parse the message list of whatever the currently-viewed chat is. + * + * @return {[MessageData]} - A list of messages. + */ + async parseMessageList() { + const msgList = Array.from(document.querySelectorAll("#_chat_room_msg_list > div[data-local-id]")) + msgList.sort((a,b) => a.getAttribute("data-local-id") - b.getAttribute("data-local-id")) + return await this._tryParseMessages(msgList, this.getChatType(this.getCurrentChatID())) + } + /** * @typedef PathImage * @type object @@ -457,7 +461,7 @@ class MautrixController { return this._getPathImage(element.querySelector(".mdRGT13Img img[src]")) } - getParticipantListItemId(element) { + getParticipantListItemID(element) { // TODO Cache own ID return element.getAttribute("data-mid") } @@ -483,7 +487,7 @@ class MautrixController { return [ownParticipant].concat(Array.from(element.children).slice(1).map(child => { const name = this.getParticipantListItemName(child) - const id = this.getParticipantListItemId(child) || this.getUserIdFromFriendsList(name) + const id = this.getParticipantListItemID(child) || this.getUserIdFromFriendsList(name) return { id: id, // NOTE Don't want non-own user's ID to ever be null. avatar: this.getParticipantListItemAvatar(child), @@ -504,7 +508,7 @@ class MautrixController { * (e.g. "7:16 PM", "Thu" or "Aug 4") */ - getChatListItemId(element) { + getChatListItemID(element) { return element.getAttribute("data-chatid") } @@ -528,12 +532,12 @@ class MautrixController { * Parse a conversation list item element. * * @param {Element} element - The element to parse. - * @param {?string} knownId - The ID of this element, if it is known. + * @param {?string} knownID - The ID of this element, if it is known. * @return {ChatListInfo} - The info in the element. */ - parseChatListItem(element, knownId) { + parseChatListItem(element, knownID) { return !element.classList.contains("chatList") ? null : { - id: knownId || this.getChatListItemId(element), + id: knownID || this.getChatListItemID(element), name: this.getChatListItemName(element), icon: this.getChatListItemIcon(element), lastMsg: this.getChatListItemLastMsg(element), @@ -599,9 +603,11 @@ class MautrixController { for (const node of change.addedNodes) { } */ - } - else if (change.target.tagName == "LI") - { + } else if (change.target.tagName == "LI") { + if (!change.target.classList.contains("ExSelected")) { + console.log("Not using chat list mutation response for currently-active chat") + continue + } for (const node of change.addedNodes) { const chat = this.parseChatListItem(node) if (chat) { @@ -626,9 +632,7 @@ class MautrixController { * Add a mutation observer to the chat list. */ addChatListObserver() { - if (this.chatListObserver !== null) { - this.removeChatListObserver() - } + this.removeChatListObserver() this.chatListObserver = new MutationObserver(mutations => { try { this._observeChatListMutations(mutations) @@ -686,80 +690,108 @@ class MautrixController { * @private */ _observeReceiptsMulti(mutations, chat_id) { + const ids = new Set() const receipts = [] for (const change of mutations) { - if ( change.target.classList.contains("mdRGT07Read") && - !change.target.classList.contains("MdNonDisp")) { + let success = false + if (change.type == "attributes") { + if ( change.target.classList.contains("mdRGT07Read") && + !change.target.classList.contains("MdNonDisp")) { + success = true + } + } else if (change.type == "characterData") { + success = true + } + if (success) { const msgElement = change.target.closest(".mdRGT07Own") if (msgElement) { - receipts.push({ - id: +msgElement.getAttribute("data-local-id"), - count: this._getReceiptCount(msgElement), - }) + const id = +msgElement.getAttribute("data-local-id") + if (!ids.has(id)) { + ids.add(id) + receipts.push({ + id: id, + count: this._getReceiptCount(change.target), + }) + } } } } if (receipts.length > 0) { window.__mautrixReceiveReceiptMulti(chat_id, receipts).then( - () => console.debug(`Receipt sent for message ${receipt_id}`), - err => console.error(`Error sending receipt for message ${receipt_id}:`, err)) + () => console.debug(`Receipts sent for ${receipts.length} messages`), + err => console.error(`Error sending receipts for ${receipts.length} messages`, err)) } } /** - * Add a mutation observer to the message list. - * Used for observing read receipts. - * TODO Should also use for observing messages of the currently-viewed chat. + * Add a mutation observer to the message list of the current chat. + * Used for observing new messages & read receipts. */ - addMsgListObserver(forceCreate) { + addMsgListObserver() { 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 } - if (this.msgListObserver !== null) { - this.removeMsgListObserver() - } else if (!forceCreate) { - console.debug("No pre-existing msg list observer to replace") - return - } + this.removeMsgListObserver() - const observeReadReceipts = - this.getChatType(this.getCurrentChatId()) == ChatTypeEnum.DIRECT ? + const chatID = this.getCurrentChatID() + const chatType = this.getChatType(chatID) + + this.msgListObserver = new MutationObserver(async (changes) => { + for (const change of changes) { + const msgList = Array.from(change.addedNodes).filter( + child => child.tagName == "DIV" && child.hasAttribute("data-local-id")) + msgList.sort((a,b) => a.getAttribute("data-local-id") - b.getAttribute("data-local-id")) + window.__mautrixReceiveMessages(chatID, await this._tryParseMessages(msgList, chatType)) + } + }) + this.msgListObserver.observe(chat_room_msg_list, + { childList: true }) + + console.debug("Started msg list observer") + + + const observeReadReceipts = ( + chatType == ChatTypeEnum.DIRECT ? this._observeReceiptsDirect : this._observeReceiptsMulti + ).bind(this) - const chat_id = this.getCurrentChatId() - - this.msgListObserver = new MutationObserver(mutations => { + this.receiptObserver = new MutationObserver(changes => { try { - observeReadReceipts(mutations, chat_id) + observeReadReceipts(changes, chatID) } catch (err) { console.error("Error observing msg list mutations:", err) } }) - this.msgListObserver.observe( + this.receiptObserver.observe( chat_room_msg_list, - { subtree: true, attributes: true, attributeFilter: ["class"], characterData: true }) - console.debug("Started msg list observer") + { subtree: true, attributes: true, attributeFilter: ["class"] }) + + console.debug("Started receipt observer") } - /** - * Disconnect the most recently added mutation observer. - */ removeMsgListObserver() { + let result = false if (this.msgListObserver !== null) { this.msgListObserver.disconnect() this.msgListObserver = null console.debug("Disconnected msg list observer") + result = true } + if (this.receiptObserver !== null) { + this.receiptObserver.disconnect() + this.receiptObserver = null + console.debug("Disconnected receipt observer") + result = true + } + return result } addQRChangeObserver(element) { - if (this.qrChangeObserver !== null) { - this.removeQRChangeObserver() - } + this.removeQRChangeObserver() this.qrChangeObserver = new MutationObserver(changes => { for (const change of changes) { if (change.attributeName === "title" && change.target instanceof Element) { @@ -781,9 +813,7 @@ class MautrixController { } addQRAppearObserver(element) { - if (this.qrAppearObserver !== null) { - this.removeQRAppearObserver() - } + this.removeQRAppearObserver() this.qrAppearObserver = new MutationObserver(changes => { for (const change of changes) { for (const node of change.addedNodes) { @@ -809,9 +839,7 @@ class MautrixController { } addEmailAppearObserver(element) { - if (this.emailAppearObserver !== null) { - this.removeEmailAppearObserver() - } + this.removeEmailAppearObserver() this.emailAppearObserver = new MutationObserver(changes => { for (const change of changes) { for (const node of change.addedNodes) { @@ -836,9 +864,7 @@ class MautrixController { } addPINAppearObserver(element) { - if (this.pinAppearObserver !== null) { - this.removePINAppearObserver() - } + this.removePINAppearObserver() this.pinAppearObserver = new MutationObserver(changes => { for (const change of changes) { for (const node of change.addedNodes) { @@ -863,9 +889,7 @@ class MautrixController { } addExpiryObserver(element) { - if (this.expiryObserver !== null) { - this.removeExpiryObserver() - } + this.removeExpiryObserver() const button = element.querySelector("dialog button") this.expiryObserver = new MutationObserver(changes => { if (changes.length == 1 && !changes[0].target.classList.contains("MdNonDisp")) { diff --git a/puppet/src/puppet.js b/puppet/src/puppet.js index 1ed2622..2dcdae5 100644 --- a/puppet/src/puppet.js +++ b/puppet/src/puppet.js @@ -97,6 +97,8 @@ export default class MessagesPuppeteer { id => this.sentMessageIDs.add(id)) await this.page.exposeFunction("__mautrixReceiveChanges", this._receiveChatListChanges.bind(this)) + await this.page.exposeFunction("__mautrixReceiveMessages", + this._receiveMessages.bind(this)) await this.page.exposeFunction("__mautrixReceiveReceiptDirectLatest", this._receiveReceiptDirectLatest.bind(this)) await this.page.exposeFunction("__mautrixReceiveReceiptMulti", @@ -366,6 +368,21 @@ export default class MessagesPuppeteer { return { id: await this.taskQueue.push(() => this._sendMessageUnsafe(chatID, text)) } } + _filterMessages(chatID, messages) { + const minID = this.mostRecentMessages.get(chatID) || 0 + const filtered_messages = messages.filter(msg => msg.id > minID && !this.sentMessageIDs.has(msg.id)) + + let range = 0 + if (filtered_messages.length > 0) { + const newFirstID = filtered_messages[0].id + const newLastID = filtered_messages[filtered_messages.length - 1].id + this.mostRecentMessages.set(chatID, newLastID) + range = newFirstID === newLastID ? newFirstID : `${newFirstID}-${newLastID}` + } + this.log(`Loaded ${messages.length} messages in ${chatID}: got ${range} newer than ${minID}`) + return filtered_messages + } + /** * Get messages in a chat. * @@ -376,15 +393,9 @@ export default class MessagesPuppeteer { return this.taskQueue.push(async () => { const messages = await this._getMessagesUnsafe(chatID) if (messages.length > 0) { - // TODO Commonize this - const newFirstID = messages[0].id - const newLastID = messages[messages.length - 1].id - this.mostRecentMessages.set(chatID, newLastID) - const range = newFirstID === newLastID ? newFirstID : `${newFirstID}-${newLastID}` - this.log(`Loaded ${messages.length} messages in ${chatID}: got ${range}`) - } - for (const message of messages) { - message.chat_id = chatID + for (const message of messages) { + message.chat_id = chatID + } } return messages }) @@ -449,7 +460,7 @@ export default class MessagesPuppeteer { await this.page.evaluate( () => window.__mautrixController.addChatListObserver()) await this.page.evaluate( - () => window.__mautrixController.addMsgListObserver(true)) + () => window.__mautrixController.addMsgListObserver()) } async stopObserving() { @@ -482,6 +493,10 @@ export default class MessagesPuppeteer { if (await this.page.evaluate(isCorrectChatVisible, chatName)) { this.log("Already viewing chat, no need to switch") } else { + this.log("Switching chat, so remove msg list observer") + const hadMsgListObserver = await this.page.evaluate( + () => window.__mautrixController.removeMsgListObserver()) + await chatListItem.click() this.log(`Waiting for chat header title to be "${chatName}"`) await this.page.waitForFunction( @@ -495,8 +510,13 @@ export default class MessagesPuppeteer { {}, await this.page.$("#_chat_detail_area")) - await this.page.evaluate( - () => window.__mautrixController.addMsgListObserver(false)) + if (hadMsgListObserver) { + this.log("Restoring msg list observer") + await this.page.evaluate( + () => window.__mautrixController.addMsgListObserver()) + } else { + this.log("Not restoring msg list observer, as there never was one") + } } } @@ -591,21 +611,29 @@ export default class MessagesPuppeteer { } } - // TODO Inbound read receipts - // Probably use a MutationObserver mapped to msgID + _receiveMessages(chatID, messages) { + if (this.client) { + messages = this._filterMessages(chatID, messages) + if (messages.length > 0) { + for (const message of messages) { + message.chat_id = chatID + this.client.sendMessage(message).catch(err => + this.error("Failed to send message", message.id, "to client:", err)) + } + } + } else { + this.log("No client connected, not sending messages") + } + } async _getMessagesUnsafe(chatID) { // TODO Also handle "decrypting" state // TODO Handle unloaded messages. Maybe scroll up // TODO This will mark the chat as "read"! await this._switchChat(chatID) - const minID = this.mostRecentMessages.get(chatID) || 0 - this.log(`Waiting for messages newer than ${minID}`) - const messages = await this.page.evaluate( - chatID => window.__mautrixController.parseMessageList(chatID), chatID) - const filtered_messages = messages.filter(msg => msg.id > minID && !this.sentMessageIDs.has(msg.id)) - this.log(`Found messages: ${messages.length} total, ${filtered_messages.length} new`) - return filtered_messages + const messages = await this.page.evaluate(() => + window.__mautrixController.parseMessageList()) + return this._filterMessages(chatID, messages) } async _processChatListChangeUnsafe(chatID) { @@ -616,11 +644,6 @@ export default class MessagesPuppeteer { this.log("No new messages found in", chatID) return } - const newFirstID = messages[0].id - const newLastID = messages[messages.length - 1].id - this.mostRecentMessages.set(chatID, newLastID) - const range = newFirstID === newLastID ? newFirstID : `${newFirstID}-${newLastID}` - this.log(`Loaded ${messages.length} messages in ${chatID}: got ${range}`) if (this.client) { for (const message of messages) { @@ -652,14 +675,10 @@ export default class MessagesPuppeteer { _receiveReceiptMulti(chat_id, receipts) { this.log(`Received bulk read receipts for chat ${chat_id}:`, receipts) - this.taskQueue.push(() => this._receiveReceiptMulti(chat_id, receipts)) - .catch(err => this.error("Error handling read receipt changes:", err)) - } - - async _receiveReceiptMultiUnsafe(chat_id, receipts) { - for (receipt of receipts) { + for (const receipt of receipts) { receipt.chat_id = chat_id - await this.client.sendReceipt(receipt) + this.taskQueue.push(() => this.client.sendReceipt(receipt)) + .catch(err => this.error("Error handling read receipt changes:", err)) } }