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