From cb25b392cfd6b4ac5175ef06bafe08ca6e310e7e Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Tue, 11 Aug 2026 18:54:54 +0300 Subject: [PATCH] TIMELINES STORE YEAH BABY --- src/boot/routes.js | 51 ++-- .../bookmark_timeline/bookmark_timeline.js | 40 --- .../bookmark_timeline/bookmark_timeline.vue | 10 - .../bubble_timeline/bubble_timeline.js | 20 -- .../lists_timeline/lists_timeline.js | 48 ---- .../lists_timeline/lists_timeline.vue | 10 - src/components/mentions/mentions.js | 14 -- src/components/mentions/mentions.vue | 9 - .../quotes_timeline/quotes_timeline.js | 38 --- .../quotes_timeline/quotes_timeline.vue | 10 - src/components/tag_timeline/tag_timeline.js | 38 --- src/components/timeline/timeline.js | 104 +++----- src/components/user_profile/user_profile.js | 38 +-- src/components/user_profile/user_profile.vue | 29 ++- src/modules/api.js | 49 ---- .../notification_utils/notification_utils.js | 5 +- .../timeline_fetcher.service.js | 172 ++++++------- src/stores/notifications.js | 4 +- src/stores/statuses.js | 235 ++---------------- src/stores/timelines.js | 233 +++++++++++++++++ src/stores/users.js | 3 +- 21 files changed, 426 insertions(+), 734 deletions(-) delete mode 100644 src/components/bookmark_timeline/bookmark_timeline.js delete mode 100644 src/components/bookmark_timeline/bookmark_timeline.vue delete mode 100644 src/components/bubble_timeline/bubble_timeline.js delete mode 100644 src/components/lists_timeline/lists_timeline.js delete mode 100644 src/components/lists_timeline/lists_timeline.vue delete mode 100644 src/components/mentions/mentions.js delete mode 100644 src/components/mentions/mentions.vue delete mode 100644 src/components/quotes_timeline/quotes_timeline.js delete mode 100644 src/components/quotes_timeline/quotes_timeline.vue delete mode 100644 src/components/tag_timeline/tag_timeline.js create mode 100644 src/stores/timelines.js diff --git a/src/boot/routes.js b/src/boot/routes.js index 3b21d874d..8f8335c9c 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -1,10 +1,7 @@ import AuthForm from 'src/components/auth_form/auth_form.js' -import BookmarkTimeline from 'src/components/bookmark_timeline/bookmark_timeline.vue' import ConversationPage from 'src/components/conversation-page/conversation-page.vue' import NavPanel from 'src/components/nav_panel/nav_panel.vue' -import QuotesTimeline from 'src/components/quotes_timeline/quotes_timeline.vue' import RemoteUserResolver from 'src/components/remote_user_resolver/remote_user_resolver.vue' -import TagTimeline from 'src/components/tag_timeline/tag_timeline.vue' import Timeline from 'src/components/timeline/timeline.vue' import { useInstanceStore } from 'src/stores/instance.js' @@ -40,7 +37,7 @@ export default (store) => { path: '/main/all', component: Timeline, props: () => ({ - timelineName: 'publicAndExternal', + timelineRef: { name: 'publicAndExternal' }, }), }, { @@ -48,7 +45,7 @@ export default (store) => { path: '/main/public', component: Timeline, props: () => ({ - timelineName: 'public', + timelineRef: { name: 'public' }, }), }, { @@ -57,17 +54,31 @@ export default (store) => { component: Timeline, beforeEnter: validateAuthenticatedRoute, props: () => ({ - timelineName: 'friends', + timelineRef: { name: 'friends' }, + }), + }, + { + name: 'tag-timeline', + path: '/tag/:id', + component: Timeline, + props: (route) => ({ + timelineRef: { name: 'tag', argument: route.params.id }, + }), + }, + { + name: 'bookmarks', + path: '/bookmarks', + component: Timeline, + props: (route) => ({ + timelineRef: { name: 'bookmarks', argument: null }, }), }, - { name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline }, - { name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline }, { name: 'bubble', path: '/bubble', component: Timeline, props: () => ({ - timelineName: 'bubble', + timelineRef: { name: 'bubble' }, }), }, { @@ -84,7 +95,14 @@ export default (store) => { meta: { dontScroll: true }, beforeEnter: validateAuthenticatedRoute, }, - { name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline }, + { + name: 'quotes', + path: '/notice/:id/quotes', + component: Timeline, + props: (route) => ({ + timelineRef: { name: 'quotes', argument: route.params.id }, + }), + }, { name: 'remote-user-profile-acct', path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)', @@ -120,7 +138,7 @@ export default (store) => { component: Timeline, beforeEnter: validateAuthenticatedRoute, props: () => ({ - timelineName: 'dms', + timelineRef: { name: 'dms' }, }), }, { @@ -218,8 +236,10 @@ export default (store) => { { name: 'lists-timeline', path: '/lists/:id', - component: () => - import('src/components/lists_timeline/lists_timeline.vue'), + component: Timeline, + props: (route) => ({ + timelineRef: { name: 'lists', argument: route.params.id }, + }), }, { name: 'lists-edit', @@ -253,7 +273,10 @@ export default (store) => { { name: 'bookmark-folder', path: '/bookmarks/:id', - component: BookmarkTimeline, + component: Timeline, + props: (route) => ({ + timelineRef: { name: 'bookmarks', argument: route.params.id }, + }), }, { name: 'bookmark-folder-edit', diff --git a/src/components/bookmark_timeline/bookmark_timeline.js b/src/components/bookmark_timeline/bookmark_timeline.js deleted file mode 100644 index dbda08ba1..000000000 --- a/src/components/bookmark_timeline/bookmark_timeline.js +++ /dev/null @@ -1,40 +0,0 @@ -import Timeline from 'src/components/timeline/timeline.vue' - -import { useStatusesStore } from 'src/stores/statuses.js' - -const Bookmarks = { - created() { - useStatusesStore().clearTimeline({ timeline: 'bookmarks' }) - this.$store.dispatch('startFetchingTimeline', { - timeline: 'bookmarks', - bookmarkFolderId: this.folderId || null, - }) - }, - components: { - Timeline, - }, - computed: { - folderId() { - return this.$route.params.id - }, - timeline() { - return this.$store.state.statuses.timelines.bookmarks - }, - }, - watch: { - folderId() { - useStatusesStore().clearTimeline({ timeline: 'bookmarks' }) - this.$store.dispatch('stopFetchingTimeline', 'bookmarks') - this.$store.dispatch('startFetchingTimeline', { - timeline: 'bookmarks', - bookmarkFolderId: this.folderId || null, - }) - }, - }, - unmounted() { - useStatusesStore().clearTimeline({ timeline: 'bookmarks' }) - this.$store.dispatch('stopFetchingTimeline', 'bookmarks') - }, -} - -export default Bookmarks diff --git a/src/components/bookmark_timeline/bookmark_timeline.vue b/src/components/bookmark_timeline/bookmark_timeline.vue deleted file mode 100644 index cc86cf12e..000000000 --- a/src/components/bookmark_timeline/bookmark_timeline.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/components/bubble_timeline/bubble_timeline.js b/src/components/bubble_timeline/bubble_timeline.js deleted file mode 100644 index fcbc31ad2..000000000 --- a/src/components/bubble_timeline/bubble_timeline.js +++ /dev/null @@ -1,20 +0,0 @@ -import Timeline from 'src/components/timeline/timeline.vue' - -const BubbleTimeline = { - components: { - Timeline, - }, - computed: { - timeline() { - return this.$store.state.statuses.timelines.bubble - }, - }, - created() { - this.$store.dispatch('startFetchingTimeline', { timeline: 'bubble' }) - }, - unmounted() { - this.$store.dispatch('stopFetchingTimeline', 'bubble') - }, -} - -export default BubbleTimeline diff --git a/src/components/lists_timeline/lists_timeline.js b/src/components/lists_timeline/lists_timeline.js deleted file mode 100644 index ae58e58ed..000000000 --- a/src/components/lists_timeline/lists_timeline.js +++ /dev/null @@ -1,48 +0,0 @@ -import Timeline from 'src/components/timeline/timeline.vue' - -import { useListsStore } from 'src/stores/lists.js' -import { useStatusesStore } from 'src/stores/statuses.js' - -const ListsTimeline = { - data() { - return { - listId: null, - } - }, - components: { - Timeline, - }, - computed: { - timeline() { - return this.$store.state.statuses.timelines.list - }, - }, - watch: { - $route: function (route) { - if (route.name === 'lists-timeline' && route.params.id !== this.listId) { - this.listId = route.params.id - this.$store.dispatch('stopFetchingTimeline', 'list') - useStatusesStore().clearTimeline({ timeline: 'list' }) - useListsStore().fetchList({ listId: this.listId }) - this.$store.dispatch('startFetchingTimeline', { - timeline: 'list', - listId: this.listId, - }) - } - }, - }, - created() { - this.listId = this.$route.params.id - useListsStore().fetchList({ listId: this.listId }) - this.$store.dispatch('startFetchingTimeline', { - timeline: 'list', - listId: this.listId, - }) - }, - unmounted() { - this.$store.dispatch('stopFetchingTimeline', 'list') - useStatusesStore().clearTimeline({ timeline: 'list' }) - }, -} - -export default ListsTimeline diff --git a/src/components/lists_timeline/lists_timeline.vue b/src/components/lists_timeline/lists_timeline.vue deleted file mode 100644 index 18156b812..000000000 --- a/src/components/lists_timeline/lists_timeline.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/components/mentions/mentions.js b/src/components/mentions/mentions.js deleted file mode 100644 index 48e06a950..000000000 --- a/src/components/mentions/mentions.js +++ /dev/null @@ -1,14 +0,0 @@ -import Timeline from 'src/components/timeline/timeline.vue' - -const Mentions = { - computed: { - timeline() { - return this.$store.state.statuses.timelines.mentions - }, - }, - components: { - Timeline, - }, -} - -export default Mentions diff --git a/src/components/mentions/mentions.vue b/src/components/mentions/mentions.vue deleted file mode 100644 index 70f60bafe..000000000 --- a/src/components/mentions/mentions.vue +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/src/components/quotes_timeline/quotes_timeline.js b/src/components/quotes_timeline/quotes_timeline.js deleted file mode 100644 index 4778f1daa..000000000 --- a/src/components/quotes_timeline/quotes_timeline.js +++ /dev/null @@ -1,38 +0,0 @@ -import Timeline from 'src/components/timeline/timeline.vue' - -import { useStatusesStore } from 'src/stores/statuses.js' - -const QuotesTimeline = { - created() { - useStatusesStore().clearTimeline({ timeline: 'tag' }) - this.$store.dispatch('startFetchingTimeline', { - timeline: 'quotes', - statusId: this.statusId, - }) - }, - components: { - Timeline, - }, - computed: { - statusId() { - return this.$route.params.id - }, - timeline() { - return this.$store.state.statuses.timelines.quotes - }, - }, - watch: { - statusId() { - useStatusesStore().clearTimeline({ timeline: 'tag' }) - this.$store.dispatch('startFetchingTimeline', { - timeline: 'quotes', - statusId: this.statusId, - }) - }, - }, - unmounted() { - this.$store.dispatch('stopFetchingTimeline', 'quotes') - }, -} - -export default QuotesTimeline diff --git a/src/components/quotes_timeline/quotes_timeline.vue b/src/components/quotes_timeline/quotes_timeline.vue deleted file mode 100644 index 835abd12e..000000000 --- a/src/components/quotes_timeline/quotes_timeline.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/components/tag_timeline/tag_timeline.js b/src/components/tag_timeline/tag_timeline.js deleted file mode 100644 index da3fcb693..000000000 --- a/src/components/tag_timeline/tag_timeline.js +++ /dev/null @@ -1,38 +0,0 @@ -import Timeline from 'src/components/timeline/timeline.vue' - -import { useStatusesStore } from 'src/stores/statuses.js' - -const TagTimeline = { - created() { - useStatusesStore().clearTimeline({ timeline: 'tag' }) - this.$store.dispatch('startFetchingTimeline', { - timeline: 'tag', - tag: this.tag, - }) - }, - components: { - Timeline, - }, - computed: { - tag() { - return this.$route.params.tag - }, - timeline() { - return this.$store.state.statuses.timelines.tag - }, - }, - watch: { - tag() { - useStatusesStore().clearTimeline({ timeline: 'tag' }) - this.$store.dispatch('startFetchingTimeline', { - timeline: 'tag', - tag: this.tag, - }) - }, - }, - unmounted() { - this.$store.dispatch('stopFetchingTimeline', 'tag') - }, -} - -export default TagTimeline diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index e3ba35e07..040ae80e7 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -10,10 +10,9 @@ import TimelineMenu from 'src/components/timeline_menu/timeline_menu.vue' import { useInterfaceStore } from 'src/stores/interface.js' import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useStatusesStore } from 'src/stores/statuses.js' +import { useTimelinesStore } from 'src/stores/timelines.js' import { useUsersStore } from 'src/stores/users.js' -import timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.js' - import { library } from '@fortawesome/fontawesome-svg-core' import { faArrowUp, @@ -28,12 +27,8 @@ library.add(faCircleNotch, faCog, faMinus, faArrowUp, faCirclePlus, faCheck) const Timeline = { props: { - timelineName: String, - userId: String, - listId: String, - statusId: String, - bookmarkFolderId: String, - tag: String, + timelineRef: Object, + argument: String, embedded: Boolean, count: Number, pinnedStatusIds: Set, @@ -59,15 +54,16 @@ const Timeline = { }, computed: { timeline() { - return useStatusesStore().timelines[this.timelineName] + return useTimelinesStore()[this.timelineRef.name] }, filteredVisibleStatuses() { - return [...this.timeline.visibleStatuses.values()].filter( - (status) => - this.timelineName !== 'user' || - (status.id >= this.timeline.minId && - status.id <= this.timeline.maxId), - ) + return [...this.timeline.visibleStatusesIds.keys()] + .filter( + (id) => + this.timelineRef.name !== 'user' || + (id >= this.timeline.minId && id <= this.timeline.maxId), + ) + .map((id) => this.timeline.statuses.get(id)) }, filteredPinnedStatusIds() { return (this.pinnedStatusIds || []).filter( @@ -118,13 +114,15 @@ const Timeline = { return keyBy(this.pinnedStatusIds) }, statusesToDisplay() { - const amount = this.timeline.visibleStatuses.size + const amount = this.timeline.visibleStatusesIds.size const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80)) const nonPinnedIndex = this.virtualScrollIndex - this.filteredPinnedStatusIds.length const min = Math.max(0, nonPinnedIndex - statusesPerSide) const max = Math.min(amount, nonPinnedIndex + statusesPerSide) - return new Set([...this.timeline.visibleStatuses.keys()].slice(min, max)) + return new Set( + [...this.timeline.visibleStatusesIds.keys()].slice(min, max), + ) }, virtualScrollingEnabled() { return useMergedConfigStore().mergedConfig.virtualScrolling @@ -134,26 +132,7 @@ const Timeline = { }), }, created() { - const store = this.$store - const credentials = useUsersStore().currentUser.credentials - const showImmediately = this.timeline.visibleStatuses.length === 0 - - window.addEventListener('scroll', this.handleScroll) - - if (store.state.api.fetchers[this.timelineName]) { - return false - } - - timelineFetcher.fetchAndUpdate({ - credentials, - timeline: this.timelineName, - showImmediately, - userId: this.userId, - listId: this.listId, - statusId: this.statusId, - bookmarkFolderId: this.bookmarkFolderId, - tag: this.tag, - }) + this.timelineChange(this.timelineRef) }, mounted() { if (document.hidden !== undefined) { @@ -165,6 +144,7 @@ const Timeline = { this.unfocused = document.hidden } window.addEventListener('keydown', this.handleShortKey) + window.addEventListener('scroll', this.handleScroll) setTimeout(this.determineVisibleStatuses, 250) }, unmounted() { @@ -176,12 +156,14 @@ const Timeline = { this.handleVisibilityChange, false, ) - useStatusesStore().setLoading({ - timeline: this.timelineName, - value: false, - }) }, methods: { + timelineChange(newTimeline, oldTimeline) { + if (oldTimeline && oldTimeline.name !== 'friends') { + useTimelinesStore().clearTimeline(oldTimeline.name) + } + useTimelinesStore().startFetchingTimeline(newTimeline.name, newTimeline.argument) + }, stopBlockingClicks: debounce(function () { this.blockingClicks = false }, 1000), @@ -198,52 +180,25 @@ const Timeline = { }, showNewStatuses() { if (this.timeline.flushMarker !== 0) { - useStatusesStore().clearTimeline({ - timeline: this.timelineName, - excludeUserId: true, - }) - useStatusesStore().queueFlush({ timeline: this.timelineName, id: 0 }) - if (this.timelineName === 'user') { - this.$store.dispatch('fetchPinnedStatuses', this.userId) - } + useTimelinesStore().clearTimeline(this.timelineRef.name) + useTimelinesStore().queueFlush(this.timelineRef.name, '') this.fetchOlderStatuses() } else { this.blockClicksTemporarily() - useStatusesStore().showNewStatuses(this.timelineName) + useTimelinesStore().showNewStatuses(this.timelineRef.name) this.paused = false } window.scrollTo({ top: 0 }) }, fetchOlderStatuses: throttle( function () { - const credentials = useUsersStore().currentUser.credentials - useStatusesStore().setLoading({ - timeline: this.timelineName, - value: true, - }) - timelineFetcher - .fetchAndUpdate({ - credentials, - timeline: this.timelineName, - older: true, - showImmediately: true, - userId: this.userId, - listId: this.listId, - statusId: this.statusId, - bookmarkFolderId: this.bookmarkFolderId, - tag: this.tag, - }) + this.timeline.fetcher + .fetchAndUpdate() .then(({ statuses }) => { if (statuses?.length === 0) { this.bottomedOut = true } }) - .finally(() => - useStatusesStore().setLoading({ - timeline: this.timelineName, - value: false, - }), - ) }, 1000, this, @@ -314,6 +269,9 @@ const Timeline = { }, }, watch: { + timelineRef(newTimeline, oldTimeline) { + this.timelineChange(newTimeline, oldTimeline) + }, filteredVisibleStatuses() { this.determineVisibleStatuses() }, diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 49065c2a9..1b6b5f31c 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -9,7 +9,7 @@ import UserCard from 'src/components/user_card/user_card.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 { useStatusesStore } from 'src/stores/statuses.js' +import { useTimelinesStore } from 'src/stores/statuses.js' import { useUsersStore } from 'src/stores/users.js' import { library } from '@fortawesome/fontawesome-svg-core' @@ -44,14 +44,11 @@ const UserProfile = { this.$store.dispatch('clearFriends', this.userId) }, computed: { - timeline() { - return this.$store.state.statuses.timelines.user - }, favorites() { - return this.$store.state.statuses.timelines.favorites + return useTimelinesStore().favorites }, media() { - return this.$store.state.statuses.timelines.media + return useTimelinesStore().media }, isUs() { return ( @@ -99,32 +96,12 @@ const UserProfile = { }, fetchUsers(group) { return () => - this.$store - .dispatch('fetch' + group, this.userId) + useUsersStore()['fetch' + group](this.userId) .then((result) => ({ items: result })) }, load(userNameOrId) { - const startFetchingTimeline = (timeline, userId) => { - // Clear timeline only if load another user's profile - if (userId !== this.$store.state.statuses.timelines[timeline].userId) { - useStatusesStore().clearTimeline({ timeline: 'user' }) - useStatusesStore().clearTimeline({ timeline: 'userPinned' }) - useStatusesStore().clearTimeline({ timeline: 'media' }) - } - this.$store.dispatch('startFetchingTimeline', { timeline, userId }) - } - const loadById = (userId) => { this.userId = userId - startFetchingTimeline('user', userId) - startFetchingTimeline('media', userId) - if (this.isUs) { - startFetchingTimeline('favorites') - } else if (!this.user.hide_favorites) { - startFetchingTimeline('favorites', userId) - } - // Fetch all pinned statuses immediately - this.$store.dispatch('fetchPinnedStatuses', userId) } // Reset view @@ -138,6 +115,7 @@ const UserProfile = { const user = maybeId ? useUsersStore().findUser(maybeId) : useUsersStore().findUserByName(maybeName) + if (user) { loadById(user.id) } else { @@ -159,13 +137,7 @@ const UserProfile = { }) } }, - stopFetching() { - this.$store.dispatch('stopFetchingTimeline', 'user') - this.$store.dispatch('stopFetchingTimeline', 'favorites') - this.$store.dispatch('stopFetchingTimeline', 'media') - }, switchUser(userNameOrId) { - this.stopFetching() this.load(userNameOrId) }, onTabSwitch(tab) { diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 6ca0c39bd..e7381937d 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -8,7 +8,6 @@ - + > + +
@@ -87,8 +87,7 @@ :embedded="true" :title="$t('user_card.favorites')" timeline-name="favorites" - :timeline="favorites" - :user-id="isUs ? undefined : userId" + :argument="isUs ? undefined : userId" :in-profile="true" :footer-slipgate="footerRef" /> diff --git a/src/modules/api.js b/src/modules/api.js index e686df0fe..7a69e464e 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -10,7 +10,6 @@ import { useOAuthStore } from 'src/stores/oauth.js' import { useShoutStore } from 'src/stores/shout.js' import { useStatusesStore } from 'src/stores/statuses.js' -import { fetchTimeline } from 'src/api/timelines.js' import { getMastodonSocketURI, ProcessedWS, @@ -18,7 +17,6 @@ import { } from 'src/api/websocket.js' import followRequestFetcher from 'src/services/follow_request_fetcher/follow_request_fetcher.service' import notificationsFetcher from 'src/services/notifications_fetcher/notifications_fetcher.service.js' -import timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.js' const retryTimeout = (multiplier) => 1000 * multiplier @@ -240,53 +238,6 @@ const api = { state.mastoUserSocket.close() }, - // Timelines - startFetchingTimeline( - store, - { - timeline = 'friends', - tag = false, - userId = false, - listId = false, - statusId = false, - bookmarkFolderId = false, - }, - ) { - if ( - timeline === 'favourites' && - !useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable - ) - return - if (store.state.fetchers[timeline]) return - - const fetcher = timelineFetcher.startFetching({ - timeline, - store, - userId, - listId, - statusId, - bookmarkFolderId, - tag, - credentials: useOAuthStore().token, - }) - - store.commit('addFetcher', { fetcherName: timeline, fetcher }) - }, - stopFetchingTimeline(store, timeline) { - const fetcher = store.state.fetchers[timeline] - if (!fetcher) return - store.commit('removeFetcher', { fetcherName: timeline, fetcher }) - }, - - fetchTimeline(store, { timeline, ...rest }) { - fetchTimeline({ - store, - timeline, - ...rest, - credentials: useOAuthStore().token, - }) - }, - // Notifications startFetchingNotifications(store) { if (store.state.fetchers.notifications) return diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js index 5157504b2..1fdb7bec1 100644 --- a/src/services/notification_utils/notification_utils.js +++ b/src/services/notification_utils/notification_utils.js @@ -1,6 +1,9 @@ import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js' import { muteFilterHits } from '../status_parser/status_parser.js' -import { prepareNotificationObject, isStatusNotification } from './notification_utils_sw.js' +import { + isStatusNotification, + prepareNotificationObject, +} from './notification_utils_sw.js' import { useNotificationsStore } from 'src/stores/notifications.js' diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js index b7097f4e0..84c980556 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -6,94 +6,81 @@ import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.j import { useInterfaceStore } from 'src/stores/interface.js' import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useStatusesStore } from 'src/stores/statuses.js' +import { ARGUMENT_MAP, useTimelinesStore } from 'src/stores/timelines.js' import { useUsersStore } from 'src/stores/users.js' import { fetchTimeline } from 'src/api/timelines.js' -const update = ({ - statuses, - timeline, - showImmediately, - userId, - listId, - pagination, -}) => { - const ccTimeline = camelCase(timeline) - - useStatusesStore().addNewStatuses({ - timelineName: ccTimeline, - userId, - listId, - statuses, - showImmediately, - pagination, - }) -} +const REPLY_VISIBILITY_TIMELINES = new Set([ + 'friends', + 'public', + 'publicAndExternal', + 'bubble', +]) const fetchAndUpdate = ({ + timeline, + argument, credentials, - timeline = 'friends', - older = false, - showImmediately = false, - userId, - listId, - statusId, - bookmarkFolderId, - tag, +}, { maxId, sinceId, + older = false, + showImmediately = false, }) => { - const args = { timeline, credentials } - const timelineData = useStatusesStore().timelines[camelCase(timeline)] + timeline.loading = true const { hideMutedPosts, replyVisibility } = useMergedConfigStore().mergedConfig - const loggedIn = !!useUsersStore().currentUser + const loggedIn = useUsersStore().loggedIn + + const args = { timeline: timeline.name, credentials } + args[ARGUMENT_MAP[timeline.name]] = argument if (older) { // When minId = 0 we need to fetch without maxId param - args.maxId = maxId || timelineData.minId || null + args.maxId = maxId || timeline.minId || null } else { if (sinceId === undefined) { - args.sinceId = timelineData.maxId + args.sinceId = timeline.maxId } else if (sinceId !== null) { args.sinceId = sinceId } } - args.userId = userId - args.listId = listId - args.statusId = statusId - args.bookmarkFolderId = bookmarkFolderId - args.tag = tag args.withMuted = !hideMutedPosts - if ( - loggedIn && - ['friends', 'public', 'publicAndExternal', 'bubble'].includes(timeline) - ) { + if (loggedIn && REPLY_VISIBILITY_TIMELINES.has(timeline)) { args.replyVisibility = replyVisibility } - const numStatusesBeforeFetch = timelineData.statuses.length + const numStatusesBeforeFetch = timeline.statuses.size return fetchTimeline(args) .then((response) => { - const { data: statuses, pagination } = response + const { data: statuses, pagination, timestamp } = response if ( !older && statuses.length >= 20 && - !timelineData.loading && + !timeline.loading && numStatusesBeforeFetch > 0 ) { - useStatusesStore().queueFlush({ timeline, id: timelineData.maxId }) + useTimelinesStore().queueFlush(timeline.name, timeline.maxId) } - update({ - statuses, - timeline, - showImmediately, - userId, - listId, - pagination, - }) + + const processed = useStatusesStore() + .addNewStatuses({ statuses, timestamp }) + .filter(Boolean) + + console.log(timeline.name, argument, showImmediately, processed.length) + + useTimelinesStore().addStatusesToTimeline( + timeline.name, + argument, + { + statuses, + showImmediately, + pagination, + } + ) return { statuses, pagination } }) .catch((error) => { @@ -108,48 +95,53 @@ const fetchAndUpdate = ({ timeout: 5000, }) }) + .finally(() => { + timeline.loading = false + }) } -const startFetching = ({ - timeline = 'friends', - credentials, - userId, - listId, - statusId, - bookmarkFolderId, - tag, -}) => { - const timelineData = useStatusesStore().timelines[camelCase(timeline)] - const showImmediately = timelineData.visibleStatuses.size === 0 - console.log(timeline) - timelineData.userId = userId - timelineData.listId = listId - timelineData.bookmarkFolderId = bookmarkFolderId - fetchAndUpdate({ - timeline, - credentials, +const timelineFetcher = (timeline, argument, argumentKey, credentials) => { + const state = { + interval: null + } + + const boundFetchAndUpdate = ({ + showImmediately, + maxId, + sinceId, + older, + } = {}) => fetchAndUpdate({ + timeline, + argument, + argumentKey, + credentials, + }, { + maxId, + sinceId, + older, showImmediately, - userId, - listId, - statusId, - bookmarkFolderId, - tag, }) - const boundFetchAndUpdate = () => - fetchAndUpdate({ - timeline, - credentials, - userId, - listId, - statusId, - bookmarkFolderId, - tag, + + const startFetching = () => { + if (state.interval) throw new Error('Interval already exists!') + + boundFetchAndUpdate({ + showImmediately: timeline.visibleStatusesIds.size === 0 }) - return promiseInterval(boundFetchAndUpdate, 10000) -} -const timelineFetcher = { - fetchAndUpdate, - startFetching, + + state.interval = promiseInterval(boundFetchAndUpdate, 10000) + } + + const stopFetching = () => { + state.interval.stop() + state.interval = null + } + + return { + startFetching, + stopFetching, + fetchAndUpdate: boundFetchAndUpdate, + } } export default timelineFetcher diff --git a/src/stores/notifications.js b/src/stores/notifications.js index c45f5b0a3..372700f11 100644 --- a/src/stores/notifications.js +++ b/src/stores/notifications.js @@ -8,9 +8,7 @@ import { isValidNotification, maybeShowNotification, } from '../services/notification_utils/notification_utils.js' -import { - isStatusNotification, -} from '../services/notification_utils/notification_utils_sw.js' +import { isStatusNotification } from '../services/notification_utils/notification_utils_sw.js' import { useI18nStore } from 'src/stores/i18n.js' import { useMergedConfigStore } from 'src/stores/merged_config.js' diff --git a/src/stores/statuses.js b/src/stores/statuses.js index 55060e251..699c2cf7c 100644 --- a/src/stores/statuses.js +++ b/src/stores/statuses.js @@ -1,4 +1,3 @@ -import { first, last, maxBy, minBy } from 'lodash' import { defineStore } from 'pinia' import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' @@ -33,42 +32,12 @@ import { unretweet, } from 'src/api/user.js' -const emptyTl = (userId) => ({ - statuses: new Map(), - faves: [], - visibleStatuses: new Map(), - newStatusCount: 0, - maxId: '', - minId: '', - minVisibleId: 0, - loading: false, - followers: [], - friends: [], - userId, - flushMarker: 0, -}) - export const defaultState = () => ({ allStatuses: new Map(), timestamps: new WeakMap(), scrobblesNextFetch: {}, conversations: new Map(), favorites: new Set(), - timelines: { - mentions: emptyTl(), - public: emptyTl(), - user: emptyTl(), - userPinned: emptyTl(), - favorites: emptyTl(), - media: emptyTl(), - publicAndExternal: emptyTl(), - friends: emptyTl(), - tag: emptyTl(), - dms: emptyTl(), - bookmarks: emptyTl(), - list: emptyTl(), - bubble: emptyTl(), - }, }) const getLatestScrobble = (user) => { @@ -106,40 +75,19 @@ const getLatestScrobble = (user) => { }) } -const USER_TIMELINES = new Set(['user', 'userPinned', 'media']) - export const useStatusesStore = defineStore('statuses', { state: defaultState, actions: { - addNewStatuses({ - statuses, - showImmediately = false, - timelineName, - user = {}, - userId, - noIdUpdate = false, - pagination = {}, - timestamp, - }) { + addNewStatuses({ statuses, user = {}, userId, timestamp }) { // Sanity check if (!Array.isArray(statuses)) { - return false + throw new TypeError("Statuses aren't an array!") } - const timeline = this.timelines[timelineName] - - if (timeline && !noIdUpdate && statuses.length > 0) { - this.updateTimelineExtremes(timeline, statuses, pagination) - } - - // This makes sure that user timeline won't get data meant for other - // user. I.e. opening different user profiles makes request which could - // return data late after user already viewing different user profile - if (USER_TIMELINES.has(timelineName) && timeline.userId !== userId) { - return - } - - const addStatus = (data, showImmediately, addToTimeline = true) => { + // addStatus should always return "main" status, + // not "sub-status" i.e. retweeted/quoted/liked status + // in case of likes (which are not statuses) it should return null + const addStatus = (data) => { getLatestScrobble(data.user) const [status] = this.mergeOrAdd(this.allStatuses, data) @@ -154,49 +102,9 @@ export const useStatusesStore = defineStore('statuses', { conversations.set(conversationId, new Map([[status.id, status]])) } - // We are mentioned in a post - if ( - status.type === 'status' && - status.attentions.some(({ id }) => id === user.id) - ) { - const mentions = this.timelines.mentions - - // Add the mention to the mentions timeline - if (timeline !== mentions) { - const [, isNew] = this.mergeOrAdd(mentions.statuses, data) - if (isNew) mentions.newStatusCount += 1 - } - } - - if (status.visibility === 'direct') { - const dms = this.timelines.dms - - const [, isNew] = this.mergeOrAdd(dms.statuses, data) - if (isNew) dms.newStatusCount += 1 - } - - // Some statuses should only be added to the global status repository. - if (timeline && addToTimeline) { - // Decide if we should treat the status as new for this timeline. - const [status, isNew] = this.mergeOrAdd(timeline.statuses, data) - if (isNew) { - if (showImmediately) { - // Add it directly to the visibleStatuses, don't change - // newStatusCount - timeline.visibleStatuses.set(status.id, status) - } else { - // Just change newStatuscount - timeline.newStatusCount += 1 - } - } - } - + // Work on quote if (status.quote) { - addStatus( - status.quote, - /* showImmediately = */ false, - /* addToTimeline = */ false, - ) + status.quote = addStatus(status.quote) } return status @@ -204,41 +112,15 @@ export const useStatusesStore = defineStore('statuses', { const processors = { status: (status) => { - addStatus(status, showImmediately) + return addStatus(status) }, edit: (status) => { - addStatus(status, showImmediately) + return addStatus(status) }, retweet: (status) => { // RetweetedStatuses are never shown immediately - const retweetedStatus = addStatus( - status.retweeted_status, - false, - false, - ) - - let retweet - // If the retweeted status is already there, don't add the retweet - // to the timeline. - if ( - [...(timeline?.statuses.values() ?? [])].some((s) => { - if (s.retweeted_status) { - return ( - s.id === retweetedStatus.id || - s.retweeted_status.id === retweetedStatus.id - ) - } else { - return s.id === retweetedStatus.id - } - }) - ) { - // Already have it visible (either as the original or another RT), don't add to timeline, don't show. - retweet = addStatus(status, false, false) - } else { - retweet = addStatus(status, showImmediately) - } - - retweet.retweeted_status = retweetedStatus + if (status.retweeted_status) addStatus(status.retweeted_status) + return addStatus(status) }, favorite: (favorite) => { // Only update if this is a new favorite. @@ -258,19 +140,22 @@ export const useStatusesStore = defineStore('statuses', { } return status } + return null }, follow: () => { // NOOP, it is known status but we don't do anything about it for now + return null }, default: (unknown) => { console.warn('unknown status type', unknown) + return null }, } - statuses.forEach((status) => { + return statuses.map((status) => { const type = status.type const processor = processors[type] ?? processors.default - processor(status) + return processor(status) }) }, mergeOrAdd(map, status, timestamp) { @@ -307,21 +192,6 @@ export const useStatusesStore = defineStore('statuses', { this.addNewStatuses({ statuses: [status], timestamp }), ) }, - fetchPinnedStatuses(userId) { - return fetchPinnedStatuses({ - id: userId, - credentials: useOAuthStore().token, - }).then(({ data: statuses, timestamp }) => - this.addNewStatuses({ - statuses, - timeline: 'userPinned', - userId, - showImmediately: true, - noIdUpdate: true, - timestamp, - }), - ) - }, fetchStatusSource(id) { return fetchStatusSource({ id, @@ -394,55 +264,6 @@ export const useStatusesStore = defineStore('statuses', { status.emoji_reactions = emojiReactions }, - // Queues & Timeline manip - updateTimelineExtremes(timeline, statuses, pagination = {}) { - // Can't use Math.min/max because it doesn't work with string (duh) - const minNew = pagination.maxId ?? minBy(statuses, 'id').id ?? '' - const maxNew = pagination.minId ?? maxBy(statuses, 'id').id ?? '' - - const newer = maxNew > timeline.maxId || timeline.maxId === '' - const older = minNew < timeline.minId || timeline.minId === '' - - if (newer) { - timeline.maxId = maxNew - } - if (older) { - timeline.minId = minNew - } - }, - showNewStatuses(timelineName) { - const timeline = this.timelines[timelineName] - - timeline.newStatusCount = 0 - - timeline.visibleStatuses = new Map( - [...timeline.statuses.entries()].slice(0, 50), - ) - timeline.minVisibleId = last(timeline.visibleStatuses.keys()) - timeline.minId = '' - timeline.maxId = '' - this.updateTimelineExtremes(timeline, [...timeline.statuses.values()]) - }, - resetStatuses() { - const emptyState = defaultState() - - Object.entries(emptyState).forEach(([key, value]) => { - this[key] = value - }) - }, - clearTimeline({ timeline, excludeUserId = false }) { - const userId = excludeUserId ? this.timelines[timeline].userId : undefined - this.timelines[timeline] = emptyTl(userId) - }, - queueFlush({ timeline, id }) { - this.timelines[timeline].flushMarker = id - }, - queueFlushAll() { - Object.keys(this.timelines).forEach((timeline) => { - this.timelines[timeline].flushMarker = this.timelines[timeline].maxId - }) - }, - // Actions /// Favorite favorite(id) { @@ -770,25 +591,6 @@ export const useStatusesStore = defineStore('statuses', { return data }) }, - - // Misc - removeUserStatuses({ timelineName, userId }) { - const timeline = this.timelines[timelineName] - - timeline.statuses - .values() - .filter(({ user }) => user.id === userId) - .forEach(({ id }) => { - timeline.statuses.delete(id) - timeline.visibleStatuses.delete(id) - }) - timeline.minVisibleId = - timeline.visibleStatuses.length > 0 - ? last(timeline.visibleStatuses).id - : 0 - timeline.maxId = - timeline.statuses.length > 0 ? first(timeline.statuses).id : 0 - }, setVirtualHeight({ statusId, height }) { this.allStatuses.get(statusId).virtualHeight = height }, @@ -796,8 +598,5 @@ export const useStatusesStore = defineStore('statuses', { const status = this.allStatuses.get(id) status.poll = poll }, - setLoading({ timeline, value }) { - this.timelines[timeline].loading = value - }, }, }) diff --git a/src/stores/timelines.js b/src/stores/timelines.js new file mode 100644 index 000000000..5f78440fd --- /dev/null +++ b/src/stores/timelines.js @@ -0,0 +1,233 @@ +import { first, last, max, min } from 'lodash' +import { defineStore } from 'pinia' + +import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' +import { useOAuthStore } from 'src/stores/oauth.js' +import { useUsersStore } from 'src/stores/users.js' + +import timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.js' + +const emptyTl = (name, argument = null) => { + const result = { + name, + statuses: new Map(), + visibleStatusesIds: new Set(), + newStatusCount: 0, + maxId: 0, + minId: 0, + minVisibleId: 0, + loading: false, + flushMarker: 0, + fetcher: null, + } + + const property = USER_TIMELINES.has(name) ? 'userId' : ARGUMENT_MAP[name] + + if (property) { + result[property] = argument + } + + return result +} + +export const ARGUMENT_MAP = { + tag: 'tag', + list: 'listId', + bookmarks: 'bookmarkFolderId', + quotes: 'statusId', + search: 'query', +} + +export const defaultState = () => { + return Object.fromEntries([ + 'mentions', + 'public', + 'user', + 'userPinned', + 'media', + 'favorites', + 'publicAndExternal', + 'friends', + 'tag', + 'dms', + 'bookmarks', + 'list', + 'bubble', + 'quotes', + 'search', + ].map((name) => [name, emptyTl(name)])) +} + +const USER_TIMELINES = new Set(['user', 'userPinned', 'media', 'favorites']) +//const CUSTOM_SORT = new Set(['bookmarks', 'favorites']) + +export const useTimelinesStore = defineStore('timelines', { + state: defaultState, + actions: { + addStatusesToTimeline( + timelineName, + argument, + { + statuses, + showImmediately = false, + noIdUpdate = false, + pagination = {}, + nested = false, + }, + ) { + if (statuses.length === 0) return + const timeline = this[timelineName] + + // This makes sure that user timeline won't get data meant for other + // user. I.e. opening different user profiles makes request which could + // return data late after user already viewing different user profile + // Same can happen with tags etc. + const property = USER_TIMELINES.has(name) ? 'userId' : ARGUMENT_MAP[name] + + if (property && timeline[property] !== argument) { + return + } + + if (!noIdUpdate) { + this.updateTimelineExtremes( + timeline, + statuses.map((x) => x.id), + pagination, + ) + } + + statuses.forEach((status) => { + const isNew = !timeline.statuses.has(status.id) + timeline.statuses.set(status.id, status) + + if (isNew) { + if (showImmediately) { + // Add it directly to the visibleStatuses, don't change + // newStatusCount + timeline.visibleStatusesIds.add(status.id) + } else { + // Just change newStatuscount + timeline.newStatusCount += 1 + } + } + + if (nested) return + // We are mentioned in a post + if ( + status.type === 'status' && + status.attentions.some( + ({ id }) => id === useUsersStore().currentUser?.id, + ) + ) { + // Add the mention to the mentions timeline + if (timeline !== this.mentions) { + this.addStatusesToTimeline('mentions', null, { statuses, nested: true }) + } + } + + if (status.visibility === 'direct') { + if (timeline !== this.dms) { + this.addStatusesToTimeline('dms', null, { statuses, nested: true }) + } + } + }) + }, + + // Fetchers + startFetchingTimeline(timelineName, argument) { + const timeline = this[timelineName] + if (timeline.fetcher) return + + if ( + timelineName === 'favourites' && + !useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable + ) { + return + } + + timeline.fetcher = timelineFetcher( + timeline, + argument, + ARGUMENT_MAP[timeline.name], + useOAuthStore().token, + ) + + timeline.fetcher.startFetching() + }, + stopFetchingTimeline(timelineName) { + const timeline = this[timelineName] + timeline.fetcher?.stopFetching() + timeline.fetcher = null + }, + + // Queues & Timeline manip + updateTimelineExtremes(timeline, statuses, pagination = {}) { + // Can't use Math.min/max because it doesn't work with string (duh) + const minNew = pagination.maxId ?? min(...statuses) ?? '' + const maxNew = pagination.minId ?? max(...statuses) ?? '' + + const newer = maxNew > timeline.maxId || timeline.maxId === '' + const older = minNew < timeline.minId || timeline.minId === '' + + if (newer) { + timeline.maxId = maxNew + } + if (older) { + timeline.minId = minNew + } + }, + resetStatuses() { + const emptyState = defaultState() + + Object.entries(emptyState).forEach(([key, value]) => { + this[key] = value + }) + }, + showNewStatuses(timelineName) { + const timeline = this[timelineName] + + timeline.newStatusCount = 0 + + timeline.visibleStatusesIds = new Set( + [...timeline.statuses.keys()].slice(0, 50), + ) + timeline.minVisibleId = last(timeline.visibleStatusesIds.keys()) + timeline.minId = '' + timeline.maxId = '' + + this.updateTimelineExtremes(timeline, [...timeline.statuses.keys()]) + }, + clearTimeline(timeline, excludeUserId = false) { + const userId = excludeUserId ? this[timeline].userId : undefined + this.stopFetchingTimeline(timeline) + this[timeline] = emptyTl(timeline, userId) + }, + queueFlush(timeline, id) { + this[timeline].flushMarker = id + }, + queueFlushAll() { + Object.keys(this).forEach((timeline) => { + this[timeline].flushMarker = this[timeline].maxId + }) + }, + + // Misc + removeUserStatuses({ timelineName, userId }) { + 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 + }, + }, +}) diff --git a/src/stores/users.js b/src/stores/users.js index 4aba00743..cf88bcd43 100644 --- a/src/stores/users.js +++ b/src/stores/users.js @@ -23,6 +23,7 @@ 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 { useTimelinesStore } from 'src/stores/timelines.js' import { useSyncConfigStore } from 'src/stores/sync_config.js' import { useUserHighlightStore } from 'src/stores/user_highlight.js' @@ -667,7 +668,7 @@ export const useUsersStore = defineStore('users', { const startPolling = () => { // Start getting fresh posts. - dispatch('startFetchingTimeline', { timeline: 'friends' }) + useTimelinesStore().startFetchingTimeline('friends') // Start fetching notifications dispatch('startFetchingNotifications')