diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 5d35b9699..c893f135c 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -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) { diff --git a/src/stores/statuses.js b/src/stores/statuses.js new file mode 100644 index 000000000..1fda114cd --- /dev/null +++ b/src/stores/statuses.js @@ -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 + }, + }, +})