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