TIMELINES STORE YEAH BABY

This commit is contained in:
Henry Jameson 2026-08-11 18:54:54 +03:00
commit cb25b392cf
21 changed files with 426 additions and 734 deletions

View file

@ -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',

View file

@ -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

View file

@ -1,10 +0,0 @@
<template>
<Timeline
:title="$t('nav.bookmarks')"
:timeline="timeline"
:timeline-name="'bookmarks'"
:bookmark-folder-id="folderId"
/>
</template>
<script src="./bookmark_timeline.js"></script>

View file

@ -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

View file

@ -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

View file

@ -1,10 +0,0 @@
<template>
<Timeline
title="list.name"
:timeline="timeline"
:list-id="listId"
timeline-name="list"
/>
</template>
<script src="./lists_timeline.js"></script>

View file

@ -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

View file

@ -1,9 +0,0 @@
<template>
<Timeline
:title="$t('nav.interactions')"
:timeline="timeline"
:timeline-name="'mentions'"
/>
</template>
<script src="./mentions.js"></script>

View file

@ -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

View file

@ -1,10 +0,0 @@
<template>
<Timeline
:title="$t('nav.quotes')"
:timeline="timeline"
:timeline-name="'quotes'"
:status-id="statusId"
/>
</template>
<script src='./quotes_timeline.js'></script>

View file

@ -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

View file

@ -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()
},

View file

@ -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) {

View file

@ -8,7 +8,6 @@
<UserCard
:user-id="userId"
:switcher="true"
:selected="timeline.viewing"
:compact="compactProfiles"
avatar-action="zoom"
:has-note-editor="true"
@ -19,19 +18,21 @@
:render-only-focused="true"
:on-switch="onTabSwitch"
>
<Timeline
key="statuses"
<div
class="statuses"
:label="$t('user_card.statuses')"
:count="user.statuses_count"
:embedded="true"
:title="$t('user_profile.timeline_title')"
:timeline="timeline"
timeline-name="user"
:user-id="userId"
:pinned-status-ids="user.pinnedStatusIds"
:in-profile="true"
:footer-slipgate="footerRef"
/>
>
<Timeline
key="statuses"
:embedded="true"
timeline-name="user"
:argument="userId"
:in-profile="true"
:footer-slipgate="footerRef"
/>
</div>
<div
v-if="followsTabVisible"
key="followees"
@ -74,8 +75,7 @@
:embedded="true"
:title="$t('user_card.media')"
timeline-name="media"
:timeline="media"
:user-id="userId"
:argument="userId"
:in-profile="true"
:footer-slipgate="footerRef"
/>
@ -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"
/>

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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'

View file

@ -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
},
},
})

233
src/stores/timelines.js Normal file
View file

@ -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
},
},
})

View file

@ -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')