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