forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useEffect, useMemo, useState} from 'react'
2import {type AppBskyActorDefs, type AppBskyNotificationDefs} from '@atproto/api'
3import {type QueryClient} from '@tanstack/react-query'
4import EventEmitter from 'eventemitter3'
5
6import {batchedUpdates} from '#/lib/batchedUpdates'
7import {findAllProfilesInQueryData as findAllProfilesInActivitySubscriptionsQueryData} from '#/state/queries/activity-subscriptions'
8import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '#/state/queries/actor-search'
9import {findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
10import {findAllProfilesInQueryData as findAllProfilesInContactMatchesQueryData} from '#/state/queries/find-contacts'
11import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
12import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members'
13import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations'
14import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts'
15import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts'
16import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed'
17import {
18 type FeedPage,
19 findAllProfilesInQueryData as findAllProfilesInFeedsQueryData,
20} from '#/state/queries/post-feed'
21import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by'
22import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes'
23import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '#/state/queries/post-reposted-by'
24import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '#/state/queries/profile'
25import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '#/state/queries/profile-followers'
26import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows'
27import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows'
28import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery'
29import {findAllProfilesInQueryData as findAllProfilesInPostThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
30import type * as bsky from '#/types/bsky'
31import {castAsShadow, type Shadow} from './types'
32
33export type {Shadow} from './types'
34
35export interface ProfileShadow {
36 followingUri: string | undefined
37 muted: boolean | undefined
38 blockingUri: string | undefined
39 verification: AppBskyActorDefs.VerificationState
40 status: AppBskyActorDefs.StatusView | undefined
41 activitySubscription: AppBskyNotificationDefs.ActivitySubscription | undefined
42}
43
44const shadows: WeakMap<
45 bsky.profile.AnyProfileView,
46 Partial<ProfileShadow>
47> = new WeakMap()
48const emitter = new EventEmitter()
49
50export function useProfileShadow<
51 TProfileView extends bsky.profile.AnyProfileView,
52>(profile: TProfileView): Shadow<TProfileView> {
53 const [shadow, setShadow] = useState(() => shadows.get(profile))
54 const [prevPost, setPrevPost] = useState(profile)
55 if (profile !== prevPost) {
56 setPrevPost(profile)
57 setShadow(shadows.get(profile))
58 }
59
60 useEffect(() => {
61 function onUpdate() {
62 setShadow(shadows.get(profile))
63 }
64 emitter.addListener(profile.did, onUpdate)
65 return () => {
66 emitter.removeListener(profile.did, onUpdate)
67 }
68 }, [profile])
69
70 return useMemo(() => {
71 if (shadow) {
72 return mergeShadow(profile, shadow)
73 } else {
74 return castAsShadow(profile)
75 }
76 }, [profile, shadow])
77}
78
79/**
80 * Same as useProfileShadow, but allows for the profile to be undefined.
81 * This is useful for when the profile is not guaranteed to be loaded yet.
82 */
83export function useMaybeProfileShadow<
84 TProfileView extends bsky.profile.AnyProfileView,
85>(profile?: TProfileView): Shadow<TProfileView> | undefined {
86 const [shadow, setShadow] = useState(() =>
87 profile ? shadows.get(profile) : undefined,
88 )
89 const [prevPost, setPrevPost] = useState(profile)
90 if (profile !== prevPost) {
91 setPrevPost(profile)
92 setShadow(profile ? shadows.get(profile) : undefined)
93 }
94
95 useEffect(() => {
96 if (!profile) return
97 function onUpdate() {
98 if (!profile) return
99 setShadow(shadows.get(profile))
100 }
101 emitter.addListener(profile.did, onUpdate)
102 return () => {
103 emitter.removeListener(profile.did, onUpdate)
104 }
105 }, [profile])
106
107 return useMemo(() => {
108 if (!profile) return undefined
109 if (shadow) {
110 return mergeShadow(profile, shadow)
111 } else {
112 return castAsShadow(profile)
113 }
114 }, [profile, shadow])
115}
116
117/**
118 * Takes a list of posts, and returns a list of DIDs that should be filtered out
119 *
120 * Note: it doesn't retroactively scan the cache, but only listens to new updates.
121 * The use case here is intended for removing a post from a feed after you mute the author
122 */
123export function usePostAuthorShadowFilter(data?: FeedPage[]) {
124 const [trackedDids, setTrackedDids] = useState<string[]>(
125 () =>
126 data?.flatMap(page =>
127 page.slices.flatMap(slice =>
128 slice.items.map(item => item.post.author.did),
129 ),
130 ) ?? [],
131 )
132 const [authors, setAuthors] = useState(
133 new Map<string, {muted: boolean; blocked: boolean}>(),
134 )
135
136 const [prevData, setPrevData] = useState(data)
137 if (data !== prevData) {
138 const newAuthors = new Set(trackedDids)
139 let hasNew = false
140 for (const slice of data?.flatMap(page => page.slices) ?? []) {
141 for (const item of slice.items) {
142 const author = item.post.author
143 if (!newAuthors.has(author.did)) {
144 hasNew = true
145 newAuthors.add(author.did)
146 }
147 }
148 }
149 if (hasNew) setTrackedDids([...newAuthors])
150 setPrevData(data)
151 }
152
153 useEffect(() => {
154 const unsubs: Array<() => void> = []
155
156 for (const did of trackedDids) {
157 function onUpdate(value: Partial<ProfileShadow>) {
158 setAuthors(prev => {
159 const prevValue = prev.get(did)
160 const next = new Map(prev)
161 next.set(did, {
162 blocked: Boolean(value.blockingUri ?? prevValue?.blocked ?? false),
163 muted: Boolean(value.muted ?? prevValue?.muted ?? false),
164 })
165 return next
166 })
167 }
168 emitter.addListener(did, onUpdate)
169 unsubs.push(() => {
170 emitter.removeListener(did, onUpdate)
171 })
172 }
173
174 return () => {
175 unsubs.map(fn => fn())
176 }
177 }, [trackedDids])
178
179 return useMemo(() => {
180 const dids: Array<string> = []
181
182 for (const [did, value] of authors.entries()) {
183 if (value.blocked || value.muted) {
184 dids.push(did)
185 }
186 }
187
188 return dids
189 }, [authors])
190}
191
192export function updateProfileShadow(
193 queryClient: QueryClient,
194 did: string,
195 value: Partial<ProfileShadow>,
196) {
197 const cachedProfiles = findProfilesInCache(queryClient, did)
198 for (let profile of cachedProfiles) {
199 shadows.set(profile, {...shadows.get(profile), ...value})
200 }
201 batchedUpdates(() => {
202 emitter.emit(did, value)
203 })
204}
205
206function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>(
207 profile: TProfileView,
208 shadow: Partial<ProfileShadow>,
209): Shadow<TProfileView> {
210 return castAsShadow({
211 ...profile,
212 viewer: {
213 ...(profile.viewer || {}),
214 following:
215 'followingUri' in shadow
216 ? shadow.followingUri
217 : profile.viewer?.following,
218 muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted,
219 blocking:
220 'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking,
221 activitySubscription:
222 'activitySubscription' in shadow
223 ? shadow.activitySubscription
224 : profile.viewer?.activitySubscription,
225 },
226 verification:
227 'verification' in shadow ? shadow.verification : profile.verification,
228 status:
229 'status' in shadow
230 ? shadow.status
231 : 'status' in profile
232 ? profile.status
233 : undefined,
234 })
235}
236
237function* findProfilesInCache(
238 queryClient: QueryClient,
239 did: string,
240): Generator<bsky.profile.AnyProfileView, void> {
241 yield* findAllProfilesInListMembersQueryData(queryClient, did)
242 yield* findAllProfilesInMyBlockedAccountsQueryData(queryClient, did)
243 yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did)
244 yield* findAllProfilesInPostLikedByQueryData(queryClient, did)
245 yield* findAllProfilesInPostRepostedByQueryData(queryClient, did)
246 yield* findAllProfilesInPostQuotesQueryData(queryClient, did)
247 yield* findAllProfilesInProfileQueryData(queryClient, did)
248 yield* findAllProfilesInProfileFollowersQueryData(queryClient, did)
249 yield* findAllProfilesInProfileFollowsQueryData(queryClient, did)
250 yield* findAllProfilesInSuggestedUsersQueryData(queryClient, did)
251 yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did)
252 yield* findAllProfilesInActorSearchQueryData(queryClient, did)
253 yield* findAllProfilesInListConvosQueryData(queryClient, did)
254 yield* findAllProfilesInFeedsQueryData(queryClient, did)
255 yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
256 yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
257 yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
258 yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
259 yield* findAllProfilesInNotifsQueryData(queryClient, did)
260 yield* findAllProfilesInContactMatchesQueryData(queryClient, did)
261}