import Cookies from 'js-cookie' import { last } from 'lodash' import { defineStore } from 'pinia' import { useAnnouncementsStore } from 'src/stores/announcements.js' import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js' import { useChatsStore } from 'src/stores/chats.js' import { useEmojiStore } from 'src/stores/emoji.js' import { useInstanceStore } from 'src/stores/instance.js' import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' import { useInterfaceStore } from 'src/stores/interface.js' import { useListsStore } from 'src/stores/lists.js' import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useNotificationsStore } from 'src/stores/notifications.js' import { useOAuthStore } from 'src/stores/oauth.js' import { useStatusesStore } from 'src/stores/statuses.js' import { useStreamingStore } from 'src/stores/streaming.js' import { useSyncConfigStore } from 'src/stores/sync_config.js' import { useTimelinesStore } from 'src/stores/timelines.js' import { useUserHighlightStore } from 'src/stores/user_highlight.js' import { revokeToken } from 'src/api/oauth.js' import { fetchFollowers, fetchFriends, fetchUser, fetchUserByName, verifyCredentials, } from 'src/api/public.js' import { blockUser, editUserNote, fetchBlocks, fetchDomainMutes, fetchMutes, fetchUserInLists, fetchUserRelationship, followUser, muteDomain, muteUser, removeUserFromFollowers, unblockUser, unfollowUser, unmuteDomain, unmuteUser, } from 'src/api/user.js' import { WSConnectionStatus } from 'src/api/websocket.js' import { promiseInterval } from 'src/services/promise_interval/promise_interval.js' export const useUsersStore = defineStore('users', { state: () => ({ loggingIn: false, currentUser: null, users: new Map(), usersByName: new Map(), usersByURL: new Map(), relationships: new Map(), relationshipsLists: { friends: new WeakMap(), followers: new WeakMap(), }, timestamps: new WeakMap(), fetchesIds: new Map(), fetchesNames: new Map(), followPollers: new Map(), followPollersAttempts: new Map(), }), getters: { loggedIn: (state) => !!state.currentUser, findUser: (state) => (query) => { return state.users.get(query) }, findUserByName: (state) => (query) => { return state.usersByName.get(query.toLowerCase()) }, findUserByUrl: (state) => (query) => { return state.usersByURL.get(query.toLowerCase()) }, relationship: (state) => (id) => { const rel = id && state.relationships.get(id) return rel || { id, loading: true } }, }, actions: { // Main updates addNewUsers(response) { const { data, timestamp } = response const users = Array.isArray(data) ? data : [data] return users.map((user) => { const existing = this.users.get(user.id) ?? {} const oldTimestamp = this.timestamps.get(existing) // // Relationship might have different timestamp and // might need updating separate from user const oldRelationship = this.relationships.get(user.id) const oldRelationshipTimestamp = this.timestamps.get(oldRelationship) let relationship // Only need an update if new user data has relationship if (user.relationship) { // Only update if there is no old data or if it's outdated if ( oldRelationship === undefined || timestamp > oldRelationshipTimestamp ) { relationship = this.updateUserRelationships({ timestamp, data: { id: user.id, ...user.relationship }, })[0] } } else { relationship = oldRelationship } // implicit: if oldTimestamp is undefined this will still be false if (oldTimestamp > timestamp) return existing // not overwriting old data with new const { relationship: unused1, ...newUser } = user let reactive = this.users.get(user.id) // Initializing reactivity & avoiding excessive Map mutation if (!reactive) { this.users.set(user.id, existing) reactive = this.users.get(user.id) this.usersByName.set(user.screen_name.toLowerCase(), reactive) this.usersByURL.set(user.url.toLowerCase(), reactive) } // Relying on object reactivity to avoid mutating the Map reactive.relationship = relationship ?? reactive.relationship Object.entries(newUser).forEach(([k, v]) => { reactive[k] = v }) // Updating the timestamp this.timestamps.set(reactive, timestamp) if (user.id === this.currentUser?.id) { this.currentUser = reactive } // Initialize some stuff const { friends, followers } = this.relationshipsLists if (!friends.has(reactive)) friends.set(reactive, new Set()) if (!followers.has(reactive)) followers.set(reactive, new Set()) return reactive }) }, updateUserRelationships({ timestamp, optimism, data }) { const relationships = Array.isArray(data) ? data : [data] return relationships.map((relationship) => { const { id } = relationship const existing = this.relationships.get(id) ?? {} const oldTimestamp = this.timestamps.get(existing) // implicit: if oldTimestamp is undefined this will still be false if (!optimism && oldTimestamp > timestamp) return existing // Initializing reactivity if (!this.relationships.has(id)) this.relationships.set(id, existing) const reactive = this.relationships.get(id) // Relying on reactivity Object.entries(relationship).forEach(([k, v]) => { reactive[k] = v }) if (timestamp) { this.timestamps.set(reactive, timestamp) } // Updating user property if there is such a user if (this.users.has(id)) { this.users.get(id).relationship = reactive } // Update block/mute lists if (this.currentUser && id !== this.currentUser.id) { ;[ ['muting', this.currentUser.muteIds], ['blocking', this.currentUser.blockIds], [ 'following', this.relationshipsLists.friends.get(this.currentUser), ], [ 'followed_by', this.relationshipsLists.followers.get(this.currentUser), ], ].forEach(([relationshipName, list]) => { if (relationship[relationshipName]) { list?.add(id) } else { list?.delete(id) } }) } return reactive }) }, // Misc updates updateUserAdminData(id, data) { const user = this.users.get(id) user.adminData = data user.deactivated = !data.is_active user.tags = new Set(data.tags) }, updateRight(id, right, value) { const user = this.users.get(id) const newRights = user.rights ?? {} newRights[right] = value user.rights = newRights }, // Because frontend doesn't have a reason to keep these stuff in memory // outside of viewing someones user profile. clearFollowLists(userId) { const user = this.users.get(userId) if (user) { this.relationshipsLists.friends.set(user, new Set()) this.relationshipsLists.followers.set(user, new Set()) } }, // Fetches async fetchUserIfMissing({ id, name }) { let findFunc let fetchFunc let map let identifier if (id) { findFunc = this.findUser fetchFunc = this.fetchUser map = this.fetchesIds identifier = id } else if (name) { findFunc = this.findUserByName fetchFunc = this.fetchUserByName map = this.fetchesNames identifier = name } else { throw new TypeError('No identifier provided') } // Search in cache const user = findFunc(identifier) if (user) return user // not found => fetch let promise // Did we already search for this user? if (map.has(identifier)) { // if so, reuse the promise promise = map.get(identifier) } else { // if not, make a new one promise = fetchFunc(identifier) } map.set(identifier, promise) const result = await promise if (result) { const { id, screen_name } = result // Save promise for future use this.fetchesIds.set(id, promise) this.fetchesNames.set(screen_name, promise) return this.users.get(id) } else { return null } }, async fetchUser(id) { try { const result = await fetchUser({ id, credentials: useOAuthStore().token, }) this.addNewUsers(result) return this.users.get(result.data.id) } catch (error) { if (error.name === 'StatusCodeError' && error.statusCode === 404) { console.warn(`User ${id} not found`) return null } else { throw error } } }, async fetchUserByName(name) { try { const result = await fetchUserByName({ name, credentials: useOAuthStore().token, }) this.addNewUsers(result) return this.users.get(result.data.id) } catch (error) { if (error.name === 'StatusCodeError' && error.statusCode === 404) { console.warn(`User ${name} not found`) return null } else { throw error } } }, fetchUserRelationship(id) { return fetchUserRelationship({ id, credentials: useOAuthStore().token, }).then((result) => this.updateUserRelationships(result)) }, fetchFriends(id) { const user = this.users.get(id) const maxId = last([...this.relationshipsLists.friends.get(user)]) return fetchFriends({ id, maxId, credentials: useOAuthStore().token, }).then((result) => { const users = this.addNewUsers(result) const list = this.relationshipsLists.friends.get(user) users.forEach(({ id }) => list.add(id)) return result.data }) }, fetchFollowers(id) { const user = this.users.get(id) const maxId = last([...this.relationshipsLists.followers.get(user)]) return fetchFollowers({ id, maxId, credentials: useOAuthStore().token, }).then((result) => { const users = this.addNewUsers(result) const list = this.relationshipsLists.followers.get(user) users.forEach(({ id }) => list.add(id)) return result.data }) }, fetchUserInLists(id) { if (this.currentUser) { return fetchUserInLists({ id, credentials: useOAuthStore().token, }).then(({ data: inLists }) => { this.users.get(id).inLists = inLists }) } }, fetchMutes(args) { const { reset } = args || {} const maxId = this.currentUser.muteIdsMaxId return fetchMutes({ maxId, credentials: useOAuthStore().token, }).then((result) => { const { data: mutes } = result if (reset) { this.currentUser.muteIds = new Set(mutes.map(({ id }) => id)) } else { mutes.forEach(({ id }) => this.currentUser.muteIds.add(id)) } if (mutes.length) { this.currentUser.muteIdsMaxId = last(mutes).id } this.addNewUsers(result) return mutes }) }, fetchBlocks(args) { const { reset } = args || {} const maxId = this.currentUser.blockIdsMaxId return fetchBlocks({ maxId, credentials: useOAuthStore().token, }).then((result) => { const { data: blocks } = result if (reset) { this.currentUser.blockIds = new Set(blocks.map(({ id }) => id)) } else { blocks.forEach(({ id }) => this.currentUser.blockIds.add(id)) } if (blocks.length) { this.currentUser.blockIdsMaxId = last(blocks).id } this.addNewUsers(result) return blocks }) }, fetchDomainMutes() { return fetchDomainMutes({ credentials: useOAuthStore().token, }).then(({ data: domainMutes }) => { this.currentUser.domainMutes = new Set(domainMutes) return domainMutes }) }, // Actions /// Follow async followUser(id) { // Don't spam follow requests if we are already polling if (this.followPollers.has(id)) return const followFunc = () => followUser({ id, credentials: useOAuthStore().token, }).then((result) => this.updateUserRelationships(result)) const checker = async (func = () => this.fetchUserRelationship(id)) => { await func() // Either fetch relationships or follow request const relationship = this.relationships.get(id) return ( relationship.following || (relationship.locked && relationship.requested) ) } const immediate = await checker(followFunc) if (immediate) return // If follow goes through immediately don'd to looping const loop = async () => { const attempts = this.followPollersAttempts.get(id) const result = await checker() if (result || attempts === 1) { this.followPollersAttempts.delete(id) this.followPollers.get(id).stop() this.followPollers.delete(id) } else { this.followPollersAttempts.set(id, attempts - 1) } } this.followPollersAttempts.set(id, 3) this.followPollers.set(id, promiseInterval(loop, 500)) }, async unfollowUser(id) { const result = await unfollowUser({ id, credentials: useOAuthStore().token, }) return this.updateUserRelationships(result) }, /// Subscribe subscribeUser(id) { return followUser({ id, notify: true, credentials: useOAuthStore().token, }).then((result) => this.updateUserRelationships(result)) }, unsubscribeUser(id) { return followUser({ id, notify: false, credentials: useOAuthStore().token, }).then((result) => this.updateUserRelationships(result)) }, /// User Note editUserNote(id, comment) { return editUserNote({ id, comment }).then((result) => this.updateUserRelationships(result), ) }, /// Hide reblogs hideReblogs(id) { return followUser({ id, reblogs: false, credentials: useOAuthStore().token, }).then((result) => this.updateUserRelationships(result)) }, showReblogs(id) { return followUser({ id, reblogs: true, credentials: useOAuthStore().token, }).then((result) => this.updateUserRelationships(result)) }, /// Remove follower removeUserFromFollowers(id) { return removeUserFromFollowers({ id }).then((result) => this.updateUserRelationships(result), ) }, /// Mute muteUser(id, expiresIn = 0) { const predictedRelationship = this.relationships[id] || { id } predictedRelationship.muting = true this.updateUserRelationships({ optimism: true, data: [predictedRelationship], }) return muteUser({ id, expiresIn, credentials: useOAuthStore().token, }).then((result) => { this.updateUserRelationships(result) }) }, muteUsers(data = []) { return Promise.all(data.map((d) => this.muteUser(d))) }, unmuteUser(id) { const predictedRelationship = this.relationships[id] || { id } predictedRelationship.muting = false this.updateUserRelationships({ optimism: true, data: [predictedRelationship], }) return unmuteUser({ id }).then((result) => this.updateUserRelationships(result), ) }, unmuteUsers(ids = []) { return Promise.all(ids.map((d) => this.unmuteUser(d))) }, /// Block blockUser(id, expiresIn = 0) { const predictedRelationship = this.relationships[id] || { id } this.updateUserRelationships({ optimism: true, data: [predictedRelationship], }) return blockUser({ id, expiresIn }).then((result) => { this.updateUserRelationships(result) const ids = useStatusesStore().wipeUserStatuses(id) useTimelinesStore().wipeStatuses(ids) useNotificationsStore().wipeStatuses(ids) }) }, blockUsers(data = []) { return Promise.all(data.map((d) => this.blockUser(d))) }, unblockUser(id) { return unblockUser({ id }).then((result) => { this.updateUserRelationships(result) }) }, unblockUsers(data = []) { return Promise.all(data.map((d) => this.unblockUser(d))) }, /// Domain Mute muteDomain(domain) { return muteDomain({ domain, credentials: useOAuthStore().token, }).then(() => this.currentUser.domainMutes.add(domain)) }, unmuteDomain(domain) { return unmuteDomain({ domain, credentials: useOAuthStore().token, }).then(() => this.currentUser.domainMutes.delete(domain)) }, muteDomains(domains = []) { return Promise.all(domains.map((domain) => this.muteDomain(domain))) }, unmuteDomains(domain = []) { return Promise.all(domain.map((domain) => this.unmuteDomain(domain))) }, // Login/Logout async loginUser(accessToken) { const store = window.vuex const dispatch = store?.dispatch ?? (() => { /* no-op */ }) // for tests this.loggingIn = true try { const { data: user, ...rest } = await verifyCredentials({ credentials: useOAuthStore().token, }) user.blockIds = new Set() user.muteIds = new Set() user.domainMutes = new Set() useTimelinesStore().deactivateAll() useStatusesStore().resetStatuses() this.users = new Map() this.usersByName = new Map() this.usersByURL = new Map() this.relationships = new Map() this.currentUser = user this.addNewUsers({ data: user, ...rest }) useInterfaceStore().onLogin() useSyncConfigStore() .initSyncConfig(user) .then(() => { useInterfaceStore() .applyTheme() .catch((e) => { console.error('Error setting theme', e) }) }) useUserHighlightStore().initUserHighlight(user) useEmojiStore().fetchEmoji() // Do server-side storage migrations // Debug snippet to clean up storage and reset migrations /* // Reset wordfilter Object.keys( useSyncConfigStore().prefsStorage.simple.muteFilters ).forEach(key => { useSyncConfigStore().unsetSimplePrefAndSave({ path: 'muteFilters.' + key, value: null }) }) // Reset flag to 0 to re-run migrations useSyncConfigStore().setFlag({ flag: 'configMigration', value: 0 }) /**/ if (user.token) { // Shoutbox dispatch('setWsToken', user.token) dispatch('initializeSocket') } // DMs and Home useTimelinesStore().activatePersistents() useNotificationsStore().activate() if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) { // Start fetching chats useChatsStore().startFetching() } useListsStore().startFetching() useBookmarkFoldersStore().startFetching() if (user.locked) { dispatch('startFetchingFollowRequests') } if (useMergedConfigStore().mergedConfig.useStreamingApi) { useStreamingStore().initSocket(true) } // Start fetching things that don't need to block the UI useAnnouncementsStore().startFetching() this.fetchMutes() dispatch('loadDrafts') } catch (error) { console.error(error) // Authentication failed this.loggingIn = false // remove authentication token on client/authentication errors if ([400, 401, 403, 422].includes(error.statusCode)) { useOAuthStore().clearToken() } if (error.tatusCode === 401) { throw new Error('Wrong username or password', error) } else { throw new Error('An error occurred, please try again', error) } } finally { this.loggingIn = false } }, logout() { const store = window.vuex const oauth = useOAuthStore() // Pause fetching useNotificationsStore().pause() useTimelinesStore().pauseAll() // Pause-less stores useAnnouncementsStore().stopFetching() useListsStore().stopFetching() useBookmarkFoldersStore().stopFetching() store?.dispatch('stopFetchingFollowRequests') // NOTE: No need to verify the app still exists, because if it doesn't, // the token will be invalid too return oauth .ensureApp() .then((app) => { const params = { app, instance: useInstanceStore().server, token: oauth.userToken, } return revokeToken(params) }) .then(() => { oauth.clearToken() this.currentUser = null useNotificationsStore().deactivate() // Full reset on logout success useTimelinesStore().deactivateAll() useStatusesStore().resetStatuses() useChatsStore().stopFetching() useChatsStore().resetChats() this.users = new Map() this.usersByName = new Map() this.usersByURL = new Map() this.relationships = new Map() // Socket is most likely already closed by server if ( useMergedConfigStore().mergedConfig.useStreamingApi && useStreamingStore().state !== WSConnectionStatus.CLOSED ) { useStreamingStore().stopSocket() } Cookies.remove('__Host-pleroma_key', { path: '/' }) useInterfaceStore().onLogout() }) .catch((e) => { useInterfaceStore().pushGlobalNotice({ messageKey: 'user.logout_failure', messageArgs: { error: e, }, level: 'error', }) console.error('Logout error!', e) useAnnouncementsStore().startFetching() useListsStore().startFetching() useBookmarkFoldersStore().startFetching() useChatsStore().startFetching() store?.dispatch('startFetchingFollowRequests') }) .finally(() => { useNotificationsStore().resume() useTimelinesStore().resumeAll() }) }, }, })