initial implementation of statuses store

This commit is contained in:
Henry Jameson 2026-08-10 20:18:50 +03:00
commit 5b9ab6b1ea
2 changed files with 815 additions and 0 deletions

View file

@ -329,6 +329,7 @@ export const parseStatus = (data) => {
output.attentions = (data.mentions || []).map(parseUser)
output.attachments = (data.media_attachments || []).map(parseAttachment)
output.deleted = false
const retweetedStatus = data.reblog
if (retweetedStatus) {

814
src/stores/statuses.js Normal file
View file

@ -0,0 +1,814 @@
import { each, first, last, maxBy, merge, minBy, omitBy, remove } from 'lodash'
import { defineStore } from 'pinia'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useUsersStore } from 'src/stores/users.js'
import {
fetchEmojiReactions,
fetchFavoritedByUsers,
fetchPinnedStatuses,
fetchRebloggedByUsers,
fetchScrobbles,
fetchStatus,
fetchStatusHistory,
fetchStatusSource,
search2,
} from 'src/api/public.js'
import {
bookmarkStatus,
deleteStatus,
favorite,
muteConversation,
pinOwnStatus,
reactWithEmoji,
retweet,
unbookmarkStatus,
unfavorite,
unmuteConversation,
unpinOwnStatus,
unreactWithEmoji,
unretweet,
} from 'src/api/user.js'
const emptyTl = () => ({
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 mergeOrAdd = (map, status, timestamp) => {
const existing = map.get(status.id)
const oldTimestamp = this.timestamps.get(existing)
const { user: unused0, ...old } = existing ?? {}
const { user: statusUser, ...neu } = status
const [user] = useUsersStore().addNewUsers({ data: statusUser, timestamp })
existing.user = user
// implicit: if oldTimestamp is undefined this will still be false
if (oldTimestamp > timestamp) return [existing, false] // not overwriting old data with new
const newStatus = {
...old,
...neu,
user,
}
map.set(item.id, item)
this.timestamps.set(newStatus, timestamp)
return [map.get(item.id), true]
}
const sortById = (a, b) => {
const seqA = Number(a.id)
const seqB = Number(b.id)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA > seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return 1
} else if (!isSeqA && isSeqB) {
return -1
} else {
return a.id > b.id ? -1 : 1
}
}
const getLatestScrobble = (user) => {
const scrobblesSupport =
useInstanceCapabilitiesStore().pleromaScrobblesAvailable
if (!scrobblesSupport || !user.name || user.id === 'undefined') {
return
}
if (
this.scrobblesNextFetch[user.id] &&
this.scrobblesNextFetch[user.id] > Date.now()
) {
return
}
this.scrobblesNextFetch[user.id] = Date.now() + 24 * 60 * 60 * 1000
if (!scrobblesSupport) return
fetchScrobbles({ accountId: user.id })
.then(({ data: scrobbles }) => {
if (scrobbles?.error) {
useInstanceCapabilitiesStore().set('pleromaScrobblesAvailable', false)
return
}
if (scrobbles.length > 0) {
user.latestScrobble = scrobbles[0]
this.scrobblesNextFetch[user.id] = Date.now() + 60 * 1000
}
})
.catch((e) => {
console.warn('cannot fetch scrobbles', e)
})
}
const USER_TIMELINES = new Set(['user', 'userPinned', 'media'])
export const useStatusesStore = defineStore('statuses', {
state: defaultState,
actions: {
addNewStatuses({
statuses,
showImmediately = false,
timelineName,
user = {},
noIdUpdate = false,
Id,
pagination = {},
timestamp,
}) {
// Sanity check
if (!Array.isArray(statuses)) {
return false
}
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) => {
getLatestScrobble(data.user)
const [status] = mergeOrAdd(this.allStatuses, data)
// Add to conversation
const conversations = this.conversations
const conversationId = status.statusnet_conversation_id
if (conversations.has(conversationId)) {
conversations.get(conversationId).set(status.id, status)
} else {
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] = mergeOrAdd(mentions.statuses, status)
if (isNew) mentions.newStatusCount += 1
}
}
if (status.visibility === 'direct') {
const dms = this.timelines.dms
const [, isNew] = mergeOrAdd(dms.statuses, status)
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] = mergeOrAdd(timeline.statuses, status)
if (showImmediately) {
// Add it directly to the visibleStatuses, don't change
// newStatusCount
timeline.visibleStatuses.add(status.id, status)
} else {
// Just change newStatuscount
timeline.newStatusCount += 1
}
}
if (status.quote) {
addStatus(
status.quote,
/* showImmediately = */ false,
/* addToTimeline = */ false,
)
}
return status
}
const processors = {
status: (status) => {
addStatus(status, showImmediately)
},
edit: (status) => {
addStatus(status, showImmediately)
},
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
},
favorite: (favorite) => {
// Only update if this is a new favorite.
// Ignore our own favorites because we get info about likes as response to like request
if (!this.favorites.has(favorite.id)) {
this.favorites.add(favorite.id)
const status = this.allStatuses.get(favorite.in_reply_to_status_id)
if (status) {
// This is our favorite, so the relevant bit.
if (favorite.user.id === user.id) {
status.favorited = true
} else {
status.fave_num += 1
}
}
return status
}
},
follow: () => {
// NOOP, it is known status but we don't do anything about it for now
},
default: (unknown) => {
console.warn('unknown status type', unknown)
},
}
statuses.forEach((status) => {
const type = status.type
const processor = processors[type] ?? processors.default
processor(status)
})
},
fetchStatus(id) {
return fetchStatus({ id }).then(({ data: status, timestamp }) =>
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,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
fetchStatusHistory(status) {
return fetchStatusHistory({ status }).then(({ data }) => data)
},
fetchEmojiReactionsBy(id) {
return fetchEmojiReactions({
id,
credentials: useOAuthStore().token,
}).then(({ data: emojiReactions }) => {
this.addEmojiReactionsBy({
id,
emojiReactions,
})
})
},
fetchFavs(id) {
return fetchFavoritedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data: favoritedByUsers }) =>
this.addFavs({
id,
favoritedByUsers,
}),
)
},
fetchRepeats(id) {
return fetchRebloggedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data: rebloggedByUsers }) =>
this.addRepeats({
id,
rebloggedByUsers,
}),
)
},
fetchFavsAndRepeats(id) {
return Promise.all([
this.fetchFavs(id),
this.fetchRepeats(id),
])
},
// Updates
addRepeats({ id, rebloggedByUsers }) {
const currentUser = useUsersStore().currentUser
const newStatus = this.allStatuses.get(id)
newStatus.rebloggedBy = rebloggedByUsers.filter(Boolean)
// repeats stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.repeat_num = newStatus.rebloggedBy.length
newStatus.repeated = !!newStatus.rebloggedBy.find(
({ id }) => currentUser.id === id,
)
},
addFavs({ id, favoritedByUsers }) {
const currentUser = useUsersStore().currentUser
const newStatus = this.allStatuses.get(id)
newStatus.favoritedBy = favoritedByUsers.filter(Boolean)
// favorites stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.fave_num = newStatus.favoritedBy.length
newStatus.favorited = !!newStatus.favoritedBy.find(
({ id }) => currentUser.id === id,
)
},
addEmojiReactionsBy({ id, emojiReactions }) {
const status = this.allStatuses.get(id)
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).id
timeline.minId = ''
timeline.maxId = ''
this.updateTimelineExtremes(timeline, [...timeline.statuses.values()])
},
resetStatuses() {
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
this[key] = value
})
},
clearTimeline(state, { timeline, excludeUserId = false }) {
const userId = excludeUserId ? state.timelines[timeline].userId : undefined
state.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) {
this.setFavorited({ id, value: true })
favorite({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
unfavorite(id) {
this.setFavorited({ id, value: false })
unfavorite({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
setFavorited({ id, value }) {
const newStatus = this.allStatuses.get(id)
if (newStatus.favorited !== value) {
if (value) {
newStatus.fave_num++
} else {
newStatus.fave_num--
}
}
newStatus.favorited = value
},
/// Reprööt
retweet(id) {
this.setRetweeted({ id, value: true })
retweet({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
unretweet(id) {
this.setRetweeted({ id, value: false })
unretweet({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
setRetweeted({ statusId, value }) {
const newStatus = this.allStatuses.get(statusId)
if (newStatus.repeated !== value) {
if (value) {
newStatus.repeat_num++
} else {
newStatus.repeat_num--
}
}
newStatus.repeated = value
},
// React
reactWithEmoji(id, emoji) {
this.addOwnReaction({ id, emoji })
reactWithEmoji({
id,
emoji,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
unreactWithEmoji(id, emoji) {
this.removeOwnReaction({ id, emoji })
unreactWithEmoji({
id,
emoji,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
addOwnReaction(id, emoji) {
const currentUser = useUsersStore().currentUser
const status = this.allStatuses.get(id)
const reactionIndex = status.emoji_reactions.findIndex(
(react) => react.name === emoji,
)
const reaction = status.emoji_reactions[reactionIndex] || {
name: emoji,
count: 0,
accounts: [],
}
const newReaction = {
...reaction,
count: reaction.count + 1,
me: true,
accounts: [...reaction.accounts, currentUser],
}
// Update count of existing reaction if it exists, otherwise append at the end
if (reactionIndex >= 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions.push(newReaction)
}
},
removeOwnReaction(id, emoji) {
const currentUser = useUsersStore().currentUser
const status = this.allStatuses.get(id)
const reactionIndex = status.emoji_reactions.findIndex(
(react) => react.name === emoji,
)
if (reactionIndex < 0) return
const reaction = status.emoji_reactions[reactionIndex]
const accounts = reaction.accounts || []
const newReaction = {
...reaction,
count: reaction.count - 1,
me: false,
accounts: accounts.filter((acc) => acc.id !== currentUser.id),
}
if (newReaction.count > 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions = status.emoji_reactions.filter(
(r) => r.name !== emoji,
)
}
},
/// Bookmark
bookmark(id, bookmark_folder_id) {
this.setBookmarked({ id, value: true, bookmark_folder_id })
bookmarkStatus({
id,
folder_id: bookmark_folder_id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
unbookmark(id) {
this.setBookmarked({ id, value: false })
unbookmarkStatus({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
setBookmarked({ id, value, bookmark_folder_id }) {
const status = this.allStatuses.get(id)
newStatus.bookmarked = value
newStatus.bookmark_folder_id = value ? bookmark_folder_id : null
},
/// Mute
muteConversation(id) {
return muteConversation({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
return status
}).then((status) => this.setMutedStatus(status))
},
unmuteConversation(id) {
return unmuteConversation({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
return status
}).then((status) => this.setMutedStatus(status))
},
setMutedStatus({ id, thread_muted }) {
// Setting thread_muted flag on all other known statuses
// belonging to same conversation
const newStatus = this.allStatuses.get(id)
newStatus.thread_muted = thread_muted
if (newStatus.thread_muted !== undefined) {
state.conversations.get(newStatus.statusnet_conversation_id).forEach(
(status) => {
status.thread_muted = thread_muted
},
)
}
},
/// Pin
pinStatus(id) {
return pinOwnStatus({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
unpinStatus(id) {
return unpinOwnStatus({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
/// Delete
deleteStatus(id) {
deleteStatus({
id,
credentials: useOAuthStore().token,
})
.then(() => {
this.setDeleted({ id })
})
.catch((e) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'status.delete_error',
messageArgs: [e.message],
timeout: 5000,
})
})
},
setDeleted({ id }) {
const newStatus = this.allStatuses.get(id)
if (newStatus) newStatus.deleted = true
},
setManyDeleted(condition) {
this.allStatuses.values().forEach((status) => {
if (condition(status)) {
status.deleted = true
}
})
},
// Search
search({ q, resolve, limit, offset, following, type }) {
return search2({
q,
resolve,
limit,
offset,
following,
type,
credentials: useOAuthStore().token,
}).then((result) => {
const { data, ...rest } = result
useUsersStore().addNewUsers({
...rest,
data: data.accounts,
})
useUsersStore().addNewUsers({
...rest,
data: data.statuses.map((s) => s.user).filter(Boolean),
})
this.addNewStatuses({
statuses: data.statuses,
})
data.statuses = data.statuses.map(
(s) => this.allStatuses.get(s.id),
)
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
},
updateStatusWithPoll({ id, poll }) {
const status = this.allStatuses.get(id)
status.poll = poll
},
setLoading(state, { timeline, value }) {
state.timelines[timeline].loading = value
},
},
})