From 55eb6c56287c512128d9d47e65cbaf9ee88be5b0 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Dec 2025 01:09:54 +0200 Subject: [PATCH] add shadow filter to post feeds (#9406) --- src/state/cache/profile-shadow.ts | 80 ++++++++++++++++++++++++++++++- src/view/com/posts/PostFeed.tsx | 19 +++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index e1cff94092..5ee7c9c423 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -13,7 +13,10 @@ import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts' import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts' import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed' -import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '#/state/queries/post-feed' +import { + type FeedPage, + findAllProfilesInQueryData as findAllProfilesInFeedsQueryData, +} from '#/state/queries/post-feed' import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by' import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes' import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '#/state/queries/post-reposted-by' @@ -110,6 +113,81 @@ export function useMaybeProfileShadow< }, [profile, shadow]) } +/** + * Takes a list of posts, and returns a list of DIDs that should be filtered out + * + * Note: it doesn't retroactively scan the cache, but only listens to new updates. + * The use case here is intended for removing a post from a feed after you mute the author + */ +export function usePostAuthorShadowFilter(data?: FeedPage[]) { + const [trackedDids, setTrackedDids] = useState( + () => + data?.flatMap(page => + page.slices.flatMap(slice => + slice.items.map(item => item.post.author.did), + ), + ) ?? [], + ) + const [authors, setAuthors] = useState( + new Map(), + ) + + const [prevData, setPrevData] = useState(data) + if (data !== prevData) { + const newAuthors = new Set(trackedDids) + let hasNew = false + for (const slice of data?.flatMap(page => page.slices) ?? []) { + for (const item of slice.items) { + const author = item.post.author + if (!newAuthors.has(author.did)) { + hasNew = true + newAuthors.add(author.did) + } + } + } + if (hasNew) setTrackedDids([...newAuthors]) + setPrevData(data) + } + + useEffect(() => { + const unsubs: Array<() => void> = [] + + for (const did of trackedDids) { + function onUpdate(value: Partial) { + setAuthors(prev => { + const prevValue = prev.get(did) + const next = new Map(prev) + next.set(did, { + blocked: Boolean(value.blockingUri ?? prevValue?.blocked ?? false), + muted: Boolean(value.muted ?? prevValue?.muted ?? false), + }) + return next + }) + } + emitter.addListener(did, onUpdate) + unsubs.push(() => { + emitter.removeListener(did, onUpdate) + }) + } + + return () => { + unsubs.map(fn => fn()) + } + }, [trackedDids]) + + return useMemo(() => { + const dids: Array = [] + + for (const [did, value] of authors.entries()) { + if (value.blocked || value.muted) { + dids.push(did) + } + } + + return dids + }, [authors]) +} + export function updateProfileShadow( queryClient: QueryClient, did: string, diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 4f4e6352ab..7e70d918c3 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -35,6 +35,7 @@ import {logEvent} from '#/lib/statsig/statsig' import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {isIOS, isNative, isWeb} from '#/platform/detection' +import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow' import {listenPostCreated} from '#/state/events' import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useTrendingSettings} from '#/state/preferences/trending' @@ -363,6 +364,11 @@ let PostFeed = ({ */ const [isCurrentFeedAtStartupSelected] = useState(selectedFeed === feed) + const blockedOrMutedAuthors = usePostAuthorShadowFilter( + // author feeds have their own handling + feed.startsWith('author|') ? undefined : data?.pages, + ) + const feedItems: FeedRow[] = useMemo(() => { // wraps a slice item, and replaces it with a showLessFollowup item // if the user has pressed show less on it @@ -423,7 +429,11 @@ let PostFeed = ({ // eslint-disable-next-line @typescript-eslint/no-shadow item => item.uri === slice.feedPostUri, ) - if (item && AppBskyEmbedVideo.isView(item.post.embed)) { + if ( + item && + AppBskyEmbedVideo.isView(item.post.embed) && + !blockedOrMutedAuthors.includes(item.post.author.did) + ) { videos.push({ item, feedContext: slice.feedContext, @@ -541,6 +551,12 @@ let PostFeed = ({ key: 'sliceFallbackMarker-' + sliceIndex + '-' + lastFetchedAt, }) + } else if ( + slice.items.some(item => + blockedOrMutedAuthors.includes(item.post.author.did), + ) + ) { + // skip } else if (slice.isIncompleteThread && slice.items.length >= 3) { const beforeLast = slice.items.length - 2 const last = slice.items.length - 1 @@ -636,6 +652,7 @@ let PostFeed = ({ hasPressedShowLessUris, ageAssuranceBannerState, isCurrentFeedAtStartupSelected, + blockedOrMutedAuthors, ]) // events