about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-10-08 13:22:37 -0500
committerGitHub <noreply@github.com>2024-10-08 13:22:37 -0500
commit1db39ed1d1cf34c59948b4f0bb4125b30d00357e (patch)
treeb304d171df6f15533b9073dd584f26889c0ecdb1 /src
parentfc82d2f6d5e8a93f0e7ce4861c5205c8a4b49c30 (diff)
downloadvoidsky-1db39ed1d1cf34c59948b4f0bb4125b30d00357e.tar.zst
Filter posts containing mute words from search and notifications (#5599)
* Filter mute words from search

* Filter mute words from notifications

* Do no filter search if using from filter
Diffstat (limited to 'src')
-rw-r--r--src/state/queries/notifications/feed.ts103
-rw-r--r--src/state/queries/search-posts.ts85
2 files changed, 170 insertions, 18 deletions
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 55e048308..19a92fc3c 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -17,7 +17,12 @@
  */
 
 import {useCallback, useEffect, useMemo, useRef} from 'react'
-import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
+import {
+  AppBskyActorDefs,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AtUri,
+} from '@atproto/api'
 import {
   InfiniteData,
   QueryClient,
@@ -26,6 +31,7 @@ import {
   useQueryClient,
 } from '@tanstack/react-query'
 
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {useAgent} from '#/state/session'
 import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
 import {useModerationOpts} from '../../preferences/moderation-opts'
@@ -67,9 +73,15 @@ export function useNotificationFeedQuery(opts?: {
 
   const selectArgs = useMemo(() => {
     return {
+      moderationOpts,
       hiddenReplyUris,
     }
-  }, [hiddenReplyUris])
+  }, [moderationOpts, hiddenReplyUris])
+  const lastRun = useRef<{
+    data: InfiniteData<FeedPage>
+    args: typeof selectArgs
+    result: InfiniteData<FeedPage>
+  } | null>(null)
 
   const query = useInfiniteQuery<
     FeedPage,
@@ -111,7 +123,38 @@ export function useNotificationFeedQuery(opts?: {
     enabled,
     select: useCallback(
       (data: InfiniteData<FeedPage>) => {
-        const {hiddenReplyUris} = selectArgs
+        const {moderationOpts, hiddenReplyUris} = selectArgs
+
+        // Keep track of the last run and whether we can reuse
+        // some already selected pages from there.
+        let reusedPages = []
+        if (lastRun.current) {
+          const {
+            data: lastData,
+            args: lastArgs,
+            result: lastResult,
+          } = lastRun.current
+          let canReuse = true
+          for (let key in selectArgs) {
+            if (selectArgs.hasOwnProperty(key)) {
+              if ((selectArgs as any)[key] !== (lastArgs as any)[key]) {
+                // Can't do reuse anything if any input has changed.
+                canReuse = false
+                break
+              }
+            }
+          }
+          if (canReuse) {
+            for (let i = 0; i < data.pages.length; i++) {
+              if (data.pages[i] && lastData.pages[i] === data.pages[i]) {
+                reusedPages.push(lastResult.pages[i])
+                continue
+              }
+              // Stop as soon as pages stop matching up.
+              break
+            }
+          }
+        }
 
         // override 'isRead' using the first page's returned seenAt
         // we do this because the `markAllRead()` call above will
@@ -124,23 +167,49 @@ export function useNotificationFeedQuery(opts?: {
           }
         }
 
-        data = {
+        const result = {
           ...data,
-          pages: data.pages.map(page => {
-            return {
-              ...page,
-              items: page.items.filter(item => {
-                const isHiddenReply =
-                  item.type === 'reply' &&
-                  item.subjectUri &&
-                  hiddenReplyUris.has(item.subjectUri)
-                return !isHiddenReply
-              }),
-            }
-          }),
+          pages: [
+            ...reusedPages,
+            ...data.pages.slice(reusedPages.length).map(page => {
+              return {
+                ...page,
+                items: page.items
+                  .filter(item => {
+                    const isHiddenReply =
+                      item.type === 'reply' &&
+                      item.subjectUri &&
+                      hiddenReplyUris.has(item.subjectUri)
+                    return !isHiddenReply
+                  })
+                  .filter(item => {
+                    if (
+                      item.type === 'reply' ||
+                      item.type === 'mention' ||
+                      item.type === 'quote'
+                    ) {
+                      /*
+                       * The `isPostView` check will fail here bc we don't have
+                       * a `$type` field on the `subject`. But if the nested
+                       * `record` is a post, we know it's a post view.
+                       */
+                      if (AppBskyFeedPost.isRecord(item.subject?.record)) {
+                        const mod = moderatePost(item.subject, moderationOpts!)
+                        if (mod.ui('contentList').filter) {
+                          return false
+                        }
+                      }
+                    }
+                    return true
+                  }),
+              }
+            }),
+          ],
         }
 
-        return data
+        lastRun.current = {data, result, args: selectArgs}
+
+        return result
       },
       [selectArgs],
     ),
diff --git a/src/state/queries/search-posts.ts b/src/state/queries/search-posts.ts
index 5c50ad267..8a8a3fa52 100644
--- a/src/state/queries/search-posts.ts
+++ b/src/state/queries/search-posts.ts
@@ -1,3 +1,4 @@
+import React from 'react'
 import {
   AppBskyActorDefs,
   AppBskyFeedDefs,
@@ -11,6 +12,8 @@ import {
   useInfiniteQuery,
 } from '@tanstack/react-query'
 
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useAgent} from '#/state/session'
 import {
   didOrHandleUriMatches,
@@ -35,6 +38,20 @@ export function useSearchPostsQuery({
   enabled?: boolean
 }) {
   const agent = useAgent()
+  const moderationOpts = useModerationOpts()
+  const selectArgs = React.useMemo(
+    () => ({
+      isSearchingSpecificUser: /from:(\w+)/.test(query),
+      moderationOpts,
+    }),
+    [query, moderationOpts],
+  )
+  const lastRun = React.useRef<{
+    data: InfiniteData<AppBskyFeedSearchPosts.OutputSchema>
+    args: typeof selectArgs
+    result: InfiniteData<AppBskyFeedSearchPosts.OutputSchema>
+  } | null>(null)
+
   return useInfiniteQuery<
     AppBskyFeedSearchPosts.OutputSchema,
     Error,
@@ -54,7 +71,73 @@ export function useSearchPostsQuery({
     },
     initialPageParam: undefined,
     getNextPageParam: lastPage => lastPage.cursor,
-    enabled,
+    enabled: enabled ?? !!moderationOpts,
+    select: React.useCallback(
+      (data: InfiniteData<AppBskyFeedSearchPosts.OutputSchema>) => {
+        const {moderationOpts, isSearchingSpecificUser} = selectArgs
+
+        /*
+         * If a user applies the `from:<user>` filter, don't apply any
+         * moderation. Note that if we add any more filtering logic below, we
+         * may need to adjust this.
+         */
+        if (isSearchingSpecificUser) {
+          return data
+        }
+
+        // Keep track of the last run and whether we can reuse
+        // some already selected pages from there.
+        let reusedPages = []
+        if (lastRun.current) {
+          const {
+            data: lastData,
+            args: lastArgs,
+            result: lastResult,
+          } = lastRun.current
+          let canReuse = true
+          for (let key in selectArgs) {
+            if (selectArgs.hasOwnProperty(key)) {
+              if ((selectArgs as any)[key] !== (lastArgs as any)[key]) {
+                // Can't do reuse anything if any input has changed.
+                canReuse = false
+                break
+              }
+            }
+          }
+          if (canReuse) {
+            for (let i = 0; i < data.pages.length; i++) {
+              if (data.pages[i] && lastData.pages[i] === data.pages[i]) {
+                reusedPages.push(lastResult.pages[i])
+                continue
+              }
+              // Stop as soon as pages stop matching up.
+              break
+            }
+          }
+        }
+
+        const result = {
+          ...data,
+          pages: [
+            ...reusedPages,
+            ...data.pages.slice(reusedPages.length).map(page => {
+              return {
+                ...page,
+                posts: page.posts.filter(post => {
+                  const mod = moderatePost(post, moderationOpts!)
+                  return !mod.ui('contentList').filter
+                }),
+              }
+            }),
+          ],
+        }
+
+        lastRun.current = {data, result, args: selectArgs}
+
+        return result
+      },
+      [selectArgs],
+    ),
   })
 }