pleroma-fe/src/stores/timelines.js

423 lines
11 KiB
JavaScript

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 { TIMELINE_STREAM_MAP, useStreamingStore } from 'src/stores/streaming.js'
const emptyTl = (name, argument = null) => {
const result = {
// Name of the timeline. Useful for debugging and logging
name,
// Order of statuses, important for timelines that
// have different ordering, i.e. bookmarks and favorites
order: [],
// All statuses belonging to the timeline
statusIds: new Set(),
// Statuses shown to user
visibleStatusIds: new Set(),
// Number of statuses not shown yet
newStatusCount: 0,
// Pagination
maxId: '',
minId: '',
// Indicates whether timeline receives push updates
streaming: false,
// Indicates whether timeline is SUPPOSED to be fetching
// this is partiualrly useful for when pausing/resuming
// timeline. I.e. whether we need to start fetching again
// if timeline was resumed.
fetching: false,
// Indicates that in recent poll update we've hit more than or
// equal to 20 statuses and most likely missed some statuses
// between polls
reloadNeeded: false,
// Reference to fetcher, used for polling for new statuses and
// manually fetching old statuses
fetcher: null,
// Reference to WS subscriber
socket: null,
// Whether the timeline has been paused - it stops fetching
// (but still receives pushes!)
paused: false,
}
const property = ARGUMENT_MAP[name]
if (property) {
result[property] = argument
}
if (name === 'dms' || name === 'friends') {
result.persistent = true
}
return result
}
export const ARGUMENT_MAP = {
tag: 'tag',
list: 'listId',
bookmarks: 'bookmarkFolderId',
quotes: 'statusId',
search: 'query',
user: 'userId',
userPinned: 'userId',
media: 'userId',
}
const TIMELINES = new Set([
'mentions',
'public',
'user',
'userPinned',
'media',
'favorites',
'publicAndExternal',
'friends',
'tag',
'dms',
'bookmarks',
'list',
'bubble',
'quotes',
'search',
])
export const defaultState = () => {
return Object.fromEntries([...TIMELINES].map((name) => [name, emptyTl(name)]))
}
//const CUSTOM_SORT = new Set(['bookmarks', 'favorites'])
export const useTimelinesStore = defineStore('timelines', {
state: defaultState,
actions: {
// (De)Initialization stuff
activate(timelineName, argument, persistent) {
const timeline = this[timelineName]
if (timeline.persistent && !persistent) return
if (
timelineName === 'favorites' &&
!useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable
) {
console.warn("Instance doesn't support public favorites timeline")
return
}
const property = ARGUMENT_MAP[timelineName]
if (property) {
timeline[property] = argument
}
timeline.fetcher = timelineFetcher(
timeline,
argument,
useOAuthStore().token,
)
this.startFetchingTimeline(timelineName, argument, 'Timeline activated')
const streamName = TIMELINE_STREAM_MAP[timelineName]
if (streamName) {
const et = new EventTarget()
const openHandler = () => this.onStreamConnect(timelineName, argument)
const closeHandler = () =>
this.onStreamDisconnect(timelineName, argument)
const messageHandler = (message) => {
this.onStreamMessage(timelineName, argument, message)
}
et.addEventListener('open', openHandler)
et.addEventListener('close', closeHandler)
et.addEventListener('update', messageHandler)
timeline.socket = {
name: 'timelines',
stream: {
name: streamName,
argument,
},
et,
handlers: {
openHandler,
closeHandler,
messageHandler,
},
}
useStreamingStore().addSubscriber(timeline.socket)
}
},
deactivate(timelineName, persistent) {
const timeline = this[timelineName]
if (timeline.persistent && !persistent) return
if (timeline.fetching) {
this.stopFetchingTimeline(timelineName, 'Timeline deactivation')
}
if (timeline.socket) {
useStreamingStore().removeSubscriber(timeline.socket)
const { openHandler, closeHandler, messageHandler } =
timeline.socket.handlers
timeline.socket.et.removeEventListener('open', openHandler)
timeline.socket.et.removeEventListener('close', closeHandler)
timeline.socket.et.removeEventListener('message', messageHandler)
}
this[timelineName] = emptyTl(timelineName)
},
clearTimeline(timelineName) {
const timeline = this[timelineName]
timeline.order = []
timeline.statusIds = new Set()
timeline.visibleStatusIds = new Set()
timeline.newStatusCount = 0
timeline.maxId = ''
timeline.minId = ''
timeline.reloadNeeded = false
},
activatePersistents() {
TIMELINES.forEach((name) => {
if (this[name].persistent) {
this.activate(name, undefined, true)
}
})
},
deactivateAll() {
TIMELINES.forEach((name) => {
try {
this.deactivate(name, true)
} catch (e) {
console.error(`Failed to deactivate timeline ${name}:`, e)
}
})
},
// Pause
pause(name) {
const timeline = this[name]
timeline.paused = true
console.debug('[Timelines] Pausing timeline', name)
if (timeline.fetcher && timeline.fetching) {
timeline.fetcher.stopFetching()
}
},
resume(name) {
const timeline = this[name]
timeline.paused = false
console.debug('[Timelines] Resuming timeline', name)
if (timeline.fetcher && timeline.fetching) {
timeline.fetcher.startFetching()
}
},
pauseAll() {
TIMELINES.forEach((name) => {
try {
this.pause(name)
} catch (e) {
console.error(`[Timelines] Failed to pause timeline ${name}:`, e)
}
})
},
resumeAll() {
TIMELINES.forEach((name) => {
try {
this.resume(name)
} catch (e) {
console.error(`[Timelines] Failed to pause timeline ${name}:`, e)
}
})
},
// Update stuff
addStatusesToTimeline(
timelineName,
argument,
{
statuses,
showImmediately = false,
noIdUpdate = false,
pagination = {},
older = 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 = ARGUMENT_MAP[timelineName]
if (property && timeline[property] !== argument) {
return
}
if (!noIdUpdate) {
this.updateTimelineExtremes(timeline, pagination)
}
const filtered = statuses.filter((id) => !timeline.statusIds.has(id))
if (older) {
timeline.order.push(...filtered)
} else {
timeline.order.unshift(...filtered)
}
statuses.forEach((statusId) => {
const isNew = !timeline.statusIds.has(statusId)
timeline.statusIds.add(statusId)
if (isNew) {
if (showImmediately) {
// Add it directly to the visibleStatuses, don't change
// newStatusCount
timeline.visibleStatusIds.add(statusId)
} else {
// Just change newStatuscount
timeline.newStatusCount += 1
}
}
})
},
onStreamMessage(timeline, argument, event) {
this.addStatusesToTimeline(timeline, argument, {
statuses: event.data.map(({ id }) => id),
})
},
// Poll & Push
onStreamConnect(timeline) {
console.debug('[Timelines] Stream connected', timeline)
this[timeline].streaming = true
this.stopFetchingTimeline(timeline, 'Socket connected')
},
onStreamDisconnect(timeline, argument) {
this[timeline].streaming = false
this.startFetchingTimeline(timeline, argument, 'Socket disconnected')
},
startFetchingTimeline(timelineName, argument, reason) {
const timeline = this[timelineName]
if (timeline.paused) {
console.debug(
'[Timelines] NOT Starting timeline fetcher because it is paused',
timelineName,
argument,
'Original Reason:',
reason,
)
return
}
console.debug(
'[Timelines] Starting timeline fetcher',
timelineName,
argument,
'Reason:',
reason,
)
timeline.fetcher.startFetching()
timeline.fetching = true
},
stopFetchingTimeline(timelineName, reason) {
const timeline = this[timelineName]
if (timeline.fetcher === null) {
console.debug(
'[Timelines] Already inactive timeline',
timelineName,
'Reason:',
reason,
)
return
} else if (timeline.paused) {
console.debug(
'[Timelines] Deactivating paused timeline',
timelineName,
'Reason:',
reason,
)
timeline.fetching = false
} else {
timeline.fetcher.stopFetching()
console.debug(
'[Timelines] Stopped fetching timeline',
timelineName,
'Reason:',
reason,
)
timeline.fetching = false
}
},
// Queues & Timeline manip
updateTimelineExtremes(timeline, pagination = {}) {
// Can't use Math.min/max because it doesn't work with string (duh)
const minNew = pagination.maxId ?? last(timeline.order) ?? ''
const maxNew = pagination.minId ?? first(timeline.order) ?? ''
const newer = maxNew > timeline.maxId
const older = minNew < timeline.minId
if (newer || timeline.maxId === '') {
timeline.maxId = maxNew
}
if (older || timeline.minId === '') {
timeline.minId = minNew
}
this.syncOrder(timeline)
},
showNewStatuses(timelineName) {
const timeline = this[timelineName]
timeline.newStatusCount = 0
timeline.order = timeline.order.slice(0, 50)
timeline.statusIds = new Set([...timeline.order])
timeline.visibleStatusIds = new Set([...timeline.order])
this.updateTimelineExtremes(timeline)
},
syncOrder(timeline) {
timeline.order = timeline.order.filter((id) => timeline.statusIds.has(id))
},
requireReload(timeline, id) {
this[timeline].reloadNeeded = true
},
requireReloadAll() {
Object.keys(this).forEach((timeline) => {
this[timeline].reloadNeeded = true
})
},
// Misc
wipeStatuses(ids) {
TIMELINES.forEach((timelineName) => {
const timeline = this[timelineName]
ids.forEach((id) => {
timeline.statusIds.delete(id)
timeline.visibleStatusIds.delete(id)
})
this.syncOrder(timeline)
})
},
},
})