/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { nsSimpleEnumerator, l10nHelper, } from "resource:///modules/imXPCOMUtils.sys.mjs"; import { IMServices } from "resource:///modules/IMServices.sys.mjs"; import { GenericAccountPrototype, GenericConvChatPrototype, GenericConvChatBuddyPrototype, GenericConversationPrototype, GenericConvIMPrototype, GenericAccountBuddyPrototype, GenericMessagePrototype, GenericSessionPrototype, TooltipInfo, } from "resource:///modules/jsProtoHelper.sys.mjs"; const lazy = {}; XPCOMUtils.defineLazyGetter(lazy, "_", () => l10nHelper("chrome://chat/locale/matrix.properties") ); XPCOMUtils.defineLazyGetter( lazy, "l10n", () => new Localization(["chat/matrix.ftl"], true) ); ChromeUtils.defineESModuleGetters(lazy, { DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", InteractiveBrowser: "resource:///modules/InteractiveBrowser.sys.mjs", MatrixCrypto: "resource:///modules/matrix-sdk.sys.mjs", MatrixMessageContent: "resource:///modules/matrixMessageContent.sys.mjs", MatrixPowerLevels: "resource:///modules/matrixPowerLevels.sys.mjs", MatrixSDK: "resource:///modules/matrix-sdk.sys.mjs", OlmLib: "resource:///modules/matrix-sdk.sys.mjs", ReceiptType: "resource:///modules/matrix-sdk.sys.mjs", SyncState: "resource:///modules/matrix-sdk.sys.mjs", }); /** * Homeserver information in client .well-known payload. * * @constant {string} */ const HOMESERVER_WELL_KNOWN = "m.homeserver"; // This matches the configuration of the .userIcon class in chat.css, which // expects square icons. const USER_ICON_SIZE = 48; const SERVER_NOTICE_TAG = "m.server_notice"; const MAX_CATCHUP_EVENTS = 300; // Should always be smaller or equal to MAX_CATCHUP_EVENTS const CATCHUP_PAGE_SIZE = 25; /** * @param {string} who - Message sender ID. * @param {string} text - Message text. * @param {object} properties - Message properties, should also have an event * property containing the corresponding MatrixEvent instance. * @param {MatrixRoom} conversation - The conversation the Message belongs to. */ export function MatrixMessage(who, text, properties, conversation) { this._init(who, text, properties, conversation); } MatrixMessage.prototype = { __proto__: GenericMessagePrototype, /** * @type {MatrixEvent} */ event: null, /** * @type {{msg: string, action: boolean, notice: boolean}} */ retryInfo: null, get hideReadReceipts() { // Cache pref value. If this pref gets exposed in UI we need cache busting. if (this._hideReadReceipts === undefined) { this._hideReadReceipts = !Services.prefs.getBoolPref( "purple.conversations.im.send_read" ); } return this._hideReadReceipts; }, _displayed: false, _read: false, whenDisplayed() { if ( this._displayed || !this.event || (this.event.status && this.event.status !== lazy.MatrixSDK.EventStatus.SENT) ) { return; } this._displayed = true; this.conversation._account._client .sendReadReceipt( this.event, this.hideReadReceipts ? lazy.ReceiptType.ReadPrivate : lazy.ReceiptType.Read ) .catch(error => this.conversation.ERROR(error)); }, whenRead() { // whenRead is also called when the conversation is closed. if ( this._read || !this.event || !this.conversation._account || this.conversation._account.noFullyRead || (this.event.status && this.event.status !== lazy.MatrixSDK.EventStatus.SENT) ) { return; } this._read = true; this.conversation._account._client .setRoomReadMarkers(this.conversation._roomId, this.event.getId()) .catch(error => { if (error.errcode === "M_UNRECOGNIZED") { // Server does not support setting the fully read marker. this.conversation._account.noFullyRead = true; } else { this.conversation.ERROR(error); } }); }, getActions() { const actions = []; if (this.event?.isDecryptionFailure()) { actions.push({ label: lazy._("message.action.requestKey"), run: () => { if (this.event) { this.conversation?._account?._client ?.cancelAndResendEventRoomKeyRequest(this.event) .catch(error => this.conversation._account.ERROR(error)); } }, }); } if ( this.event && this.conversation?.roomState.maySendRedactionForEvent( this.event, this.conversation._account?.userId ) ) { actions.push({ label: lazy._("message.action.redact"), run: () => { this.conversation?._account?._client ?.redactEvent( this.event.getRoomId(), this.event.threadRootId, this.event.getId() ) .catch(error => this.conversation._account.ERROR(error)); }, }); } if (this.incoming && this.event) { actions.push({ label: lazy._("message.action.report"), run: () => { this.conversation?._account?._client ?.reportEvent(this.event.getRoomId(), this.event.getId(), -100, "") .catch(error => this.conversation._account.ERROR(error)); }, }); } if (this.event?.status === lazy.MatrixSDK.EventStatus.NOT_SENT) { actions.push({ label: lazy._("message.action.retry"), run: () => { this.conversation?._account?._client?.resendEvent( this.event, this.conversation.room ); }, }); } if ( [ lazy.MatrixSDK.EventStatus.NOT_SENT, lazy.MatrixSDK.EventStatus.QUEUED, lazy.MatrixSDK.EventStatus.ENCRYPTING, ].includes(this.event?.status) ) { actions.push({ label: lazy._("message.action.cancel"), run: () => { this.conversation?._account?._client?.cancelPendingEvent(this.event); }, }); } return actions; }, }; /** * Check if a user has unverified devices. * * @param {string} userId - User to check. * @param {MatrixClient} client - Matrix SDK client instance to use. * @returns {boolean} */ function checkUserHasUnverifiedDevices(userId, client) { const devices = client.getStoredDevicesForUser(userId); return devices.some( ({ deviceId }) => !client.checkDeviceTrust(userId, deviceId).isVerified() ); } /** * Shared implementation for canVerifyIdentity between MatrixParticipant and * MatrixBuddy. * * @param {string} userId - Matrix ID of the user. * @param {MatrixClient} client - Matrix SDK client instance. * @returns {boolean} */ function canVerifyUserIdentity(userId, client) { client.downloadKeys([userId]); return Boolean(client.getStoredDevicesForUser(userId)?.length); } /** * Checks if we consider the identity of a user as verified. * * @param {string} userId - Matrix ID of the user to check. * @param {MatrixClient} client - Matrix SDK client instance to use. * @returns {boolean} */ function userIdentityVerified(userId, client) { return ( client.checkUserTrust(userId).isCrossSigningVerified() && !checkUserHasUnverifiedDevices(userId, client) ); } function MatrixParticipant(roomMember, account) { this._id = roomMember.userId; this._roomMember = roomMember; this._account = account; } MatrixParticipant.prototype = { __proto__: GenericConvChatBuddyPrototype, get alias() { return this._roomMember.name; }, get name() { return this._id; }, get buddyIconFilename() { return ( this._roomMember.getAvatarUrl( this._account._client.getHomeserverUrl(), USER_ICON_SIZE, USER_ICON_SIZE, "scale", false ) || "" ); }, get voiced() { // If the default power level doesn't let you send messages, set voiced if // the user can send messages const room = this._account?._client?.getRoom(this._roomMember.roomId); if (room) { const powerLevels = room.currentState .getStateEvents(lazy.MatrixSDK.EventType.RoomPowerLevels, "") ?.getContent(); const defaultLevel = lazy.MatrixPowerLevels.getUserDefaultLevel(powerLevels); const messageLevel = lazy.MatrixPowerLevels.getEventLevel( powerLevels, this._account._client.isRoomEncrypted(room.roomId) ? lazy.MatrixSDK.EventType.RoomMessageEncrypted : lazy.MatrixSDK.EventType.RoomMessage ); if (defaultLevel < messageLevel) { return room.currentState.maySendMessage(this._id); } } // Else use a synthetic power level for the voiced flag return this._roomMember.powerLevelNorm >= lazy.MatrixPowerLevels.voice; }, get moderator() { return this._roomMember.powerLevelNorm >= lazy.MatrixPowerLevels.moderator; }, get admin() { return this._roomMember.powerLevelNorm >= lazy.MatrixPowerLevels.admin; }, get canVerifyIdentity() { return canVerifyUserIdentity(this.name, this._account._client); }, get _identityVerified() { return userIdentityVerified(this.name, this._account._client); }, _startVerification() { return this._account.startVerificationDM(this.name); }, }; const kPresenceToStatusEnum = { online: Ci.imIStatusInfo.STATUS_AVAILABLE, offline: Ci.imIStatusInfo.STATUS_OFFLINE, unavailable: Ci.imIStatusInfo.STATUS_IDLE, }; const kSetIdleStatusAfterSeconds = 300; /** * Map matrix presence information to a Ci.imIStatusInfo statusType. * * @param {User} user - Matrix JS SDK User instance to get the status for. * @returns {number} Status enum value for the user. */ function getStatusFromPresence(user) { let status = kPresenceToStatusEnum[user.presence]; // If the user hasn't been seen in a long time, consider them idle. if ( user.presence === "online" && !user.currentlyActive && user.lastActiveAgo > kSetIdleStatusAfterSeconds ) { status = Ci.imIStatusInfo.STATUS_IDLE; } if (!status) { status = Ci.imIStatusInfo.STATUS_UNKNOWN; } return status; } /** * Matrix buddies only exist in association with at least one direct * conversation. They serve primarily to provide metadata to the * direct conversation rooms. * * @param {imIAccount} account * @param {imIBuddy|null} buddy * @param {imITag|null} tag * @param {string} [userId] - Matrix user ID, only required if no buddy is provided. */ function MatrixBuddy(account, buddy, tag, userId) { this._init(account, buddy, tag, userId); } MatrixBuddy.prototype = { __proto__: GenericAccountBuddyPrototype, get buddyIconFilename() { return ( (this._user && this._account._client.mxcUrlToHttp(this._user.avatarUrl)) || "" ); }, get canSendMessage() { return true; }, /** * Initialize the buddy with a user. * * @param {User} user - Matrix user. */ setUser(user) { this._user = user; this._serverAlias = user.displayName; // The contacts service might not have had a chance to add an imIBuddy yet, // since it also wants the serverAlias to be set if possible. if (this.buddy) { this.setStatus(getStatusFromPresence(user), user.presenceStatusMsg ?? ""); } }, /** * Updates the buddy's status based on its JS SDK user's presence. */ setStatusFromPresence() { this.setStatus( getStatusFromPresence(this._user), this._user.presenceStatusMsg ?? "" ); }, remove() { const otherDMRooms = this._account._userToRoom[this.userName]; for (const roomId of otherDMRooms) { if (this._account.roomList.has(roomId)) { const conversation = this._account.roomList.get(roomId); if (!conversation.isChat) { // Prevent the conversation from doing buddy cleanup delete conversation.buddy; conversation.close(); } } } this._account.buddies.delete(this.userName); GenericAccountBuddyPrototype.remove.call(this); }, getTooltipInfo() { return this._account.getBuddyInfo(this.userName); }, createConversation() { return this._account.getDirectConversation(this.userName); }, get canVerifyIdentity() { return canVerifyUserIdentity(this.userName, this._account._client); }, get _identityVerified() { return userIdentityVerified(this.userName, this._account._client); }, _startVerification() { return this._account.startVerificationDM(this.userName); }, }; /** * Determine if the event will likely have text content composed by a user to * display in a conversation based on its type. * * @param {MatrixEvent} event - Event to check the type of * @returns {boolean} True if the event would typically be shown as text content * sent by a user in a conversation. */ function isContentEvent(event) { return [ lazy.MatrixSDK.EventType.RoomMessage, lazy.MatrixSDK.EventType.RoomMessageEncrypted, lazy.MatrixSDK.EventType.Sticker, ].includes(event.getType()); } /** * Matrix rooms are androgynous. Sometimes they are DM conversations, other * times they are MUCs. * This class implements both conversations state and transition between the * two. Methods are grouped by shared/MUC/DM. * The type is only changed on explicit request. * * @param {MatrixAccount} account - Account this room belongs to. * @param {boolean} isMUC - True if this is a group conversation. * @param {string} name - Name of the room. */ export function MatrixRoom(account, isMUC, name) { this._isChat = isMUC; this._init(account, name, account.userId); this._initialized = new Promise(resolve => { this._resolveInitializer = resolve; }); this._eventsWaitingForDecryption = new Set(); this._joiningLocks = new Set(); this._addJoiningLock("roomInit"); } MatrixRoom.prototype = { __proto__: GenericConvChatPrototype, /** * This conversation implements both the IM and the Chat prototype. */ _interfaces: [Ci.prplIConversation, Ci.prplIConvIM, Ci.prplIConvChat], get isChat() { return this._isChat; }, /** * ID of the most recent event written to the conversation. * * @type {string} */ _mostRecentEventId: null, /** * Event IDs that we showed a decryption error in the conversation for. * * @type {Set} */ _eventsWaitingForDecryption: null, /** * A set of operations that are pending that want the room to show as joining. * * @type {Set} */ _joiningLocks: null, /** * Add a lock on the joining state during an operation. * * @param {string} lockName - Name of the operation that wants to lock joining * state. */ _addJoiningLock(lockName) { this._joiningLocks.add(lockName); if (!this.joining) { this.joining = true; } }, /** * Release a joining state lock by an operation. * * @param {string} lockName - Name of the operation that completed. */ _releaseJoiningLock(lockName) { this._joiningLocks.delete(lockName); if (this.joining && this._joiningLocks.size === 0) { this.joining = false; } }, /** * Leave the room if we close the conversation. */ close() { // Clean up any outgoing verification request by us. if (!this.isChat) { this.cleanUpOutgoingVerificationRequests(); } this._account._client.leave(this._roomId); this.left = true; this.forget(); }, /** * Forget about this conversation instance. This closes the conversation in * the UI, but doesn't update the user's membership in the room. */ forget() { if (!this.isChat) { this.closeDm(); } this._account?.roomList.delete(this._roomId); this._releaseJoiningLock("roomInit"); if (this._account) { GenericConversationPrototype.close.call(this); } }, /** * Sends the given message as a text message to the Matrix room. Does not * create the local copy, that is handled by the local echo of the SDK. * * @param {string} msg - Message to send. * @param {boolean} [action=false] - If the message is an emote. * @param {boolean} [notice=false] */ dispatchMessage(msg, action = false, notice = false) { const handleSendError = type => error => { this._account.ERROR( `Failed to send ${type} to ${this._roomId}: ${error.message}` ); }; this.sendTyping(""); if (action) { this._account._client .sendEmoteMessage(this._roomId, null, msg) .catch(handleSendError("emote")); } else if (notice) { this._account._client .sendNotice(this._roomId, null, msg) .catch(handleSendError("notice")); } else { this._account._client .sendTextMessage(this._roomId, null, msg) .catch(handleSendError("message")); } }, /** * Shared init function between conversation types * * @param {Room} room - associated room with the conversation. */ async initRoom(room) { if (!room) { return; } if (room.isSpaceRoom()) { this.writeMessage( this._account.userId, lazy._("message.spaceNotSupported"), { system: true, incoming: true, error: true, } ); this._setInitialized(); this.left = true; return; } // Store the ID of the room to look up information in the future. this._roomId = room.roomId; // Update the title to the human readable version. if (room.name && this._name != room.name && room.name !== room.roomId) { this._name = room.name; this.notifyObservers(null, "update-conv-title"); } this.updateConvIcon(); if (this.isChat) { await this.initRoomMuc(room); } else { this.initRoomDm(room); await this.searchForVerificationRequests().catch(error => this._account.WARN(error) ); } // Room may have been disposed in the mean time. if (this._replacedBy || !this._account) { return; } await this.updateUnverifiedDevices(); this._setInitialized(); }, /** * Mark conversation as initialized, meaning it has an associated room in the * state of the SDK. Sets the joining state to false and resolves * _initialized. */ _setInitialized() { this._releaseJoiningLock("roomInit"); this._resolveInitializer(); }, /** * Function to mark this room instance superseded by another one. * Useful when converting between DM and MUC or possibly room version * upgrades. * * @param {MatrixRoom} newRoom - Room that replaces this room. */ replaceRoom(newRoom) { this._replacedBy = newRoom; newRoom._mostRecentEventId = this._mostRecentEventId; this._setInitialized(); }, /** * Wait until the conversation is fully initialized. Handles replacements of * the conversation in the meantime. * * @returns {MatrixRoom} The most recent instance of this room * that is fully initialized. */ async waitForRoom() { await this._initialized; if (this._replacedBy) { return this._replacedBy.waitForRoom(); } return this; }, /** * Write all missing events to the conversation. Should be called once the * client is in a stable sync state again. * * @returns {Promise} */ async catchup() { this._addJoiningLock("catchup"); await this.waitForRoom(); if (this.isChat) { await this.room.loadMembersIfNeeded(); const members = this.room.getJoinedMembers(); const memberUserIds = members.map(member => member.userId); for (const userId of this._participants.keys()) { if (!memberUserIds.includes(userId)) { this.removeParticipant(userId); } } for (const member of members) { this.addParticipant(member); } this._name = this.room.name; this.notifyObservers(null, "update-conv-title"); } // Find the newest event id the user has already seen let latestOldEvent; if (this._mostRecentEventId) { latestOldEvent = this._mostRecentEventId; } else { // Last message the user has read with high certainty. const fullyRead = this.room.getAccountData( lazy.MatrixSDK.EventType.FullyRead ); if (fullyRead) { latestOldEvent = fullyRead.getContent().event_id; } } // Get the timeline for the event, or just the current live timeline of the room let timelineWindow = new lazy.MatrixSDK.TimelineWindow( this._account._client, this.room.getUnfilteredTimelineSet(), { windowLimit: MAX_CATCHUP_EVENTS, } ); const newestEvent = this.room.getLiveTimeline().getEvents().at(-1); // Start the window at the newest event. await timelineWindow.load(newestEvent.getId(), CATCHUP_PAGE_SIZE); // Check if the oldest event we want to see is already in the window let checkEvent = event => event.getId() === latestOldEvent || (event.getSender() === this._account.userId && isContentEvent(event)); let endIndex = -1; if (latestOldEvent) { const events = timelineWindow.getEvents(); endIndex = events.slice().reverse().findIndex(checkEvent); if (endIndex >= 0) { endIndex = events.length - endIndex - 1; } } // Paginate backward until we either find our oldest event or we reach the max backscroll length. while ( endIndex === -1 && timelineWindow.getEvents().length < MAX_CATCHUP_EVENTS && timelineWindow.canPaginate(lazy.MatrixSDK.EventTimeline.BACKWARDS) ) { const baseSize = timelineWindow.getEvents().length; const windowSize = Math.min( MAX_CATCHUP_EVENTS - baseSize, CATCHUP_PAGE_SIZE ); const didLoadEvents = await timelineWindow.paginate( lazy.MatrixSDK.EventTimeline.BACKWARDS, windowSize ); // Only search in the newly added events const events = timelineWindow.getEvents(); endIndex = events.slice(0, -baseSize).reverse().findIndex(checkEvent); if (endIndex >= 0) { endIndex = events.length - baseSize - endIndex - 1; } if (!didLoadEvents) { break; } } // Remove the old event from the window. if (endIndex !== -1) { timelineWindow.unpaginate(endIndex + 1, true); } const newEvents = timelineWindow.getEvents(); for (const event of newEvents) { this.addEvent(event, true); } this._releaseJoiningLock("catchup"); }, /** * Add a matrix event to the conversation's logs. * * @param {MatrixEvent} event * @param {boolean} [delayed=false] - Event is added while catching up to a live state. * @param {boolean} [replace=false] - Event replaces an existing message. */ addEvent(event, delayed = false, replace = false) { if (event.isRedaction()) { // Handled by the SDK. return; } // If the event we got isn't actually a new event in the conversation, // change this to the appropriate value. let newestEventId = event.getId(); // Contents of the message to write/update let message = lazy.MatrixMessageContent.getIncomingPlain( event, this._account._client.getHomeserverUrl(), eventId => this.room.findEventById(eventId) ); // Options for the message. Many options derived from event are set in // createMessage. let opts = { event, delayed, }; if ( event.isEncrypted() && (event.shouldAttemptDecryption() || event.isBeingDecrypted() || event.isDecryptionFailure()) ) { // Wait for the decryption event for this message. event.once(lazy.MatrixSDK.MatrixEventEvent.Decrypted, event => { this.addEvent(event, false, true); }); } const eventType = event.getType(); if (event.isRedacted()) { newestEventId = event.getRedactionEvent()?.event_id; replace = true; opts.system = !isContentEvent(event); opts.deleted = true; } else if (isContentEvent(event)) { const eventContent = event.getContent(); // Only print server notices when we're in a server notice room. if ( eventContent.msgtype === "m.server_notice" && !this?.room.tags[SERVER_NOTICE_TAG] ) { return; } opts.system = [ "m.server_notice", lazy.MatrixSDK.MsgType.KeyVerificationRequest, ].includes(eventContent.msgtype); opts.error = event.isDecryptionFailure(); opts.notification = eventContent.msgtype === lazy.MatrixSDK.MsgType.Notice; opts.action = eventContent.msgtype === lazy.MatrixSDK.MsgType.Emote; } else if (eventType === lazy.MatrixSDK.EventType.RoomEncryption) { this.notifyObservers(this, "update-conv-encryption"); opts.system = true; this.updateUnverifiedDevices(); } else if (eventType == lazy.MatrixSDK.EventType.RoomTopic) { this.setTopic(event.getContent().topic, event.getSender()); } else if (eventType == lazy.MatrixSDK.EventType.RoomTombstone) { // Room version update this.writeMessage(event.getSender(), event.getContent().body, { system: true, event, }); // Don't write the body using the normal message handling because that // will be too late. message = ""; let newConversation = this._account.getGroupConversation( event.getContent().replacement_room, this.name ); // Make sure the new room gets the correct conversation type. newConversation.checkForUpdate(); this.replaceRoom(newConversation); this.forget(); //TODO link to the old logs based on the |predecessor| field of m.room.create } else if (eventType == lazy.MatrixSDK.EventType.RoomAvatar) { // Update the icon of this room. this.updateConvIcon(); } else { opts.system = true; // We don't think we should show a notice for this event. if (!message) { this.LOG("Unhandled event: " + JSON.stringify(event.toJSON())); } } if (message) { if (replace) { this.updateMessage(event.getSender(), message, opts); } else { this.writeMessage(event.getSender(), message, opts); } } this._mostRecentEventId = newestEventId; }, _typingTimer: null, _typingDebounce: null, /** * Sets up the composing end timeout and sets the typing state based on the * draft message if typing notifications should be sent. * * @param {string} string - Current draft message. * @returns {number} Amount of remaining characters. */ sendTyping(string) { if (!this.shouldSendTypingNotifications) { return Ci.prplIConversation.NO_TYPING_LIMIT; } const isTyping = string.length > 0; this._cancelTypingTimer(); if (isTyping) { this._typingTimer = setTimeout(this.finishedComposing.bind(this), 10000); } this._setTypingState(isTyping); return Ci.prplIConversation.NO_TYPING_LIMIT; }, /** * Set the typing status to false if typing notifications are sent. * * @returns {undefined} */ finishedComposing() { if (!this.shouldSendTypingNotifications) { return; } this._setTypingState(false); }, /** * Send the given typing state if it is not typing or alternatively not been * sent in the last second. * * @param {boolean} isTyping - If the user is currently composing a message. * @returns {undefined} */ _setTypingState(isTyping) { if (isTyping) { if (this._typingDebounce) { return; } this._typingDebounce = setTimeout(() => { delete this._typingDebounce; }, 1000); } else if (this._typingDebounce) { clearTimeout(this._typingDebounce); delete this._typingDebounce; } this._account._client .sendTyping(this._roomId, isTyping, 10000) .catch(error => this._account.ERROR(error)); }, /** * Cancel the typing end timer. */ _cancelTypingTimer() { if (this._typingTimer) { clearTimeout(this._typingTimer); delete this._typingTimer; } }, _cleanUpTimers() { this._cancelTypingTimer(); if (this._typingDebounce) { clearTimeout(this._typingDebounce); delete this._typingDebounce; } }, /** * Sets the containsNick flag on the message if appropriate. If an event is * provided in properties, many of the message properties are set based on * it here. * * @param {string} who - MXID that composed the message. * @param {string} text - Message text. * @param {object} properties - Extra attributes for the MatrixMessage. */ createMessage(who, text, properties) { if (properties.event) { const actions = this._account._client.getPushActionsForEvent( properties.event ); const isOutgoing = properties.event.getSender() == this._account.userId; properties.incoming = !isOutgoing; properties.outgoing = isOutgoing; properties._alias = properties.event.sender?.name; properties.isEncrypted = properties.event.isEncrypted(); properties.containsNick = !isOutgoing && Boolean((this.isChat && actions?.notify) || actions?.tweaks?.highlight); properties.time = Math.floor(properties.event.getDate() / 1000); properties._iconURL = properties.event.sender?.getAvatarUrl( this._account._client.getHomeserverUrl(), USER_ICON_SIZE, USER_ICON_SIZE, "scale", false ) || ""; properties.remoteId = properties.event.getId(); } if (this.isChat && !properties.containsNick) { properties.containsNick = properties.incoming && this._pingRegexp.test(text); } const message = new MatrixMessage(who, text, properties, this); return message; }, /** * @param {imIMessage} msg */ prepareForDisplaying(msg) { const formattedHTML = lazy.MatrixMessageContent.getIncomingHTML( msg.wrappedJSObject.prplMessage.wrappedJSObject.event, this._account._client.getHomeserverUrl(), eventId => this.room.findEventById(eventId) ); if (formattedHTML) { msg.displayMessage = formattedHTML; } GenericConversationPrototype.prepareForDisplaying.apply(this, arguments); }, /** * @type {Room|null} */ get room() { return this._account?._client.getRoom(this._roomId); }, get roomState() { return this.room ?.getLiveTimeline() .getState(lazy.MatrixSDK.EventTimeline.FORWARDS); }, /** * If we should send typing notifications to the remote server. * * @type {boolean} */ get shouldSendTypingNotifications() { return Services.prefs.getBoolPref("purple.conversations.im.send_typing"); }, /** * The ID of the room. * * @type {string} */ get normalizedName() { return this._roomId; }, /** * Check if the type of the conversation (MUC or DM) needs to be changed and * if it needs to change, update it. If the conv was replaced this will * check for an update on the new conversation. * * @returns {Promise} */ async checkForUpdate() { if (this._waitingForUpdate || this.left) { return; } this._waitingForUpdate = true; const conv = await this.waitForRoom(); if (conv !== this) { await conv.checkForUpdate(); return; } this._waitingForUpdate = false; if (this.left) { return; } const shouldBeMuc = this.expectedToBeMuc(); if (shouldBeMuc === this.isChat) { return; } this._addJoiningLock("checkForUpdate"); this._isChat = shouldBeMuc; this.notifyObservers(null, "chat-update-type"); if (shouldBeMuc) { await this.makeMuc(); } else { await this.makeDm(); } this.updateConvIcon(); this._releaseJoiningLock("checkForUpdate"); }, /** * Check if the current conversation should be a MUC. * * @returns {boolean} If this conversation should be a MUC. */ expectedToBeMuc() { return !this._account.isDirectRoom(this._roomId); }, /** * Change the data in this conversation to match what we expect for a DM. * This means setting a buddy and no participants. */ async makeDm() { this._participants.clear(); this.initRoomDm(this.room); await this.updateUnverifiedDevices(); }, /** * Change the data in this conversation to match what we expect for a MUC. * This means removing the associated buddy, initializing the participants * list and updating the topic. */ async makeMuc() { // Cancel any pending outgoing verification request we sent. this.cleanUpOutgoingVerificationRequests(); this.closeDm(); await this.initRoomMuc(this.room); }, /** * Set the convIconFilename field for the conversation. Only writes to the * field when the value changes. */ updateConvIcon() { const avatarUrl = this.room?.getAvatarUrl( this._account._client.getHomeserverUrl(), USER_ICON_SIZE, USER_ICON_SIZE, "scale", false ); if (avatarUrl && this.convIconFilename !== avatarUrl) { this.convIconFilename = avatarUrl; } else if (!avatarUrl && this.convIconFilename) { this.convIconFilename = ""; } }, // mostly copied from jsProtoHelper but made type independent _convIconFilename: "", get convIconFilename() { // By default, pass through information from the buddy for IM conversations // that don't have their own icon. const convIconFilename = this._convIconFilename; if (convIconFilename || this.isChat) { return convIconFilename; } return this.buddy?.buddyIconFilename; }, set convIconFilename(aNewFilename) { this._convIconFilename = aNewFilename; this.notifyObservers(this, "update-conv-icon"); }, /* MUC */ addParticipant(roomMember) { if (this._participants.has(roomMember.userId)) { return; } let participant = new MatrixParticipant(roomMember, this._account); this._participants.set(roomMember.userId, participant); this.notifyObservers( new nsSimpleEnumerator([participant]), "chat-buddy-add" ); this.updateUnverifiedDevices(); }, removeParticipant(userId) { if (!this._participants.has(userId)) { return; } GenericConvChatPrototype.removeParticipant.call(this, userId); this.updateUnverifiedDevices(); }, /** * Initialize the room after the response from the Matrix client. * * @param {object} room - associated room with the conversation. */ async initRoomMuc(room) { let roomState = this.roomState; if (roomState.getStateEvents(lazy.MatrixSDK.EventType.RoomTopic).length) { let event = roomState.getStateEvents( lazy.MatrixSDK.EventType.RoomTopic )[0]; this.setTopic(event.getContent().topic, event.getSender(), true); } await room.loadMembersIfNeeded(); // If there are any participants, create them. let participants = []; room.getJoinedMembers().forEach(roomMember => { if (!this._participants.has(roomMember.userId)) { let participant = new MatrixParticipant(roomMember, this._account); participants.push(participant); this._participants.set(roomMember.userId, participant); } }); if (participants.length) { this.notifyObservers( new nsSimpleEnumerator(participants), "chat-buddy-add" ); } }, get topic() { return this._topic; }, set topic(aTopic) { // Check if our user has the permissions to set the topic. if (this.topicSettable && aTopic !== this.topic) { this._account._client.setRoomTopic(this._roomId, aTopic); } }, get topicSettable() { if (this.room) { return this.roomState.maySendEvent( lazy.MatrixSDK.EventType.RoomTopic, this._account.userId ); } return false; }, /* DM */ /** * Initialize the room after the response from the Matrix client. * * @param {Room} room - associated room with the conversation. */ initRoomDm(room) { const dmUserId = room.guessDMUserId(); if (dmUserId === this._account.userId) { // We are the only member of the room that we know of. // This can sometimes happen when we get a room before all membership // events got synced in. return; } if (!this.buddy) { this.initBuddy(dmUserId); } }, /** * Initialize the buddy for this conversation. * * @param {string} dmUserId - MXID of the user on the other side of this DM. */ initBuddy(dmUserId) { if (this._account.buddies.has(dmUserId)) { this.buddy = this._account.buddies.get(dmUserId); if (!this.buddy._user) { const user = this._account._client.getUser(dmUserId); this.buddy.setUser(user); } return; } const user = this._account._client.getUser(dmUserId); this.buddy = new MatrixBuddy( this._account, null, IMServices.tags.defaultTag, user.userId ); this.buddy.setUser(user); IMServices.contacts.accountBuddyAdded(this.buddy); // We can only set the status after the contacts service set the imIBuddy. this.buddy.setStatusFromPresence(); this._account.buddies.set(dmUserId, this.buddy); }, /** * Searches for recent verification requests in the room history. * Optimally we would instead handle verification requests with natural event * backfill for the room. Until then, we search the last three days of events * for verification requests. */ async searchForVerificationRequests() { // Wait for us to join the room. let myMembership = this.room.getMyMembership(); if (myMembership === "invite") { let listener; try { await new Promise((resolve, reject) => { listener = (event, member) => { if (member.userId === this._account.userId) { if (member.membership === "join") { resolve(); } else if (member.membership === "leave") { reject(new Error("Not in room")); } } }; this._account._client.on("RoomMember.membership", listener); }); } catch (error) { return; } finally { this._account._client.removeListener("RoomMember.membership", listener); } } else if (myMembership === "leave") { return; } let timelineWindow = new lazy.MatrixSDK.TimelineWindow( this._account._client, this.room.getUnfilteredTimelineSet() ); // Limit how far back we search. Three days seems like it would catch most // relevant verification requests. We might get even older events in the // initial load of 25 events. const windowChunkSize = 25; const threeDaysMs = 1000 * 60 * 60 * 24 * 3; const newerThanMs = Date.now() - threeDaysMs; await timelineWindow.load(undefined, windowChunkSize); while ( timelineWindow.canPaginate(lazy.MatrixSDK.EventTimeline.BACKWARDS) && timelineWindow.getEvents()[0].getTs() >= newerThanMs ) { if ( !(await timelineWindow.paginate( lazy.MatrixSDK.EventTimeline.BACKWARDS, windowChunkSize )) ) { // Pagination was unable to add any more events break; } } let events = timelineWindow.getEvents(); for (const event of events) { // Find verification requests that are still in the requested state that // were sent by the other user. if ( event.getType() === lazy.MatrixSDK.EventType.RoomMessage && event.getContent().msgtype === lazy.MatrixSDK.EventType.KeyVerificationRequest && event.getSender() !== this._account.userId && event.verificationRequest?.requested ) { this._account.handleIncomingVerificationRequest( event.verificationRequest ); } } }, /** * Cancel any pending outgoing verification requests. Used when we leave a * DM room, when the other party leaves or when the room can no longer be * considered a DM room. */ cleanUpOutgoingVerificationRequests() { const request = this._account._pendingOutgoingVerificationRequests.get( this.buddy?.userName ); if (request && request.requestEvent.getRoomId() == this._roomId) { request.cancel(); this._account._pendingOutgoingVerificationRequests.delete( this.buddy.userName ); } }, /** * Clean up the buddy associated with this DM conversation if it is the last * conversation associated with it. */ closeDm() { if (this.buddy) { const dmUserId = this.buddy.userName; const otherDMRooms = Array.from(this._account.roomList.values()).filter( conv => conv.buddy && conv.buddy === this.buddy && conv !== this ); if (otherDMRooms.length == 0) { IMServices.contacts.accountBuddyRemoved(this.buddy); this._account.buddies.delete(dmUserId); delete this.buddy; } } }, updateTyping: GenericConvIMPrototype.updateTyping, typingState: Ci.prplIConvIM.NOT_TYPING, _hasUnverifiedDevices: true, /** * Update the cached value for device trust and fire an * update-conv-encryption if the value changed. We cache the unverified * devices state, since the encryption state getter is sync. Does nothing if * the room is not encrypted. */ async updateUnverifiedDevices() { let account = this._account; if ( !account._client.isCryptoEnabled() || !account._client.isRoomEncrypted(this._roomId) ) { return; } const members = await this.room.getEncryptionTargetMembers(); // Check for participants that we haven't verified via cross signing, or // of which we don't trust a device, and if everyone seems fine, check our // own device verification state. let newValue = members.some(({ userId }) => { return !userIdentityVerified(userId, account._client); }) || checkUserHasUnverifiedDevices(account.userId, account._client); if (this._hasUnverifiedDevices !== newValue) { this._hasUnverifiedDevices = newValue; this.notifyObservers(this, "update-conv-encryption"); } }, get encryptionState() { if ( !this._account._client.isCryptoEnabled() || (!this._account._client.isRoomEncrypted(this._roomId) && !this.room?.currentState.mayClientSendStateEvent( lazy.MatrixSDK.EventType.RoomEncryption, this._account._client )) ) { return Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED; } if (!this._account._client.isRoomEncrypted(this._roomId)) { return Ci.prplIConversation.ENCRYPTION_AVAILABLE; } if (this._hasUnverifiedDevices) { return Ci.prplIConversation.ENCRYPTION_ENABLED; } return Ci.prplIConversation.ENCRYPTION_TRUSTED; }, initializeEncryption() { if (this._account._client.isRoomEncrypted(this._roomId)) { return; } this._account._client.sendStateEvent( this._roomId, lazy.MatrixSDK.EventType.RoomEncryption, { algorithm: lazy.OlmLib.MEGOLM_ALGORITHM, } ); }, }; /** * Initialize the verification, choosing the challenge method and calculating * the challenge string and description. * * @param {VerificationRequest} request - Matrix SDK verification request. * @returns {Promise<{ challenge: string, challengeDescription: string?, handleResult: (boolean) => {}, cancel: () => {}, cancelPromise: Promise}} */ async function startVerification(request) { if (!request.verifier) { if (!request.initiatedByMe) { await request.accept(); if (request.cancelled) { throw new Error("verification aborted"); } // Auto chose method as the only one we both support. await request.beginKeyVerification( request.methods[0], request.targetDevice ); } else { await request.waitFor(() => request.started || request.cancelled); } if (request.cancelled) { throw new Error("verification aborted"); } } const sasEventPromise = new Promise(resolve => request.verifier.once(lazy.MatrixSDK.Crypto.VerifierEvent.ShowSas, resolve) ); request.verifier.verify(); const sasEvent = await sasEventPromise; if (request.cancelled) { throw new Error("verification aborted"); } let challenge = ""; let challengeDescription; if (sasEvent.sas.emoji) { challenge = sasEvent.sas.emoji.map(emoji => emoji[0]).join(" "); challengeDescription = sasEvent.sas.emoji.map(emoji => emoji[1]).join(" "); } else if (sasEvent.sas.decimal) { challenge = sasEvent.sas.decimal.join(" "); } else { sasEvent.cancel(); throw new Error("unknown verification method"); } return { challenge, challengeDescription, handleResult(challengeMatches) { if (!challengeMatches) { sasEvent.mismatch(); } else { sasEvent.confirm(); } }, cancel() { if (!request.cancelled) { sasEvent.cancel(); } }, cancelPromise: request.waitFor(() => request.cancelled), }; } /** * @param {prplIAccount} account - Matrix account this session is associated with. * @param {string} ownerId - Matrix ID that this session is from. * @param {DeviceInfo} deviceInfo - Session device info. */ function MatrixSession(account, ownerId, deviceInfo) { this._deviceInfo = deviceInfo; this._ownerId = ownerId; let id = deviceInfo.deviceId; if (deviceInfo.getDisplayName()) { id = lazy._("options.encryption.session", id, deviceInfo.getDisplayName()); } const deviceTrust = account._client.checkDeviceTrust( ownerId, deviceInfo.deviceId ); const isCurrentDevice = deviceInfo.deviceId === account._client.getDeviceId(); this._init( account, id, deviceTrust.isCrossSigningVerified(), isCurrentDevice ); } MatrixSession.prototype = { __proto__: GenericSessionPrototype, _deviceInfo: null, async _startVerification() { let request; const requestKey = this.currentSession ? this._ownerId : this._deviceInfo.deviceId; if (this._account._pendingOutgoingVerificationRequests.has(requestKey)) { throw new Error( "Already have a pending verification request for " + requestKey ); } if (this.currentSession) { request = await this._account._client.requestVerification(this._ownerId); } else { request = await this._account._client.requestVerification(this._ownerId, [ this._deviceInfo.deviceId, ]); } this._account.trackOutgoingVerificationRequest(request, requestKey); return startVerification(request); }, }; function getStatusString(status) { return status ? lazy._("options.encryption.statusOk") : lazy._("options.encryption.statusNotOk"); } /** * Get the conversation name to display for a room. * * @param {string} roomId - ID of the room. * @param {RoomNameState} state - State of the room name from the SDK. * @returns {string?} Name to show for the room. If nothing is returned, the SDK * uses its built in naming logic. */ function getRoomName(roomId, state) { switch (state.type) { case lazy.MatrixSDK.RoomNameType.Actual: return state.name; case lazy.MatrixSDK.RoomNameType.Generated: { if (!state.names) { return lazy.l10n.formatValueSync("room-name-empty"); } if (state.names.length === 1 && state.count <= 2) { return state.names[0]; } if (state.names.length === 2 && state.count <= 3) { return new Intl.ListFormat(undefined, { style: "long", type: "conjunction", }).format(state.names); } return lazy.l10n.formatValueSync("room-name-others2", { participant: state.names[0], otherParticipantCount: state.names.length - 1, }); } case lazy.MatrixSDK.RoomNameType.EmptyRoom: if (state.oldName) { return lazy.l10n.formatValueSync("room-name-empty-had-name", { oldName: state.oldName, }); } return lazy.l10n.formatValueSync("room-name-empty"); } // Else fall through to default SDK room naming logic. return null; } /* * TODO Other random functionality from MatrixClient that will be useful: * getRooms / getUsers / publicRooms * invite * ban / kick * redactEvent * scrollback * setAvatarUrl * setPassword */ export function MatrixAccount(aProtocol, aImAccount) { this._init(aProtocol, aImAccount); this.roomList = new Map(); this._userToRoom = {}; this.buddies = new Map(); this._pendingDirectChats = new Map(); this._pendingRoomAliases = new Map(); this._pendingRoomInvites = new Set(); this._pendingOutgoingVerificationRequests = new Map(); this._failedEvents = new Set(); this._verificationRequestTimeouts = new Set(); } MatrixAccount.prototype = { __proto__: GenericAccountPrototype, observe(aSubject, aTopic, aData) { if (aTopic === "status-changed") { this.setPresence(aSubject); } else if (aTopic === "user-display-name-changed") { this._client.setDisplayName(aData); } }, remove() { for (let conv of this.roomList.values()) { // We want to remove all the conversations. We are not using conv.close // function call because we don't want user to leave all the matrix rooms. // User just want to remove the account so we need to remove the listed // conversations. conv.forget(); conv._cleanUpTimers(); } delete this.roomList; for (let timeout of this._verificationRequestTimeouts) { clearTimeout(timeout); } this._verificationRequestTimeouts.clear(); // Cancel all pending outgoing verification requests, as we can no longer handle them. let pendingClientOperations = Promise.all( Array.from( this._pendingOutgoingVerificationRequests.values(), request => { return request.cancel().catch(error => this.ERROR(error)); } ) ).then(() => { this._pendingOutgoingVerificationRequests.clear(); }); // We want to clear data stored for syncing in indexedDB so when // user logins again, one gets the fresh start. if (this._client) { if (this._client.isLoggedIn()) { pendingClientOperations = pendingClientOperations.then(() => this._client.logout() ); } pendingClientOperations.finally(() => { this._client.clearStores(); }); } else { // Without client we can still clear the stores at least. pendingClientOperations.finally(async () => { // getClientOptions wipes the session storage. const opts = await this.getClientOptions(); opts.store.deleteAllData(); opts.cryptoStore.deleteAllData(); }); } }, unInit() { if (this.roomList) { for (let conv of this.roomList.values()) { conv._cleanUpTimers(); } } for (let timeout of this._verificationRequestTimeouts) { clearTimeout(timeout); } // Cancel all pending outgoing verification requests, as we can no longer handle them. let pendingClientOperations = Promise.all( Array.from( this._pendingOutgoingVerificationRequests.values(), request => { return request.cancel().catch(error => this.ERROR(error)); } ) ); if (this._client) { pendingClientOperations.finally(() => { // Avoid sending connection status changes. this._client.removeAllListeners(lazy.MatrixSDK.ClientEvent.Sync); this._client.stopClient(); }); } }, connect() { this.reportConnecting(); this.connectClient().catch(error => { this.reportDisconnecting( Ci.prplIAccount.ERROR_OTHER_ERROR, error.message ); this.reportDisconnected(); }); }, async connectClient() { this._baseURL = await this.getServer(); let deviceId = this.prefs.getStringPref("deviceId", "") || undefined; let accessToken = this.prefs.getStringPref("accessToken", "") || undefined; // Make sure accessToken saved as deviceId is disposed of. if (deviceId && deviceId === accessToken) { // Revoke accessToken stored in deviceId const tempClient = lazy.MatrixSDK.createClient({ useAuthorizationHeader: true, baseUrl: this._baseURL, accessToken: deviceId, }); if (tempClient.isLoggedIn()) { tempClient.logout(); } this.prefs.clearUserPref("deviceId"); this.prefs.clearUserPref("accessToken"); deviceId = undefined; accessToken = undefined; } // Ensure any existing client will no longer interact with the network and // this account instance. A client will already exist whenever the account // is reconnected via the chat account connection management, or when we // have to create a new client to handle a new indexedDB schema. if (this._client) { this._client.stopClient(); this._client.removeAllListeners(); } const opts = await this.getClientOptions(); this._client = lazy.MatrixSDK.createClient(opts); if (this._client.isLoggedIn()) { this.startClient(); return; } const { flows } = await this._client.loginFlows(); const usePasswordFlow = Boolean(this.imAccount.password); let wantedFlows = []; if (usePasswordFlow) { wantedFlows.push("m.login.password"); } else { wantedFlows.push("m.login.sso", "m.login.token"); } if ( wantedFlows.every(flowType => flows.some(flow => flow.type === flowType)) ) { if (usePasswordFlow) { let user = this.name; // extract user localpart in case server is not the canonical one for the matrix ID. if (this.nameIsMXID) { user = this.protocol.splitUsername(user)[0]; } await this.loginToClient("m.login.password", { identifier: { type: "m.id.user", user, }, password: this.imAccount.password, }); } else { this.requestAuthorization(); } } else { this.reportDisconnecting( Ci.prplIAccount.ERROR_AUTHENTICATION_IMPOSSIBLE, lazy._("connection.error.noSupportedFlow") ); this.reportDisconnected(); } }, /** * Run autodiscovery to find the matrix server base URL for the account. * For accounts created before the username split was implemented, we will * most likely use the server preference that was set during setup. * All other accounts that have a full MXID as identifier will use the host * from the MXID as start for the auto discovery. * * @returns {string} Matrix server base URL. * @throws {Error} When the autodiscovery failed. */ async getServer() { let domain = "matrix.org"; if (this.nameIsMXID) { domain = this.protocol.splitUsername(this.name)[1]; } else if (this.prefs.prefHasUserValue("server")) { // Use legacy server field return ( this.prefs.getStringPref("server") + ":" + this.prefs.getIntPref("port", 443) ); } let discoveredInfo = await lazy.MatrixSDK.AutoDiscovery.findClientConfig( domain ); let homeserverResult = discoveredInfo[HOMESERVER_WELL_KNOWN]; // If the well-known lookup fails, pretend the domain has a well-known for // itself. if (homeserverResult.state !== lazy.MatrixSDK.AutoDiscovery.SUCCESS) { discoveredInfo = await lazy.MatrixSDK.AutoDiscovery.fromDiscoveryConfig({ [HOMESERVER_WELL_KNOWN]: { base_url: `https://${domain}`, }, }); homeserverResult = discoveredInfo[HOMESERVER_WELL_KNOWN]; } if (homeserverResult.state === lazy.MatrixSDK.AutoDiscovery.PROMPT) { throw new Error(lazy._("connection.error.serverNotFound")); } if (homeserverResult.state !== lazy.MatrixSDK.AutoDiscovery.SUCCESS) { //TODO these are English strings generated by the SDK. throw new Error(homeserverResult.error); } return homeserverResult.base_url; }, /** * If the |name| property of this account looks like a valid Matrix ID. * * @type {boolean} */ get nameIsMXID() { return ( this.name[0] === this.protocol.usernamePrefix && this.name.includes(this.protocol.usernameSplits[0].separator) ); }, /** * Error displayed to the user if there is some user-action required for the * encryption setup. */ _encryptionError: "", /** * Builds the options for the |createClient| call to the SDK including all * stores. * * @returns {Promise} */ async getClientOptions() { let dbName = "chat:matrix:" + this.imAccount.id; const opts = { useAuthorizationHeader: true, baseUrl: this._baseURL, store: new lazy.MatrixSDK.IndexedDBStore({ indexedDB, dbName, }), cryptoStore: new lazy.MatrixSDK.IndexedDBCryptoStore( indexedDB, dbName + ":crypto" ), deviceId: this.prefs.getStringPref("deviceId", "") || undefined, accessToken: this.prefs.getStringPref("accessToken", "") || undefined, userId: this.prefs.getStringPref("userId", "") || undefined, timelineSupport: true, cryptoCallbacks: { getSecretStorageKey: async ({ keys }) => { const backupPassphrase = this.getString("backupPassphrase"); if (!backupPassphrase) { this.WARN("Missing secret storage key"); this._encryptionError = lazy._( "options.encryption.needBackupPassphrase" ); await this.updateEncryptionStatus(); return null; } let keyId = await this._client.getDefaultSecretStorageKeyId(); if (keyId && !keys[keyId]) { keyId = undefined; } if (!keyId) { keyId = keys[0][0]; } const backupInfo = await this._client.getKeyBackupVersion(); const key = await this._client.keyBackupKeyFromPassword( backupPassphrase, backupInfo ); return [keyId, key]; }, }, verificationMethods: [lazy.MatrixCrypto.verificationMethods.SAS], roomNameGenerator: getRoomName, }; await Promise.all([opts.store.startup(), opts.cryptoStore.startup()]); return opts; }, /** * Log the client in. Sets the session device display name if configured and * stores the session information on successful login. * * @param {string} loginType - The m.login.* flow to use. * @param {object} loginInfo - Params for the login flow. * @param {boolean} [retry=false] - If we should retry SSO if the error isn't failed auth. */ async loginToClient(loginType, loginInfo, retry = false) { try { if (this.getString("deviceDisplayName")) { loginInfo.initial_device_display_name = this.getString("deviceDisplayName"); } const data = await this._client.login(loginType, loginInfo); if (data.error) { throw new Error(data.error); } if (data.well_known?.[HOMESERVER_WELL_KNOWN]?.base_url) { this._baseURL = data.well_known[HOMESERVER_WELL_KNOWN].base_url; } this.storeSessionInformation(data); // Need to create a new client with the device ID set. const opts = await this.getClientOptions(); this._client.stopClient(); this._client = lazy.MatrixSDK.createClient(opts); if (!this._client.isLoggedIn()) { throw new Error("Client has no access token after login"); } this.startClient(); } catch (error) { let errorType = Ci.prplIAccount.ERROR_OTHER_ERROR; if (error.errcode === "M_FORBIDDEN") { errorType = Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED; } this.reportDisconnecting(errorType, error.message); this.reportDisconnected(); if (errorType !== Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED && retry) { this.requestAuthorization(); } } }, /** * Login to the homeserver using m.login.token. * * @param {string} token - The auth token received from the SSO flow. */ loginWithToken(token) { return this.loginToClient("m.login.token", { token }, true); }, /** * Show SSO prompt and handle response token. */ requestAuthorization() { this.reportConnecting(lazy._("connection.requestAuth")); let url = this._client.getSsoLoginUrl( lazy.InteractiveBrowser.COMPLETION_URL, "sso" ); lazy.InteractiveBrowser.waitForRedirect( url, `${this.name} - ${this._baseURL}` ) .then(resultUrl => { let parsedUrl = new URL(resultUrl); let rawUrlData = parsedUrl.searchParams; let urlData = new URLSearchParams(rawUrlData); if (!urlData.has("loginToken")) { throw new Error("No token in redirect"); } this.reportConnecting(lazy._("connection.requestAccess")); this.loginWithToken(urlData.get("loginToken")); }) .catch(() => { this.reportDisconnecting( Ci.prplIAccount.ERROR_AUTHENTICATION_FAILED, lazy._("connection.error.authCancelled") ); this.reportDisconnected(); }); }, /** * Stores the device ID and if enabled the access token in the account preferences, so they can be * re-used in the next Thunderbird session. * * @param {object} data - Response data from a matrix login request. */ storeSessionInformation(data) { if (this.getBool("saveToken")) { this.prefs.setStringPref("accessToken", data.access_token); } this.prefs.setStringPref("deviceId", data.device_id); this.prefs.setStringPref("userId", data.user_id); }, get _catchingUp() { return this._client?.getSyncState() !== lazy.SyncState.Syncing; }, /** * Set of event IDs for events that have failed to send. Used to avoid * showing an error after resending a message fails again. * * @type {Set} */ _failedEvents: null, /* * Hook up the Matrix Client to callbacks to handle various events. * * The possible events are documented starting at: * https://matrix-org.github.io/matrix-js-sdk/2.4.1/module-client.html#~event:MatrixClient%22accountData%22 */ startClient() { this._client.on( lazy.MatrixSDK.ClientEvent.Sync, (state, prevState, data) => { switch (state) { case lazy.SyncState.Prepared: if (prevState !== state) { this.setPresence(this.imAccount.statusInfo); } this.reportConnected(); break; case lazy.SyncState.Stopped: this.reportDisconnected(); break; case lazy.SyncState.Syncing: if (prevState !== state) { this.reportConnected(); this.handleCaughtUp(); } break; case lazy.SyncState.Reconnecting: this.reportConnecting(); break; case lazy.SyncState.Error: if ( data.error.reason == lazy.MatrixSDK.InvalidStoreError.TOGGLED_LAZY_LOADING ) { this._client.store.deleteAllData().then(() => this.connect()); break; } this.reportDisconnecting( Ci.prplIAccount.ERROR_OTHER_ERROR, data.error.message ); this.reportDisconnected(); break; case lazy.SyncState.Catchup: this.reportConnecting(); break; } } ); this._client.on( lazy.MatrixSDK.RoomMemberEvent.Membership, (event, member, oldMembership) => { if (this._catchingUp) { return; } if (this.roomList.has(member.roomId)) { let conv = this.roomList.get(member.roomId); if (conv.isChat) { if (member.membership === "join") { conv.addParticipant(member); } else if (member.membership === "leave") { conv.removeParticipant(member.userId); } } // If we are leaving the room, remove the conversation. If any user gets // added or removed in the direct chat, update the conversation type. We // are treating the direct chat with two people as a direct conversation // only. Matrix supports multiple users in the direct chat. So we will // treat all the rooms which have 2 users including us and classified as // a DM room by SDK a direct conversation and all other rooms as a group // conversations. if (member.membership === "leave" && member.userId == this.userId) { conv.forget(); } else if ( member.membership === "join" || member.membership === "leave" ) { conv.checkForUpdate(); } } } ); /* * Get the map of direct messaging rooms. */ this._client.on(lazy.MatrixSDK.ClientEvent.AccountData, event => { if (event.getType() == lazy.MatrixSDK.EventType.Direct) { const oldRooms = Object.values(this._userToRoom ?? {}).flat(); this._userToRoom = event.getContent(); // Check type for all conversations that were added or removed from the // m.direct state. const newRooms = Object.values(this._userToRoom ?? {}).flat(); for (const roomId of oldRooms) { if (!newRooms.includes(roomId)) { this.roomList.get(roomId)?.checkForUpdate(); } } for (const roomId of newRooms) { if (!oldRooms.includes(roomId)) { this.roomList.get(roomId)?.checkForUpdate(); } } } }); this._client.on( lazy.MatrixSDK.RoomEvent.Timeline, (event, room, toStartOfTimeline, removed, data) => { if (this._catchingUp || room.isSpaceRoom() || !data?.liveEvent) { return; } let conv = this.roomList.get(room.roomId); if (!conv) { // If our membership changed to join without us knowing about the // room, another client probably accepted an invite. if ( event.getType() == lazy.MatrixSDK.EventType.RoomMember && event.target.userId == this.userId && event.getContent().membership == "join" && event.getPrevContent()?.membership == "invite" ) { if (event.getPrevContent()?.is_direct) { let userId = room.getDMInviter(); if (this._pendingRoomInvites.has(room.roomId)) { this.cancelBuddyRequest(userId); this._pendingRoomInvites.delete(room.roomId); } conv = this.getDirectConversation(userId, room.roomId, room.name); } else { if (this._pendingRoomInvites.has(room.roomId)) { let alias = room.getCanonicalAlias() ?? room.roomId; this.cancelChatRequest(alias); this._pendingRoomInvites.delete(room.roomId); } conv = this.getGroupConversation(room.roomId, room.name); } } else { return; } } conv.addEvent(event); } ); // Queued, sending and failed events this._client.on( lazy.MatrixSDK.RoomEvent.LocalEchoUpdated, (event, room, oldEventId, oldStatus) => { if ( this._catchingUp || room.isSpaceRoom() || event.getType() !== lazy.MatrixSDK.EventType.RoomMessage ) { return; } const conv = this.roomList.get(room.roomId); if (!conv) { return; } if (event.status === lazy.MatrixSDK.EventStatus.NOT_SENT) { if (this._failedEvents.has(event.getId())) { return; } this._failedEvents.add(event.getId()); conv.writeMessage( this._roomId, lazy._("error.sendMessageFailed", event.getContent().body), { error: true, system: true, event, } ); } else if ( (event.status === lazy.MatrixSDK.EventStatus.SENT || event.status === null) && oldEventId ) { this._failedEvents.delete(oldEventId); conv.removeMessage(oldEventId); } else if (event.status === lazy.MatrixSDK.EventStatus.CANCELLED) { this._failedEvents.delete(event.getId()); conv.removeMessage(event.getId()); } } ); // An event that was already in the room timeline was redacted this._client.on(lazy.MatrixSDK.RoomEvent.Redaction, (event, room) => { let conv = this.roomList.get(room.roomId); if (conv) { const redactedEvent = conv.room?.findEventById(event.getAssociatedId()); if (redactedEvent) { conv.addEvent(redactedEvent); } } }); // Update the chat participant information. this._client.on( lazy.MatrixSDK.RoomMemberEvent.Name, this.updateRoomMember.bind(this) ); this._client.on( lazy.MatrixSDK.RoomMemberEvent.PowerLevel, this.updateRoomMember.bind(this) ); this._client.on(lazy.MatrixSDK.RoomEvent.Name, room => { if (room.isSpaceRoom()) { return; } // Update the title to the human readable version. let conv = this.roomList.get(room.roomId); if (!this._catchingUp && conv && room?.name && conv._name != room.name) { conv._name = room.name; conv.notifyObservers(null, "update-conv-title"); } }); /* * We show invitation notifications for rooms where the membership is * invite. This will also be fired for all the rooms we have joined * earlier when SDK gets connected. We will use that part to to make * conversations, direct or group. */ this._client.on(lazy.MatrixSDK.ClientEvent.Room, room => { if (this._catchingUp || room.isSpaceRoom()) { return; } let me = room.getMember(this.userId); if (me?.membership == "invite") { if (me.events.member.getContent().is_direct) { this.invitedToDM(room); } else { this.invitedToChat(room); } } else if (me?.membership == "join") { // To avoid the race condition. Whenever we will create the room, // this will also be fired. So we want to avoid creating duplicate // conversations for the same room. if ( this.roomList.has(room.roomId) || this._pendingRoomAliases.size + this._pendingDirectChats.size > 0 ) { return; } // Joined a new room that we don't know about yet. if (this.isDirectRoom(room.roomId)) { let interlocutorId; for (let roomMember of room.getJoinedMembers()) { if (roomMember.userId != this.userId) { interlocutorId = roomMember.userId; break; } } this.getDirectConversation(interlocutorId, room.roomId, room.name); } else { this.getGroupConversation(room.roomId, room.name); } } }); this._client.on(lazy.MatrixSDK.RoomMemberEvent.Typing, (event, member) => { if (member.userId != this.userId) { let conv = this.roomList.get(member.roomId); if (!conv) { return; } if (!conv.isChat) { let typingState = Ci.prplIConvIM.NOT_TYPING; if (member.typing) { typingState = Ci.prplIConvIM.TYPING; } conv.updateTyping(typingState, member.name); } } }); this._client.on( lazy.MatrixSDK.RoomStateEvent.Members, (event, state, member) => { if (this.roomList.has(state.roomId)) { const conversation = this.roomList.get(state.roomId); if (conversation.isChat) { const participant = conversation._participants.get(member.userId); if (participant) { conversation.notifyObservers(participant, "chat-buddy-update"); } } } } ); this._client.on(lazy.MatrixSDK.HttpApiEvent.SessionLoggedOut, error => { this.prefs.clearUserPref("accessToken"); // https://spec.matrix.org/unstable/client-server-api/#soft-logout if (!error.data.soft_logout) { this.prefs.clearUserPref("deviceId"); this.prefs.clearUserPref("userId"); } // TODO handle soft logout with an auto reconnect this.reportDisconnecting( Ci.prplIAccount.ERROR_OTHER_ERROR, lazy._("connection.error.sessionEnded") ); this.reportDisconnected(); }); this._client.on( lazy.MatrixSDK.UserEvent.AvatarUrl, this.updateBuddy.bind(this) ); this._client.on( lazy.MatrixSDK.UserEvent.DisplayName, this.updateBuddy.bind(this) ); this._client.on( lazy.MatrixSDK.UserEvent.Presence, this.updateBuddy.bind(this) ); this._client.on( lazy.MatrixSDK.UserEvent.CurrentlyActive, this.updateBuddy.bind(this) ); this._client.on( lazy.MatrixSDK.CryptoEvent.UserTrustStatusChanged, (userId, trustLevel) => { this.updateConvDeviceTrust( conv => (conv.isChat && conv.getParticipant(userId)) || (!conv.isChat && conv.buddy?.userName == userId) ); } ); this._client.on(lazy.MatrixSDK.CryptoEvent.DevicesUpdated, users => { if (users.includes(this.userId)) { this.reportSessionsChanged(); this.updateEncryptionStatus(); this.updateConvDeviceTrust(); } else { this.updateConvDeviceTrust(conv => users.some( userId => (conv.isChat && conv.getParticipant(userId)) || (!conv.isChat && conv.buddy?.userName == userId) ) ); } }); // From the SDK documentation: Fires when the user's cross-signing keys // have changed or cross-signing has been enabled/disabled this._client.on(lazy.MatrixSDK.CryptoEvent.KeysChanged, () => { this.reportSessionsChanged(); this.updateEncryptionStatus(); this.updateConvDeviceTrust(); }); this._client.on(lazy.MatrixSDK.CryptoEvent.KeyBackupStatus, () => { this.bootstrapSSSS(); this.updateEncryptionStatus(); }); this._client.on(lazy.MatrixSDK.CryptoEvent.VerificationRequest, request => { this.handleIncomingVerificationRequest(request); }); // TODO Other events to handle: // Room.localEchoUpdated // Room.tags // crypto.suggestKeyRestore // crypto.warning this._client .initCrypto() .then(() => Promise.all([ this._client.startClient({ pendingEventOrdering: lazy.MatrixSDK.PendingEventOrdering.Detached, lazyLoadMembers: true, }), this.updateEncryptionStatus(), this.bootstrapSSSS(), this.reportSessionsChanged(), ]) ) .finally(() => { // We can disable the unknown devices error thanks to cross signing. this._client.setGlobalErrorOnUnknownDevices(false); }) .catch(error => this.ERROR(error)); }, /** * Update UI state to reflect the current state of the SDK after a full sync. * This includes adding and removing rooms and catching up their contents. */ async handleCaughtUp() { const allRooms = this._client .getVisibleRooms() .filter(room => !room.isSpaceRoom()); const joinedRooms = allRooms .filter(room => room.getMyMembership() === "join") .map(room => room.roomId); // Ensure existing conversations are up to date for (const [roomId, conv] of this.roomList.entries()) { if (!joinedRooms.includes(roomId)) { conv.forget(); } else { try { await conv.checkForUpdate(); await conv.catchup(); } catch (error) { this.ERROR(error); } } } // Create new conversations for (const roomId of joinedRooms) { if (!this.roomList.has(roomId)) { let conv; if (this.isDirectRoom(roomId)) { const room = this._client.getRoom(roomId); if (this._pendingRoomInvites.has(roomId)) { let userId = room.getDMInviter(); this.cancelBuddyRequest(userId); this._pendingRoomInvites.delete(roomId); } const interlocutorId = room .getJoinedMembers() .find(member => member.userId != this.userId)?.userId; if (!interlocutorId) { this.ERROR( "Could not find opposing party for " + roomId + ". No conversation was created." ); continue; } conv = this.getDirectConversation(interlocutorId, roomId, room.name); } else { if (this._pendingRoomInvites.has(roomId)) { const room = this._client.getRoom(roomId); let alias = room.getCanonicalAlias() ?? roomId; this.cancelChatRequest(alias); this._pendingRoomInvites.delete(roomId); } conv = this.getGroupConversation(roomId); } try { await conv.catchup(); } catch (error) { this.ERROR(error); } } } // Add pending invites const invites = allRooms.filter( room => room.getMyMembership() === "invite" ); for (const room of invites) { const me = room.getMember(this.userId); if (me.events.member.getContent().is_direct) { this.invitedToDM(room); } else { this.invitedToChat(room); } } // Remove orphaned buddies. for (const [userId, buddy] of this.buddies) { // getDMRoomIdsForUserId uses the room list from the client, so we don't // have to wait for the room mutations above to propagate to our internal // state. if (this.getDMRoomIdsForUserId(userId).length === 0) { buddy.remove(); } } }, /** * Update the encryption status message based on the current state. */ async updateEncryptionStatus() { const secretStorageReady = await this._client.isSecretStorageReady(); const crossSigningReady = await this._client.isCrossSigningReady(); const keyBackupReady = this._client.getKeyBackupEnabled(); const statuses = [ lazy._( "options.encryption.enabled", getStatusString(this._client.isCryptoEnabled()) ), lazy._( "options.encryption.secretStorage", getStatusString(secretStorageReady) ), lazy._("options.encryption.keyBackup", getStatusString(keyBackupReady)), lazy._( "options.encryption.crossSigning", getStatusString(crossSigningReady) ), ]; if (this._encryptionError) { statuses.push(this._encryptionError); } else if (!secretStorageReady) { statuses.push(lazy._("options.encryption.setUpSecretStorage")); } else if (!keyBackupReady && !crossSigningReady) { statuses.push(lazy._("options.encryption.setUpBackupAndCrossSigning")); } this.encryptionStatus = statuses; }, /** * Ensures secret storage and cross signing are ready for use. Does not * support initial setup of secret storage. If the backup passphrase is not * set, this is a no-op, else it is cleared once the operation is complete. * * @returns {Promise} */ async bootstrapSSSS() { if (!this._client) { // client startup will do bootstrapping return; } const password = this.getString("backupPassphrase"); if (!password) { // We do not support setting up secret storage, so we need a passphrase // to bootstrap. return; } const backupInfo = await this._client.getKeyBackupVersion(); await this._client.bootstrapSecretStorage({ setupNewKeyBackup: false, async getKeyBackupPassphrase() { const key = await this._client.keyBackupKeyFromPassword( password, backupInfo ); return key; }, }); await this._client.bootstrapCrossSigning({ authUploadDeviceSigningKeys(makeRequest) { makeRequest(); return Promise.resolve(); }, }); await this._client.checkOwnCrossSigningTrust(); if (backupInfo) { await this._client.restoreKeyBackupWithSecretStorage(backupInfo); } // Clear passphrase once bootstrap was successful this.imAccount.setString("backupPassphrase", ""); this.imAccount.save(); this._encryptionError = ""; await this.updateEncryptionStatus(); }, setString(name, value) { if (!this._client) { return; } if (name === "backupPassphrase" && value) { this.bootstrapSSSS().catch(this.WARN); } else if (name === "deviceDisplayName") { this._client .setDeviceDetails(this._client.getDeviceId(), { display_name: value, }) .catch(this.WARN); } }, /** * Update the untrusted/unverified devices state for all encrypted * conversations. Can limit the conversations by supplying a callback that * only returns true if the conversation should update the state. * * @param {(prplIConversation) => boolean} [shouldUpdateConv] - Condition to * evaluate if a conversation should have the device trust recalculated. */ updateConvDeviceTrust(shouldUpdateConv) { for (const conv of this.roomList.values()) { const encryptionStatus = conv.encryptionStatus; if ( encryptionStatus !== Ci.prplIConversation.ENCRYPTION_AVAILABLE && encryptionStatus !== Ci.prplIConversation.ENCRYPTION_NOT_SUPPORTED && (!shouldUpdateConv || shouldUpdateConv(conv)) ) { conv.updateUnverifiedDevices(); } } }, /** * Handle an incoming verification request. * * @param {VerificationRequest} request - Verification request from another * user that is still pending and not handled by another session. */ handleIncomingVerificationRequest(request) { const abort = new AbortController(); request .waitFor( () => request.cancelled || (!request.requested && request.observeOnly) ) .then(() => abort.abort()); let displayName = request.otherUserId; if (request.isSelfVerification) { const deviceInfo = this._client.getStoredDevice( this.userId, request.targetDevice.deviceId ); if (deviceInfo?.getDisplayName()) { displayName = lazy._( "options.encryption.session", request.targetDevice.deviceId, deviceInfo.getDisplayName() ); } else { displayName = request.targetDevice.deviceId; } } let _handleResult; let _cancel; const uiRequest = this.addVerificationRequest( displayName, async () => { const { challenge, challengeDescription, handleResult, cancel } = await startVerification(request); _handleResult = handleResult; _cancel = cancel; return { challenge, challengeDescription }; }, abort.signal ); uiRequest.then( result => { if (!_handleResult) { this.ERROR( "Can not handle the result for verification request with " + request.otherUserId + " because the verification was never started." ); request.cancel(); } _handleResult(result); }, () => { if (_cancel) { _cancel(); } else { request.cancel(); } } ); }, /** * Set of currently pending timeouts for verification DM starts. * * @type {Set} */ _verificationRequestTimeouts: null, /** * Shared implementation to initiate a verification with a MatrixParticipant or * MatrixBuddy. * * @param {string} userId - Matrix ID of the user to verify. * @returns {Promise} Same payload as startVerification. */ async startVerificationDM(userId) { let request; if (this._pendingOutgoingVerificationRequests.has(userId)) { throw new Error("Already have a pending request for user " + userId); } if (userId == this.userId) { request = await this._client.requestVerification(userId); } else { let conv = this.getDirectConversation(userId); conv = await conv.waitForRoom(); // Wait for the user to become part of the room (so being invited) for two // seconds before sending verification request. if (conv.isChat || !conv.room.getMember(userId)) { let waitForMember; let timeout; try { await new Promise(resolve => { waitForMember = (event, state, member) => { if (member.roomId == conv._roomId && member.userId == userId) { resolve(); } }; this._client.on( lazy.MatrixSDK.RoomStateEvent.NewMember, waitForMember ); timeout = setTimeout(resolve, 2000); this._verificationRequestTimeouts.add(timeout); }); } finally { this._verificationRequestTimeouts.delete(timeout); clearTimeout(timeout); this._client.removeListener( lazy.MatrixSDK.RoomStateEvent.NewMember, waitForMember ); } } request = await this._client.requestVerificationDM(userId, conv._roomId); } this.trackOutgoingVerificationRequest(request, userId); return startVerification(request); }, /** * Tracks a verification throughout its lifecycle, adding and removing it * from the |_pendingOutgoingVerificationRequests| map. * * @param {VerificationRequest} request - Outgoing verification request. * @param {string} requestKey - Key to identify this request. */ async trackOutgoingVerificationRequest(request, requestKey) { if (request.cancelled || request.done) { return; } this._pendingOutgoingVerificationRequests.set(requestKey, request); request .waitFor(() => request.done || request.cancelled) .then(() => { this._pendingOutgoingVerificationRequests.delete(requestKey); }); }, /** * Set of room IDs that have pending invites that are being displayed to the * user this session. * * @type {Set} */ _pendingRoomInvites: null, /** * A user invited this user to a DM room. * * @param {Room} room - Room we're invited to. */ invitedToDM(room) { if (this._pendingRoomInvites.has(room.roomId)) { return; } let userId = room.getDMInviter(); this.addBuddyRequest( userId, () => { this._pendingRoomInvites.delete(room.roomId); this.setDirectRoom(userId, room.roomId); // For the invited rooms, we will not get the summary info from // the room object created after the joining. So we need to use // the name from the room object here. const conversation = this.getDirectConversation( userId, room.roomId, room.name ); if (room.getInvitedAndJoinedMemberCount() !== 2) { conversation.checkForUpdate(); } }, () => { this._pendingRoomInvites.delete(room.roomId); this._client.leave(room.roomId); } ); this._pendingRoomInvites.add(room.roomId); }, /** * The account has been invited to a group chat. * * @param {Room} room - Room we're invited to. */ invitedToChat(room) { if (this._pendingRoomInvites.has(room.roomId)) { return; } let alias = room.getCanonicalAlias() ?? room.roomId; this.addChatRequest( alias, () => { this._pendingRoomInvites.delete(room.roomId); const conversation = this.getGroupConversation(room.roomId, room.name); if (room.getInvitedAndJoinedMemberCount() === 2) { conversation.checkForUpdate(); } }, // Server notice room invites can not be rejected. !room.tags[SERVER_NOTICE_TAG] && (() => { this._pendingRoomInvites.delete(room.roomId); this._client.leave(room.roomId).catch(error => { this.ERROR(error.message); }); }) ); this._pendingRoomInvites.add(room.roomId); }, /** * Set the matrix user presence based on the given status info. * * @param {imIStatus} statusInfo */ setPresence(statusInfo) { const presenceDetails = { presence: "offline", status_msg: statusInfo.statusText, }; if (statusInfo.statusType === Ci.imIStatusInfo.STATUS_AVAILABLE) { presenceDetails.presence = "online"; } else if ( statusInfo.statusType === Ci.imIStatusInfo.STATUS_AWAY || statusInfo.statusType === Ci.imIStatusInfo.STATUS_IDLE ) { presenceDetails.presence = "unavailable"; } this._client.setPresence(presenceDetails); }, /** * Update the local buddy with the latest information given the changes from * the event. * * @param {MatrixEvent} event * @param {User} user */ updateBuddy(event, user) { const buddy = this.buddies.get(user.userId); if (!buddy) { return; } if (!buddy._user) { buddy.setUser(user); } else { buddy._user = user; } if (event.getType() === lazy.MatrixSDK.UserEvent.AvatarUrl) { buddy._notifyObservers("icon-changed"); } else if ( event.getType() === lazy.MatrixSDK.UserEvent.Presence || event.getType() === lazy.MatrixSDK.UserEvent.CurrentlyActive ) { buddy.setStatusFromPresence(); } else if (event.getType() === lazy.MatrixSDK.UserEvent.DisplayName) { buddy.serverAlias = user.displayName; } }, /** * Checks if the room is the direct messaging room or not. We also check * if number of joined users are two including us. * * @param {string} checkRoomId - ID of the room to check if it is direct * messaging room or not. * @returns {boolean} - If room is direct direct messaging room or not. */ isDirectRoom(checkRoomId) { for (let user of Object.keys(this._userToRoom)) { for (let roomId of this._userToRoom[user]) { if (roomId == checkRoomId) { let room = this._client.getRoom(roomId); if (room && room.getJoinedMembers().length == 2) { return true; } } } } return false; }, /** * Room aliases and their conversation that are currently being created. * * @type {Map} */ _pendingRoomAliases: null, /** * Returns the group conversation according to the room-id. * 1) If we have a group conversation already, we will return that. * 2) If the user is already in the room but we don't have a conversation for * it yet, create one. * 3) Else we try to join the room and create a new conversation for it. * 4) Create a new room if the room does not exist and is local to our server. * * @param {string} roomId - ID of the room. * @param {string} [roomName] - Name of the room. * * @returns {MatrixRoom?} - The resulted conversation. */ getGroupConversation(roomId, roomName) { if (!roomId) { return null; } const existingConv = this.getConversationByIdOrAlias(roomId); if (existingConv) { return existingConv; } const conv = new MatrixRoom(this, true, roomName || roomId); // If we are already in the room, just initialize the conversation with it. const existingRoom = this._client.getRoom(roomId); if (existingRoom?.getMyMembership() === "join") { this.roomList.set(existingRoom.roomId, conv); conv.initRoom(existingRoom); return conv; } // Try to join the room this._client .joinRoom(roomId) .then( room => { this.roomList.set(room.roomId, conv); conv.initRoom(room); }, error => { // If room does not exist and it is local to our server, create it. if ( error.errcode === "M_NOT_FOUND" && roomId.endsWith(":" + this._client.getDomain()) && roomId[0] !== "!" ) { this.LOG( "Creating room " + roomId + ", since we could not join: " + error ); if (this._pendingRoomAliases.has(roomId)) { conv.replaceRoom(this._pendingRoomAliases.get(roomId)); conv.forget(); return null; } // extract alias from #: const alias = roomId.split(":", 1)[0].slice(1); return this.createRoom(this._pendingRoomAliases, roomId, conv, { room_alias_name: alias, name: roomName || alias, visibility: lazy.MatrixSDK.Visibility.Private, preset: lazy.MatrixSDK.Preset.PrivateChat, }); } conv.close(); throw error; } ) .catch(error => { this.ERROR(error); if (!conv.room) { conv.forget(); } }); return conv; }, /** * Get an existing conversation for a room ID or alias. * * @param {string} roomIdOrAlias - Identifier for the conversation. * @returns {GenericMatrixConversation?} */ getConversationByIdOrAlias(roomIdOrAlias) { if (!roomIdOrAlias) { return null; } const conv = this.getConversationById(roomIdOrAlias); if (conv) { return conv; } const existingRoom = this._client.getRoom(roomIdOrAlias); if (!existingRoom) { return null; } return this.getConversationById(existingRoom.roomId); }, /** * Get an existing conversation for a room ID. * * @param {string} roomId - Room ID of the conversation. * @returns {GenericMatrixConversation?} */ getConversationById(roomId) { if (!roomId) { return null; } // If there is a conversation return it. if (this.roomList.has(roomId)) { return this.roomList.get(roomId); } // Are we already creating a room with the ID? if (this._pendingRoomAliases.has(roomId)) { return this._pendingRoomAliases.get(roomId); } return null; }, /** * Returns the room ID for user ID if exists for direct messaging. * * @param {string} roomId - ID of the user. * * @returns {string} - ID of the room. */ getDMRoomIdForUserId(userId) { // Check in the 'other' user's roomList for common m.direct rooms. // Select the most recent room based on the timestamp of the // most recent event in the room's timeline. const rooms = this.getDMRoomIdsForUserId(userId) .map(roomId => { const room = this._client.getRoom(roomId); const mostRecentTimestamp = room.getLastActiveTimestamp(); return { roomId, mostRecentTimestamp, }; }) .sort( (roomA, roomB) => roomB.mostRecentTimestamp - roomA.mostRecentTimestamp ); if (rooms.length) { return rooms[0].roomId; } return null; }, /** * Get all room IDs of active DM rooms with the given user. * * @param {string} userId - User ID to find rooms for. * @returns {string[]} Array of rooom IDs. */ getDMRoomIdsForUserId(userId) { if (!Array.isArray(this._userToRoom[userId])) { return []; } return this._userToRoom[userId].filter(roomId => { const room = this._client.getRoom(roomId); if (!room || room.isSpaceRoom()) { return false; } const accountMembership = room.getMyMembership() ?? "leave"; // Default to invite, since the invite for the other member may not be in // the room events yet. let userMembership = room.getMember(userId)?.membership ?? "invite"; // If either party left the room we shouldn't try to rejoin. return userMembership !== "leave" && accountMembership !== "leave"; }); }, /** * Sets the room ID for for corresponding user ID for direct messaging * by setting the "m.direct" event of account data of the SDK client. * * @param {string} roomId - ID of the user. * * @param {string} - ID of the room. */ setDirectRoom(userId, roomId) { let dmRoomMap = this._userToRoom; let roomList = dmRoomMap[userId] || []; if (!roomList.includes(roomId)) { roomList.push(roomId); dmRoomMap[userId] = roomList; this._client.setAccountData(lazy.MatrixSDK.EventType.Direct, dmRoomMap); } }, updateRoomMember(event, member) { if (this.roomList && this.roomList.has(member.roomId)) { let conv = this.roomList.get(member.roomId); if (conv.isChat) { let participant = conv._participants.get(member.userId); // A participant might not exist (for example, this happens if the user // has only been invited, but has not yet joined). if (participant) { participant._roomMember = member; conv.notifyObservers(participant, "chat-buddy-update"); conv.notifyObservers(null, "chat-update-topic"); } } } }, disconnect() { this._client.setPresence({ presence: "offline" }); this._client.stopClient(); this.reportDisconnected(); }, get canJoinChat() { return true; }, chatRoomFields: { //TODO should split the fields like in account setup, though we would // probably want to keep the type prefix roomIdOrAlias: { get label() { return lazy._("chatRoomField.room"); }, required: true, }, }, parseDefaultChatName(aDefaultName) { let chatFields = { roomIdOrAlias: aDefaultName, }; return chatFields; }, joinChat(components) { // For the format of room id and alias, see the matrix documentation: // https://matrix.org/docs/spec/appendices#room-ids-and-event-ids // https://matrix.org/docs/spec/appendices#room-aliases let roomIdOrAlias = components.getValue("roomIdOrAlias").trim(); // If domain is missing, append the domain from the user's server. if (!roomIdOrAlias.includes(":")) { roomIdOrAlias += ":" + this._client.getDomain(); } // There will be following types of ids: // !fubIsJzeAcCcjYTQvm:mozilla.org => General room id. // #maildev:mozilla.org => Group Conversation room id. // @clokep:mozilla.org => Direct Conversation room id. if (roomIdOrAlias.startsWith("!")) { // We create the group conversation initially. Then we check if the room // is the direct messaging room or not. //TODO init with correct type from isDirectMessage(roomIdOrAlias) let conv = this.getGroupConversation(roomIdOrAlias); if (!conv) { return null; } // It can be any type of room so update it according to direct conversation // or group conversation. conv.checkForUpdate(); return conv; } // If the ID does not start with @ or #, assume it is a group conversation and append #. if (!roomIdOrAlias.startsWith("@") && !roomIdOrAlias.startsWith("#")) { roomIdOrAlias = "#" + roomIdOrAlias; } // If the ID starts with a @, it is a direct conversation. if (roomIdOrAlias.startsWith("@")) { return this.getDirectConversation(roomIdOrAlias); } // Otherwise, it is a group conversation. return this.getGroupConversation(roomIdOrAlias); }, createConversation(userId) { if (userId == this.userId) { return null; } return this.getDirectConversation(userId); }, /** * User IDs and their DM conversations which are being created. * * @type {Map} */ _pendingDirectChats: null, /** * Returns the direct conversation according to the room-id or user-id. * If the room ID is specified, it is the preferred way of identifying the * conversation to return. * * 1) If we have a direct conversation already, we will return that. * 2) If the room exists on the server, we will join it. It will not do * anything if we are already joined, it will just create the * conversation. This is used mainly when a new room gets added. * 3) Create a new room if the conversation does not exist. * * @param {string} userId - ID of the user for which we want to get the * direct conversation. * @param {string} [roomId] - ID of the room. * @param {string} [roomName] - Name of the room. * * @returns {MatrixRoom} - The resulted conversation. */ getDirectConversation(userId, roomID, roomName) { let DMRoomId = this.getDMRoomIdForUserId(userId); if (roomID && DMRoomId !== roomID) { this.setDirectRoom(userId, roomID); DMRoomId = roomID; } if (!DMRoomId && roomID) { DMRoomId = roomID; } if (DMRoomId && this.roomList.has(DMRoomId)) { return this.roomList.get(DMRoomId); } // If user is invited to the room then DMRoomId will be null. In such // cases, we will pass roomID so that user will be joined to the room // and we will create corresponding conversation. if (DMRoomId) { let conv = new MatrixRoom(this, false, roomName || DMRoomId); this.roomList.set(DMRoomId, conv); this._client .joinRoom(DMRoomId) .catch(error => { conv.close(); throw error; }) .then(room => { conv.initRoom(room); // The membership events will sometimes be missing to initialize the // buddy correctly in the normal room init. if (!conv.buddy) { conv.initBuddy(userId); } }) .catch(error => { this.ERROR("Error creating conversation " + DMRoomId + ": " + error); if (!conv.room) { conv.forget(); } }); return conv; } // Check if we're already creating a DM room with userId if (this._pendingDirectChats.has(userId)) { return this._pendingDirectChats.get(userId); } // Create new DM room with userId let conv = new MatrixRoom(this, false, userId); this.createRoom( this._pendingDirectChats, userId, conv, { is_direct: true, invite: [userId], visibility: lazy.MatrixSDK.Visibility.Private, preset: lazy.MatrixSDK.Preset.TrustedPrivateChat, }, roomId => { this.setDirectRoom(userId, roomId); } ); return conv; }, /** * Create a new matrix room. Locks room creation handling during the * operation. If there are no more pending rooms on completion, we need to * make sure we didn't miss a join from another room. * * @param {Map} pendingMap - One of the lock maps. * @param {string} key - The key to lock with in the set. * @param {MatrixRoom} conversation - Conversation for the room. * @param {object} roomInit - Parameters for room creation. * @param {Function} [onCreated] - Callback to execute before room creation * is finalized. * @returns {Promise} The returned promise should never reject. */ async createRoom(pendingMap, key, conversation, roomInit, onCreated) { pendingMap.set(key, conversation); if (roomInit.is_direct && roomInit.invite) { try { const userDeviceMap = await this._client.downloadKeys(roomInit.invite); // Encrypt if there are devices and each user has at least 1 device // capable of encryption. const shouldEncrypt = userDeviceMap.size > 0 && [...userDeviceMap.values()].every(deviceMap => deviceMap.size > 0); if (shouldEncrypt) { if (!roomInit.initial_state) { roomInit.initial_state = []; } roomInit.initial_state.push({ type: lazy.MatrixSDK.EventType.RoomEncryption, state_key: "", content: { algorithm: lazy.OlmLib.MEGOLM_ALGORITHM, }, }); } } catch (error) { const users = roomInit.invite.join(", "); this.WARN( `Error while checking encryption devices for ${users}: ${error}` ); } } try { const res = await this._client.createRoom(roomInit); const newRoomId = res.room_id; if (typeof onCreated === "function") { onCreated(newRoomId); } this.roomList.set(newRoomId, conversation); const room = this._client.getRoom(newRoomId); if (room) { conversation.initRoom(room); } } catch (error) { this.ERROR(error); // Only leave room if it was ever associated with the conversation if (!conversation.room) { conversation.forget(); } else { conversation.close(); } } finally { pendingMap.delete(key); if (this._pendingDirectChats.size + this._pendingRoomAliases.size === 0) { await this.handleCaughtUp(); } } }, addBuddy(aTag, aName) { if (aName[0] !== this.protocol.usernamePrefix) { this.ERROR("Buddy name must start with @"); return; } if (!aName.includes(this.protocol.usernameSplits[0].separator)) { this.ERROR("Buddy name must include :"); return; } if (aName == this.userId) { return; } if (this.buddies.has(aName)) { return; } // Prepare buddy for use with the conversation while preserving the tag. const buddy = new MatrixBuddy(this, null, aTag, aName); IMServices.contacts.accountBuddyAdded(buddy); this.buddies.set(aName, buddy); this.getDirectConversation(aName); }, loadBuddy(aBuddy, aTag) { const buddy = new MatrixBuddy(this, aBuddy, aTag); this.buddies.set(buddy.userName, buddy); return buddy; }, /** * Get tooltip info for a user. * * @param {string} aUserId - MXID to get tooltip data for. * @returns {Array} */ getBuddyInfo(aUserId) { if (!this.connected) { return []; } let user = this._client.getUser(aUserId); if (!user) { return []; } // Convert timespan in milli-seconds into a human-readable form. let getNormalizedTime = function (aTime) { let valuesAndUnits = lazy.DownloadUtils.convertTimeUnits(aTime / 1000); // If the time is exact to the first set of units, trim off // the subsequent zeroes. if (!valuesAndUnits[2]) { valuesAndUnits.splice(2, 2); } return lazy._("tooltip.timespan", valuesAndUnits.join(" ")); }; let tooltipInfo = []; if (user.displayName) { tooltipInfo.push( new TooltipInfo(lazy._("tooltip.displayName"), user.displayName) ); } // Add the user's current status. let status = getStatusFromPresence(user); if (status === Ci.imIStatusInfo.STATUS_IDLE) { tooltipInfo.push( new TooltipInfo( lazy._("tooltip.lastActive"), getNormalizedTime(user.lastActiveAgo) ) ); } tooltipInfo.push( new TooltipInfo( status, user.presenceStatusMsg, Ci.prplITooltipInfo.status ) ); if (user.avatarUrl) { // Convert the MXC URL to an HTTP URL. let realUrl = this._client.mxcUrlToHttp( user.avatarUrl, USER_ICON_SIZE, USER_ICON_SIZE, "scale", false ); // TODO Cache the photo URI for this participant. tooltipInfo.push( new TooltipInfo(null, realUrl, Ci.prplITooltipInfo.icon) ); } return tooltipInfo; }, requestBuddyInfo(aUserId) { Services.obs.notifyObservers( new nsSimpleEnumerator(this.getBuddyInfo(aUserId)), "user-info-received", aUserId ); }, getSessions() { if (!this._client || !this._client.isCryptoEnabled()) { return []; } return this._client .getStoredDevicesForUser(this.userId) .map(deviceInfo => new MatrixSession(this, this.userId, deviceInfo)); }, get userId() { return this._client.credentials.userId; }, _client: null, };