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