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