import { defineStore } from 'pinia' import { useInterfaceStore } from 'src/stores/interface.js' import { useOAuthStore } from 'src/stores/oauth.js' import { useStreamingStore } from 'src/stores/streaming.js' import { useUsersStore } from 'src/stores/users.js' import { fetchEmojiReactions, fetchFavoritedByUsers, fetchRebloggedByUsers, fetchStatus, fetchStatusSource, } 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(), statusesPerUser: new Map(), timestamps: new WeakMap(), scrobblesNextFetch: {}, conversations: new Map(), favorites: new Set(), socket: null, }) export const useStatusesStore = defineStore('statuses', { state: defaultState, actions: { // Init attachSocket() { const et = new EventTarget() const handleUpdate = ({ data, timestamp }) => this.addNewStatuses({ statuses: data, timestamp }) const handleDelete = ({ data }) => data.forEach((id) => this.setDeleted(id)) const socket = { name: 'statuses', et, handlers: { handleUpdate, handleDelete, }, } et.addEventListener('update', handleUpdate) et.addEventListener('status.update', handleUpdate) et.addEventListener('delete', handleDelete) useStreamingStore().addSubscriber(socket) this.socket = socket }, resetStatuses() { const emptyState = defaultState() Object.entries(emptyState).forEach(([key, value]) => { if (key === 'socket') return this[key] = value }) }, addNewStatuses({ statuses, timestamp }) { // Sanity check if (!Array.isArray(statuses)) { throw new TypeError("Statuses aren't an array!") } // 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) => { const [status] = this.mergeOrAdd(this.allStatuses, data, timestamp) let userSet = this.statusesPerUser.get(status.user.id) if (userSet === undefined) { userSet = new Set() this.statusesPerUser.set(status.user.id, userSet) } userSet.add(status.id) // Add to conversation const conversations = this.conversations const conversationId = status.statusnet_conversation_id if (conversations.has(conversationId)) { conversations.get(conversationId).add(status.id) } else { conversations.set(conversationId, new Set([status.id])) } // Work on quote if (status.quote) { status.quote = addStatus(status.quote) } return status } const processors = { status: (status) => { return addStatus(status) }, edit: (status) => { return addStatus(status) }, retweet: (status) => { if (status.retweeted_status) addStatus(status.retweeted_status) return addStatus(status) }, default: (unknown) => { console.warn('unknown status type', unknown) return null }, } return statuses.map((status) => { const type = status.type const processor = processors[type] ?? processors.default return processor(status) }) }, 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, }) const { in_reply_to_user_id } = status if (in_reply_to_user_id) { useUsersStore().fetchUserIfMissing({ id: in_reply_to_user_id }) } 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, ...Object.fromEntries( Object.entries(neu).filter(([, v]) => v !== undefined), ), user, } map.set(newStatus.id, newStatus) this.timestamps.set(newStatus, timestamp) return [map.get(newStatus.id), true] }, // Fetches fetchStatus(id) { return fetchStatus({ id, credentials: useOAuthStore().token, }).then(({ data: status, timestamp }) => this.addNewStatuses({ statuses: [status], timestamp }), ) }, fetchStatusSource(id) { return fetchStatusSource({ id, credentials: useOAuthStore().token, }).then(({ data }) => data) }, fetchEmojiReactions(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 }, updateStatusWithPoll(id, poll) { const status = this.allStatuses.get(id) status.poll = poll }, // Actions 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 {} } })() // Optimistic optimisticCall(id, value, argument) return apiCall({ id, ...apiArgs, credentials: useOAuthStore().token, }) .then(({ data: status, timestamp }) => { this.addNewStatuses({ statuses: [status], timestamp, }) }) .catch((error) => { optimisticCall(id, oldValue, argument) console.error('Interact Error', error) useInterfaceStore().pushGlobalNotice({ level: 'error', messageKey: 'status.interact_error', messageArgs: [error], timeout: 5000, }) }) }, /// Favorite favorite(id) { return this.requestInteract({ name: 'favorite', id, apiCall: favorite, optimisticCall: this.setFavorited, value: true, }) }, unfavorite(id) { return this.requestInteract({ name: 'favorite', id, apiCall: unfavorite, optimisticCall: this.setFavorited, value: false, }) }, 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) { return this.requestInteract({ name: 'retweet', id, apiCall: retweet, optimisticCall: this.setRetweeted, value: true, }) }, unretweet(id) { return this.requestInteract({ name: 'retweet', id, apiCall: unretweet, optimisticCall: this.setRetweeted, value: false, }) }, 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) { return this.requestInteract({ name: 'emoji', id, apiCall: reactWithEmoji, optimisticCall: this.setOwnReaction, argument: emoji, value: true, }) }, unreactWithEmoji(id, emoji) { return this.requestInteract({ name: 'emoji', id, apiCall: unreactWithEmoji, optimisticCall: this.setOwnReaction, argument: emoji, value: false, }) }, setOwnReaction(id, value, emoji) { const currentUser = useUsersStore().currentUser const status = this.allStatuses.get(id) const reactionIndex = status.emoji_reactions.findIndex( (react) => react.name === emoji, ) const reactionPresent = reactionIndex >= 0 if (!value && !reactionPresent) return const reaction = status.emoji_reactions[reactionIndex] || { name: emoji, count: 0, accounts: [], } const count = value ? reaction.count + 1 : reaction.count - 1 const accounts = value ? [...reaction.accounts, currentUser] : reaction.accounts.filter((acc) => acc.id !== currentUser.id) const newReaction = { ...reaction, count, me: value, accounts, } if (reactionPresent && count > 0) { status.emoji_reactions[reactionIndex] = newReaction } else if (count === 0) { status.emoji_reactions = status.emoji_reactions.filter( (r) => r.name !== emoji, ) } else { status.emoji_reactions.push(newReaction) } }, /// Bookmark bookmark(id, bookmark_folder_id) { return this.requestInteract({ name: 'bookmark', id, apiCall: bookmarkStatus, optimisticCall: this.setBookmarked, argument: bookmark_folder_id, value: true, }) }, unbookmark(id) { return this.requestInteract({ name: 'bookmark', id, apiCall: unbookmarkStatus, optimisticCall: this.setBookmarked, value: false, }) }, setBookmarked(id, value, bookmark_folder_id) { const status = this.allStatuses.get(id) status.bookmarked = value // 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) { return this.requestInteract({ name: 'mute', id, apiCall: muteConversation, optimisticCall: this.setMutedStatus, value: true, }) }, unmuteConversation(id) { return this.requestInteract({ name: 'mute', id, apiCall: unmuteConversation, optimisticCall: this.setMutedStatus, value: false, }) }, setMutedStatus(id, value) { // Setting thread_muted flag on all other known statuses // belonging to same conversation const newStatus = this.allStatuses.get(id) newStatus.thread_muted = value if (newStatus.thread_muted !== undefined) { this.conversations .get(newStatus.statusnet_conversation_id) .forEach((statusId) => { this.allStatuses.get(statusId).thread_muted = value }) } }, /// Pin pinStatus(id) { return this.requestInteract({ name: 'pin', id, apiCall: pinOwnStatus, optimisticCall: () => { /* no-op */ }, value: true, }) }, unpinStatus(id) { return this.requestInteract({ name: 'pin', id, apiCall: unpinOwnStatus, optimisticCall: () => { /* no-op */ }, value: false, }) }, /// Delete deleteStatus(id) { return 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 }, // For when blocking a user wipeUserStatuses(userId) { const removed = this.statusesPerUser.get(userId) removed.forEach((statusId) => { const status = this.allStatuses.get(statusId) this.allStatuses.delete(statusId) const conversationSet = this.conversations.get( status.statusnet_conversation_id, ) conversationSet.delete(statusId) if (conversationSet.size === 0) { this.conversations.delete(status.statusnet_conversation_id) } }) this.statusesPerUser.delete(userId) return removed }, }, })