pleroma-fe/src/stores/statuses.js

632 lines
17 KiB
JavaScript
Raw Normal View History

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'
2026-08-13 01:12:48 +03:00
import { useStreamingStore } from 'src/stores/streaming.js'
2026-08-13 01:22:44 +03:00
import { useUsersStore } from 'src/stores/users.js'
import {
fetchEmojiReactions,
fetchFavoritedByUsers,
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'
export const defaultState = () => ({
allStatuses: new Map(),
timestamps: new WeakMap(),
scrobblesNextFetch: {},
conversations: new Map(),
favorites: new Set(),
2026-08-13 16:45:44 +03:00
socket: null,
})
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)
})
}
export const useStatusesStore = defineStore('statuses', {
state: defaultState,
actions: {
2026-08-13 01:12:48 +03:00
// Init
attachSocket() {
const et = new EventTarget()
const handleUpdate = ({ data, timestamp }) =>
this.addNewStatuses({ statuses: data, timestamp })
const handleDelete = ({ data }) =>
data.forEach((id) => this.setDeleted(id))
2026-08-13 17:19:00 +03:00
2026-08-13 16:45:44 +03:00
const socket = {
et,
handlers: {
handleUpdate,
handleDelete,
},
2026-08-13 16:45:44 +03:00
}
2026-08-13 01:12:48 +03:00
2026-08-13 16:45:44 +03:00
et.addEventListener('update', handleUpdate)
et.addEventListener('status.update', handleUpdate)
et.addEventListener('delete', handleDelete)
useStreamingStore().addSubscriber(socket)
this.socket = socket
2026-08-13 01:12:48 +03:00
},
2026-08-13 16:45:44 +03:00
resetStatuses() {
this.socket.et.removeEventListener('update', this.socket.handleUpdate)
this.socket.et.removeEventListener(
'status.update',
this.socket.handleUpdate,
)
2026-08-13 16:45:44 +03:00
this.socket.et.removeEventListener('delete', this.socket.handleDelete)
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
this[key] = value
})
},
2026-08-13 01:12:48 +03:00
addNewStatuses({ statuses, timestamp }) {
// Sanity check
if (!Array.isArray(statuses)) {
2026-08-11 18:54:54 +03:00
throw new TypeError("Statuses aren't an array!")
}
2026-08-11 18:54:54 +03:00
// 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)
2026-08-10 22:26:22 +03:00
const [status] = this.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]]))
}
2026-08-11 18:54:54 +03:00
// Work on quote
if (status.quote) {
2026-08-11 18:54:54 +03:00
status.quote = addStatus(status.quote)
}
return status
}
const processors = {
status: (status) => {
2026-08-11 18:54:54 +03:00
return addStatus(status)
},
edit: (status) => {
2026-08-11 18:54:54 +03:00
return addStatus(status)
},
retweet: (status) => {
// RetweetedStatuses are never shown immediately
2026-08-11 18:54:54 +03:00
if (status.retweeted_status) addStatus(status.retweeted_status)
return addStatus(status)
},
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.
2026-08-13 01:12:48 +03:00
if (favorite.user.id === useUsersStore().currentUser?.id) {
status.favorited = true
} else {
status.fave_num += 1
}
}
return status
}
2026-08-11 18:54:54 +03:00
return null
},
follow: () => {
// NOOP, it is known status but we don't do anything about it for now
2026-08-11 18:54:54 +03:00
return null
},
default: (unknown) => {
console.warn('unknown status type', unknown)
2026-08-11 18:54:54 +03:00
return null
},
}
2026-08-11 18:54:54 +03:00
return statuses.map((status) => {
const type = status.type
const processor = processors[type] ?? processors.default
2026-08-11 18:54:54 +03:00
return processor(status)
})
},
2026-08-10 22:26:22 +03:00
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 // reactive update in case we return old
// 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(newStatus.id, newStatus)
this.timestamps.set(newStatus, timestamp)
return [map.get(newStatus.id), true]
},
2026-08-13 16:45:44 +03:00
// Fetches
fetchStatus(id) {
return fetchStatus({ id }).then(({ data: status, timestamp }) =>
this.addNewStatuses({ statuses: [status], 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) {
2026-08-10 22:26:22 +03:00
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(
2026-08-14 19:38:37 +03:00
({ 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(
2026-08-14 19:38:37 +03:00
({ id }) => currentUser?.id === id,
)
},
addEmojiReactionsBy({ id, emojiReactions }) {
const status = this.allStatuses.get(id)
status.emoji_reactions = emojiReactions
},
// Actions
2026-08-14 17:38:15 +03:00
requestInteract({ name, id, optimisticCall, apiCall, argument, value }) {
const oldValue = !value // Assumption
const apiArgs = (() => {
switch (name) {
case 'emoji':
return { emoji: argument }
case 'bookmark':
return { folder_id: argument }
default:
return {}
}
})()
2026-08-14 17:38:15 +03:00
// Optimistic
optimisticCall(id, value, argument)
return apiCall({
id,
...apiArgs,
credentials: useOAuthStore().token,
2026-08-14 17:38:15 +03:00
})
.then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
2026-08-14 17:38:15 +03:00
.catch((error) => {
optimisticCall(id, oldValue, argument)
useInterfaceStore().pushGlobalNotice({
level: 'error',
2026-08-14 18:54:18 +03:00
messageKey: 'status.interact_error',
2026-08-14 17:38:15 +03:00
messageArgs: [error],
timeout: 5000,
})
})
},
/// Favorite
favorite(id) {
return this.requestInteract({
name: 'favorite',
id,
apiCall: favorite,
optimisticCall: this.setFavorited,
value: true,
})
},
unfavorite(id) {
2026-08-14 17:38:15 +03:00
return this.requestInteract({
name: 'favorite',
id,
2026-08-14 17:38:15 +03:00
apiCall: unfavorite,
optimisticCall: this.setFavorited,
value: false,
})
},
2026-08-14 17:38:15 +03:00
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) {
2026-08-14 17:38:15 +03:00
return this.requestInteract({
name: 'retweet',
id,
2026-08-14 17:38:15 +03:00
apiCall: retweet,
optimisticCall: this.setRetweeted,
value: true,
})
},
unretweet(id) {
2026-08-14 17:38:15 +03:00
return this.requestInteract({
name: 'retweet',
id,
2026-08-14 17:38:15 +03:00
apiCall: unretweet,
optimisticCall: this.setRetweeted,
value: false,
})
},
2026-08-14 17:38:15 +03:00
setRetweeted(id, value) {
const newStatus = this.allStatuses.get(id)
if (newStatus.repeated !== value) {
if (value) {
newStatus.repeat_num++
} else {
newStatus.repeat_num--
}
}
newStatus.repeated = value
},
// React
reactWithEmoji(id, emoji) {
2026-08-14 17:38:15 +03:00
return this.requestInteract({
name: 'emoji',
id,
2026-08-14 17:38:15 +03:00
apiCall: reactWithEmoji,
optimisticCall: this.setOwnReaction,
argument: emoji,
value: true,
})
},
unreactWithEmoji(id, emoji) {
2026-08-14 17:38:15 +03:00
return this.requestInteract({
name: 'emoji',
id,
2026-08-14 17:38:15 +03:00
apiCall: unreactWithEmoji,
optimisticCall: this.setOwnReaction,
argument: emoji,
value: false,
})
},
2026-08-14 17:38:15 +03:00
setOwnReaction(id, value, emoji) {
const currentUser = useUsersStore().currentUser
const status = this.allStatuses.get(id)
const reactionIndex = status.emoji_reactions.findIndex(
(react) => react.name === emoji,
)
2026-08-14 17:38:15 +03:00
const reactionPresent = reactionIndex >= 0
if (!value && !reactionPresent) return
const reaction = status.emoji_reactions[reactionIndex] || {
name: emoji,
count: 0,
accounts: [],
}
2026-08-14 17:38:15 +03:00
const count = value ? reaction.count + 1 : reaction.count - 1
2026-08-14 17:38:15 +03:00
const accounts = value
? [...reaction.accounts, currentUser]
: accounts.filter((acc) => acc.id !== currentUser.id)
const newReaction = {
...reaction,
2026-08-14 17:38:15 +03:00
count,
me: value,
accounts,
}
2026-08-14 17:38:15 +03:00
if (reactionPresent && count > 0) {
status.emoji_reactions[reactionIndex] = newReaction
2026-08-14 17:38:15 +03:00
} else if (count === 0) {
status.emoji_reactions = status.emoji_reactions.filter(
(r) => r.name !== emoji,
)
2026-08-14 17:38:15 +03:00
} else {
status.emoji_reactions.push(newReaction)
}
},
/// Bookmark
bookmark(id, bookmark_folder_id) {
2026-08-14 17:38:15 +03:00
return this.requestInteract({
name: 'bookmark',
id,
2026-08-14 17:38:15 +03:00
apiCall: bookmarkStatus,
optimisticCall: this.setBookmarked,
argument: bookmark_folder_id,
value: false,
})
},
unbookmark(id) {
2026-08-14 17:38:15 +03:00
return this.requestInteract({
name: 'bookmark',
id,
2026-08-14 17:38:15 +03:00
apiCall: unbookmarkStatus,
optimisticCall: this.setBookmarked,
value: false,
})
},
2026-08-14 17:38:15 +03:00
setBookmarked(id, value, bookmark_folder_id) {
const status = this.allStatuses.get(id)
2026-08-10 22:26:22 +03:00
status.bookmarked = value
2026-08-14 17:38:15 +03:00
// When unbookmarking we don't specify folder so we wanna keep
// reference to the folder even when setting bookmarked to false
// the proper reference will be updated when api call resolves
if (bookmark_folder_id) {
status.bookmark_folder_id = value ? bookmark_folder_id : null
}
},
/// Mute
muteConversation(id) {
2026-08-14 17:38:15 +03:00
return this.requestInteract({
name: 'mute',
id,
2026-08-14 17:38:15 +03:00
apiCall: muteConversation,
optimisticCall: this.setMutedStatus,
value: false,
2026-08-10 22:26:22 +03:00
})
},
unmuteConversation(id) {
2026-08-14 17:38:15 +03:00
return this.requestInteract({
name: 'mute',
id,
2026-08-14 17:38:15 +03:00
apiCall: unmuteConversation,
optimisticCall: this.setMutedStatus,
value: false,
2026-08-10 22:26:22 +03:00
})
},
2026-08-14 17:38:15 +03:00
setMutedStatus(id, value) {
// Setting thread_muted flag on all other known statuses
// belonging to same conversation
const newStatus = this.allStatuses.get(id)
2026-08-14 17:38:15 +03:00
newStatus.thread_muted = value
if (newStatus.thread_muted !== undefined) {
2026-08-10 22:26:22 +03:00
this.conversations
.get(newStatus.statusnet_conversation_id)
.forEach((status) => {
2026-08-14 17:38:15 +03:00
status.thread_muted = value
2026-08-10 22:26:22 +03:00
})
}
},
/// Pin
pinStatus(id) {
2026-08-14 17:38:15 +03:00
return this.requestInteract({
name: 'pin',
id,
2026-08-14 17:38:15 +03:00
apiCall: pinOwnStatus,
optimisticCall: () => {
/* no-op */
},
value: true,
})
},
unpinStatus(id) {
2026-08-14 17:38:15 +03:00
return this.requestInteract({
name: 'pin',
id,
2026-08-14 17:38:15 +03:00
apiCall: unpinOwnStatus,
optimisticCall: () => {
/* no-op */
},
value: false,
})
},
/// Delete
deleteStatus(id) {
deleteStatus({
id,
credentials: useOAuthStore().token,
})
.then(() => {
2026-08-13 17:19:00 +03:00
this.setDeleted(id)
})
.catch((e) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'status.delete_error',
messageArgs: [e.message],
timeout: 5000,
})
})
},
2026-08-13 17:19:00 +03:00
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,
})
2026-08-10 22:26:22 +03:00
data.statuses = data.statuses.map((s) => this.allStatuses.get(s.id))
return data
})
},
setVirtualHeight({ statusId, height }) {
this.allStatuses.get(statusId).virtualHeight = height
},
updateStatusWithPoll({ id, poll }) {
const status = this.allStatuses.get(id)
status.poll = poll
},
},
})