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