diff --git a/src/api/user.js b/src/api/user.js index cb7195f49..ec1763bb4 100644 --- a/src/api/user.js +++ b/src/api/user.js @@ -18,7 +18,7 @@ const CHANGE_PASSWORD_URL = '/api/pleroma/change_password' const MOVE_ACCOUNT_URL = '/api/pleroma/move_account' const ALIASES_URL = '/api/pleroma/aliases' const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings' -const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read' +export const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read' const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa' const MFA_BACKUP_CODES_URL = '/api/pleroma/accounts/mfa/backup_codes' @@ -27,7 +27,7 @@ const MFA_SETUP_OTP_URL = '/api/pleroma/accounts/mfa/setup/totp' const MFA_CONFIRM_OTP_URL = '/api/pleroma/accounts/mfa/confirm/totp' const MFA_DISABLE_OTP_URL = '/api/pleroma/accounts/mfa/totp' -const MASTODON_DISMISS_NOTIFICATION_URL = (id) => +export const MASTODON_DISMISS_NOTIFICATION_URL = (id) => `/api/v1/notifications/${id}/dismiss` export const MASTODON_FAVORITE_URL = (id) => `/api/v1/statuses/${id}/favourite` export const MASTODON_UNFAVORITE_URL = (id) => diff --git a/src/components/follow_request_card/follow_request_card.js b/src/components/follow_request_card/follow_request_card.js index f72e3bf6c..02d3bec34 100644 --- a/src/components/follow_request_card/follow_request_card.js +++ b/src/components/follow_request_card/follow_request_card.js @@ -59,12 +59,6 @@ const FollowRequestCard = { const notifId = this.findFollowRequestNotificationId() this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId }) - this.$store.dispatch('updateNotification', { - id: notifId, - updater: (notification) => { - notification.type = 'follow' - }, - }) this.hideApproveConfirmDialog() }, denyUser() { diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 1004f50c9..577a81142 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -154,12 +154,6 @@ const Notification = { this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id, }) - this.$store.dispatch('updateNotification', { - id: this.notification.id, - updater: (notification) => { - notification.type = 'follow' - }, - }) this.hideApproveConfirmDialog() }, denyUser() { diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index 92272facf..3f65cc65b 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -10,7 +10,6 @@ import { filteredNotifications, unseenNotifications, } from '../../services/notification_utils/notification_utils.js' -import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' import NotificationFilters from './notification_filters.vue' import { useAnnouncementsStore } from 'src/stores/announcements.js' @@ -54,7 +53,6 @@ const Notifications = { data() { return { showScrollTop: false, - bottomedOut: false, // How many seen notifications to display in the list. The more there are, // the heavier the page becomes. This count is increased when loading // older notifications, and cut back to default whenever hitting "Read!". @@ -124,7 +122,10 @@ const Notifications = { ) }, loading() { - return useNotificationsStore().loading + return useNotificationsStore().fetcher.loading.value + }, + bottomedOut() { + return useNotificationsStore().fetcher.bottomedOut.value }, noHeading() { const { layoutType } = useInterfaceStore() @@ -247,22 +248,7 @@ const Notifications = { this.seenToDisplayCount = seenCount } - const store = this.$store - const credentials = useUsersStore().currentUser.credentials - useNotificationsStore().setNotificationsLoading(true) - notificationsFetcher - .fetchAndUpdate({ - store, - credentials, - older: true, - }) - .then((notifs) => { - useNotificationsStore().setNotificationsLoading(false) - if (notifs.length === 0) { - this.bottomedOut = true - } - this.seenToDisplayCount += notifs.length - }) + useNotificationsStore().fetcher.fetchOlder() }, }, } diff --git a/src/components/status/status.js b/src/components/status/status.js index f3e3ff154..7c8400bdc 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -168,22 +168,22 @@ const Status = { return this.status.deleted }, repeaterStyle() { - const user = this.status.user - return highlightStyle(useUserHighlightStore().get(this.repeater.screen_name)) + return highlightStyle( + useUserHighlightStore().get(this.repeater.screen_name), + ) }, userStyle() { if (this.noHeading) return return highlightStyle(useUserHighlightStore().get(this.user.screen_name)) }, userProfileLink() { - return this.generateUserProfileLink( - this.user.id, - this.user.screen_name, - ) + return this.generateUserProfileLink(this.user.id, this.user.screen_name) }, replyProfileLink() { if (this.isReply) { - const user = useUsersStore().findUser(this.mainStatus.in_reply_to_user_id) + const user = useUsersStore().findUser( + this.mainStatus.in_reply_to_user_id, + ) // FIXME Why user not found sometimes??? return user ? user.statusnet_profile_url : 'NOT_FOUND' } @@ -359,14 +359,17 @@ const Status = { }, isReply() { return !!( - this.mainStatus.in_reply_to_status_id && this.mainStatus.in_reply_to_user_id + this.mainStatus.in_reply_to_status_id && + this.mainStatus.in_reply_to_user_id ) }, replyToName() { if (this.mainStatus.in_reply_to_screen_name) { return this.status.in_reply_to_screen_name } else { - const user = useUsersStore().findUser(this.mainStatus.in_reply_to_user_id) + const user = useUsersStore().findUser( + this.mainStatus.in_reply_to_user_id, + ) return user?.screen_name_ui } }, diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 38bf1387c..a76438ad0 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -8,8 +8,8 @@ import ScrollTopButton from 'src/components/scroll_top_button/scroll_top_button. import TimelineMenu from 'src/components/timeline_menu/timeline_menu.vue' import { useInterfaceStore } from 'src/stores/interface.js' -import { useStatusesStore } from 'src/stores/statuses.js' import { useMergedConfigStore } from 'src/stores/merged_config.js' +import { useStatusesStore } from 'src/stores/statuses.js' import { useTimelinesStore } from 'src/stores/timelines.js' import { library } from '@fortawesome/fontawesome-svg-core' @@ -103,9 +103,7 @@ const Timeline = { const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80)) const min = Math.max(0, this.virtualScrollIndex - statusesPerSide) const max = Math.min(amount, this.virtualScrollIndex + statusesPerSide) - return new Set( - this.timeline.order.slice(min, max), - ) + return new Set(this.timeline.order.slice(min, max)) }, virtualScrollingEnabled() { return useMergedConfigStore().mergedConfig.virtualScrolling @@ -213,7 +211,6 @@ const Timeline = { // if we have a previous scroll index that can be used, test if it's - const virtualScrollIndexY = statuses[cappedScrollIndex].getBoundingClientRect().y if (Math.abs(err) > virtualScrollIndexY) { diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js deleted file mode 100644 index 30f973ea5..000000000 --- a/src/services/notifications_fetcher/notifications_fetcher.service.js +++ /dev/null @@ -1,143 +0,0 @@ -import { promiseInterval } from '../promise_interval/promise_interval.js' - -import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' -import { useInterfaceStore } from 'src/stores/interface.js' -import { useMergedConfigStore } from 'src/stores/merged_config.js' -import { useNotificationsStore } from 'src/stores/notifications.js' - -import { fetchTimeline } from 'src/api/timelines.js' - -// For using include_types when fetching notifications. -// Note: chat_mention excluded as pleroma-fe polls them separately -const mastoApiNotificationTypes = new Set([ - 'mention', - 'status', - 'favourite', - 'reblog', - 'follow', - 'follow_request', - 'move', - 'poll', - 'pleroma:emoji_reaction', - 'pleroma:report', -]) - -const fetchAndUpdate = ({ credentials }, { older = false, sinceId }) => { - useNotificationsStore().setLoading(true) - const args = { credentials } - const timelineData = useNotificationsStore() - const hideMutedPosts = useMergedConfigStore().mergedConfig.hideMutedPosts - - if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) { - mastoApiNotificationTypes.add('pleroma:chat_mention') - } - - args.includeTypes = [...mastoApiNotificationTypes] - args.withMuted = !hideMutedPosts - - args.timeline = 'notifications' - if (older) { - if (timelineData.minId !== Number.POSITIVE_INFINITY) { - args.maxId = timelineData.minId - } - return fetchNotifications({ args, older }) - } else { - // fetch new notifications - if ( - sinceId === undefined && - timelineData.maxId !== Number.POSITIVE_INFINITY - ) { - args.sinceId = timelineData.maxId - } else if (sinceId !== null) { - args.sinceId = sinceId - } - const result = fetchNotifications({ args, older }) - - // If there's any unread notifications, try fetch notifications since - // the newest read notification to check if any of the unread notifs - // have changed their 'seen' state (marked as read in another session), so - // we can update the state in this session to mark them as read as well. - // The normal maxId-check does not tell if older notifications have changed - const notifications = timelineData.data - const readNotifsIds = notifications.filter((n) => n.seen).map((n) => n.id) - const unreadNotifsIds = notifications - .filter((n) => !n.seen) - .map((n) => n.id) - - if (readNotifsIds.length > 0 && unreadNotifsIds.length > 0) { - const minId = Math.min(...unreadNotifsIds) // Oldest known unread notification - if (minId !== Infinity) { - args.sinceId = null // Don't use since_id since it sorta conflicts with min_id - args.minId = minId - 1 // go beyond - fetchNotifications({ args, older }) - } - } - - return result - } -} - -const fetchNotifications = ({ args, older }) => { - return fetchTimeline(args) - .then((response) => { - const notifications = response.data - - useNotificationsStore().addNewNotifications(response) - - return notifications - }) - .catch((error) => { - if ( - error.statusCode === 400 && - error.statusText.includes('Invalid value for enum') - ) { - error.statusText - .matchAll(/(\w+) - Invalid value for enum./g) - .toArray() - .map((x) => x[1]) - .forEach((x) => mastoApiNotificationTypes.delete(x)) - return fetchNotifications({ args, older }) - } - - console.error('Notifications Error', error) - useInterfaceStore().pushGlobalNotice({ - level: 'error', - messageKey: 'notifications.error', - messageArgs: [error.message], - timeout: 5000, - }) - }) - .finally(() => { - useNotificationsStore().setLoading(false) - }) -} - -const notificationsFetcher = (credentials) => { - const state = { - interval: null, - } - - const boundFetchAndUpdate = ({ older = false, sinceId } = {}) => - fetchAndUpdate({ credentials }, { older, sinceId }) - - const startFetching = () => { - if (state.interval) throw new Error('Interval already exists!') - - boundFetchAndUpdate() - - state.interval = promiseInterval(boundFetchAndUpdate, 10000) - } - - const stopFetching = () => { - state.interval.stop() - state.interval = null - } - - return { - startFetching, - stopFetching, - fetchAndUpdate: boundFetchAndUpdate, - } -} - -export default notificationsFetcher diff --git a/src/stores/fetchers/notifications_fetcher.js b/src/stores/fetchers/notifications_fetcher.js new file mode 100644 index 000000000..a31877072 --- /dev/null +++ b/src/stores/fetchers/notifications_fetcher.js @@ -0,0 +1,143 @@ +import { ref } from 'vue' + +import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' +import { useInterfaceStore } from 'src/stores/interface.js' +import { useMergedConfigStore } from 'src/stores/merged_config.js' +import { useNotificationsStore } from 'src/stores/notifications.js' + +import { fetchTimeline } from 'src/api/timelines.js' +import { promiseInterval } from 'src/services/promise_interval/promise_interval.js' + +// For using include_types when fetching notifications. +// Note: chat_mention excluded as pleroma-fe polls them separately +const mastoApiNotificationTypes = new Set([ + 'mention', + 'status', + 'favourite', + 'reblog', + 'follow', + 'follow_request', + 'move', + 'poll', + 'pleroma:emoji_reaction', + 'pleroma:report', +]) + +const notificationsFetcher = (credentials) => { + const interval = ref(null) + const loading = ref(false) + const bottomedOut = ref(false) + + const fetchNotifications = async ({ args, older }) => { + loading.value = true + + try { + const response = await fetchTimeline(args) + const notifications = response.data + if (older && notifications.length === 0) bottomedOut.value = true + + useNotificationsStore().addNewNotifications(response) + } catch (error) { + if ( + error.statusCode === 400 && + error.statusText.includes('Invalid value for enum') + ) { + error.statusText + .matchAll(/(\w+) - Invalid value for enum./g) + .toArray() + .map((x) => x[1]) + .forEach((x) => mastoApiNotificationTypes.delete(x)) + + // Retry + return await fetchNotifications({ args, older }) + } + + console.error('Notifications Error', error) + useInterfaceStore().pushGlobalNotice({ + level: 'error', + messageKey: 'notifications.error', + messageArgs: [error.message], + timeout: 5000, + }) + } finally { + loading.value = false + } + } + + const fetchAndUpdate = async ({ older = false, sinceId } = {}) => { + const args = { credentials } + const timelineData = useNotificationsStore() + const hideMutedPosts = useMergedConfigStore().mergedConfig.hideMutedPosts + + if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) { + mastoApiNotificationTypes.add('pleroma:chat_mention') + } + + args.includeTypes = [...mastoApiNotificationTypes] + args.withMuted = !hideMutedPosts + + args.timeline = 'notifications' + if (older) { + if (timelineData.minId !== Number.POSITIVE_INFINITY) { + args.maxId = timelineData.minId + } + return await fetchNotifications({ args, older }) + } else { + // fetch new notifications + if ( + sinceId === undefined && + timelineData.maxId !== Number.POSITIVE_INFINITY + ) { + args.sinceId = timelineData.maxId + } else if (sinceId !== null) { + args.sinceId = sinceId + } + const result = await fetchNotifications({ args, older }) + + // If there's any unread notifications, try fetch notifications since + // the newest read notification to check if any of the unread notifs + // have changed their 'seen' state (marked as read in another session), so + // we can update the state in this session to mark them as read as well. + // The normal maxId-check does not tell if older notifications have changed + const notifications = timelineData.data + const readNotifsIds = notifications.filter((n) => n.seen).map((n) => n.id) + const unreadNotifsIds = notifications + .filter((n) => !n.seen) + .map((n) => n.id) + + if (readNotifsIds.length > 0 && unreadNotifsIds.length > 0) { + const minId = Math.min(...unreadNotifsIds) // Oldest known unread notification + if (minId !== Infinity) { + args.sinceId = null // Don't use since_id since it sorta conflicts with min_id + args.minId = minId - 1 // go beyond + fetchNotifications({ args, older }) + } + } + + return result + } + } + + const startFetching = () => { + if (interval.value) throw new Error('Interval already exists!') + + fetchAndUpdate() + + interval.value = promiseInterval(fetchAndUpdate, 10000) + } + + const stopFetching = () => { + interval.value.stop() + interval.value = null + } + + return { + loading, + bottomedOut, + startFetching, + stopFetching, + fetchOlder: () => fetchAndUpdate({ older: true }), + } +} + +export default notificationsFetcher diff --git a/src/stores/fetchers/timeline_fetcher.js b/src/stores/fetchers/timeline_fetcher.js index 1b7524443..25e6e895f 100644 --- a/src/stores/fetchers/timeline_fetcher.js +++ b/src/stores/fetchers/timeline_fetcher.js @@ -1,5 +1,4 @@ import { ref } from 'vue' -import { promiseInterval } from 'src/services/promise_interval/promise_interval.js' import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' import { useInterfaceStore } from 'src/stores/interface.js' @@ -9,6 +8,7 @@ import { ARGUMENT_MAP, useTimelinesStore } from 'src/stores/timelines.js' import { useUsersStore } from 'src/stores/users.js' import { fetchTimeline } from 'src/api/timelines.js' +import { promiseInterval } from 'src/services/promise_interval/promise_interval.js' const REPLY_VISIBILITY_TIMELINES = new Set([ 'friends', @@ -49,11 +49,7 @@ const timelineFetcher = (timeline, argument, credentials) => { return fetchTimeline(args) .then(({ data: statuses, pagination, timestamp }) => { - if ( - !older && - statuses.length >= 20 && - numStatusesBeforeFetch > 0 - ) { + if (!older && statuses.length >= 20 && numStatusesBeforeFetch > 0) { useTimelinesStore().queueFlush(timeline.name, timeline.maxId) } @@ -111,7 +107,6 @@ const timelineFetcher = (timeline, argument, credentials) => { startFetching, stopFetching, fetchOlder: () => fetchAndUpdate({ showImmediately: true, older: true }), - fetchNewer: () => fetchAndUpdate({ showImmediately: true, older: false }), loading, bottomedOut, } diff --git a/src/stores/notifications.js b/src/stores/notifications.js index bc516d3bd..cfdccac2c 100644 --- a/src/stores/notifications.js +++ b/src/stores/notifications.js @@ -1,5 +1,6 @@ import { defineStore } from 'pinia' +import notificationsFetcher from 'src/stores/fetchers/notifications_fetcher.js' import { useI18nStore } from 'src/stores/i18n.js' import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useOAuthStore } from 'src/stores/oauth.js' @@ -19,7 +20,6 @@ import { maybeShowNotification, } from 'src/services/notification_utils/notification_utils.js' import { isStatusNotification } from 'src/services/notification_utils/notification_utils_sw.js' -import notificationsFetcher from 'src/services/notifications_fetcher/notifications_fetcher.service.js' export const defaultState = () => ({ desktopNotificationSilence: true, @@ -28,7 +28,7 @@ export const defaultState = () => ({ data: [], statusNotificationRelations: new WeakMap(), idStore: new Map(), - loading: false, + statusIdStore: new Set(), socket: null, streaming: false, fetcher: null, @@ -108,30 +108,35 @@ export const useNotificationsStore = defineStore('notifications', { }, // Updates - updateNotificationsMinMaxId(id) { - this.maxId = id > this.maxId ? id : this.maxId - this.minId = id < this.minId ? id : this.minId + updateExtremes(id) { + if (this.maxId === '' || id > this.maxId) { + this.maxId = id + } + if (this.minId === '' || id < this.minId) { + this.minId = id + } }, - updateNotification({ id, updater }) { - const notification = this.idStore.get(id) - notification && updater(notification) - }, - addNewNotifications(result) { - const { timestamp, data: notifications } = result + addNewNotifications(result, older) { + const { timestamp, data } = result + + const notifications = older + ? data + : [...data].reverse() useUsersStore().addNewUsers({ timestamp, data: notifications.map((n) => n.from_profile), }) - notifications.forEach( - (n) => (n.from_profile = useUsersStore().findUser(n.from_profile.id)), - ) + + notifications.forEach((n) => { + n.from_profile = useUsersStore().findUser(n.from_profile.id) + }) const validNotifications = notifications.filter((notification) => { // If invalid notification, update ids but don't add it to store if (!isValidNotification(notification)) { console.error('Invalid notification:', notification) - this.updateNotificationsMinMaxId(notification.id) + this.updateExtremes(notification.id) return false } return true @@ -178,12 +183,14 @@ export const useNotificationsStore = defineStore('notifications', { // Only add a new notification if we don't have one for the same action if (!this.idStore.has(notification.id)) { - this.updateNotificationsMinMaxId(notification.id) + this.updateExtremes(notification.id) - notifications.forEach((notification) => { + if (older) { this.data.push(notification) - this.idStore.set(notification.id, notification) - }) + } else { + this.data.unshift(notification) + } + this.idStore.set(notification.id, notification) this.statusNotificationRelations.set( notification.status, @@ -192,7 +199,7 @@ export const useNotificationsStore = defineStore('notifications', { maybeShowNotification( useMergedConfigStore().mergedConfig.notificationVisibility, - Object.values(useSyncConfigStore().prefsStorage.simple.muteFilters), + Object.values(useSyncConfigStore().prefsStorage.simple.muteFilters ?? {}), notification, useI18nStore().i18n, ) @@ -225,26 +232,26 @@ export const useNotificationsStore = defineStore('notifications', { markNotificationsAsSeen({ id: this.maxId, - credentials: useUsersStore().currentUser.credentials, + credentials: useOAuthStore().token, }).then(() => { closeAllDesktopNotifications() }) }, - markSingleNotificationAsSeen({ id }) { + markSingleNotificationAsSeen(id) { const notification = this.idStore.get(id) if (notification) notification.seen = true markNotificationsAsSeen({ single: true, id, - credentials: useUsersStore().currentUser.credentials, + credentials: useOAuthStore().token, }).then(() => { closeDesktopNotification(id) }) }, dismissNotificationLocal(id) { - this.data = this.data.filter((n) => n.id !== id) - delete this.idStore.delete(id) + this.idStore.delete(id) + this.syncOrder() }, dismissNotification(id) { this.dismissNotificationLocal(id) @@ -254,10 +261,27 @@ export const useNotificationsStore = defineStore('notifications', { credentials: useOAuthStore().token, }) }, + syncOrder() { + this.minId = '' + this.maxId = '' + this.data = this.data.filter(({ id }) => { + const present = this.idStore.has(id) + if (present) { + this.updateExtremes(id) // Side-effect + } + return present + }) + }, + wipeStatuses(ids) { + const set = new Set(ids) + this.data.forEach((notification) => { + const status = isStatusNotification(notification.type) && notification.status + if (status && set.has(status.id)) { + this.idStore.delete(notification.id) + } + }) - // Misc - setLoading(value) { - this.loading = value + this.syncOrder() }, }, }) diff --git a/src/stores/search.js b/src/stores/search.js index 06fe73634..ad85712c4 100644 --- a/src/stores/search.js +++ b/src/stores/search.js @@ -4,7 +4,7 @@ import { useOAuthStore } from 'src/stores/oauth.js' import { useStatusesStore } from 'src/stores/statuses.js' import { useUsersStore } from 'src/stores/users.js' -import { search2 } from 'src/api/public.js' +import { search2, searchUsers } from 'src/api/public.js' export const useSearchStore = defineStore('search', { actions: { diff --git a/src/stores/statuses.js b/src/stores/statuses.js index f63efd35c..91a3448a8 100644 --- a/src/stores/statuses.js +++ b/src/stores/statuses.js @@ -11,7 +11,6 @@ import { fetchRebloggedByUsers, fetchStatus, fetchStatusSource, - search2, } from 'src/api/public.js' import { bookmarkStatus, diff --git a/src/stores/timelines.js b/src/stores/timelines.js index 9a329cdff..df1c41a69 100644 --- a/src/stores/timelines.js +++ b/src/stores/timelines.js @@ -1,13 +1,10 @@ -import { first, last, max, min } from 'lodash' +import { first, last } from 'lodash' import { defineStore } from 'pinia' +import timelineFetcher from 'src/stores/fetchers/timeline_fetcher.js' import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' import { useOAuthStore } from 'src/stores/oauth.js' -import { useStatusesStore } from 'src/stores/statuses.js' import { TIMELINE_STREAM_MAP, useStreamingStore } from 'src/stores/streaming.js' -import { useUsersStore } from 'src/stores/users.js' - -import timelineFetcher from 'src/stores/fetchers/timeline_fetcher.js' const emptyTl = (name, argument = null) => { const result = { @@ -164,7 +161,7 @@ export const useTimelinesStore = defineStore('timelines', { try { this.deactivate(name, true) } catch (e) { - console.error(`Failed to deactivate timeline ${name}`) + console.error(`Failed to deactivate timeline ${name}:`, e) } }) }, @@ -178,7 +175,7 @@ export const useTimelinesStore = defineStore('timelines', { showImmediately = false, noIdUpdate = false, pagination = {}, - older = false + older = false, }, ) { if (statuses.length === 0) return @@ -195,10 +192,7 @@ export const useTimelinesStore = defineStore('timelines', { } if (!noIdUpdate) { - this.updateTimelineExtremes( - timeline, - pagination, - ) + this.updateTimelineExtremes(timeline, pagination) } const filtered = statuses.filter((id) => !timeline.statusIds.has(id)) diff --git a/src/stores/users.js b/src/stores/users.js index 98589f020..10d9222c9 100644 --- a/src/stores/users.js +++ b/src/stores/users.js @@ -25,7 +25,6 @@ import { fetchFriends, fetchUser, fetchUserByName, - searchUsers, verifyCredentials, } from 'src/api/public.js' import { diff --git a/test/unit/specs/stores/notifications.spec.js b/test/unit/specs/stores/notifications.spec.js new file mode 100644 index 000000000..6947607fb --- /dev/null +++ b/test/unit/specs/stores/notifications.spec.js @@ -0,0 +1,379 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' + +import { useNotificationsStore } from 'src/stores/notifications.js' +import { useStatusesStore } from 'src/stores/statuses.js' +import { useUsersStore } from 'src/stores/users.js' +import { useReportsStore } from 'src/stores/reports.js' +import { useStreamingStore } from 'src/stores/streaming.js' +import { useI18nStore } from 'src/stores/i18n.js' + +import * as USER_API from 'src/api/user.js' + +const userId = '1' +const userScreenName = 'user' +const userName = 'Guy' +const userUrl = 'http://localhost/user' + +const mockMastoAPIUser = ({ + screen_name = userScreenName, + name = userName, + url = userUrl, + id = userId, +} = {}) => ({ + id, + acct: screen_name, + display_name: name, + fields: [], + avatar: '', + url, + pleroma: { + emoji_reactions: [], + }, +}) + +const mockUser = ({ + screen_name = userScreenName, + id = userId, + name = userName, + url = userUrl, +} = {}) => ({ + _original: mockMastoAPIUser({ + screen_name, + id, + name, + url, + }), + id, + name, + screen_name, + url, + relationship: undefined, +}) + +const mockStatus = ({ + id = '1', + text, + summary, + type = 'status', + statusUser = mockUser(), +} = {}) => ({ + id, + user: statusUser, + summary: summary ?? `Summary number ${id}`, + name: 'status', + text: text ?? `Text number ${id}`, + uri: '', + type, + attentions: [], + statusnet_conversation_id: 'c1', + emoji_reactions: [], +}) + +const mockStatusNotification = ({ + id = '1', + type = 'like', + status = mockStatus({ id }), + seen = false, + user = mockUser(), +} = {}) => ({ + type, + id, + status, + seen, + user, + from_profile: user, +}) + +const DEFAULT_OPTIONS = (method = 'POST') => ({ + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + }, +}) + +describe('Notifications store', () => { + beforeEach(() => { + vi.useFakeTimers() + setActivePinia(createTestingPinia({ stubActions: false })) + useI18nStore().i18n = { t: () => { /* no-op */} } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('activate', () => { + const store = useNotificationsStore() + const sub = vi.fn() + useStreamingStore().addSubscriber = sub + + store.activate() + + expect(sub).to.have.been.called + expect(store.fetcher).to.not.be.null + expect(store.socket).to.not.be.null + + store.deactivate() + }) + + it('deactivate', () => { + const store = useNotificationsStore() + const unsub = vi.fn() + useStreamingStore().removeSubscriber = unsub + + store.activate() + // Checking so that they were set properly before + // since reset changes them to '' + store.maxId = '2' + store.minId = '1' + store.idStore = new Map() + store.idStore.set('1', {}) + store.idStore.set('2', {}) + store.deactivate() + + expect(unsub).to.have.been.called + expect(store.fetcher).to.be.null + expect(store.socket).to.be.null + expect(store.idStore).to.have.length(0) + expect(store).to.have.property('maxId', '') + expect(store).to.have.property('minId', '') + }) + + it('updateExtremes should update min and max ids', () => { + const store = useNotificationsStore() + store.maxId = '10' + store.minId = '05' + + store.updateExtremes('04') + store.updateExtremes('11') + + expect(store).to.have.property('maxId', '11') + expect(store).to.have.property('minId', '04') + }) + + describe('addNewNotifications', () => { + it('adds notifications to the list', () => { + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: 'a' }), + mockStatusNotification({ id: 'b' }), + ], + }) + + // must be ordered + expect(store.data.map(({ id }) => id)).to.eql(['a', 'b']) + expect(store.idStore).to.have.keys(['a', 'b']) + expect(store).to.have.property('maxId', 'b') + expect(store).to.have.property('minId', 'a') + }) + + it('ignores duplicates', () => { + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: '2' }), + mockStatusNotification({ id: '1' }), + ], + }) + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: '3' }), + mockStatusNotification({ id: '2' }), + ], + }) + + // must be ordered + expect(store.data.map(({ id }) => id)).to.eql(['3', '2', '1']) + expect(store.idStore).to.have.keys(['1', '2', '3']) + expect(store).to.have.property('maxId', '3') + expect(store).to.have.property('minId', '1') + }) + + it('appends notifications if fetching older', () => { + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: '4' }), + mockStatusNotification({ id: '3' }), + ], + }) + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: '2' }), + mockStatusNotification({ id: '1' }), + ], + }, true) + + // must be ordered + expect(store.data.map(({ id }) => id)).to.eql(['4', '3', '2', '1']) + expect(store.idStore).to.have.keys(['1', '2', '3', '4']) + expect(store).to.have.property('maxId', '4') + expect(store).to.have.property('minId', '1') + }) + + it('should update usersStore', () => { + const store = useNotificationsStore() + const mock = vi.spyOn(useUsersStore(), 'addNewUsers') + const mockedNotification = mockStatusNotification({ type: 'follow' }) + + store.addNewNotifications({ + timestamp: 1337, + data: [mockedNotification], + }) + + expect(mock).to.have.been.called + expect(mock.mock.calls[0][0]).to.have.property('timestamp', 1337) + expect(mock.mock.calls[0][0].data[0]).to.eql(mockedNotification.from_profile) + }) + + it('should update reportsStore', (notificationType) => { + const store = useNotificationsStore() + const mock = vi.spyOn(useReportsStore(), 'addReport') + const mockedNotification = mockStatusNotification({ type: 'pleroma:report' }) + mockedNotification.report = { data: '123' } + + store.addNewNotifications({ + timestamp: 1337, + data: [mockedNotification], + }) + + expect(mock).to.have.been.calledWith({ data: '123' }) + }) + + it.each([ + 'like', + 'mention', + 'status', + 'repeat', + 'pleroma:emoji_reaction', + 'poll', + ])('should update statusesStore on %s notification', (notificationType) => { + const store = useNotificationsStore() + const mock = vi.fn() + useStatusesStore().addNewStatuses = mock + const mockedNotification = mockStatusNotification({ type: notificationType }) + + store.addNewNotifications({ + timestamp: 1337, + data: [mockedNotification], + }) + + expect(mock).to.have.been.called + expect(mock.mock.calls[0][0]).to.have.property('timestamp', 1337) + expect(mock.mock.calls[0][0].statuses[0]).to.eql(mockedNotification.status) + }) + }) + + describe('wipeStatuses', () => { + it('clears all statuses', () => { + const store = useNotificationsStore() + + store.addNewNotifications({ + timestamp: 1, + data: [ + mockStatusNotification({ id: 'n2', status: mockStatus({ id: 's2' }) }), + mockStatusNotification({ id: 'n1', status: mockStatus({ id: 's1' }) }), + ], + }) + + store.wipeStatuses(['s2']) + expect(store.idStore).to.not.have.members('n2') + expect(store.data.map(({ id }) => id)).to.eql(['n1']) + }) + }) + + describe('read/dismiss', () => { + it('read single', () => { + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useNotificationsStore() + const mockedNotification = mockStatusNotification() + + store.addNewNotifications({ + timestamp: 1337, + data: [ + mockStatusNotification({ id: 'n3' }), + mockStatusNotification({ id: 'n2' }), + mockStatusNotification({ id: 'n1' }), + ], + }) + + expect(store.data[1]).to.have.property('seen', false) + + store.markSingleNotificationAsSeen('n2') + + expect(store.data[0]).to.have.property('seen', false) + expect(store.data[1]).to.have.property('seen', true) + expect(store.data[0]).to.have.property('seen', false) + + const calls = mockFetch.mock.calls + expect(calls).to.have.length(1) + const callOne = calls[0] + expect(callOne[0]).to.eql(USER_API.NOTIFICATION_READ_URL) + const formData = Object.fromEntries(callOne[1].body.entries()) + expect(formData).to.eql({ id: 'n2' }) + }) + + it('read all', () => { + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + + vi.stubGlobal('fetch', mockFetch) + + const store = useNotificationsStore() + const mockedNotification = mockStatusNotification() + + // FormData is weird to test + store.addNewNotifications({ + timestamp: 1337, + data: [ + mockStatusNotification({ id: 'n3' }), + mockStatusNotification({ id: 'n2' }), + mockStatusNotification({ id: 'n1' }), + ], + }) + + expect(store.data[0]).to.have.property('seen', false) + expect(store.data[1]).to.have.property('seen', false) + expect(store.data[2]).to.have.property('seen', false) + + store.markNotificationsAsSeen() + + expect(store.data[0]).to.have.property('seen', true) + expect(store.data[1]).to.have.property('seen', true) + expect(store.data[2]).to.have.property('seen', true) + + // FormData is weird to test + const calls = mockFetch.mock.calls + expect(calls).to.have.length(1) + const callOne = calls[0] + expect(callOne[0]).to.eql(USER_API.NOTIFICATION_READ_URL) + const formData = Object.fromEntries(callOne[1].body.entries()) + expect(formData).to.eql({ max_id: 'n3' }) + }) + }) +}) diff --git a/test/unit/specs/stores/timelines.spec.js b/test/unit/specs/stores/timelines.spec.js index df71a37a6..f810c029d 100644 --- a/test/unit/specs/stores/timelines.spec.js +++ b/test/unit/specs/stores/timelines.spec.js @@ -1,9 +1,8 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' -import { useStatusesStore } from 'src/stores/statuses.js' -import { useTimelinesStore } from 'src/stores/timelines.js' import { useStreamingStore } from 'src/stores/streaming.js' +import { useTimelinesStore } from 'src/stores/timelines.js' describe('Timelines store', () => { beforeEach(() => { @@ -49,14 +48,15 @@ describe('Timelines store', () => { const store = useTimelinesStore() const unsub = vi.fn() useStreamingStore().removeSubscriber = unsub + useStreamingStore().addSubscriber = vi.fn() store.activate('friends', undefined, true) // Checking so that they were set properly before // since reset changes them to '' store.friends.maxId = '3' store.friends.minId = '4' - store.friends.statusIds = new Set(['3','4']) - store.friends.visibleStausIds = new Set(['3','4']) + store.friends.statusIds = new Set(['3', '4']) + store.friends.visibleStausIds = new Set(['3', '4']) store.deactivate('friends', true) expect(unsub).to.have.been.called @@ -72,14 +72,15 @@ describe('Timelines store', () => { const store = useTimelinesStore() const unsub = vi.fn() useStreamingStore().removeSubscriber = unsub + useStreamingStore().addSubscriber = vi.fn() store.activate('user', '1') // Checking so that they were set properly before // since reset changes them to '' store.user.maxId = '3' store.user.minId = '4' - store.user.statusIds = new Set(['3','4']) - store.user.visibleStausIds = new Set(['3','4']) + store.user.statusIds = new Set(['3', '4']) + store.user.visibleStausIds = new Set(['3', '4']) store.deactivate('user') expect(unsub).to.not.have.been.called @@ -96,7 +97,7 @@ describe('Timelines store', () => { it('should derive extremes from data', () => { const store = useTimelinesStore() const timeline = useTimelinesStore().friends - timeline.order = ['4','1','3','2'] + timeline.order = ['4', '1', '3', '2'] timeline.statusesIds = new Set(timeline.order) store.updateTimelineExtremes(timeline) @@ -107,10 +108,7 @@ describe('Timelines store', () => { it('should use extremes from pagination', () => { const store = useTimelinesStore() const timeline = useTimelinesStore().friends - store.updateTimelineExtremes( - timeline, - { maxId: '1', minId: '2' } - ) + store.updateTimelineExtremes(timeline, { maxId: '1', minId: '2' }) // Min and max are swapped! expect(store.friends).to.have.property('maxId', '2') @@ -121,17 +119,13 @@ describe('Timelines store', () => { describe('addStatusesToTimeline', () => { it('adds the status to the given timeline', () => { const store = useTimelinesStore() - const statuses = ['1','2','3'] + const statuses = ['1', '2', '3'] store.activate('list', '1') - store.addStatusesToTimeline( - 'list', - '1', - { - statuses, - pagination: { minId: '1', maxId: '3' } - } - ) + store.addStatusesToTimeline('list', '1', { + statuses, + pagination: { minId: '1', maxId: '3' }, + }) expect(store.list.order).to.eql(statuses) expect(store.list.statusIds).to.eql(new Set(statuses)) @@ -143,25 +137,17 @@ describe('Timelines store', () => { it('ignores duplicates', () => { const store = useTimelinesStore() - const statuses = ['1','2','3'] + const statuses = ['1', '2', '3'] store.activate('list', '1') - store.addStatusesToTimeline( - 'list', - '1', - { - statuses, - pagination: { minId: '1', maxId: '3' } - } - ) - store.addStatusesToTimeline( - 'list', - '1', - { - statuses, - pagination: { minId: '1', maxId: '3' } - } - ) + store.addStatusesToTimeline('list', '1', { + statuses, + pagination: { minId: '1', maxId: '3' }, + }) + store.addStatusesToTimeline('list', '1', { + statuses, + pagination: { minId: '1', maxId: '3' }, + }) expect(store.list.order).to.eql(statuses) expect(store.list.statusIds).to.eql(new Set(statuses)) @@ -173,18 +159,14 @@ describe('Timelines store', () => { it('adds the status the given timeline, directly visible', () => { const store = useTimelinesStore() - const statuses = ['1','2','3'] + const statuses = ['1', '2', '3'] store.activate('list', '1') - store.addStatusesToTimeline( - 'list', - '1', - { - statuses, - showImmediately: true, - pagination: { minId: '1', maxId: '3' } - } - ) + store.addStatusesToTimeline('list', '1', { + statuses, + showImmediately: true, + pagination: { minId: '1', maxId: '3' }, + }) expect(store.list.order).to.eql(statuses) expect(store.list.statusIds).to.eql(new Set(statuses)) @@ -196,18 +178,14 @@ describe('Timelines store', () => { it('does not update the maxId when the noIdUpdate flag is set', () => { const store = useTimelinesStore() - const statuses = ['1','2','3'] + const statuses = ['1', '2', '3'] store.activate('list', '1') - store.addStatusesToTimeline( - 'list', - '1', - { - statuses, - noIdUpdate: true, - pagination: { minId: '1', maxId: '3' } - } - ) + store.addStatusesToTimeline('list', '1', { + statuses, + noIdUpdate: true, + pagination: { minId: '1', maxId: '3' }, + }) expect(store.list.order).to.eql(statuses) expect(store.list.statusIds).to.eql(new Set(statuses)) @@ -219,18 +197,14 @@ describe('Timelines store', () => { it('does not update timeline if it belongs to a different arugment', () => { const store = useTimelinesStore() - const statuses = ['1','2','3'] + const statuses = ['1', '2', '3'] store.activate('list', '1') - store.addStatusesToTimeline( - 'list', - '2', - { - statuses, - noIdUpdate: true, - pagination: { minId: '1', maxId: '3' } - } - ) + store.addStatusesToTimeline('list', '2', { + statuses, + noIdUpdate: true, + pagination: { minId: '1', maxId: '3' }, + }) expect(store.list.order).to.eql([]) expect(store.list.statusIds).to.eql(new Set()) @@ -242,26 +216,18 @@ describe('Timelines store', () => { it('prepends timeline with new statuses', () => { const store = useTimelinesStore() - const statuses1 = ['3','2','1'] - const statuses2 = ['6','5','4'] + const statuses1 = ['3', '2', '1'] + const statuses2 = ['6', '5', '4'] store.activate('list', '1') - store.addStatusesToTimeline( - 'list', - '1', - { - statuses: statuses1, - pagination: { minId: '3', maxId: '1' } - } - ) - store.addStatusesToTimeline( - 'list', - '1', - { - statuses: statuses2, - pagination: { minId: '6', maxId: '4' } - } - ) + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + pagination: { minId: '3', maxId: '1' }, + }) + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + pagination: { minId: '6', maxId: '4' }, + }) const newOrder = [...statuses2, ...statuses1] expect(store.list.order).to.eql(newOrder) @@ -274,27 +240,19 @@ describe('Timelines store', () => { it('appends timeline with new statuses if fetching older', () => { const store = useTimelinesStore() - const statuses1 = ['6','5','4'] - const statuses2 = ['3','2','1'] + const statuses1 = ['6', '5', '4'] + const statuses2 = ['3', '2', '1'] store.activate('list', '1') - store.addStatusesToTimeline( - 'list', - '1', - { - statuses: statuses1, - pagination: { minId: '6', maxId: '4' }, - } - ) - store.addStatusesToTimeline( - 'list', - '1', - { - statuses: statuses2, - pagination: { minId: '3', maxId: '1' }, - older: true, - } - ) + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + pagination: { minId: '6', maxId: '4' }, + }) + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + pagination: { minId: '3', maxId: '1' }, + older: true, + }) const newOrder = [...statuses1, ...statuses2] expect(store.list.order).to.eql(newOrder) @@ -309,17 +267,13 @@ describe('Timelines store', () => { describe('showNewStatuses', () => { it('resets counter and makes all ids visible', () => { const store = useTimelinesStore() - const statuses = ['1','2','3'] + const statuses = ['1', '2', '3'] store.activate('public') - store.addStatusesToTimeline( - 'public', - undefined, - { - statuses, - pagination: { minId: '1', maxId: '3' } - } - ) + store.addStatusesToTimeline('public', undefined, { + statuses, + pagination: { minId: '1', maxId: '3' }, + }) expect(store.public.statusIds).to.eql(new Set(statuses)) expect(store.public.visibleStatusIds).to.eql(new Set()) @@ -336,20 +290,12 @@ describe('Timelines store', () => { store.activate('friends') store.activate('public') - store.addStatusesToTimeline( - 'public', - undefined, - { - statuses: ['1','2','3','0'] - } - ) - store.addStatusesToTimeline( - 'friends', - undefined, - { - statuses: ['5','0','9','1'] - } - ) + store.addStatusesToTimeline('public', undefined, { + statuses: ['1', '2', '3', '0'], + }) + store.addStatusesToTimeline('friends', undefined, { + statuses: ['5', '0', '9', '1'], + }) store.wipeStatuses(['0']) expect(store.friends.statusIds).to.not.have.members('0') expect(store.public.statusIds).to.not.have.members('0')