import { createApp } from "vue"; import * as sdk from "matrix-js-sdk"; import { TimelineWindow, EventTimeline, EventStatus } from "matrix-js-sdk"; import util, { STATE_EVENT_ROOM_DELETED, STATE_EVENT_ROOM_TYPE, ROOM_TYPE_CHANNEL, ROOM_TYPE_FILE_MODE, ROOM_TYPE_VOICE_MODE, ROOM_TYPE_DEFAULT } from "../plugins/utils"; import User from "../models/user"; import * as LocalStorageCryptoStoreClass from "matrix-js-sdk/lib/crypto/store/localStorage-crypto-store"; import rememberMeMixin from "../components/rememberMeMixin"; import { AttachmentManager } from "../models/attachmentManager"; const LocalStorageCryptoStore = LocalStorageCryptoStoreClass.LocalStorageCryptoStore; export const CHANNEL_POWER_LEVELS = { "m.room.encrypted": 0, // NOTE! Since practically all events in encrypted rooms get sent as "m.room.encrypted" we need to set // power to 0 here. Otherwise we would not be able to send quick reactions or poll responses... "m.poll.response": 0, "org.matrix.msc3381.poll.response": 0, "m.reaction": 0, "m.room.redaction": 0, }; export default { install(app, options) { if (!options || !options.store) { throw new Error("Please initialise plugin with a Vuex store."); } const store = options.store; // Set User-Agent headers. // Update: browser do not allow this, "Refused to set unsafe header "User-Agent"" // Keep this code around however, since it's an example of how to add headers to a request... // sdk.wrapRequest((orig, opts, callback) => { // opts.headers = opts.headers || {} // opts.headers['User-Agent'] = "Keanu"; // var ret = orig(opts, callback); // return ret; // }); const matrixService = createApp({ mixins: [ rememberMeMixin ], data() { return { matrixClient: null, matrixClientReady: false, rooms: [], userDisplayName: null, userAvatar: null, currentRoom: null, notificationCount: 0, legacyCryptoStore: undefined, tokenRefreshPromise: undefined, attachmentManager: undefined, }; }, mounted() { console.log("Matrix service mounted"); }, computed: { ready() { return this.matrixClient != null && this.matrixClientReady; }, currentUser() { return this.$store.state.auth.user; }, currentUserId() { const user = this.currentUser || {}; return user.user_id; }, currentUserDisplayName() { if (this.ready) { const user = this.matrixClient.getUser(this.currentUserId) || {}; return this.userDisplayName || user.displayName; } return null; }, currentUserMXDomain() { return User.domainPart(this.currentUserId) || this.$config.defaultMatrixDomainPart; }, currentRoomId() { return this.$store.state.currentRoomId; }, joinedRooms() { return this.rooms.filter((room) => { return room.getMyMembership() === "join"; }); }, invites() { return this.rooms.filter((room) => { return room.getMyMembership() === "invite"; }); }, knockedRooms() { return this.rooms.filter((room) => { return room.getMyMembership() === "knock"; }); }, joinedAndInvitedRooms() { return this.rooms.filter((room) => { return room.getMyMembership() === "join" || room.getMyMembership() === "invite"; }); }, }, watch: { currentRoomId: { immediate: true, handler(roomId) { this.currentRoom = this.getRoom(roomId); }, }, currentRoom: { immediate: true, handler(room) { if (room) { this.userCanSendMessageInCurrentRoom = this.userCanSendMessageInRoom(room.roomId, this.currentUserId); this.userCanSendReactionAndAnswerPollInCurrentRoom = this.userCanSendReactionAndAnswerPollInRoom(room.roomId, this.currentUserId); } else { this.userCanSendMessageInCurrentRoom = true; this.userCanSendReactionAndAnswerPollInCurrentRoom = true; } }, }, }, methods: { createCryptoStore() { if (!this.legacyCryptoStore) { console.log("create crypto store"); this.legacyCryptoStore = new LocalStorageCryptoStore(this.$store.getters.storage); } return this.legacyCryptoStore; }, login(user, registrationFlowHandler, createUser = false) { return util.getMatrixBaseUrl(user, this.$config).then((baseUrl) => { const tempMatrixClient = sdk.createClient({ baseUrl: baseUrl, idBaseUrl: this.$config.identityServer, }); var promiseLogin; const self = this; if (user.access_token) { // Logged in on "real" account promiseLogin = Promise.resolve(user); } else if (createUser || (user.is_guest && (!user.user_id || user.registration_session))) { // Generate random username and password. We don't user REAL matrix // guest accounts because 1. They are not allowed to post media, 2. They // can not use avatars and 3. They can not seamlessly be upgraded to real accounts. // // Instead, we use an ILAG approach, Improved Landing as Guest. const userId = createUser || user.registration_session ? user.user_id : util.randomUser(this.$config.userIdPrefix); const pass = createUser || user.registration_session ? user.password : util.randomPass(); const extractAndSaveUser = (response) => { var u = Object.assign({}, response); u.password = pass; u.is_guest = true; this.$store.commit("setUser", u); return u; }; promiseLogin = tempMatrixClient .register(userId, pass, user.registration_session || null, { type: "m.login.dummy", initial_device_display_name: this.$config.appName, refresh_token: true }) .then((response) => { return extractAndSaveUser(response); }) .catch((error) => { if (registrationFlowHandler && error.httpStatus == 401 && error.data) { const registrationSession = error.data.session; // Store user, pass and session, so we can resume if network failure occurs etc. // var u = {}; u.user_id = userId; u.password = pass; u.is_guest = true; u.registration_session = registrationSession; this.$store.commit("setUser", u); return registrationFlowHandler(tempMatrixClient, error.data).then((response) => extractAndSaveUser(response) ); } else { console.error(error); } throw error; }); } else { var data = { user: User.localPart(user.user_id), password: user.password, type: "m.login.password", initial_device_display_name: this.$config.appName, refresh_token: true }; if (user.device_id) { data.device_id = user.device_id; } promiseLogin = tempMatrixClient.login("m.login.password", data).then((response) => { var u = Object.assign({}, response); if (user.is_guest) { // Copy over needed properties u = Object.assign(user, response); } this.$store.commit("setUser", u); return u; }); } return promiseLogin.then((user) => { return self.getMatrixClient(user); }); }); }, clearCryptoStore() { // Clear crypto related data // TODO - for some reason "clearStores" called in "logout" only clears the "account" crypto // data item, not all sessions etc. Why? We need to do that manually here! const toRemove = []; const storage = this.$store.getters.storage; for (let i = 0; i < storage.length; ++i) { const key = storage.key(i); if (key.startsWith("crypto.")) toRemove.push(key); } for (const key of toRemove) { storage.removeItem(key); } }, logout() { if (this.matrixClient) { this.removeMatrixClientListeners(this.matrixClient); this.matrixClient.stopClient(); this.matrixClient.clearStores().then(() => { this.clearCryptoStore(); }); this.matrixClient = null; this.matrixClientReady = false; } else { this.clearCryptoStore(); } this.$store.commit("setUser", null); this.$store.commit("setCurrentRoomId", null); this.rooms = []; this.userDisplayName = null; this.userAvatar = null; this.currentRoom = null; this.notificationCount = 0; }, initClient() { this.reloadRooms(); this.matrixClientReady = true; this.matrixClient.emit("Matrix.initialized", this.matrixClient); this.matrixClient .getProfileInfo(this.currentUserId) .then((info) => { console.log("Got user profile: " + JSON.stringify(info)); this.userDisplayName = info.displayname; this.userAvatar = info.avatar_url; }) .catch((err) => { console.log("Failed to get user profile: ", err); }); }, async getMatrixClient(user) { if (user === undefined) { user = this.$store.state.auth.user; } if (this.matrixClientReady) { return user; } else if (this.matrixClient) { return new Promise((resolve, ignoredreject) => { this.matrixClient.once("Matrix.initialized", (ignoredclient) => { resolve(user); }); }); } const matrixStore = new sdk.MemoryStore(this.$store.getters.storage); const cryptoStore = this.createCryptoStore(); if (cryptoStore) { const migrationState = await cryptoStore.getMigrationState(); if (migrationState >= 0) { const store = this.$store.getters.storage; // Rust crypto migration is broken, needs the sessionId to be set. So do that here, if needed. for (let i = 0; i < store.length; ++i) { const key = store.key(i); if (key.startsWith("crypto.sessions/")) { const sessions = JSON.parse(store.getItem(key)); for (let sessionId of Object.keys(sessions)) { sessions[sessionId].sessionId = sessionId; sessions[sessionId].deviceKey = key.substring("crypto.sessions/".length); } store.setItem(key, JSON.stringify(sessions)); } } } } const baseUrl = await util.getMatrixBaseUrl(user, this.$config); var opts = { baseUrl: baseUrl, userId: user.user_id, store: matrixStore, deviceId: user.device_id, accessToken: user.access_token, refreshToken: user.refresh_token, tokenRefreshFunction: this.onSessionRefresh, timelineSupport: true, unstableClientRelationAggregation: true, cryptoStore: cryptoStore //useAuthorizationHeader: true }; const cryptoStorePassword = this.$config.appName + ":" + user.device_id; const matrixClient = sdk.createClient(opts); this.matrixClient = matrixClient; await this.matrixClient.initRustCrypto({useIndexedDB: this.rememberMe, storagePassword: cryptoStorePassword}); console.log("Crypto initialized", cryptoStorePassword); this.addMatrixClientListeners(this.matrixClient); this.matrixClient.startClient(); if (!this.matrixClient.isInitialSyncComplete()) { await new Promise((resolve, reject) => { this.matrixClient.once("sync", function (state, ignoredprevState, ignoredres) { console.log(state); // state will be 'PREPARED' when the client is ready to use if (state == "PREPARED") { resolve(matrixClient); } else if (state == "ERROR") { reject("Error syncing"); } }); }); } this.useAuthedMedia = await this.matrixClient.isVersionSupported("v1.11"); // Create the attachment manager this.attachmentManager = new AttachmentManager(this.matrixClient, this.useAuthedMedia, this.$config.maxSizeAutoDownloads); // Ready to use! Start by loading rooms. this.initClient(); return user; }, /** * Returns a promise that will log us into the Matrix. * * Will use a real account, if we have one, otherwise will create * a random account. */ getLoginPromise(registrationFlowHandler) { if (this.ready) { return Promise.resolve(this.currentUser); } return this.$store.dispatch("login", { user: this.currentUser || new User("", "", true), registrationFlowHandler, }); }, addMatrixClientListeners(client) { if (client) { client.setMaxListeners(100); // Increate max number of listeners. client.on("event", this.onEvent); client.on("Room", this.onRoom); client.on("Session.logged_out", this.onSessionLoggedOut); client.on("Room.myMembership", this.onRoomMyMembership); } }, removeMatrixClientListeners(client) { if (client) { client.off("event", this.onEvent); client.off("Room", this.onRoom); client.off("Session.logged_out", this.onSessionLoggedOut); client.off("Room.myMembership", this.onRoomMyMembership); } }, onEvent(event) { switch (event.getType()) { case "m.room.topic": { const room = this.matrixClient.getRoom(event.getRoomId()); if (room) { room["topic"] = event.getContent().topic; } } break; case "m.room.avatar": { const room = this.matrixClient.getRoom(event.getRoomId()); if (room) { room["avatar"] = room.getAvatarUrl(this.matrixClient.getHomeserverUrl(), 80, 80, "scale", true, this.useAuthedMedia); } } break; case "m.room.power_levels": { if (this.currentRoom && event.getRoomId() == this.currentRoom.roomId) { this.userCanSendMessageInCurrentRoom = this.userCanSendMessageInRoom(event.getRoomId(), this.currentUserId); this.userCanSendReactionAndAnswerPollInCurrentRoom = this.userCanSendReactionAndAnswerPollInRoom(event.getRoomId(), this.currentUserId); } } break; case "m.room.join_rules": { const room = this.matrixClient.getRoom(event.getRoomId()); if (room && room.getJoinRule() == "private" && room.getMyMembership() == "invite") { // We have an invite to a room that's now "private"? This is most probably a deleted DM room. // Reject the invite, i.e. call "leave" on it. this.matrixClient.leave(room.roomId); } // TODO - handle knocks } break; case "m.room.canonical_alias": { if (this.currentRoomId && this.currentRoomId.startsWith("#") && !this.currentRoom) { this.currentRoom = this.getRoom(this.currentRoomId); } } break; case STATE_EVENT_ROOM_DELETED: { const room = this.matrixClient.getRoom(event.getRoomId()); if (room && room.currentState) { // Before we do anything, make sure the sender is an admin! // Also, do not react if WE are the sender, since we are probably // busy doing the rest of the purging process... if (room.currentState.maySendStateEvent("m.room.power_levels", event.getSender())) { if (event.getSender() !== this.currentUserId) { this.leaveRoomAndNavigate(room.roomId).then(() => { this.matrixClient.forget(room.roomId, true); }); } } } } break; } this.updateNotificationCount(); }, onRoom(room) { if (room.getMyMembership() === "invite") { this.matrixClient .getRoomTags(room.roomId) .then((reply) => { if (Object.keys(reply.tags).includes("m.server_notice")) { room["isServiceNoticeRoom"] = true; } }) .catch((error) => { console.error(error); }); } this.reloadRooms(); this.updateNotificationCount(); }, onRoomMyMembership(room, membership, prevMembership) { if (membership === "invite") { if (prevMembership === "knock") { // This is someone accepting our knock! Auto-join. this.matrixClient.joinRoom(room.roomId); } // Invitation. Need to call "recalculate" to pick // up room name, not sure why exactly. room.recalculate(); } this.reloadRooms(); }, onSessionRefresh(refreshToken) { if (this.tokenRefreshPromise) { return this.tokenRefreshPromise; } const now = Date.now(); this.tokenRefreshPromise = this.matrixClient.http.request(sdk.Method.Post, "/refresh", undefined, { refresh_token: refreshToken}, { prefix: sdk.ClientPrefix.V3, inhibitLogoutEmit: true }).then((result) => { // Store new one! var user = this.$store.state.auth.user; user.access_token = result.access_token; user.refresh_token = result.refresh_token; user.expires_in_ms = result.expires_in_ms; this.$store.commit("setUser", user); // Return AccessTokens struct let accessTokens = { accessToken: result.access_token, refreshToken: result.refresh_token }; if (result.expires_in_ms) { accessTokens.expiry = new Date(now + result.expires_in_ms); }; return accessTokens; }) .catch((error) => { throw new sdk.TokenRefreshLogoutError(error); }) .finally(() => { this.tokenRefreshPromise = undefined; }); return this.tokenRefreshPromise; }, onSessionLoggedOut() { console.log("Logged out!"); if (this.matrixClient) { this.removeMatrixClientListeners(this.matrixClient); this.matrixClient.stopClient(); this.matrixClient = null; this.matrixClientReady = false; } // For "real" accounts we totally wipe the user object, but for "guest" // accounts (i.e. created from random data and with password never changed) // we need to hang on to the generated password and use that to login to a new // session, so only wipe the token in s that case. // Clear the access token var user = this.$store.state.auth.user; if (user.is_guest) { delete user.access_token; this.$store.commit("setUser", user); // Login again this.login(user).catch((error) => { if (error.data.errcode === "M_FORBIDDEN" && this.currentUser.is_guest) { // Guest account and password don't work. We are in a strange state, probably because // of server cleanup of accounts or similar. Wipe account and restart... this.$store.commit("setUser", null); } this.$store.commit("setCurrentRoomId", null); this.$navigation.push({ path: "/login" }, -1); }); } else { this.$store.commit("setUser", null); this.$store.commit("setCurrentRoomId", null); this.$navigation.push({ path: "/login" }, -1); } }, reloadRooms() { // TODO - do incremental update instead of replacing the whole array // each time! var updatedRooms = this.matrixClient.getVisibleRooms(); updatedRooms = updatedRooms.filter((room) => { return room.getMyMembership() && ["invite", "join", "knock"].includes(room.getMyMembership()) && room.currentState.getStateEvents(STATE_EVENT_ROOM_DELETED).length == 0; }); updatedRooms.forEach((room) => { room["avatar"] = room.getAvatarUrl(this.matrixClient.getHomeserverUrl(), 80, 80, "scale", true, this.useAuthedMedia); }); this["rooms"] = updatedRooms; const resolvedId = this.currentRoomId && this.currentRoomId.startsWith("#") ? this.matrixClient.getRoomIdForAlias(this.currentRoomId).then((r) => r.room_id) : Promise.resolve(this.currentRoomId); resolvedId .then((roomId) => { const currentRoom = this.getRoom(roomId); if (this.currentRoom != currentRoom) { this.currentRoom = currentRoom; } }) .catch((ignorederror) => { }); }, setCurrentRoomId(roomId) { this.$store.commit("setCurrentRoomId", roomId); this.currentRoom = this.getRoom(roomId); }, getRoom(roomId) { if (!roomId) { return null; } var room = null; if (this.matrixClient) { const visibleRooms = this.matrixClient.getRooms(); room = visibleRooms.find((room) => { if (roomId.startsWith("#")) { return room.getCanonicalAlias() == roomId; } return room.roomId == roomId; }); } return room || null; }, /** * Return all users we are in a "invite" only room with! */ getAllFriends() { var ids = {}; const ret = []; for (const room of this.rooms) { if (room.getMyMembership() == "join" && this.getRoomJoinRule(room) == "invite") { for (const member of room.getJoinedMembers()) { if (member.userId != this.currentUserId && !ids[member.userId]) { ids[member.userId] = member; ret.push(member); } } } } ret.sort((a, b) => { const aName = a.user ? a.user.displayName : a.name; const bName = b.user ? b.user.displayName : b.name; return aName.localeCompare(bName); }); return ret; }, getRoomJoinRule(room) { if (room) { const joinRules = room.currentState.getStateEvents("m.room.join_rules", ""); return joinRules && joinRules.getContent().join_rule; } return null; }, getRoomHistoryVisibility(room) { if (room) { const historyVisibility = room.currentState.getStateEvents("m.room.history_visibility", ""); return historyVisibility && historyVisibility.getContent().history_visibility; } return null; }, leaveRoom(roomId) { return this.matrixClient.leave(roomId).then(() => { this.$store.commit("setCurrentRoomId", null); this.rooms = this.rooms.filter((room) => { room.roomId != roomId; }); //this.matrixClient.store.removeRoom(roomId); //this.matrixClient.forget(roomId, true, undefined); }); }, /** * Leave the room, and if this is the last room we are in, navigate to the "goodbye" page. * Otherwise, navigate to home. * @param roomId */ leaveRoomAndNavigate(roomId) { const joinedRooms = this.joinedRooms; const isLastRoomWeAreJoinedTo = joinedRooms && joinedRooms.length == 1 && joinedRooms[0].roomId == roomId; return this.leaveRoom(roomId).then(() => { if (isLastRoomWeAreJoinedTo) { this.$navigation.push({ name: "Goodbye" }, -1); } else { this.$navigation.push({ name: "Home", params: { roomId: null } }, -1); } }); }, kickUser(roomId, userId) { if (this.matrixClient && roomId && userId) { this.matrixClient.kick(roomId, userId, ""); } }, banUser(roomId, userId) { if (this.matrixClient && roomId && userId) { this.matrixClient.ban(roomId, userId, ""); } }, makeAdmin(roomId, userId) { if (this.matrixClient && roomId && userId) { const room = this.getRoom(roomId); if (room && room.currentState) { this.matrixClient.setPowerLevel(roomId, userId, 100); } } }, makeModerator(roomId, userId) { if (this.matrixClient && roomId && userId) { const room = this.getRoom(roomId); console.log("Room", room); if (room && room.currentState) { this.matrixClient.setPowerLevel(roomId, userId, 50); } } }, revokeModerator(roomId, userId) { if (this.matrixClient && roomId && userId) { const room = this.getRoom(roomId); if (room && room.currentState) { this.matrixClient.setPowerLevel(roomId, userId, 0); } } }, answerKnock(roomId, userId, allowAccess, reason) { if (allowAccess) { this.matrixClient.invite(roomId, userId, reason); } else { this.matrixClient.kick(roomId, userId, reason); } }, /** * Returns true if the current user is joined to the given room. * @param roomIdOrAlias * @returns Promise - Whether the user is joined to the room or not */ isJoinedToRoom(roomIdOrAlias) { if (roomIdOrAlias && this.matrixClient) { try { const resolvedRoomId = roomIdOrAlias.startsWith("#") ? this.matrixClient.getRoomIdForAlias(roomIdOrAlias).then((res) => res.room_id) : Promise.resolve(roomIdOrAlias); return resolvedRoomId.then((roomId) => { return this.matrixClient.getJoinedRooms().then((rooms) => { return rooms.joined_rooms.includes(roomId); }); }); } catch (ignorederror) { console.error(ignorederror); return Promise.resolve(false); } } return Promise.resolve(false); }, isReadOnlyRoom(roomId) { if (this.matrixClient && roomId) { const room = this.getRoom(roomId); if (room && room.currentState) { const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); if (powerLevelEvent) { if (this.roomType(roomId) == ROOM_TYPE_CHANNEL) { return Object.keys(powerLevelEvent.getContent().events).length == 0; } return powerLevelEvent.getContent().events_default > 0; } } } return false; }, setReadOnlyRoom(roomId, readOnly) { if (this.matrixClient && roomId) { const room = this.getRoom(roomId); if (room && room.currentState) { const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); if (powerLevelEvent) { let content = powerLevelEvent.getContent(); if (this.roomType(roomId) == ROOM_TYPE_CHANNEL) { content.events = readOnly ? {} : CHANNEL_POWER_LEVELS } else { content.events_default = readOnly ? 50 : 0; } this.matrixClient.sendStateEvent(room.roomId, "m.room.power_levels", content); } } } }, userCanSendMessageInRoom(roomId, userId) { if (this.matrixClient && roomId && userId) { const room = this.getRoom(roomId); if (room && room.currentState) { let isAdmin = room.currentState.maySendEvent("m.room.power_levels", this.currentUserId); return isAdmin || (this.roomType(roomId) != ROOM_TYPE_CHANNEL && !this.isReadOnlyRoom(roomId)); } } return true; }, userCanSendReactionAndAnswerPollInRoom(roomId, userId) { if (this.matrixClient && roomId && userId) { const room = this.getRoom(roomId); if (room && room.currentState) { let isAdmin = room.currentState.maySendEvent("m.room.power_levels", this.currentUserId); return isAdmin || !this.isReadOnlyRoom(roomId); } } return true; }, roomType(roomId) { if (this.matrixClient && roomId) { const room = this.getRoom(roomId); if (room && room.currentState) { const roomTypeEvent = room.currentState.getStateEvents(STATE_EVENT_ROOM_TYPE, "") || room.currentState.getStateEvents("m.room.create", ""); if (roomTypeEvent) { const roomType = roomTypeEvent.getContent().type; if ([ROOM_TYPE_FILE_MODE, ROOM_TYPE_VOICE_MODE, ROOM_TYPE_CHANNEL].includes(roomType)) { return roomType; } } } } return ROOM_TYPE_DEFAULT; }, /** * Purge the room with the given id! This means: * - Make room invite only * - Disallow guest access * - Set history visibility to 'joined' * - Redact all events * - Kick all members * @param roomId */ purgeRoom(roomId, statusCallback) { this.currentRoomBeingPurged = true; //console.log("Purge room"); const sleep = (ms) => { return new Promise((resolve) => setTimeout(resolve, ms)); }; const withRetry = (codeBlock) => { return codeBlock().catch((error) => { if (error && error.errcode == "M_LIMIT_EXCEEDED") { var retry = 1000; if (error.data) { const retryIn = error.data.retry_after_ms; retry = Math.max(retry, retryIn ? retryIn : 0); } console.log("Rate limited, retry in", retry); return sleep(retry).then(() => withRetry(codeBlock)); } else { return Promise.resolve(); } }); }; const oldGlobalErrorSetting = this.matrixClient.getGlobalErrorOnUnknownDevices(); return new Promise((resolve, reject) => { const room = this.getRoom(roomId); if (!room) { reject("Room not found!"); return; } // Remove any possible pending events room.getLiveTimeline().getEvents().filter((e) => [EventStatus.ENCRYPTING, sdk.EventStatus.QUEUED].includes(e.status)).forEach((e) => { //console.log("Cancel pending event!"); this.matrixClient.cancelPendingEvent(e); }); const timelineWindow = new TimelineWindow(this.matrixClient, room.getUnfilteredTimelineSet(), {}); const self = this; //console.log("Purge: set invite only"); statusCallback(this.$t("room.purge_set_room_state")); withRetry(() => this.matrixClient.sendStateEvent(roomId, "m.room.join_rules", { join_rule: "private" }, "")) .then(() => { //console.log("Purge: forbid guest access"); return withRetry(() => this.matrixClient.sendStateEvent( roomId, "m.room.guest_access", { guest_access: "forbidden" }, "" )); }) .then(() => { //console.log("Purge: set history visibility to 'joined'"); return withRetry(() => this.matrixClient.sendStateEvent(roomId, "m.room.history_visibility", { history_visibility: "joined", })); }) .then(() => { return withRetry(() => this.matrixClient.sendStateEvent(roomId, STATE_EVENT_ROOM_DELETED, { status: "deleted" })); }) .then(() => { // Set message retention 1 minute (may be limited by server) return withRetry(() => this.matrixClient.sendStateEvent(roomId, "m.room.retention", { max_lifetime: 60000 })); }) .then(() => { //console.log("Purge: create timeline"); return timelineWindow.load(undefined, 100); }) .then(() => { const getMoreIfAvailable = function _getMoreIfAvailable() { if (timelineWindow.canPaginate(EventTimeline.BACKWARDS)) { //console.log("Purge: page back"); return timelineWindow.paginate(EventTimeline.BACKWARDS, 100, true, 5).then((gotmore) => { if (gotmore) { return _getMoreIfAvailable.call(self); } return Promise.resolve("Done"); }); } else { return Promise.resolve("Done"); } }.bind(self); return getMoreIfAvailable(); }) .then(() => { //console.log("Purge: redact events"); statusCallback(this.$t("room.purge_redacting_events")); // First ignore unknown device errors this.matrixClient.setGlobalErrorOnUnknownDevices(false); const allEvents = timelineWindow.getEvents().filter((event) => { return ( !event.isRedacted() && !event.isRedaction() && !event.isState() && (!room.currentState || room.currentState.maySendRedactionForEvent(event, this.currentUserId)) ); }); const redactFirstEvent = (events) => { statusCallback( this.$t("room.purge_redacting_events", { count: allEvents.length - events.length + 1, total: allEvents.length, }) ); if (events.length == 0) { return Promise.resolve(true); } const event = events[0]; return withRetry(() => this.matrixClient.redactEvent(event.getRoomId(), event.getId())).then(() => redactFirstEvent(events.slice(1)) ); }; return redactFirstEvent(allEvents); }) .then(() => { //console.log("Purge: kick members"); statusCallback(this.$t("room.purge_removing_members")); var joined = room.getMembersWithMembership("join"); var invited = room.getMembersWithMembership("invite"); var knocked = room.getMembersWithMembership("knock"); var allMembers = joined.concat(invited).concat(knocked); const me = allMembers.find((m) => m.userId == self.currentUserId); const kickFirstMember = (members) => { //console.log(`Kicking ${members.length} members`); statusCallback( this.$t("room.purge_removing_members", { count: allMembers.length - members.length + 1, total: allMembers.length, }) ); if (members.length == 0) { return Promise.resolve(true); } const member = members[0]; if (member.userId == self.currentUserId) { return kickFirstMember(members.slice(1)); } else { // Slight pause to avoid rate limiting. return sleep(0.1) .then(() => withRetry(() => { if (member.membership == "invite" && me && me.powerLevel <= member.powerLevel) { // The user is invited, but we can't kick them because of power levels. // Send a new invite with reason set to "Room Deleted". // The client will be sent stripped room state, and can from that see the // join_rule of "private". It will then "leave", i.e. reject the invite. return this.matrixClient.invite(roomId, member.userId, "Room Deleted"); } return this.matrixClient.kick(roomId, member.userId, "Room Deleted") })) .then(() => kickFirstMember(members.slice(1))); } }; return kickFirstMember(allMembers); }) .then(() => { return withRetry(() => this.matrixClient.sendStateEvent(roomId, STATE_EVENT_ROOM_DELETED, { status: "deleted" }) ); }) .then(() => { statusCallback(null); this.matrixClient.setGlobalErrorOnUnknownDevices(oldGlobalErrorSetting); return withRetry(() => this.leaveRoom(roomId)); }) .then(() => { this.currentRoomBeingPurged = false; resolve(true); // Done! }) .catch((err) => { console.error("Error purging room", err); this.currentRoomBeingPurged = false; this.matrixClient.setGlobalErrorOnUnknownDevices(oldGlobalErrorSetting); reject(err); }); }); }, /** * Get a private chat room with the given user. Searches through our rooms to see * if a suitable room already exists. If not, one is created. * @param {*} userId The user to chat with. */ getOrCreatePrivateChat(userId) { return new Promise((resolve, reject) => { for (const room of this.rooms) { // Is the other member the one we are looking for? if (this.isDirectRoomWith(room, userId)) { if (room.getMyMembership() == "invite") { this.matrixClient.joinRoom(room.roomId); } else { var member = room.getMember(userId); if (member && member.membership != "join") { // Resend invite this.matrixClient.invite(room.roomId, userId); } } resolve(room); return; } } // No room found, create one // const createRoomOptions = { visibility: "private", // Not listed! preset: "private_chat", is_direct: true, initial_state: [ { type: "m.room.encryption", state_key: "", content: { algorithm: "m.megolm.v1.aes-sha2", }, }, { type: "m.room.guest_access", state_key: "", content: { guest_access: "forbidden", }, }, { type: "m.room.history_visibility", state_key: "", content: { history_visibility: "invited", }, }, { type: "m.room.power_levels", state_key: "", content: { users: { [this.currentUserId]: 100, [userId]: 100, }, }, }, ], invite: [userId], }; return this.matrixClient .createRoom(createRoomOptions) .then(({ room_id, room_alias }) => { resolve(this.getRoom(room_alias || room_id)); }) .catch((error) => { reject(error); }); }); }, /** * Return true if this room is a direct room with the given user. * @param { } room * @param {*} userId */ isDirectRoomWith(room, userId) { if (room && room.getJoinRule() == "invite" && room.getMembers().length == 2) { let other = room.getMember(userId); if (other) { if (room.getMyMembership() == "invite" && other.membership == "join") { return true; } else if (room.getMyMembership() == "join" && room.canInvite(this.currentUserId)) { return true; } else if (room.getMyMembership() == "join" && other.membership == "join") { return true; } } } return false; }, /** * Return true if this room is a direct room with one other user. NOTE: this currently * only checks number of members, not any is_direct flag. * @param { } room */ isDirectRoom(room) { // TODO - Use the is_direct accountData flag (m.direct). WE (as the client) // apprently need to set this... if (room && room.getJoinRule() == "invite" && room.getMembers().length == 2) { return true; } return false; }, on(event, handler) { if (this.matrixClient) { this.matrixClient.on(event, handler); } }, off(event, handler) { if (this.matrixClient) { this.matrixClient.off(event, handler); } }, setUserDisplayName(name) { if (this.matrixClient) { return this.matrixClient .setDisplayName(name || this.user.userId) .then(() => (this.userDisplayName = name)) .catch((err) => console.err("Failed to set display name", err)); } else { return Promise.reject("No matrix client"); } }, setPassword(oldPassword, newPassword) { if (this.matrixClient && this.currentUser) { const authDict = { type: "m.login.password", identifier: { type: "m.id.user", user: this.currentUser.user_id, }, // TODO: Remove `user` once servers support proper UIA // See https://github.com/matrix-org/synapse/issues/5665 user: this.currentUser.user_id, password: oldPassword, }; const self = this; return this.matrixClient .setPassword(authDict, newPassword) .then(() => { // Forget password and remove the 'is_guest' flag, we are now a "real" user! self.currentUser.password = undefined; self.currentUser.is_guest = false; self.$store.commit("setUser", self.currentUser); }) .then(() => { return true; }); } return Promise.resolve(false); }, uploadFile(file, opts) { return this.matrixClient.uploadContent(file, opts); }, /** * Get a matrix client that can be used for public queries. If we are logged in, this is the normal * matrix client. If not, we create a temp one with a temp password. * @returns A MatrixClient that can be used for public queries */ getPublicQueryMatrixClient() { if (this.matrixClient) { return this.getMatrixClient().then(() => { return this.matrixClient; }); } else { var tempUserString = this.$store.state.tempuser; var tempUser = null; if (tempUserString) { tempUser = JSON.parse(tempUserString); } return util.getMatrixBaseUrl(tempUser, this.$config).then((baseUrl) => { const tempMatrixClient = sdk.createClient({ baseUrl: baseUrl }); var clientPromise; // Need to create an account? // if (tempUser) { clientPromise = Promise.resolve(tempUser); } else { const user = util.randomUser(this.$config.userIdPrefix); const pass = util.randomPass(); clientPromise = tempMatrixClient .register(user, pass, null, { type: "m.login.dummy", initial_device_display_name: this.$config.appName, }) .then((response) => { console.log("Response", response); response.password = pass; response.is_guest = true; this.$store.commit("setTempUser", response); return response; }); } // Get an access token clientPromise = clientPromise.then((user) => { var data = { user: User.localPart(user.user_id), password: user.password, type: "m.login.password", initial_device_display_name: this.$config.appName, }; if (user.device_id) { data.device_id = user.device_id; } return tempMatrixClient.login("m.login.password", data); }); // Then login // // Create a slimmed down client, without crypto. This one is // Only used to get public room info from. clientPromise = clientPromise.then((user) => { var opts = { baseUrl: baseUrl, userId: user.user_id, accessToken: user.access_token, timelineSupport: false, }; var matrixClient = sdk.createClient(opts); matrixClient.startClient(); return matrixClient; }); return clientPromise; }); } }, getPublicRoomInfo(roomId) { if (!roomId) { return Promise.reject("Invalid parameters"); } const parts = roomId.split(":"); if (parts.length != 2) { return Promise.reject("Unknown room server"); } const server = parts[1]; const clientPromise = this.getPublicQueryMatrixClient(); const findOrGetMore = function _findOrGetMore(client, useAuthedMedia, response) { for (var room of response.chunk) { if ( (roomId.startsWith("#") && room.canonical_alias == roomId) || (roomId.startsWith("!") && room.room_id == roomId) ) { if (room.avatar_url) { room["avatar"] = client.mxcUrlToHttp(room.avatar_url, 80, 80, "scale", false, undefined, useAuthedMedia); } return Promise.resolve(room); } } if (response.next_batch) { return client .publicRooms({ server: server, limit: 1000, since: response.next_batch, }) .then((response) => { return _findOrGetMore(client, useAuthedMedia, response); }) .catch((err) => { return Promise.reject("Failed to find room: " + err); }); } else { return Promise.reject("No more data"); } }; var matrixClient; let useAuthedMedia = false; return clientPromise .then((client) => { matrixClient = client; return client.isVersionSupported("v1.11"); }) .then((version1_11) => { useAuthedMedia = version1_11; return matrixClient.publicRooms({ server: server, limit: 1000 }); }) .then((response) => { return findOrGetMore(matrixClient, useAuthedMedia, response); }) .catch((err) => { return Promise.reject("Failed to find room: " + err); }); }, updateNotificationCount() { var count = 0; this.rooms.forEach((room) => { count += room.getUnreadNotificationCount("total") || 0; }); this.notificationCount = count; }, setEventPinned(room, event, pinned) { if (room && room.currentState && event) { const pinnedMessagesEvent = room.currentState.getStateEvents("m.room.pinned_events", ""); const content = pinnedMessagesEvent ? pinnedMessagesEvent.getContent() : {} let pinnedEvents = content["pinned"] || []; if (pinned && !pinnedEvents.includes(event.getId())) { pinnedEvents.push(event.getId()); } else if (!pinned && pinnedEvents.includes(event.getId())) { pinnedEvents = pinnedEvents.filter((e) => e != event.getId()); } else { return; // no change } content.pinned = pinnedEvents; this.matrixClient.sendStateEvent(room.roomId, "m.room.pinned_events", content); } }, getPinnedEvents(room) { if (room && room.currentState) { const pinnedMessagesEvent = room.currentState.getStateEvents("m.room.pinned_events", ""); const content = pinnedMessagesEvent ? pinnedMessagesEvent.getContent() : {} return content["pinned"] || []; } else { return []; } }, }, render: () => {} }); matrixService.config.globalProperties.$store = store; matrixService.config.globalProperties.$config = app.$config; matrixService.config.globalProperties.$t = app.$t; matrixService.config.globalProperties.$navigation = app.$navigation; matrixService.$store = store; matrixService.$config = app.$config; matrixService.$t = app.$t; matrixService.$navigation = app.$navigation; const instance = matrixService.mount("#app2"); app.config.globalProperties.$matrix = instance; app.$matrix = instance; sdk.setCryptoStoreFactory(instance.createCryptoStore.bind(instance)); }, };