mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {useEffect, useMemo, useState} from 'react'
2import {
3 AppBskyEmbedRecord,
4 AppBskyEmbedRecordWithMedia,
5 type AppBskyFeedDefs,
6} from '@atproto/api'
7import {type QueryClient} from '@tanstack/react-query'
8import EventEmitter from 'eventemitter3'
9
10import {batchedUpdates} from '#/lib/batchedUpdates'
11import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
12import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
13import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
14import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
15import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread'
16import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
17import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
18import {castAsShadow, type Shadow} from './types'
19export type {Shadow} from './types'
20
21export interface PostShadow {
22 likeUri: string | undefined
23 repostUri: string | undefined
24 isDeleted: boolean
25 embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
26 pinned: boolean
27}
28
29export const POST_TOMBSTONE = Symbol('PostTombstone')
30
31const emitter = new EventEmitter()
32const shadows: WeakMap<
33 AppBskyFeedDefs.PostView,
34 Partial<PostShadow>
35> = new WeakMap()
36
37export function usePostShadow(
38 post: AppBskyFeedDefs.PostView,
39): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
40 const [shadow, setShadow] = useState(() => shadows.get(post))
41 const [prevPost, setPrevPost] = useState(post)
42 if (post !== prevPost) {
43 setPrevPost(post)
44 setShadow(shadows.get(post))
45 }
46
47 useEffect(() => {
48 function onUpdate() {
49 setShadow(shadows.get(post))
50 }
51 emitter.addListener(post.uri, onUpdate)
52 return () => {
53 emitter.removeListener(post.uri, onUpdate)
54 }
55 }, [post, setShadow])
56
57 return useMemo(() => {
58 if (shadow) {
59 return mergeShadow(post, shadow)
60 } else {
61 return castAsShadow(post)
62 }
63 }, [post, shadow])
64}
65
66function mergeShadow(
67 post: AppBskyFeedDefs.PostView,
68 shadow: Partial<PostShadow>,
69): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
70 if (shadow.isDeleted) {
71 return POST_TOMBSTONE
72 }
73
74 let likeCount = post.likeCount ?? 0
75 if ('likeUri' in shadow) {
76 const wasLiked = !!post.viewer?.like
77 const isLiked = !!shadow.likeUri
78 if (wasLiked && !isLiked) {
79 likeCount--
80 } else if (!wasLiked && isLiked) {
81 likeCount++
82 }
83 likeCount = Math.max(0, likeCount)
84 }
85
86 let repostCount = post.repostCount ?? 0
87 if ('repostUri' in shadow) {
88 const wasReposted = !!post.viewer?.repost
89 const isReposted = !!shadow.repostUri
90 if (wasReposted && !isReposted) {
91 repostCount--
92 } else if (!wasReposted && isReposted) {
93 repostCount++
94 }
95 repostCount = Math.max(0, repostCount)
96 }
97
98 let embed: typeof post.embed
99 if ('embed' in shadow) {
100 if (
101 (AppBskyEmbedRecord.isView(post.embed) &&
102 AppBskyEmbedRecord.isView(shadow.embed)) ||
103 (AppBskyEmbedRecordWithMedia.isView(post.embed) &&
104 AppBskyEmbedRecordWithMedia.isView(shadow.embed))
105 ) {
106 embed = shadow.embed
107 }
108 }
109
110 return castAsShadow({
111 ...post,
112 embed: embed || post.embed,
113 likeCount: likeCount,
114 repostCount: repostCount,
115 viewer: {
116 ...(post.viewer || {}),
117 like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
118 repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost,
119 pinned: 'pinned' in shadow ? shadow.pinned : post.viewer?.pinned,
120 },
121 })
122}
123
124export function updatePostShadow(
125 queryClient: QueryClient,
126 uri: string,
127 value: Partial<PostShadow>,
128) {
129 const cachedPosts = findPostsInCache(queryClient, uri)
130 for (let post of cachedPosts) {
131 shadows.set(post, {...shadows.get(post), ...value})
132 }
133 batchedUpdates(() => {
134 emitter.emit(uri)
135 })
136}
137
138function* findPostsInCache(
139 queryClient: QueryClient,
140 uri: string,
141): Generator<AppBskyFeedDefs.PostView, void> {
142 for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
143 yield post
144 }
145 for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
146 yield post
147 }
148 for (let node of findAllPostsInThreadQueryData(queryClient, uri)) {
149 if (node.type === 'post') {
150 yield node.post
151 }
152 }
153 for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) {
154 yield post
155 }
156 for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
157 yield post
158 }
159 for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
160 yield post
161 }
162 for (let post of findAllPostsInExploreFeedPreviewsQueryData(
163 queryClient,
164 uri,
165 )) {
166 yield post
167 }
168}