From 975b33858635052972ad1d1e01df9ee2521e9f13 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Tue, 18 Aug 2026 03:20:09 +0300 Subject: [PATCH] users store unit tests --- src/api/helpers.js | 2 +- src/api/public.js | 4 +- src/api/user.js | 27 +- src/components/follow_button/follow_button.js | 29 +- src/components/user_profile/user_profile.js | 3 +- src/lib/push_notifications_plugin.js | 5 +- .../follow_manipulate/follow_manipulate.js | 67 - src/stores/admin_settings.js | 22 +- src/stores/announcements.js | 4 +- src/stores/chats.js | 4 +- src/stores/interface.js | 53 + src/stores/statuses.js | 9 + src/stores/timelines.js | 32 +- src/stores/users.js | 933 +++++++------ test/unit/specs/modules/users.spec.js | 120 -- test/unit/specs/stores/users.spec.js | 1217 +++++++++++++++++ 16 files changed, 1804 insertions(+), 727 deletions(-) delete mode 100644 src/services/follow_manipulate/follow_manipulate.js delete mode 100644 test/unit/specs/modules/users.spec.js create mode 100644 test/unit/specs/stores/users.spec.js diff --git a/src/api/helpers.js b/src/api/helpers.js index 8913cb078..f23960ed9 100644 --- a/src/api/helpers.js +++ b/src/api/helpers.js @@ -63,7 +63,7 @@ export const paramsString = (params = {}) => { } export const promisedRequest = async ({ - method, + method = 'GET', url, payload, formData, diff --git a/src/api/public.js b/src/api/public.js index 5684298e4..740a55ca2 100644 --- a/src/api/public.js +++ b/src/api/public.js @@ -13,12 +13,12 @@ const MASTODON_REGISTRATION_URL = '/api/v1/accounts' const MASTODON_PASSWORD_RESET_URL = ({ email }) => `/auth/password${paramsString({ email })}` -const MASTODON_FOLLOWING_URL = ( +export const MASTODON_FOLLOWING_URL = ( id, { minId, maxId, sinceId, limit, withRelationships }, ) => `/api/v1/accounts/${id}/following${paramsString({ minId, maxId, sinceId, limit, withRelationships })}` -const MASTODON_FOLLOWERS_URL = ( +export const MASTODON_FOLLOWERS_URL = ( id, { minId, maxId, sinceId, limit, withRelationships }, ) => diff --git a/src/api/user.js b/src/api/user.js index 4b63808a0..0802d8c8b 100644 --- a/src/api/user.js +++ b/src/api/user.js @@ -34,8 +34,8 @@ const MASTODON_UNFAVORITE_URL = (id) => `/api/v1/statuses/${id}/unfavourite` const MASTODON_RETWEET_URL = (id) => `/api/v1/statuses/${id}/reblog` const MASTODON_UNRETWEET_URL = (id) => `/api/v1/statuses/${id}/unreblog` const MASTODON_DELETE_URL = (id) => `/api/v1/statuses/${id}` -const MASTODON_FOLLOW_URL = (id) => `/api/v1/accounts/${id}/follow` -const MASTODON_UNFOLLOW_URL = (id) => `/api/v1/accounts/${id}/unfollow` +export const MASTODON_FOLLOW_URL = (id) => `/api/v1/accounts/${id}/follow` +export const MASTODON_UNFOLLOW_URL = (id) => `/api/v1/accounts/${id}/unfollow` const MASTODON_FOLLOW_REQUESTS_URL = '/api/v1/follow_requests' const MASTODON_APPROVE_USER_URL = (id) => @@ -43,30 +43,31 @@ const MASTODON_APPROVE_USER_URL = (id) => const MASTODON_DENY_USER_URL = (id) => `/api/v1/follow_requests/${id}/reject` const MASTODON_USER_RELATIONSHIPS_URL = ({ id, withSuspended }) => `/api/v1/accounts/relationships/${paramsString({ id, withSuspended })}` -const MASTODON_USER_IN_LISTS = (id) => `/api/v1/accounts/${id}/lists` +export const MASTODON_USER_IN_LISTS = (id) => `/api/v1/accounts/${id}/lists` export const MASTODON_LIST_URL = (id = '') => `/api/v1/lists/${id}` export const MASTODON_LIST_ACCOUNTS_URL = (id) => `/api/v1/lists/${id}/accounts` -const MASTODON_USER_BLOCKS_URL = ({ +export const MASTODON_USER_BLOCKS_URL = ({ maxId, sinceId, limit, withRelationships, }) => `/api/v1/blocks/${paramsString({ maxId, sinceId, limit, withRelationships })}` -const MASTODON_USER_MUTES_URL = ({ +export const MASTODON_USER_MUTES_URL = ({ maxId, sinceId, limit, withRelationships, }) => `/api/v1/mutes/${paramsString({ maxId, sinceId, limit, withRelationships })}` -const MASTODON_BLOCK_USER_URL = (id) => `/api/v1/accounts/${id}/block` -const MASTODON_UNBLOCK_USER_URL = (id) => `/api/v1/accounts/${id}/unblock` -const MASTODON_MUTE_USER_URL = (id) => `/api/v1/accounts/${id}/mute` -const MASTODON_UNMUTE_USER_URL = (id) => `/api/v1/accounts/${id}/unmute` -const MASTODON_REMOVE_USER_FROM_FOLLOWERS = (id) => +export const MASTODON_BLOCK_USER_URL = (id) => `/api/v1/accounts/${id}/block` +export const MASTODON_UNBLOCK_USER_URL = (id) => + `/api/v1/accounts/${id}/unblock` +export const MASTODON_MUTE_USER_URL = (id) => `/api/v1/accounts/${id}/mute` +export const MASTODON_UNMUTE_USER_URL = (id) => `/api/v1/accounts/${id}/unmute` +export const MASTODON_REMOVE_USER_FROM_FOLLOWERS_URL = (id) => `/api/v1/accounts/${id}/remove_from_followers` -const MASTODON_USER_NOTE_URL = (id) => `/api/v1/accounts/${id}/note` +export const MASTODON_USER_NOTE_URL = (id) => `/api/v1/accounts/${id}/note` const MASTODON_BOOKMARK_STATUS_URL = (id) => `/api/v1/statuses/${id}/bookmark` const MASTODON_UNBOOKMARK_STATUS_URL = (id) => `/api/v1/statuses/${id}/unbookmark` @@ -79,7 +80,7 @@ const MASTODON_PIN_OWN_STATUS = (id) => `/api/v1/statuses/${id}/pin` const MASTODON_UNPIN_OWN_STATUS = (id) => `/api/v1/statuses/${id}/unpin` const MASTODON_MUTE_CONVERSATION = (id) => `/api/v1/statuses/${id}/mute` const MASTODON_UNMUTE_CONVERSATION = (id) => `/api/v1/statuses/${id}/unmute` -const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks' +export const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks' const MASTODON_ANNOUNCEMENTS_URL = '/api/v1/announcements' const MASTODON_ANNOUNCEMENTS_DISMISS_URL = (id) => `/api/v1/announcements/${id}/dismiss` @@ -656,7 +657,7 @@ export const fetchUserInLists = ({ id, credentials }) => export const removeUserFromFollowers = ({ id, credentials }) => promisedRequest({ - url: MASTODON_REMOVE_USER_FROM_FOLLOWERS(id), + url: MASTODON_REMOVE_USER_FROM_FOLLOWERS_URL(id), credentials, method: 'POST', }) diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js index 3fecc025f..e4d12ec63 100644 --- a/src/components/follow_button/follow_button.js +++ b/src/components/follow_button/follow_button.js @@ -1,11 +1,8 @@ import { defineAsyncComponent } from 'vue' -import { - requestFollow, - requestUnfollow, -} from '../../services/follow_manipulate/follow_manipulate' - import { useMergedConfigStore } from 'src/stores/merged_config.js' +import { useUsersStore } from 'src/stores/users.js' + export default { props: ['relationship', 'user', 'labelFollowing', 'buttonClass'], components: { @@ -64,9 +61,11 @@ export default { }, follow() { this.inProgress = true - requestFollow(this.relationship.id, this.$store).then(() => { - this.inProgress = false - }) + useUsersStore() + .followUser(this.relationship.id) + .then(() => { + this.inProgress = false + }) }, unfollow() { if (this.shouldConfirmUnfollow) { @@ -78,13 +77,15 @@ export default { doUnfollow() { const store = this.$store this.inProgress = true - requestUnfollow(this.relationship.id, store).then(() => { - this.inProgress = false - store.commit('removeStatus', { - timeline: 'friends', - userId: this.relationship.id, + useUsersStore() + .unfollowUser(this.relationship.id) + .then(() => { + this.inProgress = false + store.commit('removeStatus', { + timeline: 'friends', + userId: this.relationship.id, + }) }) - }) this.hideConfirmUnfollow() }, diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 109c0bbbc..774ecd479 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -39,8 +39,7 @@ const UserProfile = { }, unmounted() { useInterfaceStore().setForeignProfileBackground(null) - this.$store.dispatch('clearFollowers', this.userId) - this.$store.dispatch('clearFriends', this.userId) + useUsersStore().clearFollowLists(this.userId) }, computed: { favorites() { diff --git a/src/lib/push_notifications_plugin.js b/src/lib/push_notifications_plugin.js index ec47d702c..4798068cf 100644 --- a/src/lib/push_notifications_plugin.js +++ b/src/lib/push_notifications_plugin.js @@ -7,7 +7,6 @@ export const piniaPushNotificationsPlugin = ({ store }) => { const validActions = { sync_config: new Set(['setPreference']), interface: new Set(['setNotificationPermission', 'setLoginStatus']), - user: new Set(['setCurrentUser', 'clearCurrentUser']), } if (!validActions[store.$id]) return // Not applicable to the store @@ -56,9 +55,9 @@ export const piniaPushNotificationsPlugin = ({ store }) => { } if (permissionGranted && enabled && user) { - return useUsersStore().registerPushNotifications() + return useInterfaceStore().registerPushNotifications() } else { - return useUsersStore().unregisterPushNotifications() + return useInterfaceStore().unregisterPushNotifications() } }) } diff --git a/src/services/follow_manipulate/follow_manipulate.js b/src/services/follow_manipulate/follow_manipulate.js deleted file mode 100644 index 759ba67d9..000000000 --- a/src/services/follow_manipulate/follow_manipulate.js +++ /dev/null @@ -1,67 +0,0 @@ -import { useOAuthStore } from 'src/stores/oauth.js' - -import { - fetchUserRelationship, - followUser, - unfollowUser, -} from 'src/api/user.js' - -const fetchRelationship = (attempt, userId, store) => - new Promise((resolve, reject) => { - setTimeout(() => { - fetchUserRelationship({ - id: userId, - credentials: useOAuthStore().token, - }) - .then(({ data: relationship }) => { - store.commit('updateUserRelationship', [relationship]) - return relationship - }) - .then((relationship) => - resolve([ - relationship.following, - relationship.requested, - relationship.locked, - attempt, - ]), - ) - .catch((e) => reject(e)) - }, 500) - }).then(([following, sent, locked, attempt]) => { - if (!following && !(locked && sent) && attempt <= 3) { - // If we BE reports that we still not following that user - retry, - // increment attempts by one - fetchRelationship(++attempt, userId, store) - } - }) - -export const requestFollow = async (userId, store) => { - const { data: updated } = await followUser({ - id: userId, - credentials: useOAuthStore().token, - }) - - store.commit('updateUserRelationship', [updated]) - - if (updated.following || (updated.locked && updated.requested)) { - // If we get result immediately or the account is locked, just stop. - return - } - - // But usually we don't get result immediately, so we ask server - // for updated user profile to confirm if we are following them - // Sometimes it takes several tries. Sometimes we end up not following - // user anyway, probably because they locked themselves and we - // don't know that yet. - // Recursive Promise, it will call itself up to 3 times. - return await fetchRelationship(1, updated, store) -} - -export const requestUnfollow = async (userId, store) => { - const { data: updated } = await unfollowUser({ - id: userId, - credentials: useOAuthStore().token, - }) - - return await store.commit('updateUserRelationship', [updated]) -} diff --git a/src/stores/admin_settings.js b/src/stores/admin_settings.js index c00b7a29b..70850e5f6 100644 --- a/src/stores/admin_settings.js +++ b/src/stores/admin_settings.js @@ -3,6 +3,7 @@ import { defineStore } from 'pinia' import { useOAuthStore } from 'src/stores/oauth.js' import { useStatusesStore } from 'src/stores/statuses.js' +import { useUsersStore } from 'src/stores/users.js' import { addNewEmojiFile, @@ -399,12 +400,10 @@ export const useAdminSettingsStore = defineStore('adminSettings', { return { items: await Promise.all( - users.map( - async (userAdminData) => - await window.vuex.dispatch('updateUserAdminData', { - userAdminData, - }), - ), + users.map((user) => { + useUsersStore().updateUserAdminData(user.id, user) + return useUsersStore().findUser(user.id) + }), ), count, } @@ -418,7 +417,8 @@ export const useAdminSettingsStore = defineStore('adminSettings', { screen_name, }) - window.vuex.commit('updateUserAdminData', { user: result.data }) + const { data } = result + useUsersStore().updateUserAdminData(data.id, data) }, async deleteUsers({ users }) { const screen_names = users.map((u) => u.screen_name) @@ -491,7 +491,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', { }) users.forEach((user) => { - window.vuex.commit('updateRight', { user, right, value }) + useUsersStore().updateRight(user.id, right, value) }) }, async setUsersActivationStatus({ users, value }) { @@ -505,7 +505,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', { }) resultUsers.data.forEach((user) => { - window.vuex.commit('updateUserAdminData', { user }) + useUsersStore().updateUserAdminData(user.id, user) }) }, async setUsersSuggestionStatus({ users, value }) { @@ -519,7 +519,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', { }) resultUsers.data.forEach((user) => { - window.vuex.commit('updateUserAdminData', { user }) + useUsersStore().updateUserAdminData(user.id, user) }) }, async setUsersConfirmationStatus({ users }) { @@ -545,7 +545,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', { }) resultUsers.data.forEach((user) => { - window.vuex.commit('updateUserAdminData', { user }) + useUsersStore().updateUserAdminData(user.id, user) }) }, reloadEmoji() { diff --git a/src/stores/announcements.js b/src/stores/announcements.js index 5110bd565..c78e79853 100644 --- a/src/stores/announcements.js +++ b/src/stores/announcements.js @@ -96,7 +96,7 @@ export const useAnnouncementsStore = defineStore('announcements', { this.announcements[index].read = true }) }, - startFetchingAnnouncements() { + startFetching() { if (this.fetchAnnouncementsTimer) { return } @@ -109,7 +109,7 @@ export const useAnnouncementsStore = defineStore('announcements', { return this.fetchAnnouncements() }, - stopFetchingAnnouncements() { + stopFetching() { const interval = this.fetchAnnouncementsTimer this.fetchAnnouncementsTimer = undefined clearInterval(interval) diff --git a/src/stores/chats.js b/src/stores/chats.js index f12a33db1..9da01c76b 100644 --- a/src/stores/chats.js +++ b/src/stores/chats.js @@ -43,11 +43,11 @@ export const useChatsStore = defineStore('chats', { useStreamingStore().addSubscriber(socket) }, - startFetchingChats() { + startFetching() { const fetcher = () => this.fetchChats() this.setChatListFetcher(() => promiseInterval(fetcher, 5000)) }, - stopFetchingChats() { + stopFetching() { this.setChatListFetcher(null) }, async fetchChats() { diff --git a/src/stores/interface.js b/src/stores/interface.js index fd74e579f..0eba6f158 100644 --- a/src/stores/interface.js +++ b/src/stores/interface.js @@ -6,20 +6,38 @@ import { tryLoadCache, } from '../services/style_setter/style_setter.js' import { deserialize } from '../services/theme_data/iss_deserializer.js' +import { + windowHeight, + windowWidth, +} from '../services/window_utils/window_utils' import { useInstanceStore } from 'src/stores/instance.js' import { useMergedConfigStore } from 'src/stores/merged_config.js' +import { useOAuthStore } from 'src/stores/oauth.js' import { useStreamingStore } from 'src/stores/streaming.js' import { useSyncConfigStore } from 'src/stores/sync_config.js' import { useUsersStore } from 'src/stores/users.js' import { WSConnectionStatus } from 'src/api/websocket.js' +import { + registerPushNotifications, + unregisterPushNotifications, +} from 'src/services/sw/sw.js' import { CURRENT_VERSION, generatePreset, } from 'src/services/theme_data/theme_data.service.js' import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js' +const getNotificationPermission = async () => { + const Notification = window.Notification + + if (!Notification) return null + if (Notification.permission === 'default') + return Notification.requestPermission() + return Notification.permission +} + const GENERIC_FONT_NAMES = new Set([ 'serif', 'sans-serif', @@ -85,6 +103,19 @@ export const useInterfaceStore = defineStore('interface', { useStreamingStore().addSubscriber(socket) }, + onLogin() { + getNotificationPermission().then((permission) => + useInterfaceStore().setNotificationPermission(permission), + ) + + this.setLayoutWidth(windowWidth()) + this.setLayoutHeight(windowHeight()) + }, + onLogout() { + this.setLastTimeline('public-timeline') + this.setLayoutWidth(windowWidth()) + this.setLayoutHeight(windowHeight()) + }, onStreamConnect() { if (useStreamingStore().state !== WSConnectionStatus.STARTING_INITIAL) { this.pushGlobalNotice({ @@ -805,6 +836,28 @@ export const useInterfaceStore = defineStore('interface', { window.splashError(e) } }, + + // Push notifications + registerPushNotifications() { + const token = useOAuthStore().token + const vapidPublicKey = useInstanceStore().vapidPublicKey + const isEnabled = useMergedConfigStore().mergedConfig.webPushNotifications + const notificationVisibility = + useMergedConfigStore().mergedConfig.notificationVisibility + + registerPushNotifications( + isEnabled, + vapidPublicKey, + token, + notificationVisibility, + ) + }, + + unregisterPushNotifications() { + const token = this.currentUser.credentials + + unregisterPushNotifications(token) + }, }, }) diff --git a/src/stores/statuses.js b/src/stores/statuses.js index 935aca224..9605556f1 100644 --- a/src/stores/statuses.js +++ b/src/stores/statuses.js @@ -592,6 +592,15 @@ export const useStatusesStore = defineStore('statuses', { }) }, + // For when blocking a user + wipeUserStatuses(userId) { + this.allStatuses.values().forEach((status) => { + if (status.user.id === userId) { + this.allStatuses.delete(status.id) + } + }) + }, + // Search search({ q, resolve, limit, offset, following, type }) { return search2({ diff --git a/src/stores/timelines.js b/src/stores/timelines.js index ce67bf5b5..c11795981 100644 --- a/src/stores/timelines.js +++ b/src/stores/timelines.js @@ -317,22 +317,24 @@ export const useTimelinesStore = defineStore('timelines', { }, // Misc - removeUserStatuses({ timelineName, userId }) { - const timeline = this.timelines[timelineName] + wipeUserStatuses(userId) { + TIMELINES.forEach((timelineName) => { + const timeline = this.timelines[timelineName] - timeline.statuses - .values() - .filter(({ user }) => user.id === userId) - .forEach(({ id }) => { - timeline.statuses.delete(id) - timeline.visibleStatusesIds.delete(id) - }) - timeline.minVisibleId = - timeline.visibleStatusesIds.size > 0 - ? last(timeline.visibleStatusesIds).id - : 0 - timeline.maxId = - timeline.statuses.length > 0 ? first(timeline.statuses).id : 0 + timeline.statuses + .values() + .filter(({ user }) => user.id === userId) + .forEach(({ id }) => { + timeline.statuses.delete(id) + timeline.visibleStatusesIds.delete(id) + }) + timeline.minVisibleId = + timeline.visibleStatusesIds.size > 0 + ? last(timeline.visibleStatusesIds).id + : 0 + timeline.maxId = + timeline.statuses.length > 0 ? first(timeline.statuses).id : 0 + }) }, }, }) diff --git a/src/stores/users.js b/src/stores/users.js index 166b98037..dc6193ab1 100644 --- a/src/stores/users.js +++ b/src/stores/users.js @@ -1,16 +1,7 @@ import Cookies from 'js-cookie' -import { last, map } from 'lodash' +import { last } from 'lodash' import { defineStore } from 'pinia' -import { - registerPushNotifications, - unregisterPushNotifications, -} from '../services/sw/sw.js' -import { - windowHeight, - windowWidth, -} from '../services/window_utils/window_utils' - import { useAnnouncementsStore } from 'src/stores/announcements.js' import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js' import { useChatsStore } from 'src/stores/chats.js' @@ -50,18 +41,11 @@ import { muteUser, removeUserFromFollowers, unblockUser, + unfollowUser, unmuteDomain, unmuteUser, } from 'src/api/user.js' - -const getNotificationPermission = async () => { - const Notification = window.Notification - - if (!Notification) return null - if (Notification.permission === 'default') - return Notification.requestPermission() - return Notification.permission -} +import { promiseInterval } from 'src/services/promise_interval/promise_interval.js' export const useUsersStore = defineStore('users', { state: () => ({ @@ -79,6 +63,8 @@ export const useUsersStore = defineStore('users', { timestamps: new WeakMap(), fetchesIds: new Map(), fetchesNames: new Map(), + followPollers: new Map(), + followPollersAttempts: new Map(), }), getters: { loggedIn: (state) => !!state.currentUser, @@ -97,59 +83,7 @@ export const useUsersStore = defineStore('users', { }, }, actions: { - tagUser({ user: { id }, tag }) { - const user = this.users.get(id) - user.tags.add(tag) - }, - untagUser({ user: { id }, tag }) { - const user = this.users.get(id) - user.tags.delete(tag) - }, - updateRight({ user: { id }, right, value }) { - const user = this.users.get(id) - const newRights = user.rights - newRights[right] = value - user.rights = newRights - }, - async updateUserAdminData({ user }) { - const localUser = await this.fetchUserIfMissing({ id: user.id }) - - localUser.adminData = user - localUser.deactivated = !user.is_active - localUser.tags = new Set(user.tags) - }, - setCurrentUser(user) { - this.lastLoginName = user.screen_name - this.currentUser = user - }, - clearCurrentUser() { - this.currentUser = null - this.lastLoginName = null - }, - saveFriendIds(id, friendIds) { - const user = this.users.get(id) - const list = this.relationshipsLists.friends.get(user) - friendIds.forEach((id) => list.add(id)) - }, - saveFollowerIds(id, followerIds) { - const user = this.users.get(id) - const list = this.relationshipsLists.followers.get(user) - followerIds.forEach((id) => list.add(id)) - }, - // Because frontend doesn't have a reason to keep these stuff in memory - // outside of viewing someones user profile. - clearFriends(userId) { - const user = this.users.get(userId) - if (user) { - user.friendIds = new Set() - } - }, - clearFollowers(userId) { - const user = this.users.get(userId) - if (user) { - user.followerIds = new Set() - } - }, + // Main updates addNewUsers(response) { const { data, timestamp } = response const users = Array.isArray(data) ? data : [data] @@ -157,13 +91,28 @@ export const useUsersStore = defineStore('users', { 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 relationship = this.updateUserRelationships({ - timestamp, - data: { id: user.id, ...(user.relationship ?? {}) }, - })[0] + 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 @@ -181,7 +130,7 @@ export const useUsersStore = defineStore('users', { } // Relying on object reactivity to avoid mutating the Map - reactive.relationship = relationship + reactive.relationship = relationship ?? reactive.relationship Object.entries(newUser).forEach(([k, v]) => { reactive[k] = v @@ -211,7 +160,7 @@ export const useUsersStore = defineStore('users', { const oldTimestamp = this.timestamps.get(existing) // implicit: if oldTimestamp is undefined this will still be false - if (!optimism && oldTimestamp > timestamp) existing + if (!optimism && oldTimestamp > timestamp) return existing // Initializing reactivity if (!this.relationships.has(id)) this.relationships.set(id, existing) @@ -223,7 +172,7 @@ export const useUsersStore = defineStore('users', { }) if (timestamp) { - this.timestamps.set(reactive) + this.timestamps.set(reactive, timestamp) } // Updating user property if there is such a user @@ -231,64 +180,58 @@ export const useUsersStore = defineStore('users', { 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 }) }, - updateUserInLists({ id, inLists }) { - this.users.get(id).inLists = inLists - }, - saveBlockIds(blockIds) { - this.currentUser.blockIds = blockIds - }, - addBlockId(blockId) { - this.currentUser.blockIds.add(blockId) - }, - setBlockIdsMaxId(blockIdsMaxId) { - this.currentUser.blockIdsMaxId = blockIdsMaxId - }, - saveMuteIds(muteIds) { - this.currentUser.muteIds = muteIds - }, - setMuteIdsMaxId(muteIdsMaxId) { - this.currentUser.muteIdsMaxId = muteIdsMaxId - }, - addMuteId(muteId) { - this.currentUser.muteIds.add(muteId) - }, - saveDomainMutes(domainMutes) { - this.currentUser.domainMutes = domainMutes - }, - addDomainMute(domain) { - if (this.currentUser.domainMutes.includes(domain)) { - this.currentUser.domainMutes.push(domain) - } - }, - removeDomainMute(domain) { - const index = this.currentUser.domainMutes.indexOf(domain) - if (index !== -1) { - this.currentUser.domainMutes.splice(index, 1) - } - }, - setPinnedToUser(status) { - const user = this.users.get(status.user.id) - user.pinnedStatusIds = user.pinnedStatusIds || [] - const index = user.pinnedStatusIds.indexOf(status.id) - if (status.pinned && index === -1) { - user.pinnedStatusIds.push(status.id) - } else if (!status.pinned && index !== -1) { - user.pinnedStatusIds.splice(index, 1) + // 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()) } }, - setUserForStatus(status) { - status.user = this.users.get(status.user.id) - }, - setUserForNotification(notification) { - if (notification.type !== 'follow') { - notification.action.user = this.users.get(notification.action.user.id) - } - notification.from_profile = this.users.get(notification.from_profile.id) - }, + + // Fetches async fetchUserIfMissing({ id, name }) { let findFunc let fetchFunc @@ -312,36 +255,32 @@ export const useUsersStore = defineStore('users', { // Search in cache const user = findFunc(identifier) + if (user) return user // not found => fetch - if (!user) { - let promise + 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?.data) { - const { id, screen_name } = result.data - - // Save promise for future use - this.fetchesIds.set(id, promise) - this.fetchesNames.set(screen_name, promise) - this.addNewUsers(result) - return this.users.get(id) - } else { - return null - } + // Did we already search for this user? + if (map.has(identifier)) { + // if so, reuse the promise + promise = map.get(identifier) } else { - return user + // 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) { @@ -383,181 +322,10 @@ export const useUsersStore = defineStore('users', { } }, fetchUserRelationship(id) { - if (this.currentUser) { - fetchUserRelationship({ - id, - credentials: useOAuthStore().token, - }).then((result) => this.updateUserRelationships(result)) - } - }, - fetchUserInLists(id) { - if (this.currentUser) { - fetchUserInLists({ - id, - credentials: useOAuthStore().token, - }).then(({ data: inLists }) => this.updateUserInLists({ id, inLists })) - } - }, - 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.saveBlockIds(new Set(blocks.map(({ id }) => id))) - } else { - blocks.forEach(({ id }) => this.addBlockId(id)) - } - if (blocks.length) { - this.setBlockIdsMaxId(last(blocks).id) - } - this.addNewUsers(result) - return blocks - }) - }, - blockUser(id, expiresIn = 0) { - const store = window.vuex - - const predictedRelationship = this.relationships[id] || { id } - this.updateUserRelationships({ - optimism: true, - data: [predictedRelationship], - }) - this.addBlockId(id) - - return blockUser({ id, expiresIn }).then((result) => { - this.updateUserRelationships(result) - this.addBlockId(id) - - store.commit('removeStatus', { timeline: 'friends', userId: id }) - store.commit('removeStatus', { timeline: 'public', userId: id }) - store.commit('removeStatus', { - timeline: 'publicAndExternal', - userId: id, - }) - }) - }, - unblockUser(id) { - return unblockUser({ id }).then((data) => - this.updateUserRelationships(data), - ) - }, - removeUserFromFollowers(id) { - return removeUserFromFollowers({ id }).then((data) => - this.updateUserRelationships(data), - ) - }, - blockUsers(data = []) { - return Promise.all(data.map((d) => this.blockUser(d))) - }, - unblockUsers(data = []) { - return Promise.all(data.map((d) => unblockUser(d))) - }, - editUserNote({ id, comment }) { - return editUserNote({ id, comment }).then(({ data }) => - this.updateUserRelationships(data), - ) - }, - 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.saveMuteIds(new Set(mutes.map(({ id }) => id))) - } else { - mutes.forEach(({ id }) => this.addMuteId(id)) - } - if (mutes.length) { - this.setMuteIdsMaxId(last(mutes).id) - } - this.addNewUsers(result) - return mutes - }) - }, - muteUser(id, expiresIn = 0) { - const predictedRelationship = this.relationships[id] || { id } - predictedRelationship.muting = true - this.updateUserRelationships({ - optimism: true, - data: [predictedRelationship], - }) - this.addMuteId(id) - - return muteUser({ + return fetchUserRelationship({ id, - expiresIn, credentials: useOAuthStore().token, - }).then(({ data }) => { - this.updateUserRelationships(data) - this.addMuteId(id) - }) - }, - unmuteUser(id) { - const predictedRelationship = this.relationships[id] || { id } - predictedRelationship.muting = false - this.updateUserRelationships({ - optimism: true, - data: [predictedRelationship], - }) - - return unmuteUser({ id }).then(({ data }) => - this.updateUserRelationships(data), - ) - }, - hideReblogs(id) { - return followUser({ - id, - reblogs: false, - credentials: useOAuthStore().token, - }).then(({ data }) => this.updateUserRelationships(data)) - }, - showReblogs(id) { - return followUser({ - id, - reblogs: true, - credentials: useOAuthStore().token, - }).then(({ data }) => this.updateUserRelationships(data)) - }, - muteUsers(data = []) { - return Promise.all(data.map((d) => this.muteUser(d))) - }, - unmuteUsers(ids = []) { - return Promise.all(ids.map((d) => this.unmuteUser(d))) - }, - fetchDomainMutes() { - return fetchDomainMutes({ - credentials: useOAuthStore().token, - }).then(({ data: domainMutes }) => { - this.saveDomainMutes(domainMutes) - return domainMutes - }) - }, - muteDomain(domain) { - return muteDomain({ - domain, - credentials: useOAuthStore().token, - }).then(() => this.addDomainMute(domain)) - }, - unmuteDomain(domain) { - return unmuteDomain({ - domain, - credentials: useOAuthStore().token, - }).then(() => this.removeDomainMute(domain)) - }, - muteDomains(domains = []) { - return Promise.all(domains.map((domain) => this.muteDomain(domain))) - }, - unmuteDomains(domain = []) { - return Promise.all(domain.map((domain) => this.unmuteDomain(domain))) + }).then((result) => this.updateUserRelationships(result)) }, fetchFriends(id) { const user = this.users.get(id) @@ -567,8 +335,9 @@ export const useUsersStore = defineStore('users', { maxId, credentials: useOAuthStore().token, }).then((result) => { - this.addNewUsers(result) - this.saveFriendIds(id, map(result.data, 'id')) + const users = this.addNewUsers(result) + const list = this.relationshipsLists.friends.get(user) + users.forEach(({ id }) => list.add(id)) return result.data }) }, @@ -580,54 +349,363 @@ export const useUsersStore = defineStore('users', { maxId, credentials: useOAuthStore().token, }).then((result) => { - this.addNewUsers(result) - this.saveFollowerIds(id, map(result.data, 'id')) + 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(({ data }) => this.updateUserRelationships(data)) + }).then((result) => this.updateUserRelationships(result)) }, unsubscribeUser(id) { return followUser({ id, notify: false, credentials: useOAuthStore().token, - }).then(({ data }) => this.updateUserRelationships(data)) + }).then((result) => this.updateUserRelationships(result)) }, - registerPushNotifications() { - const token = this.currentUser.credentials - const vapidPublicKey = useInstanceStore().vapidPublicKey - const isEnabled = useMergedConfigStore().mergedConfig.webPushNotifications - const notificationVisibility = - useMergedConfigStore().mergedConfig.notificationVisibility - registerPushNotifications( - isEnabled, - vapidPublicKey, - token, - notificationVisibility, + /// User Note + editUserNote(id, comment) { + return editUserNote({ id, comment }).then((result) => + this.updateUserRelationships(result), ) }, - unregisterPushNotifications() { - const token = this.currentUser.credentials - unregisterPushNotifications(token) - }, - searchUsers({ query }) { - return searchUsers({ - query, + /// Hide reblogs + hideReblogs(id) { + return followUser({ + id, + reblogs: false, credentials: useOAuthStore().token, - }).then(({ data: users }) => { - this.addNewUsers(users) - return users + }).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) + + useStatusesStore().wipeUserStatuses(id) + useTimelinesStore().wipeUserStatuses(id) + }) + }, + 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() + + this.lastLoginName = user.screen_name + this.currentUser = user + + this.users = new Map() + this.usersByName = new Map() + this.usersByURL = new Map() + this.relationships = new Map() + + useSyncConfigStore() + .initSyncConfig(user) + .then(() => { + useInterfaceStore() + .applyTheme() + .catch((e) => { + console.error('Error setting theme', e) + }) + }) + + useUserHighlightStore().initUserHighlight(user) + this.addNewUsers({ data: user, ...rest }) + + useEmojiStore().fetchEmoji() + + useInterfaceStore().onLogin() + + // 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 + useNotificationsStore().activate() + useTimelinesStore().activatePersistents() + + 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() @@ -646,133 +724,38 @@ export const useUsersStore = defineStore('users', { return revokeToken(params) }) .then(() => { - this.clearCurrentUser() + this.currentUser = null + this.lastLoginName = null + + this.users = new Map() + this.usersByName = new Map() + this.usersByURL = new Map() + this.relationships = new Map() useNotificationsStore().deactivate() + useAnnouncementsStore().stopFetching() useListsStore().stopFetching() useBookmarkFoldersStore().stopFetching() - store.dispatch('stopFetchingFollowRequests') + store?.dispatch('stopFetchingFollowRequests') useTimelinesStore().deactivateAll() useStatusesStore().resetStatuses() - useNotificationsStore().clearNotifications() + if (useMergedConfigStore().mergedConfig.useStreamingApi) { + useStreamingStore().stopSocket() + } useChatsStore().resetChats() oauth.clearToken() Cookies.remove('__Host-pleroma_key', { path: '/' }) - useInterfaceStore().setLastTimeline('public-timeline') - useInterfaceStore().setLayoutWidth(windowWidth()) - useInterfaceStore().setLayoutHeight(windowHeight()) + useInterfaceStore().onLogout() }) }, - loginUser(accessToken) { - return new Promise((resolve, reject) => { - const store = window.vuex - const dispatch = store.dispatch - this.loggingIn = true - - verifyCredentials({ - credentials: useOAuthStore().token, - }) - .then(({ data: user, ...rest }) => { - // user.credentials = userCredentials - user.credentials = accessToken - user.blockIds = new Set() - user.muteIds = new Set() - user.domainMutes = new Set() - this.setCurrentUser(user) - - useSyncConfigStore() - .initSyncConfig(user) - .then(() => { - useInterfaceStore() - .applyTheme() - .catch((e) => { - console.error('Error setting theme', e) - }) - }) - useUserHighlightStore().initUserHighlight(user) - this.addNewUsers({ data: user, ...rest }) - - useEmojiStore().fetchEmoji() - - getNotificationPermission().then((permission) => - useInterfaceStore().setNotificationPermission(permission), - ) - - // 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 - useNotificationsStore().activate() - useTimelinesStore().activatePersistents() - - if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) { - // Start fetching chats - useChatsStore().startFetchingChats() - } - - 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().startFetchingAnnouncements() - - this.fetchMutes() - dispatch('loadDrafts') - - useInterfaceStore().setLayoutWidth(windowWidth()) - useInterfaceStore().setLayoutHeight(windowHeight()) - - // Fetch our friends - fetchFriends({ id: user.id }).then((friends) => - this.addNewUsers(friends), - ) - this.loggingIn = false - resolve() - }) - .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() - } - - this.loggingIn = false - if (error.tatusCode === 401) { - throw new Error('Wrong username or password', error) - } else { - throw new Error('An error occurred, please try again', error) - } - }) + // Search + searchUsers({ query }) { + return searchUsers({ + query, + credentials: useOAuthStore().token, + }).then(({ data: users }) => { + this.addNewUsers(users) + return users }) }, }, diff --git a/test/unit/specs/modules/users.spec.js b/test/unit/specs/modules/users.spec.js deleted file mode 100644 index 1b33f8c4e..000000000 --- a/test/unit/specs/modules/users.spec.js +++ /dev/null @@ -1,120 +0,0 @@ -import { cloneDeep } from 'lodash' - -import { - defaultState, - getters, - mutations, -} from '../../../../src/modules/users.js' - -describe('The users module', () => { - describe('mutations', () => { - it('adds new users to the set, merging in new information for old users', () => { - const state = cloneDeep(defaultState) - const user = { id: '1', name: 'Guy' } - const modUser = { id: '1', name: 'Dude' } - - mutations.addNewUsers(state, [user]) - expect(state.users).to.have.length(1) - expect(state.users).to.eql([user]) - - mutations.addNewUsers(state, [modUser]) - expect(state.users).to.have.length(1) - expect(state.users).to.eql([user]) - expect(state.users[0].name).to.eql('Dude') - }) - - it('merging array field in new information for old users', () => { - const state = cloneDeep(defaultState) - const user = { - id: '1', - fields: [{ name: 'Label 1', value: 'Content 1' }], - } - const firstModUser = { - id: '1', - fields: [ - { name: 'Label 2', value: 'Content 2' }, - { name: 'Label 3', value: 'Content 3' }, - ], - } - const secondModUser = { - id: '1', - fields: [{ name: 'Label 4', value: 'Content 4' }], - } - - mutations.addNewUsers(state, [user]) - expect(state.users[0].fields).to.have.length(1) - expect(state.users[0].fields[0].name).to.eql('Label 1') - - mutations.addNewUsers(state, [firstModUser]) - expect(state.users[0].fields).to.have.length(2) - expect(state.users[0].fields[0].name).to.eql('Label 2') - expect(state.users[0].fields[1].name).to.eql('Label 3') - - mutations.addNewUsers(state, [secondModUser]) - expect(state.users[0].fields).to.have.length(1) - expect(state.users[0].fields[0].name).to.eql('Label 4') - }) - }) - - describe('findUser', () => { - it('does not return user with matching screen_name', () => { - const user = { screen_name: 'Guy', id: '1' } - const state = { - usersObject: { - 1: user, - }, - usersByNameObject: { - guy: user, - }, - } - const name = 'Guy' - expect(getters.findUser(state)(name)).to.be.undefined - }) - - it('returns user with matching id', () => { - const user = { screen_name: 'Guy', id: '1' } - const state = { - usersObject: { - 1: user, - }, - usersByNameObject: { - guy: user, - }, - } - const id = '1' - const expected = { screen_name: 'Guy', id: '1' } - expect(getters.findUser(state)(id)).to.eql(expected) - }) - }) - - describe('findUserByName', () => { - it('returns user with matching screen_name', () => { - const user = { screen_name: 'Guy', id: '1' } - const state = { - usersObject: { - 1: user, - }, - usersByNameObject: { - guy: user, - }, - } - const name = 'Guy' - const expected = { screen_name: 'Guy', id: '1' } - expect(getters.findUserByName(state)(name)).to.eql(expected) - }) - - it('does not return user with matching id', () => { - const user = { screen_name: 'Guy', id: '1' } - const state = { - usersObject: { - 1: user, - }, - usersByNameObject: { - guy: user, - }, - } - const id = '1' - expect(getters.findUserByName(state)(id)).to.be.undefined - }) - }) -}) diff --git a/test/unit/specs/stores/users.spec.js b/test/unit/specs/stores/users.spec.js new file mode 100644 index 000000000..7e26db2f6 --- /dev/null +++ b/test/unit/specs/stores/users.spec.js @@ -0,0 +1,1217 @@ +import { createTestingPinia } from '@pinia/testing' +import { snakeCase } from 'lodash' +import { setActivePinia } 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 { 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 { useUsersStore } from 'src/stores/users.js' + +import * as PUBLIC_API from 'src/api/public.js' +import * as USER_API from 'src/api/user.js' + +const DEFAULT_OPTIONS = (method = 'POST') => ({ + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, +}) + +const actionKeys = (action) => { + const result = {} + if (action === 'removeUserFromFollowers') { + result.storeAction = action + } else { + result.storeAction = action + 'User' + } + + result.apiUrl = `MASTODON_${snakeCase(result.storeAction).toUpperCase()}_URL` + return result +} + +describe('The users store', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + const userId = '1' + const userScreenName = 'user' + const userName = 'Guy' + const userUrl = 'http://localhost/user' + + const mastoApiUser = ({ + screen_name = userScreenName, + name = userName, + url = userUrl, + id = userId + } = {}) => ({ + id, + acct: screen_name, + display_name: name, + fields: [], + avatar: '', + url, + }) + + const user = ({ + screen_name = userScreenName, + id = userId, + name = userName, + url = userUrl, + } = {}) => ({ + _original: mastoApiUser({ + screen_name, + id, + name, + url, + }), + id, + name, + screen_name, + url, + relationship: undefined, + }) + + describe('addNewUsers', () => { + describe('users', () => { + it('adds new users to the set, merging in new information for old users', () => { + const store = useUsersStore() + + const modUser = user({ name: 'Dude' }) + + store.addNewUsers({ data: [user()], timestamp: 1 }) + expect(store.users).to.have.length(1) + expect(store.users).to.have.all.keys(userId) + + store.addNewUsers({ data: [modUser], timestamp: 2 }) + expect(store.users).to.have.length(1) + expect(store.users).to.have.all.keys(userId) + expect(store.users.get(userId).name).to.eql('Dude') + }) + + it('ignores new users if timestamp is older', () => { + const store = useUsersStore() + + const modUser = user({ name: 'Old guy' }) + + store.addNewUsers({ data: [user()], timestamp: 2000 }) + expect(store.users).to.have.length(1) + expect(store.users).to.have.all.keys(userId) + expect(store.users.get(userId).name).to.eql('Guy') + + store.addNewUsers({ data: [modUser], timestamp: 1991 }) + expect(store.users).to.have.length(1) + expect(store.users).to.have.all.keys(userId) + expect(store.users.get(userId).name).to.eql('Guy') + }) + + it('merging array field in new information for old users', () => { + const store = useUsersStore() + + const userFields = { + ...user(), + fields: [{ name: 'Label 1', value: 'Content 1' }], + } + const firstModUser = { + ...user(), + fields: [ + { name: 'Label 2', value: 'Content 2' }, + { name: 'Label 3', value: 'Content 3' }, + ], + } + const secondModUser = { + ...user(), + fields: [{ name: 'Label 4', value: 'Content 4' }], + } + + store.addNewUsers({ data: [userFields], timestamp: 1 }) + const reactive = store.users.get(userId) + expect(reactive.fields).to.have.length(1) + expect(reactive.fields[0].name).to.eql('Label 1') + + store.addNewUsers({ data: [firstModUser], timestamp: 2 }) + expect(reactive.fields).to.have.length(2) + expect(reactive.fields[0].name).to.eql('Label 2') + expect(reactive.fields[1].name).to.eql('Label 3') + + store.addNewUsers({ data: [secondModUser], timestamp: 3 }) + expect(reactive.fields).to.have.length(1) + expect(reactive.fields[0].name).to.eql('Label 4') + }) + }) + + describe('relationships', () => { + it('updates relationship information if present', () => { + const store = useUsersStore() + + const modUser = { + ...user(), + relationship: { + id: userId, + following: true, + }, + } + + store.addNewUsers({ data: [user()], timestamp: 1 }) + store.addNewUsers({ data: [modUser], timestamp: 2 }) + + expect(store.relationships).to.have.length(1) + expect(store.relationships).to.have.all.keys(userId) + expect(store.relationship(userId).following).to.eql(true) + expect(store.findUser(userId).relationship.following).to.eql(true) + }) + + it('updates relationship information if present even if user timestamp is older', () => { + const store = useUsersStore() + + const modUser = { + ...user({ name: 'Old Dude' }), + relationship: { + id: userId, + following: true, + }, + } + + store.addNewUsers({ data: [user()], timestamp: 2000 }) + store.addNewUsers({ data: [modUser], timestamp: 1991 }) + + expect(store.relationships).to.have.length(1) + expect(store.relationships).to.have.all.keys(userId) + expect(store.relationship(userId).following).to.eql(true) + expect(store.findUser(userId).relationship.following).to.eql(true) + }) + + it("doesn't erase relationship information if new data has it missing", () => { + const store = useUsersStore() + + const modUser = user({ name: 'Dude' }) + + store.addNewUsers({ + data: [{ ...user(), relationship: { id: userId, following: true } }], + timestamp: 1, + }) + store.addNewUsers({ data: [modUser], timestamp: 2 }) + + expect(store.relationships).to.have.length(1) + expect(store.relationships).to.have.all.keys(userId) + expect(store.relationship(userId).following).to.eql(true) + expect(store.findUser(userId).relationship.following).to.eql(true) + }) + }) + }) + + describe('updateUserRelationships', () => { + it('updates existing user relationship', () => { + const store = useUsersStore() + const relationship = { id: userId, following: true } + + store.addNewUsers({ data: [user()], timestamp: 1 }) + store.updateUserRelationships({ data: relationship, timestamp: 2 }) + + expect(store.relationship(userId)).to.eql({ id: userId, following: true }) + expect(store.findUser(userId).relationship.following).to.eql(true) + }) + + it('stores relationship for missing user', () => { + const store = useUsersStore() + const relationship = { id: userId, following: true } + + store.updateUserRelationships({ data: relationship, timestamp: 1 }) + + expect(store.relationship(userId)).to.eql({ id: userId, following: true }) + }) + + it('assigns relationship for missing user when it becomes available', () => { + const store = useUsersStore() + const relationship = { id: userId, following: true } + + store.updateUserRelationships({ data: relationship, timestamp: 1 }) + store.addNewUsers({ data: [user()], timestamp: 2 }) + + expect(store.relationship(userId)).to.eql({ id: userId, following: true }) + expect(store.findUser(userId).relationship).to.eql({ + id: userId, + following: true, + }) + }) + + it('ignores relationship update if timestamp is older than existing', () => { + const store = useUsersStore() + const oldRelationship = { id: userId, following: true } + const newRelationship = { id: userId, following: false } + + store.addNewUsers({ + data: [{ ...user(), relationship: newRelationship }], + timestamp: 2000, + }) + store.updateUserRelationships({ data: oldRelationship, timestamp: 1991 }) + + expect(store.relationship(userId)).to.eql({ + id: userId, + following: false, + }) + expect(store.findUser(userId).relationship.following).to.eql(false) + }) + }) + + describe('fetchers', () => { + describe('fetchUserIfMissing', () => { + it('should fetch requested user, add it to store and return it', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mastoApiUser()), { + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + const expected = user() + const store = useUsersStore() + const resultUser = await store.fetchUserIfMissing({ id: '1' }) + + expect(resultUser).to.deep.include(expected) + expect(store.findUser(userId)).to.deep.include(expected) + }) + + it('Should re-use existing promise for other fetches', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: '1' }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + // fetch by name yields user id which we request next + .mockResolvedValueOnce( + new Response(JSON.stringify(mastoApiUser()), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockThrowOnce(new Error("Shouldn't be called more than once")), + ) + + const expected = user() + const store = useUsersStore() + const resultUser1 = await store.fetchUserIfMissing({ name: 'user' }) + const resultUser2 = await store.fetchUserIfMissing({ id: '1' }) + + expect(resultUser1).to.deep.include(expected) + expect(resultUser2).to.deep.include(expected) + expect(store.findUser(userId)).to.deep.include(expected) + }) + + it('Should use cached data if present', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockThrowOnce(new Error("Shouldn't be called at all")), + ) + const store = useUsersStore() + store.addNewUsers({ data: [user()], timestamp: 1 }) + + const resultUser1 = await store.fetchUserIfMissing({ id: '1' }) + const resultUser2 = await store.fetchUserIfMissing({ name: 'user' }) + const expected = user() + + expect(resultUser1).to.deep.include(expected) + expect(resultUser2).to.deep.include(expected) + expect(store.findUser(userId)).to.deep.include(expected) + }) + + it('Should handle 404 gracefully', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mastoApiUser()), { + status: 404, + statusText: 'Not Found', + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + const store = useUsersStore() + + const resultUser = await store.fetchUserIfMissing({ id: '1' }) + + expect(resultUser).to.be.null + }) + + it('Should throw if no identifier is provided', async () => { + const store = useUsersStore() + + await expect( + async () => await store.fetchUserIfMissing({}), + ).rejects.to.throw(TypeError) + await expect( + async () => await store.fetchUserIfMissing(), + ).rejects.to.throw(TypeError) + }) + }) + + describe('relationships', () => { + it.each(['Friends', 'Followers'])('fetch%s', async (group) => { + const mockFetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify([ + mastoApiUser({ screen_name: 'snake', name: 'John', id: '2' }), + mastoApiUser({ screen_name: 'zero', name: 'David Oh', id: '3' }), + ]), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify([ + mastoApiUser({ screen_name: 'sigint', name: 'Mr.Anderson', id: '4' }), + mastoApiUser({ screen_name: 'paramedic', name: 'Dr.Clark', id: '5' }), + ]), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + + const store = useUsersStore() + store.addNewUsers({ timestamp: 1, data: user() }) + + const urlGroup = group === 'Friends' ? 'Following' : group + const us = store.users.get(userId) + + await store[`fetch${group}`](userId) + expect(mockFetch).to.have.been.calledWith( + PUBLIC_API[`MASTODON_${urlGroup.toUpperCase()}_URL`](userId, { + limit: 20, + withRelationships: true, + }), + DEFAULT_OPTIONS('GET'), + ) + expect(store.relationshipsLists[group.toLowerCase()].get(us)).to.have.length(2) + + await store[`fetch${group}`](userId) + expect(mockFetch).to.have.been.calledWith( + PUBLIC_API[`MASTODON_${urlGroup.toUpperCase()}_URL`](userId, { + maxId: '3', + limit: 20, + withRelationships: true, + }), + DEFAULT_OPTIONS('GET'), + ) + expect(store.relationshipsLists[group.toLowerCase()].get(us)).to.have.length(4) + }) + + it.each(['Mutes', 'Blocks'])('fetch%s', async (group) => { + const mockFetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify([ + mastoApiUser({ screen_name: 'snake', name: 'John', id: '2' }), + mastoApiUser({ screen_name: 'zero', name: 'David Oh', id: '3' }), + ]), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify([ + mastoApiUser({ screen_name: 'sigint', name: 'Mr.Anderson', id: '4' }), + mastoApiUser({ screen_name: 'paramedic', name: 'Dr.Clark', id: '5' }), + ]), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.addNewUsers({ timestamp: 1, data: user() }) + + const us = store.users.get(userId) + + store.currentUser = us + const ids = group === 'Mutes' ? 'muteIds' : 'blockIds' + + await store[`fetch${group}`]({ reset: true }) + expect(mockFetch).to.have.been.calledWith( + USER_API[`MASTODON_USER_${group.toUpperCase()}_URL`]({ + withRelationships: true, + }), + DEFAULT_OPTIONS('GET'), + ) + expect(us[ids]).to.have.length(2) + + await store[`fetch${group}`]() + expect(mockFetch).to.have.been.calledWith( + USER_API[`MASTODON_USER_${group.toUpperCase()}_URL`]({ + withRelationships: true, + }), + DEFAULT_OPTIONS('GET'), + ) + expect(us[ids]).to.have.length(4) + }) + + it('fetchDomainMutes', async () => { + const mockFetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify([ + 'example.com', + 'example.org', + ]), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.addNewUsers({ timestamp: 1, data: user() }) + + const us = store.users.get(userId) + store.currentUser = us + + await store.fetchDomainMutes() + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_DOMAIN_BLOCKS_URL, + DEFAULT_OPTIONS('GET'), + ) + + expect(us.domainMutes).to.have.eql(new Set(['example.com', 'example.org'])) + }) + + it('fetchInLists', async () => { + const inLists = [ + { exclusive: false, id: '1', title: 'Operatives' }, + { exclusive: true, id: '2', title: 'Agents' }, + ] + const mockFetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify(inLists), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.addNewUsers({ + timestamp: 1, + data: [ + user(), + { ...user({ name: 'John', screen_name: 'snake', id: '2' }) }, + { ...user({ name: 'David Oh', screen_name: 'zero', id: '3' }) }, + ] + }) + + const us = store.users.get(userId) + store.currentUser = us + + await store.fetchUserInLists('2') + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_USER_IN_LISTS('2'), + DEFAULT_OPTIONS('GET'), + ) + + expect(store.users.get('2').inLists).to.have.eql(inLists) + }) + }) + }) + + describe('misc updates', () => { + it('updateUserAdminData', () => { + const store = useUsersStore() + store.addNewUsers({ data: [user()], timestamp: 1 }) + const adminData = { is_active: true, tags: ['one', 'two'] } + store.updateUserAdminData(userId, adminData) + + const userData = store.users.get(userId) + expect(userData.deactivated).to.eql(false) + expect(userData.tags).to.eql(new Set(['one', 'two'])) + }) + + it('updateRight', () => { + const store = useUsersStore() + store.addNewUsers({ data: [user()], timestamp: 1 }) + store.updateRight(userId, 'right1', true) + store.updateRight(userId, 'right2', false) + + const userData = store.users.get(userId) + expect(userData.rights.right1).to.eql(true) + expect(userData.rights.right2).to.eql(false) + }) + + it('clearFollowLists', () => { + const store = useUsersStore() + store.addNewUsers({ data: [user()], timestamp: 1 }) + const userData = store.users.get(userId) + store.relationshipsLists.friends.get(userData).add('2') + store.relationshipsLists.friends.get(userData).add('3') + store.relationshipsLists.followers.get(userData).add('4') + store.relationshipsLists.followers.get(userData).add('5') + store.clearFollowLists(userId) + + expect(store.relationshipsLists.friends.get(userData)).to.have.length(0) + expect(store.relationshipsLists.followers.get(userData)).to.have.length(0) + }) + }) + + describe('login/logout', () => { + describe('login', () => { + it('normal login', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mastoApiUser()), { + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + const spies = [ + // Misc initialization + vi.spyOn(useSyncConfigStore(), 'initSyncConfig'), + vi.spyOn(useUserHighlightStore(), 'initUserHighlight'), + vi.spyOn(useInterfaceStore(), 'applyTheme'), + vi.spyOn(useInterfaceStore(), 'onLogin'), + vi.spyOn(useEmojiStore(), 'fetchEmoji'), + + // Timeline / Notifications + vi.spyOn(useNotificationsStore(), 'activate'), + vi.spyOn(useTimelinesStore(), 'activatePersistents'), + + // Fetchers + vi.spyOn(useChatsStore(), 'startFetching'), + vi.spyOn(useListsStore(), 'startFetching'), + vi.spyOn(useAnnouncementsStore(), 'startFetching'), + vi.spyOn(useBookmarkFoldersStore(), 'startFetching'), + vi.spyOn(useStreamingStore(), 'initSocket'), + ] + + spies.forEach((spy) => { + spy.mockImplementation(async () => { + /* no-op */ + }) + }) + + useInstanceCapabilitiesStore().pleromaChatMessagesAvailable = true + useMergedConfigStore().mergedConfig = { useStreamingApi: true } + + const store = useUsersStore() + + // Adding some users to verify they are getting cleaned afterwards + store.addNewUsers({ + data: [ + user(), + { ...user({ name: 'John', screen_name: 'snake' }) }, + { ...user({ name: 'David Oh', screen_name: 'zero' }) }, + ], + timestamp: 2000, + }) + + expect(store.loggedIn).to.eql(false) + await store.loginUser('ACCESS_TOKEN') + expect(store.loggedIn).to.eql(true) + + // We should be in the store ourselves + expect(store.users).to.have.length(1) + expect(store.usersByName).to.have.length(1) + expect(store.usersByURL).to.have.length(1) + expect(store.relationships).to.have.length(0) + expect(store.lastLoginName).to.eql(userScreenName) + spies.forEach((spy, index) => { + expect(spy, `Spy ${index} has failed`).to.have.been.called + }) + }) + + it('bad credentials', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(mastoApiUser()), { + status: 403, + statusText: 'Forbidden', + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + const spies = [vi.spyOn(useOAuthStore(), 'clearToken')] + + spies.forEach((spy) => { + spy.mockImplementation(async () => { + /* no-op */ + }) + }) + + const store = useUsersStore() + const exec = async () => { + await store.loginUser('ACCESS_TOKEN') + } + + await expect(exec).rejects.to.throw(Error) + + expect(store.loggedIn).to.eql(false) + + spies.forEach((spy, index) => { + expect(spy, `Spy ${index} has failed`).to.have.been.called + }) + }) + }) + + describe('logout', () => { + it('normal logout', async () => { + const revokeApi = vi + .fn() + .mockResolvedValueOnce( + // Ensure APP + new Response(JSON.stringify('ok'), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + // Revoke Token + new Response(JSON.stringify('ok'), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', revokeApi) + + const spies = [ + // Misc initialization + vi.spyOn(useStatusesStore(), 'resetStatuses'), + vi.spyOn(useInterfaceStore(), 'onLogout'), + + // Timeline / Notifications + vi.spyOn(useNotificationsStore(), 'deactivate'), + vi.spyOn(useTimelinesStore(), 'deactivateAll'), + + // Fetchers + vi.spyOn(useChatsStore(), 'resetChats'), + vi.spyOn(useListsStore(), 'stopFetching'), + vi.spyOn(useAnnouncementsStore(), 'stopFetching'), + vi.spyOn(useBookmarkFoldersStore(), 'stopFetching'), + vi.spyOn(useStreamingStore(), 'stopSocket'), + ] + + spies.forEach((spy) => { + spy.mockImplementation(async () => { + /* no-op */ + }) + }) + + useInstanceCapabilitiesStore().pleromaChatMessagesAvailable = true + useMergedConfigStore().mergedConfig = { useStreamingApi: true } + + const store = useUsersStore() + store.currentUser = user() + + // Adding some users to verify they are getting cleaned afterwards + store.addNewUsers({ + data: [ + user(), + { + ...user({ name: 'John', screen_name: 'snake' }), + relationship: { id: userId, following: true }, + }, + { ...user({ name: 'David Oh', screen_name: 'zero' }) }, + ], + timestamp: 2000, + }) + expect(store.loggedIn).to.eql(true) + await store.logout() + expect(store.loggedIn).to.eql(false) + expect(store.lastLoginName).to.eql(null) + expect(revokeApi).to.have.been.called + expect(store.users).to.have.length(0) + expect(store.usersByName).to.have.length(0) + expect(store.usersByURL).to.have.length(0) + expect(store.relationships).to.have.length(0) + spies.forEach((spy, index) => { + expect(spy, `Spy ${index} has failed`).to.have.been.called + }) + }) + }) + }) + + describe('actions', () => { + describe('follow', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('instant follow case', async () => { + const followApi = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: true, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', followApi) + const store = useUsersStore() + + await store.followUser(userId) + expect(followApi).to.have.been.called + + expect(store.followPollers).to.have.length(0) + expect(store.followPollersAttempts).to.have.length(0) + expect(followApi).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: '{}', + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it('delayed follow case', async () => { + const followApi = vi + .fn() + .mockResolvedValueOnce( + // Follow + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + .mockResolvedValueOnce( + // Check relationship + new Response( + JSON.stringify({ + id: userId, + following: true, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', followApi) + + const store = useUsersStore() + + await store.followUser(userId) + expect(followApi).to.have.been.called + + expect(store.followPollers).to.have.length(1) + expect(store.followPollersAttempts).to.have.length(1) + expect(store.followPollersAttempts.get(userId)).to.eql(3) + + await vi.runAllTimersAsync() + expect(followApi).to.have.been.called + expect(store.followPollers).to.have.length(0) + expect(store.followPollersAttempts).to.have.length(0) + }) + + it('unresolved follow case', async () => { + const followApi = vi.fn().mockResolvedValueOnce( + // Follow + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + followApi // mockImplementation because we need to re-create Response + .mockImplementation( + () => + // Check relationship + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', followApi) + + const store = useUsersStore() + + await store.followUser(userId) + expect(store.followPollers).to.have.length(1) + expect(store.followPollersAttempts).to.have.length(1) + expect(store.followPollersAttempts.get(userId)).to.eql(3) + + await vi.runAllTimersAsync() + expect(store.followPollersAttempts.get(userId)).to.eql(2) + + await vi.runAllTimersAsync() + expect(store.followPollersAttempts.get(userId)).to.eql(1) + + await vi.runAllTimersAsync() + expect(store.followPollers).to.have.length(0) + expect(store.followPollersAttempts).to.have.length(0) + + expect(followApi).to.have.callCount(4) // 1 follow + 3 retries + }) + }) + + it('unfollow', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.unfollowUser(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_UNFOLLOW_URL(userId), + DEFAULT_OPTIONS(), + ) + }) + + it('subscribe', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + subscribing: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.subscribeUser(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: JSON.stringify({ notify: true }), + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it('unsubscribe', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + subscribing: false, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.unsubscribeUser(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: JSON.stringify({ notify: false }), + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it('showReblogs', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + showing_reblogs: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.showReblogs(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: JSON.stringify({ reblogs: true }), + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it('hideReblogs', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: userId, + following: false, + requested: true, + showing_reblogs: false, + }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + await store.hideReblogs(userId) + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_FOLLOW_URL(userId), + { + body: JSON.stringify({ reblogs: false }), + ...DEFAULT_OPTIONS(), + }, + ) + }) + + it.each([ + 'unmute', + 'unblock', + 'removeUserFromFollowers', + ])('%s', async (action) => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + const { storeAction, apiUrl } = actionKeys(action) + await store[storeAction](userId) + console.log(apiUrl) + + expect(mockFetch).to.have.been.calledWith( + USER_API[apiUrl](userId), + DEFAULT_OPTIONS(), + ) + }) + + describe.each(['mute', 'block'])('%s', (action) => { + it('normal', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + vi.spyOn(useStatusesStore(), 'wipeUserStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + vi.spyOn(useTimelinesStore(), 'wipeUserStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + const store = useUsersStore() + const { storeAction, apiUrl } = actionKeys(action) + await store[storeAction](userId) + + expect(mockFetch).to.have.been.calledWith(USER_API[apiUrl](userId), { + body: '{}', + ...DEFAULT_OPTIONS(), + }) + }) + + it('with expiration', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + vi.spyOn(useStatusesStore(), 'wipeUserStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + vi.spyOn(useTimelinesStore(), 'wipeUserStatuses').mockImplementation( + async () => { + /* no-op */ + }, + ) + + const store = useUsersStore() + const { storeAction, apiUrl } = actionKeys(action) + await store[storeAction](userId, 20) + const argument = action === 'mute' ? 'expires_in' : 'duration' + + expect(mockFetch).to.have.been.calledWith(USER_API[apiUrl](userId), { + body: `{"${argument}":20}`, + ...DEFAULT_OPTIONS(), + }) + }) + }) + + it.each(['mute', 'unmute'])('%s domain', async (action) => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store.currentUser = user() + store.currentUser.domainMutes = new Set() + if (action === 'unmute') { + store.currentUser.domainMutes.add('example.com') + } + await store[action + 'Domain']('example.com') + + expect(mockFetch).to.have.been.calledWith( + USER_API.MASTODON_DOMAIN_BLOCKS_URL, + { + body: JSON.stringify({ domain: 'example.com' }), + ...DEFAULT_OPTIONS(action === 'mute' ? 'POST' : 'DELETE'), + }, + ) + + if (action === 'mute') { + expect(store.currentUser.domainMutes).to.include('example.com') + } else { + expect(store.currentUser.domainMutes).to.not.include('example.com') + } + }) + + it.each([ + 'muteDomain', + 'unmuteDomain', + 'muteUser', + 'unmuteUser', + 'blockUser', + 'unblockUser', + ])('%ss', async (action) => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ id: userId }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useUsersStore() + store[action] = vi.fn().mockResolvedValue(async () => { + /* no-op */ + }) + store[action + 's'](['1', '2', '3']) + expect(store[action]).to.have.been.calledWith('1') + expect(store[action]).to.have.been.calledWith('2') + expect(store[action]).to.have.been.calledWith('3') + }) + }) + + describe('getters', () => { + it('relationship returns a placeholder if relationship info is missing', () => { + const store = useUsersStore() + + expect(store.relationship(userId)).to.eql({ id: userId, loading: true }) + }) + + it('relationship returns a placeholder if relationship info is missing while user is present', () => { + const store = useUsersStore() + store.addNewUsers({ data: [user()], timestamp: 1 }) + + expect(store.relationship(userId)).to.eql({ id: userId, loading: true }) + }) + + it('findUser returns user with matching id', () => { + const store = useUsersStore() + store.addNewUsers({ data: [user()], timestamp: 1 }) + + expect(store.findUser(userId).id).to.eql(userId) + }) + + it('findUserByName returns user with matching screen_name', () => { + const store = useUsersStore() + store.addNewUsers({ data: [user()], timestamp: 1 }) + + expect(store.findUserByName(user().screen_name).id).to.eql(userId) + }) + + it('findUserByName returns user with matching url', () => { + const store = useUsersStore() + store.addNewUsers({ data: [user()], timestamp: 1 }) + + expect(store.findUserByUrl(user().url).id).to.eql(userId) + }) + }) +})