diff options
Diffstat (limited to 'comm/chat/protocols/matrix/matrixAccount.sys.mjs')
-rw-r--r-- | comm/chat/protocols/matrix/matrixAccount.sys.mjs | 3495 |
1 files changed, 3495 insertions, 0 deletions
diff --git a/comm/chat/protocols/matrix/matrixAccount.sys.mjs b/comm/chat/protocols/matrix/matrixAccount.sys.mjs new file mode 100644 index 0000000000..f6ae807b53 --- /dev/null +++ b/comm/chat/protocols/matrix/matrixAccount.sys.mjs @@ -0,0 +1,3495 @@ +/* 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<string>} + */ + _eventsWaitingForDecryption: null, + + /** + * A set of operations that are pending that want the room to show as joining. + * + * @type {Set<string>} + */ + _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<void>} + */ + 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<object>} + */ + 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<string>} + */ + _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<void>} + */ + 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<TimeoutHandle>} + */ + _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<string>} + */ + _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<string, MatrixRoom>} + */ + _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 #<alias>:<domain> + 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<string, MatrixRoom>} + */ + _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<string, MatrixRoom>} 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<prplITooltipInfo>} + */ + 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, +}; |